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 blob_staging_failures_preserve_durable_state_and_allow_retry() {
577 let schema = json!({"version":1,"tables":[{"name":"attachments","primaryKey":"id","columns":[
578 {"name":"id","type":"string","nullable":false},
579 {"name":"file","type":"blob_ref","nullable":true}],"scopes":[]}]});
580 for state in ["absent", "cached", "pinned"] {
581 let path = std::env::temp_dir().join(format!(
582 "syncular-blob-stage-{}.sqlite",
583 uuid::Uuid::new_v4()
584 ));
585 let mut client = SyncClient::open_path(
586 "stage-test".into(),
587 &schema,
588 Default::default(),
589 path.to_str().unwrap(),
590 )
591 .unwrap();
592 let bytes = b"atomic stage";
593 if state != "absent" {
594 client
595 .upload_blob(bytes, Some("text/plain".into()), None)
596 .unwrap();
597 if state == "cached" {
598 client
599 .conn
600 .execute("DELETE FROM _syncular_blob_uploads", [])
601 .unwrap();
602 }
603 }
604 let bodies = client.query("SELECT * FROM _syncular_blobs", &[]).unwrap();
605 client
606 .conn
607 .execute_batch(
608 "CREATE TRIGGER fail_stage_insert BEFORE INSERT ON _syncular_blobs
609 BEGIN SELECT RAISE(ABORT, 'injected stage failure'); END;
610 CREATE TRIGGER fail_stage_update BEFORE UPDATE ON _syncular_blobs
611 BEGIN SELECT RAISE(ABORT, 'injected stage failure'); END;",
612 )
613 .unwrap();
614 let error = client
615 .upload_blob(bytes, Some("application/octet-stream".into()), None)
616 .unwrap_err();
617 assert!(error.contains("injected stage failure"), "{state}: {error}");
618 assert!(client.conn.is_autocommit());
619 let immediate = client.query("SELECT * FROM _syncular_blobs", &[]).unwrap();
620 drop(client);
621 let mut reopened = SyncClient::open_path(
622 "stage-test".into(),
623 &schema,
624 Default::default(),
625 path.to_str().unwrap(),
626 )
627 .unwrap();
628 let durable = reopened
629 .query("SELECT * FROM _syncular_blobs", &[])
630 .unwrap();
631 reopened
632 .conn
633 .execute_batch("DROP TRIGGER fail_stage_insert; DROP TRIGGER fail_stage_update")
634 .unwrap();
635 let retried = reopened.upload_blob(bytes, None, None).unwrap();
636 assert_eq!(reopened.upload_blob(bytes, None, None).unwrap(), retried);
637 assert_eq!(
638 reopened
639 .query("SELECT blob_id FROM _syncular_blob_uploads", &[])
640 .unwrap(),
641 vec![Map::from_iter([(
642 "blob_id".into(),
643 Value::from(blob_id_for(bytes)),
644 )])]
645 );
646 drop(reopened);
647 std::fs::remove_file(&path).unwrap();
648 assert_eq!(durable, bodies, "{state}: reopened body state");
649 assert_eq!(immediate, bodies, "{state}: immediate body state");
650 }
651 }
652
653 #[test]
654 fn blob_upload_failures_preserve_pins_and_original_commits_after_file_reopen() {
655 let schema = json!({"version":1,"tables":[{"name":"attachments","primaryKey":"id","columns":[
656 {"name":"id","type":"string","nullable":false},
657 {"name":"file","type":"blob_ref","nullable":true}],"scopes":[]}]});
658 for fault in [
659 "missing",
660 "body-type",
661 "length",
662 "hash",
663 "pin-metadata",
664 "read",
665 "pin-delete",
666 ] {
667 let path = std::env::temp_dir().join(format!(
668 "syncular-blob-upload-{}.sqlite",
669 uuid::Uuid::new_v4()
670 ));
671 let mut client = SyncClient::open_path(
672 "upload-test".into(),
673 &schema,
674 Default::default(),
675 path.to_str().unwrap(),
676 )
677 .unwrap();
678 let bytes = b"durable upload";
679 let ref_value = client
680 .upload_blob(bytes, Some("text/plain".into()), None)
681 .unwrap();
682 let commit = client
683 .mutate(vec![Mutation::Upsert {
684 table: "attachments".into(),
685 values: Map::from_iter([
686 ("id".into(), Value::from("pending")),
687 (
688 "file".into(),
689 Value::from(format!(
690 "{{\"blobId\":\"{}\",\"byteLength\":{}}}",
691 blob_id_for(bytes),
692 bytes.len()
693 )),
694 ),
695 ]),
696 base_version: None,
697 }])
698 .unwrap();
699 client
700 .conn
701 .execute_batch("CREATE TABLE saved_bodies AS SELECT * FROM _syncular_blobs")
702 .unwrap();
703 client.conn.execute_batch(match fault {
704 "missing" => "DELETE FROM _syncular_blobs",
705 "body-type" => "UPDATE _syncular_blobs SET bytes = 'wrong type'",
706 "length" => "UPDATE _syncular_blobs SET byte_length = byte_length + 1",
707 "hash" => "UPDATE _syncular_blobs SET bytes = zeroblob(byte_length)",
708 "pin-metadata" => "UPDATE _syncular_blob_uploads SET media_type = x'ff'",
709 "read" => "ALTER TABLE _syncular_blobs RENAME TO unavailable_bodies",
710 _ => "CREATE TRIGGER fail_pin_delete BEFORE DELETE ON _syncular_blob_uploads BEGIN SELECT RAISE(ABORT, 'injected pin deletion failure'); END",
711 }).unwrap();
712 let state = if fault == "read" {
713 Vec::new()
714 } else {
715 client.query("SELECT * FROM _syncular_blobs", &[]).unwrap()
716 };
717 let mut transport = CountingRealtimeTransport::default();
718 let error = client.flush_blob_uploads(&mut transport).unwrap_err();
719 assert_eq!(
720 error.code,
721 if matches!(fault, "read" | "pin-delete") {
722 "client.failed"
723 } else {
724 "sync.local_corrupt"
725 },
726 "{fault}"
727 );
728 assert_eq!(
729 transport.blob_uploads.len(),
730 usize::from(fault == "pin-delete"),
731 "{fault}: corrupt bytes must never be uploaded"
732 );
733 assert_eq!(client.pending_commit_ids(), std::slice::from_ref(&commit));
734 if fault != "read" {
735 assert_eq!(
736 client.query("SELECT * FROM _syncular_blobs", &[]).unwrap(),
737 state
738 );
739 } else {
740 client
741 .conn
742 .execute_batch("ALTER TABLE unavailable_bodies RENAME TO _syncular_blobs")
743 .unwrap();
744 }
745 drop(client);
746 let mut reopened = SyncClient::open_path(
747 "upload-test".into(),
748 &schema,
749 Default::default(),
750 path.to_str().unwrap(),
751 )
752 .unwrap();
753 assert_eq!(reopened.pending_commit_ids(), std::slice::from_ref(&commit));
754 if fault != "read" {
755 assert_eq!(
756 reopened
757 .query("SELECT * FROM _syncular_blobs", &[])
758 .unwrap(),
759 state
760 );
761 }
762 if fault == "pin-delete" {
763 reopened
764 .conn
765 .execute_batch("DROP TRIGGER fail_pin_delete")
766 .unwrap();
767 }
768 if fault == "pin-metadata" {
769 reopened
770 .conn
771 .execute(
772 "UPDATE _syncular_blob_uploads SET media_type = 'text/plain'",
773 [],
774 )
775 .unwrap();
776 }
777 reopened
778 .conn
779 .execute_batch(
780 "DELETE FROM _syncular_blobs;
781 INSERT INTO _syncular_blobs SELECT * FROM saved_bodies",
782 )
783 .unwrap();
784 transport.blob_uploads.clear();
785 reopened.flush_blob_uploads(&mut transport).unwrap();
786 assert_eq!(
787 transport.blob_uploads,
788 [(blob_id_for(bytes), bytes.to_vec())]
789 );
790 assert_eq!(
791 reopened
792 .query("SELECT blob_id FROM _syncular_blob_uploads", &[])
793 .unwrap(),
794 Vec::<Map<String, Value>>::new()
795 );
796 assert_eq!(reopened.pending_commit_ids(), [commit]);
797 assert_eq!(
798 reopened
799 .get_cached_blob_bytes(ref_value["blobId"].as_str().unwrap())
800 .unwrap()
801 .unwrap()
802 .bytes,
803 bytes
804 );
805 drop(reopened);
806 std::fs::remove_file(path).unwrap();
807 }
808 }
809
810 #[test]
811 fn incoming_frame_transactions_preserve_prefix_cursor_and_revision_after_reopen() {
812 let schema = json!({"version":1,"tables":[{"name":"tasks","primaryKey":"id","columns":[
813 {"name":"id","type":"string","nullable":false},{"name":"project_id","type":"string","nullable":false}],
814 "scopes":[{"pattern":"project:{project_id}"}]}]});
815 for realtime in [false, true] {
816 for fault in [
817 "none",
818 "row",
819 "revision",
820 "commit",
821 "cursor",
822 "error",
823 "missing-end",
824 ] {
825 if realtime && fault == "missing-end" {
826 continue;
827 }
828 let path = std::env::temp_dir()
829 .join(format!("syncular-frame-{}.sqlite", uuid::Uuid::new_v4()));
830 let mut client = SyncClient::open_path(
831 "frame-test".into(),
832 &schema,
833 Default::default(),
834 path.to_str().unwrap(),
835 )
836 .unwrap();
837 client.set_meta(LOG_EPOCH_KEY, "epoch-1");
838 client
839 .subscribe(
840 "tasks".into(),
841 "tasks".into(),
842 vec![("project_id".into(), vec!["p1".into()])],
843 None,
844 )
845 .unwrap();
846 client.subs[0].cursor = 0;
847 client.subs[0].synced_once = true;
848 client.subs[0].effective = Some(vec![("project_id".into(), vec!["p1".into()])]);
849 client.persist_sub(&client.subs[0]).unwrap();
850 client.realtime_state = RealtimeState::Connected;
851 client.realtime_reason_code = None;
852 client.drain_change_batches();
853 let mut transport = CountingRealtimeTransport::default();
854 let (_, meta) = client.build_request(false).unwrap();
855 let mut response = Message {
856 wire_version: WIRE_VERSION,
857 msg_kind: MsgKind::Response,
858 frames: vec![
859 Frame::RespHeader {
860 required_schema_version: None,
861 latest_schema_version: None,
862 log_epoch: Some("epoch-1".into()),
863 reset_required: Some(false),
864 },
865 Frame::SubStart {
866 id: "tasks".into(),
867 status: SubStatus::Active,
868 reason_code: "".into(),
869 effective_scopes: vec![("project_id".into(), vec!["p1".into()])],
870 bootstrap: false,
871 },
872 ],
873 };
874 for (index, id) in ["first", "second"].iter().enumerate() {
875 let values = Map::from_iter([
876 ("id".into(), Value::from(*id)),
877 ("project_id".into(), Value::from("p1")),
878 ]);
879 response.frames.push(Frame::Commit {
880 commit_seq: index as i64 + 1,
881 actor_id: "writer".into(),
882 created_at_ms: 1,
883 tables: vec!["tasks".into()],
884 changes: vec![ssp2::model::Change {
885 table_index: 0,
886 row_id: (*id).into(),
887 op: Op::Upsert,
888 row_version: Some(index as i64 + 1),
889 scopes: vec![("project_id".into(), "p1".into())],
890 row: Some(
891 encode_row_json(
892 client.schema.table("tasks").unwrap(),
893 id,
894 &values,
895 &client.encryption,
896 )
897 .unwrap(),
898 ),
899 }],
900 });
901 }
902 response.frames.push(Frame::SubEnd {
903 next_cursor: 2,
904 bootstrap_state: None,
905 });
906 let valid = response.clone();
907 match fault {
908 "row" => {
909 let Frame::Commit {changes, ..} = &mut response.frames[3] else {panic!("second frame")};
910 let mut malformed = changes[0].clone(); malformed.row_id = "malformed".into(); malformed.row = Some(vec![]); changes.push(malformed);
911 },
912 "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(),
913 "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(),
914 "cursor" => client.conn.execute_batch("CREATE TRIGGER fail_frame BEFORE INSERT ON _syncular_subscriptions BEGIN SELECT RAISE(FAIL, 'injected cursor failure'); END").unwrap(),
915 "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});},
916 "missing-end" => {response.frames.truncate(3);},
917 _ => {}
918 }
919 if realtime {
920 client.on_realtime_binary(&mut transport, &encode_message(&response));
921 } else {
922 assert_eq!(
923 matches!(
924 client.process_response(&mut transport, response, &meta),
925 SyncOutcome::Ok(_)
926 ),
927 fault == "none",
928 "{fault}"
929 );
930 }
931 assert!(
932 client.conn.is_autocommit(),
933 "no open transaction after {realtime}/{fault}"
934 );
935 let count = if matches!(fault, "none" | "cursor") {
936 2
937 } else {
938 1
939 };
940 let expected_revision = count + u64::from(realtime && fault != "none");
941 assert_eq!(
942 client.local_revision(),
943 expected_revision,
944 "{realtime}/{fault}"
945 );
946 let batches = client.drain_change_batches();
947 assert_eq!(
948 batches.len(),
949 expected_revision as usize,
950 "{realtime}/{fault}"
951 );
952 let row_batches: Vec<_> = batches
953 .iter()
954 .filter(|batch| !batch.tables.is_empty())
955 .collect();
956 assert_eq!(row_batches.len(), count as usize);
957 assert_eq!(
958 row_batches
959 .iter()
960 .map(|b| b.revision.clone())
961 .collect::<Vec<_>>(),
962 (1..=count).map(|r| r.to_string()).collect::<Vec<_>>()
963 );
964 for table in ["tasks", "_syncular_base_tasks"] {
965 assert_eq!(
966 client
967 .conn
968 .query_row(&format!("SELECT COUNT(*) FROM {table}"), [], |row| row
969 .get::<_, u64>(0))
970 .unwrap(),
971 count,
972 "{realtime}/{fault}/{table}"
973 );
974 }
975 assert_eq!(client.subs[0].cursor, if fault == "none" { 2 } else { 0 });
976 assert_eq!(
977 transport
978 .messages
979 .iter()
980 .filter(|m| m.contains("\"ack\""))
981 .count(),
982 usize::from(fault == "none")
983 );
984 if matches!(fault, "revision" | "commit" | "cursor") {
985 client
986 .conn
987 .execute_batch("DROP TRIGGER fail_frame")
988 .unwrap();
989 }
990 drop(client);
991 let mut reopened = SyncClient::open_path(
992 "frame-test".into(),
993 &schema,
994 Default::default(),
995 path.to_str().unwrap(),
996 )
997 .unwrap();
998 assert_eq!(reopened.local_revision(), expected_revision);
999 assert_eq!(
1000 reopened
1001 .query("SELECT id FROM tasks ORDER BY id", &[])
1002 .unwrap()
1003 .len(),
1004 count as usize
1005 );
1006 assert_eq!(reopened.subs[0].cursor, if fault == "none" { 2 } else { 0 });
1007 let (_, meta) = reopened.build_request(false).unwrap();
1008 assert!(matches!(
1009 reopened.process_response(&mut transport, valid, &meta),
1010 SyncOutcome::Ok(_)
1011 ));
1012 assert_eq!(
1013 reopened
1014 .query("SELECT id FROM tasks ORDER BY id", &[])
1015 .unwrap()
1016 .len(),
1017 2
1018 );
1019 assert_eq!(reopened.subs[0].cursor, 2);
1020 drop(reopened);
1021 std::fs::remove_file(path).unwrap();
1022 }
1023 }
1024 }
1025
1026 #[test]
1027 fn failed_subscription_control_persistence_rolls_back_rows_outbox_and_memory() {
1028 for status in [SubStatus::Reset, SubStatus::Revoked] {
1029 for fault in ["subscription", "revision"] {
1030 let mut client = client();
1031 client
1032 .subscribe(
1033 "tasks".into(),
1034 "tasks".into(),
1035 vec![("project_id".into(), vec!["p1".into()])],
1036 None,
1037 )
1038 .unwrap();
1039 client.subs[0].cursor = 41;
1040 client.subs[0].synced_once = true;
1041 client.subs[0].effective = Some(vec![("project_id".into(), vec!["p1".into()])]);
1042 client.persist_sub(&client.subs[0]).unwrap();
1043 client
1044 .mutate(vec![Mutation::Upsert {
1045 table: "tasks".into(),
1046 values: Map::from_iter([
1047 ("id".into(), Value::from("pending")),
1048 ("project_id".into(), Value::from("p1")),
1049 ]),
1050 base_version: None,
1051 }])
1052 .unwrap();
1053 let ids = client.pending_commit_ids();
1054 let rows = client.query("SELECT * FROM tasks", &[]).unwrap();
1055 let revision = client.local_revision();
1056 client.drain_change_batches();
1057 let (_, meta) = client.build_request(false).unwrap();
1058 client.conn.execute_batch(if fault == "subscription" {
1059 "CREATE TRIGGER fail_control BEFORE INSERT ON _syncular_subscriptions BEGIN SELECT RAISE(FAIL, 'injected subscription failure'); END"
1060 } else {
1061 "CREATE TRIGGER fail_control BEFORE INSERT ON _syncular_meta WHEN NEW.key = 'localRevision' BEGIN SELECT RAISE(FAIL, 'injected revision failure'); END"
1062 }).unwrap();
1063 let mut transport = CountingRealtimeTransport::default();
1064 let mut report = SyncReport::default();
1065 if status == SubStatus::Reset {
1067 client.conn.execute("INSERT INTO _syncular_windows(base,unit,sub_id) VALUES ('base','p1','tasks')", []).unwrap();
1068 }
1069 let result = client.process_section(
1070 &mut transport,
1071 "tasks",
1072 status,
1073 "sync.scope_revoked",
1074 vec![],
1075 vec![],
1076 Some((42, None)),
1077 &meta,
1078 &mut report,
1079 );
1080 assert!(result.is_err(), "{status:?}/{fault}");
1081 assert!(client.conn.is_autocommit());
1082 assert_eq!(client.subs[0].cursor, 41);
1083 assert_eq!(client.subs[0].state, SubState::Active);
1084 assert_eq!(client.pending_commit_ids(), ids);
1085 assert!(client.commit_outcome(&ids[0]).unwrap().is_none());
1086 assert!(client.rejections.is_empty());
1087 assert_eq!(client.query("SELECT * FROM tasks", &[]).unwrap(), rows);
1088 assert_eq!(client.local_revision(), revision);
1089 assert!(client.drain_change_batches().is_empty());
1090 assert!(report.revoked.is_empty() && report.resets.is_empty());
1091 }
1092 }
1093 }
1094
1095 #[test]
1096 fn rows_segment_blocks_commit_independently_and_clear_with_the_first_block() {
1097 let mut client = client();
1098 client
1099 .subscribe(
1100 "tasks".into(),
1101 "tasks".into(),
1102 vec![("project_id".into(), vec!["p1".into()])],
1103 None,
1104 )
1105 .unwrap();
1106 client.subs[0].effective = Some(vec![("project_id".into(), vec!["p1".into()])]);
1107 client.conn.execute_batch("INSERT INTO _syncular_base_tasks VALUES ('stale','p1',1); INSERT INTO tasks VALUES ('stale','p1',1);
1108 CREATE TRIGGER fail_block BEFORE INSERT ON _syncular_base_tasks WHEN NEW.id = 'bad' BEGIN SELECT RAISE(FAIL,'injected later block failure'); END").unwrap();
1109 let row = |id: &str| ssp2::segment::SegmentRow {
1110 server_version: 1,
1111 values: vec![
1112 Some(ColumnValue::String(id.into())),
1113 Some(ColumnValue::String("p1".into())),
1114 ],
1115 };
1116 let segment = RowsSegment {
1117 table: "tasks".into(),
1118 schema_version: 1,
1119 columns: client.schema.table("tasks").unwrap().wire_columns.clone(),
1120 blocks: vec![vec![row("first")], vec![row("second"), row("bad")]],
1121 };
1122 assert!(client.apply_segment(0, &segment, true).is_err());
1123 assert!(client.conn.is_autocommit());
1124 assert_eq!(
1125 client
1126 .query("SELECT id FROM tasks ORDER BY id", &[])
1127 .unwrap(),
1128 vec![Map::from_iter([("id".into(), Value::from("first"))])]
1129 );
1130 assert_eq!(client.local_revision(), 1);
1131 assert_eq!(client.drain_change_batches().len(), 1);
1132 assert_eq!(client.subs[0].cursor, -1);
1133 client
1134 .conn
1135 .execute_batch("DROP TRIGGER fail_block")
1136 .unwrap();
1137 assert!(matches!(client.apply_segment(0, &segment, true), Ok(3)));
1138 assert_eq!(client.local_revision(), 3);
1139 assert_eq!(client.drain_change_batches().len(), 2);
1140 }
1141
1142 #[test]
1143 fn diagnostic_storage_reads_fresh_aggregates_after_writes_rollback_and_schema_changes() {
1144 assert_eq!(
1145 client().diagnostics_storage().blob_cache_bytes_approx,
1146 Some(0)
1147 );
1148 let client = SyncClient::new(
1149 "diagnostic-storage".to_owned(),
1150 &json!({
1151 "version": 1, "tables": [{"name": "attachments", "primaryKey": "id",
1152 "columns": [{"name": "id", "type": "string", "nullable": false},
1153 {"name": "body", "type": "blob_ref", "nullable": false}], "scopes": []}]
1154 }),
1155 ClientLimits {
1156 blob_cache_max_bytes: Some(5),
1157 ..Default::default()
1158 },
1159 )
1160 .unwrap();
1161 let empty = client.diagnostics_storage();
1162 assert_eq!(empty.status, "healthy");
1163 assert_eq!(empty.pending_outbox_bytes_approx, Some(0));
1164 assert_eq!(empty.retained_outcome_entries, Some(0));
1165 assert_eq!(empty.retained_outcome_bytes_approx, Some(0));
1166 assert_eq!(empty.blob_cache_bytes_approx, Some(0));
1167 client.conn.execute_batch("INSERT INTO _syncular_outbox(commit_id, ops_json) VALUES ('one', '[]');
1168 INSERT INTO _syncular_commit_outcomes(client_commit_id,status,recorded_at_ms,results_json,operations_json)
1169 VALUES ('one','applied',0,'[]',NULL),('two','rejected',0,'[]','[]');
1170 INSERT INTO _syncular_blobs(blob_id,bytes,byte_length,media_type,created_at_ms)
1171 VALUES ('body',zeroblob(8),8,NULL,0);").unwrap();
1172 let written = client.diagnostics_storage();
1173 assert_eq!(written.status, "pressure");
1174 assert_eq!(written.pending_outbox_bytes_approx, Some(2));
1175 assert_eq!(written.retained_outcome_entries, Some(2));
1176 assert_eq!(written.retained_outcome_bytes_approx, Some(6));
1177 assert_eq!(written.blob_cache_bytes_approx, Some(8));
1178 assert_eq!(
1179 written.pressure_reason_code.as_deref(),
1180 Some("client.blob_cache_over_limit")
1181 );
1182 client
1183 .conn
1184 .execute_batch(
1185 "BEGIN; UPDATE _syncular_outbox SET ops_json = '[1,2]';
1186 DELETE FROM _syncular_commit_outcomes; DELETE FROM _syncular_blobs;",
1187 )
1188 .unwrap();
1189 let staged = client.diagnostics_storage();
1190 assert_eq!(staged.pending_outbox_bytes_approx, Some(5));
1191 assert_eq!(staged.retained_outcome_entries, Some(0));
1192 assert_eq!(staged.blob_cache_bytes_approx, Some(0));
1193 assert_eq!(staged.status, "healthy");
1194 client.conn.execute_batch("ROLLBACK").unwrap();
1195 assert_eq!(client.diagnostics_storage(), written);
1196 client.conn.execute_batch("ALTER TABLE _syncular_commit_outcomes RENAME COLUMN results_json TO hidden_results").unwrap();
1197 let unreadable = client.diagnostics_storage();
1198 assert_eq!(unreadable.status, "unreadable");
1199 assert!(unreadable.database_bytes_approx.is_none());
1200 assert!(unreadable.pending_outbox_bytes_approx.is_none());
1201 assert!(unreadable.retained_outcome_entries.is_none());
1202 assert!(unreadable.retained_outcome_bytes_approx.is_none());
1203 assert!(unreadable.blob_cache_bytes_approx.is_none());
1204 client.conn.execute_batch("ALTER TABLE _syncular_commit_outcomes RENAME COLUMN hidden_results TO results_json").unwrap();
1205 let restored = client.diagnostics_storage();
1206 assert_eq!(restored.status, written.status);
1207 assert_eq!(
1208 restored.retained_outcome_bytes_approx,
1209 written.retained_outcome_bytes_approx
1210 );
1211 client.conn.set_prepared_statement_cache_capacity(0);
1212 assert_eq!(client.diagnostics_storage(), restored);
1213 }
1214
1215 struct ServerErrorTransport {
1217 code: &'static str,
1218 retryable: bool,
1219 }
1220
1221 impl Transport for ServerErrorTransport {
1222 fn sync(&mut self, _request: &[u8]) -> Result<Vec<u8>, TransportError> {
1223 Ok(encode_message(&Message {
1224 wire_version: WIRE_VERSION,
1225 msg_kind: MsgKind::Response,
1226 frames: vec![
1227 Frame::RespHeader {
1228 required_schema_version: None,
1229 latest_schema_version: None,
1230 log_epoch: Some("epoch-1".to_owned()),
1231 reset_required: Some(false),
1232 },
1233 Frame::Error {
1234 code: self.code.to_owned(),
1235 message: "server error".to_owned(),
1236 category: "internal".to_owned(),
1237 retryable: self.retryable,
1238 recommended_action: "retryLater".to_owned(),
1239 details: None,
1240 },
1241 ],
1242 }))
1243 }
1244
1245 fn realtime_sync(&mut self, request: &[u8]) -> Result<Vec<u8>, TransportError> {
1246 self.sync(request)
1247 }
1248
1249 fn download_segment(
1250 &mut self,
1251 _request: &SegmentRequest,
1252 _on_progress: &mut dyn FnMut(u64),
1253 ) -> Result<Vec<u8>, TransportError> {
1254 Err(TransportError::new("sync.transport_failed", "offline"))
1255 }
1256
1257 fn realtime_connect(&mut self) -> Result<(), TransportError> {
1258 Ok(())
1259 }
1260
1261 fn realtime_send(&mut self, _text: &str) -> Result<(), TransportError> {
1262 Ok(())
1263 }
1264
1265 fn realtime_close(&mut self) -> Result<(), TransportError> {
1266 Ok(())
1267 }
1268 }
1269
1270 #[test]
1271 fn retryable_server_error_schedules_a_background_retry() {
1272 let mut client = client();
1273 client.set_meta(LOG_EPOCH_KEY, "epoch-1");
1274 let mut transport = ServerErrorTransport {
1275 code: "sync.internal_error",
1276 retryable: true,
1277 };
1278 assert!(matches!(
1279 client.sync(&mut transport),
1280 SyncOutcome::Failed { ref error_code, .. } if error_code == "sync.internal_error"
1281 ));
1282 assert!(matches!(
1283 client.drain_sync_intents().as_slice(),
1284 [SyncIntent::Background { delay_ms: 250 }]
1285 ));
1286 assert_eq!(
1287 client.progress().snapshot().unwrap().retry_delay_ms,
1288 Some(250)
1289 );
1290 let mut transport = ServerErrorTransport {
1291 code: "sync.invalid_request",
1292 retryable: false,
1293 };
1294 assert!(matches!(
1295 client.sync(&mut transport),
1296 SyncOutcome::Failed { ref error_code, .. } if error_code == "sync.invalid_request"
1297 ));
1298 assert!(client.drain_sync_intents().is_empty());
1299 let progress = client.progress().snapshot().unwrap();
1300 assert_eq!(progress.state, ProgressState::Failed);
1301 assert_eq!(progress.retry_delay_ms, None);
1302 }
1303
1304 #[test]
1305 fn sync_until_idle_reports_budget_exhaustion_or_failure() {
1306 let exhausted = SyncOutcome::BudgetExhausted(SyncReport::default()).to_json();
1309 assert_eq!(exhausted["ok"], json!(true));
1310 assert_eq!(exhausted["budgetExhausted"], json!(true));
1311 assert!(exhausted["report"].is_object());
1312
1313 let mut client = client();
1314 client.set_meta(LOG_EPOCH_KEY, "epoch-1");
1315 let mut idle_transport = HostTransport::new_from_config(&json!({})).unwrap();
1317 let zero = client.sync_until_idle(&mut idle_transport, Some(0));
1318 assert!(
1319 matches!(zero, SyncOutcome::Failed { ref error_code, .. } if error_code == "sync.invalid_request"),
1320 "{zero:?}"
1321 );
1322 let mut erroring = ServerErrorTransport {
1324 code: "sync.internal_error",
1325 retryable: false,
1326 };
1327 let failed = client.sync_until_idle(&mut erroring, Some(3));
1328 assert!(
1329 matches!(failed, SyncOutcome::Failed { ref error_code, .. } if error_code == "sync.internal_error"),
1330 "{failed:?}"
1331 );
1332 }
1333
1334 struct ScriptedSyncTransport {
1337 responses: std::collections::VecDeque<Message>,
1338 calls: usize,
1339 }
1340
1341 impl Transport for ScriptedSyncTransport {
1342 fn sync(&mut self, _request: &[u8]) -> Result<Vec<u8>, TransportError> {
1343 self.calls += 1;
1344 let response = self.responses.pop_front().expect("scripted response");
1345 Ok(encode_message(&response))
1346 }
1347
1348 fn realtime_sync(&mut self, request: &[u8]) -> Result<Vec<u8>, TransportError> {
1349 self.sync(request)
1350 }
1351
1352 fn download_segment(
1353 &mut self,
1354 _request: &SegmentRequest,
1355 _on_progress: &mut dyn FnMut(u64),
1356 ) -> Result<Vec<u8>, TransportError> {
1357 Err(TransportError::new("sync.transport_failed", "offline"))
1358 }
1359
1360 fn realtime_connect(&mut self) -> Result<(), TransportError> {
1361 Ok(())
1362 }
1363
1364 fn realtime_send(&mut self, _text: &str) -> Result<(), TransportError> {
1365 Ok(())
1366 }
1367
1368 fn realtime_close(&mut self) -> Result<(), TransportError> {
1369 Ok(())
1370 }
1371 }
1372
1373 #[test]
1374 fn sync_until_idle_success_then_failure_keeps_earlier_work() {
1375 let path = std::env::temp_dir().join(format!(
1376 "syncular-budget-script-{}.sqlite",
1377 uuid::Uuid::new_v4()
1378 ));
1379 let schema = json!({"version":1,"tables":[{"name":"tasks","primaryKey":"id","columns":[
1380 {"name":"id","type":"string","nullable":false},
1381 {"name":"project_id","type":"string","nullable":false}],
1382 "scopes":[{"pattern":"project:{project_id}"}]}]});
1383 let mut client = SyncClient::open_path(
1384 "budget-script".into(),
1385 &schema,
1386 ClientLimits::default(),
1387 path.to_str().unwrap(),
1388 )
1389 .expect("open client");
1390 client.set_meta(LOG_EPOCH_KEY, "epoch-1");
1391 client
1392 .subscribe(
1393 "tasks".into(),
1394 "tasks".into(),
1395 vec![("project_id".into(), vec!["p1".into()])],
1396 None,
1397 )
1398 .unwrap();
1399 client.subs[0].cursor = 0;
1400 client.subs[0].synced_once = true;
1401 client.subs[0].effective = Some(vec![("project_id".into(), vec!["p1".into()])]);
1402 client.persist_sub(&client.subs[0]).unwrap();
1403 client.drain_change_batches();
1404
1405 let row = encode_row_json(
1406 client.schema.table("tasks").unwrap(),
1407 "kept",
1408 &Map::from_iter([
1409 ("id".into(), json!("kept")),
1410 ("project_id".into(), json!("p1")),
1411 ]),
1412 &client.encryption,
1413 )
1414 .unwrap();
1415 let success = Message {
1416 wire_version: WIRE_VERSION,
1417 msg_kind: MsgKind::Response,
1418 frames: vec![
1419 Frame::RespHeader {
1420 required_schema_version: None,
1421 latest_schema_version: None,
1422 log_epoch: Some("epoch-1".into()),
1423 reset_required: Some(false),
1424 },
1425 Frame::SubStart {
1426 id: "tasks".into(),
1427 status: SubStatus::Active,
1428 reason_code: String::new(),
1429 effective_scopes: vec![("project_id".into(), vec!["p1".into()])],
1430 bootstrap: false,
1431 },
1432 Frame::Commit {
1433 commit_seq: 1,
1434 created_at_ms: 1,
1435 actor_id: "writer".into(),
1436 tables: vec!["tasks".into()],
1437 changes: vec![ssp2::model::Change {
1438 table_index: 0,
1439 row_id: "kept".into(),
1440 op: Op::Upsert,
1441 row_version: Some(1),
1442 scopes: vec![("project_id".into(), "p1".into())],
1443 row: Some(row),
1444 }],
1445 },
1446 Frame::SubEnd {
1447 next_cursor: 1,
1448 bootstrap_state: None,
1449 },
1450 ],
1451 };
1452 let failure = Message {
1453 wire_version: WIRE_VERSION,
1454 msg_kind: MsgKind::Response,
1455 frames: vec![
1456 Frame::RespHeader {
1457 required_schema_version: None,
1458 latest_schema_version: None,
1459 log_epoch: Some("epoch-1".into()),
1460 reset_required: Some(false),
1461 },
1462 Frame::Error {
1463 code: "sync.internal_error".into(),
1464 message: "second round failed".into(),
1465 category: "internal".into(),
1466 retryable: false,
1467 recommended_action: "retryLater".into(),
1468 details: None,
1469 },
1470 ],
1471 };
1472 let mut transport = ScriptedSyncTransport {
1473 responses: std::collections::VecDeque::from([success, failure]),
1474 calls: 0,
1475 };
1476 let outcome = client.sync_until_idle(&mut transport, Some(2));
1477 assert!(
1478 matches!(outcome, SyncOutcome::Failed { ref error_code, .. } if error_code == "sync.internal_error"),
1479 "{outcome:?}"
1480 );
1481 assert_eq!(
1482 transport.calls, 2,
1483 "one successful round then the failing round"
1484 );
1485 assert_eq!(
1486 client.query("SELECT id FROM tasks", &[]).unwrap()[0]["id"],
1487 json!("kept"),
1488 "the earlier round's durable work survives the later failure"
1489 );
1490 drop(client);
1491 let _ = std::fs::remove_file(&path);
1492 }
1493
1494 #[test]
1495 fn sync_until_idle_stops_on_a_schema_floor() {
1496 let mut client = client();
1497 client.set_meta(LOG_EPOCH_KEY, "epoch-1");
1498 let floor = Message {
1499 wire_version: WIRE_VERSION,
1500 msg_kind: MsgKind::Response,
1501 frames: vec![Frame::RespHeader {
1502 required_schema_version: Some(2),
1503 latest_schema_version: Some(2),
1504 log_epoch: Some("epoch-1".into()),
1505 reset_required: Some(false),
1506 }],
1507 };
1508 let mut transport = ScriptedSyncTransport {
1509 responses: std::collections::VecDeque::from([floor.clone(), floor]),
1510 calls: 0,
1511 };
1512 let outcome = client.sync_until_idle(&mut transport, Some(3));
1513 match outcome {
1514 SyncOutcome::Ok(report) => assert_eq!(
1515 report
1516 .schema_floor
1517 .as_ref()
1518 .and_then(|floor| floor.required_schema_version),
1519 Some(2),
1520 "the stop retains the latest schema floor"
1521 ),
1522 other => panic!("expected a schema-floor stop, got {other:?}"),
1523 }
1524 assert_eq!(
1525 transport.calls, 1,
1526 "a schema floor stops the budget loop after one round"
1527 );
1528 }
1529
1530 #[test]
1531 fn sync_report_merge_schema_floor_is_latest_state() {
1532 let mut aggregate = SyncReport {
1533 schema_floor: Some(SchemaFloor {
1534 required_schema_version: Some(2),
1535 latest_schema_version: Some(2),
1536 }),
1537 ..SyncReport::default()
1538 };
1539 aggregate.merge(&SyncReport::default());
1540 assert!(
1541 aggregate.schema_floor.is_none(),
1542 "a later round without a floor clears it"
1543 );
1544 }
1545
1546 #[test]
1547 fn push_capacity_limits_are_hard_and_keep_intent() {
1548 let mut c = client();
1550 c.set_meta(LOG_EPOCH_KEY, "epoch-1");
1551 c.limits.max_push_operations_per_request = Some(1);
1552 let id = c
1553 .mutate(vec![
1554 Mutation::Upsert {
1555 table: "tasks".into(),
1556 values: Map::from_iter([
1557 ("id".into(), json!("a")),
1558 ("project_id".into(), json!("p1")),
1559 ]),
1560 base_version: None,
1561 },
1562 Mutation::Upsert {
1563 table: "tasks".into(),
1564 values: Map::from_iter([
1565 ("id".into(), json!("b")),
1566 ("project_id".into(), json!("p1")),
1567 ]),
1568 base_version: None,
1569 },
1570 ])
1571 .unwrap();
1572 let error = c.build_request(false).unwrap_err();
1573 let SyncOutcome::Failed {
1574 error_code,
1575 details,
1576 ..
1577 } = *error
1578 else {
1579 panic!("expected a capacity failure");
1580 };
1581 assert_eq!(error_code, "client.push_request_too_large");
1582 let details = details.expect("capacity details");
1583 assert_eq!(details["kind"], json!("operations"));
1584 assert_eq!(details["limit"], json!(1));
1585 assert_eq!(details["size"], json!(2));
1586 assert_eq!(details["clientCommitId"], json!(id));
1587 assert_eq!(c.pending_commit_ids(), vec![id]);
1588
1589 let mut c = client();
1591 c.set_meta(LOG_EPOCH_KEY, "epoch-1");
1592 c.limits.max_push_commits_per_request = Some(1);
1593 let first = c
1594 .mutate(vec![Mutation::Upsert {
1595 table: "tasks".into(),
1596 values: Map::from_iter([
1597 ("id".into(), json!("one")),
1598 ("project_id".into(), json!("p1")),
1599 ]),
1600 base_version: None,
1601 }])
1602 .unwrap();
1603 let second = c
1604 .mutate(vec![Mutation::Upsert {
1605 table: "tasks".into(),
1606 values: Map::from_iter([
1607 ("id".into(), json!("two")),
1608 ("project_id".into(), json!("p1")),
1609 ]),
1610 base_version: None,
1611 }])
1612 .unwrap();
1613 let (_, meta) = c.build_request(false).unwrap();
1614 assert_eq!(meta.pushed_ids, vec![first.clone()]);
1615 assert_eq!(meta.deferred_commits, 1);
1616 assert_eq!(c.pending_commit_ids(), vec![first, second]);
1617 }
1618
1619 #[test]
1620 fn push_request_byte_cap_defers_the_prefix_boundary_and_errors_the_head() {
1621 let mut c = client();
1624 c.set_meta(LOG_EPOCH_KEY, "epoch-1");
1625 let first = c
1626 .mutate(vec![Mutation::Upsert {
1627 table: "tasks".into(),
1628 values: Map::from_iter([
1629 ("id".into(), json!("one")),
1630 ("project_id".into(), json!("p1")),
1631 ]),
1632 base_version: None,
1633 }])
1634 .unwrap();
1635 let second = c
1636 .mutate(vec![Mutation::Upsert {
1637 table: "tasks".into(),
1638 values: Map::from_iter([
1639 ("id".into(), json!("two")),
1640 ("project_id".into(), json!("p1")),
1641 ]),
1642 base_version: None,
1643 }])
1644 .unwrap();
1645 let (message, meta) = c.build_request(false).unwrap();
1646 assert_eq!(meta.pushed_ids.len(), 2);
1647 let full = encode_message(&message).len();
1648 c.limits.max_push_request_bytes = Some(full - 1);
1649 let (_, meta) = c.build_request(false).unwrap();
1650 assert_eq!(meta.pushed_ids, vec![first.clone()]);
1651 assert_eq!(meta.deferred_commits, 1);
1652 assert_eq!(c.pending_commit_ids(), vec![first, second]);
1653
1654 let mut c = client();
1657 c.set_meta(LOG_EPOCH_KEY, "epoch-1");
1658 let huge = c
1659 .mutate(vec![Mutation::Upsert {
1660 table: "tasks".into(),
1661 values: Map::from_iter([
1662 ("id".into(), json!("x".repeat(20_000))),
1663 ("project_id".into(), json!("p1")),
1664 ]),
1665 base_version: None,
1666 }])
1667 .unwrap();
1668 let (message, meta) = c.build_request(false).unwrap();
1669 assert_eq!(meta.pushed_ids, vec![huge.clone()]);
1670 let with_huge = encode_message(&message).len();
1671 c.limits.max_push_request_bytes = Some(with_huge - 1);
1672 let error = c.build_request(false).unwrap_err();
1673 let SyncOutcome::Failed {
1674 error_code,
1675 details,
1676 ..
1677 } = *error
1678 else {
1679 panic!("expected a capacity failure");
1680 };
1681 assert_eq!(error_code, "client.push_request_too_large");
1682 let details = details.expect("capacity details");
1683 assert_eq!(details["kind"], json!("bytes"));
1684 assert_eq!(details["limit"], json!((with_huge - 1) as u64));
1685 assert_eq!(details["size"], json!(with_huge as u64));
1686 assert_eq!(details["clientCommitId"], json!(huge));
1687 assert_eq!(c.pending_commit_ids(), vec![huge]);
1688
1689 let mut c = client();
1691 c.set_meta(LOG_EPOCH_KEY, "epoch-1");
1692 c.limits.max_push_request_bytes = Some(1);
1693 let error = c.build_request(false).unwrap_err();
1694 let SyncOutcome::Failed { details, .. } = *error else {
1695 panic!("expected a capacity failure");
1696 };
1697 let details = details.expect("capacity details");
1698 assert_eq!(details["kind"], json!("bytes"));
1699 assert!(details.get("clientCommitId").is_none(), "{details}");
1700 }
1701
1702 #[test]
1703 fn push_limits_validate_at_construction() {
1704 for limits in [
1705 ClientLimits {
1706 max_push_commits_per_request: Some(0),
1707 ..Default::default()
1708 },
1709 ClientLimits {
1710 max_push_operations_per_request: Some(0),
1711 ..Default::default()
1712 },
1713 ClientLimits {
1714 max_push_request_bytes: Some(0),
1715 ..Default::default()
1716 },
1717 ClientLimits {
1718 max_push_request_bytes: Some(u32::MAX as usize + 1),
1719 ..Default::default()
1720 },
1721 ] {
1722 let schema = json!({"version":1,"tables":[]});
1723 let error = SyncClient::with_connection(
1724 "bad-limits".into(),
1725 &schema,
1726 limits,
1727 Connection::open_in_memory().unwrap(),
1728 )
1729 .err();
1730 assert_eq!(
1731 error.as_deref(),
1732 Some("sync.invalid_request: push limit must be an integer in 1..=4294967295")
1733 );
1734 }
1735 }
1736
1737 #[test]
1738 fn open_path_rejects_invalid_push_limits_before_storage() {
1739 for identity in [false, true] {
1740 let path = std::env::temp_dir().join(format!(
1741 "syncular-bad-limits-{}.sqlite",
1742 uuid::Uuid::new_v4()
1743 ));
1744 let schema = json!({"version":1,"tables":[]});
1745 let limits = ClientLimits {
1746 max_push_request_bytes: Some(0),
1747 ..Default::default()
1748 };
1749 let error = if identity {
1750 SyncClient::open_path_with_identity(None, &schema, limits, path.to_str().unwrap())
1751 } else {
1752 SyncClient::open_path("bad-limits".into(), &schema, limits, path.to_str().unwrap())
1753 }
1754 .err();
1755 assert_eq!(
1756 error.as_deref(),
1757 Some("sync.invalid_request: push limit must be an integer in 1..=4294967295")
1758 );
1759 assert!(
1760 !path.exists(),
1761 "an invalid limit is rejected before the database file is created"
1762 );
1763 }
1764 }
1765
1766 #[test]
1767 fn failed_progress_retry_delay_doubles_to_the_cap() {
1768 let mut client = client();
1769 client.set_meta(LOG_EPOCH_KEY, "epoch-1");
1770 let mut transport = ServerErrorTransport {
1771 code: "sync.internal_error",
1772 retryable: true,
1773 };
1774 let delays: Vec<Option<u64>> = (0..9)
1775 .map(|_| {
1776 client.sync(&mut transport);
1777 client.progress().snapshot().unwrap().retry_delay_ms
1778 })
1779 .collect();
1780 assert_eq!(
1781 delays,
1782 [250, 500, 1_000, 2_000, 4_000, 8_000, 16_000, 30_000, 30_000].map(Some)
1783 );
1784 }
1785
1786 #[test]
1787 fn background_retry_deadlines_back_off_and_reset() {
1788 let mut client = client();
1789 client.schedule_background_retry();
1790 assert!(matches!(
1791 client.drain_sync_intents().as_slice(),
1792 [SyncIntent::Background { delay_ms: 250 }]
1793 ));
1794 client.schedule_background_retry();
1795 assert!(matches!(
1796 client.drain_sync_intents().as_slice(),
1797 [SyncIntent::Background { delay_ms: 500 }]
1798 ));
1799 client.reset_background_retry();
1800 client.schedule_background_retry();
1801 assert!(matches!(
1802 client.drain_sync_intents().as_slice(),
1803 [SyncIntent::Background { delay_ms: 250 }]
1804 ));
1805 }
1806
1807 #[test]
1808 fn log_epoch_reset_requests_an_immediate_follow_up_round() {
1809 let mut client = client();
1810 client.set_meta(LOG_EPOCH_KEY, "epoch-1");
1811 client
1812 .subscribe(
1813 "epoch-tasks".to_owned(),
1814 "tasks".to_owned(),
1815 vec![("project_id".to_owned(), vec!["p1".to_owned()])],
1816 None,
1817 )
1818 .expect("subscribe");
1819 client.drain_sync_intents();
1820
1821 client
1822 .run_log_epoch_reset("epoch-2")
1823 .expect("reset partition log epoch");
1824
1825 assert!(matches!(
1826 client.drain_sync_intents().as_slice(),
1827 [SyncIntent::Interactive]
1828 ));
1829 }
1830
1831 #[derive(Default)]
1832 struct CountingRealtimeTransport {
1833 connects: usize,
1834 closes: usize,
1835 messages: Vec<String>,
1836 blob_uploads: Vec<(String, Vec<u8>)>,
1837 blob_download: Option<Vec<u8>>,
1838 sync_calls: usize,
1839 realtime_sync_calls: usize,
1840 refuse_connect: bool,
1841 }
1842
1843 impl Transport for CountingRealtimeTransport {
1844 fn blob_upload(
1845 &mut self,
1846 blob_id: &str,
1847 bytes: &[u8],
1848 _media_type: Option<&str>,
1849 ) -> Result<(), TransportError> {
1850 self.blob_uploads.push((blob_id.to_owned(), bytes.to_vec()));
1851 Ok(())
1852 }
1853
1854 fn blob_download(&mut self, _blob_id: &str) -> Result<BlobDownload, TransportError> {
1855 self.blob_download
1856 .clone()
1857 .map(BlobDownload::Bytes)
1858 .ok_or_else(|| TransportError::new("blob.not_found", "missing blob"))
1859 }
1860
1861 fn sync(&mut self, _request: &[u8]) -> Result<Vec<u8>, TransportError> {
1862 self.sync_calls += 1;
1863 Err(TransportError::new("sync.transport_failed", "offline"))
1864 }
1865
1866 fn realtime_sync(&mut self, _request: &[u8]) -> Result<Vec<u8>, TransportError> {
1867 self.realtime_sync_calls += 1;
1868 Err(TransportError::new("sync.transport_failed", "offline"))
1869 }
1870
1871 fn download_segment(
1872 &mut self,
1873 _request: &SegmentRequest,
1874 _on_progress: &mut dyn FnMut(u64),
1875 ) -> Result<Vec<u8>, TransportError> {
1876 Err(TransportError::new("sync.transport_failed", "offline"))
1877 }
1878
1879 fn realtime_connect(&mut self) -> Result<(), TransportError> {
1880 self.connects += 1;
1881 if self.refuse_connect {
1882 return Err(TransportError::new("transport.failed", "refused"));
1883 }
1884 Ok(())
1885 }
1886
1887 fn realtime_send(&mut self, text: &str) -> Result<(), TransportError> {
1888 self.messages.push(text.to_owned());
1889 Ok(())
1890 }
1891
1892 fn realtime_close(&mut self) -> Result<(), TransportError> {
1893 self.closes += 1;
1894 Ok(())
1895 }
1896 }
1897
1898 #[test]
1899 fn blob_results_own_downloaded_bytes() {
1900 let schema = json!({"version":1,"tables":[{"name":"attachments","primaryKey":"id","columns":[
1901 {"name":"id","type":"string","nullable":false},
1902 {"name":"file","type":"blob_ref","nullable":true}],"scopes":[]}]});
1903 let bytes = b"owned typed result".to_vec();
1904 let blob_id = blob_id_for(&bytes);
1905 let mut transport = CountingRealtimeTransport {
1906 blob_download: Some(bytes.clone()),
1907 ..Default::default()
1908 };
1909 let result = {
1910 let mut client =
1911 SyncClient::new("owned-result".to_owned(), &schema, ClientLimits::default())
1912 .unwrap();
1913 let result = client.fetch_blob_bytes(&mut transport, &blob_id).unwrap();
1914 assert_eq!(result.blob_id, blob_id);
1915 assert_eq!(result.byte_length, bytes.len() as i64);
1916 assert_eq!(result.bytes, bytes);
1917 assert_eq!(result.media_type, None);
1918 assert_eq!(
1919 client
1920 .conn
1921 .query_row(
1922 "SELECT count(*) FROM _syncular_blob_uploads WHERE blob_id = ?",
1923 rusqlite::params![blob_id],
1924 |row| row.get::<_, i64>(0),
1925 )
1926 .unwrap(),
1927 0
1928 );
1929 result
1930 };
1931 assert_eq!(result.bytes, bytes);
1932
1933 let mut client =
1934 SyncClient::new("blob-errors".to_owned(), &schema, ClientLimits::default()).unwrap();
1935 assert_eq!(
1936 client
1937 .fetch_blob_bytes(&mut transport, "not json")
1938 .unwrap_err(),
1939 (
1940 "client.failed".to_owned(),
1941 "blob ref is not JSON".to_owned()
1942 )
1943 );
1944 }
1945
1946 #[test]
1947 fn visible_blob_references_survive_cache_trimming() {
1948 let schema = json!({"version":1,"tables":[
1949 {"name":"attachments","primaryKey":"id","columns":[
1950 {"name":"id","type":"string","nullable":false},
1951 {"name":"body","type":"blob_ref","nullable":true},
1952 {"name":"preview","type":"blob_ref","nullable":true}],"scopes":[]},
1953 {"name":"avatars","primaryKey":"id","columns":[
1954 {"name":"id","type":"string","nullable":false},
1955 {"name":"photo","type":"blob_ref","nullable":true}],"scopes":[]}
1956 ]});
1957 let bytes = b"referenced above cap".to_vec();
1958 let blob_id = blob_id_for(&bytes);
1959 let reference = json!({"blobId": blob_id, "byteLength": bytes.len()}).to_string();
1960 let mut client = SyncClient::new(
1961 "visible-blob-refs".to_owned(),
1962 &schema,
1963 ClientLimits {
1964 blob_cache_max_bytes: Some(1),
1965 ..Default::default()
1966 },
1967 )
1968 .unwrap();
1969 client.conn.execute(
1970 "INSERT INTO attachments VALUES ('one', ?, ?, 0), ('two', ?, 'malformed', 0), ('three', 12, ?, 0)",
1971 rusqlite::params![reference, reference, reference, reference],
1972 ).unwrap();
1973 client
1974 .conn
1975 .execute(
1976 "INSERT INTO avatars VALUES ('one', ?, 0), ('two', '{\"blobId\":12}', 0)",
1977 rusqlite::params![reference],
1978 )
1979 .unwrap();
1980 client.conn.execute(
1981 "INSERT INTO _syncular_blobs(blob_id, bytes, byte_length, media_type, created_at_ms) VALUES ('sha256:other', X'02', 1, NULL, 0)",
1982 [],
1983 ).unwrap();
1984 let mut transport = CountingRealtimeTransport {
1985 blob_download: Some(bytes.clone()),
1986 ..Default::default()
1987 };
1988
1989 assert_eq!(
1990 client
1991 .fetch_blob_bytes(&mut transport, &blob_id)
1992 .unwrap()
1993 .bytes,
1994 bytes
1995 );
1996 let rows = client
1997 .conn
1998 .prepare("SELECT blob_id FROM _syncular_blobs ORDER BY blob_id")
1999 .unwrap()
2000 .query_map([], |row| row.get::<_, String>(0))
2001 .unwrap()
2002 .collect::<Result<Vec<_>, _>>()
2003 .unwrap();
2004 assert_eq!(rows, vec![blob_id]);
2005 }
2006
2007 #[test]
2008 fn closed_transport_never_invokes_network_and_reopen_is_host_owned() {
2009 let mut transport = CountingRealtimeTransport::default();
2010 let path = std::env::temp_dir().join(format!(
2011 "syncular-transport-gate-{}.db",
2012 uuid::Uuid::new_v4()
2013 ));
2014 let schema = json!({"version":1,"tables":[{"name":"tasks","primaryKey":"id","columns":[
2015 {"name":"id","type":"string","nullable":false},
2016 {"name":"project_id","type":"string","nullable":false}],"scopes":[{"pattern":"project:{project_id}"}]}]});
2017 let mut client = SyncClient::open_path(
2018 "gate".into(),
2019 &schema,
2020 ClientLimits::default(),
2021 path.to_str().unwrap(),
2022 )
2023 .unwrap();
2024 client.connect_realtime(&mut transport).unwrap();
2025 client.set_transport_enabled(&mut transport, false);
2026 assert_eq!(transport.closes, 1);
2027 client.begin_security_preflight();
2028 client.activate_security(Default::default()).unwrap();
2029 client
2030 .subscribe(
2031 "tasks".into(),
2032 "tasks".into(),
2033 vec![("project_id".into(), vec!["p1".into()])],
2034 None,
2035 )
2036 .unwrap();
2037 for id in ["one", "two"] {
2038 client
2039 .mutate(vec![Mutation::Upsert {
2040 table: "tasks".into(),
2041 values: Map::from_iter([
2042 ("id".into(), json!(id)),
2043 ("project_id".into(), json!("p1")),
2044 ]),
2045 base_version: None,
2046 }])
2047 .unwrap();
2048 }
2049 assert_eq!(
2050 client
2051 .query("SELECT id FROM tasks ORDER BY id", &[])
2052 .unwrap()
2053 .len(),
2054 2
2055 );
2056 assert!(
2057 matches!(client.sync(&mut transport), SyncOutcome::Failed { ref error_code, .. } if error_code == "sync.offline")
2058 );
2059 assert!(client
2060 .connect_realtime(&mut transport)
2061 .unwrap_err()
2062 .starts_with("sync.offline:"));
2063 assert!(client
2064 .set_presence(&mut transport, "project:p1", None)
2065 .unwrap_err()
2066 .starts_with("sync.offline:"));
2067 assert_eq!(transport.sync_calls, 0);
2068 assert_eq!(transport.realtime_sync_calls, 0);
2069 assert_eq!(transport.connects, 1);
2070 assert!(transport.messages.is_empty());
2071 assert!(matches!(
2072 client.drain_sync_intents().as_slice(),
2073 [SyncIntent::None]
2074 ));
2075 let pending = client.pending_commit_ids();
2076 drop(client);
2077 let mut reopened = SyncClient::open_path(
2078 "gate".into(),
2079 &schema,
2080 ClientLimits::default(),
2081 path.to_str().unwrap(),
2082 )
2083 .unwrap();
2084 assert!(reopened.transport_enabled());
2085 reopened.set_transport_enabled(&mut transport, false);
2086 assert_eq!(reopened.pending_commit_ids(), pending);
2087 assert_eq!(
2088 reopened.query("SELECT id FROM tasks", &[]).unwrap().len(),
2089 2
2090 );
2091 reopened.set_transport_enabled(&mut transport, true);
2092 assert!(matches!(
2093 reopened.drain_sync_intents().as_slice(),
2094 [SyncIntent::Interactive]
2095 ));
2096 reopened.set_transport_enabled(&mut transport, true);
2097 assert!(reopened.drain_sync_intents().is_empty());
2098 drop(reopened);
2099 std::fs::remove_file(path).unwrap();
2100 }
2101
2102 #[test]
2103 fn realtime_connection_ownership_is_idempotent() {
2104 let mut client = client();
2105 let mut transport = CountingRealtimeTransport {
2106 connects: 0,
2107 closes: 0,
2108 ..Default::default()
2109 };
2110 client
2111 .connect_realtime(&mut transport)
2112 .expect("first connect");
2113 client
2114 .connect_realtime(&mut transport)
2115 .expect("idempotent connect");
2116 assert_eq!(transport.connects, 1);
2117 client.disconnect_realtime(&mut transport);
2118 client.disconnect_realtime(&mut transport);
2119 assert_eq!(transport.closes, 1);
2120 client
2121 .connect_realtime(&mut transport)
2122 .expect("deliberate reconnect");
2123 assert_eq!(transport.connects, 2);
2124 }
2125
2126 #[test]
2127 fn required_policy_refuses_a_round_without_a_socket_and_never_uses_http() {
2128 let mut client = client();
2129 client
2130 .set_realtime_policy(RealtimePolicy::Required)
2131 .expect("set policy");
2132 let mut transport = CountingRealtimeTransport::default();
2133 match client.sync(&mut transport) {
2134 SyncOutcome::RealtimeUnavailable {
2135 state,
2136 reason_code,
2137 retry_delay_ms,
2138 } => {
2139 assert_eq!(state, RealtimeState::Disconnected);
2140 assert_eq!(reason_code, None);
2141 assert_eq!(retry_delay_ms, 250);
2142 }
2143 other => panic!("expected RealtimeUnavailable, got {other:?}"),
2144 }
2145 assert_eq!(transport.sync_calls, 0);
2146 assert!(matches!(
2147 client.drain_sync_intents().as_slice(),
2148 [SyncIntent::Background { delay_ms: 250 }]
2149 ));
2150 let snapshot = client
2151 .diagnostics_snapshot(&ClientDiagnosticsRequest::default())
2152 .expect("diagnostics");
2153 assert_eq!(snapshot.host.realtime, RealtimeState::Disconnected);
2154 assert_eq!(snapshot.host.realtime_policy, RealtimePolicy::Required);
2155 assert_eq!(snapshot.host.realtime_reason_code, None);
2156 assert_eq!(snapshot.host.realtime_retry_delay_ms, Some(250));
2157 assert_eq!(
2158 snapshot
2159 .last_round
2160 .as_ref()
2161 .and_then(|round| round.error_code.clone()),
2162 Some(REALTIME_UNAVAILABLE_CODE.to_owned())
2163 );
2164 }
2165
2166 #[test]
2167 fn refused_handshake_is_a_visible_state_and_a_required_round_does_not_fall_back() {
2168 let mut client = client();
2169 client
2170 .set_realtime_policy(RealtimePolicy::Required)
2171 .expect("set policy");
2172 let mut transport = CountingRealtimeTransport {
2173 refuse_connect: true,
2174 ..Default::default()
2175 };
2176 assert!(client.connect_realtime(&mut transport).is_err());
2177 assert_eq!(client.realtime_state(), RealtimeState::Refused);
2178 let snapshot = client
2179 .diagnostics_snapshot(&ClientDiagnosticsRequest::default())
2180 .expect("diagnostics");
2181 assert_eq!(
2182 snapshot.host.realtime_reason_code.as_deref(),
2183 Some("transport.failed")
2184 );
2185 match client.sync(&mut transport) {
2186 SyncOutcome::RealtimeUnavailable {
2187 state, reason_code, ..
2188 } => {
2189 assert_eq!(state, RealtimeState::Refused);
2190 assert_eq!(reason_code.as_deref(), Some("transport.failed"));
2191 }
2192 other => panic!("expected RealtimeUnavailable, got {other:?}"),
2193 }
2194 assert_eq!(transport.sync_calls, 0);
2195 assert_eq!(transport.realtime_sync_calls, 0);
2196 }
2197
2198 #[test]
2199 fn a_failed_socket_round_is_lost_and_the_next_required_round_refuses_http() {
2200 let mut client = client();
2201 client
2202 .set_realtime_policy(RealtimePolicy::Required)
2203 .expect("set policy");
2204 let mut transport = CountingRealtimeTransport::default();
2205 client.connect_realtime(&mut transport).expect("connect");
2206 assert_eq!(client.realtime_state(), RealtimeState::Connected);
2207 assert!(
2208 client.set_realtime_policy(RealtimePolicy::Off).is_err(),
2209 "a policy change cannot silently re-route a connected socket"
2210 );
2211 assert!(matches!(
2212 client.sync(&mut transport),
2213 SyncOutcome::Failed { ref error_code, .. } if error_code == "sync.transport_failed"
2214 ));
2215 assert_eq!(client.realtime_state(), RealtimeState::Lost);
2216 assert_eq!(transport.realtime_sync_calls, 1);
2217 assert_eq!(transport.sync_calls, 0);
2218 let snapshot = client
2219 .diagnostics_snapshot(&ClientDiagnosticsRequest::default())
2220 .expect("diagnostics");
2221 assert_eq!(
2222 snapshot.host.realtime_reason_code.as_deref(),
2223 Some("sync.transport_failed")
2224 );
2225 assert_eq!(snapshot.host.realtime_retry_delay_ms, Some(250));
2226 match client.sync(&mut transport) {
2227 SyncOutcome::RealtimeUnavailable {
2228 state, reason_code, ..
2229 } => {
2230 assert_eq!(state, RealtimeState::Lost);
2231 assert_eq!(reason_code.as_deref(), Some("sync.transport_failed"));
2232 }
2233 other => panic!("expected RealtimeUnavailable, got {other:?}"),
2234 }
2235 assert_eq!(transport.sync_calls, 0);
2236 client.disconnect_realtime(&mut transport);
2237 assert_eq!(client.realtime_state(), RealtimeState::Disconnected);
2238 assert_eq!(transport.closes, 1);
2239 client.connect_realtime(&mut transport).expect("reconnect");
2240 assert_eq!(client.realtime_state(), RealtimeState::Connected);
2241 assert_eq!(transport.connects, 2);
2242 }
2243
2244 #[test]
2245 fn off_policy_uses_http_and_refuses_an_explicit_connect() {
2246 let mut client = client();
2247 client
2248 .set_realtime_policy(RealtimePolicy::Off)
2249 .expect("set policy");
2250 let mut transport = CountingRealtimeTransport::default();
2251 assert!(client.connect_realtime(&mut transport).is_err());
2252 assert_eq!(client.realtime_state(), RealtimeState::Disabled);
2253 assert!(matches!(
2254 client.sync(&mut transport),
2255 SyncOutcome::Failed { .. }
2256 ));
2257 assert_eq!(transport.sync_calls, 1);
2258 assert_eq!(transport.realtime_sync_calls, 0);
2259 let snapshot = client
2260 .diagnostics_snapshot(&ClientDiagnosticsRequest::default())
2261 .expect("diagnostics");
2262 assert_eq!(snapshot.host.realtime, RealtimeState::Disabled);
2263 assert_eq!(snapshot.host.realtime_policy, RealtimePolicy::Off);
2264 }
2265
2266 #[test]
2267 fn optional_policy_keeps_the_http_round() {
2268 let mut client = client();
2269 let mut transport = CountingRealtimeTransport::default();
2270 assert!(matches!(
2271 client.sync(&mut transport),
2272 SyncOutcome::Failed { .. }
2273 ));
2274 assert_eq!(transport.sync_calls, 1);
2275 assert_eq!(transport.realtime_sync_calls, 0);
2276 let snapshot = client
2277 .diagnostics_snapshot(&ClientDiagnosticsRequest::default())
2278 .expect("diagnostics");
2279 assert_eq!(snapshot.host.realtime, RealtimeState::Disconnected);
2280 assert_eq!(snapshot.host.realtime_policy, RealtimePolicy::Optional);
2281 }
2282
2283 #[test]
2284 fn local_rebootstrap_is_atomic_idempotent_and_preserves_offline_work() {
2285 let mut client = client();
2286 client
2287 .subscribe(
2288 "repair-tasks".to_owned(),
2289 "tasks".to_owned(),
2290 vec![("project_id".to_owned(), vec!["p1".to_owned()])],
2291 None,
2292 )
2293 .expect("subscribe");
2294 {
2295 let sub = client
2296 .subs
2297 .iter_mut()
2298 .find(|sub| sub.id == "repair-tasks")
2299 .expect("subscription");
2300 sub.cursor = 42;
2301 sub.synced_once = true;
2302 let persisted = sub.clone();
2303 client.persist_sub(&persisted).unwrap();
2304 }
2305 for table in ["_syncular_base_tasks", "tasks"] {
2306 client
2307 .conn
2308 .execute(
2309 &format!(
2310 "INSERT INTO {table}(id, project_id, _syncular_version) VALUES (?1, ?2, 1)"
2311 ),
2312 rusqlite::params!["server-row", "p1"],
2313 )
2314 .expect("seed server row");
2315 }
2316 let pending = client
2317 .mutate(vec![Mutation::Upsert {
2318 table: "tasks".to_owned(),
2319 values: Map::from_iter([
2320 ("id".to_owned(), Value::from("offline-row")),
2321 ("project_id".to_owned(), Value::from("p1")),
2322 ]),
2323 base_version: None,
2324 }])
2325 .expect("queue offline work");
2326 client.drain_change_batches();
2327 client.drain_sync_intents();
2328
2329 assert_eq!(
2330 client
2331 .rebootstrap_local_data(&LocalDataRebootstrapInput {
2332 rebootstrap_id: "support-case-001".to_owned(),
2333 })
2334 .expect("rebootstrap"),
2335 LocalDataRebootstrapResult {
2336 already_applied: false,
2337 retained_commits: 1,
2338 reset_subscriptions: 1,
2339 }
2340 );
2341 let visible_ids = client
2342 .conn
2343 .prepare("SELECT id FROM tasks ORDER BY id")
2344 .expect("prepare visible ids")
2345 .query_map([], |row| row.get::<_, String>(0))
2346 .expect("query visible ids")
2347 .collect::<Result<Vec<_>, _>>()
2348 .expect("collect visible ids");
2349 assert_eq!(visible_ids, vec!["offline-row"]);
2350 assert_eq!(client.pending_commit_ids(), vec![pending]);
2351 assert_eq!(
2352 client
2353 .subscription_state("repair-tasks")
2354 .expect("subscription")
2355 .cursor,
2356 -1
2357 );
2358 assert!(client.upgrading());
2359 assert!(client.sync_needed());
2360 assert!(matches!(
2361 client.drain_sync_intents().as_slice(),
2362 [SyncIntent::Interactive]
2363 ));
2364 assert_eq!(client.drain_change_batches().len(), 1);
2365
2366 assert_eq!(
2367 client
2368 .rebootstrap_local_data(&LocalDataRebootstrapInput {
2369 rebootstrap_id: "support-case-001".to_owned(),
2370 })
2371 .expect("idempotent retry"),
2372 LocalDataRebootstrapResult {
2373 already_applied: true,
2374 retained_commits: 1,
2375 reset_subscriptions: 1,
2376 }
2377 );
2378 assert!(client.drain_change_batches().is_empty());
2379 }
2380
2381 #[test]
2382 fn local_rebootstrap_receipt_codec_is_bounded_and_legacy_compatible() {
2383 let encoded = encode_local_rebootstrap_receipt(3, 4).expect("encode receipt");
2384 assert_eq!(
2385 decode_local_rebootstrap_receipt(&encoded).expect("decode receipt"),
2386 (3, 4)
2387 );
2388 assert_eq!(
2389 decode_local_rebootstrap_receipt("v1").expect("legacy marker"),
2390 (0, 0)
2391 );
2392 for malformed in [
2393 "",
2394 "{}",
2395 r#"{"version":3,"retainedCommits":1,"resetSubscriptions":1}"#,
2396 r#"{"version":2,"retainedCommits":1,"resetSubscriptions":1,"extra":true}"#,
2397 r#"{"version":2,"retainedCommits":9007199254740992,"resetSubscriptions":1}"#,
2398 ] {
2399 assert_eq!(
2400 decode_local_rebootstrap_receipt(malformed)
2401 .expect_err("malformed receipt must fail"),
2402 "sync.local_corrupt: persisted local rebootstrap receipt is invalid"
2403 );
2404 }
2405 }
2406
2407 #[test]
2408 fn local_rebootstrap_replays_the_original_receipt_after_reopen() {
2409 let path = std::env::temp_dir().join(format!(
2410 "syncular-rebootstrap-receipt-{}.db",
2411 uuid::Uuid::new_v4()
2412 ));
2413 let schema = json!({
2414 "version": 1,
2415 "tables": [{
2416 "name": "tasks",
2417 "primaryKey": "id",
2418 "columns": [
2419 { "name": "id", "type": "string", "nullable": false },
2420 { "name": "project_id", "type": "string", "nullable": false }
2421 ],
2422 "scopes": [{ "pattern": "project:{project_id}" }]
2423 }]
2424 });
2425 let path_string = path.to_str().expect("UTF-8 temp path");
2426
2427 {
2428 let mut first = SyncClient::open_path(
2429 "repair-restart-client".to_owned(),
2430 &schema,
2431 ClientLimits::default(),
2432 path_string,
2433 )
2434 .expect("first open");
2435 first
2436 .subscribe(
2437 "repair-tasks".to_owned(),
2438 "tasks".to_owned(),
2439 vec![("project_id".to_owned(), vec!["p1".to_owned()])],
2440 None,
2441 )
2442 .expect("subscribe");
2443 first
2444 .mutate(vec![Mutation::Upsert {
2445 table: "tasks".to_owned(),
2446 values: Map::from_iter([
2447 ("id".to_owned(), Value::from("offline-row")),
2448 ("project_id".to_owned(), Value::from("p1")),
2449 ]),
2450 base_version: None,
2451 }])
2452 .expect("queue offline work");
2453 assert_eq!(
2454 first
2455 .rebootstrap_local_data(&LocalDataRebootstrapInput {
2456 rebootstrap_id: "restart-receipt".to_owned(),
2457 })
2458 .expect("first rebootstrap"),
2459 LocalDataRebootstrapResult {
2460 already_applied: false,
2461 retained_commits: 1,
2462 reset_subscriptions: 1,
2463 }
2464 );
2465 }
2466
2467 let mut reopened = SyncClient::open_path(
2468 "repair-restart-client".to_owned(),
2469 &schema,
2470 ClientLimits::default(),
2471 path_string,
2472 )
2473 .expect("reopen");
2474 reopened.drain_change_batches();
2475 reopened.drain_sync_intents();
2476 assert_eq!(
2477 reopened
2478 .rebootstrap_local_data(&LocalDataRebootstrapInput {
2479 rebootstrap_id: "restart-receipt".to_owned(),
2480 })
2481 .expect("receipt replay"),
2482 LocalDataRebootstrapResult {
2483 already_applied: true,
2484 retained_commits: 1,
2485 reset_subscriptions: 1,
2486 }
2487 );
2488 assert!(reopened.drain_change_batches().is_empty());
2489 assert!(reopened.drain_sync_intents().is_empty());
2490 drop(reopened);
2491 std::fs::remove_file(path).expect("remove temp database");
2492 }
2493
2494 #[test]
2495 fn local_rebootstrap_fails_closed_on_malformed_or_unreadable_receipts() {
2496 let mut malformed = client();
2497 malformed
2498 .subscribe(
2499 "repair-tasks".to_owned(),
2500 "tasks".to_owned(),
2501 vec![("project_id".to_owned(), vec!["p1".to_owned()])],
2502 None,
2503 )
2504 .expect("subscribe");
2505 malformed.set_meta("localRebootstrap:malformed", "{\"version\":2}");
2506 malformed.drain_change_batches();
2507 malformed.drain_sync_intents();
2508 let malformed_error = malformed
2509 .rebootstrap_local_data(&LocalDataRebootstrapInput {
2510 rebootstrap_id: "malformed".to_owned(),
2511 })
2512 .expect_err("malformed receipt must fail closed");
2513 assert_eq!(
2514 malformed_error,
2515 "sync.local_corrupt: persisted local rebootstrap receipt is invalid"
2516 );
2517 assert!(!malformed.upgrading());
2518 assert_eq!(
2519 malformed
2520 .subscription_state("repair-tasks")
2521 .expect("unchanged subscription")
2522 .cursor,
2523 -1
2524 );
2525 assert!(malformed.drain_change_batches().is_empty());
2526 assert!(malformed.drain_sync_intents().is_empty());
2527
2528 let mut unreadable = client();
2529 unreadable
2530 .conn
2531 .execute(
2532 "INSERT INTO tasks(id, project_id, _syncular_version) VALUES (?1, ?2, 1)",
2533 rusqlite::params!["server-row", "p1"],
2534 )
2535 .expect("seed visible row");
2536 unreadable
2537 .conn
2538 .execute("DROP TABLE _syncular_meta", [])
2539 .expect("break marker storage");
2540 let unreadable_error = unreadable
2541 .rebootstrap_local_data(&LocalDataRebootstrapInput {
2542 rebootstrap_id: "unreadable".to_owned(),
2543 })
2544 .expect_err("unreadable marker storage must fail closed");
2545 assert_eq!(
2546 unreadable_error,
2547 "sync.local_corrupt: persisted local rebootstrap receipt is unreadable"
2548 );
2549 let visible_rows = unreadable
2550 .conn
2551 .query_row("SELECT COUNT(*) FROM tasks", [], |row| row.get::<_, i64>(0))
2552 .expect("visible projection remains");
2553 assert_eq!(visible_rows, 1);
2554 assert!(!unreadable.upgrading());
2555 assert!(unreadable.drain_change_batches().is_empty());
2556 assert!(unreadable.drain_sync_intents().is_empty());
2557 }
2558
2559 #[test]
2560 fn local_rebootstrap_cannot_bypass_schema_floor() {
2561 let mut client = client();
2562 client.set_schema_floor(Some(SchemaFloor {
2563 required_schema_version: Some(2),
2564 latest_schema_version: Some(2),
2565 }));
2566 let error = client
2567 .rebootstrap_local_data(&LocalDataRebootstrapInput {
2568 rebootstrap_id: "blocked-floor".to_owned(),
2569 })
2570 .expect_err("schema floor must block repair");
2571 assert!(error.contains("cannot bypass an active schema-floor stop"));
2572 }
2573
2574 #[test]
2575 fn diego_applied_sparse_moves_stay_visible_until_pull_image_arrives() {
2576 let schema = json!({"version":1,"tables":[{"name":"tasks","primaryKey":"id",
2577 "columns":[{"name":"id","type":"string","nullable":false},
2578 {"name":"project_id","type":"string","nullable":false},
2579 {"name":"starts_at_ms","type":"integer","nullable":false},
2580 {"name":"version","type":"integer","nullable":false}],
2581 "scopes":[{"pattern":"project:{project_id}"}]}]});
2582 let mut client =
2583 SyncClient::new("diego-ack-gap".into(), &schema, ClientLimits::default()).unwrap();
2584 client.set_meta(LOG_EPOCH_KEY, "epoch-1");
2585 client
2586 .write_base_row(
2587 "tasks",
2588 &vec![
2589 Some(ColumnValue::String("t1".into())),
2590 Some(ColumnValue::String("p1".into())),
2591 Some(ColumnValue::Integer(100)),
2592 Some(ColumnValue::Integer(1)),
2593 ],
2594 1,
2595 )
2596 .unwrap();
2597 client.rebuild_overlay().expect("rebuild overlay");
2598 let first = client
2599 .patch(
2600 "tasks",
2601 "t1",
2602 Map::from_iter([
2603 ("starts_at_ms".into(), json!(200)),
2604 ("version".into(), json!(2)),
2605 ]),
2606 None,
2607 )
2608 .unwrap();
2609 let (_, first_request) = client.build_request(false).unwrap();
2610 let second = client
2612 .patch(
2613 "tasks",
2614 "t1",
2615 Map::from_iter([
2616 ("starts_at_ms".into(), json!(300)),
2617 ("version".into(), json!(3)),
2618 ]),
2619 None,
2620 )
2621 .unwrap();
2622 let mut transport = HostTransport::new_from_config(&json!({})).unwrap();
2623 for (id, seq, meta) in [
2624 (first, 2, first_request),
2625 (second, 3, client.build_request(false).unwrap().1),
2626 ] {
2627 let response = Message {
2628 wire_version: WIRE_VERSION,
2629 msg_kind: MsgKind::Response,
2630 frames: vec![
2631 Frame::RespHeader {
2632 required_schema_version: None,
2633 latest_schema_version: None,
2634 log_epoch: Some("epoch-1".into()),
2635 reset_required: Some(false),
2636 },
2637 Frame::PushResult {
2638 client_commit_id: id,
2639 status: PushStatus::Applied,
2640 commit_seq: Some(seq),
2641 results: vec![OpResult::Applied { op_index: 0 }],
2642 },
2643 ],
2644 };
2645 assert!(matches!(
2646 client.process_response(&mut transport, response, &meta),
2647 SyncOutcome::Ok(_)
2648 ));
2649 let row = &client
2650 .query("SELECT starts_at_ms, version FROM tasks WHERE id='t1'", &[])
2651 .unwrap()[0];
2652 assert_eq!(
2653 row["starts_at_ms"],
2654 json!(300),
2655 "an applied ACK without a pull image must not expose the old base"
2656 );
2657 assert_eq!(
2658 row["version"],
2659 json!(3),
2660 "the queued domain version must remain monotonic"
2661 );
2662 }
2663 }
2664
2665 #[test]
2666 fn first_epoch_acquisition_preserves_tables_subscriptions_and_local_intent() {
2667 for stored in [None, Some("epoch-old")] {
2668 let mut client = client();
2669 client
2670 .subscribe(
2671 "tasks".into(),
2672 "tasks".into(),
2673 vec![("project_id".into(), vec!["p1".into()])],
2674 None,
2675 )
2676 .unwrap();
2677 client
2678 .conn
2679 .execute_batch(
2680 "CREATE TRIGGER epoch_table_identity AFTER INSERT ON tasks BEGIN SELECT 1; END",
2681 )
2682 .unwrap();
2683 let id = client
2684 .mutate(vec![Mutation::Upsert {
2685 table: "tasks".into(),
2686 values: Map::from_iter([
2687 ("id".into(), json!("offline")),
2688 ("project_id".into(), json!("p1")),
2689 ]),
2690 base_version: None,
2691 }])
2692 .unwrap();
2693 if let Some(epoch) = stored {
2694 client.set_meta(LOG_EPOCH_KEY, epoch);
2695 }
2696 let before = serde_json::to_value(client.subscription_state("tasks")).unwrap();
2697 let (_, meta) = client.build_request(false).unwrap();
2698 client.drain_change_batches();
2699 client.drain_sync_intents();
2700 let response = Message {
2701 wire_version: WIRE_VERSION,
2702 msg_kind: MsgKind::Response,
2703 frames: vec![Frame::RespHeader {
2704 required_schema_version: None,
2705 latest_schema_version: None,
2706 log_epoch: Some("epoch-new".into()),
2707 reset_required: Some(true),
2708 }],
2709 };
2710 let mut transport = HostTransport::new_from_config(&json!({})).unwrap();
2711 let outcome = client.process_response(&mut transport, response, &meta);
2712 let SyncOutcome::Ok(report) = outcome else {
2713 panic!("epoch round failed: {outcome:?}");
2714 };
2715 assert_eq!(
2716 report.resets,
2717 if stored.is_none() {
2718 Vec::<String>::new()
2719 } else {
2720 vec!["tasks".into()]
2721 }
2722 );
2723 assert_eq!(client.get_meta(LOG_EPOCH_KEY).as_deref(), Some("epoch-new"));
2724 assert_eq!(client.pending_commit_ids(), vec![id]);
2725 assert_eq!(
2726 client.query("SELECT id FROM tasks", &[]).unwrap()[0]["id"],
2727 json!("offline")
2728 );
2729 let trigger: i64 = client
2730 .conn
2731 .query_row(
2732 "SELECT count(*) FROM sqlite_master WHERE name='epoch_table_identity'",
2733 [],
2734 |row| row.get(0),
2735 )
2736 .unwrap();
2737 if stored.is_none() {
2738 assert!(!client.upgrading);
2739 assert!(client
2740 .drain_change_batches()
2741 .iter()
2742 .all(|batch| batch.status.as_ref().is_none_or(|status| !status.upgrading)));
2743 assert_eq!(
2744 serde_json::to_value(client.subscription_state("tasks")).unwrap(),
2745 before
2746 );
2747 assert_eq!(trigger, 1, "acquisition must not drop the synced table");
2748 } else {
2749 assert!(client.upgrading);
2750 assert_eq!(trigger, 0, "an epoch change must drop the old table");
2751 }
2752 assert!(matches!(
2753 client.drain_sync_intents().as_slice(),
2754 [SyncIntent::Interactive]
2755 ));
2756 }
2757 }
2758
2759 #[test]
2760 fn epoch_acquisition_storage_failures_preserve_ready_state_and_intent() {
2761 for key in ["logEpoch", "localRevision"] {
2762 let mut client = client();
2763 let id = client
2764 .mutate(vec![Mutation::Upsert {
2765 table: "tasks".into(),
2766 values: Map::from_iter([
2767 ("id".into(), json!("offline")),
2768 ("project_id".into(), json!("p1")),
2769 ]),
2770 base_version: None,
2771 }])
2772 .unwrap();
2773 let revision = client.local_revision();
2774 let needed = client.sync_needed;
2775 client.drain_change_batches();
2776 client.drain_sync_intents();
2777 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();
2778 assert!(client.run_log_epoch_reset("epoch-new").is_err());
2779 assert!(client.get_meta(LOG_EPOCH_KEY).is_none());
2780 assert!(!client.upgrading);
2781 assert_eq!(client.sync_needed, needed);
2782 assert_eq!(client.local_revision(), revision);
2783 assert_eq!(client.pending_commit_ids(), [id]);
2784 assert!(client.drain_change_batches().is_empty());
2785 assert!(client.drain_sync_intents().is_empty());
2786 }
2787 }
2788
2789 #[test]
2790 fn failed_log_epoch_resets_restore_memory_and_durable_state() {
2791 for key in [
2792 "localSchemaVersion",
2793 "localSchemaDescriptor",
2794 "logEpoch",
2795 "localRevision",
2796 ] {
2797 let mut client = client();
2798 client.set_meta(LOG_EPOCH_KEY, "epoch-old");
2799 let commit = client
2800 .mutate(vec![Mutation::Upsert {
2801 table: "tasks".into(),
2802 values: Map::from_iter([
2803 ("id".into(), json!("offline")),
2804 ("project_id".into(), json!("p1")),
2805 ]),
2806 base_version: None,
2807 }])
2808 .unwrap();
2809 client.active_round = Some(uuid::Uuid::new_v4());
2810 client.overlay_dirty.set(false);
2811 let round = client.active_round;
2812 let needed = client.sync_needed;
2813 let revision = client.local_revision();
2814 let rows = serde_json::to_value(client.read_rows("tasks").unwrap()).unwrap();
2815 client.drain_change_batches();
2816 client.drain_sync_intents();
2817 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();
2818 assert!(client.run_log_epoch_reset("epoch-new").is_err(), "{key}");
2819 assert_eq!(client.get_meta(LOG_EPOCH_KEY).as_deref(), Some("epoch-old"));
2820 assert!(!client.upgrading);
2821 assert_eq!(client.active_round, round);
2822 assert!(!client.overlay_dirty.get());
2823 assert_eq!(client.sync_needed, needed);
2824 assert_eq!(client.local_revision(), revision);
2825 assert_eq!(
2826 client.pending_commit_ids().as_slice(),
2827 std::slice::from_ref(&commit)
2828 );
2829 assert_eq!(
2830 serde_json::to_value(client.read_rows("tasks").unwrap()).unwrap(),
2831 rows
2832 );
2833 assert!(client.drain_change_batches().is_empty());
2834 assert!(client.drain_sync_intents().is_empty());
2835 assert!(client.conn.is_autocommit());
2836 client
2837 .conn
2838 .execute_batch("DROP TRIGGER fail_reset")
2839 .unwrap();
2840 client.run_log_epoch_reset("epoch-new").unwrap();
2841 assert_eq!(client.get_meta(LOG_EPOCH_KEY).as_deref(), Some("epoch-new"));
2842 assert_eq!(client.pending_commit_ids(), [commit]);
2843 }
2844 }
2845
2846 #[test]
2847 fn acknowledged_restore_and_replay_ignore_unrelated_subscription_count() {
2848 for subscriptions in [0, 2, 6, 12] {
2849 let mut client = SyncClient::new("ack-scope".into(), &json!({ "version": 1, "tables": [
2850 { "name": "tasks", "primaryKey": "id", "columns": [{ "name": "id", "type": "string", "nullable": false }, { "name": "project_id", "type": "string", "nullable": false }], "scopes": [{ "pattern": "project:{project_id}" }] },
2851 { "name": "docs", "primaryKey": "id", "columns": [{ "name": "id", "type": "string", "nullable": false }, { "name": "project_id", "type": "string", "nullable": false }], "scopes": [{ "pattern": "project:{project_id}" }] }
2852 ] }), ClientLimits::default()).unwrap();
2853 client.set_meta(LOG_EPOCH_KEY, "epoch-1");
2854 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();
2855 for index in 0..32 {
2856 client
2857 .mutate(vec![Mutation::Upsert {
2858 table: "tasks".into(),
2859 values: Map::from_iter([
2860 ("id".into(), json!(format!("task-{index}"))),
2861 ("project_id".into(), json!("p1")),
2862 ]),
2863 base_version: None,
2864 }])
2865 .unwrap();
2866 }
2867 let (_, meta) = client.build_request(false).unwrap();
2868 let mut frames = vec![Frame::RespHeader {
2869 required_schema_version: None,
2870 latest_schema_version: None,
2871 log_epoch: Some("epoch-1".into()),
2872 reset_required: Some(false),
2873 }];
2874 frames.extend(meta.pushed_ids.iter().enumerate().map(|(index, id)| {
2875 Frame::PushResult {
2876 client_commit_id: id.clone(),
2877 status: PushStatus::Applied,
2878 commit_seq: Some(index as i64 + 1),
2879 results: vec![OpResult::Applied { op_index: 0 }],
2880 }
2881 }));
2882 let mut transport = HostTransport::new_from_config(&json!({})).unwrap();
2883 client.acknowledged_replay_count.set(0);
2884 client.conn.execute("DELETE FROM ack_writes", []).unwrap();
2885 assert!(matches!(
2886 client.process_response(
2887 &mut transport,
2888 Message {
2889 wire_version: WIRE_VERSION,
2890 msg_kind: MsgKind::Response,
2891 frames
2892 },
2893 &meta
2894 ),
2895 SyncOutcome::Ok(_)
2896 ));
2897 assert_eq!(client.acknowledged_replay_count.get(), 32);
2898 assert_eq!(
2899 client
2900 .query(
2901 "SELECT count(*) AS n FROM ack_writes WHERE kind='restore'",
2902 &[]
2903 )
2904 .unwrap()[0]["n"],
2905 json!(32)
2906 );
2907 assert_eq!(
2908 client
2909 .query(
2910 "SELECT count(*) AS n FROM ack_writes WHERE kind='replay'",
2911 &[]
2912 )
2913 .unwrap()[0]["n"],
2914 json!(32)
2915 );
2916 client.conn.execute("DELETE FROM ack_writes", []).unwrap();
2917 client.acknowledged_replay_count.set(0);
2918 for index in 0..subscriptions {
2919 client
2920 .subscribe(
2921 format!("docs-{index}"),
2922 "docs".into(),
2923 vec![("project_id".into(), vec![format!("scope-{index}")])],
2924 None,
2925 )
2926 .unwrap();
2927 let sub = client.subs.len() - 1;
2928 client.subs[sub].effective =
2929 Some(vec![("project_id".into(), vec![format!("scope-{index}")])]);
2930 let segment = RowsSegment {
2931 table: "docs".into(),
2932 schema_version: 1,
2933 columns: client.schema.table("docs").unwrap().wire_columns.clone(),
2934 blocks: if index % 2 == 0 {
2935 vec![vec![ssp2::segment::SegmentRow {
2936 values: vec![
2937 Some(ColumnValue::String(format!("doc-{index}"))),
2938 Some(ColumnValue::String(format!("scope-{index}"))),
2939 ],
2940 server_version: 1,
2941 }]]
2942 } else {
2943 vec![]
2944 },
2945 };
2946 assert!(client.apply_segment(sub, &segment, true).is_ok());
2947 }
2948 assert_eq!(
2949 client
2950 .query("SELECT count(*) AS n FROM ack_writes", &[])
2951 .unwrap()[0]["n"],
2952 json!(0),
2953 "unrelated bootstrap must not restore/replay tasks"
2954 );
2955 assert_eq!(
2956 client.acknowledged_replay_count.get(),
2957 0,
2958 "unrelated bootstrap must not decode task ACKs"
2959 );
2960 assert_eq!(
2961 client
2962 .query("SELECT count(*) AS n FROM tasks", &[])
2963 .unwrap()[0]["n"],
2964 json!(32)
2965 );
2966 assert_eq!(
2967 client
2968 .query("SELECT count(*) AS n FROM _syncular_acked_rows", &[])
2969 .unwrap()[0]["n"],
2970 json!(32)
2971 );
2972 }
2973 }
2974
2975 #[test]
2976 fn batched_push_acknowledgements_rebuild_overlay_once_per_response() {
2977 let mut client = client();
2978 client.set_meta(LOG_EPOCH_KEY, "epoch-1");
2979 const COMMIT_COUNT: usize = 32;
2980
2981 for index in 0..COMMIT_COUNT {
2982 client
2983 .mutate(vec![Mutation::Upsert {
2984 table: "tasks".to_owned(),
2985 values: Map::from_iter([
2986 ("id".to_owned(), Value::from(format!("task-{index}"))),
2987 ("project_id".to_owned(), Value::from("project-1")),
2988 ]),
2989 base_version: None,
2990 }])
2991 .expect("queue commit");
2992 }
2993
2994 let (_, request_meta) = client.build_request(false).unwrap();
2995 assert_eq!(request_meta.pushed_ids.len(), COMMIT_COUNT);
2996 let mut frames = vec![Frame::RespHeader {
2997 required_schema_version: None,
2998 latest_schema_version: None,
2999 log_epoch: Some("epoch-1".to_owned()),
3000 reset_required: Some(false),
3001 }];
3002 frames.extend(request_meta.pushed_ids.iter().enumerate().map(
3003 |(index, client_commit_id)| Frame::PushResult {
3004 client_commit_id: client_commit_id.clone(),
3005 status: PushStatus::Applied,
3006 commit_seq: Some(index as i64 + 1),
3007 results: vec![OpResult::Applied { op_index: 0 }],
3008 },
3009 ));
3010 let response = Message {
3011 wire_version: WIRE_VERSION,
3012 msg_kind: MsgKind::Response,
3013 frames,
3014 };
3015 let mut transport =
3016 HostTransport::new_from_config(&json!({})).expect("no-network host transport");
3017
3018 client.overlay_rebuild_count.set(0);
3019 client.outcome_prune_count.set(0);
3020 let outcome = client.process_response(&mut transport, response, &request_meta);
3021 assert!(matches!(outcome, SyncOutcome::Ok(_)));
3022 assert!(client.pending_commit_ids().is_empty());
3023 assert_eq!(
3024 client.overlay_rebuild_count.get(),
3025 1,
3026 "one response must reconcile its acknowledged commits with one overlay rebuild"
3027 );
3028 assert_eq!(
3029 client.outcome_prune_count.get(),
3030 1,
3031 "one response must enforce outcome retention once"
3032 );
3033 }
3034
3035 #[test]
3036 fn clean_commit_mirroring_matches_full_replay_with_fts_and_unique_indexes() {
3037 let schema = json!({ "version": 1, "tables": [{ "name": "tasks", "primaryKey": "id",
3038 "columns": [
3039 {"name":"id", "type":"string", "nullable":false},
3040 {"name":"project_id", "type":"string", "nullable":false},
3041 {"name":"title", "type":"string", "nullable":false}
3042 ], "scopes": [{"pattern":"project:{project_id}"}],
3043 "indexes": [{"name":"unique_title", "columns":["title"], "unique":true}],
3044 "ftsIndexes": [{"name":"tasks_fts", "columns":["title"], "tokenize":"unicode61"}]
3045 }] });
3046 let mut incremental =
3047 SyncClient::new("mirror".to_owned(), &schema, ClientLimits::default()).unwrap();
3048 let mut reference =
3049 SyncClient::new("mirror".to_owned(), &schema, ClientLimits::default()).unwrap();
3050 let tables = vec!["tasks".to_owned()];
3051 for index in 0..32 {
3052 let id = format!("task-{}", index % 8);
3053 let values = Map::from_iter([
3054 ("id".to_owned(), Value::from(id.clone())),
3055 (
3056 "project_id".to_owned(),
3057 Value::from(format!("p{}", index % 2)),
3058 ),
3059 ("title".to_owned(), Value::from(format!("needle{index}"))),
3060 ]);
3061 let payload = encode_row_json(
3062 incremental.schema.table("tasks").unwrap(),
3063 &id,
3064 &values,
3065 &incremental.encryption,
3066 )
3067 .unwrap();
3068 let change = ssp2::model::Change {
3069 table_index: 0,
3070 row_id: id,
3071 op: if index % 5 == 0 {
3072 Op::Delete
3073 } else {
3074 Op::Upsert
3075 },
3076 row_version: Some(index + 1),
3077 scopes: vec![("project_id".to_owned(), format!("p{}", index % 2))],
3078 row: Some(payload),
3079 };
3080 reference.overlay_dirty.set(true);
3081 for instance in [&mut incremental, &mut reference] {
3082 instance.begin_observation("mirror_test").unwrap();
3083 let mut batch = ChangeAccumulator::default();
3084 instance.record_commit_changes(&mut batch, &tables, std::slice::from_ref(&change));
3085 instance
3086 .apply_commit_changes(&tables, std::slice::from_ref(&change))
3087 .unwrap();
3088 instance
3089 .rebuild_overlay_if_dirty()
3090 .expect("rebuild overlay");
3091 instance.finish_observation("mirror_test", batch).unwrap();
3092 }
3093 for sql in [
3094 "SELECT * FROM tasks ORDER BY id",
3095 "SELECT _syncular_source_id, title FROM tasks_fts ORDER BY _syncular_source_id",
3096 ] {
3097 assert_eq!(
3098 incremental.query(sql, &[]).unwrap(),
3099 reference.query(sql, &[]).unwrap()
3100 );
3101 }
3102 assert_eq!(incremental.local_revision(), reference.local_revision());
3103 assert_eq!(
3104 serde_json::to_value(incremental.drain_change_batches()).unwrap(),
3105 serde_json::to_value(reference.drain_change_batches()).unwrap()
3106 );
3107 }
3108 assert_eq!(incremental.overlay_rebuild_count.get(), 0);
3109 assert_eq!(reference.overlay_rebuild_count.get(), 32);
3110 }
3111
3112 #[test]
3113 fn row_id_lookups_preserve_text_matching_and_seek_lossless_keys() {
3114 let cases = [
3115 (
3116 "string",
3117 vec![
3118 json!("1"),
3119 json!("01"),
3120 json!(""),
3121 json!("nul\u{0}id"),
3122 json!("é"),
3123 ],
3124 ),
3125 (
3126 "integer",
3127 vec![
3128 json!(i64::MIN),
3129 json!(-1),
3130 json!(0),
3131 json!(1),
3132 json!(i64::MAX),
3133 ],
3134 ),
3135 ("boolean", vec![json!(false), json!(true)]),
3136 ("json", vec![json!("null"), json!("1"), json!("{\"a\":1}")]),
3137 ];
3138 for (kind, ids) in cases {
3139 let schema = json!({"version":1,"tables":[{"name":"tasks","primaryKey":"id",
3140 "columns":[{"name":"id","type":kind,"nullable":false},
3141 {"name":"project_id","type":"string","nullable":false}],
3142 "scopes":[{"pattern":"project:{project_id}"}]}]});
3143 let mut client =
3144 SyncClient::new("row-id-lookup".into(), &schema, ClientLimits::default()).unwrap();
3145 for (index, id) in ids.iter().enumerate() {
3146 let table = client.schema.table("tasks").unwrap();
3147 client
3148 .write_base_row(
3149 "tasks",
3150 &vec![
3151 if kind == "integer" {
3152 Some(ColumnValue::Integer(id.as_i64().unwrap()))
3155 } else {
3156 json_to_column_value(&table.columns[0], Some(id)).unwrap()
3157 },
3158 Some(ColumnValue::String(format!("p{index}"))),
3159 ],
3160 1,
3161 )
3162 .unwrap();
3163 }
3164 client.rebuild_overlay_if_dirty().expect("rebuild overlay");
3165 for base in [true, false] {
3166 let full = if base {
3167 base_table("tasks")
3168 } else {
3169 visible_table("tasks")
3170 };
3171 let predicate = row_id_predicate(client.schema.table("tasks").unwrap());
3172 for row_id in [
3173 "1",
3174 "01",
3175 "1.0",
3176 "1.0000000000000002",
3177 "+1",
3178 "1e0",
3179 " 1",
3180 "-1",
3181 "0",
3182 "9223372036854775807",
3183 "-9223372036854775808",
3184 "true",
3185 "false",
3186 "",
3187 "nul\u{0}id",
3188 "é",
3189 "null",
3190 "{\"a\":1}",
3191 ] {
3192 let old_sql =
3193 format!("SELECT project_id FROM {full} WHERE CAST(id AS TEXT) = ?1");
3194 let new_sql = format!("SELECT project_id FROM {full} WHERE {predicate}");
3195 let old = client
3196 .conn
3197 .prepare(&old_sql)
3198 .unwrap()
3199 .query_map([row_id], |row| row.get::<_, String>(0))
3200 .unwrap()
3201 .collect::<Result<Vec<_>, _>>()
3202 .unwrap();
3203 let new = client
3204 .conn
3205 .prepare_cached(&new_sql)
3206 .unwrap()
3207 .query_map([row_id], |row| row.get::<_, String>(0))
3208 .unwrap()
3209 .collect::<Result<Vec<_>, _>>()
3210 .unwrap();
3211 assert_eq!(old, new, "{kind}, base={base}, row_id={row_id:?}");
3212 let mut batch = ChangeAccumulator::default();
3213 assert_eq!(
3214 client.record_row_scopes(&mut batch, "tasks", row_id, base),
3215 !old.is_empty()
3216 );
3217 let expected = old.first().map(|scope| {
3218 BTreeMap::from([(
3219 "tasks".to_owned(),
3220 Some(BTreeSet::from([format!("project:{scope}")])),
3221 )])
3222 });
3223 assert_eq!(batch.tables, expected.unwrap_or_default());
3224 client
3225 .conn
3226 .execute_batch("SAVEPOINT lookup_delete")
3227 .unwrap();
3228 if base {
3229 client.delete_base_row("tasks", row_id).unwrap();
3230 } else {
3231 client
3232 .apply_outbox_ops(&[OutboxOp {
3233 upsert: false,
3234 table: "tasks".into(),
3235 row_id: row_id.into(),
3236 base_version: None,
3237 values: None,
3238 }])
3239 .expect("replay delete");
3240 }
3241 let remaining: i64 = client
3242 .conn
3243 .query_row(&format!("SELECT count(*) FROM {full}"), [], |row| {
3244 row.get(0)
3245 })
3246 .unwrap();
3247 assert_eq!(remaining, ids.len() as i64 - old.len() as i64);
3248 client
3249 .conn
3250 .execute_batch("ROLLBACK TO lookup_delete; RELEASE lookup_delete")
3251 .unwrap();
3252 client.overlay_dirty.set(false);
3253 }
3254 for (predicate, expected) in [
3255 ("CAST(id AS TEXT) = ?1".to_owned(), "SCAN"),
3256 (predicate, "SEARCH"),
3257 ] {
3258 let sql = format!("EXPLAIN QUERY PLAN SELECT project_id FROM {full} WHERE {predicate} LIMIT 1");
3259 let details = client
3260 .conn
3261 .prepare(&sql)
3262 .unwrap()
3263 .query_map(["1"], |row| row.get::<_, String>(3))
3264 .unwrap()
3265 .collect::<Result<Vec<_>, _>>()
3266 .unwrap();
3267 assert!(
3268 details.iter().any(|detail| detail.contains(expected)),
3269 "{kind}: {details:?}"
3270 );
3271 }
3272 }
3273 }
3274 }
3275
3276 #[test]
3277 fn primary_keys_reject_every_ineligible_column_type() {
3278 for ty in ["float", "bytes", "crdt", "blob_ref"] {
3282 let schema = json!({"version":1,"tables":[{"name":"tasks","primaryKey":"id",
3283 "columns":[{"name":"id","type":ty,"nullable":false},
3284 {"name":"project_id","type":"string","nullable":false}],
3285 "scopes":[{"pattern":"project:{project_id}"}]}]});
3286 let error =
3287 match SyncClient::new("pk-eligibility".into(), &schema, ClientLimits::default()) {
3288 Ok(_) => panic!("ineligible primary key {ty} must be rejected"),
3289 Err(error) => error,
3290 };
3291 assert!(
3292 error.contains("has an ineligible column type (§2.4)"),
3293 "{ty}: {error}"
3294 );
3295 }
3296 for ty in ["string", "integer", "boolean", "json"] {
3297 let schema = json!({"version":1,"tables":[{"name":"tasks","primaryKey":"id",
3298 "columns":[{"name":"id","type":ty,"nullable":false},
3299 {"name":"project_id","type":"string","nullable":false}],
3300 "scopes":[{"pattern":"project:{project_id}"}]}]});
3301 SyncClient::new("pk-eligibility".into(), &schema, ClientLimits::default())
3302 .unwrap_or_else(|error| panic!("{ty}: {error}"));
3303 }
3304 }
3305
3306 #[test]
3307 fn pending_row_reconciliation_matches_full_fifo_replay() {
3308 for unique in [false, true] {
3309 let schema = json!({"version": 1, "tables": (["tasks", "notes"].map(|name| json!({
3310 "name": name, "primaryKey": "id", "columns": [
3311 {"name":"id", "type":"string", "nullable":false},
3312 {"name":"project_id", "type":"string", "nullable":false},
3313 {"name":"title", "type":"string", "nullable":false}],
3314 "scopes": [{"pattern":"project:{project_id}"}],
3315 "indexes": [{"name":format!("{name}_title"), "columns":["title"], "unique":unique || name == "notes"}],
3316 "ftsIndexes": [{"name":format!("{name}_fts"), "columns":["title"], "tokenize":"unicode61"}]
3317 })))});
3318 let mut incremental =
3319 SyncClient::new("pending".into(), &schema, ClientLimits::default()).unwrap();
3320 let mut reference =
3321 SyncClient::new("pending".into(), &schema, ClientLimits::default()).unwrap();
3322 let tables = vec!["tasks".to_owned(), "notes".to_owned()];
3323 for instance in [&mut incremental, &mut reference] {
3324 for table in &tables {
3325 for index in 0..12 {
3326 instance
3327 .write_base_row(
3328 table,
3329 &vec![
3330 Some(ColumnValue::String(format!("row{index}"))),
3331 Some(ColumnValue::String("p0".into())),
3332 Some(ColumnValue::String(format!("base{index}"))),
3333 ],
3334 1,
3335 )
3336 .unwrap();
3337 }
3338 }
3339 instance
3340 .rebuild_overlay_if_dirty()
3341 .expect("rebuild overlay");
3342 }
3343 for index in 0..80 {
3344 let mutations = (0..3)
3345 .map(|offset| {
3346 let table = tables[(index + offset) % 2].clone();
3347 let id = format!("row{}", (index + offset) % 16);
3348 if (index + offset) % 7 == 0 {
3349 Mutation::Delete {
3350 table,
3351 row_id: id,
3352 base_version: None,
3353 }
3354 } else {
3355 Mutation::Upsert {
3356 table,
3357 values: Map::from_iter([
3358 ("id".into(), Value::from(id)),
3359 ("project_id".into(), Value::from(format!("p{}", index % 3))),
3360 (
3361 "title".into(),
3362 Value::from(format!("needle{}", (index + offset) % 16)),
3363 ),
3364 ]),
3365 base_version: None,
3366 }
3367 }
3368 })
3369 .collect::<Vec<_>>();
3370 for instance in [&mut incremental, &mut reference] {
3371 instance.mutate(mutations.clone()).unwrap();
3372 }
3373 let changes = (0..3)
3376 .map(|offset| {
3377 let table_index = if index % 4 == 0 { offset % 2 } else { 0 };
3378 let id = format!("row{}", (index + offset) % 16);
3379 let values = Map::from_iter([
3380 ("id".into(), Value::from(id.clone())),
3381 (
3382 "project_id".into(),
3383 Value::from(format!("p{}", (index + 1) % 3)),
3384 ),
3385 (
3386 "title".into(),
3387 Value::from(format!("remote{index}_{offset}")),
3388 ),
3389 ]);
3390 ssp2::model::Change {
3391 table_index: table_index as u16,
3392 row_id: id.clone(),
3393 op: if (index + offset) % 5 == 0 {
3394 Op::Delete
3395 } else {
3396 Op::Upsert
3397 },
3398 row_version: Some(index as i64 + 2),
3399 scopes: vec![],
3400 row: Some(
3401 encode_row_json(
3402 incremental.schema.table(&tables[table_index]).unwrap(),
3403 &id,
3404 &values,
3405 &incremental.encryption,
3406 )
3407 .unwrap(),
3408 ),
3409 }
3410 })
3411 .collect::<Vec<_>>();
3412 reference.overlay_dirty.set(true);
3413 incremental
3414 .apply_commit_frame(&tables, &changes, None)
3415 .unwrap();
3416 reference
3417 .apply_commit_frame(&tables, &changes, None)
3418 .unwrap();
3419 for table in &tables {
3420 for sql in [format!("SELECT * FROM {table} ORDER BY id"),
3421 format!("SELECT * FROM _syncular_base_{table} ORDER BY id"),
3422 format!("SELECT _syncular_source_id, title FROM {table}_fts ORDER BY _syncular_source_id"),
3423 format!("SELECT _syncular_source_id FROM {table}_fts WHERE {table}_fts MATCH 'needle1' ORDER BY _syncular_source_id")] {
3424 assert_eq!(incremental.query(&sql, &[]).unwrap(), reference.query(&sql, &[]).unwrap(), "unique={unique}, frame={index}: {sql}");
3425 }
3426 }
3427 assert_eq!(incremental.local_revision(), reference.local_revision());
3428 assert_eq!(
3429 serde_json::to_value(incremental.drain_change_batches()).unwrap(),
3430 serde_json::to_value(reference.drain_change_batches()).unwrap()
3431 );
3432 }
3433 assert_eq!(
3434 incremental.overlay_rebuild_count.get(),
3435 if unique { 81 } else { 21 }
3436 );
3437 assert_eq!(reference.overlay_rebuild_count.get(), 81);
3438 }
3439 }
3440
3441 #[test]
3442 fn remote_unique_values_reconsider_pending_writes_on_other_rows() {
3443 let schema = json!({"version": 1, "tables": [{"name":"tasks", "primaryKey":"id",
3444 "columns":[{"name":"id", "type":"string", "nullable":false},
3445 {"name":"title", "type":"string", "nullable":false}],
3446 "scopes":[], "indexes":[{"name":"unique_title", "columns":["title"], "unique":true}]}]});
3447 let mut client =
3448 SyncClient::new("unique-pending".into(), &schema, ClientLimits::default()).unwrap();
3449 for (id, title) in [("a", "original"), ("b", "occupied")] {
3450 client
3451 .write_base_row(
3452 "tasks",
3453 &vec![
3454 Some(ColumnValue::String(id.into())),
3455 Some(ColumnValue::String(title.into())),
3456 ],
3457 1,
3458 )
3459 .unwrap();
3460 }
3461 client.rebuild_overlay_if_dirty().expect("rebuild overlay");
3462 let pending = client
3463 .mutate(vec![Mutation::Upsert {
3464 table: "tasks".into(),
3465 values: Map::from_iter([
3466 ("id".into(), Value::from("a")),
3467 ("title".into(), Value::from("available")),
3468 ]),
3469 base_version: None,
3470 }])
3471 .unwrap();
3472 assert_eq!(
3473 client
3474 .conn
3475 .query_row("SELECT title FROM tasks WHERE id = 'a'", [], |row| row
3476 .get::<_, String>(
3477 0
3478 ))
3479 .unwrap(),
3480 "available"
3481 );
3482 for (title, expected) in [("available", "original"), ("occupied", "available")] {
3483 let change = ssp2::model::Change {
3484 table_index: 0,
3485 row_id: "b".into(),
3486 op: Op::Upsert,
3487 row_version: Some(2),
3488 scopes: vec![],
3489 row: Some(
3490 encode_row_json(
3491 client.schema.table("tasks").unwrap(),
3492 "b",
3493 &Map::from_iter([
3494 ("id".into(), Value::from("b")),
3495 ("title".into(), Value::from(title)),
3496 ]),
3497 &client.encryption,
3498 )
3499 .unwrap(),
3500 ),
3501 };
3502 client
3503 .apply_commit_frame(&["tasks".into()], &[change], None)
3504 .unwrap();
3505 assert_eq!(
3506 client
3507 .conn
3508 .query_row("SELECT title FROM tasks WHERE id = 'a'", [], |row| row
3509 .get::<_, String>(
3510 0
3511 ))
3512 .unwrap(),
3513 expected
3514 );
3515 assert_eq!(client.pending_commit_ids(), vec![pending.clone()]);
3516 }
3517 assert_eq!(client.overlay_rebuild_count.get(), 3);
3518 }
3519
3520 fn measure_scoped_overlay(unchanged_rows: i64, rounds: i64) -> Vec<u128> {
3521 let schema = json!({ "version": 1, "tables": [
3522 { "name":"tasks", "primaryKey":"id", "columns":[
3523 {"name":"id","type":"string","nullable":false},
3524 {"name":"project_id","type":"string","nullable":false}],
3525 "scopes":[{"pattern":"project:{project_id}"}] },
3526 { "name":"catalogue", "primaryKey":"id", "columns":[
3527 {"name":"id","type":"string","nullable":false},
3528 {"name":"project_id","type":"string","nullable":false},
3529 {"name":"title","type":"string","nullable":false}],
3530 "scopes":[{"pattern":"project:{project_id}"}],
3531 "ftsIndexes":[{"name":"catalogue_fts","columns":["title"],"tokenize":"unicode61"}] }
3532 ] });
3533 let mut client =
3534 SyncClient::new("overlay-scope".into(), &schema, ClientLimits::default()).unwrap();
3535 if unchanged_rows > 0 {
3536 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();
3537 }
3538 client.rebuild_overlay().expect("rebuild overlay");
3539 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();
3540 let mut elapsed = Vec::new();
3541 for round in 1..=rounds {
3542 let pending = client
3543 .mutate(vec![Mutation::Upsert {
3544 table: "tasks".into(),
3545 values: Map::from_iter([
3546 ("id".into(), json!("t1")),
3547 ("project_id".into(), json!("p1")),
3548 ]),
3549 base_version: None,
3550 }])
3551 .unwrap();
3552 let started = std::time::Instant::now();
3553 client
3554 .write_base_row(
3555 "tasks",
3556 &vec![
3557 Some(ColumnValue::String("t1".into())),
3558 Some(ColumnValue::String("p1".into())),
3559 ],
3560 round,
3561 )
3562 .unwrap();
3563 let mut report = SyncReport::default();
3564 client
3565 .handle_push_results(
3566 &[Frame::PushResult {
3567 client_commit_id: pending.clone(),
3568 status: PushStatus::Applied,
3569 commit_seq: Some(round),
3570 results: vec![OpResult::Applied { op_index: 0 }],
3571 }],
3572 &HashSet::from([pending.as_str()]),
3573 &HashMap::new(),
3574 &mut report,
3575 Some(&pending),
3576 )
3577 .unwrap();
3578 client.rebuild_overlay_if_dirty().expect("rebuild overlay");
3579 elapsed.push(started.elapsed().as_nanos());
3580 }
3581 assert_eq!(
3582 client
3583 .conn
3584 .query_row("SELECT count(*) FROM audit", [], |r| r.get::<_, i64>(0))
3585 .unwrap(),
3586 0,
3587 "unchanged catalogue must never be copied"
3588 );
3589 assert_eq!(
3590 client
3591 .conn
3592 .query_row(
3593 "SELECT count(*) FROM catalogue_fts WHERE catalogue_fts MATCH 'needle'",
3594 [],
3595 |r| r.get::<_, i64>(0)
3596 )
3597 .unwrap(),
3598 unchanged_rows
3599 );
3600 assert_eq!(
3601 client.query("SELECT * FROM tasks", &[]).unwrap()[0]["_syncular_version"],
3602 json!(rounds)
3603 );
3604 elapsed
3605 }
3606
3607 #[test]
3608 fn changed_overlay_leaves_large_unchanged_table_and_fts_untouched() {
3609 let elapsed = measure_scoped_overlay(100_000, 1);
3610 eprintln!(
3611 "table-scoped overlay with 100k unchanged rows: {} ns",
3612 elapsed[0]
3613 );
3614 }
3615
3616 #[test]
3617 #[ignore = "isolated performance measurement; run with the shared heavy-check lock"]
3618 fn benchmark_scoped_overlay() {
3619 for rows in [0, 100_000, 200_000] {
3620 let mut elapsed = measure_scoped_overlay(rows, 30);
3621 elapsed.sort_unstable();
3622 eprintln!(
3623 "{}",
3624 json!({"unchangedRows": rows, "rounds": elapsed.len(),
3625 "medianNs": elapsed[elapsed.len()/2], "minNs": elapsed[0],
3626 "maxNs": elapsed[elapsed.len()-1], "unchangedDeletes": 0})
3627 );
3628 }
3629 }
3630
3631 #[test]
3632 fn released_context_discards_in_flight_ack_and_server_rows() {
3633 for barrier in [
3634 "preflight",
3635 "purge",
3636 "rebootstrap",
3637 "subscription",
3638 "failed-purge-replay",
3639 "failed-purge-publish",
3640 "failed-rebootstrap",
3641 ] {
3642 let mut client = client();
3643 client.set_meta(LOG_EPOCH_KEY, "epoch");
3644 client
3645 .subscribe(
3646 "tasks".into(),
3647 "tasks".into(),
3648 vec![("project_id".into(), vec!["p1".into()])],
3649 None,
3650 )
3651 .unwrap();
3652 let commit = client
3653 .mutate(vec![Mutation::Upsert {
3654 table: "tasks".into(),
3655 values: Map::from_iter([
3656 ("id".into(), json!("local")),
3657 ("project_id".into(), json!("p1")),
3658 ]),
3659 base_version: None,
3660 }])
3661 .unwrap();
3662 let prepared = client.prepare_sync_round(false).unwrap();
3663 client.set_transport_enabled(&mut CountingRealtimeTransport::default(), false);
3664 let payload = encode_row_json(
3665 &client.schema.tables[0],
3666 "server",
3667 &Map::from_iter([
3668 ("id".into(), json!("server")),
3669 ("project_id".into(), json!("p1")),
3670 ]),
3671 &client.encryption,
3672 )
3673 .unwrap();
3674 let completed = crate::CompletedSyncRound {
3675 prepared,
3676 exchange: crate::round::ExchangeResult::Reply {
3677 transport_failed: false,
3678 downloads: Default::default(),
3679 response: Ok(Message {
3680 wire_version: WIRE_VERSION,
3681 msg_kind: MsgKind::Response,
3682 frames: vec![
3683 Frame::RespHeader {
3684 required_schema_version: None,
3685 latest_schema_version: None,
3686 log_epoch: Some("epoch".into()),
3687 reset_required: Some(false),
3688 },
3689 Frame::PushResult {
3690 client_commit_id: commit.clone(),
3691 status: PushStatus::Applied,
3692 commit_seq: Some(1),
3693 results: vec![OpResult::Applied { op_index: 0 }],
3694 },
3695 Frame::SubStart {
3696 id: "tasks".into(),
3697 status: SubStatus::Active,
3698 reason_code: String::new(),
3699 effective_scopes: vec![("project_id".into(), vec!["p1".into()])],
3700 bootstrap: true,
3701 },
3702 Frame::Commit {
3703 commit_seq: 1,
3704 created_at_ms: 0,
3705 actor_id: "actor".into(),
3706 tables: vec!["tasks".into()],
3707 changes: vec![ssp2::model::Change {
3708 table_index: 0,
3709 row_id: "server".into(),
3710 op: Op::Upsert,
3711 row_version: Some(1),
3712 scopes: vec![("project_id".into(), "p1".into())],
3713 row: Some(payload),
3714 }],
3715 },
3716 Frame::SubEnd {
3717 next_cursor: 1,
3718 bootstrap_state: None,
3719 },
3720 ],
3721 }),
3722 },
3723 };
3724 match barrier {
3725 "preflight" => client.begin_security_preflight(),
3726 "purge" => {
3727 client
3728 .purge_local_data(&LocalDataPurgeInput {
3729 purge_id: "pending-round".into(),
3730 targets: vec![LocalDataPurgeTarget {
3731 table: "tasks".into(),
3732 selectors: BTreeMap::from([(
3733 "project_id".into(),
3734 vec!["p1".into()],
3735 )]),
3736 }],
3737 })
3738 .unwrap();
3739 }
3740 "rebootstrap" => {
3741 client
3742 .rebootstrap_local_data(&LocalDataRebootstrapInput {
3743 rebootstrap_id: "pending-round".into(),
3744 })
3745 .unwrap();
3746 }
3747 "failed-purge-replay" | "failed-purge-publish" | "failed-rebootstrap" => {
3748 let trigger = if barrier == "failed-purge-replay" {
3749 "CREATE TRIGGER fail_local BEFORE DELETE ON tasks BEGIN SELECT RAISE(ABORT, 'local fault'); END"
3750 } else {
3751 "CREATE TRIGGER fail_local BEFORE INSERT ON _syncular_meta WHEN NEW.key = 'localRevision' BEGIN SELECT RAISE(ABORT, 'local fault'); END"
3752 };
3753 client.conn.execute_batch(trigger).unwrap();
3754 let revision = client.local_revision();
3755 if barrier == "failed-rebootstrap" {
3756 assert!(client
3757 .rebootstrap_local_data(&LocalDataRebootstrapInput {
3758 rebootstrap_id: "failed-round".into(),
3759 })
3760 .is_err());
3761 } else {
3762 assert!(client
3763 .purge_local_data(&LocalDataPurgeInput {
3764 purge_id: "failed-round".into(),
3765 targets: vec![LocalDataPurgeTarget {
3766 table: "tasks".into(),
3767 selectors: BTreeMap::from([(
3768 "project_id".into(),
3769 vec!["p1".into()]
3770 )]),
3771 }],
3772 })
3773 .is_err());
3774 }
3775 assert_eq!(client.local_revision(), revision);
3776 assert_eq!(client.pending_commit_ids(), vec![commit.clone()]);
3777 client
3778 .conn
3779 .execute_batch("DROP TRIGGER fail_local")
3780 .unwrap();
3781 let applied = client.apply_sync_round(completed);
3782 assert!(
3783 matches!(
3784 applied,
3785 crate::AppliedSyncRound::Complete {
3786 outcome: SyncOutcome::Ok(_),
3787 ..
3788 }
3789 ),
3790 "{barrier}"
3791 );
3792 assert!(client.pending_commit_ids().is_empty());
3793 assert_eq!(
3794 client
3795 .query("SELECT id FROM tasks WHERE id = 'server'", &[])
3796 .unwrap()
3797 .len(),
3798 1
3799 );
3800 continue;
3801 }
3802 "subscription" => {
3803 client.unsubscribe("tasks");
3804 client
3805 .subscribe(
3806 "tasks".into(),
3807 "tasks".into(),
3808 vec![("project_id".into(), vec!["p2".into()])],
3809 None,
3810 )
3811 .unwrap();
3812 }
3813 _ => unreachable!(),
3814 }
3815 let pending_before_reply = client.pending_commit_ids();
3816 let revision = client.local_revision();
3817 let applied = client.apply_sync_round(completed);
3818 assert!(
3819 matches!(applied, crate::AppliedSyncRound::Complete { outcome: SyncOutcome::Failed { ref error_code, .. }, ref controls, .. } if error_code == "client.round_cancelled" && controls.is_empty())
3820 );
3821 assert_eq!(client.local_revision(), revision);
3822 assert!(client
3823 .conn
3824 .query_row(
3825 "SELECT NOT EXISTS(SELECT 1 FROM tasks WHERE id='server')",
3826 [],
3827 |r| r.get::<_, bool>(0)
3828 )
3829 .unwrap());
3830 assert_eq!(client.pending_commit_ids(), pending_before_reply);
3831 }
3832 }
3833
3834 #[test]
3835 fn sync_round_deadline_is_anchored_once_per_round_and_carried_across_continues() {
3836 struct DeadlineRecording {
3837 budget: std::time::Duration,
3838 seen: Vec<Option<std::time::Instant>>,
3839 }
3840 impl Transport for DeadlineRecording {
3841 fn round_deadline(&self) -> Option<std::time::Duration> {
3842 Some(self.budget)
3843 }
3844 fn set_round_deadline(&mut self, deadline: Option<std::time::Instant>) {
3845 self.seen.push(deadline);
3846 }
3847 fn sync(&mut self, _: &[u8]) -> Result<Vec<u8>, TransportError> {
3848 Err(TransportError::new("transport.failed", "injected"))
3849 }
3850 fn realtime_sync(&mut self, request: &[u8]) -> Result<Vec<u8>, TransportError> {
3851 self.sync(request)
3852 }
3853 fn download_segment(
3854 &mut self,
3855 _: &SegmentRequest,
3856 _: &mut dyn FnMut(u64),
3857 ) -> Result<Vec<u8>, TransportError> {
3858 Err(TransportError::new("transport.failed", "injected"))
3859 }
3860 fn blob_upload_grant(
3861 &mut self,
3862 _: &str,
3863 _: u64,
3864 _: Option<&str>,
3865 ) -> Result<crate::BlobUploadGrant, TransportError> {
3866 Ok(crate::BlobUploadGrant::Present)
3867 }
3868 fn realtime_connect(&mut self) -> Result<(), TransportError> {
3869 Ok(())
3870 }
3871 fn realtime_send(&mut self, _: &str) -> Result<(), TransportError> {
3872 Ok(())
3873 }
3874 fn realtime_close(&mut self) -> Result<(), TransportError> {
3875 Ok(())
3876 }
3877 }
3878
3879 let schema = json!({"version":1,"tables":[{"name":"attachments","primaryKey":"id","columns":[
3880 {"name":"id","type":"string","nullable":false},
3881 {"name":"file","type":"blob_ref","nullable":true}],"scopes":[]}]});
3882 let mut client =
3883 SyncClient::new("deadline-round".into(), &schema, Default::default()).unwrap();
3884 client.set_meta(LOG_EPOCH_KEY, "epoch");
3885 client
3886 .upload_blob(b"pending upload", Some("text/plain".into()), None)
3887 .unwrap();
3888
3889 let mut transport = DeadlineRecording {
3890 budget: std::time::Duration::from_secs(30),
3891 seen: Vec::new(),
3892 };
3893 let prepared = client.prepare_sync_round(false).unwrap();
3894 assert_eq!(prepared.uploads.len(), 1, "one staged upload");
3895 let prepared = match client.apply_sync_round(prepared.exchange(&mut transport)) {
3897 crate::AppliedSyncRound::Continue(prepared) => prepared,
3898 crate::AppliedSyncRound::Complete { .. } => panic!("the upload must continue"),
3899 };
3900 let completed = prepared.exchange(&mut transport);
3902 let anchor = completed
3903 .prepared
3904 .round_deadline_at
3905 .expect("the upload round anchors a deadline");
3906 assert!(matches!(
3907 client.apply_sync_round(completed),
3908 crate::AppliedSyncRound::Complete { .. }
3909 ));
3910 assert_eq!(transport.seen.len(), 4, "one scope per exchange");
3911 assert_eq!(
3912 transport.seen[0],
3913 Some(anchor),
3914 "the upload anchors the round"
3915 );
3916 assert_eq!(
3917 transport.seen[1], None,
3918 "the upload exchange clears its scope"
3919 );
3920 assert_eq!(
3921 transport.seen[2],
3922 Some(anchor),
3923 "a continuation reuses the carried anchor instead of refreshing it"
3924 );
3925 assert_eq!(transport.seen[3], None, "the continuation clears its scope");
3926
3927 transport.budget = std::time::Duration::from_secs(60);
3930 let prepared = client.prepare_sync_round(false).unwrap();
3931 let completed = prepared.exchange(&mut transport);
3932 let fresh = completed
3933 .prepared
3934 .round_deadline_at
3935 .expect("the next round anchors a deadline");
3936 client.apply_sync_round(completed);
3937 assert!(
3938 fresh >= anchor + std::time::Duration::from_secs(30),
3939 "the next round anchors a fresh deadline from the new budget"
3940 );
3941 assert_eq!(transport.seen[4], Some(fresh));
3942 assert_eq!(
3943 transport.seen[5], None,
3944 "the new round clears its scope too"
3945 );
3946 }
3947
3948 #[derive(Default)]
3951 struct RoundDeadlineProbe {
3952 budget: Option<std::time::Duration>,
3953 budget_queries: std::cell::Cell<usize>,
3954 scoped: Vec<Option<std::time::Instant>>,
3955 }
3956
3957 impl Transport for RoundDeadlineProbe {
3958 fn round_deadline(&self) -> Option<std::time::Duration> {
3959 self.budget_queries.set(self.budget_queries.get() + 1);
3960 self.budget
3961 }
3962 fn set_round_deadline(&mut self, deadline: Option<std::time::Instant>) {
3963 self.scoped.push(deadline);
3964 }
3965 fn sync(&mut self, _: &[u8]) -> Result<Vec<u8>, TransportError> {
3966 Err(TransportError::new("transport.failed", "injected"))
3967 }
3968 fn realtime_sync(&mut self, request: &[u8]) -> Result<Vec<u8>, TransportError> {
3969 self.sync(request)
3970 }
3971 fn download_segment(
3972 &mut self,
3973 _: &SegmentRequest,
3974 _: &mut dyn FnMut(u64),
3975 ) -> Result<Vec<u8>, TransportError> {
3976 Err(TransportError::new("transport.failed", "injected"))
3977 }
3978 fn blob_upload_grant(
3979 &mut self,
3980 _: &str,
3981 _: u64,
3982 _: Option<&str>,
3983 ) -> Result<crate::BlobUploadGrant, TransportError> {
3984 Ok(crate::BlobUploadGrant::Present)
3985 }
3986 fn realtime_connect(&mut self) -> Result<(), TransportError> {
3987 Ok(())
3988 }
3989 fn realtime_send(&mut self, _: &str) -> Result<(), TransportError> {
3990 Ok(())
3991 }
3992 fn realtime_close(&mut self) -> Result<(), TransportError> {
3993 Ok(())
3994 }
3995 }
3996
3997 #[test]
3998 fn an_unusable_upload_body_does_not_anchor_the_round_deadline() {
3999 let schema = json!({"version":1,"tables":[{"name":"attachments","primaryKey":"id","columns":[
4000 {"name":"id","type":"string","nullable":false},
4001 {"name":"file","type":"blob_ref","nullable":true}],"scopes":[]}]});
4002 let mut client =
4003 SyncClient::new("deadline-unusable".into(), &schema, ClientLimits::default()).unwrap();
4004 client.set_meta(LOG_EPOCH_KEY, "epoch");
4005 client
4006 .upload_blob(b"pending upload", Some("text/plain".into()), None)
4007 .unwrap();
4008 client
4009 .conn
4010 .execute_batch("DELETE FROM _syncular_blobs")
4011 .unwrap();
4012 let prepared = client.prepare_sync_round(false).unwrap();
4013 assert!(
4014 prepared.uploads.front().is_some_and(|u| u.bytes.is_err()),
4015 "the staged body must fail locally"
4016 );
4017 let mut transport = RoundDeadlineProbe {
4018 budget: Some(std::time::Duration::from_secs(30)),
4019 ..Default::default()
4020 };
4021 let completed = prepared.exchange(&mut transport);
4022 assert!(matches!(
4023 completed.exchange,
4024 crate::round::ExchangeResult::Upload { result: Err(_), .. }
4025 ));
4026 assert_eq!(
4027 transport.budget_queries.get(),
4028 0,
4029 "a round with no network work never asks for a budget"
4030 );
4031 assert_eq!(transport.scoped, vec![None]);
4032 }
4033
4034 #[test]
4035 fn a_transport_budget_the_clock_cannot_represent_fails_before_network() {
4036 struct HugeBudget;
4037 impl Transport for HugeBudget {
4038 fn round_deadline(&self) -> Option<std::time::Duration> {
4039 Some(std::time::Duration::MAX)
4040 }
4041 fn sync(&mut self, _: &[u8]) -> Result<Vec<u8>, TransportError> {
4042 panic!("no network for an invalid budget")
4043 }
4044 fn realtime_sync(&mut self, request: &[u8]) -> Result<Vec<u8>, TransportError> {
4045 self.sync(request)
4046 }
4047 fn download_segment(
4048 &mut self,
4049 _: &SegmentRequest,
4050 _: &mut dyn FnMut(u64),
4051 ) -> Result<Vec<u8>, TransportError> {
4052 panic!("no network for an invalid budget")
4053 }
4054 fn realtime_connect(&mut self) -> Result<(), TransportError> {
4055 Ok(())
4056 }
4057 fn realtime_send(&mut self, _: &str) -> Result<(), TransportError> {
4058 Ok(())
4059 }
4060 fn realtime_close(&mut self) -> Result<(), TransportError> {
4061 Ok(())
4062 }
4063 }
4064
4065 let mut client = client();
4066 client.set_meta(LOG_EPOCH_KEY, "epoch");
4067 let prepared = client.prepare_sync_round(false).unwrap();
4068 let outcome = match client.apply_sync_round(prepared.exchange(&mut HugeBudget)) {
4069 crate::AppliedSyncRound::Complete { outcome, .. } => outcome,
4070 crate::AppliedSyncRound::Continue(_) => {
4071 panic!("an unrepresentable budget must not continue")
4072 }
4073 };
4074 match outcome {
4075 SyncOutcome::Failed {
4076 error_code,
4077 message,
4078 ..
4079 } => {
4080 assert_eq!(error_code, "sync.invalid_request");
4081 assert_eq!(message, "transport round deadline is out of range");
4082 }
4083 _ => panic!("an unrepresentable budget must fail synchronously"),
4084 }
4085 }
4086
4087 #[test]
4088 fn pending_row_reconciliation_failure_preserves_queue_and_frame_state() {
4089 for failure in ["visible", "revision", "commit"] {
4090 let mut client = client();
4091 let id = client
4092 .mutate(vec![Mutation::Upsert {
4093 table: "tasks".into(),
4094 values: Map::from_iter([
4095 ("id".into(), Value::from("t1")),
4096 ("project_id".into(), Value::from("pending")),
4097 ]),
4098 base_version: None,
4099 }])
4100 .unwrap();
4101 client.drain_change_batches();
4102 let revision = client.local_revision();
4103 let before = client.query("SELECT * FROM tasks", &[]).unwrap();
4104 let trigger = match failure {
4105 "visible" => "CREATE TRIGGER fail_pending BEFORE INSERT ON tasks WHEN NEW.project_id = 'remote' BEGIN SELECT RAISE(ABORT, 'injected visible failure'); END".to_owned(),
4106 "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"),
4107 _ => "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(),
4108 };
4109 client.conn.execute_batch(&trigger).unwrap();
4110 let change = ssp2::model::Change {
4111 table_index: 0,
4112 row_id: "t1".into(),
4113 op: Op::Upsert,
4114 row_version: Some(2),
4115 scopes: vec![],
4116 row: Some(
4117 encode_row_json(
4118 client.schema.table("tasks").unwrap(),
4119 "t1",
4120 &Map::from_iter([
4121 ("id".into(), Value::from("t1")),
4122 ("project_id".into(), Value::from("remote")),
4123 ]),
4124 &client.encryption,
4125 )
4126 .unwrap(),
4127 ),
4128 };
4129 assert!(client
4130 .apply_commit_frame(&["tasks".into()], std::slice::from_ref(&change), None)
4131 .is_err());
4132 assert!(client.conn.is_autocommit());
4133 assert!(!client.overlay_dirty.get());
4134 assert_eq!(client.local_revision(), revision);
4135 assert!(client.drain_change_batches().is_empty());
4136 assert_eq!(client.pending_commit_ids(), vec![id.clone()]);
4137 assert_eq!(client.query("SELECT * FROM tasks", &[]).unwrap(), before);
4138 assert!(client
4139 .query("SELECT * FROM _syncular_base_tasks", &[])
4140 .unwrap()
4141 .is_empty());
4142 client
4143 .conn
4144 .execute_batch("DROP TRIGGER fail_pending")
4145 .unwrap();
4146 client
4147 .apply_commit_frame(&["tasks".into()], &[change], None)
4148 .unwrap();
4149 assert_eq!(client.local_revision(), revision + 1);
4150 assert_eq!(client.pending_commit_ids(), vec![id]);
4151 let mut expected = before[0].clone();
4154 expected.insert("_syncular_version".to_owned(), Value::from(2));
4155 assert_eq!(
4156 client.query("SELECT * FROM tasks", &[]).unwrap(),
4157 vec![expected]
4158 );
4159 assert_eq!(
4160 client
4161 .conn
4162 .query_row("SELECT project_id FROM _syncular_base_tasks", [], |row| row
4163 .get::<_, String>(0))
4164 .unwrap(),
4165 "remote"
4166 );
4167 }
4168 }
4169
4170 #[test]
4171 fn appended_overlay_matches_full_fifo_replay_with_constraints_and_fts() {
4172 let schema = json!({"version": 1, "tables": [{"name": "tasks", "primaryKey": "id",
4173 "columns": [
4174 {"name":"id", "type":"string", "nullable":false},
4175 {"name":"project_id", "type":"string", "nullable":false},
4176 {"name":"title", "type":"string", "nullable":false}],
4177 "scopes": [{"pattern":"project:{project_id}"}],
4178 "indexes": [{"name":"unique_title", "columns":["title"], "unique":true}],
4179 "ftsIndexes": [{"name":"tasks_fts", "columns":["title"], "tokenize":"unicode61"}]}]});
4180 let mut incremental =
4181 SyncClient::new("append".to_owned(), &schema, ClientLimits::default()).unwrap();
4182 let mut reference =
4183 SyncClient::new("append".to_owned(), &schema, ClientLimits::default()).unwrap();
4184 for instance in [&mut incremental, &mut reference] {
4185 for index in 0..16 {
4186 instance
4187 .write_base_row(
4188 "tasks",
4189 &vec![
4190 Some(ColumnValue::String(format!("task{index}"))),
4191 Some(ColumnValue::String("p0".to_owned())),
4192 Some(ColumnValue::String(format!("base{index}"))),
4193 ],
4194 1,
4195 )
4196 .unwrap();
4197 }
4198 instance
4199 .rebuild_overlay_if_dirty()
4200 .expect("rebuild overlay");
4201 instance.overlay_rebuild_count.set(0);
4202 }
4203 for index in 0..100 {
4204 let mutations = (0..1 + index % 4)
4205 .map(|offset| {
4206 let id = format!("task{}", (index + offset) % 24);
4207 if (index + offset) % 7 == 0 {
4208 Mutation::Delete {
4209 table: "tasks".to_owned(),
4210 row_id: id,
4211 base_version: None,
4212 }
4213 } else {
4214 Mutation::Upsert {
4215 table: "tasks".to_owned(),
4216 values: Map::from_iter([
4217 ("id".to_owned(), Value::from(id)),
4218 (
4219 "project_id".to_owned(),
4220 Value::from(format!("p{}", index % 2)),
4221 ),
4222 (
4223 "title".to_owned(),
4224 Value::from(format!("needle{}", (index + offset) % 24)),
4225 ),
4226 ]),
4227 base_version: None,
4228 }
4229 }
4230 })
4231 .collect::<Vec<_>>();
4232 reference.overlay_dirty.set(true);
4233 incremental.mutate(mutations.clone()).unwrap();
4234 reference.mutate(mutations).unwrap();
4235 for sql in ["SELECT * FROM tasks ORDER BY id", "SELECT _syncular_source_id, title FROM tasks_fts ORDER BY _syncular_source_id",
4236 "SELECT _syncular_source_id FROM tasks_fts WHERE tasks_fts MATCH 'needle1' ORDER BY _syncular_source_id"] {
4237 assert_eq!(incremental.query(sql, &[]).unwrap(), reference.query(sql, &[]).unwrap(), "commit {index}: {sql}");
4238 }
4239 assert_eq!(incremental.local_revision(), reference.local_revision());
4240 assert_eq!(
4241 serde_json::to_value(incremental.drain_change_batches()).unwrap(),
4242 serde_json::to_value(reference.drain_change_batches()).unwrap()
4243 );
4244 }
4245 assert_eq!(incremental.outbox.len(), 100);
4246 assert_eq!(incremental.overlay_rebuild_count.get(), 0);
4247 assert_eq!(reference.overlay_rebuild_count.get(), 100);
4248 }
4249
4250 #[test]
4251 fn failed_append_rolls_back_durable_queue_visible_rows_and_memory() {
4252 for fail_at in ["outbox", "revision"] {
4253 let mut client = client();
4254 let values = Map::from_iter([
4255 ("id".to_owned(), Value::from("t1")),
4256 ("project_id".to_owned(), Value::from("p1")),
4257 ]);
4258 let mutation = Mutation::Upsert {
4259 table: "tasks".to_owned(),
4260 values,
4261 base_version: None,
4262 };
4263 let trigger = if fail_at == "outbox" {
4264 "CREATE TRIGGER fail_append BEFORE INSERT ON _syncular_outbox BEGIN SELECT RAISE(FAIL, 'injected outbox failure'); END".to_owned()
4265 } else {
4266 format!("CREATE TRIGGER fail_append BEFORE INSERT ON _syncular_meta WHEN NEW.key = '{LOCAL_REVISION_KEY}' BEGIN SELECT RAISE(FAIL, 'injected revision failure'); END")
4267 };
4268 client.conn.execute_batch(&trigger).unwrap();
4269 let revision = client.local_revision();
4270 assert!(client.mutate(vec![mutation.clone()]).is_err(), "{fail_at}");
4271 assert_eq!(client.local_revision(), revision);
4272 assert!(client.outbox.is_empty());
4273 assert!(client.query("SELECT * FROM tasks", &[]).unwrap().is_empty());
4274 assert_eq!(
4275 client
4276 .conn
4277 .query_row("SELECT count(*) FROM _syncular_outbox", [], |row| row
4278 .get::<_, i64>(0))
4279 .unwrap(),
4280 0
4281 );
4282 assert!(client.drain_change_batches().is_empty());
4283 assert!(!client.overlay_dirty.get());
4284 client
4285 .conn
4286 .execute_batch("DROP TRIGGER fail_append")
4287 .unwrap();
4288 client.mutate(vec![mutation]).unwrap();
4289 assert_eq!(client.outbox.len(), 1);
4290 assert_eq!(client.query("SELECT * FROM tasks", &[]).unwrap().len(), 1);
4291 }
4292 }
4293
4294 #[test]
4295 fn mirrored_commit_failure_rolls_back_base_visible_and_revision() {
4296 let mut client = client();
4297 let tables = vec!["tasks".to_owned(), "missing".to_owned()];
4298 let values = Map::from_iter([
4299 ("id".to_owned(), Value::from("t1")),
4300 ("project_id".to_owned(), Value::from("p1")),
4301 ]);
4302 let payload = encode_row_json(
4303 client.schema.table("tasks").unwrap(),
4304 "t1",
4305 &values,
4306 &client.encryption,
4307 )
4308 .unwrap();
4309 let first = ssp2::model::Change {
4310 table_index: 0,
4311 row_id: "t1".to_owned(),
4312 op: Op::Upsert,
4313 row_version: Some(1),
4314 scopes: vec![("project_id".to_owned(), "p1".to_owned())],
4315 row: Some(payload),
4316 };
4317 let mut invalid = first.clone();
4318 invalid.table_index = 1;
4319 let revision = client.local_revision();
4320 client.begin_observation("failed_mirror").unwrap();
4321 assert!(client
4322 .apply_commit_changes(&tables, &[first, invalid])
4323 .is_err());
4324 client.rollback_observation("failed_mirror");
4325 client.rebuild_overlay_if_dirty().expect("rebuild overlay");
4326 assert!(client.query("SELECT * FROM tasks", &[]).unwrap().is_empty());
4327 assert_eq!(
4328 client
4329 .conn
4330 .query_row("SELECT count(*) FROM _syncular_base_tasks", [], |row| row
4331 .get::<_, i64>(
4332 0
4333 ))
4334 .unwrap(),
4335 0
4336 );
4337 assert_eq!(client.local_revision(), revision);
4338 assert!(client.drain_change_batches().is_empty());
4339 }
4340
4341 #[test]
4342 fn successful_acknowledgement_runs_are_atomic_and_preserve_completed_runs() {
4343 for failure in ["journal", "revision", "commit", "later-frame"] {
4344 let mut client = client();
4345 client.set_meta(LOG_EPOCH_KEY, "epoch-1");
4346 for id in ["first", "second"] {
4347 client
4348 .mutate(vec![Mutation::Upsert {
4349 table: "tasks".to_owned(),
4350 values: Map::from_iter([
4351 ("id".to_owned(), Value::from(id)),
4352 ("project_id".to_owned(), Value::from("p1")),
4353 ]),
4354 base_version: None,
4355 }])
4356 .unwrap();
4357 }
4358 let (_, meta) = client.build_request(false).unwrap();
4359 client
4361 .mutate(vec![Mutation::Upsert {
4362 table: "tasks".to_owned(),
4363 values: Map::from_iter([
4364 ("id".to_owned(), Value::from("later")),
4365 ("project_id".to_owned(), Value::from("p1")),
4366 ]),
4367 base_version: None,
4368 }])
4369 .unwrap();
4370 let ids = client.pending_commit_ids();
4371 let rows = client
4372 .query("SELECT * FROM tasks ORDER BY id", &[])
4373 .unwrap();
4374 let revision = client.local_revision();
4375 client.drain_change_batches();
4376 if failure != "later-frame" {
4377 client.conn.execute_batch(match failure {
4378 "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",
4379 "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",
4380 _ => "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",
4381 }).unwrap();
4382 }
4383 let mut response = Message {
4384 wire_version: WIRE_VERSION,
4385 msg_kind: MsgKind::Response,
4386 frames: vec![Frame::RespHeader {
4387 required_schema_version: None,
4388 latest_schema_version: None,
4389 log_epoch: Some("epoch-1".to_owned()),
4390 reset_required: Some(false),
4391 }],
4392 };
4393 for id in [&ids[0], &ids[0], "unknown", &ids[1], &ids[2]] {
4394 response.frames.push(Frame::PushResult {
4395 client_commit_id: id.to_owned(),
4396 status: PushStatus::Applied,
4397 commit_seq: Some(1),
4398 results: vec![OpResult::Applied { op_index: 0 }],
4399 });
4400 }
4401 if failure == "later-frame" {
4402 response.frames.push(Frame::Error {
4403 code: "sync.invalid_request".to_owned(),
4404 message: "later error".to_owned(),
4405 category: "protocol".to_owned(),
4406 retryable: false,
4407 recommended_action: "retry".to_owned(),
4408 details: None,
4409 });
4410 }
4411 let mut transport = HostTransport::new_from_config(&json!({})).unwrap();
4412 let outcome = client.process_response(&mut transport, response.clone(), &meta);
4413 assert!(
4414 matches!(outcome, SyncOutcome::Failed { error_code, .. } if error_code == if failure == "later-frame" { "sync.invalid_request" } else { "client.outcome_persistence_failed" })
4415 );
4416 if failure == "later-frame" {
4417 assert_eq!(client.pending_commit_ids(), ids[2..]);
4418 assert!(client.commit_outcome(&ids[0]).unwrap().is_some());
4419 assert!(client.commit_outcome(&ids[1]).unwrap().is_some());
4420 let batches = client.drain_change_batches();
4421 assert_eq!(batches.len(), 1);
4422 assert_eq!(batches[0].status.as_ref().unwrap().outbox, 1);
4423 assert_eq!(client.local_revision(), revision + 1);
4424 response.frames.pop();
4425 } else {
4426 assert_eq!(client.pending_commit_ids(), ids);
4427 assert!(client.commit_outcome(&ids[0]).unwrap().is_none());
4428 assert!(client.commit_outcome(&ids[1]).unwrap().is_none());
4429 assert_eq!(client.local_revision(), revision);
4430 assert!(client.drain_change_batches().is_empty());
4431 assert_eq!(
4432 client
4433 .query("SELECT * FROM tasks ORDER BY id", &[])
4434 .unwrap(),
4435 rows
4436 );
4437 client.conn.execute_batch("DROP TRIGGER fail_run").unwrap();
4438 }
4439 for frame in &mut response.frames {
4440 if let Frame::PushResult { status, .. } = frame {
4441 *status = PushStatus::Cached;
4442 }
4443 }
4444 let SyncOutcome::Ok(report) = client.process_response(&mut transport, response, &meta)
4445 else {
4446 panic!("retry failed");
4447 };
4448 assert_eq!(
4449 report.applied,
4450 if failure == "later-frame" {
4451 vec![]
4452 } else {
4453 ids[..2].to_vec()
4454 }
4455 );
4456 assert_eq!(client.pending_commit_ids(), ids[2..]);
4457 let batches = client.drain_change_batches();
4458 assert_eq!(batches.len(), usize::from(failure != "later-frame"));
4459 if let Some(batch) = batches.first() {
4460 assert_eq!(batch.status.as_ref().unwrap().outbox, 1);
4461 }
4462 assert_eq!(client.local_revision(), revision + 1);
4463 }
4464 }
4465
4466 #[test]
4467 fn failed_push_result_preserves_memory_disk_and_retry_identity() {
4468 for failure in ["revision", "journal", "commit"] {
4469 for status in ["applied", "cached", "rejected", "conflict"] {
4470 let mut client = client();
4471 client.set_meta(LOG_EPOCH_KEY, "epoch-1");
4472 for id in ["first", "later"] {
4473 client
4474 .mutate(vec![Mutation::Upsert {
4475 table: "tasks".to_owned(),
4476 values: Map::from_iter([
4477 ("id".to_owned(), Value::from(id)),
4478 ("project_id".to_owned(), Value::from("p1")),
4479 ]),
4480 base_version: None,
4481 }])
4482 .unwrap();
4483 }
4484 let (_, meta) = client.build_request(false).unwrap();
4485 let ids = client.pending_commit_ids();
4486 let rows = client
4487 .query("SELECT * FROM tasks ORDER BY id", &[])
4488 .unwrap();
4489 let revision = client.local_revision();
4490 client.drain_change_batches();
4491 let (push_status, results) = match status {
4492 "applied" => (PushStatus::Applied, vec![OpResult::Applied { op_index: 0 }]),
4493 "cached" => (PushStatus::Cached, vec![OpResult::Applied { op_index: 0 }]),
4494 "conflict" => (
4495 PushStatus::Rejected,
4496 vec![OpResult::Conflict {
4497 op_index: 0,
4498 code: "sync.version_conflict".to_owned(),
4499 message: "conflict".to_owned(),
4500 server_version: 1,
4501 server_row: encode_row_json(
4502 client.schema.table("tasks").unwrap(),
4503 "first",
4504 &Map::from_iter([
4505 ("id".to_owned(), Value::from("first")),
4506 ("project_id".to_owned(), Value::from("p1")),
4507 ]),
4508 &client.encryption,
4509 )
4510 .unwrap(),
4511 conflict_columns: Vec::new(),
4512 }],
4513 ),
4514 _ => (
4515 PushStatus::Rejected,
4516 vec![OpResult::Error {
4517 op_index: 0,
4518 code: "sync.validation_failed".to_owned(),
4519 message: "rejected".to_owned(),
4520 retryable: false,
4521 }],
4522 ),
4523 };
4524 let response = Message {
4525 wire_version: WIRE_VERSION,
4526 msg_kind: MsgKind::Response,
4527 frames: vec![
4528 Frame::RespHeader {
4529 required_schema_version: None,
4530 latest_schema_version: None,
4531 log_epoch: Some("epoch-1".to_owned()),
4532 reset_required: Some(false),
4533 },
4534 Frame::PushResult {
4535 client_commit_id: ids[0].clone(),
4536 status: push_status,
4537 commit_seq: Some(1),
4538 results,
4539 },
4540 ],
4541 };
4542 client.conn.execute_batch(match failure {
4543 "journal" => "CREATE TRIGGER fail_ack BEFORE INSERT ON _syncular_commit_outcomes BEGIN SELECT RAISE(FAIL, 'injected journal failure'); END",
4544 "revision" => "CREATE TRIGGER fail_ack BEFORE INSERT ON _syncular_meta WHEN NEW.key = 'localRevision' BEGIN SELECT RAISE(FAIL, 'injected revision failure'); END",
4545 _ => "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",
4546 }).unwrap();
4547 let mut transport = HostTransport::new_from_config(&json!({})).unwrap();
4548 let outcome = client.process_response(&mut transport, response.clone(), &meta);
4549 assert_eq!(
4550 client.pending_commit_ids(),
4551 ids,
4552 "{failure}/{status}: pending identity"
4553 );
4554 assert_eq!(
4555 client.local_revision(),
4556 revision,
4557 "{failure}/{status}: revision"
4558 );
4559 assert!(client.commit_outcome(&ids[0]).unwrap().is_none());
4560 assert!(client.conflicts().is_empty());
4561 assert!(client.rejections().is_empty());
4562 assert!(client.drain_change_batches().is_empty());
4563 assert_eq!(
4564 client
4565 .query("SELECT * FROM tasks ORDER BY id", &[])
4566 .unwrap(),
4567 rows
4568 );
4569 assert!(
4570 matches!(outcome, SyncOutcome::Failed { error_code, .. } if error_code == "client.outcome_persistence_failed"),
4571 "{failure}/{status}: failed round"
4572 );
4573 client.conn.execute_batch("DROP TRIGGER fail_ack").unwrap();
4574 assert!(matches!(
4575 client.process_response(&mut transport, response, &meta),
4576 SyncOutcome::Ok(_)
4577 ));
4578 assert_eq!(client.pending_commit_ids(), ids[1..]);
4579 assert!(client.commit_outcome(&ids[0]).unwrap().is_some());
4580 assert_eq!(client.drain_change_batches().len(), 1);
4581 }
4582 }
4583 }
4584
4585 #[test]
4586 fn mixed_push_results_reconcile_each_boundary_and_prune_once_per_response() {
4587 let mut client = client();
4588 client.set_meta(LOG_EPOCH_KEY, "epoch-1");
4589 for index in 0..4 {
4590 client
4591 .mutate(vec![Mutation::Upsert {
4592 table: "tasks".to_owned(),
4593 values: Map::from_iter([
4594 ("id".to_owned(), Value::from(format!("task-{index}"))),
4595 ("project_id".to_owned(), Value::from("project-1")),
4596 ]),
4597 base_version: None,
4598 }])
4599 .expect("queue commit");
4600 }
4601
4602 let (_, request_meta) = client.build_request(false).unwrap();
4603 let ids = &request_meta.pushed_ids;
4604 assert_eq!(ids.len(), 4);
4605 let response = Message {
4606 wire_version: WIRE_VERSION,
4607 msg_kind: MsgKind::Response,
4608 frames: vec![
4609 Frame::RespHeader {
4610 required_schema_version: None,
4611 latest_schema_version: None,
4612 log_epoch: Some("epoch-1".to_owned()),
4613 reset_required: Some(false),
4614 },
4615 Frame::PushResult {
4616 client_commit_id: ids[0].clone(),
4617 status: PushStatus::Applied,
4618 commit_seq: Some(1),
4619 results: vec![OpResult::Applied { op_index: 0 }],
4620 },
4621 Frame::PushResult {
4622 client_commit_id: ids[1].clone(),
4623 status: PushStatus::Cached,
4624 commit_seq: Some(2),
4625 results: vec![OpResult::Applied { op_index: 0 }],
4626 },
4627 Frame::PushResult {
4628 client_commit_id: ids[2].clone(),
4629 status: PushStatus::Rejected,
4630 commit_seq: None,
4631 results: vec![OpResult::Error {
4632 op_index: 0,
4633 code: "sync.validation_failed".to_owned(),
4634 message: "rejected".to_owned(),
4635 retryable: false,
4636 }],
4637 },
4638 Frame::PushResult {
4639 client_commit_id: ids[3].clone(),
4640 status: PushStatus::Rejected,
4641 commit_seq: None,
4642 results: vec![OpResult::Error {
4643 op_index: 0,
4644 code: "sync.idempotency_cache_miss".to_owned(),
4645 message: "retry".to_owned(),
4646 retryable: true,
4647 }],
4648 },
4649 ],
4650 };
4651 let mut transport =
4652 HostTransport::new_from_config(&json!({})).expect("no-network host transport");
4653
4654 client.overlay_rebuild_count.set(0);
4655 client.outcome_prune_count.set(0);
4656 let outcome = client.process_response(&mut transport, response, &request_meta);
4657 let SyncOutcome::Ok(report) = outcome else {
4658 panic!("mixed push-result response failed");
4659 };
4660
4661 assert_eq!(report.applied, ids[..2]);
4662 assert_eq!(report.rejected, ids[2..3]);
4663 assert_eq!(report.retryable, ids[3..4]);
4664 assert_eq!(client.pending_commit_ids(), ids[3..4]);
4665 assert_eq!(
4666 client
4667 .query("SELECT id FROM tasks ORDER BY id", &[])
4668 .expect("query visible overlay"),
4669 ["task-0", "task-1", "task-3"]
4670 .map(|id| Map::from_iter([("id".to_owned(), Value::from(id))]))
4671 .to_vec()
4672 );
4673 assert_eq!(client.overlay_rebuild_count.get(), 2);
4675 assert_eq!(client.outcome_prune_count.get(), 1);
4676 }
4677
4678 fn replay_schema(fts: bool) -> Value {
4679 json!({"version":1,"tables":[{"name":"tasks","primaryKey":"id","columns":[
4680 {"name":"id","type":"string","nullable":false},
4681 {"name":"project_id","type":"string","nullable":false},
4682 {"name":"title","type":"string","nullable":false},
4683 {"name":"done","type":"boolean","nullable":false}
4684 ],"scopes":[{"pattern":"project:{project_id}"}], "ftsIndexes": if fts { json!([{"name":"tasks_fts","columns":["title"],"tokenize":"unicode61"}]) } else { json!([]) }}]})
4685 }
4686
4687 #[test]
4688 fn aborted_schema_and_epoch_resets_restore_memory_and_durable_intent() {
4689 for schema_reset in [false, true] {
4690 let mut schema = replay_schema(false);
4691 let mut client =
4692 SyncClient::new("reset-failure".into(), &schema, ClientLimits::default()).unwrap();
4693 let id = client
4694 .mutate(vec![Mutation::Upsert {
4695 table: "tasks".into(),
4696 values: Map::from_iter([
4697 ("id".into(), json!("queued")),
4698 ("project_id".into(), json!("p1")),
4699 ("title".into(), json!("old")),
4700 ("done".into(), json!(false)),
4701 ]),
4702 base_version: None,
4703 }])
4704 .unwrap();
4705 client.set_meta(LOG_EPOCH_KEY, "old");
4706 let revision = client.local_revision();
4707 let needed = client.sync_needed;
4708 client.drain_change_batches();
4709 client.drain_sync_intents();
4710 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();
4711 if schema_reset {
4712 schema["version"] = json!(2);
4713 schema["tables"][0]["columns"]
4714 .as_array_mut()
4715 .unwrap()
4716 .retain(|column| column["name"] != "title");
4717 client.schema = parse_schema_json(&schema).unwrap();
4718 assert!(client.run_schema_reset().is_err());
4719 } else {
4720 assert!(client.run_log_epoch_reset("new").is_err());
4721 }
4722 assert!(!client.upgrading);
4723 assert_eq!(client.sync_needed, needed);
4724 assert_eq!(client.local_revision(), revision);
4725 assert_eq!(client.pending_commit_ids(), vec![id.clone()]);
4726 assert!(client.rejections.is_empty());
4727 assert!(client.commit_outcome(&id).unwrap().is_none());
4728 assert_eq!(client.get_meta(LOG_EPOCH_KEY).as_deref(), Some("old"));
4729 assert_eq!(
4730 client
4731 .query("SELECT title FROM tasks WHERE id='queued'", &[])
4732 .unwrap()[0]["title"],
4733 "old"
4734 );
4735 assert_eq!(
4736 client
4737 .conn
4738 .query_row("SELECT count(*) FROM _syncular_outbox", [], |row| row
4739 .get::<_, i64>(0))
4740 .unwrap(),
4741 1
4742 );
4743 assert!(client.drain_change_batches().is_empty());
4744 assert!(client.drain_sync_intents().is_empty());
4745 assert!(client.conn.is_autocommit());
4746 }
4747 }
4748
4749 #[test]
4750 fn cached_blob_cleanup_propagates_delete_failures() {
4751 let schema = json!({"version":1,"tables":[{"name":"attachments","primaryKey":"id","columns":[
4752 {"name":"id","type":"string","nullable":false},
4753 {"name":"file","type":"blob_ref","nullable":true}],"scopes":[]}]});
4754 let mut client =
4755 SyncClient::new("blob-cleanup".into(), &schema, ClientLimits::default()).unwrap();
4756 client.conn.execute_batch("CREATE TRIGGER refuse_blob_cleanup BEFORE DELETE ON _syncular_blobs BEGIN SELECT RAISE(ABORT,'blob cleanup failed'); END;").unwrap();
4757 client.conn.execute("INSERT INTO _syncular_blobs(blob_id,bytes,byte_length,created_at_ms) VALUES ('test',X'01',1,0)", []).unwrap();
4758 client
4759 .subscribe("attachments".into(), "attachments".into(), vec![], None)
4760 .unwrap();
4761 client.subs[0].effective = Some(vec![]);
4762 client.persist_sub(&client.subs[0]).unwrap();
4763 let revision = client.local_revision();
4764 let (_, meta) = client.build_request(false).unwrap();
4765 let mut transport = CountingRealtimeTransport::default();
4766 let mut report = SyncReport::default();
4767 assert!(client
4768 .process_section(
4769 &mut transport,
4770 "attachments",
4771 SubStatus::Revoked,
4772 "sync.scope_revoked",
4773 vec![],
4774 vec![],
4775 Some((0, None)),
4776 &meta,
4777 &mut report
4778 )
4779 .is_err());
4780 assert_eq!(client.subs[0].state, SubState::Active);
4781 assert_eq!(client.local_revision(), revision);
4782 assert!(report.revoked.is_empty());
4783 assert!(client.conn.is_autocommit());
4784 client
4785 .conn
4786 .execute_batch("DROP TRIGGER refuse_blob_cleanup")
4787 .unwrap();
4788 assert!(client
4789 .process_section(
4790 &mut transport,
4791 "attachments",
4792 SubStatus::Revoked,
4793 "sync.scope_revoked",
4794 vec![],
4795 vec![],
4796 Some((0, None)),
4797 &meta,
4798 &mut report
4799 )
4800 .is_ok());
4801 assert_eq!(client.subs[0].state, SubState::Revoked);
4802 assert_eq!(
4803 client
4804 .conn
4805 .query_row("SELECT count(*) FROM _syncular_blobs", [], |row| row
4806 .get::<_, i64>(0))
4807 .unwrap(),
4808 0
4809 );
4810 }
4811
4812 #[test]
4813 fn overlay_replay_trigger_refuses_reopen_and_preserves_durable_intent() {
4814 let path =
4815 std::env::temp_dir().join(format!("syncular-replay-{}.db", uuid::Uuid::new_v4()));
4816 let schema = replay_schema(false);
4817 let mut client = SyncClient::open_path(
4818 "replay".into(),
4819 &schema,
4820 ClientLimits::default(),
4821 path.to_str().unwrap(),
4822 )
4823 .unwrap();
4824 let ids = ["early", "bad"].map(|id| {
4825 client
4826 .mutate(vec![Mutation::Upsert {
4827 table: "tasks".into(),
4828 values: Map::from_iter([
4829 ("id".into(), json!(id)),
4830 ("project_id".into(), json!("p1")),
4831 ("title".into(), json!(id)),
4832 ("done".into(), json!(false)),
4833 ]),
4834 base_version: None,
4835 }])
4836 .unwrap()
4837 });
4838 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();
4839 let revision = client.local_revision();
4840 drop(client);
4841 let error = SyncClient::open_path(
4842 "replay".into(),
4843 &schema,
4844 ClientLimits::default(),
4845 path.to_str().unwrap(),
4846 )
4847 .err()
4848 .expect("reopen must fail");
4849 assert!(error.contains("replay failed"));
4850 let conn = Connection::open(&path).unwrap();
4851 assert_eq!(
4852 conn.query_row("SELECT count(*) FROM tasks", [], |row| row.get::<_, i64>(0))
4853 .unwrap(),
4854 0
4855 );
4856 assert_eq!(
4857 conn.query_row("SELECT count(*) FROM _syncular_outbox", [], |row| row
4858 .get::<_, i64>(0))
4859 .unwrap(),
4860 2
4861 );
4862 assert_eq!(
4863 meta_get(&conn, LOCAL_REVISION_KEY),
4864 Some(revision.to_string())
4865 );
4866 conn.execute_batch("DROP TRIGGER fail_replay").unwrap();
4867 drop(conn);
4868 let reopened = SyncClient::open_path(
4869 "replay".into(),
4870 &schema,
4871 ClientLimits::default(),
4872 path.to_str().unwrap(),
4873 )
4874 .unwrap();
4875 assert_eq!(reopened.pending_commit_ids(), ids);
4876 assert_eq!(
4877 reopened
4878 .query("SELECT id FROM tasks ORDER BY id", &[])
4879 .unwrap()
4880 .len(),
4881 2
4882 );
4883 drop(reopened);
4884 std::fs::remove_file(path).unwrap();
4885 }
4886
4887 #[test]
4888 fn overlay_replay_failure_rolls_back_push_and_pull_apply_boundaries() {
4889 let schema = replay_schema(false);
4890 let mut client =
4891 SyncClient::new("replay".into(), &schema, ClientLimits::default()).unwrap();
4892 client.set_meta(LOG_EPOCH_KEY, "epoch-1");
4893 client
4894 .write_base_row(
4895 "tasks",
4896 &vec![
4897 Some(ColumnValue::String("queued".into())),
4898 Some(ColumnValue::String("p1".into())),
4899 Some(ColumnValue::String("server".into())),
4900 Some(ColumnValue::Boolean(false)),
4901 ],
4902 1,
4903 )
4904 .unwrap();
4905 client.rebuild_overlay().unwrap();
4906 let id = client
4907 .patch(
4908 "tasks",
4909 "queued",
4910 Map::from_iter([("title".into(), json!("local"))]),
4911 Some(1),
4912 )
4913 .unwrap();
4914 let rows = client.query("SELECT * FROM tasks", &[]).unwrap();
4915 let base = client
4916 .query("SELECT * FROM _syncular_base_tasks", &[])
4917 .unwrap();
4918 let revision = client.local_revision();
4919 client.drain_change_batches();
4920 client.conn.execute_batch("CREATE TRIGGER fail_replay BEFORE INSERT ON tasks WHEN new.title='local' BEGIN SELECT RAISE(ABORT,'replay failed'); END").unwrap();
4921 let remote = vec![
4922 Some(ColumnValue::String("queued".into())),
4923 Some(ColumnValue::String("p1".into())),
4924 Some(ColumnValue::String("remote".into())),
4925 Some(ColumnValue::Boolean(false)),
4926 ];
4927 let bytes = crate::values::encode_row_json(
4928 client.schema.table("tasks").unwrap(),
4929 "queued",
4930 &Map::from_iter(
4931 client
4932 .schema
4933 .table("tasks")
4934 .unwrap()
4935 .columns
4936 .iter()
4937 .zip(&remote)
4938 .map(|(column, value)| (column.name.clone(), column_value_to_json(value))),
4939 ),
4940 &client.encryption,
4941 )
4942 .unwrap();
4943 let change = ssp2::model::Change {
4944 table_index: 0,
4945 op: Op::Upsert,
4946 row_id: "queued".into(),
4947 row_version: Some(2),
4948 scopes: vec![("project_id".into(), "p1".into())],
4949 row: Some(bytes),
4950 };
4951 assert!(client
4952 .apply_commit_frame(&["tasks".into()], std::slice::from_ref(&change), Some(2))
4953 .is_err());
4954 assert_eq!(client.query("SELECT * FROM tasks", &[]).unwrap(), rows);
4955 assert_eq!(
4956 client
4957 .query("SELECT * FROM _syncular_base_tasks", &[])
4958 .unwrap(),
4959 base
4960 );
4961 assert_eq!(client.local_revision(), revision);
4962 assert!(client.drain_change_batches().is_empty());
4963 let response = Message {
4964 wire_version: 2,
4965 msg_kind: MsgKind::Response,
4966 frames: vec![
4967 Frame::RespHeader {
4968 required_schema_version: None,
4969 latest_schema_version: None,
4970 log_epoch: Some("epoch-1".into()),
4971 reset_required: Some(false),
4972 },
4973 Frame::PushResult {
4974 client_commit_id: id.clone(),
4975 status: PushStatus::Applied,
4976 commit_seq: Some(2),
4977 results: vec![OpResult::Applied { op_index: 0 }],
4978 },
4979 ],
4980 };
4981 let (_, meta) = client.build_request(false).unwrap();
4982 let mut transport = HostTransport::new_from_config(&json!({})).unwrap();
4983 assert!(
4984 matches!(client.process_response(&mut transport,response.clone(),&meta),SyncOutcome::Failed { error_code,.. } if error_code=="client.outcome_persistence_failed")
4985 );
4986 assert_eq!(client.pending_commit_ids(), vec![id.clone()]);
4987 assert!(client.commit_outcome(&id).unwrap().is_none());
4988 assert_eq!(
4989 client
4990 .conn
4991 .query_row("SELECT count(*) FROM _syncular_acked_rows", [], |row| row
4992 .get::<_, i64>(
4993 0
4994 ))
4995 .unwrap(),
4996 0
4997 );
4998 assert_eq!(
4999 client
5000 .conn
5001 .query_row("SELECT count(*) FROM _syncular_row_deliveries", [], |row| {
5002 row.get::<_, i64>(0)
5003 })
5004 .unwrap(),
5005 0
5006 );
5007 assert_eq!(client.query("SELECT * FROM tasks", &[]).unwrap(), rows);
5008 assert_eq!(client.local_revision(), revision);
5009 assert!(client.drain_change_batches().is_empty());
5010 client
5011 .conn
5012 .execute_batch("DROP TRIGGER fail_replay")
5013 .unwrap();
5014 assert!(matches!(
5015 client.process_response(&mut transport, response, &meta),
5016 SyncOutcome::Ok(_)
5017 ));
5018 client
5019 .apply_commit_frame(&["tasks".into()], &[change], Some(2))
5020 .unwrap();
5021 assert!(client.pending_commit_ids().is_empty());
5022 assert_eq!(
5023 client.query("SELECT title FROM tasks", &[]).unwrap()[0]["title"],
5024 json!("remote")
5025 );
5026 }
5027
5028 #[test]
5029 fn overlay_replay_distinguishes_absence_from_sql_and_value_decode_errors() {
5030 let client = SyncClient::new(
5031 "replay".into(),
5032 &replay_schema(false),
5033 ClientLimits::default(),
5034 )
5035 .unwrap();
5036 let table = client.schema.table("tasks").unwrap();
5037 let patch = OutboxOp {
5038 table: "tasks".into(),
5039 row_id: "absent".into(),
5040 upsert: true,
5041 base_version: Some(1),
5042 values: Some(Map::from_iter([
5043 ("id".into(), json!("absent")),
5044 ("title".into(), json!("patch")),
5045 ])),
5046 };
5047 assert!(client.visible_row(table, "absent").unwrap().is_none());
5048 client
5049 .apply_outbox_ops([&patch])
5050 .expect("sparse replay over truly absent row");
5051 client
5052 .conn
5053 .execute_batch("INSERT INTO tasks VALUES ('bad','p1','title','invalid',1)")
5054 .unwrap();
5055 assert!(client
5056 .visible_row(table, "bad")
5057 .unwrap_err()
5058 .starts_with("sync.local_corrupt:"));
5059 let mut invalid = patch.clone();
5060 invalid.values = Some(Map::from_iter([
5061 ("id".into(), json!("bad-value")),
5062 ("project_id".into(), json!("p1")),
5063 ("title".into(), json!("title")),
5064 ("done".into(), json!({"bad":true})),
5065 ]));
5066 assert!(client
5067 .apply_outbox_ops([&invalid])
5068 .unwrap_err()
5069 .starts_with("sync.local_corrupt:"));
5070 client.conn.execute_batch("DROP TABLE tasks").unwrap();
5071 assert!(
5072 client.apply_outbox_ops([&patch]).is_err(),
5073 "SQL failure must not count as absence"
5074 );
5075 }
5076
5077 #[test]
5078 fn overlay_replay_keeps_secondary_unique_deferral_and_lazy_base_precedence() {
5079 let mut schema = replay_schema(false);
5080 schema["tables"][0]["indexes"] =
5081 json!([{"name":"tasks_project_title", "columns":["project_id","title"],"unique":true}]);
5082 let mut client =
5083 SyncClient::new("replay".into(), &schema, ClientLimits::default()).unwrap();
5084 let id = client
5085 .mutate(vec![Mutation::Upsert {
5086 table: "tasks".into(),
5087 values: Map::from_iter([
5088 ("id".into(), json!("queued")),
5089 ("project_id".into(), json!("p1")),
5090 ("title".into(), json!("occupied")),
5091 ("done".into(), json!(false)),
5092 ]),
5093 base_version: None,
5094 }])
5095 .unwrap();
5096 client
5097 .write_base_row(
5098 "tasks",
5099 &vec![
5100 Some(ColumnValue::String("server".into())),
5101 Some(ColumnValue::String("p1".into())),
5102 Some(ColumnValue::String("occupied".into())),
5103 Some(ColumnValue::Boolean(false)),
5104 ],
5105 1,
5106 )
5107 .unwrap();
5108 client.rebuild_overlay_if_dirty().unwrap();
5109 assert_eq!(client.pending_commit_ids(), vec![id.clone()]);
5110 assert!(client
5111 .visible_row(client.schema.table("tasks").unwrap(), "queued")
5112 .unwrap()
5113 .is_none());
5114 assert!(client.rejections().is_empty());
5115 client.delete_base_row("tasks", "server").unwrap();
5116 client.rebuild_overlay_if_dirty().unwrap();
5117 assert!(client
5118 .visible_row(client.schema.table("tasks").unwrap(), "queued")
5119 .unwrap()
5120 .is_some());
5121 client
5122 .write_base_row(
5123 "tasks",
5124 &vec![
5125 Some(ColumnValue::String("base".into())),
5126 Some(ColumnValue::String("p1".into())),
5127 Some(ColumnValue::String("base-title".into())),
5128 Some(ColumnValue::Boolean(false)),
5129 ],
5130 1,
5131 )
5132 .unwrap();
5133 client.conn.execute_batch("DROP TABLE tasks").unwrap();
5134 client
5135 .retain_failed_commit(
5136 "retained",
5137 &[OutboxOp {
5138 table: "tasks".into(),
5139 row_id: "base".into(),
5140 upsert: true,
5141 base_version: Some(1),
5142 values: Some(Map::from_iter([
5143 ("id".into(), json!("base")),
5144 ("title".into(), json!("patch")),
5145 ])),
5146 }],
5147 )
5148 .expect("an existing base must not read the unavailable visible table");
5149 }
5150
5151 #[test]
5152 fn overlay_replay_fts_and_savepoint_release_failures_roll_back_all_visible_changes() {
5153 for failure in ["fts", "release"] {
5154 let mut client = SyncClient::new(
5155 "replay".into(),
5156 &replay_schema(true),
5157 ClientLimits::default(),
5158 )
5159 .unwrap();
5160 client
5161 .mutate(vec![Mutation::Upsert {
5162 table: "tasks".into(),
5163 values: Map::from_iter([
5164 ("id".into(), json!("queued")),
5165 ("project_id".into(), json!("p1")),
5166 ("title".into(), json!("needle")),
5167 ("done".into(), json!(false)),
5168 ]),
5169 base_version: None,
5170 }])
5171 .unwrap();
5172 let before = client.query("SELECT * FROM tasks", &[]).unwrap();
5173 let fts_before = client
5174 .query("SELECT _syncular_source_id,title FROM tasks_fts", &[])
5175 .unwrap();
5176 let triggers_before: Vec<String> = client
5177 .conn
5178 .prepare("SELECT sql FROM sqlite_master WHERE type='trigger' ORDER BY name")
5179 .unwrap()
5180 .query_map([], |row| row.get(0))
5181 .unwrap()
5182 .collect::<Result<_, _>>()
5183 .unwrap();
5184 if failure == "fts" {
5185 client.conn.execute_batch("CREATE TRIGGER fail_fts BEFORE INSERT ON _syncular_fts_tasks_fts BEGIN SELECT RAISE(ABORT,'fts replay failed'); END;").unwrap();
5186 } else {
5187 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();
5188 }
5189 client.overlay_dirty.set(true);
5190 assert!(client.rebuild_overlay().is_err());
5191 assert!(client.overlay_dirty.get());
5192 assert!(client.conn.is_autocommit());
5193 assert_eq!(client.query("SELECT * FROM tasks", &[]).unwrap(), before);
5194 assert_eq!(
5195 client
5196 .query("SELECT _syncular_source_id,title FROM tasks_fts", &[])
5197 .unwrap(),
5198 fts_before
5199 );
5200 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();
5201 assert_eq!(original_triggers, triggers_before);
5202 assert_eq!(client.pending_commit_ids().len(), 1);
5203 client
5204 .conn
5205 .execute_batch(if failure == "fts" {
5206 "DROP TRIGGER fail_fts"
5207 } else {
5208 "DROP TRIGGER fail_release"
5209 })
5210 .unwrap();
5211 client
5212 .rebuild_overlay()
5213 .expect("retry after removing the fault");
5214 }
5215 }
5216
5217 #[test]
5218 fn secondary_unique_collision_preserves_existing_synced_row() {
5219 let client = SyncClient::new(
5220 "unique-upsert-test".to_owned(),
5221 &json!({
5222 "version": 1,
5223 "tables": [{
5224 "name": "tasks",
5225 "primaryKey": "id",
5226 "columns": [
5227 { "name": "id", "type": "string", "nullable": false },
5228 { "name": "project_id", "type": "string", "nullable": false },
5229 { "name": "title", "type": "string", "nullable": false }
5230 ],
5231 "scopes": [{ "pattern": "project:{project_id}" }],
5232 "indexes": [{
5233 "name": "tasks_by_project_title",
5234 "columns": ["project_id", "title"],
5235 "unique": true
5236 }]
5237 }]
5238 }),
5239 ClientLimits::default(),
5240 )
5241 .expect("test client");
5242 let table = client.schema.table("tasks").expect("tasks table");
5243 let sql = client.insert_row_sql(&base_table("tasks"), table);
5244
5245 client
5246 .conn
5247 .execute(&sql, rusqlite::params!["t1", "p1", "original", 1])
5248 .expect("insert first row");
5249 client
5250 .conn
5251 .execute(&sql, rusqlite::params!["t1", "p1", "updated", 2])
5252 .expect("update same primary key");
5253 client
5254 .conn
5255 .execute(&sql, rusqlite::params!["t2", "p1", "original", 1])
5256 .expect("insert second row");
5257 assert!(client
5258 .conn
5259 .execute(&sql, rusqlite::params!["t3", "p1", "original", 2])
5260 .is_err());
5261
5262 let rows = client
5263 .conn
5264 .prepare("SELECT id, title FROM _syncular_base_tasks ORDER BY id")
5265 .expect("prepare rows")
5266 .query_map([], |row| {
5267 Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
5268 })
5269 .expect("query rows")
5270 .collect::<Result<Vec<_>, _>>()
5271 .expect("collect rows");
5272 assert_eq!(
5273 rows,
5274 vec![
5275 ("t1".to_owned(), "updated".to_owned()),
5276 ("t2".to_owned(), "original".to_owned())
5277 ]
5278 );
5279 }
5280
5281 #[test]
5282 fn reopening_active_subscriptions_emits_a_catch_up_intent() {
5283 let path = std::env::temp_dir().join(format!(
5284 "syncular-startup-intent-{}.db",
5285 uuid::Uuid::new_v4()
5286 ));
5287 let schema = json!({
5288 "version": 1,
5289 "tables": [{
5290 "name": "tasks",
5291 "primaryKey": "id",
5292 "columns": [
5293 { "name": "id", "type": "string", "nullable": false },
5294 { "name": "project_id", "type": "string", "nullable": false }
5295 ],
5296 "scopes": [{ "pattern": "project:{project_id}" }]
5297 }]
5298 });
5299
5300 {
5301 let mut first = SyncClient::open_path_with_identity(
5302 None,
5303 &schema,
5304 ClientLimits::default(),
5305 path.to_str().expect("UTF-8 temp path"),
5306 )
5307 .expect("first open");
5308 first
5309 .set_window(
5310 &WindowBase {
5311 table: "tasks".to_owned(),
5312 variable: "project_id".to_owned(),
5313 fixed_scopes: Vec::new(),
5314 params: None,
5315 },
5316 &["persisted".to_owned()],
5317 )
5318 .expect("persist window");
5319 }
5320
5321 let mut reopened = SyncClient::open_path_with_identity(
5322 None,
5323 &schema,
5324 ClientLimits::default(),
5325 path.to_str().expect("UTF-8 temp path"),
5326 )
5327 .expect("reopen");
5328 assert!(reopened.sync_needed());
5329 assert!(matches!(
5330 reopened.drain_sync_intents().as_slice(),
5331 [SyncIntent::Interactive]
5332 ));
5333 drop(reopened);
5334 std::fs::remove_file(path).expect("remove temp database");
5335 }
5336
5337 #[test]
5338 fn reopening_preserves_immutable_subscription_identity_and_progress() {
5339 let path = std::env::temp_dir().join(format!(
5340 "syncular-subscription-identity-{}.db",
5341 uuid::Uuid::new_v4()
5342 ));
5343 let schema = json!({
5344 "version": 1,
5345 "tables": [
5346 {
5347 "name": "tasks",
5348 "primaryKey": "id",
5349 "columns": [
5350 { "name": "id", "type": "string", "nullable": false },
5351 { "name": "project_id", "type": "string", "nullable": false }
5352 ],
5353 "scopes": [{ "pattern": "project:{project_id}" }]
5354 },
5355 {
5356 "name": "docs",
5357 "primaryKey": "id",
5358 "columns": [
5359 { "name": "id", "type": "string", "nullable": false },
5360 { "name": "org_id", "type": "string", "nullable": false },
5361 { "name": "project_id", "type": "string", "nullable": false }
5362 ],
5363 "scopes": [
5364 { "pattern": "org:{org_id}" },
5365 { "pattern": "project:{projectId}", "column": "project_id" }
5366 ]
5367 }
5368 ]
5369 });
5370
5371 {
5372 let mut first = SyncClient::open_path_with_identity(
5373 None,
5374 &schema,
5375 ClientLimits::default(),
5376 path.to_str().expect("UTF-8 temp path"),
5377 )
5378 .expect("first open");
5379 first
5380 .subscribe(
5381 "stable-subscription".to_owned(),
5382 "tasks".to_owned(),
5383 vec![(
5384 "project_id".to_owned(),
5385 vec!["p2".to_owned(), "p1".to_owned()],
5386 )],
5387 Some(r#"{"view":"v1"}"#.to_owned()),
5388 )
5389 .expect("persist subscription");
5390 let persisted = {
5391 let subscription = first
5392 .subs
5393 .iter_mut()
5394 .find(|subscription| subscription.id == "stable-subscription")
5395 .expect("subscription");
5396 subscription.cursor = 41;
5397 subscription.bootstrap_state = Some("resume-token".to_owned());
5398 subscription.effective = Some(vec![(
5399 "project_id".to_owned(),
5400 vec!["p1".to_owned(), "p2".to_owned()],
5401 )]);
5402 subscription.synced_once = true;
5403 subscription.clone()
5404 };
5405 first.persist_sub(&persisted).unwrap();
5406 }
5407
5408 let mut reopened = SyncClient::open_path_with_identity(
5409 None,
5410 &schema,
5411 ClientLimits::default(),
5412 path.to_str().expect("UTF-8 temp path"),
5413 )
5414 .expect("reopen");
5415 let progress = reopened
5416 .subscription_state("stable-subscription")
5417 .expect("persisted state");
5418 assert_eq!(progress.cursor, 41);
5419 assert!(progress.has_resume_token);
5420
5421 reopened
5422 .subscribe(
5423 "stable-subscription".to_owned(),
5424 "tasks".to_owned(),
5425 vec![(
5426 "project_id".to_owned(),
5427 vec!["p1".to_owned(), "p2".to_owned(), "p1".to_owned()],
5428 )],
5429 Some(r#"{"view":"v1"}"#.to_owned()),
5430 )
5431 .expect("canonical intent is idempotent");
5432 assert_eq!(
5433 reopened
5434 .subscription_state("stable-subscription")
5435 .expect("unchanged state")
5436 .cursor,
5437 progress.cursor
5438 );
5439
5440 for (table, scopes, params) in [
5441 (
5442 "tasks",
5443 vec![("project_id".to_owned(), vec!["p1".to_owned()])],
5444 Some(r#"{"view":"v1"}"#.to_owned()),
5445 ),
5446 (
5447 "tasks",
5448 vec![(
5449 "project_id".to_owned(),
5450 vec!["p2".to_owned(), "p1".to_owned()],
5451 )],
5452 Some(r#"{"view":"v2"}"#.to_owned()),
5453 ),
5454 (
5455 "docs",
5456 vec![
5457 ("org_id".to_owned(), vec!["o1".to_owned()]),
5458 ("projectId".to_owned(), vec!["p1".to_owned()]),
5459 ],
5460 Some(r#"{"view":"v1"}"#.to_owned()),
5461 ),
5462 ] {
5463 let error = reopened
5464 .subscribe(
5465 "stable-subscription".to_owned(),
5466 table.to_owned(),
5467 scopes,
5468 params,
5469 )
5470 .expect_err("identity rebind must fail");
5471 assert!(error.starts_with("client.subscription_intent_mismatch:"));
5472 assert_eq!(
5473 reopened
5474 .subscription_state("stable-subscription")
5475 .expect("unchanged state")
5476 .cursor,
5477 progress.cursor
5478 );
5479 }
5480
5481 drop(reopened);
5482 std::fs::remove_file(path).expect("remove temp database");
5483 }
5484
5485 #[test]
5486 fn reopening_clears_a_schema_floor_the_running_app_already_satisfies() {
5487 let path = std::env::temp_dir().join(format!(
5488 "syncular-satisfied-schema-floor-{}.db",
5489 uuid::Uuid::new_v4()
5490 ));
5491 let schema = json!({
5492 "version": 23,
5493 "tables": [{
5494 "name": "tasks",
5495 "primaryKey": "id",
5496 "columns": [
5497 { "name": "id", "type": "string", "nullable": false },
5498 { "name": "project_id", "type": "string", "nullable": false }
5499 ],
5500 "scopes": [{ "pattern": "project:{project_id}" }]
5501 }]
5502 });
5503
5504 {
5505 let mut first = SyncClient::open_path_with_identity(
5506 None,
5507 &schema,
5508 ClientLimits::default(),
5509 path.to_str().expect("UTF-8 temp path"),
5510 )
5511 .expect("first open");
5512 first
5513 .subscribe(
5514 "tasks".to_owned(),
5515 "tasks".to_owned(),
5516 vec![("project_id".to_owned(), vec!["p1".to_owned()])],
5517 None,
5518 )
5519 .expect("persist subscription");
5520 first.set_schema_floor(Some(SchemaFloor {
5521 required_schema_version: Some(22),
5522 latest_schema_version: Some(22),
5523 }));
5524 }
5525
5526 let mut reopened = SyncClient::open_path_with_identity(
5527 None,
5528 &schema,
5529 ClientLimits::default(),
5530 path.to_str().expect("UTF-8 temp path"),
5531 )
5532 .expect("reopen");
5533 assert!(reopened.schema_floor().is_none());
5534 assert!(reopened.get_meta(SCHEMA_FLOOR_KEY).is_none());
5535 assert!(reopened.sync_needed());
5536 assert!(matches!(
5537 reopened.drain_sync_intents().as_slice(),
5538 [SyncIntent::Interactive]
5539 ));
5540 drop(reopened);
5541 std::fs::remove_file(path).expect("remove temp database");
5542 }
5543
5544 #[test]
5545 fn reopening_keeps_an_unsatisfied_schema_floor_stopped() {
5546 let path = std::env::temp_dir().join(format!(
5547 "syncular-unsatisfied-schema-floor-{}.db",
5548 uuid::Uuid::new_v4()
5549 ));
5550 let schema = json!({
5551 "version": 1,
5552 "tables": [{
5553 "name": "tasks",
5554 "primaryKey": "id",
5555 "columns": [
5556 { "name": "id", "type": "string", "nullable": false },
5557 { "name": "project_id", "type": "string", "nullable": false }
5558 ],
5559 "scopes": [{ "pattern": "project:{project_id}" }]
5560 }]
5561 });
5562
5563 {
5564 let mut first = SyncClient::open_path_with_identity(
5565 None,
5566 &schema,
5567 ClientLimits::default(),
5568 path.to_str().expect("UTF-8 temp path"),
5569 )
5570 .expect("first open");
5571 first
5572 .subscribe(
5573 "tasks".to_owned(),
5574 "tasks".to_owned(),
5575 vec![("project_id".to_owned(), vec!["p1".to_owned()])],
5576 None,
5577 )
5578 .expect("persist subscription");
5579 first.set_schema_floor(Some(SchemaFloor {
5580 required_schema_version: Some(2),
5581 latest_schema_version: Some(2),
5582 }));
5583 }
5584
5585 let reopened = SyncClient::open_path_with_identity(
5586 None,
5587 &schema,
5588 ClientLimits::default(),
5589 path.to_str().expect("UTF-8 temp path"),
5590 )
5591 .expect("reopen");
5592 assert_eq!(
5593 reopened.schema_floor(),
5594 Some(&SchemaFloor {
5595 required_schema_version: Some(2),
5596 latest_schema_version: Some(2),
5597 })
5598 );
5599 assert!(!reopened.sync_needed());
5600 drop(reopened);
5601 std::fs::remove_file(path).expect("remove temp database");
5602 }
5603
5604 #[test]
5605 fn schema_bump_precedes_index_ddl_and_prunes_removed_subscriptions() {
5606 let path = std::env::temp_dir().join(format!(
5607 "syncular-indexed-column-bump-{}.db",
5608 uuid::Uuid::new_v4()
5609 ));
5610 let old_schema = json!({
5611 "version": 1,
5612 "tables": [
5613 {
5614 "name": "tasks",
5615 "primaryKey": "id",
5616 "columns": [
5617 { "name": "id", "type": "string", "nullable": false },
5618 { "name": "project_id", "type": "string", "nullable": false }
5619 ],
5620 "scopes": [{ "pattern": "project:{project_id}" }]
5621 },
5622 {
5623 "name": "legacy",
5624 "primaryKey": "id",
5625 "columns": [
5626 { "name": "id", "type": "string", "nullable": false },
5627 { "name": "project_id", "type": "string", "nullable": false }
5628 ],
5629 "scopes": [{ "pattern": "project:{project_id}" }]
5630 }
5631 ]
5632 });
5633 {
5634 let mut first = SyncClient::open_path_with_identity(
5635 None,
5636 &old_schema,
5637 ClientLimits::default(),
5638 path.to_str().expect("UTF-8 temp path"),
5639 )
5640 .expect("open old schema");
5641 first
5642 .subscribe(
5643 "legacy-sub".to_owned(),
5644 "legacy".to_owned(),
5645 vec![("project_id".to_owned(), vec!["p1".to_owned()])],
5646 None,
5647 )
5648 .expect("persist legacy subscription");
5649 }
5650
5651 let new_schema = json!({
5652 "version": 2,
5653 "tables": [{
5654 "name": "tasks",
5655 "primaryKey": "id",
5656 "columns": [
5657 { "name": "id", "type": "string", "nullable": false },
5658 { "name": "project_id", "type": "string", "nullable": false },
5659 { "name": "facility_membership_id", "type": "string", "nullable": true }
5660 ],
5661 "scopes": [{ "pattern": "project:{project_id}" }],
5662 "indexes": [{
5663 "name": "tasks_by_membership",
5664 "columns": ["project_id", "facility_membership_id"],
5665 "unique": false
5666 }]
5667 }]
5668 });
5669 let upgraded = SyncClient::open_path_with_identity(
5670 None,
5671 &new_schema,
5672 ClientLimits::default(),
5673 path.to_str().expect("UTF-8 temp path"),
5674 )
5675 .expect("open upgraded schema");
5676 let columns = upgraded
5677 .conn
5678 .prepare("PRAGMA table_info(tasks)")
5679 .expect("prepare columns")
5680 .query_map([], |row| row.get::<_, String>(1))
5681 .expect("query columns")
5682 .collect::<Result<Vec<_>, _>>()
5683 .expect("collect columns");
5684 assert!(columns.contains(&"facility_membership_id".to_owned()));
5685 let index_count: i64 = upgraded
5686 .conn
5687 .query_row(
5688 "SELECT COUNT(*) FROM sqlite_master WHERE type = 'index' AND name = 'tasks_by_membership'",
5689 [],
5690 |row| row.get(0),
5691 )
5692 .expect("query index");
5693 assert_eq!(index_count, 1);
5694 assert!(upgraded.subscription_state("legacy-sub").is_none());
5695 drop(upgraded);
5696 std::fs::remove_file(path).expect("remove temp database");
5697 }
5698
5699 #[test]
5700 fn migrates_pre_envelope_outcome_journal_additively() {
5701 let path = std::env::temp_dir().join(format!(
5702 "syncular-outcome-migration-{}.db",
5703 uuid::Uuid::new_v4()
5704 ));
5705 let conn = Connection::open(&path).expect("open legacy database");
5706 conn.execute_batch(
5707 "CREATE TABLE _syncular_commit_outcomes (
5708 seq INTEGER PRIMARY KEY AUTOINCREMENT,
5709 client_commit_id TEXT NOT NULL UNIQUE,
5710 status TEXT NOT NULL,
5711 recorded_at_ms INTEGER NOT NULL,
5712 results_json TEXT NOT NULL,
5713 resolution TEXT NOT NULL DEFAULT 'active',
5714 resolved_at_ms INTEGER,
5715 replacement_client_commit_id TEXT);",
5716 )
5717 .expect("create legacy outcome journal");
5718 drop(conn);
5719 let schema = json!({
5720 "version": 1,
5721 "tables": [{
5722 "name": "tasks",
5723 "primaryKey": "id",
5724 "columns": [
5725 { "name": "id", "type": "string", "nullable": false },
5726 { "name": "project_id", "type": "string", "nullable": false }
5727 ],
5728 "scopes": [{ "pattern": "project:{project_id}" }]
5729 }]
5730 });
5731 let client = SyncClient::open_path(
5732 "migration-native".to_owned(),
5733 &schema,
5734 ClientLimits::default(),
5735 path.to_str().expect("UTF-8 temp path"),
5736 )
5737 .expect("migrate database");
5738 let has_operations = client
5739 .conn
5740 .prepare("PRAGMA table_info(_syncular_commit_outcomes)")
5741 .expect("prepare table info")
5742 .query_map([], |row| row.get::<_, String>(1))
5743 .expect("query table info")
5744 .filter_map(Result::ok)
5745 .any(|column| column == "operations_json");
5746 assert!(has_operations);
5747 drop(client);
5748 std::fs::remove_file(path).expect("remove temp database");
5749 }
5750
5751 #[test]
5752 fn retained_rejection_survives_file_reopen_and_security_purge() {
5753 let path =
5754 std::env::temp_dir().join(format!("syncular-retained-{}.db", uuid::Uuid::new_v4()));
5755 let schema = json!({ "version": 1, "tables": [{ "name": "tasks", "primaryKey": "id", "columns": [
5756 { "name": "id", "type": "string", "nullable": false },
5757 { "name": "project_id", "type": "string", "nullable": false },
5758 { "name": "title", "type": "string", "nullable": false }
5759 ], "scopes": [{ "pattern": "project:{project_id}" }] }] });
5760 {
5761 let mut first = SyncClient::open_path(
5762 "retained-native".to_owned(),
5763 &schema,
5764 ClientLimits::default(),
5765 path.to_str().unwrap(),
5766 )
5767 .unwrap();
5768 first
5769 .write_base_row(
5770 "tasks",
5771 &vec![
5772 Some(ColumnValue::String("t1".into())),
5773 Some(ColumnValue::String("p1".into())),
5774 Some(ColumnValue::String("server".into())),
5775 ],
5776 1,
5777 )
5778 .unwrap();
5779 first.rebuild_overlay().expect("rebuild overlay");
5780 let operation = OutboxOp {
5781 table: "tasks".into(),
5782 row_id: "t1".into(),
5783 upsert: true,
5784 base_version: Some(1),
5785 values: Some(Map::from_iter([
5786 ("id".into(), json!("t1")),
5787 ("title".into(), json!("mine")),
5788 ])),
5789 };
5790 first
5791 .retain_failed_commit("retained", std::slice::from_ref(&operation))
5792 .unwrap();
5793 first
5794 .persist_commit_outcome(
5795 "retained",
5796 CommitOutcomeStatus::Rejected,
5797 &[CommitOperationOutcome::Error {
5798 rejection: RejectionRecord {
5799 client_commit_id: "retained".into(),
5800 op_index: 0,
5801 code: "validation.denied".into(),
5802 message: "denied".into(),
5803 retryable: false,
5804 details: None,
5805 operation: None,
5806 },
5807 }],
5808 Some(&[operation]),
5809 )
5810 .unwrap();
5811 first.rebuild_overlay().expect("rebuild overlay");
5812 }
5813 {
5814 let mut reopened = SyncClient::open_path(
5815 "retained-native".to_owned(),
5816 &schema,
5817 ClientLimits::default(),
5818 path.to_str().unwrap(),
5819 )
5820 .unwrap();
5821 let row = reopened
5822 .visible_row(reopened.schema.table("tasks").unwrap(), "t1")
5823 .expect("read visible row")
5824 .unwrap();
5825 assert_eq!(row.0[2], Some(ColumnValue::String("mine".into())));
5826 assert_eq!(
5827 reopened
5828 .commit_outcome("retained")
5829 .unwrap()
5830 .unwrap()
5831 .retained_rows
5832 .unwrap()[0]
5833 .server_row
5834 .as_ref()
5835 .unwrap()["title"],
5836 json!("server")
5837 );
5838 reopened
5839 .conn
5840 .execute("DELETE FROM _syncular_base_tasks WHERE id = 't1'", [])
5841 .unwrap();
5842 reopened.rebuild_overlay().expect("rebuild overlay");
5843 assert!(reopened
5844 .visible_row(reopened.schema.table("tasks").unwrap(), "t1")
5845 .expect("read visible row")
5846 .is_none());
5847 let retained = reopened.commit_outcome("retained").unwrap().unwrap();
5848 assert!(retained.retained_rows.is_some());
5849 assert_eq!(
5850 retained.operations.unwrap()[0].values.as_ref().unwrap()["title"],
5851 json!("mine")
5852 );
5853 reopened
5854 .purge_local_data(&LocalDataPurgeInput {
5855 purge_id: "protected-purge".into(),
5856 targets: vec![LocalDataPurgeTarget {
5857 table: "tasks".into(),
5858 selectors: BTreeMap::from_iter([("project_id".into(), vec!["p1".into()])]),
5859 }],
5860 })
5861 .unwrap();
5862 assert!(reopened
5863 .visible_row(reopened.schema.table("tasks").unwrap(), "t1")
5864 .expect("read visible row")
5865 .is_none());
5866 assert!(reopened
5867 .commit_outcome("retained")
5868 .unwrap()
5869 .unwrap()
5870 .retained_rows
5871 .is_none());
5872 }
5873 std::fs::remove_file(path).unwrap();
5874 }
5875
5876 #[test]
5877 fn durable_conflict_outcome_and_resolution_survive_reopen() {
5878 let path = std::env::temp_dir().join(format!(
5879 "syncular-durable-outcome-{}.db",
5880 uuid::Uuid::new_v4()
5881 ));
5882 let schema = json!({
5883 "version": 1,
5884 "tables": [{
5885 "name": "tasks",
5886 "primaryKey": "id",
5887 "columns": [
5888 { "name": "id", "type": "string", "nullable": false },
5889 { "name": "project_id", "type": "string", "nullable": false }
5890 ],
5891 "scopes": [{ "pattern": "project:{project_id}" }]
5892 }]
5893 });
5894 let conflict = ConflictRecord {
5895 client_commit_id: "losing-commit".to_owned(),
5896 op_index: 0,
5897 table: "tasks".to_owned(),
5898 row_id: "t1".to_owned(),
5899 code: "sync.version_conflict".to_owned(),
5900 message: "stale base version".to_owned(),
5901 server_version: 2,
5902 server_row: Map::from_iter([("id".to_owned(), json!("t1"))]),
5903 conflict_columns: vec!["project_id".to_owned()],
5904 operation: Some(CommitOperation {
5905 table: "tasks".to_owned(),
5906 row_id: "t1".to_owned(),
5907 op: "upsert".to_owned(),
5908 base_version: Some(1),
5909 values: Some(Map::from_iter([
5910 ("id".into(), json!("t1")),
5911 ("project_id".into(), json!("p1")),
5912 ])),
5913 }),
5914 };
5915 let failed_operations = vec![
5916 OutboxOp {
5917 upsert: true,
5918 table: "tasks".to_owned(),
5919 row_id: "t1".to_owned(),
5920 base_version: Some(1),
5921 values: Some(Map::from_iter([
5922 ("id".into(), json!("t1")),
5923 ("project_id".into(), json!("p1")),
5924 ])),
5925 },
5926 OutboxOp {
5927 upsert: true,
5928 table: "tasks".to_owned(),
5929 row_id: "status-event-1".to_owned(),
5930 base_version: Some(0),
5931 values: Some(Map::from_iter([
5932 ("id".into(), json!("status-event-1")),
5933 ("project_id".into(), json!("p1")),
5934 ])),
5935 },
5936 ];
5937
5938 {
5939 let mut first = SyncClient::open_path(
5940 "durable-native".to_owned(),
5941 &schema,
5942 ClientLimits::default(),
5943 path.to_str().expect("UTF-8 temp path"),
5944 )
5945 .expect("first open");
5946 first
5947 .begin_observation("test_outcome")
5948 .expect("begin outcome");
5949 first
5950 .persist_commit_outcome(
5951 "losing-commit",
5952 CommitOutcomeStatus::Conflict,
5953 &[CommitOperationOutcome::Conflict {
5954 conflict: conflict.clone(),
5955 }],
5956 Some(&failed_operations),
5957 )
5958 .expect("persist outcome");
5959 first.conflicts.push(conflict);
5960 first
5961 .finish_observation(
5962 "test_outcome",
5963 ChangeAccumulator {
5964 conflicts: true,
5965 outcomes: true,
5966 ..ChangeAccumulator::default()
5967 },
5968 )
5969 .expect("commit outcome");
5970 }
5971
5972 {
5973 let mut reopened = SyncClient::open_path(
5974 "durable-native".to_owned(),
5975 &schema,
5976 ClientLimits::default(),
5977 path.to_str().expect("UTF-8 temp path"),
5978 )
5979 .expect("reopen");
5980 assert_eq!(reopened.conflicts().len(), 1);
5981 assert_eq!(
5982 reopened.conflicts()[0].conflict_columns,
5983 vec!["project_id".to_owned()],
5984 "conflictColumns survives the journal round trip"
5985 );
5986 let outcome = reopened
5987 .commit_outcome("losing-commit")
5988 .expect("read outcome")
5989 .expect("outcome");
5990 assert_eq!(outcome.status, CommitOutcomeStatus::Conflict);
5991 let operations = outcome.operations.expect("aggregate envelope");
5992 assert_eq!(operations.len(), 2);
5993 assert_eq!(operations[1].row_id, "status-event-1");
5994 let resolved = reopened
5995 .resolve_commit_outcome(ResolveCommitOutcomeInput {
5996 client_commit_id: "losing-commit".to_owned(),
5997 resolution: CommitOutcomeResolution::ResolvedKeepServer,
5998 replacement_client_commit_id: None,
5999 })
6000 .expect("resolve");
6001 assert_eq!(
6002 resolved.resolution,
6003 CommitOutcomeResolution::ResolvedKeepServer
6004 );
6005 assert!(reopened.conflicts().is_empty());
6006 }
6007
6008 let reopened = SyncClient::open_path(
6009 "durable-native".to_owned(),
6010 &schema,
6011 ClientLimits::default(),
6012 path.to_str().expect("UTF-8 temp path"),
6013 )
6014 .expect("second reopen");
6015 assert!(reopened.conflicts().is_empty());
6016 assert_eq!(
6017 reopened
6018 .commit_outcome("losing-commit")
6019 .expect("read outcome")
6020 .expect("outcome")
6021 .resolution,
6022 CommitOutcomeResolution::ResolvedKeepServer
6023 );
6024 drop(reopened);
6025 std::fs::remove_file(path).expect("remove temp database");
6026 }
6027
6028 #[test]
6029 fn file_snapshot_reader_matches_owner_rows_revision_and_coverage() {
6030 let path =
6031 std::env::temp_dir().join(format!("syncular-read-sidecar-{}.db", uuid::Uuid::new_v4()));
6032 let schema = json!({
6033 "version": 1,
6034 "tables": [{
6035 "name": "tasks",
6036 "primaryKey": "id",
6037 "columns": [
6038 { "name": "id", "type": "string", "nullable": false },
6039 { "name": "project_id", "type": "string", "nullable": false }
6040 ],
6041 "scopes": [{ "pattern": "project:{project_id}" }]
6042 }]
6043 });
6044 let mut client = SyncClient::open_path_with_identity(
6045 Some("sidecar-client".to_owned()),
6046 &schema,
6047 ClientLimits::default(),
6048 path.to_str().expect("UTF-8 temp path"),
6049 )
6050 .expect("open owner");
6051 let base = WindowBase {
6052 table: "tasks".to_owned(),
6053 variable: "project_id".to_owned(),
6054 fixed_scopes: Vec::new(),
6055 params: None,
6056 };
6057 client
6058 .set_window(&base, &["one".to_owned()])
6059 .expect("set window");
6060 client
6061 .mutate(vec![Mutation::Upsert {
6062 table: "tasks".to_owned(),
6063 values: Map::from_iter([
6064 ("id".to_owned(), Value::from("t1")),
6065 ("project_id".to_owned(), Value::from("one")),
6066 ]),
6067 base_version: None,
6068 }])
6069 .expect("local mutate");
6070
6071 let coverage = [WindowCoverage {
6072 base,
6073 units: vec!["one".to_owned(), "missing".to_owned()],
6074 }];
6075 let owner = client
6076 .query_snapshot(
6077 "SELECT id, project_id, _sync_version AS server_version FROM tasks ORDER BY id",
6078 &[],
6079 &coverage,
6080 None,
6081 )
6082 .expect("owner snapshot");
6083 let mut reader = FileQuerySnapshotReader::new(path.to_string_lossy());
6084 let sidecar = reader
6085 .query_snapshot(
6086 "SELECT id, project_id, _sync_version AS server_version FROM tasks ORDER BY id",
6087 &[],
6088 &coverage,
6089 )
6090 .expect("sidecar snapshot");
6091
6092 assert_eq!(sidecar.revision, owner.revision);
6093 assert_eq!(sidecar.rows, owner.rows);
6094 assert_eq!(
6095 serde_json::to_value(&sidecar.coverage).expect("serialize sidecar coverage"),
6096 serde_json::to_value(&owner.coverage).expect("serialize owner coverage")
6097 );
6098 assert_eq!(sidecar.revision, "2");
6099 assert_eq!(sidecar.rows[0]["id"], "t1");
6100 assert_eq!(sidecar.rows[0]["server_version"], -1);
6101 assert!(!sidecar.coverage.complete);
6102 assert_eq!(sidecar.coverage.pending.len(), 1);
6103 assert_eq!(sidecar.coverage.missing.len(), 1);
6104
6105 drop(reader);
6106 drop(client);
6107 std::fs::remove_file(path).expect("remove temp database");
6108 }
6109
6110 #[test]
6111 fn file_snapshot_reader_reports_catchup_delivery_and_multi_statement() {
6112 let path =
6113 std::env::temp_dir().join(format!("syncular-read-sidecar-{}.db", uuid::Uuid::new_v4()));
6114 let schema = json!({
6115 "version": 1,
6116 "tables": [{
6117 "name": "tasks",
6118 "primaryKey": "id",
6119 "columns": [
6120 { "name": "id", "type": "string", "nullable": false },
6121 { "name": "project_id", "type": "string", "nullable": false }
6122 ],
6123 "scopes": [{ "pattern": "project:{project_id}" }]
6124 }]
6125 });
6126 let mut client = SyncClient::open_path_with_identity(
6127 Some("sidecar-catchup".to_owned()),
6128 &schema,
6129 ClientLimits::default(),
6130 path.to_str().expect("UTF-8 temp path"),
6131 )
6132 .expect("open owner");
6133 client
6134 .subscribe(
6135 "s1".into(),
6136 "tasks".into(),
6137 vec![("project_id".into(), vec!["one".into()])],
6138 None,
6139 )
6140 .expect("subscribe");
6141 let commit_id = client
6142 .mutate(vec![Mutation::Upsert {
6143 table: "tasks".to_owned(),
6144 values: Map::from_iter([
6145 ("id".to_owned(), Value::from("t1")),
6146 ("project_id".to_owned(), Value::from("one")),
6147 ]),
6148 base_version: None,
6149 }])
6150 .expect("mutate");
6151 let revision = client.local_revision().to_string();
6152
6153 let mut reader = FileQuerySnapshotReader::new(path.to_string_lossy());
6154 assert_eq!(reader.local_revision().expect("revision"), revision);
6155
6156 let read = reader
6157 .snapshot_read(&SnapshotReadRequest {
6158 statements: vec![
6159 SnapshotStatement {
6160 sql: "SELECT 1 AS n",
6161 params: &[],
6162 },
6163 SnapshotStatement {
6164 sql: "SELECT id FROM tasks ORDER BY id",
6165 params: &[],
6166 },
6167 ],
6168 coverage: &[],
6169 subscriptions: vec!["s1".to_owned(), "absent".to_owned()],
6170 commit_ids: vec![commit_id.clone(), "never-known".to_owned()],
6171 })
6172 .expect("snapshot read");
6173 assert_eq!(read.revision, revision);
6174 assert_eq!(read.queries.len(), 2);
6175 assert_eq!(read.queries[0][0]["n"], 1);
6176 assert_eq!(read.queries[1][0]["id"], "t1");
6177 match &read.subscriptions[0] {
6178 SubscriptionCatchup::Known {
6179 status,
6180 cursor,
6181 bootstrap_complete,
6182 known_pending_pages,
6183 ..
6184 } => {
6185 assert_eq!(status, "active");
6186 assert_eq!(*cursor, -1);
6187 assert!(!bootstrap_complete);
6188 assert!(known_pending_pages);
6189 }
6190 other => panic!("expected a known subscription: {other:?}"),
6191 }
6192 assert!(matches!(
6193 &read.subscriptions[1],
6194 SubscriptionCatchup::Unknown { id } if id.as_str() == "absent"
6195 ));
6196 assert!(matches!(
6197 &read.deliveries[0],
6198 CommitDelivery::Pending { client_commit_id } if client_commit_id == &commit_id
6199 ));
6200 assert!(matches!(
6201 &read.deliveries[1],
6202 CommitDelivery::Unknown { client_commit_id } if client_commit_id.as_str() == "never-known"
6203 ));
6204
6205 assert!(reader
6207 .query_snapshot("SELECT * FROM does_not_exist", &[], &[])
6208 .is_err());
6209 assert_eq!(
6210 reader.local_revision().expect("revision after rollback"),
6211 revision
6212 );
6213 assert!(reader
6215 .query_snapshot("DELETE FROM tasks", &[], &[])
6216 .is_err());
6217
6218 client
6220 .conn
6221 .execute(
6222 "UPDATE _syncular_meta SET value = '+1' WHERE key = 'localRevision'",
6223 [],
6224 )
6225 .expect("corrupt revision");
6226 let failure = reader.local_revision().expect_err("corrupt revision fails");
6227 assert_eq!(failure.code, Some("sync.local_corrupt"));
6228
6229 drop(reader);
6230 drop(client);
6231 std::fs::remove_file(path).expect("remove temp database");
6232 }
6233
6234 #[test]
6235 fn snapshot_delivery_is_the_persisted_journal_view_without_retained_rows() {
6236 let path =
6237 std::env::temp_dir().join(format!("syncular-read-sidecar-{}.db", uuid::Uuid::new_v4()));
6238 let schema = json!({
6239 "version": 1,
6240 "tables": [{
6241 "name": "tasks",
6242 "primaryKey": "id",
6243 "columns": [
6244 { "name": "id", "type": "string", "nullable": false },
6245 { "name": "project_id", "type": "string", "nullable": false }
6246 ],
6247 "scopes": [{ "pattern": "project:{project_id}" }]
6248 }]
6249 });
6250 let mut client = SyncClient::open_path_with_identity(
6251 Some("sidecar-delivery".to_owned()),
6252 &schema,
6253 ClientLimits::default(),
6254 path.to_str().expect("UTF-8 temp path"),
6255 )
6256 .expect("open owner");
6257 client.begin_observation("test_delivery").expect("observe");
6258 let operations = [OutboxOp {
6259 upsert: true,
6260 table: "tasks".to_owned(),
6261 row_id: "t1".to_owned(),
6262 base_version: Some(1),
6263 values: Some(Map::from_iter([
6264 ("id".into(), json!("t1")),
6265 ("project_id".into(), json!("p1")),
6266 ])),
6267 }];
6268 client
6269 .persist_commit_outcome(
6270 "conflict-commit",
6271 CommitOutcomeStatus::Conflict,
6272 &[CommitOperationOutcome::Conflict {
6273 conflict: ConflictRecord {
6274 client_commit_id: "conflict-commit".to_owned(),
6275 op_index: 0,
6276 table: "tasks".to_owned(),
6277 row_id: "t1".to_owned(),
6278 code: "sync.version_conflict".to_owned(),
6279 message: "stale base version".to_owned(),
6280 server_version: 2,
6281 server_row: Map::from_iter([("id".to_owned(), json!("t1"))]),
6282 conflict_columns: vec!["project_id".to_owned()],
6283 operation: None,
6284 },
6285 }],
6286 Some(&operations),
6287 )
6288 .expect("persist conflict");
6289 client
6290 .persist_commit_outcome(
6291 "rejected-commit",
6292 CommitOutcomeStatus::Rejected,
6293 &[CommitOperationOutcome::Error {
6294 rejection: RejectionRecord {
6295 client_commit_id: "rejected-commit".to_owned(),
6296 op_index: 0,
6297 code: "sync.unknown_table".to_owned(),
6298 message: "unknown table".to_owned(),
6299 retryable: false,
6300 details: None,
6301 operation: None,
6302 },
6303 }],
6304 Some(&operations),
6305 )
6306 .expect("persist rejection");
6307 client
6308 .finish_observation(
6309 "test_delivery",
6310 ChangeAccumulator {
6311 conflicts: true,
6312 outcomes: true,
6313 ..ChangeAccumulator::default()
6314 },
6315 )
6316 .expect("commit outcomes");
6317
6318 let mut reader = FileQuerySnapshotReader::new(path.to_string_lossy());
6319 match reader.commit_delivery("conflict-commit").expect("conflict") {
6320 CommitDelivery::Known { outcome, .. } => {
6321 assert_eq!(outcome.status, CommitOutcomeStatus::Conflict);
6322 assert!(matches!(
6323 outcome.results.as_slice(),
6324 [CommitOperationOutcome::Conflict { conflict }]
6325 if conflict.code == "sync.version_conflict"
6326 && conflict.server_version == 2
6327 ));
6328 assert_eq!(outcome.operations.as_ref().map(Vec::len), Some(1));
6329 assert!(
6330 outcome.retained_rows.is_none(),
6331 "the sidecar reports the persisted view, not owner-derived retained rows"
6332 );
6333 }
6334 other => panic!("expected a known conflict: {other:?}"),
6335 }
6336 match reader
6337 .commit_delivery("rejected-commit")
6338 .expect("rejection")
6339 {
6340 CommitDelivery::Known { outcome, .. } => {
6341 assert_eq!(outcome.status, CommitOutcomeStatus::Rejected);
6342 assert!(matches!(
6343 outcome.results.as_slice(),
6344 [CommitOperationOutcome::Error { rejection }]
6345 if rejection.code == "sync.unknown_table"
6346 ));
6347 assert_eq!(outcome.operations.as_ref().map(Vec::len), Some(1));
6348 assert!(outcome.retained_rows.is_none());
6349 }
6350 other => panic!("expected a known rejection: {other:?}"),
6351 }
6352 drop(reader);
6353 drop(client);
6354 std::fs::remove_file(path).expect("remove temp database");
6355 }
6356
6357 #[test]
6358 fn snapshot_read_isolates_a_concurrent_writer_commit() {
6359 use std::sync::mpsc::channel;
6360 let path = std::env::temp_dir().join(format!(
6361 "syncular-read-concurrent-{}.db",
6362 uuid::Uuid::new_v4()
6363 ));
6364 let schema = json!({
6365 "version": 1,
6366 "tables": [{
6367 "name": "tasks",
6368 "primaryKey": "id",
6369 "columns": [
6370 { "name": "id", "type": "string", "nullable": false },
6371 { "name": "project_id", "type": "string", "nullable": false }
6372 ],
6373 "scopes": [{ "pattern": "project:{project_id}" }]
6374 }]
6375 });
6376 let mut client = SyncClient::open_path_with_identity(
6377 Some("sidecar-concurrent".to_owned()),
6378 &schema,
6379 ClientLimits::default(),
6380 path.to_str().expect("UTF-8 temp path"),
6381 )
6382 .expect("open owner");
6383 client
6384 .mutate(vec![Mutation::Upsert {
6385 table: "tasks".to_owned(),
6386 values: Map::from_iter([
6387 ("id".to_owned(), Value::from("t1")),
6388 ("project_id".to_owned(), Value::from("one")),
6389 ]),
6390 base_version: None,
6391 }])
6392 .expect("seed row");
6393 let revision_before = client.local_revision().to_string();
6394
6395 let (ready_tx, ready_rx) = channel::<()>();
6398 let (done_tx, done_rx) = channel::<()>();
6399 let writer_path = path.to_string_lossy().into_owned();
6400 let writer_schema = schema.clone();
6401 let writer = std::thread::spawn(move || {
6402 let mut writer = SyncClient::open_path_with_identity(
6403 Some("sidecar-concurrent".to_owned()),
6404 &writer_schema,
6405 ClientLimits::default(),
6406 &writer_path,
6407 )
6408 .expect("open writer");
6409 ready_rx.recv().expect("reader signalled");
6410 writer
6411 .mutate(vec![Mutation::Upsert {
6412 table: "tasks".to_owned(),
6413 values: Map::from_iter([
6414 ("id".to_owned(), Value::from("t2")),
6415 ("project_id".to_owned(), Value::from("one")),
6416 ]),
6417 base_version: None,
6418 }])
6419 .expect("writer commit");
6420 let _ = done_tx.send(());
6421 });
6422 let mut reader = FileQuerySnapshotReader::new(path.to_string_lossy());
6423 SNAPSHOT_READ_HOOK.with(|slot| {
6424 *slot.borrow_mut() = Some(Box::new(move || {
6425 ready_tx.send(()).expect("signal writer");
6426 done_rx.recv().expect("writer finished");
6427 }));
6428 });
6429 let read = reader
6430 .snapshot_read(&SnapshotReadRequest {
6431 statements: vec![
6432 SnapshotStatement {
6433 sql: "SELECT count(*) AS n FROM tasks",
6434 params: &[],
6435 },
6436 SnapshotStatement {
6437 sql: "SELECT count(*) AS n FROM tasks",
6438 params: &[],
6439 },
6440 ],
6441 coverage: &[],
6442 subscriptions: Vec::new(),
6443 commit_ids: Vec::new(),
6444 })
6445 .expect("snapshot read");
6446 assert_eq!(
6447 read.queries[0][0]["n"], 1,
6448 "first statement sees the pre-commit row"
6449 );
6450 assert_eq!(
6451 read.queries[1][0]["n"], 1,
6452 "a later statement keeps the one read snapshot"
6453 );
6454 assert_eq!(
6455 read.revision, revision_before,
6456 "the read reports the snapshot revision, not the writer's"
6457 );
6458 writer.join().expect("join writer");
6459
6460 assert_ne!(
6461 reader.local_revision().expect("revision after commit"),
6462 revision_before,
6463 "the next read observes the committed revision"
6464 );
6465 let after = reader
6466 .query_snapshot("SELECT count(*) AS n FROM tasks", &[], &[])
6467 .expect("count after commit");
6468 assert_eq!(
6469 after.rows[0]["n"], 2,
6470 "the committed row is visible afterwards"
6471 );
6472
6473 drop(reader);
6474 drop(client);
6475 std::fs::remove_file(path).expect("remove temp database");
6476 }
6477
6478 #[test]
6479 fn snapshot_read_rejects_every_non_canonical_revision() {
6480 let path = std::env::temp_dir().join(format!(
6481 "syncular-read-revision-{}.db",
6482 uuid::Uuid::new_v4()
6483 ));
6484 let schema = json!({
6485 "version": 1,
6486 "tables": [{
6487 "name": "tasks",
6488 "primaryKey": "id",
6489 "columns": [
6490 { "name": "id", "type": "string", "nullable": false },
6491 { "name": "project_id", "type": "string", "nullable": false }
6492 ],
6493 "scopes": [{ "pattern": "project:{project_id}" }]
6494 }]
6495 });
6496 let client = SyncClient::open_path_with_identity(
6497 Some("sidecar-revision".to_owned()),
6498 &schema,
6499 ClientLimits::default(),
6500 path.to_str().expect("UTF-8 temp path"),
6501 )
6502 .expect("open owner");
6503 let mut reader = FileQuerySnapshotReader::new(path.to_string_lossy());
6504 for bad in [
6505 "+1",
6506 "01",
6507 "-1",
6508 " 1",
6509 "1 ",
6510 "18446744073709551616",
6511 "1.0",
6512 "abc",
6513 "",
6514 ] {
6515 client
6516 .conn
6517 .execute(
6518 "UPDATE _syncular_meta SET value = ?1 WHERE key = 'localRevision'",
6519 rusqlite::params![bad],
6520 )
6521 .expect("corrupt revision");
6522 let failure = reader.local_revision().expect_err("corrupt marker fails");
6523 assert_eq!(failure.code, Some("sync.local_corrupt"), "{bad:?}");
6524 }
6525 for value in ["X'31'", "CAST(X'FF' AS TEXT)"] {
6526 client
6527 .conn
6528 .execute_batch(&format!(
6529 "UPDATE _syncular_meta SET value={value} WHERE key='localRevision'"
6530 ))
6531 .expect("corrupt stored revision type");
6532 assert_eq!(
6533 reader.local_revision().expect_err("invalid SQL field").code,
6534 Some("sync.local_corrupt")
6535 );
6536 }
6537 client
6539 .conn
6540 .execute("DELETE FROM _syncular_meta WHERE key = 'localRevision'", [])
6541 .expect("delete marker");
6542 assert_eq!(reader.local_revision().expect("legacy zero"), "0");
6543
6544 drop(reader);
6545 drop(client);
6546 std::fs::remove_file(path).expect("remove temp database");
6547 }
6548
6549 #[test]
6550 fn snapshot_read_rejects_corrupt_subscription_state() {
6551 let path = std::env::temp_dir().join(format!(
6552 "syncular-read-substate-{}.db",
6553 uuid::Uuid::new_v4()
6554 ));
6555 let schema = json!({
6556 "version": 1,
6557 "tables": [{
6558 "name": "tasks",
6559 "primaryKey": "id",
6560 "columns": [
6561 { "name": "id", "type": "string", "nullable": false },
6562 { "name": "project_id", "type": "string", "nullable": false }
6563 ],
6564 "scopes": [{ "pattern": "project:{project_id}" }]
6565 }]
6566 });
6567 let mut client = SyncClient::open_path_with_identity(
6568 Some("sidecar-substate".to_owned()),
6569 &schema,
6570 ClientLimits::default(),
6571 path.to_str().expect("UTF-8 temp path"),
6572 )
6573 .expect("open owner");
6574 client
6575 .subscribe(
6576 "s1".into(),
6577 "tasks".into(),
6578 vec![("project_id".into(), vec!["one".into()])],
6579 None,
6580 )
6581 .expect("subscribe");
6582 let mut reader = FileQuerySnapshotReader::new(path.to_string_lossy());
6583 for bad in [
6584 "not json".to_owned(),
6585 "{}".to_owned(),
6586 json!({ "status": "bogus", "cursor": 0 }).to_string(),
6587 json!({ "status": "active", "cursor": "x" }).to_string(),
6588 json!({ "status": "active", "cursor": 0, "bootstrapState": 7 }).to_string(),
6589 json!({ "status": "active", "cursor": 0, "reasonCode": 7 }).to_string(),
6590 ] {
6591 client
6592 .conn
6593 .execute(
6594 "UPDATE _syncular_subscriptions SET state_json = ?1 WHERE id = 's1'",
6595 rusqlite::params![bad],
6596 )
6597 .expect("corrupt subscription state");
6598 let failure = reader
6599 .subscription_catchup("s1")
6600 .expect_err("corrupt state fails");
6601 assert_eq!(failure.code, Some("sync.local_corrupt"), "{bad}");
6602 }
6603
6604 for assignment in [
6605 "tbl=X'7461736b73'",
6606 "state_json=X'7b7d'",
6607 "state_json=CAST(X'FF' AS TEXT)",
6608 ] {
6609 client.conn.execute_batch(&format!(
6610 "UPDATE _syncular_subscriptions SET tbl='tasks',state_json='{{\"status\":\"active\",\"cursor\":0}}'; UPDATE _syncular_subscriptions SET {assignment} WHERE id='s1'"
6611 )).expect("corrupt stored subscription type");
6612 assert_eq!(
6613 reader
6614 .subscription_catchup("s1")
6615 .expect_err("invalid SQL field")
6616 .code,
6617 Some("sync.local_corrupt")
6618 );
6619 }
6620
6621 drop(reader);
6622 drop(client);
6623 std::fs::remove_file(path).expect("remove temp database");
6624 }
6625
6626 #[test]
6627 fn snapshot_read_rolls_back_a_later_invalid_statement_and_guards_every_write() {
6628 let path = std::env::temp_dir().join(format!(
6629 "syncular-read-rollback-{}.db",
6630 uuid::Uuid::new_v4()
6631 ));
6632 let schema = json!({
6633 "version": 1,
6634 "tables": [{
6635 "name": "tasks",
6636 "primaryKey": "id",
6637 "columns": [
6638 { "name": "id", "type": "string", "nullable": false },
6639 { "name": "project_id", "type": "string", "nullable": false }
6640 ],
6641 "scopes": [{ "pattern": "project:{project_id}" }]
6642 }]
6643 });
6644 let mut client = SyncClient::open_path_with_identity(
6645 Some("sidecar-rollback".to_owned()),
6646 &schema,
6647 ClientLimits::default(),
6648 path.to_str().expect("UTF-8 temp path"),
6649 )
6650 .expect("open owner");
6651 let commit_id = client
6652 .mutate(vec![Mutation::Upsert {
6653 table: "tasks".to_owned(),
6654 values: Map::from_iter([
6655 ("id".to_owned(), Value::from("t1")),
6656 ("project_id".to_owned(), Value::from("one")),
6657 ]),
6658 base_version: None,
6659 }])
6660 .expect("seed row");
6661 let mut reader = FileQuerySnapshotReader::new(path.to_string_lossy());
6662 let revision = reader.local_revision().expect("revision");
6663
6664 let failure = reader
6667 .snapshot_read(&SnapshotReadRequest {
6668 statements: vec![
6669 SnapshotStatement {
6670 sql: "SELECT id FROM tasks",
6671 params: &[],
6672 },
6673 SnapshotStatement {
6674 sql: "SELECT * FROM does_not_exist",
6675 params: &[],
6676 },
6677 ],
6678 coverage: &[],
6679 subscriptions: Vec::new(),
6680 commit_ids: vec![commit_id.clone()],
6681 })
6682 .expect_err("second statement fails");
6683 assert_eq!(failure.code, None);
6684 assert_eq!(
6685 reader.local_revision().expect("revision after rollback"),
6686 revision,
6687 "a rolled-back read consumes no revision"
6688 );
6689 assert!(
6690 !matches!(
6691 reader
6692 .commit_delivery(&commit_id)
6693 .expect("delivery after rollback"),
6694 CommitDelivery::Unknown { .. }
6695 ),
6696 "the same reader serves the next read after releasing the connection"
6697 );
6698
6699 let guard = reader
6701 .snapshot_read(&SnapshotReadRequest {
6702 statements: vec![SnapshotStatement {
6703 sql: "DELETE FROM tasks",
6704 params: &[],
6705 }],
6706 coverage: &[],
6707 subscriptions: Vec::new(),
6708 commit_ids: Vec::new(),
6709 })
6710 .expect_err("write guard");
6711 assert!(guard.message.contains("read-only"), "{}", guard.message);
6712
6713 drop(reader);
6714 drop(client);
6715 std::fs::remove_file(path).expect("remove temp database");
6716 }
6717
6718 #[test]
6719 fn owned_query_failures_classify_order_bound_and_clear() {
6720 let io = QueryReadFailure::from(rusqlite::Error::SqliteFailure(
6721 rusqlite::ffi::Error::new(266),
6722 Some("private SQLite prose".to_owned()),
6723 ));
6724 assert_eq!(io.code, Some("client.storage_io"));
6725 assert_eq!(io.sqlite_code, Some(266));
6726 assert_eq!(io.message, "local SQLite storage I/O failed");
6727 let corrupt = QueryReadFailure::from(rusqlite::Error::SqliteFailure(
6728 rusqlite::ffi::Error::new(26),
6729 None,
6730 ));
6731 assert_eq!(corrupt.code, Some("client.storage_corrupt"));
6732 assert!(!io.retryable());
6733 assert!(!corrupt.retryable());
6734
6735 for (raw, extended) in [(5, 5), (5 | (1 << 8), 261), (6, 6), (6 | (1 << 8), 262)] {
6738 let busy = QueryReadFailure::from(rusqlite::Error::SqliteFailure(
6739 rusqlite::ffi::Error::new(raw),
6740 Some("database is locked".to_owned()),
6741 ));
6742 assert_eq!(busy.code, Some("client.storage_busy"));
6743 assert_eq!(busy.sqlite_code, Some(extended));
6744 assert_eq!(busy.message, "local SQLite storage is busy");
6745 assert!(busy.retryable());
6746 }
6747 let unrelated = QueryReadFailure::from(rusqlite::Error::SqliteFailure(
6748 rusqlite::ffi::Error::new(1),
6749 Some("generic SQLite prose".to_owned()),
6750 ));
6751 assert_eq!(unrelated.code, None);
6752 assert!(!unrelated.retryable());
6753
6754 let mut client = client();
6755 let generic = QueryReadFailure::from("private query prose".to_owned());
6756 client.record_query_read(
6757 QueryOwner {
6758 id: "first",
6759 tables: &["tasks", "tasks"],
6760 },
6761 Some(&generic),
6762 );
6763 client.record_query_read(
6764 QueryOwner {
6765 id: "second",
6766 tables: &["tasks"],
6767 },
6768 Some(&generic),
6769 );
6770 client.record_query_read(
6771 QueryOwner {
6772 id: "first",
6773 tables: &["docs"],
6774 },
6775 Some(&io),
6776 );
6777 let failures = &client
6778 .diagnostics_snapshot(&Default::default())
6779 .expect("diagnostics")
6780 .query_failures;
6781 assert_eq!(
6782 failures
6783 .iter()
6784 .map(|entry| entry.id.as_str())
6785 .collect::<Vec<_>>(),
6786 vec!["second", "first"]
6787 );
6788 assert_eq!(failures[1].tables, vec!["docs"]);
6789 assert_eq!(failures[1].code, "client.storage_io");
6790
6791 for index in 0..=MAX_DIAGNOSTIC_QUERY_FAILURES {
6792 client.record_query_read(
6793 QueryOwner {
6794 id: &format!("query-{index}"),
6795 tables: &["tasks"],
6796 },
6797 Some(&generic),
6798 );
6799 }
6800 let failures = &client
6801 .diagnostics_snapshot(&Default::default())
6802 .expect("bounded diagnostics")
6803 .query_failures;
6804 assert_eq!(failures.len(), MAX_DIAGNOSTIC_QUERY_FAILURES);
6805 assert_eq!(failures.last().expect("last failure").id, "query-256");
6806
6807 client
6808 .query_snapshot(
6809 "SELECT id FROM tasks",
6810 &[],
6811 &[],
6812 Some(QueryOwner {
6813 id: "query-256",
6814 tables: &["tasks"],
6815 }),
6816 )
6817 .expect("successful owned read");
6818 assert!(client
6819 .diagnostics_snapshot(&Default::default())
6820 .expect("cleared diagnostics")
6821 .query_failures
6822 .iter()
6823 .all(|entry| entry.id != "query-256"));
6824 }
6825
6826 #[test]
6827 fn image_chunks_keep_their_committed_prefix_after_a_later_invalid_row() {
6828 let mut client = client();
6829 let image = Connection::open_in_memory().unwrap();
6830 image.execute_batch("CREATE TABLE tasks(id TEXT PRIMARY KEY, project_id TEXT, _syncular_version INTEGER NOT NULL);
6831 WITH RECURSIVE n(x) AS (SELECT 1 UNION ALL SELECT x+1 FROM n WHERE x<2050)
6832 INSERT INTO tasks SELECT printf('%05d',x),CASE WHEN x=1500 THEN NULL ELSE 'p1' END,1 FROM n;
6833 CREATE TABLE _syncular_segment(format INTEGER, \"table\" TEXT, \"schemaVersion\" INTEGER, \"asOfCommitSeq\" INTEGER, \"scopeDigest\" TEXT, \"rowCount\" INTEGER);
6834 INSERT INTO _syncular_segment VALUES (1,'tasks',1,7,'digest',2050);").unwrap();
6835 let table = client.schema.table("tasks").unwrap().clone();
6836 let scopes = vec![("project_id".into(), vec!["p1".into()])];
6837 assert!(client
6838 .apply_sqlite_image(&image, &table, true, &scopes, 2050, 7, "digest")
6839 .is_err());
6840 assert!(client.conn.is_autocommit());
6841 assert_eq!(
6842 client
6843 .query("SELECT count(*) AS n FROM tasks", &[])
6844 .unwrap()[0]["n"],
6845 1024
6846 );
6847 assert_eq!(client.drain_change_batches().len(), 1);
6848 image
6849 .execute("UPDATE tasks SET project_id='p1' WHERE id='01500'", [])
6850 .unwrap();
6851 assert!(matches!(
6852 client.apply_sqlite_image(&image, &table, true, &scopes, 2050, 7, "digest"),
6853 Ok(2050)
6854 ));
6855 assert_eq!(
6856 client
6857 .query("SELECT count(*) AS n FROM tasks", &[])
6858 .unwrap()[0]["n"],
6859 2050
6860 );
6861 assert_eq!(client.drain_change_batches().len(), 3);
6862 }
6863
6864 #[test]
6865 fn eviction_chunks_commit_before_failure_and_resume_with_exact_scope() {
6866 let mut client = client();
6867 let base = WindowBase {
6868 table: "tasks".into(),
6869 variable: "project_id".into(),
6870 fixed_scopes: Vec::new(),
6871 params: None,
6872 };
6873 client
6874 .set_window(&base, &["p1".into(), "p2".into()])
6875 .unwrap();
6876 client.conn.execute_batch("WITH RECURSIVE n(x) AS (SELECT 1 UNION ALL SELECT x+1 FROM n WHERE x<2050)
6877 INSERT INTO _syncular_base_tasks SELECT printf('%05d',x),'p1',1 FROM n;
6878 INSERT INTO _syncular_base_tasks VALUES ('held','p2',1);
6879 INSERT INTO tasks SELECT * FROM _syncular_base_tasks;
6880 CREATE TRIGGER interrupt_evict BEFORE DELETE ON tasks WHEN old.id='01500' BEGIN SELECT RAISE(ABORT,'interrupted chunk'); END;").unwrap();
6881 client.drain_change_batches();
6882 assert!(client.set_window(&base, &["p2".into()]).is_err());
6883 assert!(client.conn.is_autocommit());
6884 assert_eq!(
6885 client
6886 .query("SELECT count(*) AS n FROM tasks", &[])
6887 .unwrap()[0]["n"],
6888 1027
6889 );
6890 assert_eq!(client.window_state(&base).units, vec!["p2"]);
6891 assert_eq!(client.load_pending_evictions().unwrap().len(), 1);
6892 assert_eq!(client.drain_change_batches().len(), 1);
6893 client
6894 .conn
6895 .execute_batch("DROP TRIGGER interrupt_evict")
6896 .unwrap();
6897 client.drain_pending_evictions().unwrap();
6898 assert!(client.load_pending_evictions().unwrap().is_empty());
6899 assert_eq!(
6900 client.query("SELECT id FROM tasks", &[]).unwrap()[0]["id"],
6901 "held"
6902 );
6903 assert_eq!(client.drain_change_batches().len(), 2);
6904 }
6905
6906 #[test]
6907 fn eviction_marker_failures_roll_back_window_changes() {
6908 let mut client = client();
6909 let base = WindowBase {
6910 table: "tasks".into(),
6911 variable: "project_id".into(),
6912 fixed_scopes: Vec::new(),
6913 params: None,
6914 };
6915 client.set_window(&base, &["p1".into()]).unwrap();
6916 client.conn.execute_batch("WITH RECURSIVE n(x) AS (SELECT 1 UNION ALL SELECT x+1 FROM n WHERE x<2050)
6917 INSERT INTO _syncular_base_tasks SELECT printf('%05d',x),'p1',1 FROM n;
6918 INSERT INTO tasks SELECT * FROM _syncular_base_tasks;
6919 CREATE TRIGGER fail_marker BEFORE INSERT ON _syncular_window_pending_evict BEGIN SELECT RAISE(ABORT,'marker failed'); END;").unwrap();
6920 let revision = client.local_revision();
6921 assert!(client.set_window(&base, &[]).is_err());
6922 assert!(client.conn.is_autocommit());
6923 assert_eq!(client.local_revision(), revision);
6924 assert_eq!(
6925 client
6926 .query("SELECT count(*) AS n FROM tasks", &[])
6927 .unwrap()[0]["n"],
6928 2050
6929 );
6930 assert_eq!(client.window_state(&base).units, vec!["p1"]);
6931 client.conn.execute_batch("DROP TRIGGER fail_marker;
6932 CREATE TRIGGER interrupt_evict BEFORE DELETE ON tasks WHEN old.id='01500' BEGIN SELECT RAISE(ABORT,'interrupted chunk'); END;").unwrap();
6933 assert!(client.set_window(&base, &[]).is_err());
6934 client.conn.execute_batch("DROP TRIGGER interrupt_evict;
6935 CREATE TRIGGER fail_marker BEFORE DELETE ON _syncular_window_pending_evict BEGIN SELECT RAISE(ABORT,'marker failed'); END;").unwrap();
6936 assert!(client.drain_pending_evictions().is_err());
6937 assert!(client.conn.is_autocommit());
6938 assert_eq!(
6939 client
6940 .query("SELECT count(*) AS n FROM tasks", &[])
6941 .unwrap()[0]["n"],
6942 2
6943 );
6944 assert!(client.set_window(&base, &["p1".into()]).is_err());
6945 assert!(client.conn.is_autocommit());
6946 assert!(client.window_state(&base).units.is_empty());
6947 assert!(client.subs.is_empty());
6948 assert_eq!(
6949 client
6950 .query(
6951 "SELECT count(*) AS n FROM _syncular_window_pending_evict",
6952 &[]
6953 )
6954 .unwrap()[0]["n"],
6955 1
6956 );
6957 client
6958 .conn
6959 .execute_batch("DROP TRIGGER fail_marker")
6960 .unwrap();
6961 client.set_window(&base, &["p1".into()]).unwrap();
6962 assert_eq!(client.window_state(&base).units, vec!["p1"]);
6963 }
6964
6965 #[test]
6966 fn malformed_eviction_marker_is_reported_without_starting_cleanup() {
6967 let mut client = client();
6968 client
6969 .conn
6970 .execute(
6971 "INSERT INTO _syncular_window_pending_evict VALUES ('broken','tasks','{')",
6972 [],
6973 )
6974 .unwrap();
6975 assert_eq!(
6976 client.drain_pending_evictions().unwrap_err(),
6977 "sync.local_corrupt: invalid pending eviction scopes"
6978 );
6979 assert!(client.conn.is_autocommit());
6980 assert!(client.enqueue_startup_sync_if_needed().is_err());
6981 }
6982
6983 #[test]
6984 fn image_chunks_reject_null_primary_keys() {
6985 let mut client = client();
6986 let image = Connection::open_in_memory().unwrap();
6987 image.execute_batch("CREATE TABLE tasks(id TEXT PRIMARY KEY, project_id TEXT NOT NULL, _syncular_version INTEGER NOT NULL);
6988 WITH RECURSIVE n(x) AS (SELECT 1 UNION ALL SELECT x+1 FROM n WHERE x<1025)
6989 INSERT INTO tasks SELECT NULL,'p1',1 FROM n;
6990 CREATE TABLE _syncular_segment(format INTEGER, \"table\" TEXT, \"schemaVersion\" INTEGER, \"asOfCommitSeq\" INTEGER, \"scopeDigest\" TEXT, \"rowCount\" INTEGER);
6991 INSERT INTO _syncular_segment VALUES (1,'tasks',1,7,'digest',1025);").unwrap();
6992 let table = client.schema.table("tasks").unwrap().clone();
6993 assert!(client
6994 .apply_sqlite_image(
6995 &image,
6996 &table,
6997 true,
6998 &[("project_id".into(), vec!["p1".into()])],
6999 1025,
7000 7,
7001 "digest"
7002 )
7003 .is_err());
7004 assert!(client.conn.is_autocommit());
7005 assert_eq!(
7006 client
7007 .query("SELECT count(*) AS n FROM tasks", &[])
7008 .unwrap()[0]["n"],
7009 0
7010 );
7011 }
7012
7013 #[test]
7014 fn import_chunks_with_unique_constraints_match_full_pending_replay() {
7015 for images in [false, true] {
7016 let schema = json!({"version":1,"tables": (["tasks", "notes"].map(|name| json!({
7017 "name":name,"primaryKey":"id","columns":[
7018 {"name":"id","type":"string","nullable":false},
7019 {"name":"project_id","type":"string","nullable":false},
7020 {"name":"title","type":"string","nullable":false}],
7021 "scopes":[{"pattern":"project:{project_id}"}],
7022 "indexes":[{"name":format!("{name}_unique_title"),"columns":["title"],"unique":true}],
7023 "ftsIndexes":[{"name":format!("{name}_fts"),"columns":["title"],"tokenize":"unicode61"}]
7024 })))});
7025 let mut actual =
7026 SyncClient::new("actual".into(), &schema, ClientLimits::default()).unwrap();
7027 let mut reference =
7028 SyncClient::new("reference".into(), &schema, ClientLimits::default()).unwrap();
7029 let scopes = vec![("project_id".into(), vec!["p1".into()])];
7030 for client in [&mut actual, &mut reference] {
7031 client
7032 .subscribe("tasks".into(), "tasks".into(), scopes.clone(), None)
7033 .unwrap();
7034 client.subs[0].effective = Some(scopes.clone());
7035 for (table, id, title) in [
7036 ("tasks", "a", "original"),
7037 ("tasks", "b", "occupied"),
7038 ("tasks", "c", "deleted"),
7039 ("notes", "n", "untouched"),
7040 ] {
7041 client
7042 .write_base_row(
7043 table,
7044 &vec![
7045 Some(ColumnValue::String(id.into())),
7046 Some(ColumnValue::String("p1".into())),
7047 Some(ColumnValue::String(title.into())),
7048 ],
7049 1,
7050 )
7051 .unwrap();
7052 }
7053 client.rebuild_overlay_if_dirty().expect("rebuild overlay");
7054 client
7055 .mutate(vec![
7056 Mutation::Upsert {
7057 table: "tasks".into(),
7058 values: Map::from_iter([
7059 ("id".into(), Value::from("a")),
7060 ("project_id".into(), Value::from("p1")),
7061 ("title".into(), Value::from("available")),
7062 ]),
7063 base_version: None,
7064 },
7065 Mutation::Delete {
7066 table: "tasks".into(),
7067 row_id: "c".into(),
7068 base_version: None,
7069 },
7070 Mutation::Upsert {
7071 table: "tasks".into(),
7072 values: Map::from_iter([
7073 ("id".into(), Value::from("local")),
7074 ("project_id".into(), Value::from("p1")),
7075 ("title".into(), Value::from("local value")),
7076 ]),
7077 base_version: None,
7078 },
7079 Mutation::Upsert {
7080 table: "notes".into(),
7081 values: Map::from_iter([
7082 ("id".into(), Value::from("n")),
7083 ("project_id".into(), Value::from("p1")),
7084 ("title".into(), Value::from("pending note")),
7085 ]),
7086 base_version: None,
7087 },
7088 ])
7089 .unwrap();
7090 }
7091 let pending = actual.pending_commit_ids();
7092 actual.overlay_rebuild_count.set(0);
7093 for (id, title) in [
7094 ("b", "available"),
7095 ("b", "occupied"),
7096 ("a", "server original"),
7097 ("d", "local value"),
7098 ("d", "released"),
7099 ("c", "redelivered"),
7100 ] {
7101 let table = actual.schema.table("tasks").unwrap().clone();
7102 let values = vec![
7103 Some(ColumnValue::String(id.into())),
7104 Some(ColumnValue::String("p1".into())),
7105 Some(ColumnValue::String(title.into())),
7106 ];
7107 if images {
7108 let image = Connection::open_in_memory().unwrap();
7109 image.execute_batch("CREATE TABLE tasks(id TEXT PRIMARY KEY, project_id TEXT NOT NULL, title TEXT NOT NULL, _syncular_version INTEGER NOT NULL);
7110 CREATE TABLE _syncular_segment(format INTEGER, \"table\" TEXT, \"schemaVersion\" INTEGER, \"asOfCommitSeq\" INTEGER, \"scopeDigest\" TEXT, \"rowCount\" INTEGER);
7111 INSERT INTO _syncular_segment VALUES (1,'tasks',1,7,'digest',1);").unwrap();
7112 image
7113 .execute("INSERT INTO tasks VALUES (?1,'p1',?2,2)", [id, title])
7114 .unwrap();
7115 assert!(matches!(
7116 actual.apply_sqlite_image(&image, &table, false, &scopes, 1, 7, "digest"),
7117 Ok(1)
7118 ));
7119 } else {
7120 let segment = RowsSegment {
7121 table: "tasks".into(),
7122 schema_version: 1,
7123 columns: table.wire_columns.clone(),
7124 blocks: vec![vec![ssp2::segment::SegmentRow {
7125 server_version: 2,
7126 values: values.clone(),
7127 }]],
7128 };
7129 assert!(matches!(actual.apply_segment(0, &segment, false), Ok(1)));
7130 }
7131 reference.write_base_row("tasks", &values, 2).unwrap();
7132 reference.rebuild_overlay().expect("rebuild overlay");
7133 for table in ["tasks", "notes"] {
7134 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")] {
7135 assert_eq!(actual.query(&sql, &[]).unwrap(), reference.query(&sql, &[]).unwrap(), "images={images}, {id}/{title}: {sql}");
7136 }
7137 }
7138 assert_eq!(actual.pending_commit_ids(), pending);
7139 }
7140 assert_eq!(
7141 actual.overlay_rebuild_count.get(),
7142 0,
7143 "each chunk must leave unrelated tables alone"
7144 );
7145 let before = actual
7146 .query("SELECT * FROM tasks ORDER BY id", &[])
7147 .unwrap();
7148 let fts_before = actual
7149 .query("SELECT * FROM tasks_fts ORDER BY _syncular_source_id", &[])
7150 .unwrap();
7151 let revision = actual.local_revision();
7152 actual.conn.execute_batch("CREATE TRIGGER fail_import BEFORE INSERT ON tasks WHEN new.id='b' BEGIN SELECT RAISE(ABORT,'restore failed'); END").unwrap();
7153 let table = actual.schema.table("tasks").unwrap().clone();
7154 let segment = RowsSegment {
7155 table: "tasks".into(),
7156 schema_version: 1,
7157 columns: table.wire_columns.clone(),
7158 blocks: vec![vec![ssp2::segment::SegmentRow {
7159 server_version: 3,
7160 values: vec![
7161 Some(ColumnValue::String("b".into())),
7162 Some(ColumnValue::String("p1".into())),
7163 Some(ColumnValue::String("fresh".into())),
7164 ],
7165 }]],
7166 };
7167 assert!(actual.apply_segment(0, &segment, false).is_err());
7168 assert!(actual.conn.is_autocommit());
7169 assert_eq!(
7170 actual
7171 .query("SELECT * FROM tasks ORDER BY id", &[])
7172 .unwrap(),
7173 before
7174 );
7175 assert_eq!(
7176 actual
7177 .query("SELECT * FROM tasks_fts ORDER BY _syncular_source_id", &[])
7178 .unwrap(),
7179 fts_before
7180 );
7181 assert_eq!(actual.local_revision(), revision);
7182 assert_eq!(actual.pending_commit_ids(), pending);
7183 }
7184 }
7185
7186 #[test]
7187 fn import_reconciliation_preserves_typed_pending_primary_keys() {
7188 for (kind, a, b) in [
7189 ("string", json!("a"), json!("b")),
7190 ("integer", json!(1), json!(2)),
7191 ("boolean", json!(true), json!(false)),
7192 ("json", json!("1"), json!("2")),
7193 ] {
7194 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}]}]});
7195 for images in [false, true] {
7196 let mut client =
7197 SyncClient::new("typed".into(), &schema, ClientLimits::default()).unwrap();
7198 let table = client.schema.table("tasks").unwrap().clone();
7199 client
7200 .subscribe("tasks".into(), "tasks".into(), vec![], None)
7201 .unwrap();
7202 for (id, title) in [(&a, "original"), (&b, "occupied")] {
7203 client
7204 .write_base_row(
7205 "tasks",
7206 &vec![
7207 json_to_column_value(&table.columns[0], Some(id)).unwrap(),
7208 Some(ColumnValue::String(title.into())),
7209 ],
7210 1,
7211 )
7212 .unwrap();
7213 }
7214 client.rebuild_overlay_if_dirty().expect("rebuild overlay");
7215 client
7216 .mutate(vec![Mutation::Upsert {
7217 table: "tasks".into(),
7218 values: Map::from_iter([
7219 ("id".into(), a.clone()),
7220 ("title".into(), json!("available")),
7221 ]),
7222 base_version: None,
7223 }])
7224 .unwrap();
7225 client.overlay_rebuild_count.set(0);
7226 for (title, expected) in [("available", "original"), ("occupied", "available")] {
7227 let values = vec![
7228 json_to_column_value(&table.columns[0], Some(&b)).unwrap(),
7229 Some(ColumnValue::String(title.into())),
7230 ];
7231 if images {
7232 let image = Connection::open_in_memory().unwrap();
7233 image.execute_batch("CREATE TABLE tasks(id PRIMARY KEY, title TEXT NOT NULL, _syncular_version INTEGER NOT NULL);
7234 CREATE TABLE _syncular_segment(format INTEGER, \"table\" TEXT, \"schemaVersion\" INTEGER, \"asOfCommitSeq\" INTEGER, \"scopeDigest\" TEXT, \"rowCount\" INTEGER);
7235 INSERT INTO _syncular_segment VALUES (1,'tasks',1,7,'digest',1);").unwrap();
7236 image
7237 .execute(
7238 "INSERT INTO tasks VALUES (?1,?2,2)",
7239 rusqlite::params![RowParam::Cell(&values[0]), title],
7240 )
7241 .unwrap();
7242 assert!(
7243 matches!(
7244 client.apply_sqlite_image(
7245 &image,
7246 &table,
7247 false,
7248 &[],
7249 1,
7250 7,
7251 "digest"
7252 ),
7253 Ok(1)
7254 ),
7255 "{kind}, images={images}"
7256 );
7257 } else {
7258 let segment = RowsSegment {
7259 table: "tasks".into(),
7260 schema_version: 1,
7261 columns: table.wire_columns.clone(),
7262 blocks: vec![vec![ssp2::segment::SegmentRow {
7263 server_version: 2,
7264 values,
7265 }]],
7266 };
7267 assert!(
7268 matches!(client.apply_segment(0, &segment, false), Ok(1)),
7269 "{kind}"
7270 );
7271 }
7272 let key = json_to_column_value(&table.columns[0], Some(&a)).unwrap();
7273 let actual: String = client
7274 .conn
7275 .query_row(
7276 "SELECT title FROM tasks WHERE id=?1",
7277 [RowParam::Cell(&key)],
7278 |row| row.get(0),
7279 )
7280 .unwrap();
7281 assert_eq!(actual, expected, "{kind}, images={images}");
7282 }
7283 assert_eq!(client.overlay_rebuild_count.get(), 0);
7284 }
7285 }
7286 }
7287
7288 fn unique_import_fixture(rows: usize) -> (SyncClient, Connection, TableSchema) {
7289 let schema = json!({"version":1,"tables": (["tasks", "notes"].map(|name| json!({
7290 "name":name,"primaryKey":"id","columns":[{"name":"id","type":"string","nullable":false},{"name":"project_id","type":"string","nullable":false},{"name":"title","type":"string","nullable":false}],
7291 "scopes":[{"pattern":"project:{project_id}"}],"indexes":[{"name":format!("{name}_title"),"columns":["title"],"unique":true}],
7292 "ftsIndexes":[{"name":format!("{name}_fts"),"columns":["title"],"tokenize":"unicode61"}]
7293 })))});
7294 let mut client =
7295 SyncClient::new("unique-import".into(), &schema, ClientLimits::default()).unwrap();
7296 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();
7297 client.rebuild_overlay().expect("rebuild overlay");
7298 client
7299 .mutate(vec![Mutation::Upsert {
7300 table: "tasks".into(),
7301 values: Map::from_iter([
7302 ("id".into(), json!("offline")),
7303 ("project_id".into(), json!("p1")),
7304 ("title".into(), json!("pending edit")),
7305 ]),
7306 base_version: None,
7307 }])
7308 .unwrap();
7309 let image = Connection::open_in_memory().unwrap();
7310 image.execute_batch("CREATE TABLE tasks(id TEXT PRIMARY KEY, project_id TEXT NOT NULL, title TEXT NOT NULL, _syncular_version INTEGER NOT NULL);
7311 CREATE TABLE _syncular_segment(format INTEGER, \"table\" TEXT, \"schemaVersion\" INTEGER, \"asOfCommitSeq\" INTEGER, \"scopeDigest\" TEXT, \"rowCount\" INTEGER);").unwrap();
7312 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();
7313 image
7314 .execute(
7315 "INSERT INTO _syncular_segment VALUES (1,'tasks',1,7,'digest',?1)",
7316 [rows],
7317 )
7318 .unwrap();
7319 client.overlay_rebuild_count.set(0);
7320 let table = client.schema.table("tasks").unwrap().clone();
7321 (client, image, table)
7322 }
7323
7324 #[test]
7325 fn sqlite_full_image_import_retains_the_first_code_and_recovers() {
7326 let (mut client, image, table) = unique_import_fixture(1);
7327 image
7328 .execute("UPDATE tasks SET title = ?1", ["full ".repeat(32768)])
7329 .unwrap();
7330 client
7331 .conn
7332 .execute_batch("PRAGMA max_page_count = 1")
7333 .unwrap();
7334 let effective = [("project_id".into(), vec!["p1".into()])];
7335 assert!(client
7336 .apply_sqlite_image(&image, &table, true, &effective, 1, 7, "digest")
7337 .is_err());
7338 let failure = client.storage_failure.borrow().clone().unwrap();
7339 assert_eq!(failure.code, Some("client.storage_full"));
7340 assert_eq!(failure.sqlite_code, Some(13));
7341 assert!(!failure.message.contains("rollback"));
7342 assert!(client.conn.is_autocommit());
7343 client
7344 .conn
7345 .execute_batch("PRAGMA max_page_count = 1073741823")
7346 .unwrap();
7347 assert!(matches!(
7348 client.apply_sqlite_image(&image, &table, true, &effective, 1, 7, "digest"),
7349 Ok(1)
7350 ));
7351 assert!(client.conn.is_autocommit());
7352 }
7353
7354 #[test]
7355 fn authoring_storage_failures_are_structured_and_scoped() {
7356 let path = std::env::temp_dir().join(format!(
7357 "syncular-authoring-failure-{}.db",
7358 uuid::Uuid::new_v4()
7359 ));
7360 let schema = json!({"version":1,"tables":[{"name":"tasks","primaryKey":"id","columns":[
7361 {"name":"id","type":"string","nullable":false},
7362 {"name":"project_id","type":"string","nullable":false},
7363 {"name":"title","type":"string","nullable":false}],"scopes":[{"pattern":"project:{project_id}"}]}]});
7364 let mut client = SyncClient::open_path(
7365 "authoring".into(),
7366 &schema,
7367 ClientLimits::default(),
7368 path.to_str().unwrap(),
7369 )
7370 .expect("open client");
7371 client.create_synced_tables().expect("create synced tables");
7372 client.conn.busy_timeout(std::time::Duration::ZERO).unwrap();
7374
7375 let upsert = |id: &str| {
7376 vec![Mutation::Upsert {
7377 table: "tasks".into(),
7378 values: Map::from_iter([
7379 ("id".into(), json!(id)),
7380 ("project_id".into(), json!("p1")),
7381 ("title".into(), json!("first")),
7382 ]),
7383 base_version: None,
7384 }]
7385 };
7386
7387 let locker = Connection::open(&path).expect("second connection");
7388 locker.execute_batch("BEGIN IMMEDIATE").unwrap();
7389 let busy = client.mutate(upsert("t1")).expect_err("busy write");
7390 assert_eq!(busy.code, "client.storage_busy");
7391 assert!(busy.retryable);
7392 assert_eq!(busy.details.as_ref().unwrap()["sqliteCode"], 5);
7393 assert!(client.pending_commit_ids().is_empty());
7394 assert_eq!(client.local_revision(), 0);
7395
7396 let invalid = client.mutate(Vec::new()).expect_err("empty commit");
7398 assert_eq!(invalid.code, "sync.invalid_request");
7399 assert_eq!(invalid.message, "the authoring request is invalid");
7400 assert!(
7401 invalid.details.as_ref().unwrap()["legacyCause"]
7402 .as_str()
7403 .unwrap()
7404 .contains("at least one operation"),
7405 "{invalid:?}"
7406 );
7407
7408 locker.execute_batch("ROLLBACK").unwrap();
7409 let id = client.mutate(upsert("t1")).expect("retry after busy");
7410 assert_eq!(client.pending_commit_ids(), vec![id]);
7411 assert_eq!(client.local_revision(), 1);
7412
7413 locker.execute_batch("BEGIN IMMEDIATE").unwrap();
7415 let busy_again = client.mutate(upsert("t2")).expect_err("busy write again");
7416 assert_eq!(busy_again.code, "client.storage_busy");
7417 assert!(busy_again.retryable);
7418 locker.execute_batch("ROLLBACK").unwrap();
7419
7420 let full = ClientError::from(QueryReadFailure::from(rusqlite::Error::SqliteFailure(
7422 rusqlite::ffi::Error::new(13),
7423 Some("database or disk is full".to_owned()),
7424 )));
7425 assert_eq!(full.code, "client.storage_full");
7426 assert!(!full.retryable);
7427 assert_eq!(full.details.as_ref().unwrap()["sqliteCode"], 13);
7428
7429 drop(locker);
7430 drop(client);
7431 let _ = std::fs::remove_file(&path);
7432 }
7433
7434 #[test]
7435 fn caller_value_failures_are_invalid_request_and_leave_no_outbox_entry() {
7436 let path = std::env::temp_dir().join(format!(
7437 "syncular-caller-values-{}.db",
7438 uuid::Uuid::new_v4()
7439 ));
7440 let schema = json!({"version":1,"tables":[{"name":"tasks","primaryKey":"id","columns":[
7441 {"name":"id","type":"string","nullable":false},
7442 {"name":"project_id","type":"string","nullable":false},
7443 {"name":"title","type":"string","nullable":false},
7444 {"name":"priority","type":"integer","nullable":false},
7445 {"name":"payload","type":"bytes","nullable":false},
7446 {"name":"extra","type":"bytes","nullable":true}],"scopes":[{"pattern":"project:{project_id}"}]}]});
7447 let mut client = SyncClient::open_path(
7448 "caller-values".into(),
7449 &schema,
7450 ClientLimits::default(),
7451 path.to_str().unwrap(),
7452 )
7453 .expect("open client");
7454 client.create_synced_tables().expect("create synced tables");
7455
7456 let values = |id: &str| {
7457 Map::from_iter([
7458 ("id".to_owned(), json!(id)),
7459 ("project_id".to_owned(), json!("p1")),
7460 ("title".to_owned(), json!("first")),
7461 ("priority".to_owned(), json!(1)),
7462 ("payload".to_owned(), json!({"$bytes": "0a"})),
7463 ])
7464 };
7465 let upsert = |values: Map<String, Value>| {
7466 vec![Mutation::Upsert {
7467 table: "tasks".into(),
7468 values,
7469 base_version: None,
7470 }]
7471 };
7472 let seed = client.mutate(upsert(values("t1"))).expect("seed row");
7473 let rows = client.query("SELECT * FROM tasks", &[]).unwrap();
7474
7475 for (label, override_value, cause) in [
7480 ("unknown column", ("nope", json!(1)), "unknown column"),
7481 (
7482 "internal sync column",
7483 ("_sync_version", json!(1)),
7484 "internal sync column",
7485 ),
7486 ("wrong value type", ("title", json!(7)), "expected a string"),
7487 (
7488 "malformed bytes envelope",
7489 ("payload", json!({"$bytes": "zz"})),
7490 "expected exactly",
7491 ),
7492 (
7493 "odd-length bytes envelope",
7494 ("payload", json!({"$bytes": "abc"})),
7495 "expected exactly",
7496 ),
7497 (
7498 "plus-sign bytes envelope",
7499 ("payload", json!({"$bytes": "+a"})),
7500 "expected exactly",
7501 ),
7502 (
7503 "bytes envelope with a second key",
7504 ("payload", json!({"$bytes": "0a", "extra": 1})),
7505 "expected exactly",
7506 ),
7507 (
7508 "no bytes envelope",
7509 ("payload", json!({"nested": true})),
7510 "expected exactly",
7511 ),
7512 ] {
7513 let mut full = values("t2");
7514 full.insert(override_value.0.to_owned(), override_value.1);
7515 let error = client.mutate(upsert(full)).expect_err(label);
7516 assert_eq!(error.code, "sync.invalid_request", "{label}: {error:?}");
7517 assert_eq!(error.message, "the authoring request is invalid", "{label}");
7518 assert!(!error.retryable, "{label}");
7519 let legacy = error.details.as_ref().unwrap()["legacyCause"]
7520 .as_str()
7521 .unwrap();
7522 assert!(
7523 legacy.starts_with("sync.invalid_request: ") && legacy.contains(cause),
7524 "{label}: {legacy}"
7525 );
7526 assert_eq!(client.pending_commit_ids(), vec![seed.clone()], "{label}");
7527 assert_eq!(client.local_revision(), 1, "{label}");
7528 assert_eq!(
7529 client.query("SELECT * FROM tasks", &[]).unwrap(),
7530 rows,
7531 "{label}"
7532 );
7533 }
7534
7535 let mut missing = values("t3");
7538 missing.remove("payload");
7539 let missing_error = client.mutate(upsert(missing)).expect_err("missing column");
7540 assert_eq!(missing_error.code, "sync.invalid_request");
7541 assert!(missing_error.details.as_ref().unwrap()["legacyCause"]
7542 .as_str()
7543 .unwrap()
7544 .contains("is not nullable"));
7545
7546 let mut bad_row_id = values("t4");
7549 bad_row_id.insert("id".to_owned(), json!({"nested": true}));
7550 let row_id_error = client.mutate(upsert(bad_row_id)).expect_err("bad rowId");
7551 assert_eq!(row_id_error.code, "sync.invalid_request");
7552
7553 for (label, partial, cause) in [
7555 (
7556 "patch wrong value type",
7557 ("priority", json!("high")),
7558 "expected an integer",
7559 ),
7560 ("patch unknown column", ("nope", json!(1)), "unknown column"),
7561 ] {
7562 let error = client
7563 .patch(
7564 "tasks",
7565 "t1",
7566 Map::from_iter([(partial.0.to_owned(), partial.1)]),
7567 None,
7568 )
7569 .expect_err(label);
7570 assert_eq!(error.code, "sync.invalid_request", "{label}: {error:?}");
7571 assert_eq!(error.message, "the authoring request is invalid", "{label}");
7572 assert!(
7573 error.details.as_ref().unwrap()["legacyCause"]
7574 .as_str()
7575 .unwrap()
7576 .contains(cause),
7577 "{label}"
7578 );
7579 }
7580 assert_eq!(client.pending_commit_ids(), vec![seed.clone()]);
7581 assert_eq!(client.local_revision(), 1);
7582 assert_eq!(client.query("SELECT * FROM tasks", &[]).unwrap(), rows);
7583
7584 for (label, payload) in [
7587 ("explicit null", json!(null)),
7588 ("empty envelope", json!({"$bytes": ""})),
7589 ("uppercase envelope", json!({"$bytes": "0AFF"})),
7590 ] {
7591 let mut accepted = values("t-ok");
7592 accepted.insert("extra".to_owned(), payload);
7593 let encoded = client.mutate(upsert(accepted)).unwrap_or_else(|error| {
7594 panic!("{label} must be accepted: {error:?}");
7595 });
7596 assert!(!encoded.is_empty(), "{label}");
7597 }
7598
7599 let next = client
7601 .mutate(upsert(values("t5")))
7602 .expect("valid after failures");
7603 assert_ne!(next, seed);
7604
7605 drop(client);
7606 let _ = std::fs::remove_file(&path);
7607 }
7608
7609 #[test]
7610 fn a_legacy_outbox_value_the_codec_refuses_is_dropped_instead_of_wedging() {
7611 let schema = json!({"version":1,"tables":[{"name":"tasks","primaryKey":"id","columns":[
7612 {"name":"id","type":"string","nullable":false},
7613 {"name":"project_id","type":"string","nullable":false},
7614 {"name":"payload","type":"bytes","nullable":true}],
7615 "scopes":[{"pattern":"project:{project_id}"}]}]});
7616 let open = |path: &std::path::Path| {
7617 SyncClient::open_path(
7618 "legacy-values".into(),
7619 &schema,
7620 ClientLimits::default(),
7621 path.to_str().unwrap(),
7622 )
7623 .expect("open client")
7624 };
7625 let row = |id: &str| {
7626 Map::from_iter([
7627 ("id".to_owned(), json!(id)),
7628 ("project_id".to_owned(), json!("p1")),
7629 ("payload".to_owned(), json!({"$bytes": "0a"})),
7630 ])
7631 };
7632 let upsert = |values| {
7633 vec![Mutation::Upsert {
7634 table: "tasks".into(),
7635 values,
7636 base_version: None,
7637 }]
7638 };
7639 for (label, stored_cell, representable) in [
7640 ("plus-sign envelope", json!({"$bytes": "+a"}), false),
7641 ("non-hex envelope", json!({"$bytes": "zz"}), false),
7642 ("odd-length envelope", json!({"$bytes": "abc"}), false),
7643 ("no envelope", json!({"nested": true}), false),
7644 ("wrong declared type", json!("high"), true),
7648 ("stored null for a non-nullable column", json!(null), true),
7649 ] {
7650 let path = std::env::temp_dir().join(format!(
7651 "syncular-legacy-values-{}-{}.db",
7652 label.replace(' ', "-"),
7653 uuid::Uuid::new_v4()
7654 ));
7655 let mut client = open(&path);
7656 client.create_synced_tables().expect("create synced tables");
7657 let stale = client.mutate(upsert(row("t1"))).expect("stale commit");
7658 let successor = client.mutate(upsert(row("t2"))).expect("successor commit");
7659 drop(client);
7660
7661 let connection = Connection::open(&path).expect("raw connection");
7663 let (column, cell) = if stored_cell.is_null() {
7664 ("project_id", stored_cell.clone())
7665 } else {
7666 ("payload", stored_cell.clone())
7667 };
7668 let mut planted_values = json!({
7669 "id": "t1", "project_id": "p1", "payload": {"$bytes": "0a"}
7670 });
7671 planted_values[column] = cell;
7672 let planted = json!([{
7673 "op": "upsert",
7674 "table": "tasks",
7675 "rowId": "t1",
7676 "baseVersion": null,
7677 "values": planted_values
7678 }]);
7679 connection
7680 .execute(
7681 "UPDATE _syncular_outbox SET ops_json=? WHERE commit_id=?",
7682 rusqlite::params![planted.to_string(), stale],
7683 )
7684 .expect("plant legacy intent");
7685 drop(connection);
7686
7687 let reopened = open(&path);
7690 assert_eq!(
7691 reopened.pending_commit_ids(),
7692 vec![successor.clone()],
7693 "{label}"
7694 );
7695 let rejection = reopened
7696 .rejections()
7697 .iter()
7698 .find(|rejection| rejection.client_commit_id == stale)
7699 .unwrap_or_else(|| panic!("{label}: rejection"));
7700 assert_eq!(rejection.code, OUTBOX_INCOMPATIBLE_CODE, "{label}");
7701 assert_eq!(
7702 rejection.message, "the persisted commit carries values the current codec refuses",
7703 "{label}"
7704 );
7705 assert_eq!(rejection.operation.is_some(), representable, "{label}");
7706 assert_eq!(
7707 rejection
7708 .details
7709 .as_ref()
7710 .and_then(|details| details.reason.as_deref()),
7711 Some(INVALID_STORED_VALUES_REASON),
7712 "{label}"
7713 );
7714 let journal = reopened
7716 .commit_outcome(&stale)
7717 .expect("read outcome")
7718 .expect("journal entry");
7719 assert!(matches!(journal.status, CommitOutcomeStatus::Rejected));
7720 drop(reopened);
7721 let mut reopened = open(&path);
7722 let reread = reopened
7723 .commit_outcome(&stale)
7724 .expect("read outcome")
7725 .expect("journal entry");
7726 assert!(matches!(reread.status, CommitOutcomeStatus::Rejected));
7727
7728 assert_eq!(
7731 reopened
7732 .query("SELECT id FROM tasks ORDER BY id", &[])
7733 .unwrap(),
7734 vec![Map::from_iter([("id".to_owned(), json!("t2"))])],
7735 "{label}"
7736 );
7737 assert_eq!(reopened.pending_commit_ids(), vec![successor.clone()]);
7738 assert_eq!(reopened.pending_payloads().len(), 1, "{label}");
7739 assert!(!reopened
7740 .drop_unencodable_outbox(false, None)
7741 .expect("idempotent prepass"));
7742 let mut transport = CountingRealtimeTransport::default();
7743 let outcome = reopened.sync(&mut transport);
7744 assert!(matches!(outcome, SyncOutcome::Failed { .. }), "{outcome:?}");
7745 assert_eq!(reopened.pending_commit_ids(), vec![successor.clone()]);
7746 let next = reopened.mutate(upsert(row("t3"))).expect("after the drop");
7747 assert_eq!(reopened.pending_commit_ids(), vec![successor, next]);
7748 assert_eq!(reopened.pending_payloads().len(), 2, "{label}");
7749 drop(reopened);
7750 let _ = std::fs::remove_file(&path);
7751 }
7752 }
7753
7754 #[test]
7755 fn two_legacy_values_on_the_same_row_recover_in_fifo_order() {
7756 let schema = json!({"version":1,"tables":[{"name":"tasks","primaryKey":"id","columns":[
7757 {"name":"id","type":"string","nullable":false},
7758 {"name":"project_id","type":"string","nullable":false},
7759 {"name":"payload","type":"bytes","nullable":true}],
7760 "scopes":[{"pattern":"project:{project_id}"}]}]});
7761 for (label, column, planted) in [
7762 ("malformed envelope", "payload", json!({"$bytes": "zz"})),
7763 (
7764 "stored null for a non-nullable column",
7765 "project_id",
7766 json!(null),
7767 ),
7768 ("wrong declared type", "payload", json!(7)),
7769 ] {
7770 let path = std::env::temp_dir().join(format!(
7771 "syncular-shared-row-{}-{}.db",
7772 label.replace(' ', "-"),
7773 uuid::Uuid::new_v4()
7774 ));
7775 let open = |path: &std::path::Path| {
7776 SyncClient::open_path(
7777 "shared-row".into(),
7778 &schema,
7779 ClientLimits::default(),
7780 path.to_str().unwrap(),
7781 )
7782 .expect("open client")
7783 };
7784 let row = |id: &str| {
7785 Map::from_iter([
7786 ("id".to_owned(), json!(id)),
7787 ("project_id".to_owned(), json!("p1")),
7788 ("payload".to_owned(), json!({"$bytes": "0a"})),
7789 ])
7790 };
7791 let upsert = |values| {
7792 vec![Mutation::Upsert {
7793 table: "tasks".into(),
7794 values,
7795 base_version: None,
7796 }]
7797 };
7798 let mut client = open(&path);
7799 client.create_synced_tables().expect("create synced tables");
7800 let older = client.mutate(upsert(row("t1"))).expect("older commit");
7801 let newer = client.mutate(upsert(row("t1"))).expect("newer commit");
7802 let successor = client.mutate(upsert(row("t2"))).expect("successor commit");
7803 drop(client);
7804
7805 let connection = Connection::open(&path).expect("raw connection");
7806 for commit_id in [&older, &newer] {
7807 let mut values = row("t1");
7808 values.insert(column.to_owned(), planted.clone());
7809 let planted_ops = json!([{
7810 "op": "upsert",
7811 "table": "tasks",
7812 "rowId": "t1",
7813 "baseVersion": null,
7814 "values": values
7815 }]);
7816 connection
7817 .execute(
7818 "UPDATE _syncular_outbox SET ops_json=? WHERE commit_id=?",
7819 rusqlite::params![planted_ops.to_string(), commit_id],
7820 )
7821 .expect("plant legacy intent");
7822 }
7823 drop(connection);
7824
7825 let mut reopened = open(&path);
7827 assert_eq!(
7828 reopened.pending_commit_ids(),
7829 vec![successor.clone()],
7830 "{label}"
7831 );
7832 assert_eq!(
7833 reopened
7834 .rejections()
7835 .iter()
7836 .map(|rejection| rejection.client_commit_id.clone())
7837 .collect::<Vec<_>>(),
7838 vec![older.clone(), newer.clone()],
7839 "{label}"
7840 );
7841 assert!(
7842 reopened
7843 .rejections()
7844 .iter()
7845 .all(|rejection| rejection.code == OUTBOX_INCOMPATIBLE_CODE
7846 && rejection.details.as_ref().and_then(|d| d.reason.as_deref())
7847 == Some(INVALID_STORED_VALUES_REASON)),
7848 "{label}"
7849 );
7850 for commit_id in [&older, &newer] {
7851 assert!(
7852 matches!(
7853 reopened
7854 .commit_outcome(commit_id)
7855 .expect("outcome")
7856 .map(|o| o.status),
7857 Some(CommitOutcomeStatus::Rejected)
7858 ),
7859 "{label}"
7860 );
7861 }
7862 assert_eq!(
7863 reopened
7864 .query("SELECT id FROM tasks ORDER BY id", &[])
7865 .unwrap(),
7866 vec![Map::from_iter([("id".to_owned(), json!("t2"))])],
7867 "{label}"
7868 );
7869 let revision = reopened.local_revision();
7870 let batches = reopened.drain_change_batches();
7871 assert!(
7872 batches.iter().any(|batch| batch.rejections_changed
7873 && batch.outcomes_changed
7874 && batch.tables.iter().any(|change| change.table == "tasks")
7875 && batch.revision == revision.to_string()),
7876 "{label}: {batches:?}"
7877 );
7878 drop(reopened);
7879
7880 let mut stable = open(&path);
7883 assert_eq!(stable.local_revision(), revision, "{label}");
7884 assert!(stable.drain_change_batches().is_empty(), "{label}");
7885 assert_eq!(stable.pending_commit_ids(), vec![successor], "{label}");
7886 drop(stable);
7887 let _ = std::fs::remove_file(&path);
7888 }
7889 }
7890
7891 #[test]
7892 fn recovery_omits_a_legacy_operation_the_journal_reader_rejects() {
7893 let schema = json!({"version":1,"tables":[{"name":"tasks","primaryKey":"id","columns":[
7894 {"name":"id","type":"string","nullable":false},
7895 {"name":"project_id","type":"string","nullable":false},
7896 {"name":"payload","type":"bytes","nullable":true}],
7897 "scopes":[{"pattern":"project:{project_id}"}]}]});
7898 for (label, second_op) in [
7899 (
7900 "delete carrying values",
7901 json!({"op": "delete", "table": "tasks", "rowId": "t9", "values": {}}),
7902 ),
7903 (
7904 "baseVersion beyond the safe bound",
7905 json!({"op": "upsert", "table": "tasks", "rowId": "t9",
7906 "baseVersion": MAX_JS_SAFE_INTEGER + 1,
7907 "values": {"id": "t9", "project_id": "p1", "payload": {"$bytes": "0a"}}}),
7908 ),
7909 ] {
7910 let path = std::env::temp_dir().join(format!(
7911 "syncular-journal-shape-{}-{}.db",
7912 label.replace(' ', "-"),
7913 uuid::Uuid::new_v4()
7914 ));
7915 let open = |path: &std::path::Path| {
7916 SyncClient::open_path(
7917 "journal-shape".into(),
7918 &schema,
7919 ClientLimits::default(),
7920 path.to_str().unwrap(),
7921 )
7922 .expect("open client")
7923 };
7924 let values = |id: &str| {
7925 Map::from_iter([
7926 ("id".to_owned(), json!(id)),
7927 ("project_id".to_owned(), json!("p1")),
7928 ("payload".to_owned(), json!({"$bytes": "0a"})),
7929 ])
7930 };
7931 let upsert = |values| {
7932 vec![Mutation::Upsert {
7933 table: "tasks".into(),
7934 values,
7935 base_version: None,
7936 }]
7937 };
7938 let mut client = open(&path);
7939 client.create_synced_tables().expect("create synced tables");
7940 let stale = client.mutate(upsert(values("t1"))).expect("stale commit");
7941 drop(client);
7942
7943 let connection = Connection::open(&path).expect("raw connection");
7944 let planted = json!([
7948 {
7949 "op": "upsert",
7950 "table": "tasks",
7951 "rowId": "t1",
7952 "baseVersion": null,
7953 "values": {"id": "t1", "project_id": null,
7954 "payload": {"$bytes": "0a"}}
7955 },
7956 second_op
7957 ]);
7958 connection
7959 .execute(
7960 "UPDATE _syncular_outbox SET ops_json=? WHERE commit_id=?",
7961 rusqlite::params![planted.to_string(), stale],
7962 )
7963 .expect("plant legacy intent");
7964 drop(connection);
7965
7966 let reopened = open(&path);
7967 let journal = reopened
7968 .commit_outcome(&stale)
7969 .unwrap_or_else(|error| panic!("{label}: journal read: {error}"))
7970 .unwrap_or_else(|| panic!("{label}: journal entry"));
7971 assert!(
7972 matches!(journal.status, CommitOutcomeStatus::Rejected),
7973 "{label}"
7974 );
7975 assert!(journal.operations.is_none(), "{label}");
7976 assert!(
7977 reopened
7978 .commit_outcomes(CommitOutcomeQuery::default())
7979 .is_ok(),
7980 "{label}: outcome list query"
7981 );
7982 assert!(reopened.pending_commit_ids().is_empty(), "{label}");
7983 drop(reopened);
7984 let _ = std::fs::remove_file(&path);
7985 }
7986 }
7987
7988 #[test]
7989 fn legacy_values_recover_at_the_recreate_and_bump_reset_boundaries() {
7990 let schema = |version: i32, extra: bool| {
7991 let mut columns = vec![
7992 json!({"name":"id","type":"string","nullable":false}),
7993 json!({"name":"project_id","type":"string","nullable":false}),
7994 json!({"name":"payload","type":"bytes","nullable":true}),
7995 ];
7996 if extra {
7997 columns.push(json!({"name":"later","type":"string","nullable":true}));
7998 }
7999 json!({"version":version,"tables":[{"name":"tasks","primaryKey":"id","columns":columns,
8000 "scopes":[{"pattern":"project:{project_id}"}]}]})
8001 };
8002 for (label, bump) in [
8003 ("same-version recreation", false),
8004 ("version-bump reset", true),
8005 ("version-only bump reset", true),
8006 ] {
8007 let path = std::env::temp_dir().join(format!(
8008 "syncular-legacy-reset-{}-{}.db",
8009 label.replace(' ', "-"),
8010 uuid::Uuid::new_v4()
8011 ));
8012 let v1 = schema(1, false);
8013 let open = |schema: &Value, path: &std::path::Path| {
8014 SyncClient::open_path(
8015 "legacy-reset".into(),
8016 schema,
8017 ClientLimits::default(),
8018 path.to_str().unwrap(),
8019 )
8020 .expect("open client")
8021 };
8022 let row = |id: &str| {
8023 Map::from_iter([
8024 ("id".to_owned(), json!(id)),
8025 ("project_id".to_owned(), json!("p1")),
8026 ("payload".to_owned(), json!({"$bytes": "0a"})),
8027 ])
8028 };
8029 let upsert = |values| {
8030 vec![Mutation::Upsert {
8031 table: "tasks".into(),
8032 values,
8033 base_version: None,
8034 }]
8035 };
8036 let mut client = open(&v1, &path);
8037 client.create_synced_tables().expect("create synced tables");
8038 let stale = client.mutate(upsert(row("t1"))).expect("stale commit");
8039 let successor = client.mutate(upsert(row("t2"))).expect("successor commit");
8040 drop(client);
8041
8042 let connection = Connection::open(&path).expect("raw connection");
8043 let planted = json!([{
8044 "op": "upsert",
8045 "table": "tasks",
8046 "rowId": "t1",
8047 "baseVersion": null,
8048 "values": {"id": "t1", "project_id": 7, "payload": {"$bytes": "0a"}}
8049 }]);
8050 connection
8051 .execute(
8052 "UPDATE _syncular_outbox SET ops_json=? WHERE commit_id=?",
8053 rusqlite::params![planted.to_string(), stale],
8054 )
8055 .expect("plant legacy intent");
8056 drop(connection);
8057
8058 let target = if bump {
8059 schema(2, label == "version-bump reset")
8060 } else {
8061 v1.clone()
8062 };
8063 let reopened = open(&target, &path);
8064 let rejection = reopened
8065 .rejections()
8066 .iter()
8067 .find(|rejection| rejection.client_commit_id == stale)
8068 .unwrap_or_else(|| panic!("{label}: rejection"));
8069 assert_eq!(rejection.code, OUTBOX_INCOMPATIBLE_CODE, "{label}");
8070 assert_eq!(
8071 rejection
8072 .details
8073 .as_ref()
8074 .and_then(|details| details.reason.as_deref()),
8075 Some(INVALID_STORED_VALUES_REASON),
8076 "{label}"
8077 );
8078 assert!(rejection.operation.is_some(), "{label}");
8079 assert_eq!(
8080 reopened.pending_commit_ids(),
8081 vec![successor.clone()],
8082 "{label}"
8083 );
8084 assert_eq!(reopened.pending_payloads().len(), 1, "{label}");
8089 if label == "version-bump reset" {
8090 assert_eq!(
8091 reopened
8092 .query("SELECT id FROM tasks ORDER BY id", &[])
8093 .unwrap(),
8094 Vec::<Map<String, Value>>::new(),
8095 "{label}"
8096 );
8097 let mut next = reopened;
8101 let revision = next.local_revision();
8102 let batches = next.drain_change_batches();
8103 assert!(
8104 batches.iter().any(|batch| batch.rejections_changed
8105 && batch.outcomes_changed
8106 && batch.tables.iter().any(|change| change.table == "tasks")
8107 && batch.revision == revision.to_string()),
8108 "{label}: {batches:?}"
8109 );
8110 next.mutate(upsert(Map::from_iter([
8111 ("id".to_owned(), json!("t3")),
8112 ("project_id".to_owned(), json!("p1")),
8113 ("payload".to_owned(), json!({"$bytes": "0b"})),
8114 ("later".to_owned(), json!("v2")),
8115 ])))
8116 .expect("v2 authoring after the reset");
8117 assert_eq!(next.pending_commit_ids().len(), 2, "{label}");
8118 assert_eq!(next.pending_payloads().len(), 2, "{label}");
8119 drop(next);
8120 } else {
8121 assert_eq!(
8122 reopened
8123 .query("SELECT id FROM tasks ORDER BY id", &[])
8124 .unwrap(),
8125 vec![Map::from_iter([("id".to_owned(), json!("t2"))])],
8126 "{label}"
8127 );
8128 drop(reopened);
8129 }
8130
8131 if !bump {
8134 let mut recreated = open(&v1, &path);
8135 let fresh = recreated
8136 .mutate(upsert(row("t3")))
8137 .expect("fresh commit while open");
8138 recreated.drain_change_batches();
8139 let planted_fresh = json!([{
8140 "op": "upsert",
8141 "table": "tasks",
8142 "rowId": "t3",
8143 "baseVersion": null,
8144 "values": {"id": "t3", "project_id": "p1",
8145 "payload": {"$bytes": "zz"}}
8146 }]);
8147 let connection = Connection::open(&path).expect("raw connection");
8148 let affected = connection
8149 .execute(
8150 "UPDATE _syncular_outbox SET ops_json=? WHERE commit_id=?",
8151 rusqlite::params![planted_fresh.to_string(), fresh],
8152 )
8153 .expect("plant fresh legacy intent");
8154 assert_eq!(
8155 affected, 1,
8156 "{label}: the in-place plant must hit the queued commit"
8157 );
8158 drop(connection);
8159 let revision_before = recreated.local_revision();
8160 recreated
8161 .recreate_with_schema(&v1)
8162 .unwrap_or_else(|error| panic!("{label}: recreation failed: {error}"));
8163 assert_eq!(recreated.pending_commit_ids(), vec![successor], "{label}");
8164 let fresh_rejection = recreated
8165 .rejections()
8166 .iter()
8167 .find(|rejection| rejection.client_commit_id == fresh)
8168 .expect("the in-place recovery rejection");
8169 assert_eq!(fresh_rejection.code, OUTBOX_INCOMPATIBLE_CODE, "{label}");
8170 assert_eq!(
8171 fresh_rejection
8172 .details
8173 .as_ref()
8174 .and_then(|details| details.reason.as_deref()),
8175 Some(INVALID_STORED_VALUES_REASON),
8176 "{label}"
8177 );
8178 assert!(
8179 matches!(
8180 recreated
8181 .commit_outcome(&fresh)
8182 .expect("outcome read")
8183 .map(|outcome| outcome.status),
8184 Some(CommitOutcomeStatus::Rejected)
8185 ),
8186 "{label}"
8187 );
8188 assert!(recreated.local_revision() > revision_before, "{label}");
8189 let revision = recreated.local_revision();
8190 let batches = recreated.drain_change_batches();
8191 assert_eq!(batches.len(), 1, "{label}: one recovery batch");
8192 assert!(
8193 batches.iter().any(|batch| batch.rejections_changed
8194 && batch.outcomes_changed
8195 && batch.tables.iter().any(|change| change.table == "tasks")
8196 && batch.revision == revision.to_string()),
8197 "{label}: {batches:?}"
8198 );
8199 assert_eq!(recreated.pending_payloads().len(), 1, "{label}");
8200 assert_eq!(
8201 recreated
8202 .query("SELECT id FROM tasks ORDER BY id", &[])
8203 .unwrap(),
8204 vec![Map::from_iter([("id".to_owned(), json!("t2"))])],
8205 "{label}: the recovered commit leaves no projection"
8206 );
8207 drop(recreated);
8208 }
8209 let _ = std::fs::remove_file(&path);
8210 }
8211 }
8212
8213 #[test]
8214 fn legacy_values_recover_over_a_synced_authoritative_row() {
8215 let path = std::env::temp_dir().join(format!(
8216 "syncular-authoritative-row-{}.db",
8217 uuid::Uuid::new_v4()
8218 ));
8219 let schema = json!({"version":1,"tables":[{"name":"tasks","primaryKey":"id","columns":[
8220 {"name":"id","type":"string","nullable":false},
8221 {"name":"project_id","type":"string","nullable":false},
8222 {"name":"title","type":"string","nullable":false},
8223 {"name":"payload","type":"bytes","nullable":true}],
8224 "scopes":[{"pattern":"project:{project_id}"}]}]});
8225 let open = |path: &std::path::Path| {
8226 SyncClient::open_path(
8227 "authoritative-row".into(),
8228 &schema,
8229 ClientLimits::default(),
8230 path.to_str().unwrap(),
8231 )
8232 .expect("open client")
8233 };
8234 let row = |title: &str| {
8235 Map::from_iter([
8236 ("id".to_owned(), json!("t1")),
8237 ("project_id".to_owned(), json!("p1")),
8238 ("title".to_owned(), json!(title)),
8239 ("payload".to_owned(), json!({"$bytes": "0a"})),
8240 ])
8241 };
8242 let upsert = |values| {
8243 vec![Mutation::Upsert {
8244 table: "tasks".into(),
8245 values,
8246 base_version: None,
8247 }]
8248 };
8249 let mut client = open(&path);
8250 client.create_synced_tables().expect("create synced tables");
8251 client
8254 .conn
8255 .execute_batch(
8256 "INSERT INTO _syncular_base_tasks VALUES ('t1','p1','authoritative',x'0a',1);
8257 INSERT INTO tasks VALUES ('t1','p1','authoritative',x'0a',1);",
8258 )
8259 .expect("seed authoritative row");
8260 let older = client.mutate(upsert(row("older"))).expect("older");
8261 let newer = client.mutate(upsert(row("newer"))).expect("newer");
8262 let successor = client.mutate(upsert(row("successor"))).expect("successor");
8263 drop(client);
8264
8265 let connection = Connection::open(&path).expect("raw connection");
8266 for commit_id in [&older, &newer] {
8267 let planted = json!([{
8268 "op": "upsert",
8269 "table": "tasks",
8270 "rowId": "t1",
8271 "baseVersion": null,
8272 "values": {"id": "t1", "project_id": "p1", "title": "legacy",
8273 "payload": {"$bytes": "zz"}}
8274 }]);
8275 connection
8276 .execute(
8277 "UPDATE _syncular_outbox SET ops_json=? WHERE commit_id=?",
8278 rusqlite::params![planted.to_string(), commit_id],
8279 )
8280 .expect("plant legacy intent");
8281 }
8282 drop(connection);
8283
8284 let reopened = open(&path);
8285 assert_eq!(reopened.pending_commit_ids(), vec![successor.clone()]);
8286 assert_eq!(
8287 reopened
8288 .rejections()
8289 .iter()
8290 .map(|rejection| rejection.client_commit_id.clone())
8291 .collect::<Vec<_>>(),
8292 vec![older.clone(), newer.clone()]
8293 );
8294 assert_eq!(
8297 reopened.query("SELECT id,title,_syncular_version FROM tasks", &[]),
8298 Ok(vec![Map::from_iter([
8299 ("id".to_owned(), json!("t1")),
8300 ("title".to_owned(), json!("successor")),
8301 ("_syncular_version".to_owned(), json!(1)),
8302 ])])
8303 );
8304
8305 drop(reopened);
8308 let connection = Connection::open(&path).expect("raw connection");
8309 connection
8310 .execute(
8311 "UPDATE _syncular_outbox SET ops_json=? WHERE commit_id=?",
8312 rusqlite::params![
8313 json!([{
8314 "op": "upsert",
8315 "table": "tasks",
8316 "rowId": "t1",
8317 "baseVersion": null,
8318 "values": {"id": "t1", "project_id": "p1", "title": "legacy",
8319 "payload": {"$bytes": "zz"}}
8320 }])
8321 .to_string(),
8322 successor
8323 ],
8324 )
8325 .expect("plant successor intent");
8326 drop(connection);
8327 let late = open(&path);
8328 assert!(late.pending_commit_ids().is_empty());
8329 assert_eq!(
8330 late.query("SELECT id,title,_syncular_version FROM tasks", &[]),
8331 Ok(vec![Map::from_iter([
8332 ("id".to_owned(), json!("t1")),
8333 ("title".to_owned(), json!("authoritative")),
8334 ("_syncular_version".to_owned(), json!(1)),
8335 ])])
8336 );
8337 drop(late);
8338 let _ = std::fs::remove_file(&path);
8339 }
8340
8341 #[test]
8342 fn a_failed_value_recovery_commit_restores_every_side_effect() {
8343 let path = std::env::temp_dir().join(format!(
8344 "syncular-value-recovery-commit-{}.db",
8345 uuid::Uuid::new_v4()
8346 ));
8347 let path_str = path.to_str().expect("path");
8348 let schema = json!({"version":1,"tables":[{"name":"tasks","primaryKey":"id","columns":[
8349 {"name":"id","type":"string","nullable":false},
8350 {"name":"project_id","type":"string","nullable":false},
8351 {"name":"payload","type":"bytes","nullable":true}],
8352 "scopes":[{"pattern":"project:{project_id}"}]}]});
8353 let conn = Connection::open(path_str).expect("replica connection");
8356 let mut client = SyncClient::with_connection(
8357 "value-recovery-commit".into(),
8358 &schema,
8359 ClientLimits::default(),
8360 conn,
8361 )
8362 .expect("replica");
8363 assert_eq!(
8364 client
8365 .conn
8366 .query_row("PRAGMA journal_mode", [], |row| row.get::<_, String>(0))
8367 .expect("journal mode"),
8368 "delete"
8369 );
8370 client.create_synced_tables().expect("create synced tables");
8371 let stale = client
8372 .mutate(vec![Mutation::Upsert {
8373 table: "tasks".into(),
8374 values: Map::from_iter([
8375 ("id".to_owned(), json!("t1")),
8376 ("project_id".to_owned(), json!("p1")),
8377 ("payload".to_owned(), json!({"$bytes": "0a"})),
8378 ]),
8379 base_version: None,
8380 }])
8381 .expect("queued write");
8382 client.drain_change_batches();
8383 let affected = client
8384 .conn
8385 .execute(
8386 "UPDATE _syncular_outbox SET ops_json=? WHERE commit_id=?",
8387 rusqlite::params![
8388 json!([{
8389 "op": "upsert",
8390 "table": "tasks",
8391 "rowId": "t1",
8392 "baseVersion": null,
8393 "values": {"id": "t1", "project_id": "p1",
8394 "payload": {"$bytes": "zz"}}
8395 }])
8396 .to_string(),
8397 stale
8398 ],
8399 )
8400 .expect("plant legacy intent");
8401 assert_eq!(affected, 1, "the planted row must exist");
8402 let revision = client.local_revision();
8403 let rows = client.query("SELECT * FROM tasks", &[]).expect("rows");
8404 let prior_outbox = client.pending_commit_ids();
8405 let prior_rejections = client.rejections().len();
8406 let prior_changes = client.change_queue.len();
8407 let prior_intents = client.sync_intent_queue.len();
8408 let prior_failed = client.failed_commits.len();
8409 let reader = Connection::open(path_str).expect("reader");
8412 reader
8413 .execute_batch("BEGIN; SELECT count(*) FROM _syncular_meta;")
8414 .expect("reader snapshot");
8415 client
8416 .conn
8417 .busy_timeout(std::time::Duration::ZERO)
8418 .expect("busy timeout");
8419 let error = client
8420 .recreate_with_schema(&schema)
8421 .expect_err("a blocked COMMIT must refuse the recovery");
8422 assert!(error.starts_with("client.storage_busy:"), "{error}");
8423 assert!(client.conn.is_autocommit(), "the transaction is released");
8424 assert_eq!(client.local_revision(), revision);
8425 assert_eq!(client.pending_commit_ids(), prior_outbox);
8426 assert_eq!(client.rejections().len(), prior_rejections);
8427 assert_eq!(client.change_queue.len(), prior_changes);
8428 assert_eq!(client.sync_intent_queue.len(), prior_intents);
8429 assert_eq!(client.failed_commits.len(), prior_failed);
8430 assert_eq!(
8431 client.query("SELECT * FROM tasks", &[]).expect("rows"),
8432 rows
8433 );
8434 assert!(
8435 client
8436 .commit_outcome(&stale)
8437 .expect("outcome read")
8438 .is_none(),
8439 "the rolled-back recovery kept no outcome"
8440 );
8441 reader.execute_batch("ROLLBACK").expect("release reader");
8443 client
8444 .recreate_with_schema(&schema)
8445 .expect("retry after the blocked commit");
8446 assert!(client.pending_commit_ids().is_empty());
8447 assert_eq!(client.rejections().len(), prior_rejections + 1);
8448 assert!(client.local_revision() > revision);
8449 let batches = client.drain_change_batches();
8450 assert!(
8451 batches.iter().any(|batch| batch.rejections_changed
8452 && batch.outcomes_changed
8453 && batch.tables.iter().any(|change| change.table == "tasks")),
8454 "{batches:?}"
8455 );
8456 drop(client);
8457 let _ = std::fs::remove_file(&path);
8458 }
8459
8460 #[cfg(feature = "e2ee")]
8461 #[test]
8462 fn a_failed_recovery_restores_retained_failed_commits() {
8463 let path = std::env::temp_dir().join(format!(
8464 "syncular-value-recovery-retention-{}.db",
8465 uuid::Uuid::new_v4()
8466 ));
8467 let path_str = path.to_str().expect("path");
8468 let schema = json!({"version":1,"tables":[{"name":"secrets","primaryKey":"id","columns":[
8469 {"name":"id","type":"string","nullable":false},
8470 {"name":"project_id","type":"string","nullable":false},
8471 {"name":"note","type":"bytes","nullable":true,"encrypted":true,"declaredType":"string"}],
8472 "scopes":[{"pattern":"project:{project_id}"}]}]});
8473 let conn = Connection::open(path_str).expect("replica connection");
8474 let mut client = SyncClient::with_connection(
8475 "value-recovery-retention".into(),
8476 &schema,
8477 ClientLimits::default(),
8478 conn,
8479 )
8480 .expect("replica");
8481 client.create_synced_tables().expect("create synced tables");
8482 client.set_retain_failed_commits(true).expect("retention");
8483 client.set_encryption(crate::values::EncryptionConfig::default());
8485 client
8486 .mutate(vec![Mutation::Upsert {
8487 table: "secrets".into(),
8488 values: Map::from_iter([
8489 ("id".to_owned(), json!("s1")),
8490 ("project_id".to_owned(), json!("p1")),
8491 ("note".to_owned(), json!("secret")),
8492 ]),
8493 base_version: None,
8494 }])
8495 .expect("queued write");
8496 client.drain_change_batches();
8497 let revision = client.local_revision();
8498 let prior_outbox = client.pending_commit_ids();
8499 let prior_rejections = client.rejections().len();
8500 let prior_failed = client.failed_commits.len();
8501 let reader = Connection::open(path_str).expect("reader");
8502 reader
8503 .execute_batch("BEGIN; SELECT count(*) FROM _syncular_meta;")
8504 .expect("reader snapshot");
8505 client
8506 .conn
8507 .busy_timeout(std::time::Duration::ZERO)
8508 .expect("busy timeout");
8509 let error = client
8510 .drop_unencodable_outbox(true, None)
8511 .expect_err("a blocked COMMIT must refuse the recovery");
8512 assert!(error.starts_with("client.storage_busy:"), "{error}");
8513 assert_eq!(client.failed_commits.len(), prior_failed);
8514 assert_eq!(client.pending_commit_ids(), prior_outbox);
8515 assert_eq!(client.rejections().len(), prior_rejections);
8516 assert_eq!(client.local_revision(), revision);
8517 reader.execute_batch("ROLLBACK").expect("release reader");
8519 client
8520 .drop_unencodable_outbox(true, None)
8521 .expect("retry after the blocked commit");
8522 assert_eq!(client.failed_commits.len(), prior_failed + 1);
8523 assert!(client.pending_commit_ids().is_empty());
8524 assert_eq!(client.rejections().len(), prior_rejections + 1);
8525 drop(client);
8526 let _ = std::fs::remove_file(&path);
8527 }
8528
8529 #[test]
8530 fn client_error_maps_legacy_codes_and_static_messages() {
8531 let code_only = ClientError::from("sync.unknown_table".to_owned());
8532 assert_eq!(code_only.code, "sync.unknown_table");
8533 assert_eq!(code_only.message, "the commit targets an unknown table");
8534 assert!(code_only.details.is_none());
8535
8536 let dynamic = ClientError::from(
8537 "sync.invalid_request: table \"tasks\": patch cannot write scope column".to_owned(),
8538 );
8539 assert_eq!(dynamic.code, "sync.invalid_request");
8540 assert_eq!(dynamic.message, "the authoring request is invalid");
8541 assert!(dynamic.details.as_ref().unwrap()["legacyCause"]
8542 .as_str()
8543 .unwrap()
8544 .contains("patch cannot write scope column"));
8545
8546 let unclassified =
8547 ClientError::from("a commit must contain at least one operation".to_owned());
8548 assert_eq!(unclassified.code, "client.failed");
8549 assert_eq!(unclassified.message, "the local authoring operation failed");
8550 assert!(unclassified.details.as_ref().unwrap()["legacyCause"]
8551 .as_str()
8552 .unwrap()
8553 .contains("at least one operation"));
8554
8555 let metadata = ClientError::from(QueryReadFailure {
8557 code: None,
8558 sqlite_code: Some(1),
8559 sqlite_message: Some("generic SQLite prose".to_owned()),
8560 rollback_failure: Some(Box::new(QueryReadFailure::from(
8561 rusqlite::Error::SqliteFailure(rusqlite::ffi::Error::new(1), None),
8562 ))),
8563 message: "unclassified read".to_owned(),
8564 });
8565 assert_eq!(metadata.code, "client.failed");
8566 assert_eq!(metadata.message, "the local authoring operation failed");
8567 let details = metadata.details.as_ref().unwrap();
8568 assert_eq!(details["sqliteCode"], 1);
8569 assert_eq!(details["sqliteMessage"], "generic SQLite prose");
8570 assert_eq!(details["rollbackFailure"]["sqliteCode"], 1);
8571 assert_eq!(details["legacyCause"], "unclassified read");
8572 }
8573
8574 #[test]
8575 fn schema_marker_read_keeps_storage_classification() {
8576 let busy = schema_marker_read_failure(rusqlite::Error::SqliteFailure(
8577 rusqlite::ffi::Error::new(5),
8578 Some("database is locked".to_owned()),
8579 ));
8580 assert!(busy.starts_with("client.storage_busy:"), "{busy}");
8581 let invalid = schema_marker_read_failure(rusqlite::Error::SqliteFailure(
8582 rusqlite::ffi::Error::new(1),
8583 Some("unreadable".to_owned()),
8584 ));
8585 assert_eq!(
8586 invalid,
8587 "sync.local_corrupt: persisted local schema marker is unreadable or invalid"
8588 );
8589
8590 let path =
8593 std::env::temp_dir().join(format!("syncular-marker-busy-{}.db", uuid::Uuid::new_v4()));
8594 let setup = Connection::open(&path).expect("setup connection");
8595 setup
8596 .execute_batch(
8597 "CREATE TABLE _syncular_meta(key TEXT PRIMARY KEY, value TEXT);\n INSERT INTO _syncular_meta VALUES ('localSchemaVersion','1');",
8598 )
8599 .unwrap();
8600 let locker = Connection::open(&path).expect("locker connection");
8601 locker.execute_batch("BEGIN EXCLUSIVE").unwrap();
8602 let reader = Connection::open(&path).expect("reader connection");
8603 reader.busy_timeout(std::time::Duration::ZERO).unwrap();
8604 let locked = read_local_schema_version(&reader, 1).expect_err("locked marker read");
8605 assert!(locked.starts_with("client.storage_busy:"), "{locked}");
8606 locker.execute_batch("ROLLBACK").unwrap();
8607 assert_eq!(read_local_schema_version(&reader, 1).unwrap(), Some(1));
8608 let rows = setup
8609 .query_row("SELECT count(*) FROM _syncular_meta", [], |row| {
8610 row.get::<_, i64>(0)
8611 })
8612 .unwrap();
8613 assert_eq!(rows, 1, "the guard performed no writes");
8614 drop(reader);
8615 drop(locker);
8616 drop(setup);
8617 let _ = std::fs::remove_file(&path);
8618 }
8619
8620 #[cfg(feature = "crdt-yjs")]
8621 #[test]
8622 fn crdt_authoring_pre_read_preserves_storage_classification() {
8623 let path =
8624 std::env::temp_dir().join(format!("syncular-crdt-busy-{}.db", uuid::Uuid::new_v4()));
8625 let schema = json!({"version":1,"tables":[{"name":"notes","primaryKey":"id","columns":[
8626 {"name":"id","type":"string","nullable":false},
8627 {"name":"project_id","type":"string","nullable":false},
8628 {"name":"doc","type":"crdt","nullable":true,"crdtType":"yjs-doc"}],
8629 "scopes":[{"pattern":"project:{project_id}"}]}]});
8630 let mut client = SyncClient::open_path(
8631 "crdt-busy".into(),
8632 &schema,
8633 ClientLimits::default(),
8634 path.to_str().unwrap(),
8635 )
8636 .expect("open client");
8637 client.create_synced_tables().expect("create synced tables");
8638 client.conn.busy_timeout(std::time::Duration::ZERO).unwrap();
8639
8640 let locker = Connection::open(&path).expect("locker connection");
8641 locker.execute_batch("BEGIN EXCLUSIVE").unwrap();
8642 let busy = client
8643 .crdt_apply_update("notes", "n1", "doc", &[])
8644 .expect_err("busy crdt pre-read");
8645 assert_eq!(busy.code, "client.storage_busy");
8646 assert!(busy.retryable);
8647 assert_eq!(busy.details.as_ref().unwrap()["sqliteCode"], 5);
8648 assert!(client.pending_commit_ids().is_empty());
8649 assert_eq!(client.local_revision(), 0);
8650
8651 locker.execute_batch("ROLLBACK").unwrap();
8652 drop(locker);
8653 drop(client);
8654 let _ = std::fs::remove_file(&path);
8655 }
8656
8657 #[test]
8658 fn large_unique_import_copies_no_unrelated_tables() {
8659 let (mut client, image, table) = unique_import_fixture(10240);
8660 let pending = client.pending_commit_ids();
8661 client.conn.execute_batch("CREATE TRIGGER unrelated_insert BEFORE INSERT ON notes BEGIN SELECT RAISE(ABORT,'unrelated insert'); END;
8662 CREATE TRIGGER unrelated_delete BEFORE DELETE ON notes BEGIN SELECT RAISE(ABORT,'unrelated delete'); END;").unwrap();
8663 assert!(matches!(
8664 client.apply_sqlite_image(
8665 &image,
8666 &table,
8667 true,
8668 &[("project_id".into(), vec!["p1".into()])],
8669 10240,
8670 7,
8671 "digest"
8672 ),
8673 Ok(10240)
8674 ));
8675 assert_eq!(client.overlay_rebuild_count.get(), 0);
8676 assert_eq!(client.pending_commit_ids(), pending);
8677 assert_eq!(
8678 client
8679 .query("SELECT count(*) AS n FROM tasks", &[])
8680 .unwrap()[0]["n"],
8681 10241
8682 );
8683 assert_eq!(
8684 client
8685 .query("SELECT count(*) AS n FROM tasks_fts", &[])
8686 .unwrap()[0]["n"],
8687 10241
8688 );
8689 assert_eq!(
8690 client
8691 .query("SELECT count(*) AS n FROM notes_fts", &[])
8692 .unwrap()[0]["n"],
8693 8192
8694 );
8695 }
8696
8697 #[test]
8698 #[ignore = "manual scaling benchmark; see bench/README.md"]
8699 fn benchmark_unique_image_import() {
8700 let mut samples = Vec::new();
8701 for rows in [10240, 51200, 102400] {
8702 for trial in 0..4 {
8703 let (mut client, image, table) = unique_import_fixture(rows);
8704 let start = std::time::Instant::now();
8705 assert!(
8706 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)
8707 );
8708 let elapsed = start.elapsed().as_secs_f64() * 1000.0;
8709 assert_eq!(
8710 client
8711 .query("SELECT count(*) AS n FROM tasks", &[])
8712 .unwrap()[0]["n"],
8713 rows + 1
8714 );
8715 assert_eq!(
8716 client
8717 .query("SELECT count(*) AS n FROM tasks_fts", &[])
8718 .unwrap()[0]["n"],
8719 rows + 1
8720 );
8721 assert_eq!(
8722 client
8723 .query("SELECT count(*) AS n FROM notes_fts", &[])
8724 .unwrap()[0]["n"],
8725 8192
8726 );
8727 assert_eq!(client.pending_commit_ids().len(), 1);
8728 if trial > 0 {
8729 samples.push(json!({"rows":rows,"trial":trial,"elapsedMs":elapsed,"fullRebuilds":client.overlay_rebuild_count.get()}));
8730 }
8731 }
8732 }
8733 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});
8734 let output = serde_json::to_string_pretty(&artifact).unwrap();
8735 if let Ok(path) = std::env::var("SYNCULAR_IMPORT_BENCH_OUTPUT") {
8736 use std::io::Write;
8737 let mut file = std::fs::OpenOptions::new()
8738 .write(true)
8739 .create_new(true)
8740 .open(path)
8741 .unwrap();
8742 file.write_all(output.as_bytes()).unwrap();
8743 } else {
8744 println!("{output}");
8745 }
8746 }
8747
8748 #[test]
8749 fn local_fts_projection_tracks_optimistic_overlay_rebuilds() {
8750 let schema = json!({
8751 "version": 1,
8752 "tables": [{
8753 "name": "catalogue_codes",
8754 "primaryKey": "id",
8755 "columns": [
8756 { "name": "id", "type": "string", "nullable": false },
8757 { "name": "release_id", "type": "string", "nullable": false },
8758 { "name": "code", "type": "string", "nullable": false },
8759 { "name": "title", "type": "string", "nullable": false }
8760 ],
8761 "scopes": [{ "pattern": "release:{release_id}" }],
8762 "ftsIndexes": [{
8763 "name": "catalogue_codes_fts",
8764 "columns": ["code", "title"],
8765 "tokenize": "unicode61 remove_diacritics 2"
8766 }]
8767 }]
8768 });
8769 let mut client = SyncClient::new("fts-test".to_owned(), &schema, ClientLimits::default())
8770 .expect("FTS5 client");
8771 let insert_trigger: String = client
8772 .conn
8773 .query_row(
8774 "SELECT sql FROM sqlite_master WHERE type='trigger' AND name='catalogue_codes_fts_ai'",
8775 [],
8776 |row| row.get(0),
8777 )
8778 .expect("insert trigger");
8779 let replace_guard: String = client
8780 .conn
8781 .query_row(
8782 "SELECT sql FROM sqlite_master WHERE type='trigger' AND name='catalogue_codes_fts_bi'",
8783 [],
8784 |row| row.get(0),
8785 )
8786 .expect("replace guard");
8787 assert!(!insert_trigger.contains("DELETE FROM"));
8788 assert!(replace_guard.contains("BEFORE INSERT"));
8789 assert!(replace_guard.contains("WHEN EXISTS"));
8790 let search = |client: &SyncClient, query: &str| {
8791 client
8792 .query(
8793 "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",
8794 &[Value::from(query)],
8795 )
8796 .expect("FTS query")
8797 };
8798
8799 client
8800 .mutate(vec![Mutation::Upsert {
8801 table: "catalogue_codes".to_owned(),
8802 values: Map::from_iter([
8803 ("id".to_owned(), Value::from("c1")),
8804 ("release_id".to_owned(), Value::from("r1")),
8805 ("code".to_owned(), Value::from("A01")),
8806 ("title".to_owned(), Value::from("Cholera")),
8807 ]),
8808 base_version: None,
8809 }])
8810 .expect("insert code");
8811 assert_eq!(search(&client, "cholera").len(), 1);
8812 let prior = client
8813 .query("SELECT rowid, * FROM catalogue_codes_fts", &[])
8814 .unwrap();
8815 client
8816 .conn
8817 .execute_batch("DROP TABLE _syncular_fts_catalogue_codes_fts")
8818 .unwrap();
8819 client.create_synced_tables().unwrap();
8820 assert_eq!(
8821 client
8822 .query("SELECT rowid, * FROM catalogue_codes_fts", &[])
8823 .unwrap(),
8824 prior
8825 );
8826 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();
8827 let rendered = serde_json::to_string(&plan).unwrap();
8828 assert!(rendered.contains("COVERING INDEX"));
8829 assert!(rendered.contains("INDEX 0:="));
8830 client.conn.execute_batch("SAVEPOINT test_mapping; DELETE FROM catalogue_codes WHERE id='c1'; ROLLBACK TO test_mapping; RELEASE test_mapping").unwrap();
8831 assert_eq!(search(&client, "cholera").len(), 1);
8832
8833 client
8834 .mutate(vec![Mutation::Upsert {
8835 table: "catalogue_codes".to_owned(),
8836 values: Map::from_iter([
8837 ("id".to_owned(), Value::from("c1")),
8838 ("release_id".to_owned(), Value::from("r1")),
8839 ("code".to_owned(), Value::from("A01")),
8840 ("title".to_owned(), Value::from("Enteric infection")),
8841 ]),
8842 base_version: None,
8843 }])
8844 .expect("update code");
8845 assert!(search(&client, "cholera").is_empty());
8846 assert_eq!(search(&client, "enteric").len(), 1);
8847
8848 client
8849 .mutate(vec![Mutation::Delete {
8850 table: "catalogue_codes".to_owned(),
8851 row_id: "c1".to_owned(),
8852 base_version: None,
8853 }])
8854 .expect("delete code");
8855 assert!(search(&client, "enteric").is_empty());
8856 }
8857
8858 #[test]
8859 fn application_authorized_local_purge_is_exact_atomic_and_idempotent() {
8860 let schema = json!({
8861 "version": 1,
8862 "tables": [{
8863 "name": "patient_notes",
8864 "primaryKey": "id",
8865 "columns": [
8866 { "name": "id", "type": "string", "nullable": false },
8867 { "name": "practice_id", "type": "string", "nullable": false },
8868 { "name": "encryption_key_id", "type": "string", "nullable": false },
8869 { "name": "title", "type": "string", "nullable": false }
8870 ],
8871 "scopes": [{ "pattern": "practice:{practice_id}" }],
8872 "ftsIndexes": [{
8873 "name": "patient_notes_fts",
8874 "columns": ["title"],
8875 "tokenize": "unicode61 remove_diacritics 2"
8876 }]
8877 }]
8878 });
8879 let mut client = SyncClient::new(
8880 "local-purge-test".to_owned(),
8881 &schema,
8882 ClientLimits::default(),
8883 )
8884 .expect("local purge client");
8885 client
8886 .conn
8887 .execute(
8888 "INSERT INTO _syncular_base_patient_notes(id, practice_id, encryption_key_id, title, _syncular_version) VALUES
8889 ('target', 'practice-1', 'key-revoked', 'Target original', 1),
8890 ('unrelated', 'practice-1', 'key-held', 'Unrelated original', 1)",
8891 [],
8892 )
8893 .expect("seed base rows");
8894 client.overlay_dirty.set(true);
8895 client.rebuild_overlay_if_dirty().expect("rebuild overlay");
8896
8897 let note = |id: &str, key_id: &str, title: &str| {
8898 Map::from_iter([
8899 ("id".to_owned(), Value::from(id)),
8900 ("practice_id".to_owned(), Value::from("practice-1")),
8901 ("encryption_key_id".to_owned(), Value::from(key_id)),
8902 ("title".to_owned(), Value::from(title)),
8903 ])
8904 };
8905 let doomed = client
8906 .mutate(vec![
8907 Mutation::Upsert {
8908 table: "patient_notes".to_owned(),
8909 values: note("target", "key-revoked", "Target changed"),
8910 base_version: None,
8911 },
8912 Mutation::Upsert {
8913 table: "patient_notes".to_owned(),
8914 values: note("unrelated", "key-held", "Unrelated changed"),
8915 base_version: None,
8916 },
8917 ])
8918 .expect("doomed commit");
8919 let kept = client
8920 .mutate(vec![Mutation::Upsert {
8921 table: "patient_notes".to_owned(),
8922 values: note("kept", "key-held", "Kept optimistic"),
8923 base_version: None,
8924 }])
8925 .expect("kept commit");
8926 client.drain_change_batches();
8927
8928 let input = LocalDataPurgeInput {
8929 purge_id: "purge-001".to_owned(),
8930 targets: vec![LocalDataPurgeTarget {
8931 table: "patient_notes".to_owned(),
8932 selectors: BTreeMap::from([(
8933 "encryption_key_id".to_owned(),
8934 vec!["key-revoked".to_owned()],
8935 )]),
8936 }],
8937 };
8938 assert_eq!(
8939 client.purge_local_data(&input).expect("apply purge"),
8940 LocalDataPurgeResult {
8941 already_applied: false,
8942 purged_rows: 1,
8943 dropped_commits: 1,
8944 }
8945 );
8946 let rows = client
8947 .query("SELECT id, title FROM patient_notes ORDER BY id", &[])
8948 .expect("visible rows");
8949 assert_eq!(rows.len(), 2);
8950 assert_eq!(rows[0]["id"], "kept");
8951 assert_eq!(rows[1]["id"], "unrelated");
8952 assert_eq!(rows[1]["title"], "Unrelated original");
8953 let fts = client
8954 .query(
8955 "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'",
8956 &[],
8957 )
8958 .expect("fts query");
8959 assert!(fts.is_empty());
8960 assert_eq!(client.outbox.len(), 1);
8961 assert_eq!(client.outbox[0].client_commit_id, kept);
8962 let outcome = client
8963 .commit_outcome(&doomed)
8964 .expect("read doomed outcome")
8965 .expect("doomed outcome");
8966 assert_eq!(outcome.status, CommitOutcomeStatus::Rejected);
8967 match &outcome.results[0] {
8968 CommitOperationOutcome::Error { rejection } => {
8969 assert_eq!(rejection.code, "client.local_data_purged");
8970 assert_eq!(rejection.client_commit_id, doomed);
8971 }
8972 other => panic!("expected local purge rejection, got {other:?}"),
8973 }
8974 assert_eq!(client.drain_change_batches().len(), 1);
8975 assert_eq!(
8976 client.purge_local_data(&input).expect("retry purge"),
8977 LocalDataPurgeResult {
8978 already_applied: true,
8979 purged_rows: 0,
8980 dropped_commits: 0,
8981 }
8982 );
8983 let conflicting = LocalDataPurgeInput {
8984 purge_id: input.purge_id.clone(),
8985 targets: vec![LocalDataPurgeTarget {
8986 table: "patient_notes".to_owned(),
8987 selectors: BTreeMap::from([(
8988 "encryption_key_id".to_owned(),
8989 vec!["key-held".to_owned()],
8990 )]),
8991 }],
8992 };
8993 assert!(client
8994 .purge_local_data(&conflicting)
8995 .expect_err("id collision must fail")
8996 .contains("already used with a different plan"));
8997 }
8998}
8999
9000impl SubState {
9001 fn name(self) -> &'static str {
9002 match self {
9003 SubState::Active => "active",
9004 SubState::Revoked => "revoked",
9005 SubState::Failed => "failed",
9006 }
9007 }
9008
9009 fn parse(value: &str) -> Self {
9010 match value {
9011 "revoked" => Self::Revoked,
9012 "failed" => Self::Failed,
9013 _ => Self::Active,
9014 }
9015 }
9016}
9017
9018#[derive(Debug, Clone)]
9019struct Subscription {
9020 id: String,
9021 table: String,
9022 requested: Vec<(String, Vec<String>)>,
9023 params: Option<String>,
9024 cursor: i64,
9025 bootstrap_state: Option<String>,
9027 state: SubState,
9028 reason_code: Option<String>,
9029 effective: Option<Vec<(String, Vec<String>)>>,
9032 synced_once: bool,
9033}
9034
9035#[derive(Debug, Clone)]
9036struct OutboxOp {
9037 upsert: bool,
9038 table: String,
9039 row_id: String,
9040 base_version: Option<i64>,
9041 values: Option<Map<String, Value>>,
9045}
9046
9047impl From<&OutboxOp> for CommitOperation {
9048 fn from(operation: &OutboxOp) -> Self {
9049 Self {
9050 table: operation.table.clone(),
9051 row_id: operation.row_id.clone(),
9052 op: if operation.upsert { "upsert" } else { "delete" }.to_owned(),
9053 base_version: operation.base_version,
9054 values: operation.values.clone(),
9055 }
9056 }
9057}
9058
9059#[derive(Debug, Clone)]
9060struct OutboxCommit {
9061 client_commit_id: String,
9062 ops: Vec<OutboxOp>,
9063}
9064
9065fn commit_audit_operations(commit: &OutboxCommit) -> Vec<(&str, Vec<&str>)> {
9069 commit
9070 .ops
9071 .iter()
9072 .filter(|operation| operation.upsert)
9073 .map(|operation| {
9074 (
9075 operation.table.as_str(),
9076 operation
9077 .values
9078 .as_ref()
9079 .map(|values| values.keys().map(String::as_str).collect())
9080 .unwrap_or_default(),
9081 )
9082 })
9083 .collect()
9084}
9085
9086type FailedCommit = (OutboxCommit, Map<String, Value>);
9087
9088#[derive(Debug, Clone)]
9089struct CompiledLocalDataPurgeTarget {
9090 table: String,
9091 selectors: Vec<(String, Vec<String>)>,
9092}
9093
9094struct StoredCommitOutcomeRow {
9095 sequence: i64,
9096 client_commit_id: String,
9097 status: String,
9098 recorded_at_ms: i64,
9099 results_json: String,
9100 operations_json: Option<String>,
9101 resolution: String,
9102 resolved_at_ms: Option<i64>,
9103 replacement_client_commit_id: Option<String>,
9104}
9105
9106impl StoredCommitOutcomeRow {
9107 fn from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<Self> {
9108 Ok(Self {
9109 sequence: row.get(0)?,
9110 client_commit_id: row.get(1)?,
9111 status: row.get(2)?,
9112 recorded_at_ms: row.get(3)?,
9113 results_json: row.get(4)?,
9114 operations_json: row.get(5)?,
9115 resolution: row.get(6)?,
9116 resolved_at_ms: row.get(7)?,
9117 replacement_client_commit_id: row.get(8)?,
9118 })
9119 }
9120}
9121
9122enum SectionError {
9125 FailClosed,
9126 Abort(String, String),
9127}
9128
9129#[derive(Debug)]
9130pub(crate) struct RequestMeta {
9131 pushed_ids: Vec<String>,
9132 fresh: Vec<(String, bool)>,
9135 pub(crate) accept: u8,
9136 deferred_commits: usize,
9140}
9141
9142#[derive(Default)]
9143struct ChangeAccumulator {
9144 tables: BTreeMap<String, Option<BTreeSet<String>>>,
9146 windows: BTreeMap<(String, String), BTreeSet<String>>,
9147 status: bool,
9148 conflicts: bool,
9149 rejections: bool,
9150 outcomes: bool,
9151}
9152
9153impl ChangeAccumulator {
9154 fn table(&mut self, table: &str) {
9155 self.tables.insert(table.to_owned(), None);
9156 }
9157
9158 fn scope(&mut self, table: &str, key: String) {
9159 match self.tables.get_mut(table) {
9160 Some(None) => {}
9161 Some(Some(keys)) => {
9162 keys.insert(key);
9163 }
9164 None => {
9165 self.tables
9166 .insert(table.to_owned(), Some(BTreeSet::from([key])));
9167 }
9168 }
9169 }
9170
9171 fn window(&mut self, base_key: &str, table: &str, unit: &str) {
9172 self.windows
9173 .entry((base_key.to_owned(), table.to_owned()))
9174 .or_default()
9175 .insert(unit.to_owned());
9176 }
9177
9178 fn touched(&self) -> bool {
9179 !self.tables.is_empty()
9180 || !self.windows.is_empty()
9181 || self.status
9182 || self.conflicts
9183 || self.rejections
9184 || self.outcomes
9185 }
9186}
9187
9188const PUSH_OPS_PER_REQUEST: usize = 500;
9194const MAX_LOCAL_PURGE_TARGETS: usize = 64;
9195const MAX_LOCAL_PURGE_SELECTORS: usize = 8;
9196const MAX_LOCAL_PURGE_VALUES: usize = 128;
9197const MAX_LOCAL_PURGE_VALUE_LENGTH: usize = 256;
9198pub const SECURITY_PREFLIGHT_REQUIRED_CODE: &str = "client.security_preflight_required";
9199
9200struct OverlayDirty(RefCell<Option<BTreeSet<String>>>);
9202
9203impl OverlayDirty {
9204 fn clean() -> Self {
9205 Self(RefCell::new(Some(BTreeSet::new())))
9206 }
9207 fn get(&self) -> bool {
9208 self.0
9209 .borrow()
9210 .as_ref()
9211 .is_none_or(|tables| !tables.is_empty())
9212 }
9213 fn set(&self, dirty: bool) {
9214 *self.0.borrow_mut() = if dirty { None } else { Some(BTreeSet::new()) };
9215 }
9216 fn table(&self, table: &str) {
9217 if let Some(tables) = self.0.borrow_mut().as_mut() {
9218 tables.insert(table.to_owned());
9219 }
9220 }
9221 fn snapshot(&self) -> Option<BTreeSet<String>> {
9222 self.0.borrow().clone()
9223 }
9224 fn restore(&self, tables: Option<BTreeSet<String>>) {
9225 *self.0.borrow_mut() = tables;
9226 }
9227}
9228
9229pub struct SyncClient {
9230 #[cfg(feature = "bench-internals")]
9231 benchmark_phases: Recorder,
9232 conn: Connection,
9233 schema: ClientSchema,
9234 client_id: String,
9235 limits: ClientLimits,
9236 subs: Vec<Subscription>,
9237 outbox: Vec<OutboxCommit>,
9238 failed_commits: Vec<FailedCommit>,
9239 retain_failed_commits: bool,
9240 conflicts: Vec<ConflictRecord>,
9241 rejections: Vec<RejectionRecord>,
9242 schema_floor: Option<SchemaFloor>,
9243 lease_state: Option<LeaseState>,
9245 stopped: bool,
9247 upgrading: bool,
9249 sync_needed: bool,
9251 realtime_policy: RealtimePolicy,
9254 realtime_state: RealtimeState,
9255 realtime_reason_code: Option<String>,
9256 realtime_retry_delay_ms: Option<u64>,
9257 presence: HashMap<String, HashMap<String, PresencePeer>>,
9259 now_ms: Option<i64>,
9262 encryption: crate::values::EncryptionConfig,
9266 security_preflight: bool,
9269 transport_enabled: bool,
9270 previous_version: Option<PreviousVersionContextConfig>,
9274 insert_sql: RefCell<HashMap<String, String>>,
9280 overlay_dirty: OverlayDirty,
9283 storage_failure: RefCell<Option<QueryReadFailure>>,
9285 #[cfg(test)]
9288 overlay_rebuild_count: Cell<usize>,
9289 #[cfg(test)]
9290 acknowledged_replay_count: Cell<usize>,
9291 #[cfg(test)]
9294 outcome_prune_count: Cell<usize>,
9295 progress: ProgressObserver,
9297 change_queue: VecDeque<ClientChangeBatch>,
9298 sync_intent_queue: VecDeque<SyncIntent>,
9299 retry_delay_ms: u64,
9301 round_retry_delay_ms: Option<u64>,
9303 active_round: Option<uuid::Uuid>,
9304 last_round: Option<DiagnosticLastRound>,
9305 last_change: Option<DiagnosticLastChange>,
9306 query_failures: Vec<DiagnosticQueryFailure>,
9308}
9309
9310pub(crate) fn quote_ident(name: &str) -> String {
9311 format!("\"{}\"", name.replace('"', "\"\""))
9312}
9313pub(crate) fn meta_get(conn: &Connection, key: &str) -> Option<String> {
9317 conn.query_row(
9318 "SELECT value FROM _syncular_meta WHERE key = ?1",
9319 rusqlite::params![key],
9320 |row| row.get::<_, String>(0),
9321 )
9322 .ok()
9323}
9324
9325pub(crate) fn meta_set(conn: &Connection, key: &str, value: &str) {
9326 let _ = conn.execute(
9327 "INSERT OR REPLACE INTO _syncular_meta (key, value) VALUES (?1, ?2)",
9328 rusqlite::params![key, value],
9329 );
9330}
9331
9332pub(crate) fn meta_delete(conn: &Connection, key: &str) {
9333 let _ = conn.execute(
9334 "DELETE FROM _syncular_meta WHERE key = ?1",
9335 rusqlite::params![key],
9336 );
9337}
9338
9339fn row_id_predicate(table: &TableSchema) -> String {
9341 let key = quote_ident(&table.primary_key);
9342 let text_match = format!("CAST({key} AS TEXT) = ?1");
9343 match table.columns[table.pk_index].ty {
9344 ColumnType::String | ColumnType::Json => {
9345 format!("{key} = ?1 AND {text_match}")
9346 }
9347 ColumnType::Integer | ColumnType::Boolean => {
9351 format!("{key} = +CAST(?1 AS INTEGER) AND {text_match}")
9352 }
9353 _ => text_match,
9356 }
9357}
9358
9359fn is_local_operation_code_like(value: &str) -> bool {
9360 let bytes = value.as_bytes();
9361 bytes.first().is_some_and(u8::is_ascii_alphanumeric)
9362 && bytes
9363 .iter()
9364 .all(|byte| byte.is_ascii_alphanumeric() || matches!(*byte, b'.' | b'_' | b':' | b'-'))
9365}
9366
9367fn base_table(name: &str) -> String {
9368 quote_ident(&format!("_syncular_base_{name}"))
9369}
9370
9371type PendingEvict = (String, String, Vec<(String, Vec<String>)>);
9376
9377fn window_base_key(base: &WindowBase) -> String {
9378 format!(
9379 "{}\0{}\0{}",
9380 base.table,
9381 base.variable,
9382 canonical_scope_json(&base.fixed_scopes)
9383 )
9384}
9385
9386fn unit_scopes(base: &WindowBase, unit: &str) -> Vec<(String, Vec<String>)> {
9388 let mut scopes = base.fixed_scopes.clone();
9389 scopes.retain(|(k, _)| k != &base.variable);
9390 scopes.push((base.variable.clone(), vec![unit.to_owned()]));
9391 scopes
9392}
9393
9394fn derive_sub_id(base: &WindowBase, unit: &str) -> String {
9398 let canonical = canonical_scope_json(&unit_scopes(base, unit));
9399 let digest = Sha256::digest(canonical.as_bytes());
9400 let hex = bytes_to_hex(&digest);
9401 format!("w:{}:{}", base.table, &hex[..16])
9402}
9403
9404fn visible_table(name: &str) -> String {
9405 quote_ident(name)
9406}
9407
9408const FTS_SOURCE_ID_COLUMN: &str = "_syncular_source_id";
9409
9410fn is_synced_table_name(name: &str) -> bool {
9413 if name.starts_with("sqlite_") {
9414 return false;
9415 }
9416 if name.starts_with("_syncular_base_") {
9417 return true; }
9419 !name.starts_with("_syncular_")
9421}
9422
9423fn blob_id_for(bytes: &[u8]) -> String {
9425 let digest = Sha256::digest(bytes);
9426 format!("sha256:{}", bytes_to_hex(&digest))
9427}
9428
9429enum RowParam<'a> {
9433 Cell(&'a Option<ColumnValue>),
9434 Version(i64),
9435}
9436
9437impl rusqlite::ToSql for RowParam<'_> {
9438 fn to_sql(&self) -> rusqlite::Result<ToSqlOutput<'_>> {
9439 Ok(match self {
9440 RowParam::Version(v) => ToSqlOutput::Owned(SqlValue::Integer(*v)),
9441 RowParam::Cell(cell) => match cell {
9442 None => ToSqlOutput::Owned(SqlValue::Null),
9443 Some(ColumnValue::String(s)) => ToSqlOutput::Borrowed(ValueRef::Text(s.as_bytes())),
9444 Some(ColumnValue::Integer(i)) => ToSqlOutput::Owned(SqlValue::Integer(*i)),
9445 Some(ColumnValue::Float(f)) => ToSqlOutput::Owned(SqlValue::Real(*f)),
9446 Some(ColumnValue::Boolean(b)) => {
9447 ToSqlOutput::Owned(SqlValue::Integer(i64::from(*b)))
9448 }
9449 Some(ColumnValue::Json(raw)) | Some(ColumnValue::BlobRef(raw)) => {
9450 ToSqlOutput::Borrowed(ValueRef::Text(raw.0.as_bytes()))
9451 }
9452 Some(ColumnValue::Bytes(b)) | Some(ColumnValue::Crdt(b)) => {
9454 ToSqlOutput::Borrowed(ValueRef::Blob(b))
9455 }
9456 },
9457 })
9458 }
9459}
9460
9461fn owned_sql_value(param: impl rusqlite::ToSql) -> Result<SqlValue, String> {
9463 match param.to_sql().map_err(|error| error.to_string())? {
9464 ToSqlOutput::Owned(value) => Ok(value),
9465 ToSqlOutput::Borrowed(value) => {
9466 SqlValue::try_from(value).map_err(|error| error.to_string())
9467 }
9468 _ => Err("sync.invalid_request: unsupported SQLite key parameter".into()),
9469 }
9470}
9471
9472fn image_cell_param<'a>(column: &Column, value: ValueRef<'a>) -> Result<ToSqlOutput<'a>, String> {
9480 use ssp2::segment::ColumnType;
9481 let mismatch = || {
9482 Err(format!(
9483 "image column {:?} holds a value of the wrong type",
9484 column.name
9485 ))
9486 };
9487 match value {
9488 ValueRef::Null => {
9489 if !column.nullable {
9490 return Err(format!(
9491 "image column {:?} is NULL but not nullable",
9492 column.name
9493 ));
9494 }
9495 Ok(ToSqlOutput::Owned(SqlValue::Null))
9496 }
9497 ValueRef::Integer(i) => match column.ty {
9498 ColumnType::Integer => Ok(ToSqlOutput::Borrowed(value)),
9499 ColumnType::Boolean => Ok(ToSqlOutput::Owned(SqlValue::Integer(i64::from(i != 0)))),
9500 ColumnType::Float => Ok(ToSqlOutput::Owned(SqlValue::Real(i as f64))),
9501 _ => mismatch(),
9502 },
9503 ValueRef::Real(_) => match column.ty {
9504 ColumnType::Float => Ok(ToSqlOutput::Borrowed(value)),
9505 _ => mismatch(),
9506 },
9507 ValueRef::Text(t) => {
9508 std::str::from_utf8(t)
9509 .map_err(|_| format!("image column {:?} is not UTF-8", column.name))?;
9510 match column.ty {
9511 ColumnType::String | ColumnType::Json | ColumnType::BlobRef => {
9512 Ok(ToSqlOutput::Borrowed(value))
9513 }
9514 _ => mismatch(),
9515 }
9516 }
9517 ValueRef::Blob(_) => match column.ty {
9518 ColumnType::Bytes | ColumnType::Crdt => Ok(ToSqlOutput::Borrowed(value)),
9520 _ => mismatch(),
9521 },
9522 }
9523}
9524
9525pub(crate) fn sql_ref_to_json(column: &Column, value: rusqlite::types::ValueRef<'_>) -> Value {
9526 use rusqlite::types::ValueRef;
9527 match value {
9528 ValueRef::Null => Value::Null,
9529 ValueRef::Integer(i) => match column.ty {
9530 ssp2::segment::ColumnType::Boolean => Value::Bool(i != 0),
9531 ssp2::segment::ColumnType::Float => {
9532 serde_json::Number::from_f64(i as f64).map_or(Value::Null, Value::Number)
9533 }
9534 _ => Value::from(i),
9535 },
9536 ValueRef::Real(f) => serde_json::Number::from_f64(f).map_or(Value::Null, Value::Number),
9537 ValueRef::Text(t) => Value::from(String::from_utf8_lossy(t).into_owned()),
9538 ValueRef::Blob(b) => {
9539 let mut map = Map::new();
9540 map.insert("$bytes".to_owned(), Value::from(bytes_to_hex(b)));
9541 Value::Object(map)
9542 }
9543 }
9544}
9545
9546fn json_param_to_sql(value: &Value) -> Result<SqlValue, String> {
9550 Ok(match value {
9551 Value::Null => SqlValue::Null,
9552 Value::Bool(b) => SqlValue::Integer(i64::from(*b)),
9553 Value::Number(n) => {
9554 if let Some(i) = n.as_i64() {
9555 SqlValue::Integer(i)
9556 } else if let Some(f) = n.as_f64() {
9557 SqlValue::Real(f)
9558 } else {
9559 return Err(format!("query param number {n} is out of range"));
9560 }
9561 }
9562 Value::String(s) => SqlValue::Text(s.clone()),
9563 Value::Object(_) => {
9564 if let Some(hex) = value.get("$bytes").and_then(Value::as_str) {
9565 SqlValue::Blob(crate::values::hex_to_bytes(hex)?)
9566 } else if let Some(decimal) = value.get("$bigint").and_then(Value::as_str) {
9567 SqlValue::Integer(decimal.parse::<i64>().map_err(|_| {
9568 format!("query bigint param {decimal:?} is outside SQLite's i64 range")
9569 })?)
9570 } else {
9571 return Err(
9572 "query object param must be a {$bytes: hex} or {$bigint: decimal} value"
9573 .to_owned(),
9574 );
9575 }
9576 }
9577 Value::Array(_) => return Err("query array params are not supported".to_owned()),
9578 })
9579}
9580
9581fn sql_ref_to_json_dynamic(value: rusqlite::types::ValueRef<'_>) -> Value {
9586 use rusqlite::types::ValueRef;
9587 match value {
9588 ValueRef::Null => Value::Null,
9589 ValueRef::Integer(i) => {
9590 const MAX_SAFE_INTEGER: i64 = 9_007_199_254_740_991;
9594 if (-MAX_SAFE_INTEGER..=MAX_SAFE_INTEGER).contains(&i) {
9595 Value::from(i)
9596 } else {
9597 let mut map = Map::new();
9598 map.insert("$bigint".to_owned(), Value::from(i.to_string()));
9599 Value::Object(map)
9600 }
9601 }
9602 ValueRef::Real(f) => serde_json::Number::from_f64(f).map_or(Value::Null, Value::Number),
9603 ValueRef::Text(t) => Value::from(String::from_utf8_lossy(t).into_owned()),
9604 ValueRef::Blob(b) => {
9605 let mut map = Map::new();
9606 map.insert("$bytes".to_owned(), Value::from(bytes_to_hex(b)));
9607 Value::Object(map)
9608 }
9609 }
9610}
9611
9612fn query_connection(
9614 conn: &Connection,
9615 sql: &str,
9616 params: &[QueryValue],
9617) -> Result<Vec<QueryRow>, QueryReadFailure> {
9618 crate::query_guard::assert_read_only_query(sql)?;
9619 let lowered_sql = crate::query_guard::lower_public_query_sql(sql);
9620 let bound: Vec<SqlValue> = params
9621 .iter()
9622 .map(json_param_to_sql)
9623 .collect::<Result<_, _>>()?;
9624 let mut stmt = conn.prepare(&lowered_sql)?;
9625 let column_names: Vec<String> = stmt.column_names().into_iter().map(str::to_owned).collect();
9626 let bound_refs: Vec<&dyn rusqlite::ToSql> =
9627 bound.iter().map(|v| v as &dyn rusqlite::ToSql).collect();
9628 let mut sql_rows = stmt.query(bound_refs.as_slice())?;
9629 let mut out = Vec::new();
9630 while let Some(row) = sql_rows.next()? {
9631 let mut record = Map::new();
9632 for (i, name) in column_names.iter().enumerate() {
9633 let value = row.get_ref(i)?;
9634 record.insert(name.clone(), sql_ref_to_json_dynamic(value));
9635 }
9636 out.push(record);
9637 }
9638 Ok(out)
9639}
9640
9641fn persisted_window_state(
9642 conn: &Connection,
9643 base: &WindowBase,
9644) -> Result<WindowState, QueryReadFailure> {
9645 let mut stmt = conn.prepare(
9646 "SELECT windows.unit, subscriptions.state_json
9647 FROM _syncular_windows AS windows
9648 JOIN _syncular_subscriptions AS subscriptions
9649 ON subscriptions.id = windows.sub_id
9650 WHERE windows.base = ?1
9651 ORDER BY windows.unit ASC",
9652 )?;
9653 let rows = stmt.query_map(rusqlite::params![window_base_key(base)], |row| {
9654 Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
9655 })?;
9656 let mut units = Vec::new();
9657 let mut pending = Vec::new();
9658 for row in rows {
9659 let (unit, raw) = row?;
9660 let state: Value = serde_json::from_str(&raw)
9661 .map_err(|error| format!("invalid persisted window subscription: {error}"))?;
9662 let is_pending = state.get("status").and_then(Value::as_str) != Some("active")
9663 || state.get("cursor").and_then(Value::as_i64).unwrap_or(-1) < 0
9664 || state
9665 .get("bootstrapState")
9666 .is_some_and(|value| !value.is_null());
9667 if is_pending {
9668 pending.push(unit.clone());
9669 }
9670 units.push(unit);
9671 }
9672 Ok(WindowState { units, pending })
9673}
9674
9675#[derive(Debug, Clone, Copy)]
9677pub struct SnapshotStatement<'a> {
9678 pub sql: &'a str,
9679 pub params: &'a [Value],
9680}
9681
9682#[derive(Debug, Clone, Default)]
9686pub struct SnapshotReadRequest<'a> {
9687 pub statements: Vec<SnapshotStatement<'a>>,
9688 pub coverage: &'a [WindowCoverage],
9689 pub subscriptions: Vec<String>,
9690 pub commit_ids: Vec<String>,
9691}
9692
9693#[cfg(test)]
9694thread_local! {
9695 static SNAPSHOT_READ_HOOK: std::cell::RefCell<Option<Box<dyn FnOnce()>>> =
9699 std::cell::RefCell::new(None);
9700}
9701
9702#[cfg(test)]
9703fn run_snapshot_read_hook() {
9704 SNAPSHOT_READ_HOOK.with(|slot| {
9705 if let Some(hook) = slot.borrow_mut().take() {
9706 hook();
9707 }
9708 });
9709}
9710
9711fn snapshot_read_connection(
9716 conn: &Connection,
9717 request: &SnapshotReadRequest<'_>,
9718) -> Result<SnapshotRead, QueryReadFailure> {
9719 conn.execute_batch("SAVEPOINT syncular_snapshot_read")?;
9720 let corrupt = |message: &str| QueryReadFailure {
9721 code: Some("sync.local_corrupt"),
9722 sqlite_code: None,
9723 sqlite_message: None,
9724 rollback_failure: None,
9725 message: message.to_owned(),
9726 };
9727 let stored_failure = |error: rusqlite::Error, message: &str| match error {
9728 rusqlite::Error::Utf8Error(..)
9729 | rusqlite::Error::InvalidColumnType(..)
9730 | rusqlite::Error::FromSqlConversionFailure(..)
9731 | rusqlite::Error::IntegralValueOutOfRange(..) => corrupt(message),
9732 _ => QueryReadFailure::from(error),
9733 };
9734 let result: Result<SnapshotRead, QueryReadFailure> = (|| {
9735 let revision: Option<String> = conn
9738 .query_row(
9739 "SELECT value FROM _syncular_meta WHERE key = ?1",
9740 rusqlite::params![LOCAL_REVISION_KEY],
9741 |row| row.get(0),
9742 )
9743 .optional()
9744 .map_err(|error| stored_failure(error, "persisted local revision is invalid"))?;
9745 let revision = match revision {
9746 None => "0".to_owned(),
9747 Some(value)
9748 if value == "0"
9749 || (matches!(value.as_bytes().first(), Some(b'1'..=b'9'))
9750 && value.bytes().all(|b| b.is_ascii_digit())
9751 && value.parse::<u64>().is_ok()) =>
9752 {
9753 value
9754 }
9755 Some(_) => return Err(corrupt("persisted local revision is invalid")),
9756 };
9757 let mut queries = Vec::with_capacity(request.statements.len());
9758 for statement in &request.statements {
9759 queries.push(query_connection(conn, statement.sql, statement.params)?);
9760 #[cfg(test)]
9761 run_snapshot_read_hook();
9762 }
9763 let mut pending = Vec::new();
9764 let mut missing = Vec::new();
9765 for requested in request.coverage {
9766 let base_key = window_base_key(&requested.base);
9767 let state = persisted_window_state(conn, &requested.base)?;
9768 for unit in BTreeSet::from_iter(requested.units.iter().cloned()) {
9769 let reference = WindowUnitRef {
9770 base_key: base_key.clone(),
9771 unit: unit.clone(),
9772 };
9773 if !state.units.iter().any(|held| held == &unit) {
9774 missing.push(reference);
9775 } else if state.pending.iter().any(|held| held == &unit) {
9776 pending.push(reference);
9777 }
9778 }
9779 }
9780 let mut subscriptions = Vec::with_capacity(request.subscriptions.len());
9781 for id in &request.subscriptions {
9782 let row: Option<(String, String)> = conn
9783 .query_row(
9784 "SELECT tbl, state_json FROM _syncular_subscriptions WHERE id = ?1",
9785 rusqlite::params![id],
9786 |row| Ok((row.get(0)?, row.get(1)?)),
9787 )
9788 .optional()
9789 .map_err(|error| {
9790 stored_failure(error, "persisted subscription state is invalid")
9791 })?;
9792 let Some((table, raw)) = row else {
9793 subscriptions.push(SubscriptionCatchup::Unknown { id: id.clone() });
9794 continue;
9795 };
9796 let state: Value = serde_json::from_str(&raw)
9799 .map_err(|_| corrupt("persisted subscription state is invalid"))?;
9800 let status = match state.get("status").and_then(Value::as_str) {
9801 Some(value @ ("active" | "revoked" | "failed")) => value,
9802 _ => return Err(corrupt("persisted subscription state is invalid")),
9803 };
9804 let Some(cursor) = state.get("cursor").and_then(Value::as_i64) else {
9805 return Err(corrupt("persisted subscription state is invalid"));
9806 };
9807 if cursor.unsigned_abs() > MAX_JS_SAFE_INTEGER {
9808 return Err(corrupt("persisted subscription state is invalid"));
9809 }
9810 let effective_scopes = state
9811 .get("effectiveScopes")
9812 .filter(|value| !value.is_null());
9813 if let Some(scopes) = effective_scopes {
9814 json_to_scope_map(scopes)
9815 .map_err(|_| corrupt("persisted subscription state is invalid"))?;
9816 }
9817 let has_resume_token = match state.get("bootstrapState") {
9818 None | Some(Value::Null) => false,
9819 Some(Value::String(_)) => true,
9820 Some(_) => return Err(corrupt("persisted subscription state is invalid")),
9821 };
9822 let reason_code = match state.get("reasonCode") {
9825 None | Some(Value::Null) => None,
9826 Some(Value::String(value)) => Some(value.clone()),
9827 Some(_) => return Err(corrupt("persisted subscription state is invalid")),
9828 };
9829 let active = status == "active";
9830 subscriptions.push(SubscriptionCatchup::Known {
9831 id: id.clone(),
9832 table,
9833 status: status.to_owned(),
9834 cursor,
9835 has_resume_token,
9836 bootstrap_complete: active && cursor >= 0 && !has_resume_token,
9837 known_pending_pages: active && (has_resume_token || cursor < 0),
9838 effective_scopes: effective_scopes.cloned(),
9839 reason_code,
9840 });
9841 }
9842 let mut deliveries = Vec::with_capacity(request.commit_ids.len());
9843 for id in &request.commit_ids {
9844 let pending: bool = conn.query_row(
9845 "SELECT EXISTS(SELECT 1 FROM _syncular_outbox WHERE commit_id = ?1)",
9846 rusqlite::params![id],
9847 |row| row.get(0),
9848 )?;
9849 if pending {
9850 deliveries.push(CommitDelivery::Pending {
9851 client_commit_id: id.clone(),
9852 });
9853 continue;
9854 }
9855 let row = conn
9856 .query_row(
9857 "SELECT seq, client_commit_id, status, recorded_at_ms, results_json, operations_json,
9858 resolution, resolved_at_ms, replacement_client_commit_id
9859 FROM _syncular_commit_outcomes WHERE client_commit_id = ?1",
9860 rusqlite::params![id],
9861 StoredCommitOutcomeRow::from_row,
9862 )
9863 .optional()
9864 .map_err(|error| stored_failure(error, "persisted commit outcome is invalid"))?;
9865 deliveries.push(match row {
9866 Some(row) => CommitDelivery::Known {
9867 client_commit_id: id.clone(),
9868 outcome: SyncClient::outcome_from_row(row)
9869 .map_err(|_| corrupt("persisted commit outcome is invalid"))?,
9870 },
9871 None => CommitDelivery::Unknown {
9872 client_commit_id: id.clone(),
9873 },
9874 });
9875 }
9876 Ok(SnapshotRead {
9877 revision,
9878 queries,
9879 coverage: CoverageSnapshot {
9880 complete: pending.is_empty() && missing.is_empty(),
9881 pending,
9882 missing,
9883 },
9884 subscriptions,
9885 deliveries,
9886 })
9887 })();
9888 match result {
9889 Ok(read) => {
9890 conn.execute_batch("RELEASE syncular_snapshot_read")?;
9891 Ok(read)
9892 }
9893 Err(mut error) => {
9894 if let Err(rollback) = conn
9895 .execute_batch("ROLLBACK TO syncular_snapshot_read; RELEASE syncular_snapshot_read")
9896 {
9897 error.rollback_failure = Some(Box::new(QueryReadFailure::from(rollback)));
9898 }
9899 Err(error)
9900 }
9901 }
9902}
9903
9904fn snapshot_connection(
9905 conn: &Connection,
9906 sql: &str,
9907 params: &[Value],
9908 coverage: &[WindowCoverage],
9909) -> Result<QuerySnapshot, QueryReadFailure> {
9910 let read = snapshot_read_connection(
9911 conn,
9912 &SnapshotReadRequest {
9913 statements: vec![SnapshotStatement { sql, params }],
9914 coverage,
9915 subscriptions: Vec::new(),
9916 commit_ids: Vec::new(),
9917 },
9918 )?;
9919 Ok(QuerySnapshot {
9920 revision: read.revision,
9921 rows: read
9922 .queries
9923 .into_iter()
9924 .next()
9925 .expect("single-query snapshot returns one result"),
9926 coverage: read.coverage,
9927 })
9928}
9929
9930pub struct FileQuerySnapshotReader {
9935 path: String,
9936 conn: Option<Connection>,
9937}
9938
9939impl FileQuerySnapshotReader {
9940 #[must_use]
9941 pub fn new(path: impl Into<String>) -> Self {
9942 Self {
9943 path: path.into(),
9944 conn: None,
9945 }
9946 }
9947
9948 fn connection(&mut self) -> Result<&Connection, QueryReadFailure> {
9949 if self.conn.is_none() {
9950 let conn = Connection::open_with_flags(
9951 &self.path,
9952 OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_NO_MUTEX,
9953 )
9954 .map_err(QueryReadFailure::from)?;
9955 conn.busy_timeout(std::time::Duration::from_millis(250))
9956 .map_err(QueryReadFailure::from)?;
9957 self.conn = Some(conn);
9958 }
9959 self.conn
9960 .as_ref()
9961 .ok_or_else(|| QueryReadFailure::from("read sidecar connection missing".to_owned()))
9962 }
9963
9964 pub fn snapshot_read(
9967 &mut self,
9968 request: &SnapshotReadRequest<'_>,
9969 ) -> Result<SnapshotRead, QueryReadFailure> {
9970 snapshot_read_connection(self.connection()?, request)
9971 }
9972
9973 pub fn local_revision(&mut self) -> Result<String, QueryReadFailure> {
9976 Ok(self
9977 .snapshot_read(&SnapshotReadRequest::default())?
9978 .revision)
9979 }
9980
9981 pub fn subscription_catchup(
9983 &mut self,
9984 id: &str,
9985 ) -> Result<SubscriptionCatchup, QueryReadFailure> {
9986 let read = self.snapshot_read(&SnapshotReadRequest {
9987 subscriptions: vec![id.to_owned()],
9988 ..Default::default()
9989 })?;
9990 Ok(read
9991 .subscriptions
9992 .into_iter()
9993 .next()
9994 .expect("one requested catch-up"))
9995 }
9996
9997 pub fn commit_delivery(&mut self, id: &str) -> Result<CommitDelivery, QueryReadFailure> {
10000 let read = self.snapshot_read(&SnapshotReadRequest {
10001 commit_ids: vec![id.to_owned()],
10002 ..Default::default()
10003 })?;
10004 Ok(read
10005 .deliveries
10006 .into_iter()
10007 .next()
10008 .expect("one requested delivery"))
10009 }
10010
10011 pub fn query_snapshot(
10014 &mut self,
10015 sql: &str,
10016 params: &[Value],
10017 coverage: &[WindowCoverage],
10018 ) -> Result<QuerySnapshot, QueryReadFailure> {
10019 snapshot_connection(self.connection()?, sql, params, coverage)
10020 }
10021}
10022
10023fn validate_authority_reads(
10024 schema: &ClientSchema,
10025 declarations: &[crate::api::AuthorityReadDeclaration],
10026) -> Result<(), String> {
10027 let mut seen = BTreeSet::new();
10028 for read in declarations {
10029 let table = schema
10030 .table(&read.table)
10031 .ok_or("client.authority_read_forbidden: authority table is not declared")?;
10032 let plain = |name: &str| {
10033 table.columns.iter().enumerate().any(|(i, c)| {
10034 c.name == name
10035 && name != "authority_version"
10036 && !name.starts_with("_sync")
10037 && !table.encrypted_columns.iter().any(|e| e.index == i)
10038 && matches!(
10039 c.ty,
10040 ssp2::segment::ColumnType::String
10041 | ssp2::segment::ColumnType::Integer
10042 | ssp2::segment::ColumnType::Float
10043 | ssp2::segment::ColumnType::Boolean
10044 | ssp2::segment::ColumnType::Json
10045 )
10046 })
10047 };
10048 if !seen.insert(&read.table)
10049 || read.columns.is_empty()
10050 || read.columns.iter().collect::<BTreeSet<_>>().len() != read.columns.len()
10051 || !read.columns.contains(&table.primary_key)
10052 || !read.columns.iter().all(|c| plain(c))
10053 || read.scopes.is_empty()
10054 || !read.scopes.iter().all(|(v, values)| {
10055 table
10056 .scope_column(v)
10057 .is_some_and(|c| read.columns.iter().any(|column| column == c) && plain(c))
10058 && !values.is_empty()
10059 && values.iter().all(|v| !v.is_empty() && v != "*")
10060 })
10061 {
10062 return Err("client.authority_read_forbidden: authority reads require plain columns, a primary key and nonempty plain scope selectors".into());
10063 }
10064 }
10065 Ok(())
10066}
10067
10068fn schema_marker_read_failure(error: rusqlite::Error) -> String {
10073 let failure = QueryReadFailure::from(error);
10074 if failure.code.is_some() {
10075 failure.to_string()
10076 } else {
10077 "sync.local_corrupt: persisted local schema marker is unreadable or invalid".to_owned()
10078 }
10079}
10080
10081fn validate_client_limits(limits: &crate::api::ClientLimits) -> Result<(), String> {
10085 if limits.outcome_retention_max_entries == Some(0) {
10086 return Err("sync.invalid_request: outcomeRetentionMaxEntries must be positive".to_owned());
10087 }
10088 if [
10089 limits.max_push_commits_per_request,
10090 limits.max_push_operations_per_request,
10091 limits.max_push_request_bytes,
10092 ]
10093 .into_iter()
10094 .flatten()
10095 .any(|value| value == 0 || value > u32::MAX as usize)
10096 {
10097 return Err(
10098 "sync.invalid_request: push limit must be an integer in 1..=4294967295".to_owned(),
10099 );
10100 }
10101 Ok(())
10102}
10103
10104fn read_local_schema_version(conn: &Connection, requested: i32) -> Result<Option<i32>, String> {
10105 let corrupt =
10106 || "sync.local_corrupt: persisted local schema marker is unreadable or invalid".to_owned();
10107 let table_type: Option<String> = conn
10108 .query_row(
10109 "SELECT type FROM sqlite_master WHERE name = '_syncular_meta'",
10110 [],
10111 |row| row.get(0),
10112 )
10113 .optional()
10114 .map_err(schema_marker_read_failure)?;
10115 let Some(table_type) = table_type else {
10116 return Ok(None);
10117 };
10118 if table_type != "table" {
10119 return Err(corrupt());
10120 }
10121 let mut statement = conn
10125 .prepare("SELECT value FROM _syncular_meta WHERE key = ?1")
10126 .map_err(schema_marker_read_failure)?;
10127 let mut rows = statement
10128 .query([LOCAL_SCHEMA_VERSION_KEY])
10129 .map_err(schema_marker_read_failure)?;
10130 let value: Option<String> = rows
10131 .next()
10132 .map_err(schema_marker_read_failure)?
10133 .map(|row| row.get(0))
10134 .transpose()
10135 .map_err(schema_marker_read_failure)?;
10136 if rows.next().map_err(schema_marker_read_failure)?.is_some() {
10137 return Err(corrupt());
10138 }
10139 let Some(value) = value else {
10140 let has_descriptor = conn
10141 .query_row(
10142 "SELECT 1 FROM _syncular_meta WHERE key = ?1 LIMIT 1",
10143 [crate::previous_version::LOCAL_SCHEMA_DESCRIPTOR_KEY],
10144 |_| Ok(()),
10145 )
10146 .optional()
10147 .map_err(schema_marker_read_failure)?
10148 .is_some();
10149 if has_descriptor {
10150 return Err(corrupt());
10151 }
10152 return Ok(None);
10153 };
10154 if !matches!(value.as_bytes().first(), Some(b'1'..=b'9'))
10155 || !value.bytes().all(|b| b.is_ascii_digit())
10156 {
10157 return Err(corrupt());
10158 }
10159 let version = value.parse::<i32>().map_err(|_| corrupt())?;
10160 if version > requested {
10161 return Err(
10162 "client.schema_downgrade: persisted local schema is newer than the requested schema"
10163 .to_owned(),
10164 );
10165 }
10166 Ok(Some(version))
10167}
10168
10169impl SyncClient {
10170 pub fn new_with_identity(
10171 client_id: Option<String>,
10172 schema_json: &Value,
10173 limits: ClientLimits,
10174 ) -> Result<Self, String> {
10175 let resolved = client_id.unwrap_or_else(|| uuid::Uuid::new_v4().to_string());
10176 let conn = Connection::open_in_memory().map_err(|e| e.to_string())?;
10177 Self::with_connection(resolved, schema_json, limits, conn)
10178 }
10179
10180 pub fn new(
10181 client_id: String,
10182 schema_json: &Value,
10183 limits: ClientLimits,
10184 ) -> Result<Self, String> {
10185 let conn = Connection::open_in_memory().map_err(|e| e.to_string())?;
10186 Self::with_connection(client_id, schema_json, limits, conn)
10187 }
10188
10189 pub fn open_path(
10195 client_id: String,
10196 schema_json: &Value,
10197 limits: ClientLimits,
10198 path: &str,
10199 ) -> Result<Self, String> {
10200 validate_client_limits(&limits)?;
10201 parse_schema_json(schema_json)?;
10202 let conn = Connection::open(path).map_err(|e| format!("open db {path:?}: {e}"))?;
10203 Self::with_connection(client_id, schema_json, limits, conn)
10204 }
10205
10206 pub fn open_path_with_identity(
10207 client_id: Option<String>,
10208 schema_json: &Value,
10209 limits: ClientLimits,
10210 path: &str,
10211 ) -> Result<Self, String> {
10212 validate_client_limits(&limits)?;
10213 parse_schema_json(schema_json)?;
10216 let conn = Connection::open(path).map_err(|e| format!("open db {path:?}: {e}"))?;
10217 conn.busy_timeout(std::time::Duration::from_millis(250))
10225 .map_err(|error| format!("configure db {path:?} busy timeout: {error}"))?;
10226 let client = Self::with_connection_identity(client_id, schema_json, limits, conn)?;
10227 let journal_mode: String = client
10228 .conn
10229 .pragma_update_and_check(None, "journal_mode", "WAL", |row| row.get(0))
10230 .map_err(|error| Self::sqlite_failure(&client.storage_failure, error))?;
10231 if journal_mode != "wal" {
10232 return Err(
10233 "sync.invalid_request: file-backed client requires WAL journal mode".to_owned(),
10234 );
10235 }
10236 Ok(client)
10237 }
10238
10239 #[must_use]
10240 pub fn client_id(&self) -> &str {
10241 &self.client_id
10242 }
10243
10244 pub fn with_connection(
10251 client_id: String,
10252 schema_json: &Value,
10253 limits: ClientLimits,
10254 conn: Connection,
10255 ) -> Result<Self, String> {
10256 Self::with_connection_identity(Some(client_id), schema_json, limits, conn)
10257 }
10258
10259 fn with_connection_identity(
10262 client_id: Option<String>,
10263 schema_json: &Value,
10264 limits: ClientLimits,
10265 conn: Connection,
10266 ) -> Result<Self, String> {
10267 validate_client_limits(&limits)?;
10268 let schema = parse_schema_json(schema_json)?;
10269 validate_authority_reads(&schema, &limits.authority_reads)?;
10270 let previous_version = match limits.previous_version_context {
10273 Some(config) => {
10274 config.validate()?;
10275 Some(config)
10276 }
10277 None => None,
10278 };
10279 let mut client = SyncClient {
10280 #[cfg(feature = "bench-internals")]
10281 benchmark_phases: Recorder::default(),
10282 conn,
10283 schema,
10284 client_id: client_id
10285 .clone()
10286 .unwrap_or_else(|| uuid::Uuid::new_v4().to_string()),
10287 limits,
10288 subs: Vec::new(),
10289 outbox: Vec::new(),
10290 failed_commits: Vec::new(),
10291 retain_failed_commits: false,
10292 conflicts: Vec::new(),
10293 rejections: Vec::new(),
10294 schema_floor: None,
10295 lease_state: None,
10296 stopped: false,
10297 upgrading: false,
10298 sync_needed: false,
10299 realtime_policy: RealtimePolicy::Optional,
10300 realtime_state: RealtimeState::Disconnected,
10301 realtime_reason_code: None,
10302 realtime_retry_delay_ms: None,
10303 presence: HashMap::new(),
10304 now_ms: None,
10305 encryption: crate::values::EncryptionConfig::default(),
10306 security_preflight: false,
10307 transport_enabled: true,
10308 previous_version,
10309 insert_sql: RefCell::new(HashMap::new()),
10310 overlay_dirty: OverlayDirty::clean(),
10311 storage_failure: RefCell::new(None),
10312 #[cfg(test)]
10313 overlay_rebuild_count: Cell::new(0),
10314 #[cfg(test)]
10315 acknowledged_replay_count: Cell::new(0),
10316 #[cfg(test)]
10317 outcome_prune_count: Cell::new(0),
10318 progress: ProgressObserver::default(),
10319 change_queue: VecDeque::new(),
10320 sync_intent_queue: VecDeque::new(),
10321 retry_delay_ms: 250,
10322 round_retry_delay_ms: None,
10323 active_round: None,
10324 last_round: None,
10325 last_change: None,
10326 query_failures: Vec::new(),
10327 };
10328 client
10332 .conn
10333 .set_prepared_statement_cache_capacity(64.max(client.schema.tables.len() * 4));
10334 client
10340 .conn
10341 .execute_batch("BEGIN IMMEDIATE")
10342 .map_err(|error| Self::sqlite_failure(&client.storage_failure, error))?;
10343 let startup = (|| -> Result<(), String> {
10344 let marker = read_local_schema_version(&client.conn, client.schema.version)?;
10347 client.create_bookkeeping_tables()?;
10350 match client.get_meta(CLIENT_ID_KEY) {
10351 Some(existing) => match client_id {
10352 Some(requested) if existing != requested => {
10353 return Err(format!(
10354 "client.identity_mismatch: this database belongs to {existing:?}; refusing to rebind it to {requested:?}"
10355 ))
10356 }
10357 _ => client.client_id = existing,
10358 },
10359 None => client.set_meta(CLIENT_ID_KEY, &client.client_id),
10360 }
10361 client.restore_persisted_state()?;
10362 client.failed_commits = client.load_failed_commits()?;
10363 match marker {
10364 None => {
10365 client.create_synced_tables()?;
10366 client.persist_schema_version()?;
10367 client.save_subscription_scope_schema();
10368 }
10369 Some(version) if version == client.schema.version => {
10370 client.create_synced_tables()?;
10371 set_local_schema_descriptor(&client.conn, &client.schema)
10375 .map_err(|error| Self::sqlite_failure(&client.storage_failure, error))?;
10376 client.save_subscription_scope_schema();
10377 }
10378 Some(_) => client.run_schema_reset()?,
10379 }
10380 client.reconcile_previous_version_at_boot()?;
10386 client.clear_satisfied_persisted_schema_floor();
10387 client.prune_unknown_subscriptions(false)?;
10388 client.drop_unencodable_outbox(false, None)?;
10397 if marker == Some(client.schema.version)
10398 && (!client.outbox.is_empty()
10399 || !client.failed_commits.is_empty()
10400 || client.has_acknowledged_rows())
10401 {
10402 client.overlay_dirty.set(true);
10406 client.rebuild_overlay()?;
10407 }
10408 client.enqueue_startup_sync_if_needed()?;
10415 Ok(())
10416 })();
10417 match startup {
10418 Ok(()) => match client.conn.execute_batch("COMMIT") {
10419 Ok(()) => Ok(client),
10420 Err(error) => {
10421 let failure = Self::sqlite_failure(&client.storage_failure, error);
10422 if let Err(rollback) = client.conn.execute_batch("ROLLBACK") {
10423 let _ = Self::sqlite_failure(&client.storage_failure, rollback);
10425 }
10426 Err(failure)
10427 }
10428 },
10429 Err(error) => {
10430 if let Err(rollback) = client.conn.execute_batch("ROLLBACK") {
10431 let _ = Self::sqlite_failure(&client.storage_failure, rollback);
10433 }
10434 Err(error)
10435 }
10436 }
10437 }
10438
10439 pub fn set_now_ms(&mut self, now_ms: i64) {
10442 self.now_ms = Some(now_ms);
10443 }
10444
10445 pub fn set_encryption(&mut self, encryption: crate::values::EncryptionConfig) {
10449 self.encryption = encryption;
10450 }
10451
10452 #[must_use]
10453 pub fn security_lifecycle(&self) -> &'static str {
10454 if self.security_preflight {
10455 "preflight"
10456 } else {
10457 "active"
10458 }
10459 }
10460
10461 #[must_use]
10462 pub fn security_preflight(&self) -> bool {
10463 self.security_preflight
10464 }
10465
10466 pub fn set_transport_enabled(&mut self, transport: &mut dyn Transport, enabled: bool) {
10468 if self.transport_enabled != enabled {
10469 self.transport_enabled = enabled;
10470 self.sync_intent_queue.clear();
10471 self.sync_intent_queue
10472 .push_back(if enabled && !self.security_preflight {
10473 SyncIntent::Interactive
10474 } else {
10475 SyncIntent::None
10476 });
10477 }
10478 if !enabled && self.active_round.is_none() {
10480 self.disconnect_realtime(transport);
10481 }
10482 }
10483
10484 pub fn transport_enabled(&self) -> bool {
10485 self.transport_enabled
10486 }
10487
10488 pub fn begin_security_preflight(&mut self) {
10495 self.seal_security_on_teardown();
10496 self.set_meta(SECURITY_PREFLIGHT_PENDING_KEY, "1");
10499 }
10500
10501 pub fn seal_security_on_teardown(&mut self) {
10511 self.cancel_sync_round();
10512 self.security_preflight = true;
10513 self.encryption = crate::values::EncryptionConfig::default();
10514 self.sync_intent_queue.clear();
10515 }
10516
10517 pub fn activate_security(
10519 &mut self,
10520 encryption: crate::values::EncryptionConfig,
10521 ) -> Result<(), String> {
10522 if !self.security_preflight {
10523 return Err(
10524 "sync.invalid_request: activateSecurity requires security preflight".to_owned(),
10525 );
10526 }
10527 self.enqueue_startup_sync_if_needed()?;
10528 self.encryption = encryption;
10529 self.security_preflight = false;
10530 self.delete_meta(SECURITY_PREFLIGHT_PENDING_KEY);
10531 Ok(())
10532 }
10533
10534 fn clock_now_ms(&self) -> i64 {
10535 self.now_ms.unwrap_or_else(|| {
10536 std::time::SystemTime::now()
10537 .duration_since(std::time::UNIX_EPOCH)
10538 .map(|d| d.as_millis() as i64)
10539 .unwrap_or(0)
10540 })
10541 }
10542
10543 fn create_bookkeeping_tables(&self) -> Result<(), String> {
10544 self.conn
10546 .execute_batch(
10547 "CREATE TABLE IF NOT EXISTS _syncular_outbox (
10548 seq INTEGER PRIMARY KEY AUTOINCREMENT,
10549 commit_id TEXT NOT NULL UNIQUE, ops_json TEXT NOT NULL);
10550 CREATE TABLE IF NOT EXISTS _syncular_row_deliveries (
10551 tbl TEXT NOT NULL, id TEXT NOT NULL, commit_seq INTEGER NOT NULL, PRIMARY KEY(tbl,id));
10552 CREATE TABLE IF NOT EXISTS _syncular_acked_rows (
10553 commit_id TEXT NOT NULL, idx INTEGER NOT NULL, tbl TEXT NOT NULL, id TEXT NOT NULL,
10554 commit_seq INTEGER NOT NULL, op_json TEXT NOT NULL, intent_json TEXT,
10555 PRIMARY KEY(commit_id, idx));
10556 CREATE TABLE IF NOT EXISTS _syncular_failed_commits (
10557 seq INTEGER PRIMARY KEY AUTOINCREMENT, client_commit_id TEXT NOT NULL UNIQUE,
10558 operations_json TEXT NOT NULL, initial_json TEXT NOT NULL);
10559 CREATE TABLE IF NOT EXISTS _syncular_commit_outcomes (
10560 seq INTEGER PRIMARY KEY AUTOINCREMENT,
10561 client_commit_id TEXT NOT NULL UNIQUE,
10562 status TEXT NOT NULL CHECK(status IN ('applied', 'cached', 'conflict', 'rejected')),
10563 recorded_at_ms INTEGER NOT NULL,
10564 results_json TEXT NOT NULL,
10565 operations_json TEXT,
10566 resolution TEXT NOT NULL DEFAULT 'active'
10567 CHECK(resolution IN ('active', 'resolved_keep_server', 'superseded', 'dismissed')),
10568 resolved_at_ms INTEGER,
10569 replacement_client_commit_id TEXT);
10570 CREATE INDEX IF NOT EXISTS _syncular_commit_outcomes_resolution_seq
10571 ON _syncular_commit_outcomes(resolution, seq);
10572 CREATE TABLE IF NOT EXISTS _syncular_subscriptions (
10573 id TEXT PRIMARY KEY, tbl TEXT NOT NULL, state_json TEXT NOT NULL);
10574 CREATE TABLE IF NOT EXISTS _syncular_meta (
10575 key TEXT PRIMARY KEY, value TEXT NOT NULL);
10576 CREATE TABLE IF NOT EXISTS _syncular_windows (
10577 base TEXT NOT NULL, unit TEXT NOT NULL, sub_id TEXT NOT NULL,
10578 PRIMARY KEY (base, unit));
10579 CREATE TABLE IF NOT EXISTS _syncular_window_pending_evict (
10580 sub_id TEXT PRIMARY KEY, tbl TEXT NOT NULL,
10581 effective_scopes TEXT NOT NULL);
10582 CREATE TABLE IF NOT EXISTS _syncular_blob_commit_refs(
10583 commit_id TEXT NOT NULL, blob_id TEXT NOT NULL,
10584 PRIMARY KEY(commit_id, blob_id));
10585 CREATE INDEX IF NOT EXISTS _syncular_blob_commit_refs_body
10586 ON _syncular_blob_commit_refs(blob_id);",
10587 )
10588 .map_err(|e| e.to_string())?;
10589 let _ = self
10592 .conn
10593 .execute_batch("ALTER TABLE _syncular_commit_outcomes ADD COLUMN operations_json TEXT");
10594 if self.get_meta(LOCAL_REVISION_KEY).is_none() {
10595 self.set_meta(LOCAL_REVISION_KEY, "0");
10596 }
10597 if self.schema_has_blobs() {
10600 let exists = self
10601 .conn
10602 .query_row(
10603 "SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = '_syncular_blobs')",
10604 [],
10605 |row| row.get::<_, bool>(0),
10606 )
10607 .map_err(|e| e.to_string())?;
10608 if exists {
10609 let mut statement = self
10610 .conn
10611 .prepare("PRAGMA table_info(_syncular_blobs)")
10612 .map_err(|e| e.to_string())?;
10613 let mut columns = statement
10614 .query_map([], |row| row.get::<_, String>(1))
10615 .map_err(|e| e.to_string())?
10616 .collect::<Result<Vec<_>, _>>()
10617 .map_err(|e| e.to_string())?;
10618 columns.sort();
10619 let mut expected = vec![
10620 "blob_id",
10621 "byte_length",
10622 "bytes",
10623 "created_at_ms",
10624 "media_type",
10625 ];
10626 expected.sort_unstable();
10627 if columns != expected {
10628 return Err(
10629 "sync.schema_mismatch: Local blob schema is incompatible".to_owned()
10630 );
10631 }
10632 } else {
10633 self.conn
10634 .execute_batch(
10635 "CREATE TABLE _syncular_blobs(
10636 blob_id TEXT PRIMARY KEY,
10637 bytes BLOB NOT NULL,
10638 byte_length INTEGER NOT NULL,
10639 media_type TEXT,
10640 created_at_ms INTEGER NOT NULL);",
10641 )
10642 .map_err(|e| e.to_string())?;
10643 }
10644 self.conn
10645 .execute_batch(
10646 "CREATE TABLE IF NOT EXISTS _syncular_blob_uploads(
10647 blob_id TEXT PRIMARY KEY,
10648 media_type TEXT,
10649 created_at_ms INTEGER NOT NULL);",
10650 )
10651 .map_err(|e| e.to_string())?;
10652 }
10653 Ok(())
10654 }
10655
10656 fn schema_has_blobs(&self) -> bool {
10658 self.schema
10659 .tables
10660 .iter()
10661 .any(|t| t.columns.iter().any(|c| c.ty == ColumnType::BlobRef))
10662 }
10663
10664 fn get_meta(&self, key: &str) -> Option<String> {
10667 meta_get(&self.conn, key)
10668 }
10669
10670 fn get_meta_strict(&self, key: &str) -> Result<Option<String>, String> {
10671 self.conn
10672 .query_row(
10673 "SELECT value FROM _syncular_meta WHERE key = ?1",
10674 rusqlite::params![key],
10675 |row| row.get::<_, String>(0),
10676 )
10677 .optional()
10678 .map_err(|_| {
10679 "sync.local_corrupt: persisted local rebootstrap receipt is unreadable".to_owned()
10680 })
10681 }
10682
10683 fn persist_schema_version(&self) -> Result<(), String> {
10686 self.conn
10687 .execute(
10688 "INSERT OR REPLACE INTO _syncular_meta(key,value) VALUES (?1,?2)",
10689 rusqlite::params![LOCAL_SCHEMA_VERSION_KEY, self.schema.version.to_string()],
10690 )
10691 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
10692 set_local_schema_descriptor(&self.conn, &self.schema)
10693 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
10694 }
10695
10696 fn set_meta(&self, key: &str, value: &str) {
10697 meta_set(&self.conn, key, value);
10698 }
10699
10700 fn delete_meta(&self, key: &str) {
10701 meta_delete(&self.conn, key);
10702 }
10703
10704 fn load_outbox(&self) -> Result<Vec<OutboxCommit>, String> {
10706 let mut stmt = self
10707 .conn
10708 .prepare("SELECT commit_id, ops_json FROM _syncular_outbox ORDER BY seq ASC")
10709 .map_err(|error| error.to_string())?;
10710 let rows = stmt
10711 .query_map([], |row| {
10712 Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
10713 })
10714 .map_err(|error| error.to_string())?;
10715 let mut commits = Vec::new();
10716 for row in rows {
10717 let (client_commit_id, raw) = row.map_err(|error| error.to_string())?;
10718 let entries: Vec<Value> = serde_json::from_str(&raw).map_err(|error| {
10719 format!("invalid persisted outbox {client_commit_id:?}: {error}")
10720 })?;
10721 let mut ops = Vec::with_capacity(entries.len());
10722 for entry in entries {
10723 let op = entry.get("op").and_then(Value::as_str).unwrap_or("delete");
10724 ops.push(OutboxOp {
10725 upsert: op == "upsert",
10726 table: entry
10727 .get("table")
10728 .and_then(Value::as_str)
10729 .ok_or_else(|| "persisted outbox operation missing table".to_owned())?
10730 .to_owned(),
10731 row_id: entry
10732 .get("rowId")
10733 .and_then(Value::as_str)
10734 .ok_or_else(|| "persisted outbox operation missing rowId".to_owned())?
10735 .to_owned(),
10736 base_version: entry.get("baseVersion").and_then(Value::as_i64),
10737 values: entry.get("values").and_then(Value::as_object).cloned(),
10740 });
10741 }
10742 commits.push(OutboxCommit {
10743 client_commit_id,
10744 ops,
10745 });
10746 }
10747 Ok(commits)
10748 }
10749
10750 fn restore_persisted_state(&mut self) -> Result<(), String> {
10751 self.subs = {
10752 let mut stmt = self
10753 .conn
10754 .prepare("SELECT id, tbl, state_json FROM _syncular_subscriptions ORDER BY id ASC")
10755 .map_err(|error| error.to_string())?;
10756 let rows = stmt
10757 .query_map([], |row| {
10758 Ok((
10759 row.get::<_, String>(0)?,
10760 row.get::<_, String>(1)?,
10761 row.get::<_, String>(2)?,
10762 ))
10763 })
10764 .map_err(|error| error.to_string())?;
10765 let mut subscriptions = Vec::new();
10766 for row in rows {
10767 let (id, table, raw) = row.map_err(|error| error.to_string())?;
10768 let state: Value = serde_json::from_str(&raw)
10769 .map_err(|error| format!("invalid persisted subscription {id:?}: {error}"))?;
10770 let requested = json_to_scope_map(
10771 state.get("requested").unwrap_or(&Value::Object(Map::new())),
10772 )?;
10773 let effective = state
10774 .get("effectiveScopes")
10775 .filter(|value| !value.is_null())
10776 .map(json_to_scope_map)
10777 .transpose()?;
10778 subscriptions.push(Subscription {
10779 id,
10780 table,
10781 requested,
10782 params: state
10783 .get("params")
10784 .and_then(Value::as_str)
10785 .map(str::to_owned),
10786 cursor: state.get("cursor").and_then(Value::as_i64).unwrap_or(-1),
10787 bootstrap_state: state
10788 .get("bootstrapState")
10789 .and_then(Value::as_str)
10790 .map(str::to_owned),
10791 state: SubState::parse(
10792 state
10793 .get("status")
10794 .and_then(Value::as_str)
10795 .unwrap_or("active"),
10796 ),
10797 reason_code: state
10798 .get("reasonCode")
10799 .and_then(Value::as_str)
10800 .map(str::to_owned),
10801 effective,
10802 synced_once: state
10803 .get("syncedOnce")
10804 .and_then(Value::as_bool)
10805 .unwrap_or(false),
10806 });
10807 }
10808 subscriptions
10809 };
10810
10811 self.outbox = self.load_outbox()?;
10812
10813 self.prune_commit_outcomes()?;
10814 let active = self.commit_outcomes(CommitOutcomeQuery {
10815 active_only: true,
10816 ..CommitOutcomeQuery::default()
10817 })?;
10818 self.conflicts = active
10819 .iter()
10820 .flat_map(|outcome| outcome.results.iter())
10821 .filter_map(|result| match result {
10822 CommitOperationOutcome::Conflict { conflict } => Some(conflict.clone()),
10823 _ => None,
10824 })
10825 .collect();
10826 self.rejections = active
10827 .iter()
10828 .flat_map(|outcome| outcome.results.iter())
10829 .filter_map(|result| match result {
10830 CommitOperationOutcome::Error { rejection } => Some(rejection.clone()),
10831 _ => None,
10832 })
10833 .collect();
10834
10835 self.lease_state = self
10836 .get_meta(LEASE_STATE_KEY)
10837 .map(|raw| serde_json::from_str(&raw))
10838 .transpose()
10839 .map_err(|error| format!("invalid persisted lease state: {error}"))?;
10840 self.schema_floor = self
10841 .get_meta(SCHEMA_FLOOR_KEY)
10842 .map(|raw| serde_json::from_str(&raw))
10843 .transpose()
10844 .map_err(|error| format!("invalid persisted schema floor: {error}"))?;
10845 self.stopped = self.schema_floor.is_some();
10846 if self.get_meta(SECURITY_PREFLIGHT_PENDING_KEY).as_deref() == Some("1") {
10849 self.security_preflight = true;
10850 }
10851 Ok(())
10852 }
10853
10854 fn save_subscription_scope_schema(&self) {
10855 let declarations: BTreeMap<_, BTreeMap<_, _>> = self
10856 .schema
10857 .tables
10858 .iter()
10859 .map(|table| {
10860 (
10861 table.name.clone(),
10862 table
10863 .scope_variables
10864 .iter()
10865 .map(|scope| {
10866 (
10867 scope.variable.clone(),
10868 [scope.prefix.clone(), scope.column.clone()],
10869 )
10870 })
10871 .collect(),
10872 )
10873 })
10874 .collect();
10875 self.set_meta(
10876 "subscriptionScopeSchema",
10877 &serde_json::to_string(&declarations).expect("scope schema serialization"),
10878 );
10879 }
10880
10881 fn prune_unknown_subscriptions(&mut self, bump: bool) -> Result<(), String> {
10883 let previous: Option<BTreeMap<String, BTreeMap<String, [String; 2]>>> = if bump {
10884 self.get_meta("subscriptionScopeSchema")
10885 .map(|raw| serde_json::from_str(&raw))
10886 .transpose()
10887 .map_err(|error| format!("invalid persisted scope schema: {error}"))?
10888 } else {
10889 None
10890 };
10891 let compatible = |table_name: &str, requested: &[(String, Vec<String>)]| {
10892 let Some(table) = self.schema.table(table_name) else {
10893 return false;
10894 };
10895 if requested
10896 .iter()
10897 .any(|(variable, _)| table.scope_column(variable).is_none())
10898 {
10899 return false;
10900 }
10901 if !bump {
10902 return true;
10903 }
10904 let Some(old) = previous.as_ref().and_then(|tables| tables.get(table_name)) else {
10905 return false;
10906 };
10907 let variables: BTreeSet<_> = if requested.is_empty() {
10908 old.keys()
10909 .cloned()
10910 .chain(
10911 table
10912 .scope_variables
10913 .iter()
10914 .map(|scope| scope.variable.clone()),
10915 )
10916 .collect()
10917 } else {
10918 requested
10919 .iter()
10920 .map(|(variable, _)| variable.clone())
10921 .collect()
10922 };
10923 variables.iter().all(|variable| {
10924 let current = table
10925 .scope_variables
10926 .iter()
10927 .find(|scope| &scope.variable == variable);
10928 match (old.get(variable), current) {
10929 (Some(old), Some(current)) => {
10930 old == &[current.prefix.clone(), current.column.clone()]
10931 }
10932 _ => false,
10933 }
10934 })
10935 };
10936 let mut stale_ids: BTreeSet<_> = self
10937 .subs
10938 .iter()
10939 .filter(|sub| !compatible(&sub.table, &sub.requested))
10940 .map(|sub| sub.id.clone())
10941 .collect();
10942 for (id, table, effective) in self.load_pending_evictions()? {
10943 if !compatible(&table, &effective) {
10944 stale_ids.insert(id);
10945 }
10946 }
10947 for id in &stale_ids {
10948 for sql in [
10949 "DELETE FROM _syncular_windows WHERE sub_id = ?1",
10950 "DELETE FROM _syncular_window_pending_evict WHERE sub_id = ?1",
10951 "DELETE FROM _syncular_subscriptions WHERE id = ?1",
10952 ] {
10953 self.conn
10954 .execute(sql, rusqlite::params![id])
10955 .map_err(|error| error.to_string())?;
10956 }
10957 }
10958 self.subs.retain(|sub| !stale_ids.contains(&sub.id));
10959 Ok(())
10960 }
10961
10962 #[must_use]
10963 pub fn local_revision(&self) -> u64 {
10964 self.get_meta(LOCAL_REVISION_KEY)
10965 .and_then(|value| value.parse().ok())
10966 .unwrap_or(0)
10967 }
10968
10969 pub fn snapshot_read(
10975 &mut self,
10976 request: &SnapshotReadRequest<'_>,
10977 owner: Option<QueryOwner<'_>>,
10978 ) -> Result<SnapshotRead, QueryReadFailure> {
10979 let result = snapshot_read_connection(&self.conn, request);
10980 if let Some(owner) = owner {
10981 self.record_query_read(owner, result.as_ref().err());
10982 }
10983 result
10984 }
10985
10986 #[must_use]
10987 pub fn status_snapshot(&self) -> SyncStatusSnapshot {
10988 SyncStatusSnapshot {
10989 current_schema_version: self.schema.version,
10990 outbox: self.outbox.len(),
10991 upgrading: self.upgrading,
10992 lease_state: self.lease_state.clone(),
10993 schema_floor: self.schema_floor.clone(),
10994 sync_needed: self.sync_needed,
10995 previous_version_context: self.previous_version_status(),
10996 }
10997 }
10998
10999 fn previous_version_status(&self) -> PreviousVersionStatus {
11002 let Some(replica_path) = self.previous_version_replica_path() else {
11003 return PreviousVersionStatus {
11004 present: false,
11005 created_at_ms: None,
11006 };
11007 };
11008 match crate::previous_version::read_previous_version_record(&replica_path) {
11009 Ok(Some(record)) => PreviousVersionStatus {
11010 present: true,
11011 created_at_ms: Some(record.created_at_ms),
11012 },
11013 _ => PreviousVersionStatus {
11014 present: std::path::Path::new(
11015 &crate::previous_version::previous_version_container_path(&replica_path),
11016 )
11017 .exists(),
11018 created_at_ms: None,
11019 },
11020 }
11021 }
11022
11023 pub fn previous_version_snapshot(
11029 &self,
11030 spec: &PreviousVersionReadSpec,
11031 ) -> Result<PreviousVersionSnapshot, String> {
11032 let now_ms = self.clock_now_ms();
11033 crate::previous_version::previous_version_snapshot(
11034 &self.conn,
11035 self.previous_version_replica_path().as_deref(),
11036 self.previous_version.as_ref(),
11037 PreviousVersionLifecycle {
11038 lease_inactive: self.previous_version_lease_inactive(now_ms),
11039 scope_revoked: self.subs.iter().any(|sub| sub.state == SubState::Revoked),
11040 coverage_complete: self.previous_version_coverage_complete(),
11041 },
11042 now_ms,
11043 self.schema.version,
11044 spec,
11045 )
11046 }
11047
11048 #[must_use]
11050 pub fn previous_version_audit(&self) -> Option<crate::previous_version::PreviousVersionAudit> {
11051 crate::previous_version::stored_previous_version_audit(&self.conn)
11052 }
11053
11054 pub fn previous_version_discard(
11058 &self,
11059 ) -> crate::previous_version::PreviousVersionDiscardOutcome {
11060 self.drop_previous_version()
11061 }
11062
11063 fn drop_previous_version(&self) -> crate::previous_version::PreviousVersionDiscardOutcome {
11067 let absent = crate::previous_version::PreviousVersionDiscardOutcome {
11068 present: false,
11069 discarded: false,
11070 };
11071 let Some(replica_path) = self.previous_version_replica_path() else {
11072 return absent;
11073 };
11074 crate::previous_version::discard_previous_version(&self.conn, &replica_path)
11075 .unwrap_or(absent)
11076 }
11077
11078 fn previous_version_lease_inactive(&self, now_ms: i64) -> bool {
11080 let Some(lease) = &self.lease_state else {
11081 return false;
11082 };
11083 if lease.error_code.is_some() {
11084 return true;
11085 }
11086 lease.expires_at_ms.is_some_and(|expires| expires <= now_ms)
11087 }
11088
11089 fn previous_version_coverage_complete(&self) -> bool {
11093 let active = self
11094 .subs
11095 .iter()
11096 .filter(|sub| sub.state == SubState::Active)
11097 .collect::<Vec<_>>();
11098 if active.is_empty() {
11099 return false;
11100 }
11101 active
11102 .iter()
11103 .all(|sub| sub.cursor >= 0 && sub.bootstrap_state.is_none())
11104 }
11105
11106 fn previous_version_lifetime_check(&self) {
11110 let Some(config) = self.previous_version.filter(|config| config.enabled) else {
11111 return;
11112 };
11113 let now_ms = self.clock_now_ms();
11114 if self.previous_version_lease_inactive(now_ms) {
11115 let _ = self.drop_previous_version();
11116 return;
11117 }
11118 let Some(replica_path) = self.previous_version_replica_path() else {
11119 return;
11120 };
11121 let Ok(Some(record)) = crate::previous_version::read_previous_version_record(&replica_path)
11122 else {
11123 return;
11124 };
11125 if self.subs.iter().any(|sub| sub.state == SubState::Revoked) {
11126 let _ = self.drop_previous_version();
11127 return;
11128 }
11129 if now_ms - record.created_at_ms > config.max_age_ms {
11130 let _ = self.drop_previous_version();
11131 return;
11132 }
11133 if self.previous_version_coverage_complete() {
11134 let _ = self.drop_previous_version();
11135 }
11136 }
11137
11138 pub fn diagnostics_snapshot(
11139 &self,
11140 request: &ClientDiagnosticsRequest,
11141 ) -> Result<ClientDiagnosticsSnapshot, String> {
11142 if request.expected_subscriptions.len() > MAX_DIAGNOSTIC_EXPECTED_SUBSCRIPTIONS {
11143 return Err(format!(
11144 "sync.invalid_request: diagnosticsSnapshot accepts at most {MAX_DIAGNOSTIC_EXPECTED_SUBSCRIPTIONS} expected subscriptions"
11145 ));
11146 }
11147 let mut subscriptions = BTreeMap::<String, DiagnosticSubscription>::new();
11148 for sub in &self.subs {
11149 let reset = sub.cursor < 0 && sub.reason_code.as_deref() == Some("sync.cursor_expired");
11150 let complete =
11151 sub.state == SubState::Active && sub.cursor >= 0 && sub.bootstrap_state.is_none();
11152 let state = match sub.state {
11153 SubState::Revoked => "revoked",
11154 SubState::Failed => "failed",
11155 SubState::Active if reset => "reset",
11156 SubState::Active if complete => "complete",
11157 SubState::Active => "bootstrapping",
11158 };
11159 subscriptions.insert(
11160 sub.id.clone(),
11161 DiagnosticSubscription {
11162 id: sub.id.clone(),
11163 table: sub.table.clone(),
11164 state: state.to_owned(),
11165 complete,
11166 cursor: Some(sub.cursor),
11167 reason_code: sub.reason_code.as_deref().map(Self::diagnostic_code),
11168 },
11169 );
11170 }
11171 for expected in &request.expected_subscriptions {
11172 if expected.id.is_empty() || expected.table.is_empty() {
11173 return Err("sync.invalid_request: diagnosticsSnapshot expected subscriptions require non-empty id and table strings".to_owned());
11174 }
11175 if subscriptions
11176 .get(&expected.id)
11177 .is_some_and(|registered| registered.table != expected.table)
11178 {
11179 subscriptions.insert(
11180 expected.id.clone(),
11181 DiagnosticSubscription {
11182 id: expected.id.clone(),
11183 table: expected.table.clone(),
11184 state: "failed".to_owned(),
11185 complete: false,
11186 cursor: None,
11187 reason_code: Some("client.subscription_intent_mismatch".to_owned()),
11188 },
11189 );
11190 } else {
11191 subscriptions.entry(expected.id.clone()).or_insert_with(|| {
11192 DiagnosticSubscription {
11193 id: expected.id.clone(),
11194 table: expected.table.clone(),
11195 state: "unregistered".to_owned(),
11196 complete: false,
11197 cursor: None,
11198 reason_code: None,
11199 }
11200 });
11201 }
11202 }
11203 let mut ordered_subscriptions = Vec::new();
11204 let mut included = BTreeSet::new();
11205 for expected in &request.expected_subscriptions {
11206 if included.insert(expected.id.clone()) {
11207 if let Some(subscription) = subscriptions.get(&expected.id) {
11208 ordered_subscriptions.push(subscription.clone());
11209 }
11210 }
11211 }
11212 for (id, subscription) in &subscriptions {
11213 if included.insert(id.clone()) {
11214 ordered_subscriptions.push(subscription.clone());
11215 }
11216 }
11217 let subscriptions_truncated =
11218 ordered_subscriptions.len() > MAX_DIAGNOSTIC_EXPECTED_SUBSCRIPTIONS;
11219 ordered_subscriptions.truncate(MAX_DIAGNOSTIC_EXPECTED_SUBSCRIPTIONS);
11220 let captured_at_ms = self.clock_now_ms();
11221 let lease = if let Some(error_code) = self
11222 .lease_state
11223 .as_ref()
11224 .and_then(|state| state.error_code.clone())
11225 {
11226 ClientDiagnosticsLease {
11227 state: "stopped".to_owned(),
11228 expires_at_ms: self
11229 .lease_state
11230 .as_ref()
11231 .and_then(|state| state.expires_at_ms),
11232 error_code: Some(Self::diagnostic_code(&error_code)),
11233 }
11234 } else if let Some(expires_at_ms) = self
11235 .lease_state
11236 .as_ref()
11237 .and_then(|state| state.expires_at_ms)
11238 {
11239 ClientDiagnosticsLease {
11240 state: if expires_at_ms <= captured_at_ms {
11241 "expired".to_owned()
11242 } else {
11243 "active".to_owned()
11244 },
11245 expires_at_ms: Some(expires_at_ms),
11246 error_code: None,
11247 }
11248 } else {
11249 ClientDiagnosticsLease {
11250 state: "none".to_owned(),
11251 expires_at_ms: None,
11252 error_code: None,
11253 }
11254 };
11255 let connectivity = match self.last_round.as_ref() {
11256 _ if !self.transport_enabled && self.active_round.is_none() => "offline",
11257 Some(round) if round.status == "succeeded" => "online",
11258 Some(round)
11259 if round.status == "failed"
11260 && round
11261 .error_code
11262 .as_deref()
11263 .is_some_and(Self::retryable_transport_code) =>
11264 {
11265 "offline"
11266 }
11267 _ => "unknown",
11268 };
11269 Ok(ClientDiagnosticsSnapshot {
11270 version: CLIENT_DIAGNOSTICS_VERSION,
11271 captured_at_ms,
11272 host: ClientDiagnosticsHost {
11273 kind: "direct".to_owned(),
11274 role: "single".to_owned(),
11275 connectivity: connectivity.to_owned(),
11276 realtime: self.realtime_state,
11277 realtime_policy: self.realtime_policy,
11278 realtime_reason_code: self.realtime_reason_code.clone(),
11279 realtime_retry_delay_ms: self.realtime_retry_delay_ms,
11280 },
11281 security_lifecycle: self.security_lifecycle().to_owned(),
11282 schema: ClientDiagnosticsSchema {
11283 current_version: self.schema.version,
11284 upgrading: self.upgrading,
11285 required_version: self
11286 .schema_floor
11287 .as_ref()
11288 .and_then(|floor| floor.required_schema_version),
11289 latest_version: self
11290 .schema_floor
11291 .as_ref()
11292 .and_then(|floor| floor.latest_schema_version),
11293 },
11294 replica: ClientDiagnosticsReplica {
11295 local_revision: self.local_revision().to_string(),
11296 sync_needed: self.sync_needed,
11297 pending_outbox: self.outbox.len(),
11298 },
11299 lease,
11300 subscriptions: ordered_subscriptions,
11301 subscriptions_truncated,
11302 last_round: self.last_round.clone(),
11303 last_change: self.last_change.clone(),
11304 storage: self.diagnostics_storage(),
11305 query_failures: self.query_failures.clone(),
11306 })
11307 }
11308
11309 fn diagnostics_storage(&self) -> ClientDiagnosticsStorage {
11310 let read = || -> Result<ClientDiagnosticsStorage, rusqlite::Error> {
11311 let page_count: i64 = self
11312 .conn
11313 .query_row("PRAGMA page_count", [], |row| row.get(0))?;
11314 let page_size: i64 = self
11315 .conn
11316 .query_row("PRAGMA page_size", [], |row| row.get(0))?;
11317 let outbox_bytes: i64 = self
11319 .conn
11320 .prepare_cached("SELECT COALESCE(SUM(LENGTH(ops_json)), 0) FROM _syncular_outbox")?
11321 .query_row([], |row| row.get(0))?;
11322 let (outcome_entries, outcome_bytes): (i64, i64) = self.conn.prepare_cached(
11323 "SELECT COUNT(*), COALESCE(SUM(LENGTH(results_json) + COALESCE(LENGTH(operations_json), 0)), 0) FROM _syncular_commit_outcomes",
11324 )?.query_row([], |row| Ok((row.get(0)?, row.get(1)?)))?;
11325 let blob_bytes = if self.schema_has_blobs() {
11326 self.conn
11327 .prepare_cached("SELECT COALESCE(SUM(byte_length), 0) FROM _syncular_blobs")?
11328 .query_row([], |row| row.get(0))?
11329 } else {
11330 0
11331 };
11332 let pressure = self
11333 .limits
11334 .blob_cache_max_bytes
11335 .is_some_and(|limit| blob_bytes > limit);
11336 Ok(ClientDiagnosticsStorage {
11337 status: if pressure { "pressure" } else { "healthy" }.to_owned(),
11338 database_bytes_approx: Some(page_count.saturating_mul(page_size).max(0)),
11339 pending_outbox_bytes_approx: Some(outbox_bytes.max(0)),
11340 retained_outcome_bytes_approx: Some(outcome_bytes.max(0)),
11341 retained_outcome_entries: Some(outcome_entries.max(0)),
11342 blob_cache_bytes_approx: Some(blob_bytes.max(0)),
11343 pressure_reason_code: pressure.then(|| "client.blob_cache_over_limit".to_owned()),
11344 })
11345 };
11346 read().unwrap_or_else(|_| ClientDiagnosticsStorage {
11347 status: "unreadable".to_owned(),
11348 database_bytes_approx: None,
11349 pending_outbox_bytes_approx: None,
11350 retained_outcome_bytes_approx: None,
11351 retained_outcome_entries: None,
11352 blob_cache_bytes_approx: None,
11353 pressure_reason_code: None,
11354 })
11355 }
11356
11357 pub fn drain_change_batches(&mut self) -> Vec<ClientChangeBatch> {
11358 self.change_queue.drain(..).collect()
11359 }
11360
11361 pub fn drain_sync_intents(&mut self) -> Vec<SyncIntent> {
11362 let intents = self.sync_intent_queue.drain(..).collect();
11363 if self.transport_enabled {
11364 intents
11365 } else {
11366 vec![SyncIntent::None]
11367 }
11368 }
11369
11370 fn schedule_background_retry(&mut self) -> u64 {
11371 let delay_ms = self.retry_delay_ms;
11372 self.sync_intent_queue
11373 .push_back(SyncIntent::Background { delay_ms });
11374 self.retry_delay_ms = (self.retry_delay_ms * 2).min(30_000);
11375 self.round_retry_delay_ms = Some(delay_ms);
11376 delay_ms
11377 }
11378
11379 fn reset_background_retry(&mut self) {
11380 self.retry_delay_ms = 250;
11381 }
11382
11383 fn retryable_transport_code(code: &str) -> bool {
11384 code == "transport.failed"
11385 || code == "transport.unavailable"
11386 || code == "sync.transport_failed"
11387 }
11388
11389 fn retryable_failure_code(code: &str) -> bool {
11394 Self::retryable_transport_code(code)
11395 || matches!(
11396 code,
11397 "sync.auth_required"
11398 | "sync.auth_lease_required"
11399 | "sync.auth_lease_revoked"
11400 | "sync.internal_error"
11401 | "sync.idempotency_cache_miss"
11402 | "sync.schema_not_ready"
11403 | "sync.segment_expired"
11404 | "sync.rate_limited"
11405 | "sync.websocket_connection_limit"
11406 )
11407 }
11408
11409 fn diagnostic_code(code: &str) -> String {
11410 let valid = !code.is_empty()
11411 && code.len() <= 96
11412 && code.contains('.')
11413 && code
11414 .bytes()
11415 .next()
11416 .is_some_and(|byte| byte.is_ascii_lowercase())
11417 && code.bytes().all(|byte| {
11418 byte.is_ascii_lowercase()
11419 || byte.is_ascii_digit()
11420 || matches!(byte, b'.' | b'_' | b'-')
11421 });
11422 if valid {
11423 code.to_owned()
11424 } else {
11425 "client.unknown_failure".to_owned()
11426 }
11427 }
11428
11429 fn set_sync_needed(&mut self, value: bool, interactive: bool) {
11430 if self.sync_needed != value {
11431 if self.begin_observation("syncular_status").is_ok() {
11432 self.sync_needed = value;
11433 let batch = ChangeAccumulator {
11434 status: true,
11435 ..ChangeAccumulator::default()
11436 };
11437 if self.finish_observation("syncular_status", batch).is_err() {
11438 self.rollback_observation("syncular_status");
11439 }
11440 } else {
11441 self.sync_needed = value;
11442 }
11443 }
11444 if value && interactive {
11445 self.sync_intent_queue.push_back(SyncIntent::Interactive);
11446 }
11447 }
11448
11449 fn sqlite_failure(
11450 storage_failure: &RefCell<Option<QueryReadFailure>>,
11451 error: rusqlite::Error,
11452 ) -> String {
11453 let message = error.to_string();
11454 let failure = QueryReadFailure::from(error);
11455 let code = failure.code;
11456 if code.is_some() && storage_failure.borrow().is_none() {
11457 *storage_failure.borrow_mut() = Some(failure);
11458 }
11459 match code {
11460 Some(code) => format!("{code}: {message}"),
11461 None => message,
11462 }
11463 }
11464
11465 fn begin_observation(&self, name: &str) -> Result<(), String> {
11466 self.conn
11467 .execute_batch(&format!("SAVEPOINT {name}"))
11468 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
11469 }
11470
11471 fn rollback_observation(&self, name: &str) {
11472 if let Err(error) = self
11473 .conn
11474 .execute_batch(&format!("ROLLBACK TO {name}; RELEASE {name}"))
11475 {
11476 if let Some(failure) = self.storage_failure.borrow_mut().as_mut() {
11477 if failure.rollback_failure.is_none() {
11478 failure.rollback_failure = Some(Box::new(QueryReadFailure::from(error)));
11479 }
11480 }
11481 }
11482 }
11483
11484 fn finish_observation(
11485 &mut self,
11486 name: &str,
11487 mut batch: ChangeAccumulator,
11488 ) -> Result<(), String> {
11489 #[cfg(feature = "bench-internals")]
11490 let _phase = self.benchmark_phases.start(Phase::ObservationCommit);
11491 if self.failed_commits.iter().any(|(commit, _)| {
11492 commit
11493 .ops
11494 .iter()
11495 .any(|operation| batch.tables.contains_key(&operation.table))
11496 }) {
11497 batch.outcomes = true;
11498 }
11499 if !batch.touched() {
11500 self.conn
11501 .execute_batch(&format!("RELEASE {name}"))
11502 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
11503 return Ok(());
11504 }
11505 let revision = self
11506 .local_revision()
11507 .checked_add(1)
11508 .ok_or_else(|| "local revision exhausted u64".to_owned())?;
11509 self.conn
11510 .execute(
11511 "INSERT OR REPLACE INTO _syncular_meta(key, value) VALUES (?1, ?2)",
11512 rusqlite::params![LOCAL_REVISION_KEY, revision.to_string()],
11513 )
11514 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
11515 let status = batch.status.then(|| self.status_snapshot());
11516 let event = ClientChangeBatch {
11517 revision: revision.to_string(),
11518 tables: batch
11519 .tables
11520 .into_iter()
11521 .map(|(table, scope_keys)| TableChange {
11522 table,
11523 scope_keys: scope_keys.map(|keys| keys.into_iter().collect()),
11524 })
11525 .collect(),
11526 windows: batch
11527 .windows
11528 .into_iter()
11529 .map(|((base_key, table), units)| WindowChange {
11530 base_key,
11531 table,
11532 units: units.into_iter().collect(),
11533 })
11534 .collect(),
11535 status,
11536 conflicts_changed: batch.conflicts,
11537 rejections_changed: batch.rejections,
11538 outcomes_changed: batch.outcomes,
11539 };
11540 self.conn
11541 .execute_batch(&format!("RELEASE {name}"))
11542 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
11543 let mut diagnostic_tables = event
11544 .tables
11545 .iter()
11546 .map(|entry| entry.table.clone())
11547 .collect::<BTreeSet<_>>()
11548 .into_iter()
11549 .collect::<Vec<_>>();
11550 let mut diagnostic_windows = event
11551 .windows
11552 .iter()
11553 .map(|entry| entry.table.clone())
11554 .collect::<BTreeSet<_>>()
11555 .into_iter()
11556 .collect::<Vec<_>>();
11557 let domains_truncated = diagnostic_tables.len() > MAX_DIAGNOSTIC_DOMAINS
11558 || diagnostic_windows.len() > MAX_DIAGNOSTIC_DOMAINS;
11559 diagnostic_tables.truncate(MAX_DIAGNOSTIC_DOMAINS);
11560 diagnostic_windows.truncate(MAX_DIAGNOSTIC_DOMAINS);
11561 self.last_change = Some(DiagnosticLastChange {
11562 revision: event.revision.clone(),
11563 recorded_at_ms: self.clock_now_ms(),
11564 tables: diagnostic_tables,
11565 windows: diagnostic_windows,
11566 domains_truncated,
11567 status_changed: event.status.is_some(),
11568 conflicts_changed: event.conflicts_changed,
11569 rejections_changed: event.rejections_changed,
11570 outcomes_changed: event.outcomes_changed,
11571 });
11572 self.change_queue.push_back(event);
11573 Ok(())
11574 }
11575
11576 fn record_scope_map(
11577 &self,
11578 batch: &mut ChangeAccumulator,
11579 table_name: &str,
11580 scopes: &[(String, Vec<String>)],
11581 ) {
11582 let Some(table) = self.schema.table(table_name) else {
11583 return;
11584 };
11585 for (variable, values) in scopes {
11586 let Some(scope) = table
11587 .scope_variables
11588 .iter()
11589 .find(|scope| &scope.variable == variable)
11590 else {
11591 continue;
11592 };
11593 for value in values {
11594 batch.scope(table_name, format!("{}:{value}", scope.prefix));
11595 }
11596 }
11597 }
11598
11599 fn record_row_scopes(
11601 &self,
11602 batch: &mut ChangeAccumulator,
11603 table_name: &str,
11604 row_id: &str,
11605 base: bool,
11606 ) -> bool {
11607 let Some(table) = self.schema.table(table_name) else {
11608 return false;
11609 };
11610 if table.scope_variables.is_empty() {
11611 return false;
11612 }
11613 let columns = table
11614 .scope_variables
11615 .iter()
11616 .map(|scope| quote_ident(&scope.column))
11617 .collect::<Vec<_>>()
11618 .join(", ");
11619 let full_table = if base {
11620 base_table(table_name)
11621 } else {
11622 visible_table(table_name)
11623 };
11624 let sql = format!(
11625 "SELECT {columns} FROM {full_table} WHERE {} LIMIT 1",
11626 row_id_predicate(table)
11627 );
11628 let Ok(mut stmt) = self.conn.prepare_cached(&sql) else {
11629 return false;
11630 };
11631 let values = stmt.query_row(rusqlite::params![row_id], |row| {
11632 let mut values = Vec::with_capacity(table.scope_variables.len());
11633 for index in 0..table.scope_variables.len() {
11634 values.push(row.get::<_, Option<String>>(index)?);
11635 }
11636 Ok(values)
11637 });
11638 let Ok(values) = values else {
11639 return false;
11640 };
11641 let mut recorded = false;
11642 for (scope, value) in table.scope_variables.iter().zip(values) {
11643 if let Some(value) = value {
11644 batch.scope(table_name, format!("{}:{value}", scope.prefix));
11645 recorded = true;
11646 }
11647 }
11648 recorded
11649 }
11650
11651 fn record_commit_changes(
11652 &self,
11653 batch: &mut ChangeAccumulator,
11654 tables: &[String],
11655 changes: &[ssp2::model::Change],
11656 ) {
11657 #[cfg(feature = "bench-internals")]
11658 let _phase = self.benchmark_phases.start(Phase::ObservationPrepare);
11659 for change in changes {
11660 let Some(table_name) = tables.get(change.table_index as usize) else {
11661 continue;
11662 };
11663 let mut precise = self.record_row_scopes(batch, table_name, &change.row_id, true);
11664 if let Some(table) = self.schema.table(table_name) {
11665 for (variable, value) in &change.scopes {
11666 if let Some(scope) = table
11667 .scope_variables
11668 .iter()
11669 .find(|scope| &scope.variable == variable)
11670 {
11671 batch.scope(table_name, format!("{}:{value}", scope.prefix));
11672 precise = true;
11673 }
11674 }
11675 }
11676 if !precise {
11677 batch.table(table_name);
11678 }
11679 }
11680 }
11681
11682 fn scoped_rows_exist(&self, table_name: &str, effective: &[(String, Vec<String>)]) -> bool {
11683 if effective.is_empty() {
11684 return false;
11685 }
11686 let Some(table) = self.schema.table(table_name) else {
11687 return false;
11688 };
11689 let mut clauses = Vec::new();
11690 let mut params = Vec::new();
11691 for (variable, values) in effective {
11692 let Some(column) = table.scope_column(variable) else {
11693 return false;
11694 };
11695 if values.is_empty() {
11696 return false;
11697 }
11698 let placeholders = values
11699 .iter()
11700 .map(|value| {
11701 params.push(SqlValue::Text(value.clone()));
11702 "?"
11703 })
11704 .collect::<Vec<_>>()
11705 .join(", ");
11706 clauses.push(format!("{} IN ({placeholders})", quote_ident(column)));
11707 }
11708 let sql = format!(
11709 "SELECT 1 FROM {} WHERE {} LIMIT 1",
11710 base_table(table_name),
11711 clauses.join(" AND ")
11712 );
11713 self.conn
11714 .query_row(&sql, rusqlite::params_from_iter(params), |_| Ok(()))
11715 .is_ok()
11716 }
11717
11718 pub fn upgrading(&self) -> bool {
11720 self.upgrading
11721 }
11722
11723 pub fn recreate_with_schema(&mut self, schema_json: &Value) -> Result<(), String> {
11731 let new_schema = parse_schema_json(schema_json)?;
11732 validate_authority_reads(&new_schema, &self.limits.authority_reads)?;
11733 self.conn
11734 .execute_batch("BEGIN IMMEDIATE")
11735 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
11736 let prior_schema = std::mem::replace(&mut self.schema, new_schema);
11737 let prior_subs = self.subs.clone();
11743 let prior_outbox = self.outbox.clone();
11744 let prior_rejections = self.rejections.clone();
11745 let prior_upgrading = self.upgrading;
11746 let prior_stopped = self.stopped;
11747 let prior_schema_floor = self.schema_floor.clone();
11748 let prior_overlay_dirty = self.overlay_dirty.snapshot();
11749 let prior_sync_needed = self.sync_needed;
11750 let prior_active_round = self.active_round;
11751 let prior_changes = self.change_queue.len();
11752 let prior_intents = self.sync_intent_queue.len();
11753 let prior_last_change = self.last_change.clone();
11754 let guarded = (|| -> Result<(), String> {
11755 let marker = read_local_schema_version(&self.conn, self.schema.version)?;
11760 self.create_bookkeeping_tables()?;
11761 match marker {
11762 Some(version) if version < self.schema.version => self.run_schema_reset()?,
11763 None => {
11764 self.create_synced_tables()?;
11765 self.persist_schema_version()?;
11766 }
11767 _ => {}
11768 }
11769 self.outbox = self.load_outbox()?;
11774 self.drop_unencodable_outbox(false, None)?;
11779 if marker == Some(self.schema.version)
11780 && (!self.outbox.is_empty()
11781 || !self.failed_commits.is_empty()
11782 || self.has_acknowledged_rows())
11783 {
11784 self.overlay_dirty.set(true);
11785 self.rebuild_overlay()?;
11786 }
11787 self.prune_unknown_subscriptions(false)?;
11788 self.save_subscription_scope_schema();
11789 self.enqueue_startup_sync_if_needed()?;
11793 Ok(())
11794 })();
11795 let outcome = match guarded {
11796 Ok(()) => self
11797 .conn
11798 .execute_batch("COMMIT")
11799 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error)),
11800 Err(error) => Err(error),
11801 };
11802 if let Err(error) = outcome {
11803 if let Err(rollback) = self.conn.execute_batch("ROLLBACK") {
11804 let _ = Self::sqlite_failure(&self.storage_failure, rollback);
11806 }
11807 self.schema = prior_schema;
11808 self.subs = prior_subs;
11809 self.outbox = prior_outbox;
11810 self.rejections = prior_rejections;
11811 self.upgrading = prior_upgrading;
11812 self.stopped = prior_stopped;
11813 self.schema_floor = prior_schema_floor;
11814 self.overlay_dirty.restore(prior_overlay_dirty);
11815 self.sync_needed = prior_sync_needed;
11816 self.active_round = prior_active_round;
11817 self.change_queue.truncate(prior_changes);
11818 self.sync_intent_queue.truncate(prior_intents);
11819 self.last_change = prior_last_change;
11820 self.insert_sql.borrow_mut().clear();
11822 return Err(error);
11823 }
11824 Ok(())
11825 }
11826
11827 fn enqueue_startup_sync_if_needed(&mut self) -> Result<(), String> {
11828 let startup_work = !self.stopped
11829 && (!self.outbox.is_empty()
11830 || self.has_acknowledged_rows()
11831 || !self.load_pending_evictions()?.is_empty()
11832 || self.subs.iter().any(|sub| sub.state == SubState::Active));
11833 if startup_work {
11834 self.sync_needed = true;
11835 self.sync_intent_queue.push_back(SyncIntent::Interactive);
11836 }
11837 Ok(())
11838 }
11839
11840 fn clear_satisfied_persisted_schema_floor(&mut self) {
11847 let satisfied = self
11848 .schema_floor
11849 .as_ref()
11850 .and_then(|floor| floor.required_schema_version)
11851 .is_some_and(|required| self.schema.version >= required);
11852 if !satisfied {
11853 return;
11854 }
11855 self.schema_floor = None;
11856 self.stopped = false;
11857 self.delete_meta(SCHEMA_FLOOR_KEY);
11858 }
11859
11860 fn reconcile_previous_version_at_boot(&self) -> Result<(), String> {
11865 let Some(replica_path) = self.previous_version_replica_path() else {
11866 return Ok(());
11867 };
11868 let max_age_ms = self
11869 .previous_version
11870 .filter(|config| config.enabled)
11871 .map(|config| config.max_age_ms);
11872 reconcile_previous_version_at_boot(
11873 &self.conn,
11874 &replica_path,
11875 self.schema.version,
11876 max_age_ms,
11877 self.clock_now_ms(),
11878 )
11879 }
11880
11881 fn previous_version_replica_path(&self) -> Option<String> {
11885 let path = self.conn.path()?;
11886 if path.is_empty() || path.starts_with("file:") {
11887 return None;
11888 }
11889 Some(path.to_owned())
11890 }
11891
11892 fn run_schema_reset(&mut self) -> Result<(), String> {
11898 self.begin_observation("syncular_schema_reset")?;
11899 let prior_subs = self.subs.clone();
11900 let prior_outbox = self.outbox.clone();
11901 let prior_rejections = self.rejections.clone();
11902 let prior_upgrading = self.upgrading;
11903 let prior_stopped = self.stopped;
11904 let prior_schema_floor = self.schema_floor.clone();
11905 let prior_overlay_dirty = self.overlay_dirty.snapshot();
11906 let prior_sync_needed = self.sync_needed;
11907 let prior_active_round = self.active_round;
11908 let mut batch = ChangeAccumulator::default();
11909 let result = self
11910 .run_schema_reset_observed(&mut batch, true)
11911 .and_then(|()| self.finish_observation("syncular_schema_reset", batch));
11912 if let Err(error) = result {
11913 self.rollback_observation("syncular_schema_reset");
11914 self.subs = prior_subs;
11915 self.outbox = prior_outbox;
11916 self.rejections = prior_rejections;
11917 self.upgrading = prior_upgrading;
11918 self.stopped = prior_stopped;
11919 self.schema_floor = prior_schema_floor;
11920 self.overlay_dirty.restore(prior_overlay_dirty);
11921 self.sync_needed = prior_sync_needed;
11922 self.active_round = prior_active_round;
11923 return Err(error);
11924 }
11925 Ok(())
11926 }
11927
11928 fn run_log_epoch_reset(&mut self, log_epoch: &str) -> Result<Vec<String>, String> {
11929 if self.get_meta(LOG_EPOCH_KEY).is_none() {
11930 self.begin_observation("syncular_epoch_acquisition")?;
11931 let previous_sync_needed = self.sync_needed;
11932 if let Err(error) = self.conn.execute(
11933 "INSERT OR REPLACE INTO _syncular_meta(key,value) VALUES (?1,?2)",
11934 rusqlite::params![LOG_EPOCH_KEY, log_epoch],
11935 ) {
11936 self.rollback_observation("syncular_epoch_acquisition");
11937 return Err(Self::sqlite_failure(&self.storage_failure, error));
11938 }
11939 self.sync_needed = true;
11940 let batch = ChangeAccumulator {
11941 status: true,
11942 ..ChangeAccumulator::default()
11943 };
11944 if let Err(error) = self.finish_observation("syncular_epoch_acquisition", batch) {
11945 self.rollback_observation("syncular_epoch_acquisition");
11946 self.sync_needed = previous_sync_needed;
11947 return Err(error);
11948 }
11949 self.sync_intent_queue.push_back(SyncIntent::Interactive);
11950 return Ok(Vec::new());
11951 }
11952 let resets = self.subs.iter().map(|sub| sub.id.clone()).collect();
11953 self.begin_observation("syncular_log_epoch_reset")?;
11954 let prior_subs = self.subs.clone();
11955 let prior_outbox = self.outbox.clone();
11956 let prior_rejections = self.rejections.clone();
11957 let prior_upgrading = self.upgrading;
11958 let prior_stopped = self.stopped;
11959 let prior_schema_floor = self.schema_floor.clone();
11960 let prior_overlay_dirty = self.overlay_dirty.snapshot();
11961 let prior_sync_needed = self.sync_needed;
11962 let prior_active_round = self.active_round;
11963 let mut batch = ChangeAccumulator::default();
11964 let result = self
11965 .run_schema_reset_observed(&mut batch, false)
11966 .and_then(|()| {
11967 self.conn
11968 .execute(
11969 "INSERT OR REPLACE INTO _syncular_meta(key,value) VALUES (?1,?2)",
11970 rusqlite::params![LOG_EPOCH_KEY, log_epoch],
11971 )
11972 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
11973 self.sync_needed = true;
11974 batch.status = true;
11975 self.finish_observation("syncular_log_epoch_reset", batch)
11976 });
11977 if let Err(error) = result {
11978 self.rollback_observation("syncular_log_epoch_reset");
11979 self.subs = prior_subs;
11980 self.outbox = prior_outbox;
11981 self.rejections = prior_rejections;
11982 self.upgrading = prior_upgrading;
11983 self.stopped = prior_stopped;
11984 self.schema_floor = prior_schema_floor;
11985 self.overlay_dirty.restore(prior_overlay_dirty);
11986 self.sync_needed = prior_sync_needed;
11987 self.active_round = prior_active_round;
11988 return Err(error);
11989 }
11990 self.sync_intent_queue.push_back(SyncIntent::Interactive);
11991 Ok(resets)
11992 }
11993
11994 fn run_schema_reset_observed(
11995 &mut self,
11996 batch: &mut ChangeAccumulator,
11997 capture: bool,
11998 ) -> Result<(), String> {
11999 read_local_schema_version(&self.conn, self.schema.version)?;
12005 self.conn
12006 .execute_batch(
12007 "DELETE FROM _syncular_acked_rows; DELETE FROM _syncular_row_deliveries;",
12008 )
12009 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
12010 self.cancel_sync_round();
12011 self.upgrading = true;
12012 batch.status = true;
12013 if self.previous_version.is_some_and(|config| config.enabled) {
12021 let previous_version = self
12022 .get_meta(LOCAL_SCHEMA_VERSION_KEY)
12023 .and_then(|value| value.parse::<i32>().ok())
12024 .unwrap_or(self.schema.version);
12025 let pending = self
12026 .outbox
12027 .iter()
12028 .map(|commit| PendingCommitAudit {
12029 commit_id: &commit.client_commit_id,
12030 operations: commit_audit_operations(commit),
12031 })
12032 .collect::<Vec<_>>();
12033 crate::previous_version::write_previous_version_audit(
12034 &self.conn,
12035 &crate::previous_version::build_previous_version_audit(
12036 &self.schema,
12037 &pending,
12038 previous_version,
12039 self.schema.version,
12040 self.clock_now_ms(),
12041 ),
12042 );
12043 }
12044 if let Some(replica_path) = self.previous_version_replica_path() {
12049 sweep_previous_version_container(&self.conn, &replica_path)?;
12050 if capture {
12051 if let Some(config) = self.previous_version.filter(|config| config.enabled) {
12052 let previous_version = self
12053 .get_meta(LOCAL_SCHEMA_VERSION_KEY)
12054 .and_then(|value| value.parse::<i32>().ok())
12055 .unwrap_or(self.schema.version);
12056 capture_previous_version_from_replica(
12057 &self.conn,
12058 &replica_path,
12059 previous_version,
12060 self.schema.version,
12061 &config,
12062 self.clock_now_ms(),
12063 )?;
12064 }
12065 }
12066 }
12067 for table in &self.schema.tables {
12068 batch.table(&table.name);
12069 }
12070 for (base_key, unit, table) in self.load_registered_window_units() {
12071 batch.window(&base_key, &table, &unit);
12072 }
12073 if capture {
12074 self.prune_unknown_subscriptions(true)?;
12075 }
12076 self.insert_sql.borrow_mut().clear();
12078 self.overlay_dirty.set(true);
12079 let virtual_tables: Vec<String> = {
12087 let mut stmt = self
12088 .conn
12089 .prepare(
12090 "SELECT name FROM sqlite_master WHERE type = 'table' AND sql LIKE 'CREATE VIRTUAL TABLE%'",
12091 )
12092 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
12093 let rows = stmt
12094 .query_map([], |row| row.get::<_, String>(0))
12095 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
12096 rows.filter_map(Result::ok)
12097 .filter(|name| is_synced_table_name(name))
12098 .collect()
12099 };
12100 for name in virtual_tables {
12101 self.conn
12102 .execute_batch(&format!(
12103 "DROP TABLE IF EXISTS {}",
12104 quote_ident(&format!("_syncular_fts_{name}"))
12105 ))
12106 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
12107 self.conn
12108 .execute(&format!("DROP TABLE IF EXISTS {}", quote_ident(&name)), [])
12109 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
12110 }
12111 let existing: Vec<String> = {
12112 let mut stmt = self
12113 .conn
12114 .prepare("SELECT name FROM sqlite_master WHERE type = 'table'")
12115 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
12116 let rows = stmt
12117 .query_map([], |row| row.get::<_, String>(0))
12118 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
12119 rows.filter_map(Result::ok)
12120 .filter(|name| is_synced_table_name(name))
12121 .collect()
12122 };
12123 for name in &existing {
12124 if let Some(table) = name.strip_prefix("_syncular_base_") {
12125 batch.table(table);
12126 } else if !name.starts_with("_syncular_") {
12127 batch.table(name);
12128 }
12129 }
12130 for name in existing {
12131 self.conn
12132 .execute(&format!("DROP TABLE IF EXISTS {}", quote_ident(&name)), [])
12133 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
12134 }
12135 self.create_synced_tables()?;
12137 for sub in &mut self.subs {
12139 sub.cursor = -1;
12140 sub.bootstrap_state = None;
12141 sub.effective = None;
12142 sub.state = SubState::Active;
12143 sub.reason_code = None;
12144 sub.synced_once = false;
12145 }
12146 let subs = self.subs.clone();
12147 for sub in &subs {
12148 self.persist_sub(sub)?;
12149 }
12150 self.stopped = false;
12152 self.schema_floor = None;
12153 self.delete_meta(SCHEMA_FLOOR_KEY);
12154 self.persist_schema_version()?;
12156 self.save_subscription_scope_schema();
12157 self.drop_unencodable_outbox(false, Some(batch))?;
12168 self.rebuild_overlay()?;
12170 Ok(())
12171 }
12172
12173 fn drop_incompatible_outbox(&mut self) -> Result<bool, String> {
12180 let incompatible = self
12181 .outbox
12182 .iter()
12183 .filter(|commit| {
12184 first_incompatibility(&self.schema, &commit_audit_operations(commit)).is_some()
12185 })
12186 .cloned()
12187 .collect::<Vec<_>>();
12188 if incompatible.is_empty() {
12189 return Ok(false);
12190 }
12191 let prior_outbox = self.outbox.clone();
12197 let prior_rejections = self.rejections.clone();
12198 let prior_overlay_dirty = self.overlay_dirty.snapshot();
12199 let owns_transaction = self.conn.is_autocommit();
12200 self.begin_observation("syncular_outbox_drop")?;
12201 let mut batch = ChangeAccumulator::default();
12202 let dropped = (|| -> Result<(), String> {
12203 let mut rejections = Vec::new();
12204 for commit in &incompatible {
12205 let results = commit
12206 .ops
12207 .iter()
12208 .enumerate()
12209 .map(|(op_index, operation)| {
12210 let rejection = RejectionRecord {
12211 client_commit_id: commit.client_commit_id.clone(),
12212 op_index: op_index as i32,
12213 code: OUTBOX_INCOMPATIBLE_CODE.to_owned(),
12214 message: "the persisted commit cannot encode under the current schema"
12215 .to_owned(),
12216 retryable: false,
12217 details: None,
12218 operation: Some(CommitOperation::from(operation)),
12219 };
12220 rejections.push(rejection.clone());
12221 CommitOperationOutcome::Error { rejection }
12222 })
12223 .collect::<Vec<_>>();
12224 self.persist_commit_outcome(
12225 &commit.client_commit_id,
12226 CommitOutcomeStatus::Rejected,
12227 &results,
12228 Some(&commit.ops),
12229 )?;
12230 self.delete_outbox_persisted(&commit.client_commit_id)?;
12231 for operation in &commit.ops {
12234 if self.schema.table(&operation.table).is_some() {
12235 batch.table(&operation.table);
12236 }
12237 }
12238 }
12239 self.prune_commit_outcomes()?;
12240 let incompatible_ids = incompatible
12241 .iter()
12242 .map(|commit| commit.client_commit_id.as_str())
12243 .collect::<BTreeSet<_>>();
12244 self.outbox
12245 .retain(|commit| !incompatible_ids.contains(commit.client_commit_id.as_str()));
12246 self.rejections.extend(rejections);
12247 self.overlay_dirty.set(true);
12250 self.rebuild_overlay()?;
12251 batch.status = true;
12252 batch.rejections = true;
12253 batch.outcomes = true;
12254 Ok(())
12255 })();
12256 let result = dropped.and_then(|()| self.finish_observation("syncular_outbox_drop", batch));
12257 if let Err(error) = result {
12258 if owns_transaction {
12262 if let Err(rollback) = self.conn.execute_batch("ROLLBACK") {
12263 let mut failure = self.storage_failure.borrow_mut();
12264 let failure =
12265 failure.get_or_insert_with(|| QueryReadFailure::from(error.clone()));
12266 failure.rollback_failure = Some(Box::new(QueryReadFailure::from(rollback)));
12267 }
12268 } else {
12269 self.rollback_observation("syncular_outbox_drop");
12270 }
12271 self.outbox = prior_outbox;
12272 self.rejections = prior_rejections;
12273 self.overlay_dirty.restore(prior_overlay_dirty);
12274 return Err(error);
12275 }
12276 Ok(true)
12277 }
12278
12279 fn create_synced_tables(&self) -> Result<(), String> {
12282 for table in &self.schema.tables {
12283 for (full, index_prefix) in [
12287 (base_table(&table.name), "_syncular_base_"),
12288 (visible_table(&table.name), ""),
12289 ] {
12290 let mut cols: Vec<String> =
12291 table.columns.iter().map(|c| quote_ident(&c.name)).collect();
12292 cols.push("\"_syncular_version\" INTEGER NOT NULL".to_owned());
12293 let sql = format!(
12294 "CREATE TABLE IF NOT EXISTS {full} ({} , PRIMARY KEY ({}))",
12295 cols.join(", "),
12296 quote_ident(&table.primary_key)
12297 );
12298 self.conn
12299 .execute(&sql, [])
12300 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
12301 for index in &table.indexes {
12305 let unique = if index.unique { "UNIQUE " } else { "" };
12306 let index_name = quote_ident(&format!("{index_prefix}{}", index.name));
12307 let cols_sql = index
12308 .columns
12309 .iter()
12310 .map(|c| quote_ident(c))
12311 .collect::<Vec<_>>()
12312 .join(", ");
12313 let index_sql = format!(
12314 "CREATE {unique}INDEX IF NOT EXISTS {index_name} ON {full} ({cols_sql})"
12315 );
12316 self.conn
12317 .execute(&index_sql, [])
12318 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
12319 }
12320 }
12321 }
12322 for table in &self.schema.tables {
12325 for index in &table.fts_indexes {
12326 self.create_fts_projection(table, index)?;
12327 }
12328 }
12329 Ok(())
12330 }
12331
12332 fn fts_projection_exists(&self, index: &FtsIndexSchema) -> Result<bool, String> {
12333 let count: i64 = self
12334 .conn
12335 .query_row(
12336 "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name=?1",
12337 rusqlite::params![index.name],
12338 |row| row.get(0),
12339 )
12340 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
12341 Ok(count > 0)
12342 }
12343
12344 fn drop_fts_triggers(&self, index: &FtsIndexSchema) -> Result<(), String> {
12345 for suffix in ["ai", "ad", "au"] {
12346 self.conn
12347 .execute(
12348 &format!(
12349 "DROP TRIGGER IF EXISTS {}",
12350 quote_ident(&format!("{}_{suffix}", index.name))
12351 ),
12352 [],
12353 )
12354 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
12355 }
12356 Ok(())
12357 }
12358
12359 fn create_fts_triggers(
12360 &self,
12361 table: &TableSchema,
12362 index: &FtsIndexSchema,
12363 ) -> Result<(), String> {
12364 let fts = quote_ident(&index.name);
12365 let source = visible_table(&table.name);
12366 let source_id = quote_ident(FTS_SOURCE_ID_COLUMN);
12367 let pk = quote_ident(&table.primary_key);
12368 let projection_columns = std::iter::once(source_id.clone())
12369 .chain(index.columns.iter().map(|column| quote_ident(column)))
12370 .collect::<Vec<_>>()
12371 .join(", ");
12372 let new_values = std::iter::once(format!("CAST(new.{pk} AS TEXT)"))
12373 .chain(
12374 index
12375 .columns
12376 .iter()
12377 .map(|column| format!("new.{}", quote_ident(column))),
12378 )
12379 .collect::<Vec<_>>()
12380 .join(", ");
12381 let mapping = quote_ident(&format!("_syncular_fts_{}", index.name));
12382 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)");
12383 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)");
12384 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})");
12385 let bi = quote_ident(&format!("{}_bi", index.name));
12386 let ai = quote_ident(&format!("{}_ai", index.name));
12387 let ad = quote_ident(&format!("{}_ad", index.name));
12388 let au = quote_ident(&format!("{}_au", index.name));
12389 let replacement_exists = format!("EXISTS (SELECT 1 FROM {source} WHERE {pk} = new.{pk})");
12390 let sql = format!(
12391 "DROP TRIGGER IF EXISTS {bi};
12392 DROP TRIGGER IF EXISTS {ai};
12393 DROP TRIGGER IF EXISTS {ad};
12394 DROP TRIGGER IF EXISTS {au};
12395 CREATE TRIGGER {bi} BEFORE INSERT ON {source} WHEN {replacement_exists} BEGIN {delete_new}; END;
12396 CREATE TRIGGER {ai} AFTER INSERT ON {source} BEGIN {insert_new}; END;
12397 CREATE TRIGGER {ad} AFTER DELETE ON {source} BEGIN {delete_old}; END;
12398 CREATE TRIGGER {au} AFTER UPDATE ON {source} BEGIN {delete_old}; {delete_new}; {insert_new}; END;",
12399 );
12400 self.conn
12401 .execute_batch(&sql)
12402 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
12403 }
12404
12405 fn rebuild_fts_projection(
12406 &self,
12407 table: &TableSchema,
12408 index: &FtsIndexSchema,
12409 ) -> Result<(), String> {
12410 let fts = quote_ident(&index.name);
12411 let source_id = quote_ident(FTS_SOURCE_ID_COLUMN);
12412 let pk = quote_ident(&table.primary_key);
12413 let indexed_columns = index
12414 .columns
12415 .iter()
12416 .map(|column| quote_ident(column))
12417 .collect::<Vec<_>>();
12418 let projection_columns = std::iter::once(source_id.clone())
12419 .chain(indexed_columns.iter().cloned())
12420 .collect::<Vec<_>>()
12421 .join(", ");
12422 let mapping = quote_ident(&format!("_syncular_fts_{}", index.name));
12423 let sql = format!(
12424 "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};",
12425 columns = indexed_columns.join(", "),
12426 source = visible_table(&table.name),
12427 );
12428 self.conn
12429 .execute_batch(&sql)
12430 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
12431 }
12432
12433 fn create_fts_projection(
12434 &self,
12435 table: &TableSchema,
12436 index: &FtsIndexSchema,
12437 ) -> Result<(), String> {
12438 self.begin_observation("syncular_fts_schema")?;
12439 let result = (|| {
12440 let existed = self.fts_projection_exists(index)?;
12441 let tokenizer = index.tokenize.replace('\'', "''");
12442 let columns = index
12443 .columns
12444 .iter()
12445 .map(|column| quote_ident(column))
12446 .collect::<Vec<_>>()
12447 .join(", ");
12448 let sql = format!(
12449 "CREATE VIRTUAL TABLE IF NOT EXISTS {fts} USING fts5({source_id} UNINDEXED, {columns}, tokenize='{tokenizer}')",
12450 fts = quote_ident(&index.name),
12451 source_id = quote_ident(FTS_SOURCE_ID_COLUMN),
12452 );
12453 self.conn.execute(&sql, []).map_err(|error| {
12454 format!(
12455 "cannot create local FTS5 projection {:?}: {error}",
12456 index.name
12457 )
12458 })?;
12459 let mapping = quote_ident(&format!("_syncular_fts_{}", index.name));
12460 let mapped: bool = self
12461 .conn
12462 .query_row(
12463 "SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type='table' AND name=?1)",
12464 [format!("_syncular_fts_{}", index.name)],
12465 |row| row.get(0),
12466 )
12467 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
12468 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))?;
12469 if !mapped && existed {
12470 self.conn
12471 .execute_batch(&format!(
12472 "INSERT INTO {mapping}(id, source_id) SELECT rowid, {} FROM {}",
12473 quote_ident(FTS_SOURCE_ID_COLUMN),
12474 quote_ident(&index.name)
12475 ))
12476 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
12477 }
12478 self.create_fts_triggers(table, index)?;
12479 if !existed {
12480 self.rebuild_fts_projection(table, index)?;
12481 }
12482 Ok(())
12483 })();
12484 if let Err(error) = result {
12485 self.rollback_observation("syncular_fts_schema");
12486 return Err(error);
12487 }
12488 if let Err(error) = self.conn.execute_batch("RELEASE syncular_fts_schema") {
12489 self.rollback_observation("syncular_fts_schema");
12490 return Err(error.to_string());
12491 }
12492 Ok(())
12493 }
12494
12495 fn persist_sub(&self, sub: &Subscription) -> Result<(), String> {
12498 let state = serde_json::json!({
12499 "requested": scope_map_to_json(&sub.requested),
12500 "params": sub.params,
12501 "cursor": sub.cursor,
12502 "bootstrapState": sub.bootstrap_state,
12503 "status": sub.state.name(),
12504 "reasonCode": sub.reason_code,
12505 "effectiveScopes": sub.effective.as_ref().map(|e| scope_map_to_json(e)),
12506 "syncedOnce": sub.synced_once,
12507 });
12508 self.conn.execute(
12509 "INSERT OR REPLACE INTO _syncular_subscriptions (id, tbl, state_json) VALUES (?1, ?2, ?3)",
12510 rusqlite::params![sub.id, sub.table, state.to_string()],
12511 ).map(|_| ()).map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
12512 }
12513
12514 fn persist_outbox_insert(&self, commit: &OutboxCommit) -> Result<(), String> {
12515 let ops: Vec<Value> = commit
12516 .ops
12517 .iter()
12518 .map(|op| {
12519 serde_json::json!({
12520 "op": if op.upsert { "upsert" } else { "delete" },
12521 "table": op.table,
12522 "rowId": op.row_id,
12523 "baseVersion": op.base_version,
12524 "values": op.values.clone().map(Value::Object),
12525 })
12526 })
12527 .collect();
12528 self.conn
12529 .execute(
12530 "INSERT OR REPLACE INTO _syncular_outbox (commit_id, ops_json) VALUES (?1, ?2)",
12531 rusqlite::params![commit.client_commit_id, Value::Array(ops).to_string()],
12532 )
12533 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
12534 for operation in &commit.ops {
12535 let Some(values) = &operation.values else {
12536 continue;
12537 };
12538 let Some(table) = self.schema.table(&operation.table) else {
12539 continue;
12540 };
12541 for column in table
12542 .columns
12543 .iter()
12544 .filter(|column| column.ty == ColumnType::BlobRef)
12545 {
12546 let Some(Value::String(raw)) = values.get(&column.name) else {
12547 continue;
12548 };
12549 let Ok(blob_ref) = serde_json::from_str::<Value>(raw) else {
12550 continue;
12551 };
12552 let Some(blob_id) = blob_ref.get("blobId").and_then(Value::as_str) else {
12553 continue;
12554 };
12555 self.conn
12556 .execute(
12557 "INSERT OR IGNORE INTO _syncular_blob_commit_refs(commit_id, blob_id)
12558 SELECT ?1, ?2 WHERE EXISTS (SELECT 1 FROM _syncular_blobs WHERE blob_id = ?2)",
12559 rusqlite::params![commit.client_commit_id, blob_id],
12560 )
12561 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
12562 }
12563 }
12564 Ok(())
12565 }
12566
12567 fn delete_outbox_persisted(&self, client_commit_id: &str) -> Result<(), String> {
12568 if let Some(commit) = self
12569 .outbox
12570 .iter()
12571 .find(|commit| commit.client_commit_id == client_commit_id)
12572 {
12573 for operation in &commit.ops {
12574 self.overlay_dirty.table(&operation.table);
12575 }
12576 }
12577
12578 self.conn
12579 .execute(
12580 "DELETE FROM _syncular_blob_commit_refs WHERE commit_id = ?1",
12581 rusqlite::params![client_commit_id],
12582 )
12583 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
12584 self.conn
12585 .execute(
12586 "DELETE FROM _syncular_outbox WHERE commit_id = ?1",
12587 rusqlite::params![client_commit_id],
12588 )
12589 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
12590 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)", [])
12591 .map(|_| ())
12592 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
12593 }
12594
12595 fn outcome_status_name(status: CommitOutcomeStatus) -> &'static str {
12596 match status {
12597 CommitOutcomeStatus::Applied => "applied",
12598 CommitOutcomeStatus::Cached => "cached",
12599 CommitOutcomeStatus::Conflict => "conflict",
12600 CommitOutcomeStatus::Rejected => "rejected",
12601 }
12602 }
12603
12604 fn outcome_resolution_name(resolution: CommitOutcomeResolution) -> &'static str {
12605 match resolution {
12606 CommitOutcomeResolution::Active => "active",
12607 CommitOutcomeResolution::ResolvedKeepServer => "resolved_keep_server",
12608 CommitOutcomeResolution::Superseded => "superseded",
12609 CommitOutcomeResolution::Dismissed => "dismissed",
12610 }
12611 }
12612
12613 fn parse_outcome_status(value: &str) -> Result<CommitOutcomeStatus, String> {
12614 match value {
12615 "applied" => Ok(CommitOutcomeStatus::Applied),
12616 "cached" => Ok(CommitOutcomeStatus::Cached),
12617 "conflict" => Ok(CommitOutcomeStatus::Conflict),
12618 "rejected" => Ok(CommitOutcomeStatus::Rejected),
12619 _ => Err(format!("invalid persisted commit outcome status {value:?}")),
12620 }
12621 }
12622
12623 fn parse_outcome_resolution(value: &str) -> Result<CommitOutcomeResolution, String> {
12624 match value {
12625 "active" => Ok(CommitOutcomeResolution::Active),
12626 "resolved_keep_server" => Ok(CommitOutcomeResolution::ResolvedKeepServer),
12627 "superseded" => Ok(CommitOutcomeResolution::Superseded),
12628 "dismissed" => Ok(CommitOutcomeResolution::Dismissed),
12629 _ => Err(format!(
12630 "invalid persisted commit outcome resolution {value:?}"
12631 )),
12632 }
12633 }
12634
12635 fn persist_commit_outcome(
12636 &self,
12637 client_commit_id: &str,
12638 status: CommitOutcomeStatus,
12639 results: &[CommitOperationOutcome],
12640 operations: Option<&[OutboxOp]>,
12641 ) -> Result<(), String> {
12642 let results_json = serde_json::to_string(results).map_err(|error| error.to_string())?;
12643 let operations_json = operations
12644 .map(|items| {
12645 serde_json::to_string(&items.iter().map(CommitOperation::from).collect::<Vec<_>>())
12646 })
12647 .transpose()
12648 .map_err(|error| error.to_string())?;
12649 self.conn
12650 .execute(
12651 "INSERT INTO _syncular_commit_outcomes (
12652 client_commit_id, status, recorded_at_ms, results_json,
12653 operations_json, resolution
12654 ) VALUES (?1, ?2, ?3, ?4, ?5, 'active')",
12655 rusqlite::params![
12656 client_commit_id,
12657 Self::outcome_status_name(status),
12658 self.clock_now_ms(),
12659 results_json,
12660 operations_json
12661 ],
12662 )
12663 .map(|_| ())
12664 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
12665 }
12666
12667 fn stored_cell_is_representable(cell: &Value) -> bool {
12670 cell.is_null()
12671 || cell.is_string()
12672 || cell.is_boolean()
12673 || cell.is_number()
12674 || is_canonical_bytes_envelope(cell)
12675 }
12676
12677 fn stored_row_is_representable(value: &Value) -> bool {
12678 value
12679 .as_object()
12680 .is_some_and(|row| row.values().all(Self::stored_cell_is_representable))
12681 }
12682
12683 fn stored_operation_is_representable(value: &Value) -> bool {
12684 value.get("table").is_some_and(Value::is_string)
12685 && value.get("rowId").is_some_and(Value::is_string)
12686 && value.get("baseVersion").is_none_or(|v| {
12687 v.as_i64()
12688 .is_some_and(|n| n.unsigned_abs() <= MAX_JS_SAFE_INTEGER)
12689 })
12690 && match value.get("op").and_then(Value::as_str) {
12691 Some("upsert") => value
12692 .get("values")
12693 .is_some_and(Self::stored_row_is_representable),
12694 Some("delete") => value.get("values").is_none(),
12695 _ => false,
12696 }
12697 }
12698
12699 fn outbox_op_is_journalable(operation: &OutboxOp) -> bool {
12703 serde_json::to_value(CommitOperation::from(operation))
12704 .is_ok_and(|value| Self::stored_operation_is_representable(&value))
12705 }
12706
12707 fn outcome_from_row(row: StoredCommitOutcomeRow) -> Result<CommitOutcome, String> {
12708 let StoredCommitOutcomeRow {
12709 sequence,
12710 client_commit_id,
12711 status,
12712 recorded_at_ms,
12713 results_json,
12714 operations_json,
12715 resolution,
12716 resolved_at_ms,
12717 replacement_client_commit_id,
12718 } = row;
12719 if sequence.unsigned_abs() > MAX_JS_SAFE_INTEGER
12720 || recorded_at_ms.unsigned_abs() > MAX_JS_SAFE_INTEGER
12721 || resolved_at_ms.is_some_and(|value| value.unsigned_abs() > MAX_JS_SAFE_INTEGER)
12722 {
12723 return Err("sync.local_corrupt: persisted commit outcome is invalid".to_owned());
12724 }
12725 let invalid = || "sync.local_corrupt: persisted commit outcome is invalid".to_owned();
12726 let stored: Vec<Value> = serde_json::from_str(&results_json).map_err(|_| invalid())?;
12727 for result in &stored {
12728 let nested = match result.get("status").and_then(Value::as_str) {
12729 Some("conflict") => result.get("conflict"),
12730 Some("error") => result.get("rejection"),
12731 _ => None,
12732 };
12733 if nested
12734 .and_then(|record| record.get("operation"))
12735 .is_some_and(|op| !Self::stored_operation_is_representable(op))
12736 || (result.get("status").and_then(Value::as_str) == Some("conflict")
12737 && nested
12738 .and_then(|record| record.get("serverRow"))
12739 .is_some_and(|row| !Self::stored_row_is_representable(row)))
12740 {
12741 return Err(invalid());
12742 }
12743 }
12744 let results: Vec<CommitOperationOutcome> =
12745 serde_json::from_value(Value::Array(stored)).map_err(|_| invalid())?;
12746 let has_conflict = results
12747 .iter()
12748 .any(|result| matches!(result, CommitOperationOutcome::Conflict { .. }));
12749 let has_error = results
12750 .iter()
12751 .any(|result| matches!(result, CommitOperationOutcome::Error { .. }));
12752 let status = Self::parse_outcome_status(&status).map_err(|_| invalid())?;
12753 let redacted = results.is_empty() && operations_json.is_none();
12754 if match status {
12755 CommitOutcomeStatus::Applied | CommitOutcomeStatus::Cached => has_conflict || has_error,
12756 CommitOutcomeStatus::Conflict => !(has_conflict || redacted),
12757 CommitOutcomeStatus::Rejected => has_conflict || !(has_error || redacted),
12758 } {
12759 return Err(invalid());
12760 }
12761 let operations: Option<Vec<CommitOperation>> = operations_json
12762 .map(|raw| {
12763 let stored: Vec<Value> = serde_json::from_str(&raw).map_err(|_| invalid())?;
12764 if !stored.iter().all(Self::stored_operation_is_representable) {
12765 return Err(invalid());
12766 }
12767 serde_json::from_value(Value::Array(stored)).map_err(|_| invalid())
12768 })
12769 .transpose()?;
12770 for result in &results {
12771 match result {
12772 CommitOperationOutcome::Conflict { conflict }
12773 if conflict.server_version.unsigned_abs() > MAX_JS_SAFE_INTEGER =>
12774 {
12775 return Err("sync.local_corrupt: persisted commit outcome is invalid".to_owned())
12776 }
12777 CommitOperationOutcome::Error { rejection } => {
12778 if let Some(details) = &rejection.details {
12779 let encoded = serde_json::to_string(details).map_err(|_| {
12780 "sync.local_corrupt: persisted commit outcome is invalid".to_owned()
12781 })?;
12782 RejectionDetails::parse(&encoded).map_err(|_| {
12783 "sync.local_corrupt: persisted commit outcome is invalid".to_owned()
12784 })?;
12785 }
12786 }
12787 _ => {}
12788 }
12789 }
12790 Ok(CommitOutcome {
12791 sequence,
12792 client_commit_id,
12793 status,
12794 recorded_at_ms,
12795 results,
12796 operations,
12797 retained_rows: None,
12798 resolution: Self::parse_outcome_resolution(&resolution)?,
12799 resolved_at_ms,
12800 replacement_client_commit_id,
12801 })
12802 }
12803
12804 fn unique_conflicts(
12806 &self,
12807 table: &TableSchema,
12808 values: &Map<String, Value>,
12809 base: bool,
12810 ) -> Result<Vec<RetainedUniqueConflict>, String> {
12811 let source = if base {
12812 base_table(&table.name)
12813 } else {
12814 visible_table(&table.name)
12815 };
12816 let mut conflicts = Vec::new();
12817 for index in table.indexes.iter().filter(|index| index.unique) {
12818 let mut params = vec![json_param_to_sql(
12819 values.get(&table.primary_key).ok_or("sync.local_corrupt")?,
12820 )?];
12821 for name in &index.columns {
12822 let column = table
12823 .columns
12824 .iter()
12825 .find(|column| column.name == *name)
12826 .ok_or("sync.local_corrupt")?;
12827 let cell = json_to_column_value(column, values.get(name))?;
12828 params.push(owned_sql_value(RowParam::Cell(&cell))?);
12829 }
12830 let sql = format!(
12831 "SELECT * FROM {source} WHERE {} != ? AND {}",
12832 quote_ident(&table.primary_key),
12833 index
12834 .columns
12835 .iter()
12836 .map(|column| format!("{} = ?", quote_ident(column)))
12837 .collect::<Vec<_>>()
12838 .join(" AND ")
12839 );
12840 let mut statement = self
12841 .conn
12842 .prepare_cached(&sql)
12843 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
12844 let records = statement
12845 .query_map(rusqlite::params_from_iter(params), |record| {
12846 let values = table
12847 .columns
12848 .iter()
12849 .enumerate()
12850 .map(|(index, column)| {
12851 let value = sql_ref_to_json(column, record.get_ref(index)?);
12852 json_to_column_value(column, Some(&value)).map_err(|message| {
12853 rusqlite::Error::ToSqlConversionFailure(message.into())
12854 })
12855 })
12856 .collect::<Result<Row, _>>()?;
12857 Ok((values, record.get::<_, i64>(table.columns.len())?))
12858 })
12859 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
12860 for record in records {
12861 let (row, version) =
12862 record.map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
12863 let row_id = render_row_id_json(Some(&column_value_to_json(&row[table.pk_index])))?;
12864 conflicts.push(RetainedUniqueConflict {
12865 index: index.name.clone(),
12866 columns: index.columns.clone(),
12867 row_id,
12868 server_row: table
12869 .columns
12870 .iter()
12871 .zip(&row)
12872 .map(|(column, value)| (column.name.clone(), column_value_to_json(value)))
12873 .collect(),
12874 server_version: version,
12875 });
12876 }
12877 }
12878 Ok(conflicts)
12879 }
12880
12881 fn attach_retained_rows(&self, mut outcome: CommitOutcome) -> Result<CommitOutcome, String> {
12882 if let Some((commit, initial)) = self
12883 .failed_commits
12884 .iter()
12885 .find(|(commit, _)| commit.client_commit_id == outcome.client_commit_id)
12886 {
12887 let mut rows = Vec::new();
12888 for operation in &commit.ops {
12889 let table = self
12890 .schema
12891 .table(&operation.table)
12892 .ok_or_else(|| "sync.unknown_table".to_owned())?;
12893 let key =
12894 serde_json::to_string(&(operation.table.clone(), operation.row_id.clone()))
12895 .map_err(|_| "sync.local_corrupt".to_owned())?;
12896 let base = self.stored_row(table, &operation.row_id, true)?;
12897 rows.push(RetainedCommitRow {
12898 table: operation.table.clone(),
12899 row_id: operation.row_id.clone(),
12900 unique_conflicts: if operation.upsert {
12901 self.unique_conflicts(
12902 table,
12903 initial
12904 .get(&key)
12905 .and_then(Value::as_object)
12906 .or(operation.values.as_ref())
12907 .ok_or("sync.local_corrupt")?,
12908 true,
12909 )?
12910 } else {
12911 Vec::new()
12912 },
12913 local_row: if operation.upsert {
12914 initial
12915 .get(&key)
12916 .and_then(Value::as_object)
12917 .cloned()
12918 .or_else(|| operation.values.clone())
12919 } else {
12920 None
12921 },
12922 server_row: base.as_ref().map(|(values, _)| {
12923 table
12924 .columns
12925 .iter()
12926 .zip(values)
12927 .map(|(column, value)| {
12928 (column.name.clone(), column_value_to_json(value))
12929 })
12930 .collect()
12931 }),
12932 server_version: base.map(|(_, version)| version),
12933 });
12934 }
12935 outcome.retained_rows = Some(rows);
12936 }
12937 Ok(outcome)
12938 }
12939
12940 pub fn commit_outcome(&self, client_commit_id: &str) -> Result<Option<CommitOutcome>, String> {
12941 let row = self
12942 .conn
12943 .query_row(
12944 "SELECT seq, client_commit_id, status, recorded_at_ms, results_json, operations_json,
12945 resolution, resolved_at_ms, replacement_client_commit_id
12946 FROM _syncular_commit_outcomes WHERE client_commit_id = ?1",
12947 rusqlite::params![client_commit_id],
12948 |row| {
12949 Ok(StoredCommitOutcomeRow {
12950 sequence: row.get(0)?,
12951 client_commit_id: row.get(1)?,
12952 status: row.get(2)?,
12953 recorded_at_ms: row.get(3)?,
12954 results_json: row.get(4)?,
12955 operations_json: row.get(5)?,
12956 resolution: row.get(6)?,
12957 resolved_at_ms: row.get(7)?,
12958 replacement_client_commit_id: row.get(8)?,
12959 })
12960 },
12961 )
12962 .optional()
12963 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
12964 row.map(|row| self.attach_retained_rows(Self::outcome_from_row(row)?))
12965 .transpose()
12966 }
12967
12968 pub fn commit_outcomes(&self, query: CommitOutcomeQuery) -> Result<Vec<CommitOutcome>, String> {
12969 if query.limit == Some(0) {
12970 return Err("sync.invalid_request: commit outcome limit must be positive".to_owned());
12971 }
12972 let mut sql = String::from(
12973 "SELECT seq, client_commit_id, status, recorded_at_ms, results_json, operations_json,
12974 resolution, resolved_at_ms, replacement_client_commit_id
12975 FROM _syncular_commit_outcomes",
12976 );
12977 if query.active_only {
12978 sql.push_str(" WHERE resolution = 'active' AND status IN ('conflict', 'rejected')");
12979 }
12980 sql.push_str(" ORDER BY seq DESC");
12981 if let Some(limit) = query.limit {
12982 sql.push_str(&format!(" LIMIT {limit}"));
12983 }
12984 let mut stmt = self
12985 .conn
12986 .prepare(&sql)
12987 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
12988 let rows = stmt
12989 .query_map([], |row| {
12990 Ok(StoredCommitOutcomeRow {
12991 sequence: row.get(0)?,
12992 client_commit_id: row.get(1)?,
12993 status: row.get(2)?,
12994 recorded_at_ms: row.get(3)?,
12995 results_json: row.get(4)?,
12996 operations_json: row.get(5)?,
12997 resolution: row.get(6)?,
12998 resolved_at_ms: row.get(7)?,
12999 replacement_client_commit_id: row.get(8)?,
13000 })
13001 })
13002 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13003 let mut outcomes = Vec::new();
13004 for row in rows {
13005 outcomes.push(self.attach_retained_rows(Self::outcome_from_row(
13006 row.map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?,
13007 )?)?);
13008 }
13009 Ok(outcomes)
13010 }
13011
13012 fn prune_commit_outcomes(&self) -> Result<(), String> {
13013 #[cfg(test)]
13014 self.outcome_prune_count
13015 .set(self.outcome_prune_count.get() + 1);
13016 let max_entries = self.limits.outcome_retention_max_entries.unwrap_or(1_000);
13017 let count = self
13018 .conn
13019 .query_row(
13020 "SELECT COUNT(*) FROM _syncular_commit_outcomes",
13021 [],
13022 |row| row.get::<_, i64>(0),
13023 )
13024 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?
13025 as usize;
13026 let excess = count.saturating_sub(max_entries);
13027 if excess == 0 {
13028 return Ok(());
13029 }
13030 let mut stmt = self
13031 .conn
13032 .prepare(
13033 "SELECT seq FROM _syncular_commit_outcomes
13034 WHERE status IN ('applied', 'cached') OR resolution != 'active'
13035 ORDER BY seq ASC LIMIT ?1",
13036 )
13037 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13038 let rows = stmt
13039 .query_map(rusqlite::params![excess as i64], |row| row.get::<_, i64>(0))
13040 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13041 let sequences = rows
13042 .collect::<Result<Vec<_>, _>>()
13043 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13044 drop(stmt);
13045 for sequence in sequences {
13046 self.conn
13047 .execute(
13048 "DELETE FROM _syncular_commit_outcomes WHERE seq = ?1",
13049 rusqlite::params![sequence],
13050 )
13051 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13052 }
13053 Ok(())
13054 }
13055
13056 pub fn subscribe(
13059 &mut self,
13060 id: String,
13061 table: String,
13062 scopes: Vec<(String, Vec<String>)>,
13063 params: Option<String>,
13064 ) -> Result<(), String> {
13065 if self.schema.table(&table).is_none() {
13066 return Err(format!("unknown table {table:?}"));
13067 }
13068 if let Some(existing) = self.subs.iter().find(|subscription| subscription.id == id) {
13069 let same_intent = existing.table == table
13070 && canonical_scope_json(&existing.requested) == canonical_scope_json(&scopes)
13071 && existing.params == params;
13072 if same_intent {
13073 return Ok(());
13074 }
13075 return Err(
13076 "client.subscription_intent_mismatch: the subscription id is already registered for a different table, scopes, or params"
13077 .to_owned(),
13078 );
13079 }
13080 let sub = Subscription {
13081 id: id.clone(),
13082 table,
13083 requested: scopes,
13084 params,
13085 cursor: -1,
13086 bootstrap_state: None,
13087 state: SubState::Active,
13088 reason_code: None,
13089 effective: None,
13090 synced_once: false,
13091 };
13092 self.persist_sub(&sub)?;
13093 self.subs.push(sub);
13094 Ok(())
13095 }
13096
13097 pub fn unsubscribe(&mut self, id: &str) {
13098 if self.subs.iter().any(|s| s.id == id) {
13099 self.cancel_sync_round();
13100 }
13101 self.subs.retain(|s| s.id != id);
13102 let _ = self.conn.execute(
13103 "DELETE FROM _syncular_subscriptions WHERE id = ?1",
13104 rusqlite::params![id],
13105 );
13106 }
13107
13108 pub fn set_window(
13116 &mut self,
13117 base: &WindowBase,
13118 units: &[String],
13119 ) -> Result<CommandEffects, String> {
13120 let table = self
13121 .schema
13122 .table(&base.table)
13123 .ok_or_else(|| format!("unknown table {:?}", base.table))?;
13124 if table.scope_column(&base.variable).is_none() {
13125 return Err(format!(
13126 "setWindow: table {:?} has no scope variable {:?} (§4.8)",
13127 base.table, base.variable
13128 ));
13129 }
13130 let base_key = window_base_key(base);
13131 let wanted: std::collections::HashSet<&String> = units.iter().collect();
13132 let live = self.load_window_units(&base_key);
13133 self.begin_observation("syncular_window")?;
13134 let mut batch = ChangeAccumulator::default();
13135 let mut changed = false;
13136
13137 let prior_subs = self.subs.clone();
13138 let widened = (|| {
13139 for unit in units {
13141 if live.iter().any(|(u, _)| u == unit) {
13142 continue;
13143 }
13144 let sub_id = derive_sub_id(base, unit);
13145 self.delete_pending_evict(&sub_id)?;
13146 self.insert_window_unit(&base_key, unit, &sub_id)?;
13147 self.subscribe(
13148 sub_id,
13149 base.table.clone(),
13150 unit_scopes(base, unit),
13151 base.params.clone(),
13152 )?;
13153 batch.window(&base_key, &base.table, unit);
13154 changed = true;
13155 }
13156
13157 self.finish_observation("syncular_window", batch)
13158 })();
13159 if let Err(error) = widened {
13160 self.rollback_observation("syncular_window");
13161 self.subs = prior_subs;
13162 return Err(error);
13163 }
13164 for (unit, sub_id) in live {
13165 if wanted.contains(&unit) {
13166 continue;
13167 }
13168 self.rebuild_overlay_if_dirty()?;
13169 let prior_subs = self.subs.clone();
13170 self.begin_observation("syncular_window")?;
13171 let effective = self
13172 .subs
13173 .iter()
13174 .find(|sub| sub.id == sub_id)
13175 .and_then(|sub| sub.effective.clone())
13176 .unwrap_or_else(|| unit_scopes(base, &unit));
13177 let mut batch = ChangeAccumulator::default();
13178 self.record_scope_map(&mut batch, &base.table, &effective);
13179 batch.window(&base_key, &base.table, &unit);
13180 let result = self.evict_unit(&base_key, base, &unit, &sub_id);
13181 let remaining = match result {
13182 Ok(remaining) => remaining,
13183 Err(error) => {
13184 self.rollback_observation("syncular_window");
13185 self.subs = prior_subs;
13186 return Err(error);
13187 }
13188 };
13189 if let Err(error) = self.finish_observation("syncular_window", batch) {
13190 self.rollback_observation("syncular_window");
13191 self.subs = prior_subs;
13192 return Err(error);
13193 }
13194 if remaining {
13195 self.drain_pending_evictions()?;
13196 }
13197 changed = true;
13198 }
13199 Ok(if changed {
13200 CommandEffects::interactive()
13201 } else {
13202 CommandEffects::none()
13203 })
13204 }
13205
13206 pub fn window_state(&self, base: &WindowBase) -> WindowState {
13211 let mut units = Vec::new();
13212 let mut pending = Vec::new();
13213 for (unit, sub_id) in self.load_window_units(&window_base_key(base)) {
13214 let is_pending = match self.subs.iter().find(|s| s.id == sub_id) {
13215 Some(sub) => {
13216 sub.state != SubState::Active || sub.cursor < 0 || sub.bootstrap_state.is_some()
13217 }
13218 None => true,
13219 };
13220 if is_pending {
13221 pending.push(unit.clone());
13222 }
13223 units.push(unit);
13224 }
13225 WindowState { units, pending }
13226 }
13227
13228 fn load_window_units(&self, base_key: &str) -> Vec<(String, String)> {
13229 let mut stmt = match self
13230 .conn
13231 .prepare("SELECT unit, sub_id FROM _syncular_windows WHERE base = ?1 ORDER BY unit ASC")
13232 {
13233 Ok(stmt) => stmt,
13234 Err(_) => return Vec::new(),
13235 };
13236 let rows = stmt.query_map(rusqlite::params![base_key], |row| {
13237 Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
13238 });
13239 match rows {
13240 Ok(rows) => rows.filter_map(Result::ok).collect(),
13241 Err(_) => Vec::new(),
13242 }
13243 }
13244
13245 fn load_registered_window_units(&self) -> Vec<(String, String, String)> {
13246 let mut stmt = match self.conn.prepare(
13247 "SELECT windows.base, windows.unit, subscriptions.tbl
13248 FROM _syncular_windows AS windows
13249 JOIN _syncular_subscriptions AS subscriptions
13250 ON subscriptions.id = windows.sub_id
13251 ORDER BY windows.base, windows.unit",
13252 ) {
13253 Ok(stmt) => stmt,
13254 Err(_) => return Vec::new(),
13255 };
13256 let rows = stmt.query_map([], |row| {
13257 Ok((
13258 row.get::<_, String>(0)?,
13259 row.get::<_, String>(1)?,
13260 row.get::<_, String>(2)?,
13261 ))
13262 });
13263 match rows {
13264 Ok(rows) => rows.filter_map(Result::ok).collect(),
13265 Err(_) => Vec::new(),
13266 }
13267 }
13268
13269 fn window_unit_by_sub_id(&self, sub_id: &str) -> Option<(String, String)> {
13270 self.conn
13271 .query_row(
13272 "SELECT base, unit FROM _syncular_windows WHERE sub_id = ?1 LIMIT 1",
13273 rusqlite::params![sub_id],
13274 |row| Ok((row.get(0)?, row.get(1)?)),
13275 )
13276 .ok()
13277 }
13278
13279 fn insert_window_unit(&self, base_key: &str, unit: &str, sub_id: &str) -> Result<(), String> {
13280 self.conn
13281 .execute(
13282 "INSERT OR REPLACE INTO _syncular_windows(base, unit, sub_id) VALUES (?1, ?2, ?3)",
13283 rusqlite::params![base_key, unit, sub_id],
13284 )
13285 .map(|_| ())
13286 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
13287 }
13288
13289 fn delete_window_unit(&self, base_key: &str, unit: &str) -> Result<(), String> {
13290 self.conn
13291 .execute(
13292 "DELETE FROM _syncular_windows WHERE base = ?1 AND unit = ?2",
13293 rusqlite::params![base_key, unit],
13294 )
13295 .map(|_| ())
13296 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
13297 }
13298
13299 fn evict_unit(
13306 &mut self,
13307 base_key: &str,
13308 base: &WindowBase,
13309 unit: &str,
13310 sub_id: &str,
13311 ) -> Result<bool, String> {
13312 let effective = self
13313 .subs
13314 .iter()
13315 .find(|s| s.id == sub_id)
13316 .and_then(|s| s.effective.clone())
13317 .unwrap_or_else(|| unit_scopes(base, unit));
13318 let pinned = self.pinned_row_ids(&base.table);
13319 let (remaining, deferred) = self.evict_scope_rows(&base.table, &effective, &pinned)?;
13320 self.delete_window_unit(base_key, unit)?;
13321 self.conn
13322 .execute(
13323 "DELETE FROM _syncular_subscriptions WHERE id = ?1",
13324 [sub_id],
13325 )
13326 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13327 self.subs.retain(|sub| sub.id != sub_id);
13328 if deferred {
13329 self.save_pending_evict(sub_id, &base.table, &effective)?;
13330 } else {
13331 self.delete_pending_evict(sub_id)?;
13332 }
13333 Ok(remaining)
13334 }
13335
13336 fn evict_scope_rows(
13340 &self,
13341 table_name: &str,
13342 effective: &[(String, Vec<String>)],
13343 pinned: &std::collections::HashSet<String>,
13344 ) -> Result<(bool, bool), String> {
13345 if effective.is_empty() {
13346 return Ok((false, false));
13347 }
13348 let table = self.schema.table(table_name).ok_or("sync.unknown_table")?;
13349 let mut clauses = Vec::new();
13350 let mut params: Vec<SqlValue> = Vec::new();
13351 for (variable, values) in effective {
13352 let column = table.scope_column(variable).ok_or("sync.scope_revoked")?;
13353 if values.is_empty() {
13354 return Ok((false, false));
13355 }
13356 let holes = values
13357 .iter()
13358 .map(|v| {
13359 params.push(SqlValue::Text(v.clone()));
13360 "?"
13361 })
13362 .collect::<Vec<_>>()
13363 .join(", ");
13364 clauses.push(format!("{} IN ({holes})", quote_ident(column)));
13365 }
13366 let scope = clauses.join(" AND ");
13367 let scope_params = params.len();
13368 let pk = quote_ident(&table.primary_key);
13369 if !pinned.is_empty() {
13370 let holes = pinned
13371 .iter()
13372 .map(|id| {
13373 params.push(SqlValue::Text(id.clone()));
13374 "?"
13375 })
13376 .collect::<Vec<_>>()
13377 .join(", ");
13378 clauses.push(format!("{pk} NOT IN ({holes})"));
13379 }
13380 let predicate = clauses.join(" AND ");
13381 let base = base_table(table_name);
13382 let visible = visible_table(table_name);
13383 let mut ids: Vec<SqlValue> = Vec::new();
13384 for source in [&base, &visible] {
13385 let mut stmt = self
13386 .conn
13387 .prepare_cached(&format!(
13388 "SELECT {pk} FROM {source} WHERE {predicate} LIMIT 1024"
13389 ))
13390 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13391 ids = stmt
13392 .query_map(rusqlite::params_from_iter(¶ms), |row| row.get(0))
13393 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?
13394 .collect::<Result<_, _>>()
13395 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13396 if !ids.is_empty() {
13397 break;
13398 }
13399 }
13400 if !ids.is_empty() {
13401 for id in &ids {
13402 self.conn
13403 .execute(
13404 "DELETE FROM _syncular_acked_rows WHERE tbl=?1 AND id=?2",
13405 rusqlite::params![table_name, id],
13406 )
13407 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13408 }
13409 let holes = vec!["?"; ids.len()].join(", ");
13410 for source in [&base, &visible] {
13411 self.conn
13412 .execute(
13413 &format!("DELETE FROM {source} WHERE {pk} IN ({holes})"),
13414 rusqlite::params_from_iter(&ids),
13415 )
13416 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13417 }
13418 }
13419 let mut remaining = false;
13420 let mut deferred = false;
13421 for source in [&base, &visible] {
13422 remaining |= self
13423 .conn
13424 .query_row(
13425 &format!("SELECT EXISTS(SELECT 1 FROM {source} WHERE {predicate})"),
13426 rusqlite::params_from_iter(¶ms),
13427 |row| row.get::<_, bool>(0),
13428 )
13429 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13430 deferred |= self
13431 .conn
13432 .query_row(
13433 &format!("SELECT EXISTS(SELECT 1 FROM {source} WHERE {scope})"),
13434 rusqlite::params_from_iter(¶ms[..scope_params]),
13435 |row| row.get::<_, bool>(0),
13436 )
13437 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13438 }
13439 Ok((remaining, deferred))
13440 }
13441
13442 fn drain_pending_evictions(&mut self) -> Result<(), String> {
13444 let pending = self.load_pending_evictions()?;
13445 if pending.is_empty() {
13446 return Ok(());
13447 }
13448 self.rebuild_overlay_if_dirty()?;
13449 for (sub_id, table_name, effective) in pending {
13450 if self.schema.table(&table_name).is_none() {
13451 self.delete_pending_evict(&sub_id)?;
13452 continue;
13453 }
13454 loop {
13455 std::thread::yield_now();
13456 self.begin_observation("syncular_evict")?;
13457 let pinned = self.pinned_row_ids(&table_name);
13458 let result = self.evict_scope_rows(&table_name, &effective, &pinned);
13459 let (remaining, deferred) = match result {
13460 Ok(result) => result,
13461 Err(error) => {
13462 self.rollback_observation("syncular_evict");
13463 return Err(error);
13464 }
13465 };
13466 if !deferred {
13467 if let Err(error) = self.delete_pending_evict(&sub_id) {
13468 self.rollback_observation("syncular_evict");
13469 return Err(error);
13470 }
13471 }
13472 let mut batch = ChangeAccumulator::default();
13473 self.record_scope_map(&mut batch, &table_name, &effective);
13474 if let Err(error) = self.finish_observation("syncular_evict", batch) {
13475 self.rollback_observation("syncular_evict");
13476 return Err(error);
13477 }
13478 if !remaining {
13479 break;
13480 }
13481 }
13482 }
13483 Ok(())
13484 }
13485
13486 fn pinned_row_ids(&self, table: &str) -> std::collections::HashSet<String> {
13489 let mut pinned = std::collections::HashSet::new();
13490 for commit in self
13491 .outbox
13492 .iter()
13493 .chain(self.failed_commits.iter().map(|(commit, _)| commit))
13494 {
13495 for op in &commit.ops {
13496 if op.table == table {
13497 pinned.insert(op.row_id.clone());
13498 }
13499 }
13500 }
13501 pinned
13502 }
13503
13504 fn save_pending_evict(
13505 &self,
13506 sub_id: &str,
13507 table: &str,
13508 effective: &[(String, Vec<String>)],
13509 ) -> Result<(), String> {
13510 self.conn.execute(
13511 "INSERT OR REPLACE INTO _syncular_window_pending_evict(sub_id, tbl, effective_scopes) VALUES (?1, ?2, ?3)",
13512 rusqlite::params![sub_id, table, scope_map_to_json(effective).to_string()],
13513 ).map(|_| ()).map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
13514 }
13515
13516 fn delete_pending_evict(&self, sub_id: &str) -> Result<(), String> {
13517 self.conn
13518 .execute(
13519 "DELETE FROM _syncular_window_pending_evict WHERE sub_id = ?1",
13520 rusqlite::params![sub_id],
13521 )
13522 .map(|_| ())
13523 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
13524 }
13525
13526 fn load_pending_evictions(&self) -> Result<Vec<PendingEvict>, String> {
13527 let mut stmt = self
13528 .conn
13529 .prepare("SELECT sub_id, tbl, effective_scopes FROM _syncular_window_pending_evict")
13530 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13531 let rows = stmt
13532 .query_map([], |row| {
13533 Ok((
13534 row.get::<_, String>(0)?,
13535 row.get::<_, String>(1)?,
13536 row.get::<_, String>(2)?,
13537 ))
13538 })
13539 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13540 rows.map(|row| {
13541 let (sub_id, table, json) =
13542 row.map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13543 let value: Value = serde_json::from_str(&json)
13544 .map_err(|_| "sync.local_corrupt: invalid pending eviction scopes".to_owned())?;
13545 let effective = json_to_scope_map(&value)
13546 .map_err(|_| "sync.local_corrupt: invalid pending eviction scopes".to_owned())?;
13547 Ok((sub_id, table, effective))
13548 })
13549 .collect()
13550 }
13551
13552 pub fn set_retain_failed_commits(&mut self, enabled: bool) -> Result<(), String> {
13554 self.retain_failed_commits = enabled;
13555 for (commit, _) in &self.failed_commits {
13556 for operation in &commit.ops {
13557 self.overlay_dirty.table(&operation.table);
13558 }
13559 }
13560 self.failed_commits = self.load_failed_commits()?;
13561 for (commit, _) in &self.failed_commits {
13562 for operation in &commit.ops {
13563 self.overlay_dirty.table(&operation.table);
13564 }
13565 }
13566 Ok(())
13567 }
13568
13569 fn load_failed_commits(&self) -> Result<Vec<FailedCommit>, String> {
13570 let mut statement = self.conn.prepare("SELECT client_commit_id, operations_json, initial_json FROM _syncular_failed_commits ORDER BY seq")
13571 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13572 let rows = statement
13573 .query_map([], |row| {
13574 Ok((
13575 row.get::<_, String>(0)?,
13576 row.get::<_, String>(1)?,
13577 row.get::<_, String>(2)?,
13578 ))
13579 })
13580 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13581 rows.map(|row| {
13582 let (id, operations, initial) =
13583 row.map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13584 let operations: Vec<CommitOperation> =
13585 serde_json::from_str(&operations).map_err(|_| "sync.local_corrupt".to_owned())?;
13586 let initial =
13587 serde_json::from_str(&initial).map_err(|_| "sync.local_corrupt".to_owned())?;
13588 Ok((
13589 OutboxCommit {
13590 client_commit_id: id,
13591 ops: operations
13592 .into_iter()
13593 .map(|op| OutboxOp {
13594 upsert: op.op == "upsert",
13595 table: op.table,
13596 row_id: op.row_id,
13597 base_version: op.base_version,
13598 values: op.values,
13599 })
13600 .collect(),
13601 },
13602 initial,
13603 ))
13604 })
13605 .collect()
13606 }
13607
13608 fn retain_failed_commit(&mut self, id: &str, operations: &[OutboxOp]) -> Result<(), String> {
13609 let mut initial = Map::new();
13610 for operation in operations {
13611 let table = self
13612 .schema
13613 .table(&operation.table)
13614 .ok_or_else(|| "sync.unknown_table".to_owned())?;
13615 let initial_row = match self.stored_row(table, &operation.row_id, true)? {
13616 Some(row) => Some(row),
13617 None => self.visible_row(table, &operation.row_id)?,
13618 };
13619 if let Some((values, _)) = initial_row {
13620 let mut intended: Map<String, Value> = table
13621 .columns
13622 .iter()
13623 .zip(values.iter())
13624 .map(|(column, value)| (column.name.clone(), column_value_to_json(value)))
13625 .collect();
13626 if let Some(patch) = &operation.values {
13627 intended.extend(patch.clone());
13628 }
13629 initial.insert(
13630 serde_json::to_string(&(operation.table.clone(), operation.row_id.clone()))
13631 .map_err(|_| "sync.local_corrupt".to_owned())?,
13632 Value::Object(intended),
13633 );
13634 }
13635 }
13636 self.conn.execute("INSERT INTO _syncular_failed_commits(client_commit_id, operations_json, initial_json) VALUES (?1, ?2, ?3)", rusqlite::params![id,
13637 serde_json::to_string(&operations.iter().map(CommitOperation::from).collect::<Vec<_>>()).map_err(|_| "sync.local_corrupt".to_owned())?,
13638 serde_json::to_string(&initial).map_err(|_| "sync.local_corrupt".to_owned())?])
13639 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13640 self.failed_commits.push((
13641 OutboxCommit {
13642 client_commit_id: id.to_owned(),
13643 ops: operations.to_vec(),
13644 },
13645 initial,
13646 ));
13647 Ok(())
13648 }
13649
13650 fn drop_failed_commits(
13651 &mut self,
13652 matches: impl Fn(&str, &Map<String, Value>) -> bool,
13653 ) -> Result<Vec<OutboxCommit>, String> {
13654 let mut doomed = self.drop_acknowledged_rows(&matches)?;
13655 for (commit, initial) in &self.failed_commits {
13656 for operation in &commit.ops {
13657 let key =
13658 serde_json::to_string(&(operation.table.clone(), operation.row_id.clone()))
13659 .map_err(|_| "sync.local_corrupt".to_owned())?;
13660 if initial
13661 .get(&key)
13662 .and_then(Value::as_object)
13663 .is_some_and(|values| matches(&operation.table, values))
13664 {
13665 doomed.push(commit.clone());
13666 break;
13667 }
13668 }
13669 }
13670 for commit in &doomed {
13671 self.conn
13672 .execute(
13673 "DELETE FROM _syncular_failed_commits WHERE client_commit_id = ?1",
13674 rusqlite::params![commit.client_commit_id],
13675 )
13676 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13677 }
13678 for commit in &doomed {
13679 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))?;
13680 }
13681 self.conflicts.retain(|record| {
13682 !doomed
13683 .iter()
13684 .any(|commit| commit.client_commit_id == record.client_commit_id)
13685 });
13686 self.rejections.retain(|record| {
13687 !doomed
13688 .iter()
13689 .any(|commit| commit.client_commit_id == record.client_commit_id)
13690 });
13691 self.failed_commits.retain(|(commit, _)| {
13692 !doomed
13693 .iter()
13694 .any(|dropped| dropped.client_commit_id == commit.client_commit_id)
13695 });
13696 for commit in &doomed {
13697 for operation in &commit.ops {
13698 self.overlay_dirty.table(&operation.table);
13699 }
13700 }
13701 Ok(doomed)
13702 }
13703
13704 pub fn mutate(&mut self, mutations: Vec<Mutation>) -> Result<String, ClientError> {
13708 let prior = self.storage_failure.borrow_mut().take();
13709 let outcome = (|| -> Result<String, String> {
13710 if mutations.is_empty() {
13711 return Err(
13712 "sync.invalid_request: a commit must contain at least one operation (§6.1)"
13713 .to_owned(),
13714 );
13715 }
13716 let mut ops = Vec::with_capacity(mutations.len());
13717 for mutation in mutations {
13718 match mutation {
13719 Mutation::Patch {
13720 table,
13721 values,
13722 base_version,
13723 } => {
13724 let schema_table = self
13725 .schema
13726 .table(&table)
13727 .ok_or_else(|| "sync.unknown_table".to_owned())?;
13728 let values = normalize_values_casing(schema_table, values)?;
13729 let row_id = render_row_id_json(values.get(&schema_table.primary_key))?;
13730 ops.push(self.prepare_patch(&table, &row_id, values, base_version)?);
13731 }
13732 Mutation::Upsert {
13733 table,
13734 values,
13735 base_version,
13736 } => {
13737 let schema_table = self.schema.table(&table).ok_or_else(|| {
13738 format!("sync.unknown_table: unknown table {table:?}")
13739 })?;
13740 let values = normalize_values_casing(schema_table, values)?;
13744 let row_id = render_row_id_json(values.get(&schema_table.primary_key))?;
13745 let values = full_row_values(schema_table, values)?;
13748 self.validate_author_encode(schema_table, &row_id, &values)?;
13754 ops.push(OutboxOp {
13755 upsert: true,
13756 table,
13757 row_id,
13758 base_version,
13759 values: Some(values),
13760 });
13761 }
13762 Mutation::Delete {
13763 table,
13764 row_id,
13765 base_version,
13766 } => {
13767 if self.schema.table(&table).is_none() {
13768 return Err(format!("sync.unknown_table: unknown table {table:?}"));
13769 }
13770 ops.push(OutboxOp {
13771 upsert: false,
13772 table,
13773 row_id,
13774 base_version,
13775 values: None,
13776 });
13777 }
13778 }
13779 }
13780 self.record_outbox_commit(ops)
13781 })();
13782 self.finish_authoring(prior, outcome)
13783 }
13784
13785 fn finish_authoring(
13791 &self,
13792 prior: Option<QueryReadFailure>,
13793 outcome: Result<String, String>,
13794 ) -> Result<String, ClientError> {
13795 let fresh = self.storage_failure.borrow_mut().take();
13796 *self.storage_failure.borrow_mut() = fresh.clone().or(prior);
13797 match outcome {
13798 Ok(id) => Ok(id),
13799 Err(message) => Err(fresh
13800 .map(ClientError::from)
13801 .unwrap_or_else(|| ClientError::from(message))),
13802 }
13803 }
13804
13805 fn record_outbox_commit(&mut self, ops: Vec<OutboxOp>) -> Result<String, String> {
13806 let commit = OutboxCommit {
13807 client_commit_id: uuid::Uuid::new_v4().to_string(),
13808 ops,
13809 };
13810 self.begin_observation("syncular_mutation")?;
13811 let mut batch = ChangeAccumulator::default();
13812 for op in &commit.ops {
13813 let mut precise = self.record_row_scopes(&mut batch, &op.table, &op.row_id, false);
13814 if let Some(values) = &op.values {
13815 if let Some(table) = self.schema.table(&op.table) {
13816 for scope in &table.scope_variables {
13817 if let Some(Value::String(value)) = values.get(&scope.column) {
13818 batch.scope(&op.table, format!("{}:{value}", scope.prefix));
13819 precise = true;
13820 }
13821 }
13822 }
13823 }
13824 if !precise {
13825 batch.table(&op.table);
13826 }
13827 }
13828 if let Err(error) = self.persist_outbox_insert(&commit) {
13829 self.rollback_observation("syncular_mutation");
13830 return Err(error);
13831 }
13832 let was_dirty = self.overlay_dirty.snapshot();
13833 if let Err(error) = self.rebuild_overlay_if_dirty() {
13838 self.rollback_observation("syncular_mutation");
13839 self.overlay_dirty.restore(was_dirty.clone());
13840 return Err(error);
13841 }
13842 if let Some(error) = commit
13843 .ops
13844 .iter()
13845 .find_map(|op| self.apply_outbox_op(op).err())
13846 {
13847 self.rollback_observation("syncular_mutation");
13848 self.overlay_dirty.restore(was_dirty.clone());
13849 return Err(error);
13850 }
13851 let id = commit.client_commit_id.clone();
13852 self.outbox.push(commit);
13853 batch.status = true;
13854 if let Err(error) = self.finish_observation("syncular_mutation", batch) {
13855 self.rollback_observation("syncular_mutation");
13856 self.outbox.pop();
13857 self.overlay_dirty.restore(was_dirty.clone());
13858 return Err(error);
13859 }
13860 Ok(id)
13861 }
13862
13863 pub fn patch(
13869 &mut self,
13870 table: &str,
13871 row_id: &str,
13872 partial: Map<String, Value>,
13873 base_version: Option<i64>,
13874 ) -> Result<String, ClientError> {
13875 let prior = self.storage_failure.borrow_mut().take();
13876 let outcome = self.perform_patch(table, row_id, partial, base_version);
13877 self.finish_authoring(prior, outcome)
13878 }
13879
13880 fn perform_patch(
13883 &mut self,
13884 table: &str,
13885 row_id: &str,
13886 partial: Map<String, Value>,
13887 base_version: Option<i64>,
13888 ) -> Result<String, String> {
13889 let operation = self.prepare_patch(table, row_id, partial, base_version)?;
13890 self.record_outbox_commit(vec![operation])
13891 }
13892
13893 fn prepare_patch(
13894 &self,
13895 table: &str,
13896 row_id: &str,
13897 partial: Map<String, Value>,
13898 base_version: Option<i64>,
13899 ) -> Result<OutboxOp, String> {
13900 let schema_table = self
13901 .schema
13902 .table(table)
13903 .ok_or_else(|| format!("sync.unknown_table: unknown table {table:?}"))?;
13904 let partial = normalize_values_casing(schema_table, partial)?;
13905 if let Some(pk) = partial.get(&schema_table.primary_key) {
13906 if pk.as_str() != Some(row_id) {
13907 return Err(format!(
13908 "sync.invalid_request: table {table:?}: patch cannot change the primary key"
13909 ));
13910 }
13911 }
13912 let exists: bool = self
13915 .conn
13916 .query_row(
13917 &format!(
13918 "SELECT EXISTS(SELECT 1 FROM {} WHERE {})",
13919 visible_table(table),
13920 row_id_predicate(schema_table)
13921 ),
13922 rusqlite::params![row_id],
13923 |row| row.get(0),
13924 )
13925 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13926 if !exists {
13927 return Err("sync.row_missing: a sparse patch requires a local row".to_owned());
13928 }
13929 for scope in &schema_table.scope_variables {
13939 if !partial.contains_key(&scope.column) || scope.column == schema_table.primary_key {
13940 continue;
13941 }
13942 let Some(column) = schema_table
13943 .columns
13944 .iter()
13945 .find(|column| column.name == scope.column)
13946 else {
13947 continue;
13948 };
13949 let stored = self
13950 .conn
13951 .query_row(
13952 &format!(
13953 "SELECT {} FROM {} WHERE {}",
13954 quote_ident(&scope.column),
13955 visible_table(&schema_table.name),
13956 row_id_predicate(schema_table)
13957 ),
13958 rusqlite::params![row_id],
13959 |row| Ok(sql_ref_to_json(column, row.get_ref(0)?)),
13960 )
13961 .optional()
13962 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13963 let incoming = json_to_column_value(column, partial.get(&scope.column))
13968 .ok()
13969 .map(|value| column_value_to_json(&value));
13970 let equal = stored.as_ref().is_some_and(|stored| {
13974 stored.get("$bytes").is_none() && incoming.as_ref() == Some(stored)
13975 });
13976 if !equal {
13977 return Err(format!(
13978 "sync.invalid_request: table {table:?}: patch cannot write scope column {:?} (§3.4)",
13979 scope.column
13980 ));
13981 }
13982 }
13983 let mut values = partial;
13984 for scope in &schema_table.scope_variables {
13987 values.remove(&scope.column);
13988 }
13989 values.insert(
13990 schema_table.primary_key.clone(),
13991 Value::from(row_id.to_owned()),
13992 );
13993 self.validate_author_encode(schema_table, row_id, &values)?;
13999 Ok(OutboxOp {
14000 upsert: true,
14001 table: table.to_owned(),
14002 row_id: row_id.to_owned(),
14003 base_version,
14004 values: Some(values),
14005 })
14006 }
14007
14008 fn stored_key_fallback(&self, table: &TableSchema, row_id: &str) -> Option<Row> {
14013 let selector = self.encryption.key_id_columns.get(&table.name)?;
14014 let index = table
14015 .columns
14016 .iter()
14017 .position(|column| &column.name == selector)?;
14018 let sql = format!(
14019 "SELECT {} FROM {} WHERE {}",
14020 quote_ident(selector),
14021 visible_table(&table.name),
14022 row_id_predicate(table)
14023 );
14024 let key: Option<String> = self
14025 .conn
14026 .query_row(&sql, rusqlite::params![row_id], |row| row.get(0))
14027 .ok()?;
14028 let key = key.filter(|key| !key.is_empty())?;
14029 let mut fallback: Row = vec![None; table.columns.len()];
14030 fallback[index] = Some(ColumnValue::String(key));
14031 Some(fallback)
14032 }
14033
14034 fn validate_author_encode(
14039 &self,
14040 table: &TableSchema,
14041 row_id: &str,
14042 values: &Map<String, Value>,
14043 ) -> Result<(), String> {
14044 match encode_sparse_row_json(table, row_id, values, &self.encryption, None) {
14045 Err(error) if error.starts_with(ENCRYPT_FAILED_CODE) => Ok(()),
14046 result => result.map(|_| ()),
14047 }
14048 }
14049
14050 fn drop_unencodable_outbox(
14064 &mut self,
14065 with_encryption: bool,
14066 batch: Option<&mut ChangeAccumulator>,
14067 ) -> Result<bool, String> {
14068 let failures = self
14069 .outbox
14070 .iter()
14071 .filter_map(|commit| {
14072 commit
14073 .ops
14074 .iter()
14075 .find_map(|op| {
14076 let values = op.values.as_ref().filter(|_| op.upsert)?;
14077 let table = self.schema.table(&op.table)?;
14078 let fallback = self.stored_key_fallback(table, &op.row_id);
14079 match encode_sparse_row_json(
14080 table,
14081 &op.row_id,
14082 values,
14083 &self.encryption,
14084 fallback.as_ref(),
14085 ) {
14086 Err(error) if error.starts_with(ENCRYPT_FAILED_CODE) => {
14087 with_encryption.then_some((error, ENCRYPT_FAILED_CODE))
14088 }
14089 Err(error) if error.starts_with(INVALID_REQUEST_CODE) => Some((
14094 LEGACY_VALUE_REFUSAL_MESSAGE.to_owned(),
14095 OUTBOX_INCOMPATIBLE_CODE,
14096 )),
14097 _ => None,
14098 }
14099 })
14100 .map(|(error, code)| (commit.clone(), error, code))
14101 })
14102 .collect::<Vec<_>>();
14103 if failures.is_empty() {
14104 return Ok(false);
14105 }
14106 let prior_outbox = self.outbox.clone();
14112 let prior_rejections = self.rejections.clone();
14113 let prior_failed_commits = self.failed_commits.clone();
14117 let prior_changes = self.change_queue.len();
14118 let prior_intents = self.sync_intent_queue.len();
14119 let prior_last_change = self.last_change.clone();
14120 let prior_overlay_dirty = self.overlay_dirty.snapshot();
14121 let owns_observation = batch.is_none();
14122 let owns_transaction = self.conn.is_autocommit();
14123 let mut domains = ChangeAccumulator::default();
14124 if owns_observation {
14125 self.begin_observation("syncular_value_recovery")?;
14126 }
14127 let dropped = (|| -> Result<(), String> {
14128 for (commit, message, code) in failures {
14129 if self.retain_failed_commits && code == ENCRYPT_FAILED_CODE {
14130 self.retain_failed_commit(&commit.client_commit_id, &commit.ops)?;
14131 }
14132 let representable = commit.ops.iter().all(Self::outbox_op_is_journalable);
14133 let rejection = RejectionRecord {
14134 client_commit_id: commit.client_commit_id.clone(),
14135 op_index: 0,
14136 code: code.to_owned(),
14137 message,
14138 retryable: false,
14139 details: (code == OUTBOX_INCOMPATIBLE_CODE).then(|| RejectionDetails {
14140 field_paths: None,
14141 reason: Some(INVALID_STORED_VALUES_REASON.to_owned()),
14142 required_action: None,
14143 references: None,
14144 }),
14145 operation: representable
14146 .then(|| commit.ops.first())
14147 .flatten()
14148 .map(CommitOperation::from),
14149 };
14150 self.persist_commit_outcome(
14151 &commit.client_commit_id,
14152 CommitOutcomeStatus::Rejected,
14153 &[CommitOperationOutcome::Error {
14154 rejection: rejection.clone(),
14155 }],
14156 representable.then_some(commit.ops.as_slice()),
14157 )?;
14158 self.delete_outbox_persisted(&commit.client_commit_id)?;
14159 self.outbox
14160 .retain(|candidate| candidate.client_commit_id != commit.client_commit_id);
14161 self.rejections.push(rejection);
14162 for operation in &commit.ops {
14163 domains.table(operation.table.as_str());
14164 }
14165 }
14166 domains.status = true;
14167 domains.rejections = true;
14168 domains.outcomes = true;
14169 self.prune_commit_outcomes()?;
14170 self.overlay_dirty.set(true);
14172 self.rebuild_overlay()?;
14173 Ok(())
14174 })();
14175 let result = match batch {
14176 Some(batch) => {
14177 if dropped.is_ok() {
14178 for table in domains.tables.keys() {
14179 batch.table(table.as_str());
14180 }
14181 batch.status |= domains.status;
14182 batch.rejections |= domains.rejections;
14183 batch.outcomes |= domains.outcomes;
14184 }
14185 dropped
14186 }
14187 None => {
14188 dropped.and_then(|()| self.finish_observation("syncular_value_recovery", domains))
14189 }
14190 };
14191 if let Err(error) = result {
14192 if owns_observation {
14193 if owns_transaction {
14194 if let Err(rollback) = self.conn.execute_batch("ROLLBACK") {
14195 let mut failure = self.storage_failure.borrow_mut();
14196 let failure =
14197 failure.get_or_insert_with(|| QueryReadFailure::from(error.clone()));
14198 failure.rollback_failure = Some(Box::new(QueryReadFailure::from(rollback)));
14199 }
14200 } else {
14201 self.rollback_observation("syncular_value_recovery");
14202 }
14203 }
14204 self.outbox = prior_outbox;
14205 self.rejections = prior_rejections;
14206 self.failed_commits = prior_failed_commits;
14207 self.change_queue.truncate(prior_changes);
14208 self.sync_intent_queue.truncate(prior_intents);
14209 self.last_change = prior_last_change;
14210 self.overlay_dirty.restore(prior_overlay_dirty);
14211 return Err(error);
14212 }
14213 Ok(true)
14214 }
14215
14216 pub fn pending_commit_ids(&self) -> Vec<String> {
14217 self.outbox
14218 .iter()
14219 .map(|c| c.client_commit_id.clone())
14220 .collect()
14221 }
14222
14223 pub fn pending_payloads(&self) -> Vec<Vec<u8>> {
14227 let mut payloads = Vec::new();
14228 for commit in &self.outbox {
14229 for op in &commit.ops {
14230 let Some(values) = op.values.as_ref().filter(|_| op.upsert) else {
14231 continue;
14232 };
14233 let Some(table) = self.schema.table(&op.table) else {
14234 continue;
14235 };
14236 let fallback = self.stored_key_fallback(table, &op.row_id);
14237 if let Ok(payload) = encode_sparse_row_json(
14238 table,
14239 &op.row_id,
14240 values,
14241 &self.encryption,
14242 fallback.as_ref(),
14243 ) {
14244 payloads.push(payload);
14245 }
14246 }
14247 }
14248 payloads
14249 }
14250
14251 pub fn conflicts(&self) -> &[ConflictRecord] {
14252 &self.conflicts
14253 }
14254
14255 pub fn rejections(&self) -> &[RejectionRecord] {
14256 &self.rejections
14257 }
14258
14259 pub fn resolve_commit_outcome(
14260 &mut self,
14261 input: ResolveCommitOutcomeInput,
14262 ) -> Result<CommitOutcome, String> {
14263 let current = self
14264 .commit_outcome(&input.client_commit_id)?
14265 .ok_or_else(|| {
14266 format!(
14267 "sync.outcome_not_found: no durable outcome exists for {:?}",
14268 input.client_commit_id
14269 )
14270 })?;
14271 if current.resolution != CommitOutcomeResolution::Active {
14272 return Ok(current);
14273 }
14274 if input.resolution == CommitOutcomeResolution::Active {
14275 return Err("sync.invalid_request: resolution must leave active state".to_owned());
14276 }
14277 match input.resolution {
14278 CommitOutcomeResolution::Superseded => {
14279 let replacement = input
14280 .replacement_client_commit_id
14281 .as_deref()
14282 .filter(|value| !value.is_empty() && *value != input.client_commit_id)
14283 .ok_or_else(|| {
14284 "sync.invalid_request: superseded outcomes require a distinct replacementClientCommitId"
14285 .to_owned()
14286 })?;
14287 let _ = replacement;
14288 }
14289 _ if input.replacement_client_commit_id.is_some() => {
14290 return Err(
14291 "sync.invalid_request: replacementClientCommitId is valid only for superseded outcomes"
14292 .to_owned(),
14293 );
14294 }
14295 _ => {}
14296 }
14297 let allowed = match current.status {
14298 CommitOutcomeStatus::Conflict => matches!(
14299 input.resolution,
14300 CommitOutcomeResolution::ResolvedKeepServer | CommitOutcomeResolution::Superseded
14301 ),
14302 CommitOutcomeStatus::Rejected => {
14303 matches!(
14304 input.resolution,
14305 CommitOutcomeResolution::Superseded
14306 | CommitOutcomeResolution::ResolvedKeepServer
14307 )
14308 }
14309 CommitOutcomeStatus::Applied | CommitOutcomeStatus::Cached => {
14310 input.resolution == CommitOutcomeResolution::Dismissed
14311 }
14312 };
14313 if !allowed {
14314 return Err(format!(
14315 "sync.invalid_request: resolution {:?} is invalid for {:?} outcome",
14316 input.resolution, current.status
14317 ));
14318 }
14319
14320 self.begin_observation("syncular_outcome_resolution")?;
14321 let previous_failed = self.failed_commits.clone();
14322 let previous_conflicts = self.conflicts.clone();
14323 let previous_rejections = self.rejections.clone();
14324 let result = (|| {
14325 self.conn
14326 .execute(
14327 "UPDATE _syncular_commit_outcomes
14328 SET resolution = ?1, resolved_at_ms = ?2,
14329 replacement_client_commit_id = ?3
14330 WHERE client_commit_id = ?4 AND resolution = 'active'",
14331 rusqlite::params![
14332 Self::outcome_resolution_name(input.resolution),
14333 self.clock_now_ms(),
14334 input.replacement_client_commit_id,
14335 input.client_commit_id
14336 ],
14337 )
14338 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
14339 let resolved = self
14340 .commit_outcome(¤t.client_commit_id)?
14341 .ok_or_else(|| "sync.outcome_not_found: outcome disappeared".to_owned())?;
14342 self.conn
14343 .execute(
14344 "DELETE FROM _syncular_failed_commits WHERE client_commit_id = ?1",
14345 rusqlite::params![input.client_commit_id],
14346 )
14347 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
14348 for (commit, _) in &self.failed_commits {
14349 if commit.client_commit_id == input.client_commit_id {
14350 for operation in &commit.ops {
14351 self.overlay_dirty.table(&operation.table);
14352 }
14353 }
14354 }
14355 self.failed_commits
14356 .retain(|(commit, _)| commit.client_commit_id != input.client_commit_id);
14357 self.prune_commit_outcomes()?;
14358 Ok(resolved)
14359 })();
14360 let resolved = match result {
14361 Ok(outcome) => outcome,
14362 Err(error) => {
14363 self.rollback_observation("syncular_outcome_resolution");
14364 self.failed_commits = previous_failed;
14365 self.conflicts = previous_conflicts;
14366 self.rejections = previous_rejections;
14367 return Err(error);
14368 }
14369 };
14370 self.conflicts
14371 .retain(|record| record.client_commit_id != current.client_commit_id);
14372 self.rejections
14373 .retain(|record| record.client_commit_id != current.client_commit_id);
14374 let mut batch = ChangeAccumulator {
14375 conflicts: current.status == CommitOutcomeStatus::Conflict,
14376 rejections: current.status == CommitOutcomeStatus::Rejected,
14377 outcomes: true,
14378 ..ChangeAccumulator::default()
14379 };
14380 if let Some(operations) = ¤t.operations {
14381 for operation in operations {
14382 batch.table(&operation.table);
14383 }
14384 }
14385 if let Err(error) = self
14386 .rebuild_overlay_if_dirty()
14387 .and_then(|()| self.finish_observation("syncular_outcome_resolution", batch))
14388 {
14389 self.rollback_observation("syncular_outcome_resolution");
14390 self.failed_commits = previous_failed;
14391 self.conflicts = previous_conflicts;
14392 self.rejections = previous_rejections;
14393 return Err(error);
14394 }
14395 Ok(resolved)
14396 }
14397
14398 pub fn schema_floor(&self) -> Option<&SchemaFloor> {
14399 self.schema_floor.as_ref()
14400 }
14401
14402 pub fn lease_state(&self) -> Option<&LeaseState> {
14404 self.lease_state.as_ref()
14405 }
14406
14407 fn record_lease_error(&mut self, code: &str) {
14410 if code != "sync.auth_lease_required" && code != "sync.auth_lease_revoked" {
14411 return;
14412 }
14413 let mut next = self.lease_state.clone().unwrap_or_default();
14414 next.error_code = Some(code.to_owned());
14415 self.set_lease_state(Some(next));
14416 }
14417
14418 fn set_lease_state(&mut self, next: Option<LeaseState>) {
14419 if self.lease_state == next {
14420 return;
14421 }
14422 if self.begin_observation("syncular_lease").is_err() {
14423 return;
14424 }
14425 self.lease_state = next;
14426 if let Some(lease) = &self.lease_state {
14427 if let Ok(json) = serde_json::to_string(lease) {
14428 self.set_meta(LEASE_STATE_KEY, &json);
14429 }
14430 } else {
14431 self.delete_meta(LEASE_STATE_KEY);
14432 }
14433 let batch = ChangeAccumulator {
14434 status: true,
14435 ..ChangeAccumulator::default()
14436 };
14437 if self.finish_observation("syncular_lease", batch).is_err() {
14438 self.rollback_observation("syncular_lease");
14439 }
14440 }
14441
14442 fn set_schema_floor(&mut self, next: Option<SchemaFloor>) {
14443 if self.schema_floor == next {
14444 return;
14445 }
14446 if self.begin_observation("syncular_schema_floor").is_err() {
14447 return;
14448 }
14449 self.schema_floor = next;
14450 self.stopped = self.schema_floor.is_some();
14451 if let Some(floor) = &self.schema_floor {
14452 if let Ok(json) = serde_json::to_string(floor) {
14453 self.set_meta(SCHEMA_FLOOR_KEY, &json);
14454 }
14455 } else {
14456 self.delete_meta(SCHEMA_FLOOR_KEY);
14457 }
14458 let batch = ChangeAccumulator {
14459 status: true,
14460 ..ChangeAccumulator::default()
14461 };
14462 if self
14463 .finish_observation("syncular_schema_floor", batch)
14464 .is_err()
14465 {
14466 self.rollback_observation("syncular_schema_floor");
14467 }
14468 }
14469
14470 fn set_upgrading(&mut self, value: bool) {
14471 if self.upgrading == value {
14472 return;
14473 }
14474 if self.begin_observation("syncular_upgrading").is_err() {
14475 return;
14476 }
14477 self.upgrading = value;
14478 let batch = ChangeAccumulator {
14479 status: true,
14480 ..ChangeAccumulator::default()
14481 };
14482 if self
14483 .finish_observation("syncular_upgrading", batch)
14484 .is_err()
14485 {
14486 self.rollback_observation("syncular_upgrading");
14487 }
14488 }
14489
14490 pub fn sync_needed(&self) -> bool {
14491 self.sync_needed
14492 }
14493
14494 pub fn subscription_state(&self, id: &str) -> Option<SubscriptionStateView> {
14495 let sub = self.subs.iter().find(|s| s.id == id)?;
14496 Some(SubscriptionStateView {
14497 id: sub.id.clone(),
14498 table: sub.table.clone(),
14499 status: sub.state.name().to_owned(),
14500 cursor: sub.cursor,
14501 has_resume_token: sub.bootstrap_state.is_some(),
14502 effective_scopes: sub.effective.as_ref().map(|e| scope_map_to_json(e)),
14503 reason_code: sub.reason_code.clone(),
14504 })
14505 }
14506
14507 pub fn read_rows(&self, table: &str) -> Result<Vec<RowState>, String> {
14508 let schema_table = self
14509 .schema
14510 .table(table)
14511 .ok_or_else(|| format!("unknown table {table:?}"))?;
14512 let sql = format!(
14513 "SELECT * FROM {} ORDER BY {} ASC",
14514 visible_table(table),
14515 quote_ident(&schema_table.primary_key)
14516 );
14517 let mut stmt = self
14518 .conn
14519 .prepare(&sql)
14520 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
14521 let mut rows = stmt
14522 .query([])
14523 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
14524 let mut out = Vec::new();
14525 while let Some(row) = rows
14526 .next()
14527 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?
14528 {
14529 let mut values = Map::new();
14530 for (i, column) in schema_table.columns.iter().enumerate() {
14531 let value = row
14532 .get_ref(i)
14533 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
14534 values.insert(column.name.clone(), sql_ref_to_json(column, value));
14535 }
14536 let version: i64 = row
14537 .get(schema_table.columns.len())
14538 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
14539 let row_id = match values.get(&schema_table.primary_key) {
14540 Some(Value::String(s)) => s.clone(),
14541 Some(Value::Number(n)) => n.to_string(),
14542 Some(Value::Bool(b)) => b.to_string(),
14543 other => format!("{}", other.cloned().unwrap_or(Value::Null)),
14544 };
14545 out.push(RowState {
14546 row_id,
14547 version,
14548 values,
14549 });
14550 }
14551 Ok(out)
14552 }
14553
14554 #[cfg(feature = "crdt-yjs")]
14570 fn crdt_column_bytes(
14571 &self,
14572 table: &str,
14573 row_id: &str,
14574 column: &str,
14575 ) -> Result<Option<Vec<u8>>, String> {
14576 let schema_table = self
14577 .schema
14578 .table(table)
14579 .ok_or_else(|| format!("unknown table {table:?}"))?;
14580 let col = schema_table
14581 .columns
14582 .iter()
14583 .find(|c| c.name == column)
14584 .ok_or_else(|| format!("table {table:?} has no column {column:?}"))?;
14585 if col.ty != ColumnType::Crdt {
14586 return Err(format!("column {column:?} is not a crdt column (§5.10.1)"));
14587 }
14588 let sql = format!(
14589 "SELECT {} FROM {} WHERE {}",
14590 quote_ident(column),
14591 visible_table(table),
14592 row_id_predicate(schema_table)
14593 );
14594 let bytes: Option<Vec<u8>> = self
14595 .conn
14596 .prepare_cached(&sql)
14597 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?
14598 .query_row(rusqlite::params![row_id], |row| {
14599 row.get::<_, Option<Vec<u8>>>(0)
14600 })
14601 .map_err(|e| match e {
14602 rusqlite::Error::QueryReturnedNoRows => "no such row".to_owned(),
14603 other => Self::sqlite_failure(&self.storage_failure, other),
14604 })?;
14605 Ok(bytes)
14606 }
14607
14608 #[cfg(feature = "crdt-yjs")]
14612 pub fn crdt_text(
14613 &self,
14614 table: &str,
14615 row_id: &str,
14616 column: &str,
14617 name: &str,
14618 ) -> Result<String, String> {
14619 let bytes = self
14620 .crdt_column_bytes(table, row_id, column)?
14621 .unwrap_or_default();
14622 crate::crdt::text(&bytes, name)
14623 }
14624
14625 #[cfg(feature = "crdt-yjs")]
14629 pub fn crdt_insert_text(
14630 &mut self,
14631 table: &str,
14632 row_id: &str,
14633 column: &str,
14634 name: &str,
14635 index: u32,
14636 value: &str,
14637 ) -> Result<String, ClientError> {
14638 let prior = self.storage_failure.borrow_mut().take();
14639 let outcome = (|| -> Result<String, String> {
14640 let current = self
14641 .crdt_column_bytes(table, row_id, column)?
14642 .unwrap_or_default();
14643 let update = crate::crdt::insert_text(¤t, name, index, value)?;
14644 self.perform_crdt_push_update(table, row_id, column, &update)
14645 })();
14646 self.finish_authoring(prior, outcome)
14647 }
14648
14649 #[cfg(feature = "crdt-yjs")]
14653 pub fn crdt_delete_text(
14654 &mut self,
14655 table: &str,
14656 row_id: &str,
14657 column: &str,
14658 name: &str,
14659 index: u32,
14660 len: u32,
14661 ) -> Result<String, ClientError> {
14662 let prior = self.storage_failure.borrow_mut().take();
14663 let outcome = (|| -> Result<String, String> {
14664 let current = self
14665 .crdt_column_bytes(table, row_id, column)?
14666 .unwrap_or_default();
14667 let update = crate::crdt::delete_text(¤t, name, index, len)?;
14668 self.perform_crdt_push_update(table, row_id, column, &update)
14669 })();
14670 self.finish_authoring(prior, outcome)
14671 }
14672
14673 #[cfg(feature = "crdt-yjs")]
14678 pub fn crdt_apply_update(
14679 &mut self,
14680 table: &str,
14681 row_id: &str,
14682 column: &str,
14683 update: &[u8],
14684 ) -> Result<String, ClientError> {
14685 let prior = self.storage_failure.borrow_mut().take();
14686 let outcome = (|| -> Result<String, String> {
14687 let current = self
14688 .crdt_column_bytes(table, row_id, column)?
14689 .unwrap_or_default();
14690 let next = crate::crdt::apply_update(¤t, update)?;
14691 self.perform_crdt_push_update(table, row_id, column, &next)
14692 })();
14693 self.finish_authoring(prior, outcome)
14694 }
14695
14696 #[cfg(feature = "crdt-yjs")]
14703 fn perform_crdt_push_update(
14704 &mut self,
14705 table: &str,
14706 row_id: &str,
14707 column: &str,
14708 crdt_bytes: &[u8],
14709 ) -> Result<String, String> {
14710 let mut bytes_obj = Map::new();
14711 bytes_obj.insert("$bytes".to_owned(), Value::from(bytes_to_hex(crdt_bytes)));
14712 let mut partial = Map::new();
14713 partial.insert(column.to_owned(), Value::Object(bytes_obj));
14714 self.perform_patch(table, row_id, partial, None)
14715 }
14716
14717 pub fn query(&self, sql: &str, params: &[QueryValue]) -> Result<Vec<QueryRow>, String> {
14732 query_connection(&self.conn, sql, params).map_err(|failure| failure.to_string())
14733 }
14734
14735 #[cfg(feature = "bench-internals")]
14737 #[doc(hidden)]
14738 pub fn benchmark_connection(&mut self) -> &mut Connection {
14739 &mut self.conn
14740 }
14741
14742 #[cfg(feature = "bench-internals")]
14744 #[doc(hidden)]
14745 pub fn benchmark_phases(
14746 &self,
14747 enabled: Option<bool>,
14748 reset: bool,
14749 ) -> Result<Option<Value>, String> {
14750 self.benchmark_phases.configure(enabled, reset)?;
14751 Ok(self.benchmark_phases.snapshot())
14752 }
14753
14754 pub fn authority_snapshot(&self) -> Result<Value, String> {
14756 validate_authority_reads(&self.schema, &self.limits.authority_reads)?;
14757 if self.limits.authority_reads.is_empty() {
14758 return Err("client.authority_read_forbidden: no authority reads were declared at client creation".into());
14759 }
14760 self.conn
14761 .execute_batch("SAVEPOINT authority_snapshot")
14762 .map_err(|e| e.to_string())?;
14763 let result = (|| {
14764 let revision = self
14765 .get_meta(LOCAL_REVISION_KEY)
14766 .ok_or("sync.local_corrupt: authority revision is missing")?;
14767 let parsed_revision = revision
14768 .parse::<u64>()
14769 .map_err(|_| "sync.local_corrupt: authority revision is invalid")?;
14770 if parsed_revision.to_string() != revision {
14771 return Err("sync.local_corrupt: authority revision is invalid".into());
14772 }
14773 let mut tables = Vec::new();
14774 for read in &self.limits.authority_reads {
14775 let table = self
14776 .schema
14777 .table(&read.table)
14778 .ok_or("client.authority_read_forbidden: authority table is not declared")?;
14779 let scalar = |column: &str, value: &Value| -> Result<String, String> {
14780 let ty = table
14781 .columns
14782 .iter()
14783 .find(|c| c.name == column)
14784 .expect("validated authority column")
14785 .ty;
14786 if ty == ColumnType::Boolean {
14787 if let Some(value) = value.as_bool() {
14788 return Ok(value.to_string());
14789 }
14790 return match value.as_i64() {
14791 Some(0) => Ok("false".into()),
14792 Some(1) => Ok("true".into()),
14793 _ => Err("sync.local_corrupt: authority boolean is invalid".into()),
14794 };
14795 }
14796 match value {
14797 Value::String(s) => Ok(s.clone()),
14798 Value::Number(n) => Ok(n.to_string()),
14799 Value::Object(o) if ty == ColumnType::Integer && o.len() == 1 => o
14800 .get("$bigint")
14801 .and_then(Value::as_str)
14802 .map(str::to_owned)
14803 .ok_or("sync.local_corrupt: authority integer is invalid".into()),
14804 _ => {
14805 Err("sync.local_corrupt: authority scalar is missing or invalid".into())
14806 }
14807 }
14808 };
14809 let matches = |row: &QueryRow| -> Result<bool, String> {
14810 for (variable, values) in &read.scopes {
14811 let column = table
14812 .scope_column(variable)
14813 .expect("validated authority scope");
14814 let value = row
14815 .get(column)
14816 .ok_or("sync.local_corrupt: authority scope column is missing")?;
14817 if value.is_null() || !values.contains(&scalar(column, value)?) {
14818 return Ok(false);
14819 }
14820 }
14821 Ok(true)
14822 };
14823 let selected = read
14824 .columns
14825 .iter()
14826 .map(|c| quote_ident(c))
14827 .collect::<Vec<_>>()
14828 .join(",");
14829 let mut intent = BTreeSet::new();
14830 let mut relevant = BTreeSet::new();
14831 for row in query_connection(&self.conn,
14832 "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=?",
14833 &[Value::String(read.table.clone())]).map_err(|e| e.to_string())? {
14834 let id = row.get("id").and_then(Value::as_str).ok_or("sync.local_corrupt: authority intent id is invalid")?.to_owned();
14835 intent.insert(id.clone());
14836 if let Some(raw) = row.get("values_json").and_then(Value::as_str) {
14837 let values: QueryRow = serde_json::from_str(raw).map_err(|_| "sync.local_corrupt: authority intent values are invalid")?;
14838 if read.scopes.keys().all(|v| values.contains_key(table.scope_column(v).expect("validated scope"))) && matches(&values)? {relevant.insert(id);}
14839 }
14840 }
14841 let mut rows = BTreeMap::new();
14842 for full in [base_table(&read.table), visible_table(&read.table)] {
14843 for mut values in query_connection(
14844 &self.conn,
14845 &format!(
14846 "SELECT {selected},_syncular_version AS authority_version FROM {full}"
14847 ),
14848 &[],
14849 )
14850 .map_err(|e| e.to_string())?
14851 {
14852 if !matches(&values)? {
14853 continue;
14854 }
14855 let id = scalar(
14856 &table.primary_key,
14857 values
14858 .get(&table.primary_key)
14859 .expect("selected primary key"),
14860 )?;
14861 relevant.insert(id.clone());
14862 if full == base_table(&read.table) {
14863 let version = values
14864 .remove("authority_version")
14865 .expect("selected version");
14866 let version = version
14867 .as_i64()
14868 .ok_or("sync.local_corrupt: authority server version is invalid")?;
14869 if version >= 0 {
14870 rows.insert(id.clone(), json!({"values": values, "version": version, "hasLocalIntent": intent.contains(&id)}));
14871 }
14872 }
14873 }
14874 }
14875 intent.retain(|id| relevant.contains(id));
14876 let rows = rows.into_values().collect::<Vec<_>>();
14877 let mut persisted = Vec::new();
14878 for row in query_connection(
14879 &self.conn,
14880 "SELECT state_json FROM _syncular_subscriptions WHERE tbl=? ORDER BY rowid",
14881 &[Value::String(read.table.clone())],
14882 )
14883 .map_err(|e| e.message)?
14884 {
14885 let state: Value = serde_json::from_str(
14886 row.get("state_json")
14887 .and_then(Value::as_str)
14888 .ok_or("sync.local_corrupt: authority subscription state is missing")?,
14889 )
14890 .map_err(|_| "sync.local_corrupt: authority subscription state is invalid")?;
14891 let effective = state.get("effectiveScopes").and_then(Value::as_object);
14892 let status = state
14893 .get("status")
14894 .and_then(Value::as_str)
14895 .ok_or("sync.local_corrupt: authority subscription status is missing")?;
14896 let cursor = state
14897 .get("cursor")
14898 .and_then(Value::as_i64)
14899 .ok_or("sync.local_corrupt: authority subscription cursor is missing")?;
14900 let complete = effective.is_some_and(|s| s.len() == read.scopes.len())
14901 && status == "active"
14902 && cursor >= 0
14903 && state.get("bootstrapState").is_none_or(Value::is_null)
14904 && state.get("params").is_none_or(Value::is_null);
14905 let exposed = effective.map(|s| {
14906 read.scopes
14907 .keys()
14908 .map(|v| (v.clone(), s.get(v).cloned().unwrap_or(json!([]))))
14909 .collect::<Map<_, _>>()
14910 });
14911 let requested = read
14912 .scopes
14913 .keys()
14914 .map(|v| {
14915 (
14916 v.clone(),
14917 state
14918 .get("requested")
14919 .and_then(|s| s.get(v))
14920 .cloned()
14921 .unwrap_or(json!([])),
14922 )
14923 })
14924 .collect::<Map<_, _>>();
14925 persisted.push(json!({"requestedScopes": requested, "status": status, "cursor": cursor, "effectiveScopes": exposed, "complete": complete}));
14926 }
14927 fn covered(selectors: &[(&String, &Vec<String>)], candidates: &[&Value]) -> bool {
14928 match selectors.split_first() {
14929 None => !candidates.is_empty(),
14930 Some(((variable, values), rest)) => values.iter().all(|value| {
14931 covered(
14932 rest,
14933 &candidates
14934 .iter()
14935 .copied()
14936 .filter(|p| {
14937 p["effectiveScopes"][variable.as_str()]
14938 .as_array()
14939 .is_some_and(|held| {
14940 held.contains(&Value::String(value.clone()))
14941 || held.contains(&Value::String("*".into()))
14942 })
14943 })
14944 .collect::<Vec<_>>(),
14945 )
14946 }),
14947 }
14948 }
14949 let coverage = if covered(
14950 &read.scopes.iter().collect::<Vec<_>>(),
14951 &persisted
14952 .iter()
14953 .filter(|p| p["complete"] == true)
14954 .collect::<Vec<_>>(),
14955 ) {
14956 "complete"
14957 } else if persisted.iter().any(|p| {
14958 p["status"] == "active"
14959 && read.scopes.iter().all(|(v, values)| {
14960 values.iter().any(|value| {
14961 p["requestedScopes"][v].as_array().is_some_and(|held| {
14962 held.contains(&Value::String(value.clone()))
14963 || held.contains(&Value::String("*".into()))
14964 })
14965 })
14966 })
14967 }) {
14968 "pending"
14969 } else {
14970 "missing"
14971 };
14972 tables.push(json!({"table": read.table, "rows": rows, "localIntentRowIds": intent, "scopes": read.scopes, "coverage": coverage, "persisted": persisted}));
14973 }
14974 Ok(
14975 json!({"revision": revision, "complete": tables.iter().all(|t| t["coverage"] == "complete"), "tables": tables}),
14976 )
14977 })();
14978 match result {
14979 Ok(snapshot) => {
14980 self.conn
14981 .execute_batch("RELEASE authority_snapshot")
14982 .map_err(|e| e.to_string())?;
14983 Ok(snapshot)
14984 }
14985 Err(error) => {
14986 self.conn
14987 .execute_batch("ROLLBACK TO authority_snapshot; RELEASE authority_snapshot")
14988 .map_err(|e| format!("{error}; rollback failed: {e}"))?;
14989 Err(error)
14990 }
14991 }
14992 }
14993
14994 pub fn query_snapshot(
14998 &mut self,
14999 sql: &str,
15000 params: &[QueryValue],
15001 coverage: &[WindowCoverage],
15002 owner: Option<QueryOwner<'_>>,
15003 ) -> Result<QuerySnapshot, String> {
15004 let result = snapshot_connection(&self.conn, sql, params, coverage);
15005 if let Some(owner) = owner {
15006 self.record_query_read(owner, result.as_ref().err());
15007 }
15008 result.map_err(|failure| failure.to_string())
15009 }
15010
15011 pub fn record_query_read(&mut self, owner: QueryOwner<'_>, failure: Option<&QueryReadFailure>) {
15015 let position = self
15016 .query_failures
15017 .iter()
15018 .position(|entry| entry.id == owner.id);
15019 let Some(failure) = failure else {
15020 if let Some(index) = position {
15021 self.query_failures.remove(index);
15022 }
15023 return;
15024 };
15025 let tables: Vec<String> = BTreeSet::from_iter(owner.tables.iter().copied())
15026 .into_iter()
15027 .map(str::to_owned)
15028 .collect();
15029 let code = failure.code.unwrap_or("client.query_failed").to_owned();
15030 if position.is_some_and(|index| {
15031 let entry = &self.query_failures[index];
15032 entry.tables == tables && entry.code == code && entry.sqlite_code == failure.sqlite_code
15033 }) {
15034 return;
15035 }
15036 if let Some(index) = position {
15037 self.query_failures.remove(index);
15038 }
15039 self.query_failures.push(DiagnosticQueryFailure {
15040 id: owner.id.to_owned(),
15041 tables,
15042 code,
15043 sqlite_code: failure.sqlite_code,
15044 at_ms: self.clock_now_ms(),
15045 });
15046 if self.query_failures.len() > MAX_DIAGNOSTIC_QUERY_FAILURES {
15047 self.query_failures.remove(0);
15048 }
15049 }
15050
15051 fn build_request(&self, url_capable: bool) -> Result<(Message, RequestMeta), Box<SyncOutcome>> {
15054 #[cfg(feature = "bench-internals")]
15055 let _phase = self.benchmark_phases.start(Phase::RequestPrepare);
15056 let log_epoch = self.get_meta(LOG_EPOCH_KEY);
15057 let max_commits = self.limits.max_push_commits_per_request;
15058 let max_ops = self
15059 .limits
15060 .max_push_operations_per_request
15061 .unwrap_or(PUSH_OPS_PER_REQUEST);
15062 let byte_cap = self.limits.max_push_request_bytes;
15063 let accept = self.limits.accept.unwrap_or(if url_capable {
15066 DEFAULT_ACCEPT | ACCEPT_SIGNED_URLS
15067 } else {
15068 DEFAULT_ACCEPT
15069 });
15070 let header = Frame::ReqHeader {
15071 client_id: self.client_id.clone(),
15072 schema_version: self.schema.version,
15073 log_epoch: log_epoch.clone(),
15074 };
15075 let pull = Frame::PullHeader {
15076 limit_commits: self.limits.limit_commits.unwrap_or(0),
15077 limit_snapshot_rows: self.limits.limit_snapshot_rows.unwrap_or(0),
15078 max_snapshot_pages: self.limits.max_snapshot_pages.unwrap_or(0),
15079 accept,
15080 };
15081 let mut fresh = Vec::new();
15082 let mut sub_frames = Vec::new();
15083 for sub in &self.subs {
15084 if sub.state != SubState::Active {
15085 continue;
15086 }
15087 let mut scopes = sub.requested.clone();
15088 sort_scope_map(&mut scopes);
15089 sub_frames.push(Frame::Subscription {
15090 id: sub.id.clone(),
15091 table: sub.table.clone(),
15092 scopes,
15093 params: sub.params.clone().map(RawJson),
15094 cursor: sub.cursor,
15095 bootstrap_state: sub.bootstrap_state.clone().map(RawJson),
15096 });
15097 fresh.push((
15098 sub.id.clone(),
15099 sub.cursor < 0 && sub.bootstrap_state.is_none(),
15100 ));
15101 }
15102 let fixed_bytes = match byte_cap {
15105 Some(_) => {
15106 let mut frames = vec![header.clone(), pull.clone()];
15107 frames.extend(sub_frames.iter().cloned());
15108 encode_message(&Message {
15109 wire_version: WIRE_VERSION,
15110 msg_kind: MsgKind::Request,
15111 frames,
15112 })
15113 .len()
15114 }
15115 None => 0,
15116 };
15117 if let Some(cap) = byte_cap {
15118 if fixed_bytes > cap {
15119 return Err(Self::push_capacity_outcome("bytes", cap, fixed_bytes, None));
15120 }
15121 }
15122 let base_bytes = match byte_cap {
15123 Some(_) => encode_message(&Message {
15124 wire_version: WIRE_VERSION,
15125 msg_kind: MsgKind::Request,
15126 frames: vec![header.clone(), pull.clone()],
15127 })
15128 .len(),
15129 None => 0,
15130 };
15131 let total_outbox = if log_epoch.is_some() {
15132 self.outbox.len()
15133 } else {
15134 0
15135 };
15136 let mut push_frames: Vec<Frame> = Vec::new();
15137 let mut pushed_ids = Vec::new();
15138 let mut ops_in_request = 0usize;
15139 let mut bytes_in_request = 0usize;
15140 let mut deferred_commits = 0usize;
15141 #[cfg(feature = "bench-internals")]
15142 let outbox_phase = self.benchmark_phases.start(Phase::OutboxEncode);
15143 for (index, commit) in self.outbox.iter().take(total_outbox).enumerate() {
15144 if max_commits.is_some_and(|cap| push_frames.len() >= cap) {
15148 deferred_commits = total_outbox - index;
15149 break;
15150 }
15151 if ops_in_request + commit.ops.len() > max_ops {
15152 if push_frames.is_empty() {
15153 return Err(Self::push_capacity_outcome(
15154 "operations",
15155 max_ops,
15156 commit.ops.len(),
15157 Some(commit.client_commit_id.as_str()),
15158 ));
15159 }
15160 deferred_commits = total_outbox - index;
15161 break;
15162 }
15163 let operations = commit
15164 .ops
15165 .iter()
15166 .map(|op| {
15167 let payload = op.values.as_ref().and_then(|values| {
15168 let table = self.schema.table(&op.table)?;
15169 let fallback = self.stored_key_fallback(table, &op.row_id);
15178 encode_sparse_row_json(
15179 table,
15180 &op.row_id,
15181 values,
15182 &self.encryption,
15183 fallback.as_ref(),
15184 )
15185 .ok()
15186 });
15187 ssp2::model::Operation {
15188 table: op.table.clone(),
15189 row_id: op.row_id.clone(),
15190 op: if op.upsert { Op::Upsert } else { Op::Delete },
15191 base_version: op.base_version,
15192 payload,
15193 }
15194 })
15195 .collect();
15196 let frame = Frame::PushCommit {
15197 client_commit_id: commit.client_commit_id.clone(),
15198 operations,
15199 };
15200 let frame_bytes = match byte_cap {
15201 Some(_) => {
15202 encode_message(&Message {
15203 wire_version: WIRE_VERSION,
15204 msg_kind: MsgKind::Request,
15205 frames: vec![header.clone(), frame.clone(), pull.clone()],
15206 })
15207 .len()
15208 - base_bytes
15209 }
15210 None => 0,
15211 };
15212 if let Some(cap) = byte_cap {
15216 if !push_frames.is_empty() && fixed_bytes + bytes_in_request + frame_bytes > cap {
15217 deferred_commits = total_outbox - index;
15218 break;
15219 }
15220 }
15221 push_frames.push(frame);
15222 pushed_ids.push(commit.client_commit_id.clone());
15223 ops_in_request += commit.ops.len();
15224 bytes_in_request += frame_bytes;
15225 }
15226 #[cfg(feature = "bench-internals")]
15227 drop(outbox_phase);
15228 if let Some(cap) = byte_cap {
15229 if fixed_bytes + bytes_in_request > cap {
15230 let blocked = if push_frames.is_empty() {
15233 None
15234 } else {
15235 self.outbox.first().map(|c| c.client_commit_id.as_str())
15236 };
15237 return Err(Self::push_capacity_outcome(
15238 "bytes",
15239 cap,
15240 fixed_bytes + bytes_in_request,
15241 blocked,
15242 ));
15243 }
15244 }
15245 let mut frames = vec![header];
15246 frames.extend(push_frames);
15247 frames.push(pull);
15248 frames.extend(sub_frames);
15249 let message = Message {
15250 wire_version: WIRE_VERSION,
15251 msg_kind: MsgKind::Request,
15252 frames,
15253 };
15254 Ok((
15255 message,
15256 RequestMeta {
15257 pushed_ids,
15258 fresh,
15259 accept,
15260 deferred_commits,
15261 },
15262 ))
15263 }
15264
15265 fn push_capacity_outcome(
15267 kind: &str,
15268 limit: usize,
15269 size: usize,
15270 client_commit_id: Option<&str>,
15271 ) -> Box<SyncOutcome> {
15272 let mut details = Map::new();
15273 details.insert("kind".to_owned(), Value::from(kind));
15274 details.insert("limit".to_owned(), Value::from(limit as u64));
15275 details.insert("size".to_owned(), Value::from(size as u64));
15276 if let Some(id) = client_commit_id {
15277 details.insert("clientCommitId".to_owned(), Value::from(id));
15278 }
15279 Box::new(SyncOutcome::Failed {
15280 error_code: "client.push_request_too_large".into(),
15281 message: "the push request exceeds a configured capacity limit".into(),
15282 details: Some(Value::Object(details)),
15283 })
15284 }
15285
15286 pub fn progress(&self) -> ProgressObserver {
15290 self.progress.clone()
15291 }
15292
15293 pub fn sync(&mut self, transport: &mut dyn Transport) -> SyncOutcome {
15294 let prepared = match self.prepare_sync_round(transport.supports_url_fetch()) {
15295 Ok(prepared) => prepared,
15296 Err(outcome) => return *outcome,
15297 };
15298 let mut next = prepared;
15303 loop {
15304 match self.apply_sync_round(next.exchange(transport)) {
15305 crate::AppliedSyncRound::Continue(prepared) => next = prepared,
15306 crate::AppliedSyncRound::Complete {
15307 outcome, controls, ..
15308 } => {
15309 if self.transport_enabled {
15310 for text in controls {
15311 let _ = transport.realtime_send(&text);
15312 }
15313 } else {
15314 self.disconnect_realtime(transport);
15315 }
15316 return outcome;
15317 }
15318 }
15319 }
15320 }
15321
15322 fn finish_sync_round(&mut self, started_at_ms: i64, mut outcome: SyncOutcome) -> SyncOutcome {
15323 if let SyncOutcome::Failed {
15324 error_code,
15325 message,
15326 details,
15327 } = &mut outcome
15328 {
15329 if let Some(failure) = self.storage_failure.borrow().as_ref() {
15330 let code = failure
15331 .code
15332 .expect("retained storage failures are classified");
15333 *error_code = code.to_owned();
15334 *message = match code {
15335 "client.storage_busy" => "local SQLite storage is busy",
15336 "client.storage_full" => "local SQLite storage is full",
15337 "client.storage_io" => "local SQLite storage I/O failed",
15338 "client.storage_corrupt" => "local SQLite storage is corrupt",
15339 _ => unreachable!("unrecognized classified storage failure"),
15340 }
15341 .to_owned();
15342 *details = failure.details();
15343 }
15344 }
15345 let retry_delay_ms = self.round_retry_delay_ms;
15346 self.progress.update(|p| match &outcome {
15347 SyncOutcome::Ok(report) | SyncOutcome::BudgetExhausted(report)
15348 if report.failed.is_empty() =>
15349 {
15350 p.state = ProgressState::Complete
15351 }
15352 SyncOutcome::Ok(_) | SyncOutcome::BudgetExhausted(_) => {
15353 p.state = ProgressState::Failed;
15354 p.error_code = Some("sync.scope_revoked".into());
15355 }
15356 SyncOutcome::Failed { error_code, .. } => {
15357 p.state = ProgressState::Failed;
15358 p.error_code = Some(Self::diagnostic_code(error_code));
15359 p.retry_delay_ms = retry_delay_ms;
15360 }
15361 SyncOutcome::RealtimeUnavailable { .. } => {
15362 p.state = ProgressState::Failed;
15363 p.error_code = Some(REALTIME_UNAVAILABLE_CODE.to_owned());
15364 p.retry_delay_ms = retry_delay_ms;
15365 }
15366 });
15367 let completed_at_ms = self.clock_now_ms();
15368 self.last_round = Some(match &outcome {
15369 SyncOutcome::Ok(report) | SyncOutcome::BudgetExhausted(report) => DiagnosticLastRound {
15370 status: "succeeded".to_owned(),
15371 started_at_ms,
15372 completed_at_ms,
15373 duration_ms: completed_at_ms.saturating_sub(started_at_ms).max(0),
15374 counters: Some(DiagnosticRoundCounters {
15375 pushed: report.pushed,
15376 applied: report.applied.len(),
15377 rejected: report.rejected.len(),
15378 retryable: report.retryable.len(),
15379 conflicts: report.conflicts,
15380 commits_applied: report.commits_applied,
15381 segment_rows_applied: report.segment_rows_applied,
15382 bootstrapping: report.bootstrapping.len(),
15383 resets: report.resets.len(),
15384 revoked: report.revoked.len(),
15385 failed: report.failed.len(),
15386 deferred_commits: report.deferred_commits,
15387 }),
15388 error_code: None,
15389 },
15390 SyncOutcome::Failed { error_code, .. } => DiagnosticLastRound {
15391 status: "failed".to_owned(),
15392 started_at_ms,
15393 completed_at_ms,
15394 duration_ms: completed_at_ms.saturating_sub(started_at_ms).max(0),
15395 counters: None,
15396 error_code: Some(Self::diagnostic_code(error_code)),
15397 },
15398 SyncOutcome::RealtimeUnavailable { .. } => DiagnosticLastRound {
15399 status: "failed".to_owned(),
15400 started_at_ms,
15401 completed_at_ms,
15402 duration_ms: completed_at_ms.saturating_sub(started_at_ms).max(0),
15403 counters: None,
15404 error_code: Some(REALTIME_UNAVAILABLE_CODE.to_owned()),
15405 },
15406 });
15407 outcome
15408 }
15409
15410 pub fn prepare_sync_round(
15413 &mut self,
15414 url_capable: bool,
15415 ) -> Result<crate::PreparedSyncRound, Box<SyncOutcome>> {
15416 if !self.transport_enabled {
15417 return Err(Box::new(SyncOutcome::Failed {
15418 error_code: "sync.offline".into(),
15419 message: "transport gate is closed".into(),
15420 details: None,
15421 }));
15422 }
15423 if self.active_round.is_some() {
15424 return Err(Box::new(SyncOutcome::Failed {
15425 error_code: "client.round_in_progress".into(),
15426 message: "a sync round is already in flight".into(),
15427 details: None,
15428 }));
15429 }
15430 self.storage_failure.borrow_mut().take();
15431 self.progress.start();
15432 self.round_retry_delay_ms = None;
15433 let started_at_ms = self.clock_now_ms();
15434 let prepared = (|| {
15435 if self.stopped {
15436 return Err(Box::new(SyncOutcome::Ok(SyncReport {
15437 schema_floor: self.schema_floor.clone(),
15438 ..SyncReport::default()
15439 })));
15440 }
15441 if self.security_preflight {
15442 return Err(Box::new(SyncOutcome::Failed {
15443 error_code: SECURITY_PREFLIGHT_REQUIRED_CODE.into(),
15444 message: "security preflight must complete before syncing".into(),
15445 details: None,
15446 }));
15447 }
15448 self.set_sync_needed(false, false);
15449 self.drain_pending_evictions()
15450 .and_then(|_| self.drop_incompatible_outbox())
15451 .and_then(|_| self.drop_unencodable_outbox(true, None))
15452 .map_err(|message| {
15453 Box::new(SyncOutcome::Failed {
15454 error_code: "storage.failed".into(),
15455 message,
15456 details: None,
15457 })
15458 })?;
15459 let uploads = if self.get_meta(LOG_EPOCH_KEY).is_some() && self.schema_has_blobs() {
15460 self.pending_blob_uploads().map_err(|e| {
15461 Box::new(SyncOutcome::Failed {
15462 error_code: e.code,
15463 message: e.message,
15464 details: e.details,
15465 })
15466 })?
15467 } else {
15468 Vec::new()
15469 };
15470 if self.realtime_state != RealtimeState::Connected
15471 && self.realtime_policy == RealtimePolicy::Required
15472 {
15473 let retry_delay_ms = self.schedule_background_retry();
15474 self.realtime_retry_delay_ms = Some(retry_delay_ms);
15475 return Err(Box::new(SyncOutcome::RealtimeUnavailable {
15476 state: self.realtime_state,
15477 reason_code: self.realtime_reason_code.clone(),
15478 retry_delay_ms,
15479 }));
15480 }
15481 let (message, meta) = self.build_request(url_capable)?;
15482 let id = uuid::Uuid::new_v4();
15483 self.active_round = Some(id);
15484 Ok(crate::PreparedSyncRound {
15485 id,
15486 started_at_ms,
15487 message,
15488 meta,
15489 uploads: uploads.into(),
15490 realtime: self.realtime_state == RealtimeState::Connected,
15491 scopes: self
15492 .subs
15493 .iter()
15494 .map(|s| (s.id.clone(), canonical_scope_json(&s.requested)))
15495 .collect(),
15496 #[cfg(feature = "bench-internals")]
15497 benchmark_phases: self.benchmark_phases.clone(),
15498 progress: self.progress.clone(),
15499 fixed_now: self.now_ms,
15500 round_deadline_at: None,
15501 })
15502 })();
15503 prepared.map_err(|outcome| Box::new(self.finish_sync_round(started_at_ms, *outcome)))
15504 }
15505
15506 pub fn cancel_sync_round(&mut self) {
15509 self.active_round = None;
15510 }
15511
15512 pub fn apply_sync_round(
15515 &mut self,
15516 completed: crate::CompletedSyncRound,
15517 ) -> crate::AppliedSyncRound {
15518 let prepared = completed.prepared;
15519 if self.active_round != Some(prepared.id) || self.security_preflight {
15520 return crate::AppliedSyncRound::Complete {
15521 outcome: SyncOutcome::Failed {
15522 error_code: "client.round_cancelled".into(),
15523 message: "sync round belongs to a released client context".into(),
15524 details: None,
15525 },
15526 controls: Vec::new(),
15527 more: false,
15528 bootstrap_advanced: false,
15529 };
15530 }
15531 self.storage_failure.borrow_mut().take();
15532 let (response, mut downloads, transport_failed) = match completed.exchange {
15533 crate::round::ExchangeResult::Upload { id, result } => {
15534 let result = result.and_then(|()| {
15535 self.conn
15536 .execute(
15537 "DELETE FROM _syncular_blob_uploads WHERE blob_id = ?",
15538 rusqlite::params![id],
15539 )
15540 .map(|_| ())
15541 .map_err(|error| {
15542 TransportError::new(
15543 "client.failed",
15544 Self::sqlite_failure(&self.storage_failure, error),
15545 )
15546 })
15547 });
15548 match result {
15549 Ok(()) => return crate::AppliedSyncRound::Continue(prepared),
15550 Err(TransportError {
15551 code,
15552 message,
15553 details,
15554 }) => {
15555 self.active_round = None;
15556 if Self::retryable_failure_code(&code) {
15557 self.schedule_background_retry();
15558 }
15559 let outcome = self.finish_sync_round(
15560 prepared.started_at_ms,
15561 SyncOutcome::Failed {
15562 error_code: code,
15563 message,
15564 details,
15565 },
15566 );
15567 return crate::AppliedSyncRound::Complete {
15568 outcome,
15569 controls: Vec::new(),
15570 more: false,
15571 bootstrap_advanced: false,
15572 };
15573 }
15574 }
15575 }
15576 crate::round::ExchangeResult::Reply {
15577 response,
15578 downloads,
15579 transport_failed,
15580 } => (response, downloads, transport_failed),
15581 };
15582 self.active_round = None;
15583 let before = self
15584 .subs
15585 .iter()
15586 .map(|s| (s.id.clone(), s.bootstrap_state.clone()))
15587 .collect::<Vec<_>>();
15588 let outcome = (|| {
15589 let response = match response {
15590 Ok(response) => response,
15591 Err(TransportError {
15592 code,
15593 message,
15594 details,
15595 }) => {
15596 if transport_failed {
15597 if prepared.realtime {
15598 self.set_realtime_state(RealtimeState::Lost, Some(&code));
15599 }
15600 self.record_lease_error(&code);
15601 }
15602 if Self::retryable_failure_code(&code) {
15603 let delay = self.schedule_background_retry();
15604 if prepared.realtime && transport_failed {
15605 self.realtime_retry_delay_ms = Some(delay);
15606 }
15607 }
15608 return SyncOutcome::Failed {
15609 error_code: code,
15610 message,
15611 details,
15612 };
15613 }
15614 };
15615 let mut outcome = self.process_response(&mut downloads, response, &prepared.meta);
15616 if let SyncOutcome::Ok(report) = &mut outcome {
15617 report.deferred_commits = prepared.meta.deferred_commits;
15618 }
15619 match &outcome {
15620 SyncOutcome::Ok(_) => self.reset_background_retry(),
15621 SyncOutcome::Failed { error_code, .. }
15622 if Self::retryable_failure_code(error_code) =>
15623 {
15624 self.schedule_background_retry();
15625 }
15626 _ => {}
15627 }
15628 if prepared.meta.deferred_commits > 0 {
15629 self.set_sync_needed(true, true);
15630 }
15631 self.previous_version_lifetime_check();
15632 outcome
15633 })();
15634 let more = self.transport_enabled
15635 && matches!(&outcome, SyncOutcome::Ok(report)
15636 if report.schema_floor.is_none()
15637 && (!report.bootstrapping.is_empty()
15638 || report.commits_applied > 0
15639 || report.segment_rows_applied > 0
15640 || !report.resets.is_empty()
15641 || report.deferred_commits > 0
15642 || self.sync_needed));
15643 let bootstrap_advanced = matches!(&outcome, SyncOutcome::Ok(report) if report.segment_rows_applied > 0 && !report.bootstrapping.is_empty())
15644 && before
15645 != self
15646 .subs
15647 .iter()
15648 .map(|s| (s.id.clone(), s.bootstrap_state.clone()))
15649 .collect::<Vec<_>>();
15650 crate::AppliedSyncRound::Complete {
15651 outcome: self.finish_sync_round(prepared.started_at_ms, outcome),
15652 controls: if self.transport_enabled {
15653 downloads.controls
15654 } else {
15655 Vec::new()
15656 },
15657 more,
15658 bootstrap_advanced,
15659 }
15660 }
15661
15662 pub fn sync_until_idle(
15663 &mut self,
15664 transport: &mut dyn Transport,
15665 max_rounds: Option<u32>,
15666 ) -> SyncOutcome {
15667 if max_rounds == Some(0) {
15670 return SyncOutcome::Failed {
15671 details: None,
15672 error_code: "sync.invalid_request".into(),
15673 message: "maxRounds must be a positive integer".into(),
15674 };
15675 }
15676 let rounds = max_rounds.unwrap_or(20);
15677 let mut aggregate = SyncReport::default();
15678 let mut spent = 0;
15679 while spent < rounds {
15680 spent += 1;
15681 let before = self
15682 .subs
15683 .iter()
15684 .map(|sub| (sub.id.clone(), sub.bootstrap_state.clone()))
15685 .collect::<Vec<_>>();
15686 match self.sync(transport) {
15687 SyncOutcome::Failed {
15688 error_code,
15689 message,
15690 details,
15691 } => {
15692 return SyncOutcome::Failed {
15693 details,
15694 error_code,
15695 message,
15696 };
15697 }
15698 outcome @ SyncOutcome::RealtimeUnavailable { .. } => return outcome,
15699 SyncOutcome::BudgetExhausted(_) => {
15700 unreachable!("sync() never exhausts a round budget")
15701 }
15702 SyncOutcome::Ok(report) => {
15703 if max_rounds.is_none()
15704 && report.segment_rows_applied > 0
15705 && !report.bootstrapping.is_empty()
15706 && before
15707 != self
15708 .subs
15709 .iter()
15710 .map(|sub| (sub.id.clone(), sub.bootstrap_state.clone()))
15711 .collect::<Vec<_>>()
15712 {
15713 spent = 0;
15714 }
15715 let stop = !self.transport_enabled() || report.schema_floor.is_some();
15718 aggregate.merge(&report);
15719 if stop {
15720 return SyncOutcome::Ok(aggregate);
15721 }
15722 let more = !report.bootstrapping.is_empty()
15728 || report.commits_applied > 0
15729 || report.segment_rows_applied > 0
15730 || !report.resets.is_empty()
15731 || report.deferred_commits > 0
15732 || self.sync_needed;
15733 if !more {
15734 return SyncOutcome::Ok(aggregate);
15735 }
15736 }
15737 }
15738 }
15739 SyncOutcome::BudgetExhausted(aggregate)
15742 }
15743
15744 fn process_response(
15745 &mut self,
15746 transport: &mut dyn Transport,
15747 response: Message,
15748 meta: &RequestMeta,
15749 ) -> SyncOutcome {
15750 #[cfg(feature = "bench-internals")]
15751 let _phase = self.benchmark_phases.start(Phase::ResponseApply);
15752 let mut report = SyncReport {
15753 pushed: meta.pushed_ids.len() as u32,
15754 ..SyncReport::default()
15755 };
15756 let mut rejection_details_by_commit: HashMap<String, BTreeMap<i32, RejectionDetails>> =
15757 HashMap::new();
15758 let pushed_ids = meta
15759 .pushed_ids
15760 .iter()
15761 .map(String::as_str)
15762 .collect::<HashSet<_>>();
15763 let mut last_final_push_result_id: Option<String> = None;
15764 for frame in &response.frames {
15765 match frame {
15766 Frame::PushResultDetails {
15767 client_commit_id,
15768 entries,
15769 } => {
15770 let details = rejection_details_by_commit
15771 .entry(client_commit_id.clone())
15772 .or_default();
15773 for entry in entries {
15774 let parsed = match RejectionDetails::parse(&entry.details.0) {
15775 Ok(value) => value,
15776 Err(message) => {
15777 return SyncOutcome::Failed {
15778 details: None,
15779 error_code: "sync.invalid_request".to_owned(),
15780 message,
15781 };
15782 }
15783 };
15784 details.insert(entry.op_index, parsed);
15785 }
15786 }
15787 Frame::PushResult {
15788 client_commit_id,
15789 status,
15790 results,
15791 ..
15792 } if pushed_ids.contains(client_commit_id.as_str())
15793 && Self::push_result_is_final(*status, results) =>
15794 {
15795 last_final_push_result_id = Some(client_commit_id.clone());
15796 }
15797 _ => {}
15798 }
15799 }
15800 let mut frames = response.frames.into_iter().peekable();
15801 if response.wire_version < 2 {
15802 return SyncOutcome::Failed {
15803 details: None,
15804 error_code: "client.invalid_host_response".to_owned(),
15805 message: "server response does not carry wire version 2 log-epoch state".to_owned(),
15806 };
15807 }
15808 match frames.next() {
15809 Some(Frame::RespHeader {
15810 required_schema_version,
15811 latest_schema_version,
15812 log_epoch,
15813 reset_required,
15814 }) => {
15815 let Some(log_epoch) = log_epoch else {
15816 return SyncOutcome::Failed {
15817 details: None,
15818 error_code: "client.invalid_host_response".to_owned(),
15819 message: "response header omits logEpoch".to_owned(),
15820 };
15821 };
15822 let Some(reset_required) = reset_required else {
15823 return SyncOutcome::Failed {
15824 details: None,
15825 error_code: "client.invalid_host_response".to_owned(),
15826 message: "response header omits resetRequired".to_owned(),
15827 };
15828 };
15829 if let Some(required) = required_schema_version {
15830 let floor = SchemaFloor {
15833 required_schema_version: Some(required),
15834 latest_schema_version,
15835 };
15836 self.set_schema_floor(Some(floor.clone()));
15837 report.schema_floor = Some(floor);
15838 return SyncOutcome::Ok(report);
15839 }
15840 if reset_required {
15841 if frames.next().is_some() {
15842 return SyncOutcome::Failed {
15843 details: None,
15844 error_code: "client.invalid_host_response".to_owned(),
15845 message: "log-epoch reset response contains body frames".to_owned(),
15846 };
15847 }
15848 match self.run_log_epoch_reset(&log_epoch) {
15849 Ok(resets) => {
15850 report.resets = resets;
15851 return SyncOutcome::Ok(report);
15852 }
15853 Err(message) => {
15854 return SyncOutcome::Failed {
15855 details: None,
15856 error_code: "sync.local_corrupt".to_owned(),
15857 message,
15858 };
15859 }
15860 }
15861 }
15862 if self.get_meta(LOG_EPOCH_KEY).as_deref() != Some(log_epoch.as_str()) {
15863 return SyncOutcome::Failed {
15864 details: None,
15865 error_code: "client.invalid_host_response".to_owned(),
15866 message: "server changed logEpoch without requiring a reset".to_owned(),
15867 };
15868 }
15869 }
15870 _ => {
15871 return SyncOutcome::Failed {
15872 details: None,
15873 error_code: "sync.invalid_request".to_owned(),
15874 message: "response does not start with RESP_HEADER".to_owned(),
15875 };
15876 }
15877 }
15878
15879 let mut failure: Option<(String, String)> = None;
15880 while let Some(frame) = frames.next() {
15881 match frame {
15882 frame @ Frame::PushResult { .. } => {
15883 let successful = matches!(
15884 &frame,
15885 Frame::PushResult {
15886 status: PushStatus::Applied | PushStatus::Cached,
15887 ..
15888 }
15889 );
15890 let mut results = vec![frame];
15891 if successful {
15892 while matches!(
15893 frames.peek(),
15894 Some(Frame::PushResult {
15895 status: PushStatus::Applied | PushStatus::Cached,
15896 ..
15897 })
15898 ) {
15899 if let Some(next) = frames.next() {
15900 results.push(next);
15901 }
15902 }
15903 }
15904 if self
15905 .handle_push_results(
15906 &results,
15907 &pushed_ids,
15908 &rejection_details_by_commit,
15909 &mut report,
15910 last_final_push_result_id.as_deref(),
15911 )
15912 .is_err()
15913 {
15914 failure = Some((
15915 "client.outcome_persistence_failed".to_owned(),
15916 "local commit outcome could not be persisted".to_owned(),
15917 ));
15918 break;
15919 }
15920 }
15921 Frame::PushResultDetails { .. } => {}
15922 Frame::SubStart {
15923 id,
15924 status,
15925 reason_code,
15926 effective_scopes,
15927 bootstrap: _,
15928 } => {
15929 let mut body = Vec::new();
15930 let mut sub_end: Option<(i64, Option<String>)> = None;
15931 for inner in frames.by_ref() {
15932 match inner {
15933 Frame::SubEnd {
15934 next_cursor,
15935 bootstrap_state,
15936 } => {
15937 sub_end = Some((next_cursor, bootstrap_state.map(|r| r.0)));
15938 break;
15939 }
15940 Frame::Unknown { .. } => {}
15941 other => body.push(other),
15942 }
15943 }
15944 if let Err(SectionError::Abort(code, message)) = self.process_section(
15945 transport,
15946 &id,
15947 status,
15948 &reason_code,
15949 effective_scopes,
15950 body,
15951 sub_end,
15952 meta,
15953 &mut report,
15954 ) {
15955 failure = Some((code, message));
15956 break;
15957 }
15958 }
15959 Frame::Lease {
15960 lease_id,
15961 expires_at_ms,
15962 } => {
15963 self.set_lease_state(Some(LeaseState {
15966 lease_id: Some(lease_id),
15967 expires_at_ms: Some(expires_at_ms),
15968 error_code: None,
15969 }));
15970 }
15971 Frame::Error { code, message, .. } => {
15972 failure = Some((code, message));
15975 break;
15976 }
15977 Frame::Unknown { .. } => {}
15978 _ => {
15979 failure = Some((
15980 "sync.invalid_request".to_owned(),
15981 "unexpected frame in response".to_owned(),
15982 ));
15983 break;
15984 }
15985 }
15986 }
15987
15988 if let Some((error_code, message)) = failure {
15989 return SyncOutcome::Failed {
15990 details: None,
15991 error_code,
15992 message,
15993 };
15994 }
15995 if let Err(message) = self.rebuild_overlay_if_dirty() {
15998 return SyncOutcome::Failed {
15999 details: None,
16000 error_code: "storage.failed".into(),
16001 message,
16002 };
16003 }
16004 if let Err(message) = self.drain_pending_evictions() {
16007 return SyncOutcome::Failed {
16008 details: None,
16009 error_code: "storage.failed".into(),
16010 message,
16011 };
16012 }
16013 self.ack_after_pull(transport);
16014 if self.upgrading && report.bootstrapping.is_empty() {
16017 self.set_upgrading(false);
16018 }
16019 SyncOutcome::Ok(report)
16020 }
16021
16022 fn handle_push_results(
16025 &mut self,
16026 frames: &[Frame],
16027 pushed_ids: &HashSet<&str>,
16028 rejection_details_by_commit: &HashMap<String, BTreeMap<i32, RejectionDetails>>,
16029 report: &mut SyncReport,
16030 last_final_push_result_id: Option<&str>,
16031 ) -> Result<(), String> {
16032 self.begin_observation("syncular_push_results")?;
16033 let previous_failed = self.failed_commits.clone();
16034 let previous_sync_needed = self.sync_needed;
16035 let conflict_count = self.conflicts.len();
16036 let rejection_count = self.rejections.len();
16037 let applied_count = report.applied.len();
16038 let rejected_count = report.rejected.len();
16039 let retryable_count = report.retryable.len();
16040 let was_dirty = self.overlay_dirty.snapshot();
16041 let mut removed = Vec::new();
16042 let mut batch = ChangeAccumulator::default();
16043 let persisted = (|| {
16044 for frame in frames {
16045 let Frame::PushResult {
16046 client_commit_id,
16047 status,
16048 results,
16049 commit_seq,
16050 } = frame
16051 else {
16052 return Err("unexpected acknowledgement frame".to_owned());
16053 };
16054 if !pushed_ids.contains(client_commit_id.as_str()) {
16055 continue;
16056 }
16057 let Some(index) = self
16058 .outbox
16059 .iter()
16060 .position(|commit| commit.client_commit_id == *client_commit_id)
16061 else {
16062 continue;
16063 };
16064 let status = *status;
16065 let rejection_details = rejection_details_by_commit.get(client_commit_id);
16066 let operations = self.outbox[index].ops.clone();
16067 match status {
16068 PushStatus::Applied | PushStatus::Cached => {
16069 let sequence =
16072 commit_seq.ok_or_else(|| "ACK lacks a commit sequence".to_owned())?;
16073 let affected: HashSet<_> = operations
16074 .iter()
16075 .map(|op| (op.table.as_str(), op.row_id.as_str()))
16076 .collect();
16077 let prior = self.acknowledged_operations(Some(&affected), None)?;
16078 for (op_index, operation) in operations.iter().enumerate() {
16079 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))
16080 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
16081 if delivered {
16082 continue;
16083 }
16084 let table = self
16085 .schema
16086 .table(&operation.table)
16087 .ok_or_else(|| "sync.unknown_table".to_owned())?;
16088 let mut intended: Map<String, Value> = self
16089 .stored_row(table, &operation.row_id, true)?
16090 .map(|(row, _)| {
16091 table
16092 .columns
16093 .iter()
16094 .zip(&row)
16095 .map(|(column, value)| {
16096 (column.name.clone(), column_value_to_json(value))
16097 })
16098 .collect()
16099 })
16100 .unwrap_or_default();
16101 let mut scope_image = intended.clone();
16102 for op in prior
16103 .iter()
16104 .chain(
16105 self.failed_commits
16106 .iter()
16107 .flat_map(|(commit, _)| &commit.ops),
16108 )
16109 .chain(
16110 self.outbox
16111 .iter()
16112 .take(index)
16113 .flat_map(|commit| &commit.ops),
16114 )
16115 .chain(operations.iter().take(op_index + 1))
16116 {
16117 if op.table != operation.table || op.row_id != operation.row_id {
16118 continue;
16119 }
16120 if let Some(values) = &op.values {
16121 intended.extend(values.clone());
16122 } else {
16123 scope_image = intended.clone();
16124 intended.clear();
16125 }
16126 }
16127 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)",
16128 rusqlite::params![client_commit_id, op_index as i64, operation.table, operation.row_id, sequence,
16129 serde_json::to_string(&CommitOperation::from(operation)).map_err(|_| "sync.local_corrupt".to_owned())?,
16130 serde_json::to_string(if operation.upsert { &intended } else { &scope_image }).map_err(|_| "sync.local_corrupt".to_owned())?])
16131 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
16132 }
16133 let journal_results = results
16134 .iter()
16135 .map(|result| {
16136 let op_index = match result {
16137 OpResult::Applied { op_index }
16138 | OpResult::Conflict { op_index, .. }
16139 | OpResult::Error { op_index, .. } => *op_index,
16140 };
16141 CommitOperationOutcome::Applied { op_index }
16142 })
16143 .collect::<Vec<_>>();
16144 let outcome_status = if status == PushStatus::Applied {
16145 CommitOutcomeStatus::Applied
16146 } else {
16147 CommitOutcomeStatus::Cached
16148 };
16149 let persisted = self
16150 .persist_commit_outcome(
16151 client_commit_id,
16152 outcome_status,
16153 &journal_results,
16154 None,
16155 )
16156 .and_then(|()| self.delete_outbox_persisted(client_commit_id));
16157 persisted?;
16158 for operation in &operations {
16159 self.overlay_dirty.table(&operation.table);
16160 }
16161 self.sync_needed = true;
16162 batch.status = true;
16163 batch.outcomes = true;
16164 }
16165 PushStatus::Rejected => {
16166 if results.iter().any(|result| {
16167 matches!(
16168 result,
16169 OpResult::Error {
16170 code,
16171 retryable: true,
16172 ..
16173 } if code == "sync.idempotency_cache_miss"
16174 )
16175 }) {
16176 report.retryable.push(client_commit_id.to_owned());
16179 continue;
16180 }
16181
16182 let mut journal_results = Vec::with_capacity(results.len());
16183 let mut conflicts = Vec::new();
16184 let mut rejections = Vec::new();
16185 for result in results {
16186 match result {
16187 OpResult::Applied { op_index } => {
16188 journal_results.push(CommitOperationOutcome::Applied {
16189 op_index: *op_index,
16190 });
16191 }
16192 OpResult::Conflict {
16193 op_index,
16194 code,
16195 message,
16196 server_version,
16197 server_row,
16198 conflict_columns: conflict_columns_bitmap,
16199 } => {
16200 let operation = operations
16201 .get(*op_index as usize)
16202 .map(CommitOperation::from);
16203 let (table, row_id) = operation
16204 .as_ref()
16205 .map(|op| (op.table.clone(), op.row_id.clone()))
16206 .unwrap_or_default();
16207 let server_row_json = self
16208 .schema
16209 .table(&table)
16210 .and_then(|t| {
16211 decode_row_bytes(t, server_row, &self.encryption)
16212 .ok()
16213 .map(|row| (t, row))
16214 })
16215 .map(|(t, row)| {
16216 let mut map = Map::new();
16217 for (i, column) in t.columns.iter().enumerate() {
16218 map.insert(
16219 column.name.clone(),
16220 column_value_to_json(
16221 row.get(i).unwrap_or(&None),
16222 ),
16223 );
16224 }
16225 map
16226 })
16227 .unwrap_or_default();
16228 let conflict_columns = self
16231 .schema
16232 .table(&table)
16233 .map(|t| {
16234 t.columns
16235 .iter()
16236 .enumerate()
16237 .filter(|(index, _)| {
16238 conflict_columns_bitmap
16239 .get(index / 8)
16240 .is_some_and(|byte| {
16241 byte & (1 << (index % 8)) != 0
16242 })
16243 })
16244 .map(|(_, column)| column.name.clone())
16245 .collect::<Vec<_>>()
16246 })
16247 .unwrap_or_default();
16248 let conflict = ConflictRecord {
16249 client_commit_id: client_commit_id.to_owned(),
16250 op_index: *op_index,
16251 table,
16252 row_id,
16253 code: code.clone(),
16254 message: message.clone(),
16255 server_version: *server_version,
16256 server_row: server_row_json,
16257 conflict_columns,
16258 operation,
16259 };
16260 journal_results.push(CommitOperationOutcome::Conflict {
16261 conflict: conflict.clone(),
16262 });
16263 conflicts.push(conflict);
16264 }
16265 OpResult::Error {
16266 op_index,
16267 code,
16268 message,
16269 retryable,
16270 } => {
16271 let rejection = RejectionRecord {
16272 client_commit_id: client_commit_id.to_owned(),
16273 op_index: *op_index,
16274 code: code.clone(),
16275 message: message.clone(),
16276 retryable: *retryable,
16277 details: rejection_details
16278 .and_then(|details| details.get(op_index))
16279 .cloned(),
16280 operation: operations
16281 .get(*op_index as usize)
16282 .map(CommitOperation::from),
16283 };
16284 journal_results.push(CommitOperationOutcome::Error {
16285 rejection: rejection.clone(),
16286 });
16287 rejections.push(rejection);
16288 }
16289 }
16290 }
16291 if self.retain_failed_commits {
16292 self.retain_failed_commit(client_commit_id, &operations)?;
16293 }
16294 let outcome_status = if conflicts.is_empty() {
16295 CommitOutcomeStatus::Rejected
16296 } else {
16297 CommitOutcomeStatus::Conflict
16298 };
16299 let persisted = self
16300 .persist_commit_outcome(
16301 client_commit_id,
16302 outcome_status,
16303 &journal_results,
16304 Some(&operations),
16305 )
16306 .and_then(|()| self.delete_outbox_persisted(client_commit_id));
16307 persisted?;
16308 batch.conflicts = !conflicts.is_empty();
16309 batch.rejections = !rejections.is_empty();
16310 batch.status = true;
16311 batch.outcomes = true;
16312 self.conflicts.extend(conflicts);
16313 self.rejections.extend(rejections);
16314 for operation in &operations {
16315 self.overlay_dirty.table(&operation.table);
16316 }
16317 }
16318 }
16319 removed.push((index, self.outbox.remove(index)));
16320 if batch.status {
16321 for operation in &operations {
16322 if !self.record_row_scopes(
16323 &mut batch,
16324 &operation.table,
16325 &operation.row_id,
16326 false,
16327 ) {
16328 batch.table(&operation.table);
16329 }
16330 }
16331 }
16332 match status {
16333 PushStatus::Applied | PushStatus::Cached => {
16334 report.applied.push(client_commit_id.to_owned())
16335 }
16336 PushStatus::Rejected => report.rejected.push(client_commit_id.to_owned()),
16337 }
16338 }
16339 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)) {
16340 self.prune_commit_outcomes()?;
16341 }
16342 self.rebuild_overlay_if_dirty()?;
16343 self.finish_observation("syncular_push_results", batch)
16344 })();
16345 if let Err(error) = persisted {
16346 self.rollback_observation("syncular_push_results");
16347 self.failed_commits = previous_failed;
16348 self.sync_needed = previous_sync_needed;
16349 for (index, commit) in removed.into_iter().rev() {
16350 self.outbox.insert(index, commit);
16351 }
16352 self.conflicts.truncate(conflict_count);
16353 self.rejections.truncate(rejection_count);
16354 self.overlay_dirty.restore(was_dirty.clone());
16355 report.applied.truncate(applied_count);
16356 report.rejected.truncate(rejected_count);
16357 report.retryable.truncate(retryable_count);
16358 return Err(error);
16359 }
16360 if !previous_sync_needed && self.sync_needed {
16361 self.sync_intent_queue.push_back(SyncIntent::Interactive);
16362 }
16363 report.conflicts += (self.conflicts.len() - conflict_count) as u32;
16364 Ok(())
16365 }
16366
16367 fn push_result_is_final(status: PushStatus, results: &[OpResult]) -> bool {
16368 status != PushStatus::Rejected
16369 || !results.iter().any(|result| {
16370 matches!(
16371 result,
16372 OpResult::Error {
16373 code,
16374 retryable: true,
16375 ..
16376 } if code == "sync.idempotency_cache_miss"
16377 )
16378 })
16379 }
16380
16381 #[allow(clippy::too_many_arguments)]
16384 fn process_section(
16385 &mut self,
16386 transport: &mut dyn Transport,
16387 id: &str,
16388 status: SubStatus,
16389 reason_code: &str,
16390 effective_scopes: Vec<(String, Vec<String>)>,
16391 body: Vec<Frame>,
16392 sub_end: Option<(i64, Option<String>)>,
16393 meta: &RequestMeta,
16394 report: &mut SyncReport,
16395 ) -> Result<(), SectionError> {
16396 let Some(sub_index) = self.subs.iter().position(|s| s.id == id) else {
16397 return Ok(()); };
16399 if status != SubStatus::Active && sub_end.is_none() {
16400 return Err(SectionError::Abort(
16401 "sync.invalid_request".into(),
16402 "subscription section without SUB_END".into(),
16403 ));
16404 }
16405 match status {
16406 SubStatus::Revoked => {
16407 self.begin_observation("syncular_revocation")
16408 .map_err(|message| SectionError::Abort("storage.failed".to_owned(), message))?;
16409 let previous = self.subs[sub_index].clone();
16410 let was_dirty = self.overlay_dirty.snapshot();
16411 let previous_outbox = self.outbox.clone();
16412 let previous_failed = self.failed_commits.clone();
16413 let previous_rejections = self.rejections.clone();
16414 let previous_conflicts = self.conflicts.clone();
16415 let revoked_count = report.revoked.len();
16416 let failed_count = report.failed.len();
16417 let result = (|| {
16418 let mut batch = ChangeAccumulator::default();
16419 let registered = self.window_unit_by_sub_id(id);
16420 let (table, effective) = {
16422 let sub = &self.subs[sub_index];
16423 (sub.table.clone(), sub.effective.clone().unwrap_or_default())
16424 };
16425 let scoped_table = self.schema.table(&table).cloned().ok_or_else(|| {
16426 SectionError::Abort(
16427 "sync.unknown_table".to_owned(),
16428 "revoked scope references an unknown table".to_owned(),
16429 )
16430 })?;
16431 let dropped = self
16432 .drop_failed_commits(|name, values| {
16433 name == table
16434 && !effective.is_empty()
16435 && effective.iter().all(|(variable, allowed)| {
16436 scoped_table
16437 .scope_column(variable)
16438 .and_then(|column| values.get(column))
16439 .and_then(Value::as_str)
16440 .is_some_and(|value| {
16441 allowed.iter().any(|allowed| allowed == value)
16442 })
16443 })
16444 })
16445 .map_err(|message| {
16446 SectionError::Abort("storage.failed".to_owned(), message)
16447 })?;
16448 for commit in dropped {
16449 batch.conflicts = true;
16450 batch.rejections = true;
16451 batch.outcomes = true;
16452 for operation in commit.ops {
16453 batch.table(&operation.table);
16454 }
16455 }
16456 let purged = self.purge_scope_rows(&table, &effective);
16457 match purged {
16458 Ok(()) => {
16459 self.record_scope_map(&mut batch, &table, &effective);
16460 let sub = &mut self.subs[sub_index];
16461 sub.state = SubState::Revoked;
16462 sub.reason_code = Some(if reason_code.is_empty() {
16463 "sync.scope_revoked".to_owned()
16464 } else {
16465 reason_code.to_owned()
16466 });
16467 report.revoked.push(id.to_owned());
16468 let doomed_effective = effective;
16469 let sub_table = table;
16470 self.persist_sub(&self.subs[sub_index].clone())
16471 .map_err(|message| {
16472 SectionError::Abort("storage.failed".into(), message)
16473 })?;
16474 let dropped = self
16475 .drop_doomed_outbox(&sub_table, &doomed_effective)
16476 .map_err(|message| {
16477 SectionError::Abort("storage.failed".to_owned(), message)
16478 })?;
16479 if dropped {
16480 batch.status = true;
16481 batch.rejections = true;
16482 batch.outcomes = true;
16483 }
16484 self.delete_unreferenced_cached_blobs().map_err(|message| {
16487 SectionError::Abort("storage.failed".into(), message)
16488 })?;
16489 }
16490 Err(()) => {
16491 let sub = &mut self.subs[sub_index];
16494 sub.state = SubState::Failed;
16495 sub.reason_code = Some("sync.scope_revoked".to_owned());
16496 report.failed.push(id.to_owned());
16497 self.persist_sub(&self.subs[sub_index].clone())
16498 .map_err(|message| {
16499 SectionError::Abort("storage.failed".into(), message)
16500 })?;
16501 }
16502 }
16503 if let Some((base_key, unit)) = registered {
16504 batch.window(&base_key, &self.subs[sub_index].table, &unit);
16505 }
16506 self.rebuild_overlay_if_dirty()
16507 .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
16508 self.finish_observation("syncular_revocation", batch)
16509 .map_err(|message| {
16510 SectionError::Abort("storage.failed".to_owned(), message)
16511 })?;
16512 Ok(())
16513 })();
16514 if result.is_err() {
16515 self.rollback_observation("syncular_revocation");
16516 self.subs[sub_index] = previous;
16517 self.overlay_dirty.restore(was_dirty.clone());
16518 self.outbox = previous_outbox;
16519 self.failed_commits = previous_failed;
16520 self.rejections = previous_rejections;
16521 self.conflicts = previous_conflicts;
16522 report.revoked.truncate(revoked_count);
16523 report.failed.truncate(failed_count);
16524 }
16525 result
16526 }
16527 SubStatus::Reset => {
16528 self.begin_observation("syncular_reset")
16529 .map_err(|message| SectionError::Abort("storage.failed".to_owned(), message))?;
16530 let previous = self.subs[sub_index].clone();
16531 let was_dirty = self.overlay_dirty.snapshot();
16532 let reset_count = report.resets.len();
16533 let result = (|| {
16534 let mut batch = ChangeAccumulator::default();
16535 let registered = self.window_unit_by_sub_id(id);
16536 let sub = &mut self.subs[sub_index];
16539 sub.cursor = -1;
16540 sub.bootstrap_state = None;
16541 sub.reason_code = Some(if reason_code.is_empty() {
16542 "sync.cursor_expired".to_owned()
16543 } else {
16544 reason_code.to_owned()
16545 });
16546 report.resets.push(id.to_owned());
16547 self.persist_sub(&self.subs[sub_index].clone())
16548 .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
16549 if let Some((base_key, unit)) = registered {
16550 batch.window(&base_key, &self.subs[sub_index].table, &unit);
16551 }
16552 self.finish_observation("syncular_reset", batch)
16553 .map_err(|message| {
16554 SectionError::Abort("storage.failed".to_owned(), message)
16555 })?;
16556 Ok(())
16557 })();
16558 if result.is_err() {
16559 self.rollback_observation("syncular_reset");
16560 self.subs[sub_index] = previous;
16561 self.overlay_dirty.restore(was_dirty.clone());
16562 report.resets.truncate(reset_count);
16563 }
16564 result
16565 }
16566 SubStatus::Active => {
16567 let fresh = meta
16568 .fresh
16569 .iter()
16570 .find(|(fid, _)| fid == id)
16571 .map(|(_, fresh)| *fresh)
16572 .unwrap_or(false);
16573 let previous = self.subs[sub_index].clone();
16574 self.subs[sub_index].effective = Some(effective_scopes);
16575 let outcome = self
16576 .apply_section_body(transport, sub_index, body, fresh, meta, report)
16577 .and_then(|()| {
16578 let (cursor, bootstrap_state) = sub_end.ok_or_else(|| {
16579 SectionError::Abort(
16580 "sync.invalid_request".into(),
16581 "subscription section without SUB_END".into(),
16582 )
16583 })?;
16584 self.apply_sub_end(sub_index, cursor, bootstrap_state)
16585 .map_err(|message| {
16586 SectionError::Abort("storage.failed".into(), message)
16587 })
16588 });
16589 match outcome {
16590 Ok(()) => {
16591 if self.subs[sub_index].bootstrap_state.is_some() {
16592 report.bootstrapping.push(id.to_owned());
16593 }
16594 Ok(())
16595 }
16596 Err(SectionError::FailClosed) => {
16597 self.subs[sub_index] = previous.clone();
16598 self.begin_observation("syncular_section_failure")
16599 .map_err(|message| {
16600 SectionError::Abort("storage.failed".into(), message)
16601 })?;
16602 let persisted = (|| {
16603 let mut batch = ChangeAccumulator::default();
16604 if let Some((base_key, unit)) = self.window_unit_by_sub_id(id) {
16605 batch.window(&base_key, &self.subs[sub_index].table, &unit);
16606 }
16607 self.subs[sub_index].state = SubState::Failed;
16608 self.subs[sub_index].reason_code = Some("sync.scope_revoked".into());
16609 self.persist_sub(&self.subs[sub_index])?;
16610 self.finish_observation("syncular_section_failure", batch)
16611 })();
16612 if let Err(message) = persisted {
16613 self.rollback_observation("syncular_section_failure");
16614 self.subs[sub_index] = previous;
16615 return Err(SectionError::Abort("storage.failed".into(), message));
16616 }
16617 report.failed.push(id.to_owned());
16618 Ok(())
16619 }
16620 Err(error) => {
16621 self.subs[sub_index] = previous;
16624 Err(error)
16625 }
16626 }
16627 }
16628 }
16629 }
16630
16631 fn apply_sub_end(
16632 &mut self,
16633 sub_index: usize,
16634 cursor: i64,
16635 bootstrap_state: Option<String>,
16636 ) -> Result<(), String> {
16637 #[cfg(feature = "bench-internals")]
16638 let _phase = self.benchmark_phases.start(Phase::CursorPersist);
16639 let previous = self.subs[sub_index].clone();
16640 self.begin_observation("syncular_sub_end")?;
16641 let persisted = (|| {
16642 let mut batch = ChangeAccumulator::default();
16643 let completed = (previous.cursor < 0 || previous.bootstrap_state.is_some())
16644 && cursor >= 0
16645 && bootstrap_state.is_none();
16646 if completed {
16647 let table = self
16648 .schema
16649 .table(&previous.table)
16650 .ok_or_else(|| "sync.unknown_table".to_owned())?;
16651 let effective = previous.effective.as_deref().unwrap_or(&[]);
16652 let mut statement = self.conn.prepare("SELECT id,intent_json FROM _syncular_acked_rows WHERE tbl=?1 AND commit_seq<=?2")
16653 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
16654 let rows = statement
16655 .query_map(rusqlite::params![previous.table, cursor], |row| {
16656 Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
16657 })
16658 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
16659 let mut covered = HashSet::new();
16660 for row in rows {
16661 let (id, intent) =
16662 row.map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
16663 let values: Map<String, Value> = serde_json::from_str(&intent)
16664 .map_err(|_| "sync.local_corrupt".to_owned())?;
16665 if !effective.is_empty()
16666 && effective.iter().all(|(variable, allowed)| {
16667 table
16668 .scope_column(variable)
16669 .and_then(|column| values.get(column))
16670 .is_some_and(|value| {
16671 allowed.contains(
16672 &value
16673 .as_str()
16674 .map_or_else(|| value.to_string(), str::to_owned),
16675 )
16676 })
16677 })
16678 {
16679 covered.insert(id);
16680 }
16681 }
16682 drop(statement);
16683 for id in covered {
16684 self.conn.execute("DELETE FROM _syncular_acked_rows WHERE tbl=?1 AND id=?2 AND commit_seq<=?3", rusqlite::params![previous.table, id, cursor])
16685 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
16686 self.overlay_dirty.table(&previous.table);
16687 batch.table(&previous.table);
16688 }
16689 self.rebuild_overlay_if_dirty()?;
16690 if let Some((base_key, unit)) = self.window_unit_by_sub_id(&previous.id) {
16691 batch.window(&base_key, &previous.table, &unit);
16692 }
16693 }
16694 let sub = &mut self.subs[sub_index];
16695 sub.cursor = cursor;
16696 sub.bootstrap_state = bootstrap_state;
16697 sub.reason_code = None;
16700 sub.synced_once = true;
16701 self.persist_sub(&self.subs[sub_index])?;
16702 self.finish_observation("syncular_sub_end", batch)
16703 })();
16704 if persisted.is_err() {
16705 self.rollback_observation("syncular_sub_end");
16706 self.subs[sub_index] = previous;
16707 }
16708 persisted
16709 }
16710
16711 fn apply_section_body(
16712 &mut self,
16713 transport: &mut dyn Transport,
16714 sub_index: usize,
16715 body: Vec<Frame>,
16716 fresh: bool,
16717 meta: &RequestMeta,
16718 report: &mut SyncReport,
16719 ) -> Result<(), SectionError> {
16720 let mut saw_segment = false;
16721 for frame in body {
16722 match frame {
16723 Frame::Commit {
16724 tables,
16725 changes,
16726 commit_seq,
16727 ..
16728 } => {
16729 self.apply_commit_frame(&tables, &changes, Some(commit_seq))
16730 .map_err(|(c, m)| SectionError::Abort(c, m))?;
16731 report.commits_applied += 1;
16732 }
16733 Frame::SegmentInline { payload } => {
16734 let segment = decode_rows_segment(&payload)
16735 .map_err(|e| SectionError::Abort(e.code.as_str().to_owned(), e.detail))?;
16736 self.progress.update(|p| {
16737 p.phase = ProgressPhase::Import;
16738 p.subscription_id = Some(self.subs[sub_index].id.clone());
16739 p.table = Some(self.subs[sub_index].table.clone());
16740 p.segment_id = None;
16741 p.bytes_received = payload.len() as u64;
16742 p.bytes_total = Some(payload.len() as u64);
16743 p.rows_processed = 0;
16744 p.rows_total =
16745 Some(segment.blocks.iter().map(|block| block.len() as u64).sum());
16746 });
16747 let first = !saw_segment;
16748 saw_segment = true;
16749 let applied = self.apply_segment(sub_index, &segment, fresh && first)?;
16750 report.segment_rows_applied += applied;
16751 }
16752 Frame::SegmentRef {
16753 segment_id,
16754 byte_length,
16755 media_type,
16756 table,
16757 row_count,
16758 as_of_commit_seq,
16759 scope_digest,
16760 row_cursor,
16761 next_row_cursor,
16762 url,
16763 url_expires_at_ms,
16764 ..
16765 } => {
16766 let advertised = match media_type {
16769 MediaType::Rows => {
16770 meta.accept & ACCEPT_EXTERNAL_ROWS != 0
16771 || meta.accept & ACCEPT_INLINE_ROWS != 0
16772 }
16773 MediaType::Sqlite => meta.accept & ACCEPT_SQLITE != 0,
16774 };
16775 if !advertised {
16776 return Err(SectionError::Abort(
16777 "sync.invalid_request".to_owned(),
16778 format!(
16779 "SEGMENT_REF mediaType {} was not advertised in accept (§4.2)",
16780 media_type.name()
16781 ),
16782 ));
16783 }
16784 self.progress.update(|p| {
16785 p.phase = ProgressPhase::Download;
16786 p.subscription_id = Some(self.subs[sub_index].id.clone());
16787 p.table = Some(table.clone());
16788 p.segment_id = Some(segment_id.clone());
16789 p.bytes_received = 0;
16790 p.bytes_total = u64::try_from(byte_length).ok();
16791 p.rows_processed = 0;
16792 p.rows_total = u64::try_from(row_count).ok();
16793 });
16794 let observer = self.progress.clone();
16795 let mut on_progress = |bytes| observer.update(|p| p.bytes_received = bytes);
16796 let bytes = if let Some(url) = url {
16797 if meta.accept & ACCEPT_SIGNED_URLS == 0 {
16802 return Err(SectionError::Abort(
16803 "sync.invalid_request".to_owned(),
16804 "SEGMENT_REF carries a url but accept bit 3 was not advertised (§5.4)"
16805 .to_owned(),
16806 ));
16807 }
16808 if url_expires_at_ms.is_some_and(|exp| {
16810 exp <= transport
16811 .segment_fetch_started_at(&url)
16812 .unwrap_or_else(|| self.clock_now_ms())
16813 }) {
16814 return Err(SectionError::Abort(
16815 "sync.segment_expired".to_owned(),
16816 format!(
16817 "signed URL for segment {segment_id} expired before fetch — re-pull mints fresh descriptors (§5.4)"
16818 ),
16819 ));
16820 }
16821 transport
16822 .fetch_url(&url, &mut on_progress)
16823 .map_err(|e| SectionError::Abort(e.code, e.message))?
16824 } else {
16825 let requested_scopes_json =
16826 canonical_scope_json(&self.subs[sub_index].requested);
16827 transport
16828 .download_segment(
16829 &SegmentRequest {
16830 segment_id: segment_id.clone(),
16831 table,
16832 requested_scopes_json,
16833 },
16834 &mut on_progress,
16835 )
16836 .map_err(|e| SectionError::Abort(e.code, e.message))?
16837 };
16838 on_progress(bytes.len() as u64);
16840 let digest = Sha256::digest(&bytes);
16841 let expected = segment_id
16842 .strip_prefix("sha256:")
16843 .unwrap_or(segment_id.as_str());
16844 if bytes_to_hex(&digest) != expected {
16845 self.schedule_background_retry();
16847 return Err(SectionError::Abort(
16848 "sync.invalid_request".to_owned(),
16849 "segment bytes do not match the content address (§5.1)".to_owned(),
16850 ));
16851 }
16852 self.progress.update(|p| p.phase = ProgressPhase::Import);
16853 if media_type == MediaType::Sqlite {
16854 if row_cursor.is_some() || next_row_cursor.is_some() {
16857 return Err(SectionError::Abort(
16858 "sync.invalid_request".to_owned(),
16859 "sqlite segments are whole-table: rowCursor/nextRowCursor must be absent (§5.3)"
16860 .to_owned(),
16861 ));
16862 }
16863 let first = !saw_segment;
16864 saw_segment = true;
16865 let applied = self.apply_sqlite_segment(
16866 sub_index,
16867 &bytes,
16868 fresh && first,
16869 row_count,
16870 as_of_commit_seq,
16871 &scope_digest,
16872 )?;
16873 report.segment_rows_applied += applied;
16874 } else {
16875 let segment = decode_rows_segment(&bytes).map_err(|e| {
16876 SectionError::Abort(e.code.as_str().to_owned(), e.detail)
16877 })?;
16878 let first = row_cursor.is_none();
16879 saw_segment = true;
16880 let applied = self.apply_segment(sub_index, &segment, fresh && first)?;
16881 report.segment_rows_applied += applied;
16882 }
16883 }
16884 Frame::Unknown { .. } => {}
16885 Frame::Error { code, message, .. } => {
16886 return Err(SectionError::Abort(code, message))
16887 }
16888 _ => {
16889 return Err(SectionError::Abort(
16890 "sync.invalid_request".to_owned(),
16891 "unexpected frame inside a subscription section".to_owned(),
16892 ));
16893 }
16894 }
16895 }
16896 Ok(())
16897 }
16898
16899 fn apply_commit_frame(
16901 &mut self,
16902 tables: &[String],
16903 changes: &[ssp2::model::Change],
16904 commit_seq: Option<i64>,
16905 ) -> Result<(), (String, String)> {
16906 #[cfg(feature = "bench-internals")]
16907 let _phase = self.benchmark_phases.start(Phase::CommitApply);
16908 let was_dirty = self.overlay_dirty.snapshot();
16909 self.begin_observation("syncular_commit")
16910 .map_err(|message| ("storage.failed".into(), message))?;
16911 let applied = (|| {
16912 let mut batch = ChangeAccumulator::default();
16913 self.record_commit_changes(&mut batch, tables, changes);
16914 if let Some(sequence) = commit_seq {
16915 for change in changes {
16916 let table = tables.get(change.table_index as usize).ok_or_else(|| {
16917 (
16918 "sync.invalid_request".to_owned(),
16919 "change tableIndex out of range".to_owned(),
16920 )
16921 })?;
16922 if self
16923 .outbox
16924 .iter()
16925 .flat_map(|commit| &commit.ops)
16926 .any(|op| op.table == *table && op.row_id == change.row_id)
16927 {
16928 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])
16929 .map_err(|error| ("storage.failed".to_owned(), Self::sqlite_failure(&self.storage_failure, error)))?;
16930 }
16931 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])
16932 .map_err(|error| ("storage.failed".to_owned(), Self::sqlite_failure(&self.storage_failure, error)))?;
16933 }
16934 }
16935 self.apply_commit_changes(tables, changes)?;
16936 self.rebuild_overlay_if_dirty()
16937 .map_err(|message| ("storage.failed".into(), message))?;
16938 self.finish_observation("syncular_commit", batch)
16939 .map_err(|message| ("storage.failed".into(), message))
16940 })();
16941 if applied.is_err() {
16942 self.rollback_observation("syncular_commit");
16943 self.overlay_dirty.restore(was_dirty.clone());
16944 }
16945 applied
16946 }
16947
16948 fn apply_commit_changes(
16949 &mut self,
16950 tables: &[String],
16951 changes: &[ssp2::model::Change],
16952 ) -> Result<(), (String, String)> {
16953 let mirror_visible = !self.overlay_dirty.get()
16958 && self.failed_commits.is_empty()
16959 && ((self.outbox.is_empty() && !self.has_acknowledged_rows())
16960 || changes.iter().all(|change| {
16961 tables
16962 .get(change.table_index as usize)
16963 .and_then(|name| self.schema.table(name))
16964 .is_some_and(|table| !table.indexes.iter().any(|index| index.unique))
16965 }));
16966 for change in changes {
16967 let table_name = tables.get(change.table_index as usize).ok_or_else(|| {
16968 (
16969 "sync.invalid_request".to_owned(),
16970 "change tableIndex out of range".to_owned(),
16971 )
16972 })?;
16973 let table = self.schema.table(table_name).ok_or_else(|| {
16974 (
16975 "sync.schema_mismatch".to_owned(),
16976 format!("change targets unknown table {table_name:?}"),
16977 )
16978 })?;
16979 match change.op {
16980 Op::Upsert => {
16981 let payload = change.row.as_ref().ok_or_else(|| {
16982 (
16983 "sync.invalid_request".to_owned(),
16984 "upsert change without row payload".to_owned(),
16985 )
16986 })?;
16987 let row = {
16989 #[cfg(feature = "bench-internals")]
16990 let _phase = self.benchmark_phases.start(Phase::RowDecode);
16991 decode_row_bytes(table, payload, &self.encryption)
16992 .map_err(|m| ("sync.invalid_request".to_owned(), m))?
16993 };
16994 let version = change.row_version.unwrap_or(0);
16995 let table_name = table.name.clone();
16996 self.write_base_row(&table_name, &row, version)
16997 .map_err(|m| ("sync.invalid_request".to_owned(), m))?;
16998 if mirror_visible {
16999 self.write_row(&visible_table(&table_name), &table_name, &row, version)
17000 .map_err(|m| ("sync.invalid_request".to_owned(), m))?;
17001 }
17002 }
17003 Op::Delete => {
17004 self.delete_base_row(table_name, &change.row_id)
17005 .map_err(|m| ("sync.invalid_request".to_owned(), m))?;
17006 if mirror_visible {
17007 let sql = format!(
17008 "DELETE FROM {} WHERE {}",
17009 visible_table(table_name),
17010 row_id_predicate(table)
17011 );
17012 self.conn
17013 .prepare_cached(&sql)
17014 .and_then(|mut statement| {
17015 statement.execute(rusqlite::params![change.row_id])
17016 })
17017 .map_err(|error| {
17018 ("sync.invalid_request".to_owned(), error.to_string())
17019 })?;
17020 }
17021 }
17022 }
17023 }
17024 if mirror_visible {
17025 {
17026 let changed_rows: HashSet<(&str, &str)> = changes
17027 .iter()
17028 .map(|change| {
17029 (
17030 tables[change.table_index as usize].as_str(),
17031 change.row_id.as_str(),
17032 )
17033 })
17034 .collect();
17035 let acknowledged = self
17036 .acknowledged_operations(Some(&changed_rows), None)
17037 .map_err(|error| ("storage.failed".to_owned(), error))?;
17038 self.apply_outbox_ops(&acknowledged)
17039 .map_err(|message| ("storage.failed".into(), message))?;
17040 self.apply_outbox_ops(
17041 self.outbox
17042 .iter()
17043 .flat_map(|commit| &commit.ops)
17044 .filter(|op| {
17045 changed_rows.contains(&(op.table.as_str(), op.row_id.as_str()))
17046 }),
17047 )
17048 .map_err(|message| ("storage.failed".into(), message))?;
17049 }
17050 self.overlay_dirty.set(false);
17051 }
17052 Ok(())
17053 }
17054
17055 fn apply_segment(
17060 &mut self,
17061 sub_index: usize,
17062 segment: &RowsSegment,
17063 first_fresh_page: bool,
17064 ) -> Result<u32, SectionError> {
17065 let (sub_table, effective) = {
17066 let sub = &self.subs[sub_index];
17067 (sub.table.clone(), sub.effective.clone().unwrap_or_default())
17068 };
17069 let table = self.schema.table(&sub_table).cloned().ok_or_else(|| {
17070 SectionError::Abort(
17071 "sync.schema_mismatch".to_owned(),
17072 format!("subscription table {sub_table:?} is not in the client schema"),
17073 )
17074 })?;
17075 let matches = segment.table == table.name
17080 && segment.schema_version == self.schema.version
17081 && segment.columns.len() == table.wire_columns.len()
17082 && segment
17083 .columns
17084 .iter()
17085 .zip(table.wire_columns.iter())
17086 .all(|(a, b)| a.name == b.name && a.ty == b.ty && a.nullable == b.nullable);
17087 if !matches {
17088 return Err(SectionError::Abort(
17089 "sync.schema_mismatch".to_owned(),
17090 "segment column table does not match the generated schema (§5.2)".to_owned(),
17091 ));
17092 }
17093 let mut applied = 0u32;
17094 let empty = Vec::new();
17095 let blocks = if segment.blocks.is_empty() {
17096 std::slice::from_ref(&empty)
17097 } else {
17098 &segment.blocks
17099 };
17100 for (index, block) in blocks.iter().enumerate() {
17101 let clear = first_fresh_page && index == 0;
17102 if !clear && block.is_empty() {
17103 continue;
17104 }
17105 let was_dirty = self.overlay_dirty.snapshot();
17106 let reconcile_pending = table.indexes.iter().any(|index| index.unique)
17107 && self
17108 .outbox
17109 .iter()
17110 .flat_map(|commit| &commit.ops)
17111 .any(|op| op.table == table.name);
17112 let mirror_visible = !reconcile_pending
17113 && (self.failed_commits.is_empty() && !self.has_acknowledged_rows());
17114 self.begin_observation("syncular_segment_block")
17115 .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
17116 let outcome = (|| {
17117 self.rebuild_overlay_if_dirty()
17118 .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
17119 let mut changed_keys = Vec::new();
17120 let mut batch = ChangeAccumulator::default();
17121 if !block.is_empty() || (clear && self.scoped_rows_exist(&table.name, &effective)) {
17122 batch.table(&table.name);
17123 }
17124 if clear {
17125 self.purge_scope_rows(&table.name, &effective)
17126 .map_err(|()| SectionError::FailClosed)?;
17127 }
17128 for (row_index, row) in block.iter().enumerate() {
17129 let decrypted;
17130 let values = if table.has_encrypted_columns() {
17131 let mut values = row.values.clone();
17132 crate::values::decrypt_segment_row(&table, &mut values, &self.encryption)
17133 .map_err(|message| {
17134 SectionError::Abort("client.decrypt_failed".into(), message)
17135 })?;
17136 decrypted = values;
17137 &decrypted
17138 } else {
17139 &row.values
17140 };
17141 self.write_base_row(&table.name, values, row.server_version)
17142 .map_err(|message| {
17143 SectionError::Abort("sync.invalid_request".into(), message)
17144 })?;
17145 if reconcile_pending {
17146 changed_keys.push(
17147 owned_sql_value(RowParam::Cell(&values[table.pk_index])).map_err(
17148 |message| SectionError::Abort("storage.failed".into(), message),
17149 )?,
17150 );
17151 }
17152 if mirror_visible {
17153 self.write_row(
17154 &visible_table(&table.name),
17155 &table.name,
17156 values,
17157 row.server_version,
17158 )
17159 .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
17160 }
17161 let processed = u64::from(applied) + row_index as u64 + 1;
17162 if processed.is_multiple_of(1024) {
17163 self.progress.update(|p| p.rows_processed = processed);
17164 }
17165 }
17166 if reconcile_pending {
17167 self.reconcile_imported_rows(&table, &mut changed_keys)
17168 .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
17169 }
17170 if self.failed_commits.is_empty() && !self.has_acknowledged_rows() {
17171 self.apply_outbox_ops(
17172 self.outbox
17173 .iter()
17174 .flat_map(|commit| &commit.ops)
17175 .filter(|op| op.table == table.name),
17176 )
17177 .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
17178 self.overlay_dirty.set(false);
17179 } else {
17180 self.rebuild_overlay_if_dirty()
17181 .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
17182 }
17183 self.finish_observation("syncular_segment_block", batch)
17184 .map_err(|message| SectionError::Abort("storage.failed".into(), message))
17185 })();
17186 if let Err(error) = outcome {
17187 self.rollback_observation("syncular_segment_block");
17188 self.overlay_dirty.restore(was_dirty.clone());
17189 return Err(error);
17190 }
17191 applied += block.len() as u32;
17192 self.progress
17193 .update(|p| p.rows_processed = u64::from(applied));
17194 std::thread::yield_now();
17195 }
17196 self.progress
17197 .update(|p| p.rows_processed = u64::from(applied));
17198 Ok(applied)
17199 }
17200
17201 fn apply_sqlite_segment(
17209 &mut self,
17210 sub_index: usize,
17211 bytes: &[u8],
17212 first_fresh_page: bool,
17213 row_count: i64,
17214 as_of_commit_seq: i64,
17215 scope_digest: &str,
17216 ) -> Result<u32, SectionError> {
17217 let invalid = |detail: &str| {
17218 SectionError::Abort(
17219 "sync.invalid_request".to_owned(),
17220 format!("sqlite segment rejected: {detail} (§5.3)"),
17221 )
17222 };
17223 let (sub_table, effective) = {
17224 let sub = &self.subs[sub_index];
17225 (sub.table.clone(), sub.effective.clone().unwrap_or_default())
17226 };
17227 let table = self.schema.table(&sub_table).cloned().ok_or_else(|| {
17228 SectionError::Abort(
17229 "sync.schema_mismatch".to_owned(),
17230 format!("subscription table {sub_table:?} is not in the client schema"),
17231 )
17232 })?;
17233
17234 let path = std::env::temp_dir().join(format!("syncular-image-{}.db", uuid::Uuid::new_v4()));
17235 std::fs::write(&path, bytes).map_err(|_| invalid("image temp file write failed"))?;
17236 let img = match rusqlite::Connection::open_with_flags(
17237 &path,
17238 rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY,
17239 ) {
17240 Ok(conn) => conn,
17241 Err(_) => {
17242 let _ = std::fs::remove_file(&path);
17243 return Err(invalid("bytes do not open as a SQLite database"));
17244 }
17245 };
17246 let outcome = self.apply_sqlite_image(
17247 &img,
17248 &table,
17249 first_fresh_page,
17250 &effective,
17251 row_count,
17252 as_of_commit_seq,
17253 scope_digest,
17254 );
17255 drop(img);
17256 let _ = std::fs::remove_file(&path);
17257 outcome
17258 }
17259
17260 #[allow(clippy::too_many_arguments)]
17261 fn apply_sqlite_image(
17262 &mut self,
17263 img: &rusqlite::Connection,
17264 table: &crate::schema::TableSchema,
17265 first_fresh_page: bool,
17266 effective: &[(String, Vec<String>)],
17267 row_count: i64,
17268 as_of_commit_seq: i64,
17269 scope_digest: &str,
17270 ) -> Result<u32, SectionError> {
17271 let invalid = |detail: String| {
17272 SectionError::Abort(
17273 "sync.invalid_request".to_owned(),
17274 format!("sqlite segment rejected: {detail} (§5.3)"),
17275 )
17276 };
17277
17278 type MetaRow = (i64, String, i64, i64, String, i64, i64);
17281 let meta: MetaRow = img
17282 .query_row(
17283 "SELECT format, \"table\", \"schemaVersion\", \"asOfCommitSeq\",
17284 \"scopeDigest\", \"rowCount\",
17285 (SELECT count(*) FROM _syncular_segment)
17286 FROM _syncular_segment",
17287 [],
17288 |row| {
17289 Ok((
17290 row.get(0)?,
17291 row.get(1)?,
17292 row.get(2)?,
17293 row.get(3)?,
17294 row.get(4)?,
17295 row.get(5)?,
17296 row.get(6)?,
17297 ))
17298 },
17299 )
17300 .map_err(|_| invalid("missing or unreadable _syncular_segment metadata".to_owned()))?;
17301 let (format, meta_table, schema_version, pin, digest, meta_rows, meta_count) = meta;
17302 if meta_count != 1 {
17303 return Err(invalid(format!(
17304 "_syncular_segment must contain exactly one row, found {meta_count}"
17305 )));
17306 }
17307 if format != 1 {
17308 return Err(invalid(format!("format {format}")));
17309 }
17310 if meta_table != table.name {
17311 return Err(invalid(format!("image table {meta_table:?}")));
17312 }
17313 if schema_version != i64::from(self.schema.version) {
17314 return Err(invalid(format!("schemaVersion {schema_version}")));
17315 }
17316 if pin != as_of_commit_seq {
17317 return Err(invalid(format!("asOfCommitSeq {pin}")));
17318 }
17319 if digest != scope_digest {
17320 return Err(invalid("scopeDigest mismatch".to_owned()));
17321 }
17322 if meta_rows != row_count {
17323 return Err(invalid(format!("rowCount {meta_rows}")));
17324 }
17325
17326 let mut names: Vec<String> = Vec::new();
17328 let mut primary_keys: Vec<String> = Vec::new();
17329 {
17330 let mut stmt = img
17331 .prepare(&format!("PRAGMA table_info({})", quote_ident(&table.name)))
17332 .map_err(|_| invalid("image data table missing".to_owned()))?;
17333 let mut rows = stmt
17334 .query([])
17335 .map_err(|_| invalid("image data table unreadable".to_owned()))?;
17336 while let Some(row) = rows
17337 .next()
17338 .map_err(|_| invalid("image data table unreadable".to_owned()))?
17339 {
17340 if row
17341 .get::<_, i64>(5)
17342 .map_err(|error| invalid(Self::sqlite_failure(&self.storage_failure, error)))?
17343 > 0
17344 {
17345 primary_keys.push(row.get(1).map_err(|error| {
17346 invalid(Self::sqlite_failure(&self.storage_failure, error))
17347 })?);
17348 }
17349 names.push(
17350 row.get::<_, String>(1)
17351 .map_err(|_| invalid("image data table unreadable".to_owned()))?,
17352 );
17353 }
17354 }
17355 let mut expected: Vec<&str> = table.columns.iter().map(|c| c.name.as_str()).collect();
17356 expected.push("_syncular_version");
17357 if names.len() != expected.len() || names.iter().zip(expected.iter()).any(|(a, b)| a != b) {
17358 return Err(SectionError::Abort(
17359 "sync.schema_mismatch".to_owned(),
17360 "sqlite segment columns do not match the generated schema (§5.3)".to_owned(),
17361 ));
17362 }
17363
17364 if primary_keys != vec![table.primary_key.clone()] {
17365 return Err(invalid(
17366 "image primary key does not match generated schema".into(),
17367 ));
17368 }
17369 let actual_count: i64 = img
17370 .query_row(
17371 &format!("SELECT count(*) FROM {}", quote_ident(&table.name)),
17372 [],
17373 |row| row.get(0),
17374 )
17375 .map_err(|error| invalid(Self::sqlite_failure(&self.storage_failure, error)))?;
17376 if actual_count != row_count {
17377 return Err(invalid("image row count does not match descriptor".into()));
17378 }
17379 let insert = self.insert_row_sql(&base_table(&table.name), table);
17380 let visible_insert = self.insert_row_sql(&visible_table(&table.name), table);
17381 let column_list = names
17382 .iter()
17383 .map(|n| quote_ident(n))
17384 .collect::<Vec<_>>()
17385 .join(", ");
17386 let mut stmt = img
17387 .prepare(&format!(
17388 "SELECT {column_list} FROM {} ORDER BY {}",
17389 quote_ident(&table.name),
17390 quote_ident(&table.primary_key)
17391 ))
17392 .map_err(|error| invalid(Self::sqlite_failure(&self.storage_failure, error)))?;
17393 let mut rows = stmt
17394 .query([])
17395 .map_err(|error| invalid(Self::sqlite_failure(&self.storage_failure, error)))?;
17396 let mut applied = 0u32;
17397 loop {
17398 let clear = applied == 0 && first_fresh_page;
17399 let was_dirty = self.overlay_dirty.snapshot();
17400 let reconcile_pending = table.indexes.iter().any(|index| index.unique)
17401 && self
17402 .outbox
17403 .iter()
17404 .flat_map(|commit| &commit.ops)
17405 .any(|op| op.table == table.name);
17406 let mirror_visible = !reconcile_pending
17407 && (self.failed_commits.is_empty() && !self.has_acknowledged_rows());
17408 self.begin_observation("syncular_image_chunk")
17409 .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
17410 let result = (|| {
17411 self.rebuild_overlay_if_dirty()
17412 .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
17413 let mut changed_keys = Vec::new();
17414 let mut batch = ChangeAccumulator::default();
17415 if clear {
17416 if self.scoped_rows_exist(&table.name, effective) {
17417 batch.table(&table.name);
17418 }
17419 self.purge_scope_rows(&table.name, effective)
17420 .map_err(|()| SectionError::FailClosed)?;
17421 }
17422 let mut processed = 0u32;
17423 {
17424 let mut ins = self.conn.prepare_cached(&insert).map_err(|error| {
17425 invalid(Self::sqlite_failure(&self.storage_failure, error))
17426 })?;
17427 let mut mirror = if mirror_visible {
17428 Some(self.conn.prepare_cached(&visible_insert).map_err(|error| {
17429 invalid(Self::sqlite_failure(&self.storage_failure, error))
17430 })?)
17431 } else {
17432 None
17433 };
17434 for _ in 0..1024 {
17435 let Some(row) = rows.next().map_err(|error| {
17436 invalid(Self::sqlite_failure(&self.storage_failure, error))
17437 })?
17438 else {
17439 break;
17440 };
17441 for (i, column) in table.columns.iter().enumerate() {
17442 let cell = row.get_ref(i).map_err(|error| {
17443 invalid(Self::sqlite_failure(&self.storage_failure, error))
17444 })?;
17445 let param = image_cell_param(column, cell).map_err(invalid)?;
17446 ins.raw_bind_parameter(i + 1, ¶m).map_err(|error| {
17447 invalid(Self::sqlite_failure(&self.storage_failure, error))
17448 })?;
17449 if let Some(mirror) = &mut mirror {
17450 mirror.raw_bind_parameter(i + 1, ¶m).map_err(|error| {
17451 invalid(Self::sqlite_failure(&self.storage_failure, error))
17452 })?;
17453 }
17454 }
17455 let version_index = table.columns.len();
17456 let version: i64 = row.get(version_index).map_err(|error| {
17457 invalid(Self::sqlite_failure(&self.storage_failure, error))
17458 })?;
17459 if version < 1 {
17460 return Err(invalid("image row version must be positive".into()));
17461 }
17462 ins.raw_bind_parameter(version_index + 1, version)
17463 .map_err(|error| {
17464 invalid(Self::sqlite_failure(&self.storage_failure, error))
17465 })?;
17466 ins.raw_execute().map_err(|error| {
17467 invalid(Self::sqlite_failure(&self.storage_failure, error))
17468 })?;
17469 if let Some(mirror) = &mut mirror {
17470 mirror
17471 .raw_bind_parameter(version_index + 1, version)
17472 .map_err(|error| {
17473 invalid(Self::sqlite_failure(&self.storage_failure, error))
17474 })?;
17475 mirror.raw_execute().map_err(|error| {
17476 invalid(Self::sqlite_failure(&self.storage_failure, error))
17477 })?;
17478 }
17479 if reconcile_pending {
17480 changed_keys.push(row.get::<_, SqlValue>(table.pk_index).map_err(
17481 |error| invalid(Self::sqlite_failure(&self.storage_failure, error)),
17482 )?);
17483 }
17484 processed += 1;
17485 }
17486 }
17487 if processed > 0 {
17488 batch.table(&table.name);
17489 self.overlay_dirty.table(&table.name);
17490 }
17491 if reconcile_pending {
17492 self.reconcile_imported_rows(table, &mut changed_keys)
17493 .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
17494 }
17495 if self.failed_commits.is_empty() && !self.has_acknowledged_rows() {
17496 self.apply_outbox_ops(
17497 self.outbox
17498 .iter()
17499 .flat_map(|commit| &commit.ops)
17500 .filter(|op| op.table == table.name),
17501 )
17502 .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
17503 self.overlay_dirty.set(false);
17504 } else {
17505 self.rebuild_overlay_if_dirty()
17506 .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
17507 }
17508 self.finish_observation("syncular_image_chunk", batch)
17509 .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
17510 Ok(processed)
17511 })();
17512 let processed = match result {
17513 Ok(processed) => processed,
17514 Err(error) => {
17515 self.rollback_observation("syncular_image_chunk");
17516 self.overlay_dirty.restore(was_dirty.clone());
17517 return Err(error);
17518 }
17519 };
17520 applied += processed;
17521 self.progress
17522 .update(|p| p.rows_processed = u64::from(applied));
17523 std::thread::yield_now();
17524 if i64::from(applied) == actual_count {
17525 break;
17526 }
17527 }
17528 Ok(applied)
17529 }
17530
17531 fn reconcile_imported_rows(
17535 &self,
17536 table: &TableSchema,
17537 keys: &mut Vec<SqlValue>,
17538 ) -> Result<(), String> {
17539 let visible = visible_table(&table.name);
17540 let base = base_table(&table.name);
17541 let pk = quote_ident(&table.primary_key);
17542 let predicate = row_id_predicate(table);
17543 let mut lookup = self.conn.prepare_cached(&format!(
17544 "SELECT {pk} FROM {base} WHERE {predicate} UNION SELECT {pk} FROM {visible} WHERE {predicate}"
17545 )).map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
17546 let mut deleted = HashSet::new();
17547 for op in self
17548 .outbox
17549 .iter()
17550 .flat_map(|commit| &commit.ops)
17551 .filter(|op| op.table == table.name)
17552 {
17553 if op.upsert {
17554 if let Some(values) = &op.values {
17555 let key = json_to_column_value(
17556 &table.columns[table.pk_index],
17557 values.get(&table.primary_key),
17558 )?;
17559 keys.push(owned_sql_value(RowParam::Cell(&key))?);
17560 }
17561 } else if deleted.insert(&op.row_id) {
17562 keys.extend(
17563 lookup
17564 .query_map([&op.row_id], |row| row.get::<_, SqlValue>(0))
17565 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?
17566 .collect::<Result<Vec<_>, _>>()
17567 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?,
17568 );
17569 }
17570 }
17571 for chunk in keys.chunks(400) {
17574 let holes = vec!["?"; chunk.len()].join(",");
17575 self.conn
17576 .prepare_cached(&format!("DELETE FROM {visible} WHERE {pk} IN ({holes})"))
17577 .and_then(|mut statement| statement.execute(rusqlite::params_from_iter(chunk)))
17578 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
17579 }
17580 let updates = table
17581 .columns
17582 .iter()
17583 .map(|column| column.name.as_str())
17584 .chain(std::iter::once("_syncular_version"))
17585 .filter(|name| *name != table.primary_key)
17586 .map(|name| {
17587 let name = quote_ident(name);
17588 format!("{name}=excluded.{name}")
17589 })
17590 .collect::<Vec<_>>()
17591 .join(",");
17592 for chunk in keys.chunks(400) {
17593 let holes = vec!["?"; chunk.len()].join(",");
17594 self.conn.prepare_cached(&format!("INSERT INTO {visible} SELECT * FROM {base} WHERE {pk} IN ({holes}) ON CONFLICT ({pk}) DO UPDATE SET {updates}"))
17595 .and_then(|mut statement| statement.execute(rusqlite::params_from_iter(chunk)))
17596 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
17597 }
17598 Ok(())
17599 }
17600
17601 fn compile_local_data_purge(
17604 &self,
17605 input: &LocalDataPurgeInput,
17606 ) -> Result<(Vec<CompiledLocalDataPurgeTarget>, String), String> {
17607 let invalid = |message: String| format!("sync.invalid_request: {message}");
17608 if input.purge_id.is_empty()
17609 || input.purge_id.len() > 128
17610 || !is_local_operation_code_like(&input.purge_id)
17611 {
17612 return Err(invalid(
17613 "local purge purgeId must be a 1–128 character code-like identifier".to_owned(),
17614 ));
17615 }
17616 if input.targets.is_empty() || input.targets.len() > MAX_LOCAL_PURGE_TARGETS {
17617 return Err(invalid(format!(
17618 "local purge needs between 1 and {MAX_LOCAL_PURGE_TARGETS} targets"
17619 )));
17620 }
17621
17622 let mut deduplicated: BTreeMap<
17623 String,
17624 (CompiledLocalDataPurgeTarget, LocalDataPurgeTarget),
17625 > = BTreeMap::new();
17626 for target in &input.targets {
17627 let table = self.schema.table(&target.table).ok_or_else(|| {
17628 invalid(format!(
17629 "local purge names unknown table {:?}",
17630 target.table
17631 ))
17632 })?;
17633 if target.selectors.is_empty() || target.selectors.len() > MAX_LOCAL_PURGE_SELECTORS {
17634 return Err(invalid(format!(
17635 "local purge target {:?} needs between 1 and {MAX_LOCAL_PURGE_SELECTORS} selectors",
17636 target.table
17637 )));
17638 }
17639 let mut selectors = Vec::with_capacity(target.selectors.len());
17640 let mut canonical_selectors = BTreeMap::new();
17641 for (column_name, raw_values) in &target.selectors {
17642 let Some((column_index, column)) = table
17643 .columns
17644 .iter()
17645 .enumerate()
17646 .find(|(_, column)| column.name == *column_name)
17647 else {
17648 return Err(invalid(format!(
17649 "local purge target {:?} names unknown column {:?}",
17650 target.table, column_name
17651 )));
17652 };
17653 let encrypted = table
17654 .encrypted_columns
17655 .iter()
17656 .any(|candidate| candidate.index == column_index);
17657 if column.ty != ColumnType::String || encrypted {
17658 return Err(invalid(format!(
17659 "local purge selector {:?}.{:?} must be a plaintext string column",
17660 target.table, column_name
17661 )));
17662 }
17663 if raw_values.is_empty() || raw_values.len() > MAX_LOCAL_PURGE_VALUES {
17664 return Err(invalid(format!(
17665 "local purge selector {:?}.{:?} needs between 1 and {MAX_LOCAL_PURGE_VALUES} values",
17666 target.table, column_name
17667 )));
17668 }
17669 let mut values = raw_values.clone();
17670 values.sort();
17671 values.dedup();
17672 if values.iter().any(|value| {
17673 value.is_empty()
17674 || value.len() > MAX_LOCAL_PURGE_VALUE_LENGTH
17675 || !is_local_operation_code_like(value)
17676 }) {
17677 return Err(invalid(format!(
17678 "local purge selector values must be 1–{MAX_LOCAL_PURGE_VALUE_LENGTH} character code-like identifiers"
17679 )));
17680 }
17681 selectors.push((column_name.clone(), values.clone()));
17682 canonical_selectors.insert(column_name.clone(), values);
17683 }
17684 let canonical = LocalDataPurgeTarget {
17685 table: target.table.clone(),
17686 selectors: canonical_selectors,
17687 };
17688 let key = serde_json::to_string(&canonical).map_err(|error| error.to_string())?;
17689 deduplicated.insert(
17690 key,
17691 (
17692 CompiledLocalDataPurgeTarget {
17693 table: target.table.clone(),
17694 selectors,
17695 },
17696 canonical,
17697 ),
17698 );
17699 }
17700 let targets = deduplicated
17701 .values()
17702 .map(|(compiled, _)| compiled.clone())
17703 .collect::<Vec<_>>();
17704 let canonical = deduplicated
17705 .values()
17706 .map(|(_, target)| target.clone())
17707 .collect::<Vec<_>>();
17708 let canonical_plan =
17709 serde_json::to_string(&canonical).map_err(|error| error.to_string())?;
17710 Ok((targets, canonical_plan))
17711 }
17712
17713 fn local_purge_base_row_ids(
17714 &self,
17715 targets: &[CompiledLocalDataPurgeTarget],
17716 ) -> Result<BTreeMap<String, BTreeSet<String>>, String> {
17717 let mut by_table: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
17718 for target in targets {
17719 let table = self
17720 .schema
17721 .table(&target.table)
17722 .ok_or_else(|| format!("sync.invalid_request: unknown table {:?}", target.table))?;
17723 let mut clauses = Vec::with_capacity(target.selectors.len());
17724 let mut params = Vec::new();
17725 for (column, values) in &target.selectors {
17726 clauses.push(format!(
17727 "{} IN ({})",
17728 quote_ident(column),
17729 values.iter().map(|_| "?").collect::<Vec<_>>().join(", ")
17730 ));
17731 params.extend(values.iter().cloned().map(SqlValue::Text));
17732 }
17733 let sql = format!(
17734 "SELECT CAST({} AS TEXT) FROM {} WHERE {}",
17735 quote_ident(&table.primary_key),
17736 base_table(&target.table),
17737 clauses.join(" AND ")
17738 );
17739 let mut statement = self
17740 .conn
17741 .prepare(&sql)
17742 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
17743 let rows = statement
17744 .query_map(rusqlite::params_from_iter(params), |row| {
17745 let value = sql_ref_to_json(&table.columns[table.pk_index], row.get_ref(0)?);
17746 render_row_id_json(Some(&value))
17747 .map_err(|message| rusqlite::Error::ToSqlConversionFailure(message.into()))
17748 })
17749 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
17750 let ids = by_table.entry(target.table.clone()).or_default();
17751 for row in rows {
17752 ids.insert(
17753 row.map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?,
17754 );
17755 }
17756 }
17757 Ok(by_table)
17758 }
17759
17760 fn local_purge_values_match(
17761 target: &CompiledLocalDataPurgeTarget,
17762 values: &Map<String, Value>,
17763 ) -> bool {
17764 target.selectors.iter().all(|(column, allowed)| {
17765 values
17766 .get(column)
17767 .and_then(Value::as_str)
17768 .is_some_and(|value| allowed.iter().any(|candidate| candidate == value))
17769 })
17770 }
17771
17772 pub fn purge_local_data(
17776 &mut self,
17777 input: &LocalDataPurgeInput,
17778 ) -> Result<LocalDataPurgeResult, String> {
17779 let (targets, canonical_plan) = self.compile_local_data_purge(input)?;
17780 let meta_key = format!("localPurge:{}", input.purge_id);
17781 let applied_plan = self
17782 .conn
17783 .query_row(
17784 "SELECT value FROM _syncular_meta WHERE key = ?1",
17785 rusqlite::params![meta_key],
17786 |row| row.get::<_, String>(0),
17787 )
17788 .optional()
17789 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
17790 if let Some(applied_plan) = applied_plan {
17791 if applied_plan != canonical_plan {
17792 return Err(format!(
17793 "sync.invalid_request: local purge id {:?} was already used with a different plan",
17794 input.purge_id
17795 ));
17796 }
17797 return Ok(LocalDataPurgeResult {
17798 already_applied: true,
17799 purged_rows: 0,
17800 dropped_commits: 0,
17801 });
17802 }
17803
17804 let _ = self.drop_previous_version();
17808
17809 let prior_outbox = self.outbox.clone();
17810 let prior_failed = self.failed_commits.clone();
17811 let prior_rejections = self.rejections.clone();
17812 let prior_conflicts = self.conflicts.clone();
17813 let prior_overlay_dirty = self.overlay_dirty.snapshot();
17814 self.begin_observation("syncular_local_purge")?;
17815 let applied = (|| -> Result<(ChangeAccumulator, LocalDataPurgeResult), String> {
17816 let row_ids = self.local_purge_base_row_ids(&targets)?;
17817 let retained_dropped = self.drop_failed_commits(|table, values| {
17818 targets.iter().any(|target| {
17819 target.table == table && Self::local_purge_values_match(target, values)
17820 })
17821 })?;
17822 let doomed = self
17823 .outbox
17824 .iter()
17825 .filter(|commit| {
17826 commit.ops.iter().any(|operation| {
17827 let matching_targets = targets
17828 .iter()
17829 .filter(|target| target.table == operation.table)
17830 .collect::<Vec<_>>();
17831 if matching_targets.is_empty() {
17832 return false;
17833 }
17834 if row_ids
17835 .get(&operation.table)
17836 .is_some_and(|ids| ids.contains(&operation.row_id))
17837 {
17838 return true;
17839 }
17840 operation.values.as_ref().is_some_and(|values| {
17841 matching_targets
17842 .iter()
17843 .any(|target| Self::local_purge_values_match(target, values))
17844 })
17845 })
17846 })
17847 .cloned()
17848 .collect::<Vec<_>>();
17849 let doomed_ids = doomed
17850 .iter()
17851 .map(|commit| commit.client_commit_id.clone())
17852 .collect::<BTreeSet<_>>();
17853 let mut batch = ChangeAccumulator::default();
17854 for commit in &retained_dropped {
17855 batch.conflicts = true;
17856 batch.rejections = true;
17857 batch.outcomes = true;
17858 for operation in &commit.ops {
17859 batch.table(&operation.table);
17860 }
17861 }
17862 let mut rejections = Vec::new();
17863 for commit in &doomed {
17864 for operation in &commit.ops {
17865 batch.table(&operation.table);
17866 }
17867 let results = commit
17868 .ops
17869 .iter()
17870 .enumerate()
17871 .map(|(op_index, operation)| {
17872 let rejection = RejectionRecord {
17873 client_commit_id: commit.client_commit_id.clone(),
17874 op_index: op_index as i32,
17875 code: "client.local_data_purged".to_owned(),
17876 message: "the commit was dropped by an application-authorized local data purge".to_owned(),
17877 retryable: false,
17878 details: None,
17879 operation: Some(CommitOperation::from(operation)),
17880 };
17881 rejections.push(rejection.clone());
17882 CommitOperationOutcome::Error { rejection }
17883 })
17884 .collect::<Vec<_>>();
17885 self.persist_commit_outcome(
17886 &commit.client_commit_id,
17887 CommitOutcomeStatus::Rejected,
17888 &results,
17889 Some(&commit.ops),
17890 )?;
17891 self.delete_outbox_persisted(&commit.client_commit_id)?;
17892 }
17893 if !doomed.is_empty() {
17894 self.outbox
17895 .retain(|commit| !doomed_ids.contains(&commit.client_commit_id));
17896 self.rejections.extend(rejections);
17897 self.prune_commit_outcomes()?;
17898 batch.status = true;
17899 batch.rejections = true;
17900 batch.outcomes = true;
17901 }
17902
17903 let mut purged_rows = 0usize;
17904 for (table, ids) in &row_ids {
17905 if ids.is_empty() {
17906 continue;
17907 }
17908 batch.table(table);
17909 for row_id in ids {
17910 self.delete_base_row(table, row_id)?;
17911 purged_rows += 1;
17912 }
17913 }
17914 self.rebuild_overlay_if_dirty()?;
17915 self.delete_unreferenced_cached_blobs()?;
17916 self.conn
17917 .execute(
17918 "INSERT INTO _syncular_meta(key, value) VALUES (?1, ?2)",
17919 rusqlite::params![meta_key, canonical_plan],
17920 )
17921 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
17922 Ok((
17923 batch,
17924 LocalDataPurgeResult {
17925 already_applied: false,
17926 purged_rows,
17927 dropped_commits: doomed.len(),
17928 },
17929 ))
17930 })();
17931 let (batch, result) = match applied {
17932 Ok(value) => value,
17933 Err(error) => {
17934 self.rollback_observation("syncular_local_purge");
17935 self.outbox = prior_outbox;
17936 self.failed_commits = prior_failed;
17937 self.rejections = prior_rejections;
17938 self.conflicts = prior_conflicts;
17939 self.overlay_dirty.restore(prior_overlay_dirty.clone());
17940 return Err(error);
17941 }
17942 };
17943 if let Err(error) = self.finish_observation("syncular_local_purge", batch) {
17944 self.rollback_observation("syncular_local_purge");
17945 self.outbox = prior_outbox;
17946 self.failed_commits = prior_failed;
17947 self.rejections = prior_rejections;
17948 self.conflicts = prior_conflicts;
17949 self.overlay_dirty.restore(prior_overlay_dirty.clone());
17950 return Err(error);
17951 }
17952 self.cancel_sync_round();
17953 Ok(result)
17954 }
17955
17956 pub fn rebootstrap_local_data(
17962 &mut self,
17963 input: &LocalDataRebootstrapInput,
17964 ) -> Result<LocalDataRebootstrapResult, String> {
17965 if input.rebootstrap_id.is_empty()
17966 || input.rebootstrap_id.len() > 128
17967 || !is_local_operation_code_like(&input.rebootstrap_id)
17968 {
17969 return Err(
17970 "sync.invalid_request: local rebootstrap rebootstrapId must be a 1–128 character code-like identifier"
17971 .to_owned(),
17972 );
17973 }
17974 let meta_key = format!("localRebootstrap:{}", input.rebootstrap_id);
17975 if let Some(persisted) = self.get_meta_strict(&meta_key)? {
17976 let (retained_commits, reset_subscriptions) =
17977 decode_local_rebootstrap_receipt(&persisted)?;
17978 return Ok(LocalDataRebootstrapResult {
17979 already_applied: true,
17980 retained_commits,
17981 reset_subscriptions,
17982 });
17983 }
17984 if self.stopped || self.schema_floor.is_some() {
17985 return Err(
17986 "sync.invalid_request: local rebootstrap cannot bypass an active schema-floor stop; update the application first"
17987 .to_owned(),
17988 );
17989 }
17990
17991 let retained_commits = self.outbox.len();
17992 let reset_subscriptions = self.subs.len();
17993 let prior_subs = self.subs.clone();
17994 let prior_upgrading = self.upgrading;
17995 let prior_stopped = self.stopped;
17996 let prior_schema_floor = self.schema_floor.clone();
17997 let prior_overlay_dirty = self.overlay_dirty.snapshot();
17998 let prior_sync_needed = self.sync_needed;
17999 let prior_sync_intents = self.sync_intent_queue.clone();
18000 let receipt = encode_local_rebootstrap_receipt(retained_commits, reset_subscriptions)?;
18001
18002 let prior_active_round = self.active_round;
18003 self.begin_observation("syncular_local_rebootstrap")?;
18004 let mut batch = ChangeAccumulator::default();
18005 let applied = (|| -> Result<(), String> {
18006 self.run_schema_reset_observed(&mut batch, false)?;
18007 self.conn
18008 .execute(
18009 "INSERT INTO _syncular_meta(key, value) VALUES (?1, ?2)",
18010 rusqlite::params![meta_key, receipt],
18011 )
18012 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
18013 self.sync_needed = true;
18014 self.sync_intent_queue.push_back(SyncIntent::Interactive);
18015 batch.status = true;
18016 Ok(())
18017 })();
18018 if let Err(error) = applied {
18019 self.rollback_observation("syncular_local_rebootstrap");
18020 self.active_round = prior_active_round;
18021 self.subs = prior_subs;
18022 self.upgrading = prior_upgrading;
18023 self.stopped = prior_stopped;
18024 self.schema_floor = prior_schema_floor;
18025 self.overlay_dirty.restore(prior_overlay_dirty.clone());
18026 self.sync_needed = prior_sync_needed;
18027 self.sync_intent_queue = prior_sync_intents;
18028 return Err(error);
18029 }
18030 if let Err(error) = self.finish_observation("syncular_local_rebootstrap", batch) {
18031 self.rollback_observation("syncular_local_rebootstrap");
18032 self.active_round = prior_active_round;
18033 self.subs = prior_subs;
18034 self.upgrading = prior_upgrading;
18035 self.stopped = prior_stopped;
18036 self.schema_floor = prior_schema_floor;
18037 self.overlay_dirty.restore(prior_overlay_dirty.clone());
18038 self.sync_needed = prior_sync_needed;
18039 self.sync_intent_queue = prior_sync_intents;
18040 return Err(error);
18041 }
18042
18043 Ok(LocalDataRebootstrapResult {
18044 already_applied: false,
18045 retained_commits,
18046 reset_subscriptions,
18047 })
18048 }
18049
18050 fn purge_scope_rows(
18055 &mut self,
18056 table_name: &str,
18057 effective: &[(String, Vec<String>)],
18058 ) -> Result<(), ()> {
18059 if effective.is_empty() {
18060 return Ok(());
18061 }
18062 let table = self.schema.table(table_name).ok_or(())?.clone();
18063 let mut clauses = Vec::new();
18064 let mut params: Vec<SqlValue> = Vec::new();
18065 for (variable, values) in effective {
18066 let column = table.scope_column(variable).ok_or(())?;
18067 let placeholders: Vec<String> = values
18068 .iter()
18069 .map(|v| {
18070 params.push(SqlValue::Text(v.clone()));
18071 "?".to_owned()
18072 })
18073 .collect();
18074 clauses.push(format!(
18075 "{} IN ({})",
18076 quote_ident(column),
18077 placeholders.join(", ")
18078 ));
18079 }
18080 let sql = format!(
18081 "DELETE FROM {} WHERE {}",
18082 base_table(table_name),
18083 clauses.join(" AND ")
18084 );
18085 self.overlay_dirty.table(table_name);
18086 self.conn
18087 .execute(&sql, rusqlite::params_from_iter(¶ms))
18088 .map_err(|_| ())?;
18089 self.conn
18090 .execute(
18091 &format!(
18092 "DELETE FROM {} WHERE {}",
18093 visible_table(table_name),
18094 clauses.join(" AND ")
18095 ),
18096 rusqlite::params_from_iter(¶ms),
18097 )
18098 .map_err(|_| ())?;
18099 Ok(())
18100 }
18101
18102 fn drop_doomed_outbox(
18105 &mut self,
18106 table_name: &str,
18107 effective: &[(String, Vec<String>)],
18108 ) -> Result<bool, String> {
18109 if effective.is_empty() {
18110 return Ok(false);
18111 }
18112 let Some(table) = self.schema.table(table_name).cloned() else {
18113 return Ok(false);
18114 };
18115 let mut mappings: Vec<(&str, &Vec<String>)> = Vec::new();
18116 for (variable, values) in effective {
18117 match table.scope_column(variable) {
18118 Some(column) => mappings.push((column, values)),
18119 None => return Ok(false), }
18121 }
18122 let doomed: Vec<OutboxCommit> = self
18123 .outbox
18124 .iter()
18125 .filter(|commit| {
18126 commit.ops.iter().any(|op| {
18127 op.upsert
18128 && op.table == table_name
18129 && op.values.as_ref().is_some_and(|values| {
18130 mappings
18131 .iter()
18132 .all(|(column, allowed)| match values.get(*column) {
18133 Some(Value::String(s)) => allowed.contains(s),
18134 Some(Value::Number(n)) => allowed.contains(&n.to_string()),
18135 _ => false,
18136 })
18137 })
18138 })
18139 })
18140 .cloned()
18141 .collect();
18142 if doomed.is_empty() {
18143 return Ok(false);
18144 }
18145 let mut rejections = Vec::new();
18146 for commit in &doomed {
18147 let results = commit
18148 .ops
18149 .iter()
18150 .enumerate()
18151 .map(|(op_index, operation)| {
18152 let rejection = RejectionRecord {
18153 client_commit_id: commit.client_commit_id.clone(),
18154 op_index: op_index as i32,
18155 code: "sync.scope_revoked".to_owned(),
18156 message: "the commit was dropped because its effective scope was revoked"
18157 .to_owned(),
18158 retryable: false,
18159 details: None,
18160 operation: Some(CommitOperation::from(operation)),
18161 };
18162 rejections.push(rejection.clone());
18163 CommitOperationOutcome::Error { rejection }
18164 })
18165 .collect::<Vec<_>>();
18166 self.persist_commit_outcome(
18167 &commit.client_commit_id,
18168 CommitOutcomeStatus::Rejected,
18169 &results,
18170 Some(&commit.ops),
18171 )?;
18172 self.delete_outbox_persisted(&commit.client_commit_id)?;
18173 }
18174 self.prune_commit_outcomes()?;
18175 let doomed_ids = doomed
18176 .iter()
18177 .map(|commit| commit.client_commit_id.as_str())
18178 .collect::<BTreeSet<_>>();
18179 self.outbox
18180 .retain(|commit| !doomed_ids.contains(commit.client_commit_id.as_str()));
18181 self.rejections.extend(rejections);
18182 Ok(true)
18183 }
18184
18185 pub fn upload_blob(
18192 &mut self,
18193 bytes: &[u8],
18194 media_type: Option<String>,
18195 name: Option<String>,
18196 ) -> Result<Value, String> {
18197 let blob_id = blob_id_for(bytes);
18198 let now = self.clock_now_ms();
18199 let tx = self
18200 .conn
18201 .transaction()
18202 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
18203 tx.execute(
18204 "INSERT INTO _syncular_blobs(blob_id, bytes, byte_length, media_type, created_at_ms) VALUES (?,?,?,?,?)
18205 ON CONFLICT(blob_id) DO NOTHING",
18206 rusqlite::params![blob_id, bytes, bytes.len() as i64, media_type, now],
18207 )
18208 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
18209 tx.execute(
18210 "INSERT OR IGNORE INTO _syncular_blob_uploads(blob_id, media_type, created_at_ms) VALUES (?,?,?)",
18211 rusqlite::params![blob_id, media_type, now],
18212 )
18213 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
18214 tx.commit()
18215 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
18216 self.enforce_blob_cache_cap();
18217 let mut obj = Map::new();
18218 obj.insert("blobId".to_owned(), Value::from(blob_id));
18219 obj.insert("byteLength".to_owned(), Value::from(bytes.len() as i64));
18220 if let Some(mt) = media_type {
18221 obj.insert("mediaType".to_owned(), Value::from(mt));
18222 }
18223 if let Some(n) = name {
18224 obj.insert("name".to_owned(), Value::from(n));
18225 }
18226 Ok(Value::Object(obj))
18227 }
18228
18229 pub fn fetch_blob_bytes(
18233 &mut self,
18234 transport: &mut dyn Transport,
18235 blob_id_or_ref: &str,
18236 ) -> Result<FetchedBlob, (String, String)> {
18237 let simple = |m: String| ("client.failed".to_owned(), m);
18238 let blob_id = if blob_id_or_ref.starts_with("sha256:") {
18239 blob_id_or_ref.to_owned()
18240 } else {
18241 let value: Value = serde_json::from_str(blob_id_or_ref)
18242 .map_err(|_| simple("blob ref is not JSON".to_owned()))?;
18243 value
18244 .get("blobId")
18245 .and_then(Value::as_str)
18246 .ok_or_else(|| simple("blob ref has no blobId".to_owned()))?
18247 .to_owned()
18248 };
18249 if let Some(cached) = self.get_cached_blob_bytes(&blob_id).map_err(simple)? {
18250 return Ok(cached);
18251 }
18252 if !self.transport_enabled {
18253 return Err(("sync.offline".into(), "transport gate is closed".into()));
18254 }
18255 #[cfg(feature = "bench-internals")]
18261 let download_phase = self.benchmark_phases.start(Phase::BlobDownload);
18262 let bytes = match transport
18263 .blob_download(&blob_id)
18264 .map_err(|e| (e.code, e.message))?
18265 {
18266 BlobDownload::Bytes(bytes) => bytes,
18267 BlobDownload::Url {
18268 url,
18269 url_expires_at_ms,
18270 } => {
18271 if url_expires_at_ms.is_some_and(|exp| exp <= self.clock_now_ms()) {
18273 return Err((
18274 "sync.segment_expired".to_owned(),
18275 format!(
18276 "blob url for {blob_id} expired before fetch — re-request mints a fresh url (§5.9.5)"
18277 ),
18278 ));
18279 }
18280 transport
18281 .fetch_blob_url(&url)
18282 .map_err(|e| (e.code, e.message))?
18283 }
18284 };
18285 #[cfg(feature = "bench-internals")]
18286 drop(download_phase);
18287 #[cfg(feature = "bench-internals")]
18288 let validate_phase = self.benchmark_phases.start(Phase::BlobValidate);
18289 if blob_id_for(&bytes) != blob_id {
18291 return Err(simple(format!(
18292 "blob content address mismatch for {blob_id}"
18293 )));
18294 }
18295 #[cfg(feature = "bench-internals")]
18296 drop(validate_phase);
18297 #[cfg(feature = "bench-internals")]
18298 let insert_phase = self.benchmark_phases.start(Phase::BlobCacheInsert);
18299 let now = self.clock_now_ms();
18300 self.conn
18301 .execute(
18302 "INSERT OR IGNORE INTO _syncular_blobs(blob_id, bytes, byte_length, media_type, created_at_ms) VALUES (?,?,?,NULL,?)",
18303 rusqlite::params![blob_id, bytes, bytes.len() as i64, now],
18304 )
18305 .map_err(|e| simple(e.to_string()))?;
18306 self.conn
18307 .query_row("PRAGMA wal_checkpoint(TRUNCATE)", [], |_| Ok(()))
18308 .map_err(|e| simple(e.to_string()))?;
18309 self.enforce_blob_cache_cap();
18310 #[cfg(feature = "bench-internals")]
18311 drop(insert_phase);
18312 self.get_cached_blob_bytes(&blob_id)
18313 .map_err(simple)?
18314 .ok_or_else(|| simple("blob cache write failed".to_owned()))
18315 }
18316
18317 fn get_cached_blob_bytes(&self, blob_id: &str) -> Result<Option<FetchedBlob>, String> {
18318 #[cfg(feature = "bench-internals")]
18319 let _phase = self.benchmark_phases.start(Phase::BlobCacheRead);
18320 let mut stmt = self
18321 .conn
18322 .prepare("SELECT bytes, byte_length, media_type FROM _syncular_blobs WHERE blob_id = ?")
18323 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
18324 let mut rows = stmt
18325 .query(rusqlite::params![blob_id])
18326 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
18327 if let Some(row) = rows
18328 .next()
18329 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?
18330 {
18331 let bytes: Vec<u8> = row
18332 .get(0)
18333 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
18334 let byte_length: i64 = row
18335 .get(1)
18336 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
18337 let media_type: Option<String> = row
18338 .get(2)
18339 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
18340 return Ok(Some(FetchedBlob {
18341 blob_id: blob_id.to_owned(),
18342 byte_length,
18343 bytes,
18344 media_type,
18345 }));
18346 }
18347 Ok(None)
18348 }
18349
18350 fn pending_blob_uploads(&self) -> Result<Vec<crate::round::PendingUpload>, TransportError> {
18352 let pending: Vec<(String, Option<String>)> = {
18353 let mut stmt = self
18354 .conn
18355 .prepare(
18356 "SELECT blob_id, media_type FROM (
18357 SELECT blob_id, media_type, created_at_ms FROM _syncular_blob_uploads
18358 UNION ALL
18359 SELECT DISTINCT r.blob_id, b.media_type, 9223372036854775807 AS created_at_ms
18360 FROM _syncular_blob_commit_refs r LEFT JOIN _syncular_blobs b ON b.blob_id = r.blob_id
18361 WHERE NOT EXISTS (SELECT 1 FROM _syncular_blob_uploads u WHERE u.blob_id = r.blob_id)
18362 ) ORDER BY created_at_ms, blob_id",
18363 )
18364 .map_err(|e| TransportError::new("client.failed", e.to_string()))?;
18365 let rows = stmt
18366 .query_map([], |row| {
18367 Ok((row.get::<_, String>(0)?, row.get::<_, Option<String>>(1)?))
18368 })
18369 .map_err(|e| TransportError::new("client.failed", e.to_string()))?;
18370 rows.collect::<Result<_, _>>()
18371 .map_err(|error| match error {
18372 rusqlite::Error::InvalidColumnType(..)
18373 | rusqlite::Error::FromSqlConversionFailure(..) => TransportError::new(
18374 "sync.local_corrupt",
18375 "Pending blob upload metadata is invalid",
18376 ),
18377 _ => TransportError::new("client.failed", error.to_string()),
18378 })?
18379 };
18380 let mut uploads = Vec::new();
18381 for (blob_id, media_type) in pending {
18382 let bytes = (|| {
18383 let stored: Option<(SqlValue, SqlValue)> = self
18384 .conn
18385 .query_row(
18386 "SELECT bytes, byte_length FROM _syncular_blobs WHERE blob_id = ?",
18387 rusqlite::params![blob_id],
18388 |row| Ok((row.get(0)?, row.get(1)?)),
18389 )
18390 .optional()
18391 .map_err(|e| TransportError::new("client.failed", e.to_string()))?;
18392 let Some((SqlValue::Blob(bytes), SqlValue::Integer(byte_length))) = stored else {
18393 return Err(TransportError::new(
18394 "sync.local_corrupt",
18395 "Pending blob upload body is missing or corrupt",
18396 ));
18397 };
18398 if byte_length != bytes.len() as i64 || blob_id_for(&bytes) != blob_id {
18399 return Err(TransportError::new(
18400 "sync.local_corrupt",
18401 "Pending blob upload body is missing or corrupt",
18402 ));
18403 }
18404 Ok(bytes)
18405 })();
18406 let failed = bytes.is_err();
18407 uploads.push(crate::round::PendingUpload {
18408 id: blob_id,
18409 bytes,
18410 media_type,
18411 });
18412 if failed {
18413 break;
18414 }
18415 }
18416 Ok(uploads)
18417 }
18418
18419 #[cfg(test)]
18420 fn flush_blob_uploads(&mut self, transport: &mut dyn Transport) -> Result<(), TransportError> {
18421 for upload in self.pending_blob_uploads()? {
18422 self.upload_one(
18423 transport,
18424 &upload.id,
18425 &upload.bytes?,
18426 upload.media_type.as_deref(),
18427 )?;
18428 self.conn
18429 .execute(
18430 "DELETE FROM _syncular_blob_uploads WHERE blob_id = ?",
18431 rusqlite::params![upload.id],
18432 )
18433 .map_err(|e| TransportError::new("client.failed", e.to_string()))?;
18434 }
18435 Ok(())
18436 }
18437
18438 #[cfg(test)]
18445 fn upload_one(
18446 &self,
18447 transport: &mut dyn Transport,
18448 blob_id: &str,
18449 bytes: &[u8],
18450 media_type: Option<&str>,
18451 ) -> Result<(), TransportError> {
18452 crate::round::upload_one(transport, blob_id, bytes, media_type, self.now_ms)
18453 }
18454
18455 fn visible_blob_ids_sql(&self) -> String {
18456 let mut selects = Vec::new();
18457 for table in &self.schema.tables {
18458 for column in table
18459 .columns
18460 .iter()
18461 .filter(|column| column.ty == ColumnType::BlobRef)
18462 {
18463 let identifier = quote_ident(&column.name);
18464 selects.push(format!(
18465 "SELECT json_extract({identifier}, '$.blobId') AS blob_id FROM {} \
18466 WHERE typeof({identifier}) = 'text' AND json_valid({identifier}) \
18467 AND json_type({identifier}, '$.blobId') = 'text'",
18468 quote_ident(&table.name),
18469 ));
18470 }
18471 }
18472 if selects.is_empty() {
18473 "SELECT NULL AS blob_id WHERE 0".to_owned()
18474 } else {
18475 selects.join(" UNION ")
18476 }
18477 }
18478
18479 fn enforce_blob_cache_cap(&self) {
18482 let Some(max_bytes) = self.limits.blob_cache_max_bytes else {
18483 return;
18484 };
18485 let mut total: i64 = self
18486 .conn
18487 .query_row(
18488 "SELECT COALESCE(SUM(byte_length), 0) FROM _syncular_blobs",
18489 [],
18490 |row| row.get(0),
18491 )
18492 .unwrap_or(0);
18493 if total <= max_bytes {
18494 return;
18495 }
18496 let query = format!(
18497 "SELECT blob_id, byte_length FROM _syncular_blobs
18498 WHERE blob_id NOT IN (SELECT blob_id FROM _syncular_blob_uploads)
18499 AND blob_id NOT IN (SELECT blob_id FROM _syncular_blob_commit_refs)
18500 AND blob_id NOT IN ({})
18501 ORDER BY created_at_ms ASC, blob_id ASC",
18502 self.visible_blob_ids_sql(),
18503 );
18504 let candidates: Vec<(String, i64)> = {
18505 let Ok(mut stmt) = self.conn.prepare(&query) else {
18506 return;
18507 };
18508 let Ok(rows) = stmt.query_map([], |row| {
18509 Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)?))
18510 }) else {
18511 return;
18512 };
18513 rows.filter_map(Result::ok).collect()
18514 };
18515 for (blob_id, byte_length) in candidates {
18516 if total <= max_bytes {
18517 break;
18518 }
18519 let _ = self.conn.execute(
18520 "DELETE FROM _syncular_blobs WHERE blob_id = ?",
18521 rusqlite::params![blob_id],
18522 );
18523 total -= byte_length;
18524 }
18525 }
18526
18527 fn delete_unreferenced_cached_blobs(&mut self) -> Result<(), String> {
18528 #[cfg(feature = "bench-internals")]
18529 let _phase = self.benchmark_phases.start(Phase::BlobRetention);
18530 if !self.schema_has_blobs() {
18531 return Ok(());
18532 }
18533 self.rebuild_overlay_if_dirty()?;
18534 self.conn
18535 .execute(
18536 &format!(
18537 "DELETE FROM _syncular_blobs
18538 WHERE blob_id NOT IN (SELECT blob_id FROM _syncular_blob_uploads)
18539 AND blob_id NOT IN (SELECT blob_id FROM _syncular_blob_commit_refs)
18540 AND blob_id NOT IN ({})",
18541 self.visible_blob_ids_sql(),
18542 ),
18543 [],
18544 )
18545 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
18546 Ok(())
18547 }
18548
18549 fn insert_row_sql(&self, full_table: &str, table: &crate::schema::TableSchema) -> String {
18554 if let Some(sql) = self.insert_sql.borrow().get(full_table) {
18555 return sql.clone();
18556 }
18557 let mut columns: Vec<String> = table.columns.iter().map(|c| quote_ident(&c.name)).collect();
18558 columns.push(quote_ident("_syncular_version"));
18559 let placeholders: Vec<&str> = columns.iter().map(|_| "?").collect();
18560 let primary_key = quote_ident(&table.primary_key);
18561 let updates = columns
18562 .iter()
18563 .filter(|column| **column != primary_key)
18564 .map(|column| format!("{column}=excluded.{column}"))
18565 .collect::<Vec<_>>()
18566 .join(", ");
18567 let sql = format!(
18568 "INSERT INTO {full_table} ({}) VALUES ({}) ON CONFLICT ({primary_key}) DO UPDATE SET {updates}",
18569 columns.join(", "),
18570 placeholders.join(", ")
18571 );
18572 self.insert_sql
18573 .borrow_mut()
18574 .insert(full_table.to_owned(), sql.clone());
18575 sql
18576 }
18577
18578 fn write_base_row(&self, table_name: &str, row: &Row, version: i64) -> Result<(), String> {
18579 self.overlay_dirty.table(table_name);
18580 self.write_row(&base_table(table_name), table_name, row, version)
18581 }
18582
18583 fn write_row(
18584 &self,
18585 full_table: &str,
18586 table_name: &str,
18587 row: &Row,
18588 version: i64,
18589 ) -> Result<(), String> {
18590 #[cfg(feature = "bench-internals")]
18591 let _phase = self.benchmark_phases.start(Phase::RowWrite);
18592 let table = self
18593 .schema
18594 .table(table_name)
18595 .ok_or_else(|| format!("unknown table {table_name:?}"))?;
18596 let sql = self.insert_row_sql(full_table, table);
18597 let mut stmt = self
18598 .conn
18599 .prepare_cached(&sql)
18600 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
18601 let params = row
18602 .iter()
18603 .map(RowParam::Cell)
18604 .chain(std::iter::once(RowParam::Version(version)));
18605 stmt.execute(rusqlite::params_from_iter(params))
18606 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
18607 Ok(())
18608 }
18609
18610 fn delete_base_row(&self, table_name: &str, row_id: &str) -> Result<(), String> {
18611 let table = self
18612 .schema
18613 .table(table_name)
18614 .ok_or_else(|| format!("unknown table {table_name:?}"))?;
18615 self.overlay_dirty.table(table_name);
18616 let sql = format!(
18617 "DELETE FROM {} WHERE {}",
18618 base_table(table_name),
18619 row_id_predicate(table)
18620 );
18621 let mut stmt = self
18622 .conn
18623 .prepare_cached(&sql)
18624 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
18625 stmt.execute(rusqlite::params![row_id])
18626 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
18627 Ok(())
18628 }
18629
18630 fn has_acknowledged_rows(&self) -> bool {
18631 self.conn
18632 .query_row(
18633 "SELECT EXISTS(SELECT 1 FROM _syncular_acked_rows)",
18634 [],
18635 |row| row.get(0),
18636 )
18637 .expect("read acknowledged intent")
18638 }
18639
18640 fn acknowledged_operations(
18641 &self,
18642 rows: Option<&HashSet<(&str, &str)>>,
18643 tables: Option<&BTreeSet<&str>>,
18644 ) -> Result<Vec<OutboxOp>, String> {
18645 let keys = rows.map(|rows| {
18646 serde_json::to_string(
18647 &rows
18648 .iter()
18649 .map(|(table, id)| serde_json::to_string(&(table, id)).expect("row key"))
18650 .collect::<Vec<_>>(),
18651 )
18652 .expect("row keys")
18653 });
18654 let mut statement = self
18655 .conn
18656 .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")
18657 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
18658 let rows = statement
18659 .query_map(
18660 rusqlite::params![
18661 keys,
18662 tables.map(|tables| serde_json::to_string(tables).expect("table keys"))
18663 ],
18664 |row| {
18665 #[cfg(test)]
18666 self.acknowledged_replay_count
18667 .set(self.acknowledged_replay_count.get() + 1);
18668 Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
18669 },
18670 )
18671 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
18672 rows.map(|row| {
18673 let (op, intent) =
18674 row.map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
18675 let op: CommitOperation =
18676 serde_json::from_str(&op).map_err(|_| "sync.local_corrupt".to_owned())?;
18677 let table = self
18678 .schema
18679 .table(&op.table)
18680 .ok_or_else(|| "sync.unknown_table".to_owned())?;
18681 let values = if op.op == "upsert" && self.visible_row(table, &op.row_id)?.is_none() {
18682 Some(serde_json::from_str(&intent).map_err(|_| "sync.local_corrupt".to_owned())?)
18683 } else {
18684 op.values
18685 };
18686 Ok(OutboxOp {
18687 table: op.table,
18688 row_id: op.row_id,
18689 upsert: op.op == "upsert",
18690 base_version: op.base_version,
18691 values,
18692 })
18693 })
18694 .collect()
18695 }
18696
18697 fn drop_acknowledged_rows(
18698 &self,
18699 matches: impl Fn(&str, &Map<String, Value>) -> bool,
18700 ) -> Result<Vec<OutboxCommit>, String> {
18701 let mut statement = self
18702 .conn
18703 .prepare("SELECT commit_id,tbl,intent_json,id FROM _syncular_acked_rows")
18704 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
18705 let rows = statement
18706 .query_map([], |row| {
18707 Ok((
18708 row.get::<_, String>(0)?,
18709 row.get::<_, String>(1)?,
18710 row.get::<_, String>(2)?,
18711 row.get::<_, String>(3)?,
18712 ))
18713 })
18714 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
18715 let mut doomed = HashSet::new();
18716 for row in rows {
18717 let (id, table, intent, row_id) =
18718 row.map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
18719 let values =
18720 serde_json::from_str(&intent).map_err(|_| "sync.local_corrupt".to_owned())?;
18721 let base_matches = self
18722 .schema
18723 .table(&table)
18724 .map(|schema| self.stored_row(schema, &row_id, true))
18725 .transpose()?
18726 .flatten()
18727 .is_some_and(|(base, _)| {
18728 let schema = self.schema.table(&table).expect("acknowledged table");
18729 let values = schema
18730 .columns
18731 .iter()
18732 .zip(base.iter())
18733 .map(|(column, value)| (column.name.clone(), column_value_to_json(value)))
18734 .collect();
18735 matches(&table, &values)
18736 });
18737 if matches(&table, &values) || base_matches {
18738 doomed.insert(id);
18739 }
18740 }
18741 let mut dropped = Vec::new();
18742 for id in doomed {
18743 let mut statement = self
18744 .conn
18745 .prepare("SELECT op_json FROM _syncular_acked_rows WHERE commit_id=?1 ORDER BY idx")
18746 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
18747 let operations = statement
18748 .query_map([&id], |row| row.get::<_, String>(0))
18749 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
18750 let mut ops = Vec::new();
18751 for operation in operations {
18752 let json = operation
18753 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
18754 let op: CommitOperation =
18755 serde_json::from_str(&json).map_err(|_| "sync.local_corrupt".to_owned())?;
18756 self.overlay_dirty.table(&op.table);
18757 ops.push(OutboxOp {
18758 table: op.table,
18759 row_id: op.row_id,
18760 upsert: op.op == "upsert",
18761 base_version: op.base_version,
18762 values: op.values,
18763 });
18764 }
18765 dropped.push(OutboxCommit {
18766 client_commit_id: id.clone(),
18767 ops,
18768 });
18769 self.conn
18770 .execute("DELETE FROM _syncular_acked_rows WHERE commit_id=?1", [id])
18771 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
18772 }
18773 Ok(dropped)
18774 }
18775
18776 fn rebuild_overlay_if_dirty(&mut self) -> Result<(), String> {
18779 if self.overlay_dirty.get() {
18780 self.rebuild_overlay()?;
18781 }
18782 Ok(())
18783 }
18784
18785 fn rebuild_overlay(&mut self) -> Result<(), String> {
18789 #[cfg(feature = "bench-internals")]
18790 let _phase = self.benchmark_phases.start(Phase::OverlayRebuild);
18791 #[cfg(test)]
18792 self.overlay_rebuild_count
18793 .set(self.overlay_rebuild_count.get() + 1);
18794 let dirty = self.overlay_dirty.snapshot();
18795 let tables: Vec<_> = self
18796 .schema
18797 .tables
18798 .iter()
18799 .filter(|table| {
18800 dirty
18801 .as_ref()
18802 .is_none_or(|names| names.is_empty() || names.contains(&table.name))
18803 })
18804 .cloned()
18805 .collect();
18806 let names: BTreeSet<_> = tables.iter().map(|table| table.name.as_str()).collect();
18807 self.begin_observation("syncular_overlay")?;
18808 let rebuilt = (|| {
18809 for table in &tables {
18810 for index in &table.fts_indexes {
18811 self.drop_fts_triggers(index)?;
18812 }
18813 let visible = visible_table(&table.name);
18814 let base = base_table(&table.name);
18815 self.conn
18816 .execute_batch(&format!(
18817 "DELETE FROM {visible}; INSERT INTO {visible} SELECT * FROM {base}"
18818 ))
18819 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
18820 }
18821 let acknowledged = self.acknowledged_operations(None, Some(&names))?;
18822 self.apply_outbox_ops(
18823 acknowledged
18824 .iter()
18825 .filter(|op| names.contains(op.table.as_str())),
18826 )?;
18827 for (commit, initial) in &self.failed_commits {
18828 for operation in &commit.ops {
18829 if !names.contains(operation.table.as_str()) || !operation.upsert {
18830 continue;
18831 }
18832 if let Some(table) = self.schema.table(&operation.table) {
18833 if self.visible_row(table, &operation.row_id)?.is_none()
18834 && operation.values.as_ref().is_some_and(|values| {
18835 table
18836 .columns
18837 .iter()
18838 .all(|column| values.contains_key(&column.name))
18839 })
18840 {
18841 let key = serde_json::to_string(&(
18842 operation.table.clone(),
18843 operation.row_id.clone(),
18844 ))
18845 .expect("row key serialization");
18846 if let Some(Value::Object(values)) = initial.get(&key) {
18847 self.apply_outbox_ops(&[OutboxOp {
18848 values: Some(values.clone()),
18849 ..operation.clone()
18850 }])?;
18851 }
18852 }
18853 }
18854 }
18855 self.apply_outbox_ops(
18856 commit
18857 .ops
18858 .iter()
18859 .filter(|op| names.contains(op.table.as_str())),
18860 )?;
18861 }
18862 for commit in &self.outbox {
18863 self.apply_outbox_ops(
18864 commit
18865 .ops
18866 .iter()
18867 .filter(|op| names.contains(op.table.as_str())),
18868 )?;
18869 }
18870 for table in &tables {
18871 for index in &table.fts_indexes {
18872 self.rebuild_fts_projection(table, index)?;
18873 self.create_fts_triggers(table, index)?;
18874 }
18875 }
18876 self.conn
18877 .execute_batch("RELEASE syncular_overlay")
18878 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
18879 Ok(())
18880 })();
18881 if rebuilt.is_err() {
18882 self.conn
18883 .execute_batch("ROLLBACK TO syncular_overlay; RELEASE syncular_overlay")
18884 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
18885 } else {
18886 self.overlay_dirty.set(false);
18887 }
18888 rebuilt
18889 }
18890
18891 fn apply_outbox_ops<'a>(
18896 &self,
18897 ops: impl IntoIterator<Item = &'a OutboxOp>,
18898 ) -> Result<(), String> {
18899 #[cfg(feature = "bench-internals")]
18900 let mut phase = self.benchmark_phases.start(Phase::PendingReplay);
18901 for op in ops {
18902 #[cfg(feature = "bench-internals")]
18903 if let Some(phase) = &mut phase {
18904 phase.unit();
18905 }
18906 if op.upsert {
18907 if let (Some(table), Some(values)) = (
18908 self.schema
18909 .table(&op.table)
18910 .filter(|table| table.indexes.iter().any(|index| index.unique)),
18911 op.values.as_ref(),
18912 ) {
18913 let mut intended: Map<String, Value> = self
18914 .visible_row(table, &op.row_id)?
18915 .map(|(row, _)| {
18916 table
18917 .columns
18918 .iter()
18919 .zip(&row)
18920 .map(|(column, value)| {
18921 (column.name.clone(), column_value_to_json(value))
18922 })
18923 .collect()
18924 })
18925 .unwrap_or_default();
18926 intended.extend(values.clone());
18927 if !self.unique_conflicts(table, &intended, false)?.is_empty() {
18928 continue;
18929 }
18930 }
18931 }
18932 self.apply_outbox_op(op)?;
18933 }
18934 Ok(())
18935 }
18936
18937 fn apply_outbox_op(&self, op: &OutboxOp) -> Result<(), String> {
18938 let Some(table) = self.schema.table(&op.table) else {
18939 return Ok(());
18940 };
18941 let visible = visible_table(&table.name);
18942 if !op.upsert {
18943 let sql = format!("DELETE FROM {visible} WHERE {}", row_id_predicate(table));
18944 self.conn
18945 .prepare_cached(&sql)
18946 .and_then(|mut statement| statement.execute(rusqlite::params![op.row_id]))
18947 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
18948 return Ok(());
18949 }
18950 let Some(values) = op.values.as_ref() else {
18951 return Ok(());
18952 };
18953 let full = table
18958 .columns
18959 .iter()
18960 .all(|column| values.contains_key(&column.name));
18961 let local = self.visible_row(table, &op.row_id)?;
18962 if local.is_none() && !full {
18963 return Ok(());
18964 }
18965 let mut row: Row = Vec::with_capacity(table.columns.len());
18966 for (index, column) in table.columns.iter().enumerate() {
18967 let incoming = match values.get(&column.name) {
18968 Some(raw) => json_to_column_value(
18969 if raw.get("$bytes").is_some()
18970 && table
18971 .encrypted_columns
18972 .iter()
18973 .any(|encrypted| encrypted.index == index)
18974 {
18975 &table.wire_columns[index]
18976 } else {
18977 column
18978 },
18979 Some(raw),
18980 ),
18981 None => Ok(local
18982 .as_ref()
18983 .and_then(|(values, _)| values.get(index).cloned())
18984 .flatten()),
18985 };
18986 row.push(incoming.map_err(|_| {
18987 "sync.local_corrupt: persisted optimistic row value is invalid".to_owned()
18988 })?);
18989 }
18990 let version = local.as_ref().map_or(-1, |(_, version)| *version);
18991 self.write_row(&visible, &table.name, &row, version)
18992 .map_err(|error| {
18993 if error.starts_with("UNIQUE constraint failed:") {
18994 "sync.constraint_violation: local write violates a unique constraint".to_owned()
18995 } else {
18996 error
18997 }
18998 })
18999 }
19000
19001 fn visible_row(&self, table: &TableSchema, row_id: &str) -> Result<Option<(Row, i64)>, String> {
19004 self.stored_row(table, row_id, false)
19005 }
19006
19007 fn stored_row(
19008 &self,
19009 table: &TableSchema,
19010 row_id: &str,
19011 base: bool,
19012 ) -> Result<Option<(Row, i64)>, String> {
19013 let sql = format!(
19014 "SELECT * FROM {} WHERE {}",
19015 if base {
19016 base_table(&table.name)
19017 } else {
19018 visible_table(&table.name)
19019 },
19020 row_id_predicate(table)
19021 );
19022 let mut stmt = self
19023 .conn
19024 .prepare_cached(&sql)
19025 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
19026 let mut rows = stmt
19027 .query(rusqlite::params![row_id])
19028 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
19029 let Some(row) = rows
19030 .next()
19031 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?
19032 else {
19033 return Ok(None);
19034 };
19035 let mut values = Vec::with_capacity(table.columns.len());
19036 for (index, column) in table.columns.iter().enumerate() {
19037 let raw = row
19038 .get_ref(index)
19039 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
19040 if let rusqlite::types::ValueRef::Blob(bytes) = raw {
19041 if table
19042 .encrypted_columns
19043 .iter()
19044 .any(|encrypted| encrypted.index == index)
19045 {
19046 values.push(Some(ColumnValue::Bytes(bytes.to_vec())));
19047 continue;
19048 }
19049 }
19050 let json = sql_ref_to_json(column, raw);
19051 values.push(json_to_column_value(column, Some(&json)).map_err(|_| {
19052 "sync.local_corrupt: persisted local row value is invalid".to_owned()
19053 })?);
19054 }
19055 let version: i64 = row
19056 .get(table.columns.len())
19057 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
19058 Ok(Some((values, version)))
19059 }
19060
19061 #[must_use]
19065 pub fn realtime_policy(&self) -> RealtimePolicy {
19066 self.realtime_policy
19067 }
19068
19069 #[must_use]
19071 pub fn realtime_state(&self) -> RealtimeState {
19072 self.realtime_state
19073 }
19074
19075 pub fn set_realtime_policy(&mut self, policy: RealtimePolicy) -> Result<(), String> {
19078 if self.realtime_state == RealtimeState::Connected && policy != self.realtime_policy {
19079 return Err(
19080 "sync.invalid_request: disconnect realtime before changing the realtime policy"
19081 .to_owned(),
19082 );
19083 }
19084 self.realtime_policy = policy;
19085 match policy {
19086 RealtimePolicy::Off => self.set_realtime_state(RealtimeState::Disabled, None),
19087 RealtimePolicy::Required | RealtimePolicy::Optional
19088 if self.realtime_state == RealtimeState::Disabled =>
19089 {
19090 self.set_realtime_state(RealtimeState::Disconnected, None);
19091 }
19092 RealtimePolicy::Required | RealtimePolicy::Optional => {}
19093 }
19094 Ok(())
19095 }
19096
19097 fn set_realtime_state(&mut self, state: RealtimeState, reason_code: Option<&str>) {
19100 self.realtime_state = state;
19101 self.realtime_reason_code = reason_code.map(Self::diagnostic_code);
19102 if matches!(state, RealtimeState::Connected | RealtimeState::Disabled) {
19103 self.realtime_reason_code = None;
19104 self.realtime_retry_delay_ms = None;
19105 }
19106 }
19107
19108 #[must_use]
19109 fn realtime_connected(&self) -> bool {
19110 self.realtime_state == RealtimeState::Connected
19111 }
19112
19113 pub fn connect_realtime(&mut self, transport: &mut dyn Transport) -> Result<(), String> {
19114 if !self.transport_enabled {
19115 return Err("sync.offline: transport gate is closed".into());
19116 }
19117 if self.realtime_policy == RealtimePolicy::Off {
19118 return Err(
19119 "sync.invalid_request: realtime is off; realtime connect is refused".to_owned(),
19120 );
19121 }
19122 if self.realtime_connected() {
19123 return Ok(());
19124 }
19125 self.realtime_state = RealtimeState::Connecting;
19126 match transport.realtime_connect_for_client(&self.client_id) {
19127 Ok(()) => {
19128 self.set_realtime_state(RealtimeState::Connected, None);
19129 Ok(())
19130 }
19131 Err(error) => {
19132 self.set_realtime_state(RealtimeState::Refused, Some(&error.code));
19133 Err(format!("{}: {}", error.code, error.message))
19134 }
19135 }
19136 }
19137
19138 pub fn disconnect_realtime(&mut self, transport: &mut dyn Transport) {
19139 self.cancel_sync_round();
19140 if self.realtime_state == RealtimeState::Disconnected
19143 || self.realtime_state == RealtimeState::Disabled
19144 {
19145 return;
19146 }
19147 let _ = transport.realtime_close();
19148 self.set_realtime_state(
19149 if self.realtime_policy == RealtimePolicy::Off {
19150 RealtimeState::Disabled
19151 } else {
19152 RealtimeState::Disconnected
19153 },
19154 None,
19155 );
19156 self.presence.clear(); }
19158
19159 pub fn set_presence(
19165 &mut self,
19166 transport: &mut dyn Transport,
19167 scope_key: &str,
19168 doc: Option<&Value>,
19169 ) -> Result<(), String> {
19170 if !self.transport_enabled {
19171 return Err("sync.offline: transport gate is closed".into());
19172 }
19173 if !self.realtime_connected() {
19174 return Err("setPresence requires a connected realtime socket (§8.6)".to_string());
19175 }
19176 let text = encode_presence_publish(scope_key, doc);
19177 transport
19178 .realtime_send(&text)
19179 .map_err(|e| format!("{}: {}", e.code, e.message))
19180 }
19181
19182 pub fn presence(&self, scope_key: &str) -> Vec<PresencePeer> {
19184 self.presence
19185 .get(scope_key)
19186 .map(|peers| peers.values().cloned().collect())
19187 .unwrap_or_default()
19188 }
19189
19190 fn apply_presence(
19192 &mut self,
19193 scope_key: String,
19194 kind: Option<PresenceKind>,
19195 actor_id: Option<String>,
19196 client_id: Option<String>,
19197 doc: Option<Value>,
19198 error: Option<String>,
19199 ) {
19200 if error.is_some() {
19203 return;
19204 }
19205 let (Some(kind), Some(actor_id), Some(client_id)) = (kind, actor_id, client_id) else {
19206 return;
19207 };
19208 let peer_key = format!("{actor_id} {client_id}");
19209 match kind {
19210 PresenceKind::Leave => {
19211 if let Some(peers) = self.presence.get_mut(&scope_key) {
19212 peers.remove(&peer_key);
19213 if peers.is_empty() {
19214 self.presence.remove(&scope_key);
19215 }
19216 }
19217 }
19218 _ => {
19219 let doc = match doc {
19220 Some(Value::Object(_)) => doc.unwrap(),
19221 _ => return,
19222 };
19223 self.presence.entry(scope_key).or_default().insert(
19224 peer_key,
19225 PresencePeer {
19226 actor_id,
19227 client_id,
19228 doc,
19229 },
19230 );
19231 }
19232 }
19233 }
19234
19235 pub fn on_realtime_text(&mut self, text: &str) {
19237 match parse_control(text) {
19238 Ok(ControlMessage::Hello { requires_sync, .. }) => {
19239 if requires_sync {
19240 self.set_sync_needed(true, true);
19242 }
19243 }
19244 Ok(ControlMessage::Presence {
19245 scope_key,
19246 kind,
19247 actor_id,
19248 client_id,
19249 doc,
19250 error,
19251 ..
19252 }) => {
19253 self.apply_presence(scope_key, kind, actor_id, client_id, doc, error);
19254 }
19255 Ok(ControlMessage::Wake { .. }) => {
19256 self.set_sync_needed(true, true);
19258 }
19259 _ => {}
19260 }
19261 }
19262
19263 pub fn on_realtime_binary_queued(&mut self, bytes: &[u8]) -> Vec<String> {
19267 let mut transport = crate::round::DownloadResults::default();
19268 self.on_realtime_binary(&mut transport, bytes);
19269 transport.controls
19270 }
19271
19272 pub fn on_realtime_binary(&mut self, transport: &mut dyn Transport, bytes: &[u8]) {
19273 if self.stopped || !self.transport_enabled {
19274 return;
19275 }
19276 #[cfg(feature = "bench-internals")]
19277 let decode_phase = self.benchmark_phases.start(Phase::ResponseDecode);
19278 let message = match decode_message(bytes) {
19279 Ok(m) if m.msg_kind == MsgKind::Response => m,
19280 _ => {
19281 self.set_sync_needed(true, true);
19282 return;
19283 }
19284 };
19285 #[cfg(feature = "bench-internals")]
19286 drop(decode_phase);
19287 let mut frames = message.frames.into_iter();
19288 let mut applied_cursor: Option<i64> = None;
19289 let mut any_covered = false;
19290 let mut dropped = false;
19291 while let Some(frame) = frames.next() {
19292 let Frame::SubStart {
19293 id,
19294 status,
19295 effective_scopes,
19296 ..
19297 } = frame
19298 else {
19299 continue;
19300 };
19301 let mut body = Vec::new();
19302 let mut next_cursor: Option<i64> = None;
19303 for inner in frames.by_ref() {
19304 match inner {
19305 Frame::SubEnd {
19306 next_cursor: nc, ..
19307 } => {
19308 next_cursor = Some(nc);
19309 break;
19310 }
19311 Frame::Unknown { .. } => {}
19312 other => body.push(other),
19313 }
19314 }
19315 let Some(sub_index) = self.subs.iter().position(|s| s.id == id) else {
19316 dropped = true;
19317 continue;
19318 };
19319 let sub = &self.subs[sub_index];
19320 if status != SubStatus::Active
19323 || sub.state != SubState::Active
19324 || sub.bootstrap_state.is_some()
19325 || !sub.synced_once
19326 {
19327 dropped = true;
19328 continue;
19329 }
19330 if next_cursor.is_some_and(|cursor| cursor <= sub.cursor) {
19331 any_covered = true;
19333 continue;
19334 }
19335 let previous_effective = self.subs[sub_index].effective.clone();
19336 let previous_cursor = self.subs[sub_index].cursor;
19337 self.subs[sub_index].effective = Some(effective_scopes);
19338 let mut failed = false;
19339 for inner in body {
19340 match inner {
19341 Frame::Commit {
19342 tables,
19343 changes,
19344 commit_seq,
19345 ..
19346 } => {
19347 if self
19348 .apply_commit_frame(&tables, &changes, Some(commit_seq))
19349 .is_err()
19350 {
19351 failed = true;
19352 break;
19353 }
19354 }
19355 _ => {
19356 failed = true;
19357 break;
19358 }
19359 }
19360 }
19361 let committed = next_cursor.filter(|_| !failed).and_then(|cursor| {
19362 self.apply_sub_end(sub_index, cursor, None)
19363 .ok()
19364 .map(|()| cursor)
19365 });
19366 let Some(next_cursor) = committed else {
19367 self.subs[sub_index].effective = previous_effective;
19368 self.subs[sub_index].cursor = previous_cursor;
19369 dropped = true;
19370 continue;
19371 };
19372 applied_cursor = Some(applied_cursor.map_or(next_cursor, |c| c.max(next_cursor)));
19373 }
19374 if let Some(cursor) = applied_cursor {
19375 let ack = format!("{{\"type\":\"ack\",\"cursor\":{cursor}}}");
19377 let _ = transport.realtime_send(&ack);
19378 } else if !any_covered || dropped {
19379 self.set_sync_needed(true, true);
19381 }
19382 }
19383
19384 fn ack_after_pull(&mut self, transport: &mut dyn Transport) {
19388 if !self.realtime_connected() {
19389 return;
19390 }
19391 let floor = self
19392 .subs
19393 .iter()
19394 .filter(|s| {
19395 s.state == SubState::Active
19396 && s.bootstrap_state.is_none()
19397 && s.synced_once
19398 && s.cursor >= 0
19399 })
19400 .map(|s| s.cursor)
19401 .min();
19402 if let Some(cursor) = floor {
19403 let ack = format!("{{\"type\":\"ack\",\"cursor\":{cursor}}}");
19404 let _ = transport.realtime_send(&ack);
19405 }
19406 }
19407}
19408
19409#[cfg(test)]
19410mod previous_version_wiring_tests {
19411 use super::*;
19412 use serde_json::{json, Value};
19413
19414 fn previous_version_schema(version: i32) -> Value {
19415 json!({
19416 "version": version,
19417 "tables": [{
19418 "name": "tasks",
19419 "primaryKey": "id",
19420 "columns": [
19421 { "name": "id", "type": "string", "nullable": false },
19422 { "name": "note", "type": "string", "nullable": true }
19423 ],
19424 "scopes": []
19425 }]
19426 })
19427 }
19428
19429 fn previous_version_limits() -> crate::api::ClientLimits {
19430 crate::api::ClientLimits {
19431 previous_version_context: Some(crate::previous_version::PreviousVersionContextConfig {
19432 enabled: true,
19433 ..Default::default()
19434 }),
19435 ..Default::default()
19436 }
19437 }
19438
19439 fn previous_version_config() -> crate::previous_version::PreviousVersionContextConfig {
19441 previous_version_limits()
19442 .previous_version_context
19443 .expect("configured")
19444 }
19445
19446 fn recapture_previous_version(client: &SyncClient) {
19449 let replica_path = client
19450 .previous_version_replica_path()
19451 .expect("file-backed replica");
19452 let outcome = crate::previous_version::capture_previous_version_from_replica(
19453 &client.conn,
19454 &replica_path,
19455 2,
19456 2,
19457 &previous_version_config(),
19458 1,
19459 )
19460 .expect("capture");
19461 assert!(matches!(
19462 outcome,
19463 crate::previous_version::CaptureOutcome::Captured(_)
19464 ));
19465 }
19466
19467 fn subscription(state: SubState, cursor: i64, bootstrap_state: Option<&str>) -> Subscription {
19468 Subscription {
19469 id: "wiring-sub".to_owned(),
19470 table: "tasks".to_owned(),
19471 requested: Vec::new(),
19472 params: None,
19473 cursor,
19474 bootstrap_state: bootstrap_state.map(str::to_owned),
19475 state,
19476 reason_code: None,
19477 effective: None,
19478 synced_once: false,
19479 }
19480 }
19481
19482 #[test]
19483 fn schema_downgrade_refuses_all_opens_and_recreate_without_mutation() {
19484 let path =
19485 std::env::temp_dir().join(format!("syncular-downgrade-{}.db", uuid::Uuid::new_v4()));
19486 let path_str = path.to_str().expect("temp path");
19487 let schema = previous_version_schema(3);
19488 let mut older = previous_version_schema(2);
19489 older["tables"][0]["columns"] =
19490 json!([{ "name": "id", "type": "string", "nullable": false }]);
19491 let mut client = SyncClient::open_path(
19492 "downgrade".into(),
19493 &previous_version_schema(1),
19494 previous_version_limits(),
19495 path_str,
19496 )
19497 .expect("fresh");
19498 client
19499 .mutate(vec![Mutation::Upsert {
19500 table: "tasks".into(),
19501 values: Map::from_iter([
19502 ("id".into(), json!("accepted")),
19503 ("note".into(), json!("old")),
19504 ]),
19505 base_version: None,
19506 }])
19507 .expect("v1 write");
19508 client
19509 .recreate_with_schema(&schema)
19510 .expect("upgrade captures context");
19511 let commit = client
19512 .mutate(vec![Mutation::Upsert {
19513 table: "tasks".into(),
19514 values: Map::from_iter([
19515 ("id".into(), json!("queued")),
19516 ("note".into(), json!("v3-only")),
19517 ]),
19518 base_version: None,
19519 }])
19520 .expect("queued v3 write");
19521 let changes = client.conn.total_changes();
19522 let outbox = client.pending_commit_ids();
19523 assert!(outbox.contains(&commit));
19524 let descriptor = client.get_meta("localSchemaDescriptor");
19525 let container = format!(
19526 "{path_str}{}",
19527 crate::previous_version::PREVIOUS_VERSION_CONTAINER_SUFFIX
19528 );
19529 let context = std::fs::read(&container).expect("captured context");
19530 assert!(client
19531 .recreate_with_schema(&older)
19532 .expect_err("refuse recreate")
19533 .starts_with("client.schema_downgrade:"));
19534 assert_eq!(client.schema.version, 3);
19535 assert_eq!(client.conn.total_changes(), changes);
19536 assert_eq!(client.pending_commit_ids(), outbox);
19537 assert_eq!(client.get_meta("localSchemaDescriptor"), descriptor);
19538 assert_eq!(
19539 client
19540 .conn
19541 .query_row("SELECT note FROM tasks WHERE id = 'queued'", [], |row| {
19542 row.get::<_, String>(0)
19543 })
19544 .expect("queued row"),
19545 "v3-only"
19546 );
19547 assert_eq!(std::fs::read(&container).expect("context remains"), context);
19548 drop(client);
19549 let before = std::fs::read(&path).expect("replica bytes");
19550 for entry in 0..3 {
19553 let error = match entry {
19554 0 => SyncClient::open_path(
19555 "downgrade".into(),
19556 &older,
19557 previous_version_limits(),
19558 path_str,
19559 ),
19560 1 => SyncClient::open_path_with_identity(
19561 None,
19562 &older,
19563 previous_version_limits(),
19564 path_str,
19565 ),
19566 _ => SyncClient::with_connection(
19567 "downgrade".into(),
19568 &older,
19569 previous_version_limits(),
19570 Connection::open(&path).expect("connection"),
19571 ),
19572 }
19573 .err()
19574 .expect("refuse open");
19575 assert!(error.starts_with("client.schema_downgrade:"));
19576 let conn = Connection::open(&path).expect("inspect");
19577 assert_eq!(
19578 conn.query_row("PRAGMA journal_mode", [], |row| row.get::<_, String>(0))
19579 .expect("journal mode"),
19580 "delete"
19581 );
19582 drop(conn);
19583 assert_eq!(std::fs::read(&path).expect("replica unchanged"), before);
19584 assert_eq!(
19585 std::fs::read(&container).expect("context unchanged"),
19586 context
19587 );
19588 }
19589 let reopened = SyncClient::open_path(
19590 "downgrade".into(),
19591 &schema,
19592 previous_version_limits(),
19593 path_str,
19594 )
19595 .expect("compatible reopen");
19596 assert_eq!(reopened.pending_commit_ids(), outbox);
19597 assert_eq!(
19598 reopened
19599 .conn
19600 .query_row("SELECT note FROM tasks WHERE id = 'queued'", [], |row| {
19601 row.get::<_, String>(0)
19602 })
19603 .expect("queued row remains"),
19604 "v3-only"
19605 );
19606 reopened.delete_meta(LOCAL_SCHEMA_VERSION_KEY);
19607 reopened.delete_meta(crate::previous_version::LOCAL_SCHEMA_DESCRIPTOR_KEY);
19608 drop(reopened);
19609 let legacy = SyncClient::open_path(
19610 "downgrade".into(),
19611 &schema,
19612 previous_version_limits(),
19613 path_str,
19614 )
19615 .expect("legacy no-marker reopen");
19616 assert_eq!(legacy.pending_commit_ids(), outbox);
19617 assert!(!legacy.upgrading());
19618 assert_eq!(
19619 legacy
19620 .conn
19621 .query_row("SELECT note FROM tasks WHERE id = 'queued'", [], |row| {
19622 row.get::<_, String>(0)
19623 })
19624 .expect("legacy row remains"),
19625 "v3-only"
19626 );
19627 drop(legacy);
19628 std::fs::remove_file(path).expect("remove replica");
19629 let _ = std::fs::remove_file(container);
19630 }
19631
19632 #[test]
19633 fn schema_marker_errors_refuse_open_and_recreate_without_mutation() {
19634 for marker in [
19635 "''",
19636 "'bad'",
19637 "'0'",
19638 "'-1'",
19639 "'2.5'",
19640 "'03'",
19641 "'3\n'",
19642 "' 3'",
19643 "'+3'",
19644 "'3e0'",
19645 "'2147483648'",
19646 "NULL",
19647 "X'33'",
19648 ] {
19649 let conn = Connection::open_in_memory().expect("connection");
19650 conn.execute_batch(&format!("CREATE TABLE _syncular_meta(key TEXT PRIMARY KEY, value); INSERT INTO _syncular_meta VALUES ('localSchemaVersion', {marker});")).expect("invalid marker fixture");
19651 let error = SyncClient::with_connection(
19652 "marker".into(),
19653 &previous_version_schema(3),
19654 ClientLimits::default(),
19655 conn,
19656 )
19657 .err()
19658 .expect("refuse marker");
19659 assert!(
19660 error.starts_with("sync.local_corrupt:"),
19661 "{marker}: {error}"
19662 );
19663 }
19664 for fault in [
19665 "UPDATE _syncular_meta SET value = 'bad' WHERE key = 'localSchemaVersion'",
19666 "ALTER TABLE _syncular_meta RENAME COLUMN value TO unreadable_value",
19667 ] {
19668 let mut client = SyncClient::new(
19669 "marker".into(),
19670 &previous_version_schema(3),
19671 ClientLimits::default(),
19672 )
19673 .expect("fresh");
19674 let commit = client
19675 .mutate(vec![Mutation::Upsert {
19676 table: "tasks".into(),
19677 values: Map::from_iter([("id".into(), json!("queued"))]),
19678 base_version: None,
19679 }])
19680 .expect("queued write");
19681 client.conn.execute_batch(fault).expect("marker fault");
19682 let changes = client.conn.total_changes();
19683 let error = client
19684 .recreate_with_schema(&previous_version_schema(4))
19685 .expect_err("refuse corrupt marker");
19686 assert!(error.starts_with("sync.local_corrupt:"));
19687 assert_eq!(client.conn.total_changes(), changes);
19688 assert_eq!(client.schema.version, 3);
19689 assert_eq!(client.pending_commit_ids(), vec![commit]);
19690 }
19691 for setup in [
19692 "CREATE TABLE _syncular_meta(key TEXT PRIMARY KEY)",
19693 "CREATE VIEW _syncular_meta AS SELECT 'localSchemaVersion' AS key, '3' AS value",
19694 ] {
19695 let path =
19696 std::env::temp_dir().join(format!("syncular-marker-{}.db", uuid::Uuid::new_v4()));
19697 let conn = Connection::open(&path).expect("connection");
19698 conn.execute_batch(setup)
19699 .expect("unreadable metadata fixture");
19700 drop(conn);
19701 let before = std::fs::read(&path).expect("before");
19702 let error = SyncClient::open_path_with_identity(
19703 None,
19704 &previous_version_schema(3),
19705 ClientLimits::default(),
19706 path.to_str().expect("path"),
19707 )
19708 .err()
19709 .expect("refuse unreadable marker");
19710 assert!(error.starts_with("sync.local_corrupt:"));
19711 assert_eq!(std::fs::read(&path).expect("after"), before);
19712 std::fs::remove_file(path).expect("remove fixture");
19713 }
19714 }
19715
19716 std::thread_local! {
19717 static CONCURRENT_UPGRADE: RefCell<Option<Connection>> =
19720 const { RefCell::new(None) };
19721 }
19722
19723 fn commit_concurrent_upgrade(attempts: i32) -> bool {
19724 CONCURRENT_UPGRADE.with(|slot| {
19725 if let Some(banner) = slot.borrow_mut().take() {
19726 banner
19727 .execute_batch(
19728 "UPDATE _syncular_meta SET value = '2' WHERE key = 'localSchemaVersion'; COMMIT",
19729 )
19730 .expect("commit the concurrent upgrade");
19731 }
19732 });
19733 attempts < 10
19734 }
19735
19736 fn journal_mode(conn: &Connection) -> String {
19737 conn.query_row("PRAGMA journal_mode", [], |row| row.get::<_, String>(0))
19738 .expect("journal mode")
19739 }
19740
19741 fn temp_replica(label: &str) -> std::path::PathBuf {
19742 std::env::temp_dir().join(format!("syncular-{label}-{}.db", uuid::Uuid::new_v4()))
19743 }
19744
19745 #[test]
19746 fn file_identity_constructor_requires_the_returned_wal_mode() {
19747 for path in [":memory:", ""] {
19748 let error = SyncClient::open_path_with_identity(
19749 None,
19750 &previous_version_schema(1),
19751 ClientLimits::default(),
19752 path,
19753 )
19754 .err()
19755 .expect("temporary SQLite databases cannot enter WAL mode");
19756 assert_eq!(
19757 error,
19758 "sync.invalid_request: file-backed client requires WAL journal mode"
19759 );
19760 }
19761 let path = temp_replica("confirmed-wal");
19762 let client = SyncClient::open_path_with_identity(
19763 None,
19764 &previous_version_schema(1),
19765 ClientLimits::default(),
19766 path.to_str().unwrap(),
19767 )
19768 .unwrap();
19769 assert_eq!(journal_mode(&client.conn), "wal");
19770 drop(client);
19771 std::fs::remove_file(path).unwrap();
19772 }
19773
19774 #[test]
19775 fn non_positive_generated_schema_versions_are_refused_before_storage() {
19776 for version in [json!(0), json!(-1), json!(i32::MIN), json!(2147483648i64)] {
19777 let mut schema = previous_version_schema(1);
19778 schema["version"] = version.clone();
19779 let error = SyncClient::new("bad-version".into(), &schema, ClientLimits::default())
19780 .err()
19781 .expect("refuse an out-of-range generated version");
19782 assert_eq!(
19783 error,
19784 "sync.invalid_request: generated schema version must be an integer in 1..=2147483647"
19785 );
19786 let path = temp_replica("bad-version");
19788 let path_str = path.to_str().expect("path");
19789 for identity in [false, true] {
19790 let error = if identity {
19791 SyncClient::open_path_with_identity(
19792 None,
19793 &schema,
19794 ClientLimits::default(),
19795 path_str,
19796 )
19797 } else {
19798 SyncClient::open_path(
19799 "bad-version".into(),
19800 &schema,
19801 ClientLimits::default(),
19802 path_str,
19803 )
19804 }
19805 .err()
19806 .expect("refuse before storage");
19807 assert!(
19808 error.starts_with("sync.invalid_request:"),
19809 "{version}: {error}"
19810 );
19811 assert!(
19812 !path.exists(),
19813 "{version}: a refused constructor creates no replica file"
19814 );
19815 }
19816 }
19817 let mut schema = previous_version_schema(1);
19819 schema
19820 .as_object_mut()
19821 .expect("schema object")
19822 .remove("version");
19823 let error = SyncClient::new("bad-version".into(), &schema, ClientLimits::default())
19824 .err()
19825 .expect("refuse a missing version");
19826 assert!(error.starts_with("sync.invalid_request:"), "{error}");
19827 }
19828
19829 #[test]
19830 fn a_duplicated_schema_marker_is_refused_without_a_reset() {
19831 let path = temp_replica("duplicate-marker");
19832 let path_str = path.to_str().expect("path");
19833 {
19834 let conn = Connection::open(path_str).expect("fixture");
19837 conn.execute_batch(
19838 "CREATE TABLE _syncular_meta(key TEXT, value);
19839 INSERT INTO _syncular_meta VALUES
19840 ('localSchemaVersion', '1'),
19841 ('localSchemaVersion', '2'),
19842 ('clientId', 'duplicate');",
19843 )
19844 .expect("duplicate marker fixture");
19845 }
19846 let error = SyncClient::open_path_with_identity(
19847 None,
19848 &previous_version_schema(3),
19849 ClientLimits::default(),
19850 path_str,
19851 )
19852 .err()
19853 .expect("refuse a duplicated marker");
19854 assert!(error.starts_with("sync.local_corrupt:"), "{error}");
19855 let probe = Connection::open(path_str).expect("probe");
19856 assert_eq!(
19857 probe
19858 .query_row(
19859 "SELECT COUNT(*) FROM _syncular_meta WHERE key = 'localSchemaVersion'",
19860 [],
19861 |row| row.get::<_, i64>(0),
19862 )
19863 .expect("marker rows"),
19864 2,
19865 "the duplicated rows are left for the operator, not collapsed"
19866 );
19867 assert_eq!(
19868 probe
19869 .query_row(
19870 "SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name NOT LIKE '_syncular_%' AND name NOT LIKE 'sqlite_%'",
19871 [],
19872 |row| row.get::<_, i64>(0),
19873 )
19874 .expect("synced tables"),
19875 0,
19876 "a duplicated marker never drives the reset that recreates v3 tables"
19877 );
19878 assert_eq!(
19879 journal_mode(&probe),
19880 "delete",
19881 "the refusal leaves the journal mode alone"
19882 );
19883 drop(probe);
19884 std::fs::remove_file(path).expect("remove replica");
19885 }
19886
19887 #[test]
19888 fn a_refused_open_does_not_persist_wal_or_mutate_the_replica() {
19889 let path = temp_replica("refused-wal");
19890 let path_str = path.to_str().expect("path");
19891 drop(
19894 SyncClient::open_path(
19895 "owner".into(),
19896 &previous_version_schema(2),
19897 ClientLimits::default(),
19898 path_str,
19899 )
19900 .expect("v2 replica"),
19901 );
19902 let before = std::fs::read(&path).expect("before");
19903 let error = SyncClient::open_path_with_identity(
19904 Some("intruder".into()),
19905 &previous_version_schema(2),
19906 ClientLimits::default(),
19907 path_str,
19908 )
19909 .err()
19910 .expect("refuse the identity mismatch");
19911 assert!(error.starts_with("client.identity_mismatch:"), "{error}");
19912 let probe = Connection::open(path_str).expect("probe");
19913 assert_eq!(
19914 journal_mode(&probe),
19915 "delete",
19916 "a refused open must not persist WAL"
19917 );
19918 assert_eq!(std::fs::read(&path).expect("after"), before);
19919 assert_eq!(
19920 meta_get(&probe, LOCAL_SCHEMA_VERSION_KEY).as_deref(),
19921 Some("2")
19922 );
19923 drop(probe);
19924 std::fs::remove_file(path).expect("remove replica");
19925 }
19926
19927 #[test]
19928 fn a_concurrent_marker_upgrade_refuses_a_stale_open() {
19929 let path = temp_replica("marker-race");
19930 let path_str = path.to_str().expect("path");
19931 drop(
19932 SyncClient::open_path_with_identity(
19933 Some("stale".into()),
19934 &previous_version_schema(1),
19935 ClientLimits::default(),
19936 path_str,
19937 )
19938 .expect("v1 replica"),
19939 );
19940 let banner = Connection::open(path_str).expect("banner");
19944 banner
19945 .execute_batch(
19946 "BEGIN IMMEDIATE; UPDATE _syncular_meta SET value = '2' WHERE key = 'localSchemaVersion';",
19947 )
19948 .expect("pending upgrade");
19949 CONCURRENT_UPGRADE.with(|slot| *slot.borrow_mut() = Some(banner));
19950 let conn = Connection::open(path_str).expect("stale connection");
19951 conn.busy_handler(Some(commit_concurrent_upgrade))
19952 .expect("busy handler");
19953 let error = SyncClient::with_connection(
19954 "stale".into(),
19955 &previous_version_schema(1),
19956 ClientLimits::default(),
19957 conn,
19958 )
19959 .err()
19960 .expect("a concurrent upgrade must refuse the stale open");
19961 assert!(error.starts_with("client.schema_downgrade:"), "{error}");
19962 let probe = Connection::open(path_str).expect("probe");
19963 assert_eq!(
19964 meta_get(&probe, LOCAL_SCHEMA_VERSION_KEY).as_deref(),
19965 Some("2"),
19966 "the concurrent upgrade survives"
19967 );
19968 drop(probe);
19969 std::fs::remove_file(path).expect("remove replica");
19970 }
19971
19972 #[test]
19973 fn a_concurrent_marker_upgrade_refuses_a_stale_recreate() {
19974 let path = temp_replica("recreate-race");
19975 let path_str = path.to_str().expect("path");
19976 let mut client = SyncClient::open_path_with_identity(
19977 Some("stale".into()),
19978 &previous_version_schema(1),
19979 ClientLimits::default(),
19980 path_str,
19981 )
19982 .expect("v1 replica");
19983 client
19984 .mutate(vec![Mutation::Upsert {
19985 table: "tasks".into(),
19986 values: Map::from_iter([("id".into(), json!("queued"))]),
19987 base_version: None,
19988 }])
19989 .expect("queued write");
19990 let commit = client.pending_commit_ids();
19991 let banner = Connection::open(path_str).expect("banner");
19992 banner
19993 .execute_batch(
19994 "BEGIN IMMEDIATE; UPDATE _syncular_meta SET value = '2' WHERE key = 'localSchemaVersion';",
19995 )
19996 .expect("pending upgrade");
19997 CONCURRENT_UPGRADE.with(|slot| *slot.borrow_mut() = Some(banner));
19998 client
19999 .conn
20000 .busy_handler(Some(commit_concurrent_upgrade))
20001 .expect("busy handler");
20002 let error = client
20003 .recreate_with_schema(&previous_version_schema(1))
20004 .expect_err("a concurrent upgrade must refuse the stale recreate");
20005 assert!(error.starts_with("client.schema_downgrade:"), "{error}");
20006 assert_eq!(client.schema.version, 1, "the compiled schema is restored");
20007 assert_eq!(
20008 client.pending_commit_ids(),
20009 commit,
20010 "the outbox is preserved"
20011 );
20012 let probe = Connection::open(path_str).expect("probe");
20013 assert_eq!(
20014 meta_get(&probe, LOCAL_SCHEMA_VERSION_KEY).as_deref(),
20015 Some("2")
20016 );
20017 drop(probe);
20018 drop(client);
20019 std::fs::remove_file(path).expect("remove replica");
20020 }
20021
20022 #[test]
20023 fn failed_schema_record_writes_roll_back_recreation() {
20024 for key in [
20025 LOCAL_SCHEMA_VERSION_KEY,
20026 crate::previous_version::LOCAL_SCHEMA_DESCRIPTOR_KEY,
20027 ] {
20028 let mut client = SyncClient::new(
20029 "schema-record".into(),
20030 &previous_version_schema(1),
20031 ClientLimits::default(),
20032 )
20033 .expect("v1");
20034 let marker = client.get_meta(LOCAL_SCHEMA_VERSION_KEY);
20035 let descriptor = client.get_meta(crate::previous_version::LOCAL_SCHEMA_DESCRIPTOR_KEY);
20036 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");
20037 client
20038 .recreate_with_schema(&previous_version_schema(2))
20039 .expect_err("both record writes must propagate failure");
20040 assert_eq!(client.schema.version, 1);
20041 assert_eq!(client.get_meta(LOCAL_SCHEMA_VERSION_KEY), marker);
20042 assert_eq!(
20043 client.get_meta(crate::previous_version::LOCAL_SCHEMA_DESCRIPTOR_KEY),
20044 descriptor
20045 );
20046 client
20047 .conn
20048 .execute_batch("DROP TRIGGER refuse_schema_record")
20049 .expect("remove fault");
20050 client
20051 .recreate_with_schema(&previous_version_schema(2))
20052 .expect("retry");
20053 assert_eq!(client.schema.version, 2);
20054 }
20055 }
20056
20057 #[test]
20058 fn a_failed_outer_commit_restores_every_reset_side_effect() {
20059 let path = temp_replica("outer-commit");
20060 let path_str = path.to_str().expect("path");
20061 let conn = Connection::open(path_str).expect("replica connection");
20064 let mut client = SyncClient::with_connection(
20065 "outer-commit".into(),
20066 &previous_version_schema(1),
20067 previous_version_limits(),
20068 conn,
20069 )
20070 .expect("v1 replica");
20071 assert_eq!(journal_mode(&client.conn), "delete");
20072 let commit = client
20073 .mutate(vec![Mutation::Upsert {
20074 table: "tasks".into(),
20075 values: Map::from_iter([
20076 ("id".into(), json!("queued")),
20077 ("note".into(), json!("dropped-by-v2")),
20078 ]),
20079 base_version: None,
20080 }])
20081 .expect("queued write");
20082 client.drain_change_batches();
20083 let mut dropped = previous_version_schema(2);
20085 dropped["tables"][0]["columns"] =
20086 json!([{ "name": "id", "type": "string", "nullable": false }]);
20087 let marker = meta_get(&client.conn, LOCAL_SCHEMA_VERSION_KEY);
20090 let revision = client.local_revision();
20091 let rows = client.query("SELECT * FROM tasks", &[]).expect("rows");
20092 let columns: i64 = client
20093 .conn
20094 .query_row(
20095 "SELECT count(*) FROM pragma_table_info('tasks')",
20096 [],
20097 |row| row.get(0),
20098 )
20099 .expect("column count");
20100 let rebuilds = client.overlay_rebuild_count.get();
20101 let prior_subs = client.subs.len();
20102 let prior_changes = client.change_queue.len();
20103 let prior_intents = client.sync_intent_queue.len();
20104 let prior_last_change = client.last_change.clone();
20105 let prior_overlay_dirty = client.overlay_dirty.get();
20106 let prior_sync_needed = client.sync_needed;
20107 let prior_upgrading = client.upgrading;
20108 let prior_stopped = client.stopped;
20109 let prior_schema_floor = client.schema_floor.clone();
20110 let prior_active_round = client.active_round;
20111 let reader = Connection::open(path_str).expect("reader");
20114 reader
20115 .execute_batch("BEGIN; SELECT count(*) FROM _syncular_meta;")
20116 .expect("reader snapshot");
20117 client
20118 .conn
20119 .busy_timeout(std::time::Duration::ZERO)
20120 .expect("busy timeout");
20121 let error = client
20122 .recreate_with_schema(&dropped)
20123 .expect_err("a blocked COMMIT must refuse the recreation");
20124 assert!(error.starts_with("client.storage_busy:"), "{error}");
20125 assert!(
20126 client.overlay_rebuild_count.get() > rebuilds,
20127 "the reset ran to its overlay rebuild inside the transaction"
20128 );
20129 assert!(client.conn.is_autocommit(), "the transaction is released");
20130 assert_eq!(meta_get(&client.conn, LOCAL_SCHEMA_VERSION_KEY), marker);
20132 assert_eq!(client.local_revision(), revision);
20133 assert_eq!(
20134 client.query("SELECT * FROM tasks", &[]).expect("rows"),
20135 rows
20136 );
20137 assert_eq!(
20138 client
20139 .conn
20140 .query_row(
20141 "SELECT count(*) FROM pragma_table_info('tasks')",
20142 [],
20143 |row| { row.get::<_, i64>(0) }
20144 )
20145 .expect("column count"),
20146 columns,
20147 "the previous table layout survives the rollback"
20148 );
20149 assert_eq!(client.pending_commit_ids(), vec![commit.clone()]);
20150 assert_eq!(client.schema.version, 1, "compiled schema");
20153 assert_eq!(client.subs.len(), prior_subs);
20154 assert_eq!(client.change_queue.len(), prior_changes);
20155 assert_eq!(client.sync_intent_queue.len(), prior_intents);
20156 assert_eq!(client.last_change, prior_last_change);
20157 assert_eq!(client.overlay_dirty.get(), prior_overlay_dirty);
20158 assert_eq!(client.sync_needed, prior_sync_needed);
20159 assert_eq!(client.upgrading, prior_upgrading);
20160 assert_eq!(client.stopped, prior_stopped);
20161 assert_eq!(client.schema_floor, prior_schema_floor);
20162 assert_eq!(client.active_round, prior_active_round);
20163 assert!(
20164 client.rejections.is_empty(),
20165 "the dropped-column rejection is rolled back too"
20166 );
20167 let container = crate::previous_version::previous_version_container_path(path_str);
20170 assert!(std::path::Path::new(&container).exists());
20171 drop(reader);
20174 let snapshot = client
20175 .previous_version_snapshot(&PreviousVersionReadSpec {
20176 table: "tasks".into(),
20177 ..Default::default()
20178 })
20179 .expect("refuse stale capture");
20180 assert!(!snapshot.available);
20181 assert_eq!(snapshot.current_version, 1);
20182 assert!(!std::path::Path::new(&container).exists());
20183 client
20188 .conn
20189 .busy_timeout(std::time::Duration::from_secs(1))
20190 .expect("busy timeout");
20191 client
20192 .recreate_with_schema(&dropped)
20193 .expect("the recreation succeeds once the reader releases");
20194 assert_eq!(client.schema.version, 2);
20195 assert_eq!(
20196 client.pending_commit_ids(),
20197 vec![commit.clone()],
20198 "the reset kept the incompatible commit pending"
20199 );
20200 assert!(
20201 client.rejections.is_empty(),
20202 "no rejection exists before the send-time prepass"
20203 );
20204 client
20206 .prepare_sync_round(false)
20207 .expect("the send prepass runs during round preparation");
20208 assert!(client.pending_commit_ids().is_empty());
20209 assert!(
20210 client
20211 .rejections
20212 .iter()
20213 .any(|rejection| rejection.code == OUTBOX_INCOMPATIBLE_CODE),
20214 "the dropped-column commit surfaced as a rejection at send time"
20215 );
20216 client.previous_version_discard();
20217 drop(client);
20218 std::fs::remove_file(path).expect("remove replica");
20219 }
20220
20221 #[test]
20222 fn the_send_time_incompatible_drop_is_atomic_and_publishes_once() {
20223 let v1 = previous_version_schema(1);
20224 let mut v2 = previous_version_schema(2);
20225 v2["tables"][0]["columns"] = json!([{ "name": "id", "type": "string", "nullable": false }]);
20227 let mut client = SyncClient::new("drop".to_string(), &v1, ClientLimits::default()).unwrap();
20228 let dropped = client
20229 .mutate(vec![Mutation::Upsert {
20230 table: "tasks".into(),
20231 values: Map::from_iter([
20232 ("id".into(), json!("dropped")),
20233 ("note".into(), json!("gone")),
20234 ]),
20235 base_version: None,
20236 }])
20237 .unwrap();
20238 let survivor = client
20242 .mutate(vec![Mutation::Delete {
20243 table: "tasks".into(),
20244 row_id: "gone".into(),
20245 base_version: None,
20246 }])
20247 .unwrap();
20248 client
20249 .recreate_with_schema(&v2)
20250 .expect("the reset preserves the outbox");
20251 assert_eq!(
20252 client.pending_commit_ids(),
20253 vec![dropped.clone(), survivor.clone()],
20254 "the reset preserves both commits"
20255 );
20256 assert!(client.rejections.is_empty(), "the reset classifies nothing");
20257 client.drain_change_batches();
20258 let rows = client.query("SELECT * FROM tasks", &[]).unwrap();
20259 assert_eq!(rows.len(), 1, "only the dropped upsert has a visible row");
20260
20261 for fault in [
20267 "outcome-insert",
20268 "outbox-delete",
20269 "overlay-rebuild",
20270 "publish-revision",
20271 ] {
20272 client.set_sync_needed(false, false);
20273 client.drain_change_batches();
20274 let revision = client.local_revision();
20275 let trigger = match fault {
20276 "outcome-insert" => {
20277 "CREATE TRIGGER fail_drop BEFORE INSERT ON _syncular_commit_outcomes BEGIN SELECT RAISE(ABORT,'drop fault'); END"
20278 }
20279 "outbox-delete" => {
20280 "CREATE TRIGGER fail_drop BEFORE DELETE ON _syncular_outbox BEGIN SELECT RAISE(ABORT,'drop fault'); END"
20281 }
20282 "publish-revision" => {
20287 "CREATE TRIGGER fail_drop BEFORE INSERT ON _syncular_meta WHEN NEW.key = 'localRevision' BEGIN SELECT RAISE(ABORT,'drop fault'); END"
20288 }
20289 _ => {
20290 "CREATE TRIGGER fail_drop BEFORE DELETE ON tasks BEGIN SELECT RAISE(ABORT,'drop fault'); END"
20291 }
20292 };
20293 client.conn.execute_batch(trigger).unwrap();
20294 let outcome = client
20295 .prepare_sync_round(false)
20296 .err()
20297 .unwrap_or_else(|| panic!("{fault}: a fault in the drop must fail the round"));
20298 assert!(
20299 matches!(*outcome, SyncOutcome::Failed { ref error_code, .. } if error_code == "storage.failed"),
20300 "{fault}: {outcome:?}"
20301 );
20302 client.conn.execute_batch("DROP TRIGGER fail_drop").unwrap();
20303 assert!(client.conn.is_autocommit(), "{fault}: no leaked savepoint");
20304 assert_eq!(
20305 client.pending_commit_ids(),
20306 vec![dropped.clone(), survivor.clone()],
20307 "{fault}: the outbox is restored"
20308 );
20309 assert!(
20310 client.rejections.is_empty(),
20311 "{fault}: no in-memory rejection survives"
20312 );
20313 assert!(
20314 client.commit_outcome(&dropped).unwrap().is_none(),
20315 "{fault}: the durable outcome rolled back"
20316 );
20317 assert_eq!(client.local_revision(), revision, "{fault}: revision");
20318 assert_eq!(
20319 client.query("SELECT * FROM tasks", &[]).unwrap(),
20320 rows,
20321 "{fault}: the visible projection is unchanged"
20322 );
20323 assert!(
20324 client.drain_change_batches().is_empty(),
20325 "{fault}: nothing is published"
20326 );
20327 }
20328
20329 client
20332 .conn
20333 .execute_batch(
20334 "BEGIN; INSERT INTO _syncular_meta(key,value) VALUES ('outerProbe','kept');
20335 CREATE TRIGGER fail_drop BEFORE INSERT ON _syncular_commit_outcomes
20336 BEGIN SELECT RAISE(ABORT,'nested drop fault'); END;",
20337 )
20338 .unwrap();
20339 assert!(client.drop_incompatible_outbox().is_err());
20340 assert!(!client.conn.is_autocommit());
20341 assert_eq!(client.get_meta("outerProbe").as_deref(), Some("kept"));
20342 assert_eq!(
20343 client.pending_commit_ids(),
20344 vec![dropped.clone(), survivor.clone()]
20345 );
20346 assert!(client.commit_outcome(&dropped).unwrap().is_none());
20347 client
20348 .conn
20349 .execute_batch("DROP TRIGGER fail_drop; COMMIT")
20350 .unwrap();
20351
20352 client.set_meta(LOG_EPOCH_KEY, "epoch");
20357 client.set_sync_needed(false, false);
20358 client.drain_change_batches();
20359 let revision = client.local_revision();
20360 let prepared = client.prepare_sync_round(false).expect("the drop succeeds");
20361 assert_eq!(client.pending_commit_ids(), vec![survivor.clone()]);
20362 assert_eq!(
20363 prepared.meta.pushed_ids,
20364 vec![survivor.clone()],
20365 "the incompatible commit is classified before any operation is sent"
20366 );
20367 let rejection = client
20368 .rejections
20369 .iter()
20370 .find(|rejection| rejection.code == OUTBOX_INCOMPATIBLE_CODE)
20371 .expect("a sync.outbox_incompatible rejection");
20372 assert_eq!(rejection.client_commit_id, dropped);
20373 assert!(
20374 !rejection.retryable,
20375 "a schema-incompatible commit is final"
20376 );
20377 assert_eq!(
20378 client
20379 .commit_outcome(&dropped)
20380 .unwrap()
20381 .expect("a durable rejected outcome")
20382 .status,
20383 CommitOutcomeStatus::Rejected
20384 );
20385 assert_eq!(client.local_revision(), revision + 1, "one revision");
20386 let batches = client.drain_change_batches();
20387 assert_eq!(batches.len(), 1, "one change batch");
20388 assert!(
20389 batches[0].rejections_changed
20390 && batches[0].outcomes_changed
20391 && batches[0].status.is_some(),
20392 "{:?}",
20393 batches[0]
20394 );
20395 assert!(
20396 batches[0].tables.iter().any(|entry| entry.table == "tasks"),
20397 "{:?}",
20398 batches[0]
20399 );
20400 let visible = client.query("SELECT * FROM tasks", &[]).unwrap();
20401 assert!(
20402 visible.is_empty(),
20403 "the dropped projection is undone: {visible:?}"
20404 );
20405
20406 let path = temp_replica("outbox-drop-commit");
20410 let path_str = path.to_str().expect("path");
20411 let conn = Connection::open(path_str).expect("replica connection");
20412 let mut client =
20413 SyncClient::with_connection("drop-commit".into(), &v1, ClientLimits::default(), conn)
20414 .expect("v1 replica");
20415 let dropped = client
20416 .mutate(vec![Mutation::Upsert {
20417 table: "tasks".into(),
20418 values: Map::from_iter([
20419 ("id".into(), json!("dropped")),
20420 ("note".into(), json!("gone")),
20421 ]),
20422 base_version: None,
20423 }])
20424 .unwrap();
20425 client
20426 .recreate_with_schema(&v2)
20427 .expect("the reset preserves the outbox");
20428 assert_eq!(client.pending_commit_ids(), vec![dropped.clone()]);
20429 let rows = client.query("SELECT * FROM tasks", &[]).unwrap();
20430 assert_eq!(rows.len(), 1, "the optimistic row is visible");
20431 client.set_sync_needed(false, false);
20432 client.drain_change_batches();
20433 let revision = client.local_revision();
20434 let reader = Connection::open(path_str).expect("reader");
20435 reader
20436 .execute_batch("BEGIN; SELECT count(*) FROM _syncular_meta;")
20437 .expect("reader snapshot");
20438 client
20439 .conn
20440 .busy_timeout(std::time::Duration::ZERO)
20441 .expect("busy timeout");
20442 let outcome = client
20443 .prepare_sync_round(false)
20444 .err()
20445 .expect("a blocked COMMIT must fail the round");
20446 assert!(
20447 matches!(*outcome, SyncOutcome::Failed { ref error_code, .. } if error_code == "client.storage_busy"),
20448 "{outcome:?}"
20449 );
20450 assert!(client.conn.is_autocommit(), "the savepoint is released");
20451 assert_eq!(client.pending_commit_ids(), vec![dropped.clone()]);
20452 assert!(client.rejections.is_empty());
20453 assert!(client.commit_outcome(&dropped).unwrap().is_none());
20454 assert_eq!(client.local_revision(), revision);
20455 assert_eq!(client.query("SELECT * FROM tasks", &[]).unwrap(), rows);
20456 assert!(client.drain_change_batches().is_empty());
20457 drop(reader);
20459 client
20460 .conn
20461 .busy_timeout(std::time::Duration::from_secs(1))
20462 .expect("busy timeout");
20463 client
20464 .prepare_sync_round(false)
20465 .expect("the drop succeeds after the reader releases");
20466 assert!(client.pending_commit_ids().is_empty());
20467 assert!(client
20468 .rejections
20469 .iter()
20470 .any(|rejection| rejection.code == OUTBOX_INCOMPATIBLE_CODE));
20471 assert!(client.query("SELECT * FROM tasks", &[]).unwrap().is_empty());
20472 drop(client);
20473 std::fs::remove_file(path).expect("remove replica");
20474 }
20475
20476 #[test]
20477 fn previous_version_read_reasons_come_from_live_client_state() {
20478 use crate::previous_version::{
20479 PreviousVersionReadSpec, PreviousVersionReason, PREVIOUS_VERSION_CONTAINER_SUFFIX,
20480 };
20481 let path = std::env::temp_dir()
20482 .join(format!("syncular-prev-wiring-{}.db", uuid::Uuid::new_v4()))
20483 .to_string_lossy()
20484 .into_owned();
20485 let container = format!("{path}{PREVIOUS_VERSION_CONTAINER_SUFFIX}");
20486 {
20487 let mut setup = SyncClient::open_path(
20488 "wiring".to_owned(),
20489 &previous_version_schema(1),
20490 previous_version_limits(),
20491 &path,
20492 )
20493 .expect("v1 install");
20494 setup
20495 .mutate(vec![Mutation::Upsert {
20496 table: "tasks".to_owned(),
20497 values: Map::from_iter([("id".to_owned(), json!("t1"))]),
20498 base_version: None,
20499 }])
20500 .expect("seed a row");
20501 }
20502 let mut client = SyncClient::open_path(
20503 "wiring".to_owned(),
20504 &previous_version_schema(2),
20505 previous_version_limits(),
20506 &path,
20507 )
20508 .expect("v2 bump captures");
20509 assert!(std::path::Path::new(&container).exists());
20510 let spec = PreviousVersionReadSpec {
20511 table: "tasks".to_owned(),
20512 ..Default::default()
20513 };
20514
20515 recapture_previous_version(&client);
20517 client.lease_state = Some(LeaseState {
20518 lease_id: None,
20519 expires_at_ms: None,
20520 error_code: Some("lease.expired".to_owned()),
20521 });
20522 let snapshot = client.previous_version_snapshot(&spec).expect("snapshot");
20523 assert_eq!(snapshot.reason, Some(PreviousVersionReason::LeaseInactive));
20524 assert!(!snapshot.available);
20525 assert!(!std::path::Path::new(&container).exists());
20526
20527 recapture_previous_version(&client);
20529 client.set_now_ms(1_000);
20530 client.lease_state = Some(LeaseState {
20531 lease_id: Some("lease".to_owned()),
20532 expires_at_ms: Some(1_000),
20533 error_code: None,
20534 });
20535 let snapshot = client.previous_version_snapshot(&spec).expect("snapshot");
20536 assert_eq!(snapshot.reason, Some(PreviousVersionReason::LeaseInactive));
20537 assert!(!std::path::Path::new(&container).exists());
20538 client.lease_state = None;
20539
20540 recapture_previous_version(&client);
20542 client.subs.push(subscription(SubState::Revoked, 0, None));
20543 let snapshot = client.previous_version_snapshot(&spec).expect("snapshot");
20544 assert_eq!(snapshot.reason, Some(PreviousVersionReason::ScopeRevoked));
20545 assert!(!std::path::Path::new(&container).exists());
20546 client.subs.clear();
20547
20548 recapture_previous_version(&client);
20551 client
20552 .subs
20553 .push(subscription(SubState::Active, 0, Some("resume")));
20554 assert!(!client.previous_version_coverage_complete());
20555 let snapshot = client.previous_version_snapshot(&spec).expect("snapshot");
20556 assert!(snapshot.available);
20557 assert_eq!(snapshot.previous_version, Some(2));
20558 assert_eq!(snapshot.rows.len(), 1);
20559
20560 recapture_previous_version(&client);
20561 client.subs[0].bootstrap_state = None;
20562 assert!(client.previous_version_coverage_complete());
20563 let snapshot = client.previous_version_snapshot(&spec).expect("snapshot");
20564 assert_eq!(
20565 snapshot.reason,
20566 Some(PreviousVersionReason::CoverageComplete)
20567 );
20568 assert!(!std::path::Path::new(&container).exists());
20569 client.subs.clear();
20570
20571 assert!(!client.previous_version_coverage_complete());
20573
20574 drop(client);
20575 let _ = std::fs::remove_file(&path);
20576 }
20577
20578 #[test]
20585 fn previous_version_lifetime_check_drops_on_lease_coverage_and_revocation() {
20586 let path = std::env::temp_dir()
20587 .join(format!(
20588 "syncular-prev-lifetime-{}.db",
20589 uuid::Uuid::new_v4()
20590 ))
20591 .to_string_lossy()
20592 .into_owned();
20593 let container = format!(
20594 "{path}{}",
20595 crate::previous_version::PREVIOUS_VERSION_CONTAINER_SUFFIX
20596 );
20597 let exists = || std::path::Path::new(&container).exists();
20598 {
20599 let mut setup = SyncClient::open_path(
20600 "lifetime".to_owned(),
20601 &previous_version_schema(1),
20602 previous_version_limits(),
20603 &path,
20604 )
20605 .expect("v1 install");
20606 setup
20607 .mutate(vec![Mutation::Upsert {
20608 table: "tasks".to_owned(),
20609 values: Map::from_iter([("id".to_owned(), json!("t1"))]),
20610 base_version: None,
20611 }])
20612 .expect("seed a row");
20613 }
20614 let mut client = SyncClient::open_path(
20615 "lifetime".to_owned(),
20616 &previous_version_schema(2),
20617 previous_version_limits(),
20618 &path,
20619 )
20620 .expect("v2 bump captures");
20621 client.set_now_ms(1_000);
20624 assert!(exists());
20625
20626 client.lease_state = Some(LeaseState {
20628 lease_id: None,
20629 expires_at_ms: None,
20630 error_code: Some("lease.expired".to_owned()),
20631 });
20632 client.previous_version_lifetime_check();
20633 assert!(!exists());
20634 client.lease_state = None;
20635
20636 recapture_previous_version(&client);
20639 client.subs.push(subscription(SubState::Active, 0, None));
20640 client.previous_version_lifetime_check();
20641 assert!(!exists());
20642 client.subs.clear();
20643
20644 recapture_previous_version(&client);
20646 client.subs.push(subscription(SubState::Revoked, 0, None));
20647 client.previous_version_lifetime_check();
20648 assert!(!exists());
20649 client.subs.clear();
20650
20651 recapture_previous_version(&client);
20654 client
20655 .subs
20656 .push(subscription(SubState::Active, 0, Some("resume")));
20657 client.previous_version_lifetime_check();
20658 assert!(exists());
20659 client.subs.clear();
20660
20661 drop(client);
20662 let _ = std::fs::remove_file(&path);
20663 let _ = std::fs::remove_file(&container);
20664 let _ = std::fs::remove_file(format!("{container}-wal"));
20665 let _ = std::fs::remove_file(format!("{container}-shm"));
20666 }
20667}