1use std::cell::{Cell, RefCell};
11use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet, VecDeque};
12
13use rusqlite::types::{ToSqlOutput, Value as SqlValue, ValueRef};
14use rusqlite::{Connection, OpenFlags, OptionalExtension};
15use serde_json::{Map, Value};
16use sha2::{Digest, Sha256};
17use ssp2::model::{Frame, MediaType, Message, MsgKind, Op, OpResult, PushStatus, SubStatus};
18use ssp2::primitives::RawJson;
19use ssp2::segment::{decode_rows_segment, Column, ColumnType, ColumnValue, Row, RowsSegment};
20use ssp2::{
21 decode_message, encode_message, encode_presence_publish, parse_control, ControlMessage,
22 PresenceKind,
23};
24
25use crate::api::{
26 ClientChangeBatch, ClientDiagnosticsHost, ClientDiagnosticsLease, ClientDiagnosticsReplica,
27 ClientDiagnosticsRequest, ClientDiagnosticsSchema, ClientDiagnosticsSnapshot,
28 ClientDiagnosticsStorage, ClientLimits, CommandEffects, CommitOperation,
29 CommitOperationOutcome, CommitOutcome, CommitOutcomeQuery, CommitOutcomeResolution,
30 CommitOutcomeStatus, ConflictRecord, CoverageSnapshot, DiagnosticLastChange,
31 DiagnosticLastRound, DiagnosticRoundCounters, DiagnosticSubscription, LeaseState,
32 LocalDataPurgeInput, LocalDataPurgeResult, LocalDataPurgeTarget, Mutation, PresencePeer,
33 QueryRow, QuerySnapshot, QueryValue, RejectionDetails, RejectionRecord,
34 ResolveCommitOutcomeInput, RowState, SchemaFloor, SubscriptionStateView, SyncIntent,
35 SyncOutcome, SyncReport, SyncStatusSnapshot, TableChange, WindowBase, WindowChange,
36 WindowCoverage, WindowState, WindowUnitRef, CLIENT_DIAGNOSTICS_VERSION,
37 MAX_DIAGNOSTIC_EXPECTED_SUBSCRIPTIONS,
38};
39use crate::schema::{parse_schema_json, ClientSchema, FtsIndexSchema, TableSchema};
40use crate::transport::{BlobDownload, BlobUploadGrant, SegmentRequest, Transport, TransportError};
41use crate::values::{
42 bytes_to_hex, canonical_scope_json, column_value_to_json, decode_row_bytes, encode_row_json,
43 json_to_column_value, json_to_scope_map, normalize_values_casing, render_row_id_json,
44 scope_map_to_json, sort_scope_map,
45};
46
47const DEFAULT_ACCEPT: u8 = 0b0111;
52const ACCEPT_INLINE_ROWS: u8 = 1 << 0;
53const ACCEPT_EXTERNAL_ROWS: u8 = 1 << 1;
54const ACCEPT_SQLITE: u8 = 1 << 2;
55const ACCEPT_SIGNED_URLS: u8 = 1 << 3;
56const MAX_DIAGNOSTIC_DOMAINS: usize = 256;
57
58const LOCAL_SCHEMA_VERSION_KEY: &str = "localSchemaVersion";
60const LOCAL_REVISION_KEY: &str = "localRevision";
61const CLIENT_ID_KEY: &str = "clientId";
62const LEASE_STATE_KEY: &str = "leaseState";
63const SCHEMA_FLOOR_KEY: &str = "schemaFloor";
64const OUTBOX_INCOMPATIBLE_CODE: &str = "sync.outbox_incompatible";
67
68#[derive(Debug, Clone, Copy, PartialEq, Eq)]
69enum SubState {
70 Active,
71 Revoked,
72 Failed,
73}
74
75#[cfg(test)]
76mod observation_tests {
77 use super::*;
78 use crate::native_transport::HostTransport;
79 use serde_json::json;
80
81 fn client() -> SyncClient {
82 SyncClient::new(
83 "retry-test".to_owned(),
84 &json!({
85 "version": 1,
86 "tables": [{
87 "name": "tasks",
88 "primaryKey": "id",
89 "columns": [
90 { "name": "id", "type": "string", "nullable": false },
91 { "name": "project_id", "type": "string", "nullable": false }
92 ],
93 "scopes": [{ "pattern": "project:{project_id}" }]
94 }]
95 }),
96 ClientLimits::default(),
97 )
98 .expect("test client")
99 }
100
101 #[test]
102 fn background_retry_deadlines_back_off_and_reset() {
103 let mut client = client();
104 client.schedule_background_retry();
105 assert!(matches!(
106 client.drain_sync_intents().as_slice(),
107 [SyncIntent::Background { delay_ms: 250 }]
108 ));
109 client.schedule_background_retry();
110 assert!(matches!(
111 client.drain_sync_intents().as_slice(),
112 [SyncIntent::Background { delay_ms: 500 }]
113 ));
114 client.reset_background_retry();
115 client.schedule_background_retry();
116 assert!(matches!(
117 client.drain_sync_intents().as_slice(),
118 [SyncIntent::Background { delay_ms: 250 }]
119 ));
120 }
121
122 #[test]
123 fn batched_push_acknowledgements_rebuild_overlay_once_per_response() {
124 let mut client = client();
125 const COMMIT_COUNT: usize = 32;
126
127 for index in 0..COMMIT_COUNT {
128 client
129 .mutate(vec![Mutation::Upsert {
130 table: "tasks".to_owned(),
131 values: Map::from_iter([
132 ("id".to_owned(), Value::from(format!("task-{index}"))),
133 ("project_id".to_owned(), Value::from("project-1")),
134 ]),
135 base_version: None,
136 }])
137 .expect("queue commit");
138 }
139
140 let (_, request_meta) = client.build_request(false);
141 assert_eq!(request_meta.pushed_ids.len(), COMMIT_COUNT);
142 let mut frames = vec![Frame::RespHeader {
143 required_schema_version: None,
144 latest_schema_version: None,
145 }];
146 frames.extend(request_meta.pushed_ids.iter().enumerate().map(
147 |(index, client_commit_id)| Frame::PushResult {
148 client_commit_id: client_commit_id.clone(),
149 status: PushStatus::Applied,
150 commit_seq: Some(index as i64 + 1),
151 results: vec![OpResult::Applied { op_index: 0 }],
152 },
153 ));
154 let response = Message {
155 msg_kind: MsgKind::Response,
156 frames,
157 };
158 let mut transport =
159 HostTransport::new_from_config(&json!({})).expect("no-network host transport");
160
161 client.overlay_rebuild_count.set(0);
162 client.outcome_prune_count.set(0);
163 let outcome = client.process_response(&mut transport, response, &request_meta);
164 assert!(matches!(outcome, SyncOutcome::Ok(_)));
165 assert!(client.pending_commit_ids().is_empty());
166 assert_eq!(
167 client.overlay_rebuild_count.get(),
168 1,
169 "one response must reconcile its acknowledged commits with one overlay rebuild"
170 );
171 assert_eq!(
172 client.outcome_prune_count.get(),
173 1,
174 "one response must enforce outcome retention once"
175 );
176 }
177
178 #[test]
179 fn mixed_push_results_reconcile_and_prune_once_per_response() {
180 let mut client = client();
181 for index in 0..4 {
182 client
183 .mutate(vec![Mutation::Upsert {
184 table: "tasks".to_owned(),
185 values: Map::from_iter([
186 ("id".to_owned(), Value::from(format!("task-{index}"))),
187 ("project_id".to_owned(), Value::from("project-1")),
188 ]),
189 base_version: None,
190 }])
191 .expect("queue commit");
192 }
193
194 let (_, request_meta) = client.build_request(false);
195 let ids = &request_meta.pushed_ids;
196 assert_eq!(ids.len(), 4);
197 let response = Message {
198 msg_kind: MsgKind::Response,
199 frames: vec![
200 Frame::RespHeader {
201 required_schema_version: None,
202 latest_schema_version: None,
203 },
204 Frame::PushResult {
205 client_commit_id: ids[0].clone(),
206 status: PushStatus::Applied,
207 commit_seq: Some(1),
208 results: vec![OpResult::Applied { op_index: 0 }],
209 },
210 Frame::PushResult {
211 client_commit_id: ids[1].clone(),
212 status: PushStatus::Cached,
213 commit_seq: Some(2),
214 results: vec![OpResult::Applied { op_index: 0 }],
215 },
216 Frame::PushResult {
217 client_commit_id: ids[2].clone(),
218 status: PushStatus::Rejected,
219 commit_seq: None,
220 results: vec![OpResult::Error {
221 op_index: 0,
222 code: "sync.validation_failed".to_owned(),
223 message: "rejected".to_owned(),
224 retryable: false,
225 }],
226 },
227 Frame::PushResult {
228 client_commit_id: ids[3].clone(),
229 status: PushStatus::Rejected,
230 commit_seq: None,
231 results: vec![OpResult::Error {
232 op_index: 0,
233 code: "sync.idempotency_cache_miss".to_owned(),
234 message: "retry".to_owned(),
235 retryable: true,
236 }],
237 },
238 ],
239 };
240 let mut transport =
241 HostTransport::new_from_config(&json!({})).expect("no-network host transport");
242
243 client.overlay_rebuild_count.set(0);
244 client.outcome_prune_count.set(0);
245 let outcome = client.process_response(&mut transport, response, &request_meta);
246 let SyncOutcome::Ok(report) = outcome else {
247 panic!("mixed push-result response failed");
248 };
249
250 assert_eq!(report.applied, ids[..2]);
251 assert_eq!(report.rejected, ids[2..3]);
252 assert_eq!(report.retryable, ids[3..4]);
253 assert_eq!(client.pending_commit_ids(), ids[3..4]);
254 assert_eq!(
255 client
256 .query("SELECT id FROM tasks ORDER BY id", &[])
257 .expect("query visible overlay"),
258 vec![Map::from_iter([("id".to_owned(), Value::from("task-3"))])]
259 );
260 assert_eq!(client.overlay_rebuild_count.get(), 1);
261 assert_eq!(client.outcome_prune_count.get(), 1);
262 }
263
264 #[test]
265 fn secondary_unique_collision_preserves_existing_synced_row() {
266 let client = SyncClient::new(
267 "unique-upsert-test".to_owned(),
268 &json!({
269 "version": 1,
270 "tables": [{
271 "name": "tasks",
272 "primaryKey": "id",
273 "columns": [
274 { "name": "id", "type": "string", "nullable": false },
275 { "name": "project_id", "type": "string", "nullable": false },
276 { "name": "title", "type": "string", "nullable": false }
277 ],
278 "scopes": [{ "pattern": "project:{project_id}" }],
279 "indexes": [{
280 "name": "tasks_by_project_title",
281 "columns": ["project_id", "title"],
282 "unique": true
283 }]
284 }]
285 }),
286 ClientLimits::default(),
287 )
288 .expect("test client");
289 let table = client.schema.table("tasks").expect("tasks table");
290 let sql = client.insert_row_sql(&base_table("tasks"), table);
291
292 client
293 .conn
294 .execute(&sql, rusqlite::params!["t1", "p1", "original", 1])
295 .expect("insert first row");
296 client
297 .conn
298 .execute(&sql, rusqlite::params!["t1", "p1", "updated", 2])
299 .expect("update same primary key");
300 client
301 .conn
302 .execute(&sql, rusqlite::params!["t2", "p1", "original", 1])
303 .expect("insert second row");
304 assert!(client
305 .conn
306 .execute(&sql, rusqlite::params!["t3", "p1", "original", 2])
307 .is_err());
308
309 let rows = client
310 .conn
311 .prepare("SELECT id, title FROM _syncular_base_tasks ORDER BY id")
312 .expect("prepare rows")
313 .query_map([], |row| {
314 Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
315 })
316 .expect("query rows")
317 .collect::<Result<Vec<_>, _>>()
318 .expect("collect rows");
319 assert_eq!(
320 rows,
321 vec![
322 ("t1".to_owned(), "updated".to_owned()),
323 ("t2".to_owned(), "original".to_owned())
324 ]
325 );
326 }
327
328 #[test]
329 fn reopening_active_subscriptions_emits_a_catch_up_intent() {
330 let path = std::env::temp_dir().join(format!(
331 "syncular-startup-intent-{}.db",
332 uuid::Uuid::new_v4()
333 ));
334 let schema = json!({
335 "version": 1,
336 "tables": [{
337 "name": "tasks",
338 "primaryKey": "id",
339 "columns": [
340 { "name": "id", "type": "string", "nullable": false },
341 { "name": "project_id", "type": "string", "nullable": false }
342 ],
343 "scopes": [{ "pattern": "project:{project_id}" }]
344 }]
345 });
346
347 {
348 let mut first = SyncClient::open_path_with_identity(
349 None,
350 &schema,
351 ClientLimits::default(),
352 path.to_str().expect("UTF-8 temp path"),
353 )
354 .expect("first open");
355 first
356 .set_window(
357 &WindowBase {
358 table: "tasks".to_owned(),
359 variable: "project_id".to_owned(),
360 fixed_scopes: Vec::new(),
361 params: None,
362 },
363 &["persisted".to_owned()],
364 )
365 .expect("persist window");
366 }
367
368 let mut reopened = SyncClient::open_path_with_identity(
369 None,
370 &schema,
371 ClientLimits::default(),
372 path.to_str().expect("UTF-8 temp path"),
373 )
374 .expect("reopen");
375 assert!(reopened.sync_needed());
376 assert!(matches!(
377 reopened.drain_sync_intents().as_slice(),
378 [SyncIntent::Interactive]
379 ));
380 drop(reopened);
381 std::fs::remove_file(path).expect("remove temp database");
382 }
383
384 #[test]
385 fn reopening_preserves_immutable_subscription_identity_and_progress() {
386 let path = std::env::temp_dir().join(format!(
387 "syncular-subscription-identity-{}.db",
388 uuid::Uuid::new_v4()
389 ));
390 let schema = json!({
391 "version": 1,
392 "tables": [
393 {
394 "name": "tasks",
395 "primaryKey": "id",
396 "columns": [
397 { "name": "id", "type": "string", "nullable": false },
398 { "name": "project_id", "type": "string", "nullable": false }
399 ],
400 "scopes": [{ "pattern": "project:{project_id}" }]
401 },
402 {
403 "name": "docs",
404 "primaryKey": "id",
405 "columns": [
406 { "name": "id", "type": "string", "nullable": false },
407 { "name": "org_id", "type": "string", "nullable": false },
408 { "name": "project_id", "type": "string", "nullable": false }
409 ],
410 "scopes": [
411 { "pattern": "org:{org_id}" },
412 { "pattern": "project:{projectId}", "column": "project_id" }
413 ]
414 }
415 ]
416 });
417
418 {
419 let mut first = SyncClient::open_path_with_identity(
420 None,
421 &schema,
422 ClientLimits::default(),
423 path.to_str().expect("UTF-8 temp path"),
424 )
425 .expect("first open");
426 first
427 .subscribe(
428 "stable-subscription".to_owned(),
429 "tasks".to_owned(),
430 vec![(
431 "project_id".to_owned(),
432 vec!["p2".to_owned(), "p1".to_owned()],
433 )],
434 Some(r#"{"view":"v1"}"#.to_owned()),
435 )
436 .expect("persist subscription");
437 let persisted = {
438 let subscription = first
439 .subs
440 .iter_mut()
441 .find(|subscription| subscription.id == "stable-subscription")
442 .expect("subscription");
443 subscription.cursor = 41;
444 subscription.bootstrap_state = Some("resume-token".to_owned());
445 subscription.effective = Some(vec![(
446 "project_id".to_owned(),
447 vec!["p1".to_owned(), "p2".to_owned()],
448 )]);
449 subscription.synced_once = true;
450 subscription.clone()
451 };
452 first.persist_sub(&persisted);
453 }
454
455 let mut reopened = SyncClient::open_path_with_identity(
456 None,
457 &schema,
458 ClientLimits::default(),
459 path.to_str().expect("UTF-8 temp path"),
460 )
461 .expect("reopen");
462 let progress = reopened
463 .subscription_state("stable-subscription")
464 .expect("persisted state");
465 assert_eq!(progress.cursor, 41);
466 assert!(progress.has_resume_token);
467
468 reopened
469 .subscribe(
470 "stable-subscription".to_owned(),
471 "tasks".to_owned(),
472 vec![(
473 "project_id".to_owned(),
474 vec!["p1".to_owned(), "p2".to_owned(), "p1".to_owned()],
475 )],
476 Some(r#"{"view":"v1"}"#.to_owned()),
477 )
478 .expect("canonical intent is idempotent");
479 assert_eq!(
480 reopened
481 .subscription_state("stable-subscription")
482 .expect("unchanged state")
483 .cursor,
484 progress.cursor
485 );
486
487 for (table, scopes, params) in [
488 (
489 "tasks",
490 vec![("project_id".to_owned(), vec!["p1".to_owned()])],
491 Some(r#"{"view":"v1"}"#.to_owned()),
492 ),
493 (
494 "tasks",
495 vec![(
496 "project_id".to_owned(),
497 vec!["p2".to_owned(), "p1".to_owned()],
498 )],
499 Some(r#"{"view":"v2"}"#.to_owned()),
500 ),
501 (
502 "docs",
503 vec![
504 ("org_id".to_owned(), vec!["o1".to_owned()]),
505 ("projectId".to_owned(), vec!["p1".to_owned()]),
506 ],
507 Some(r#"{"view":"v1"}"#.to_owned()),
508 ),
509 ] {
510 let error = reopened
511 .subscribe(
512 "stable-subscription".to_owned(),
513 table.to_owned(),
514 scopes,
515 params,
516 )
517 .expect_err("identity rebind must fail");
518 assert!(error.starts_with("client.subscription_intent_mismatch:"));
519 assert_eq!(
520 reopened
521 .subscription_state("stable-subscription")
522 .expect("unchanged state")
523 .cursor,
524 progress.cursor
525 );
526 }
527
528 drop(reopened);
529 std::fs::remove_file(path).expect("remove temp database");
530 }
531
532 #[test]
533 fn reopening_clears_a_schema_floor_the_running_app_already_satisfies() {
534 let path = std::env::temp_dir().join(format!(
535 "syncular-satisfied-schema-floor-{}.db",
536 uuid::Uuid::new_v4()
537 ));
538 let schema = json!({
539 "version": 23,
540 "tables": [{
541 "name": "tasks",
542 "primaryKey": "id",
543 "columns": [
544 { "name": "id", "type": "string", "nullable": false },
545 { "name": "project_id", "type": "string", "nullable": false }
546 ],
547 "scopes": [{ "pattern": "project:{project_id}" }]
548 }]
549 });
550
551 {
552 let mut first = SyncClient::open_path_with_identity(
553 None,
554 &schema,
555 ClientLimits::default(),
556 path.to_str().expect("UTF-8 temp path"),
557 )
558 .expect("first open");
559 first
560 .subscribe(
561 "tasks".to_owned(),
562 "tasks".to_owned(),
563 vec![("project_id".to_owned(), vec!["p1".to_owned()])],
564 None,
565 )
566 .expect("persist subscription");
567 first.set_schema_floor(Some(SchemaFloor {
568 required_schema_version: Some(22),
569 latest_schema_version: Some(22),
570 }));
571 }
572
573 let mut reopened = SyncClient::open_path_with_identity(
574 None,
575 &schema,
576 ClientLimits::default(),
577 path.to_str().expect("UTF-8 temp path"),
578 )
579 .expect("reopen");
580 assert!(reopened.schema_floor().is_none());
581 assert!(reopened.get_meta(SCHEMA_FLOOR_KEY).is_none());
582 assert!(reopened.sync_needed());
583 assert!(matches!(
584 reopened.drain_sync_intents().as_slice(),
585 [SyncIntent::Interactive]
586 ));
587 drop(reopened);
588 std::fs::remove_file(path).expect("remove temp database");
589 }
590
591 #[test]
592 fn reopening_keeps_an_unsatisfied_schema_floor_stopped() {
593 let path = std::env::temp_dir().join(format!(
594 "syncular-unsatisfied-schema-floor-{}.db",
595 uuid::Uuid::new_v4()
596 ));
597 let schema = json!({
598 "version": 1,
599 "tables": [{
600 "name": "tasks",
601 "primaryKey": "id",
602 "columns": [
603 { "name": "id", "type": "string", "nullable": false },
604 { "name": "project_id", "type": "string", "nullable": false }
605 ],
606 "scopes": [{ "pattern": "project:{project_id}" }]
607 }]
608 });
609
610 {
611 let mut first = SyncClient::open_path_with_identity(
612 None,
613 &schema,
614 ClientLimits::default(),
615 path.to_str().expect("UTF-8 temp path"),
616 )
617 .expect("first open");
618 first
619 .subscribe(
620 "tasks".to_owned(),
621 "tasks".to_owned(),
622 vec![("project_id".to_owned(), vec!["p1".to_owned()])],
623 None,
624 )
625 .expect("persist subscription");
626 first.set_schema_floor(Some(SchemaFloor {
627 required_schema_version: Some(2),
628 latest_schema_version: Some(2),
629 }));
630 }
631
632 let reopened = SyncClient::open_path_with_identity(
633 None,
634 &schema,
635 ClientLimits::default(),
636 path.to_str().expect("UTF-8 temp path"),
637 )
638 .expect("reopen");
639 assert_eq!(
640 reopened.schema_floor(),
641 Some(&SchemaFloor {
642 required_schema_version: Some(2),
643 latest_schema_version: Some(2),
644 })
645 );
646 assert!(!reopened.sync_needed());
647 drop(reopened);
648 std::fs::remove_file(path).expect("remove temp database");
649 }
650
651 #[test]
652 fn schema_bump_precedes_index_ddl_and_prunes_removed_subscriptions() {
653 let path = std::env::temp_dir().join(format!(
654 "syncular-indexed-column-bump-{}.db",
655 uuid::Uuid::new_v4()
656 ));
657 let old_schema = json!({
658 "version": 1,
659 "tables": [
660 {
661 "name": "tasks",
662 "primaryKey": "id",
663 "columns": [
664 { "name": "id", "type": "string", "nullable": false },
665 { "name": "project_id", "type": "string", "nullable": false }
666 ],
667 "scopes": [{ "pattern": "project:{project_id}" }]
668 },
669 {
670 "name": "legacy",
671 "primaryKey": "id",
672 "columns": [
673 { "name": "id", "type": "string", "nullable": false },
674 { "name": "project_id", "type": "string", "nullable": false }
675 ],
676 "scopes": [{ "pattern": "project:{project_id}" }]
677 }
678 ]
679 });
680 {
681 let mut first = SyncClient::open_path_with_identity(
682 None,
683 &old_schema,
684 ClientLimits::default(),
685 path.to_str().expect("UTF-8 temp path"),
686 )
687 .expect("open old schema");
688 first
689 .subscribe(
690 "legacy-sub".to_owned(),
691 "legacy".to_owned(),
692 vec![("project_id".to_owned(), vec!["p1".to_owned()])],
693 None,
694 )
695 .expect("persist legacy subscription");
696 }
697
698 let new_schema = json!({
699 "version": 2,
700 "tables": [{
701 "name": "tasks",
702 "primaryKey": "id",
703 "columns": [
704 { "name": "id", "type": "string", "nullable": false },
705 { "name": "project_id", "type": "string", "nullable": false },
706 { "name": "facility_membership_id", "type": "string", "nullable": true }
707 ],
708 "scopes": [{ "pattern": "project:{project_id}" }],
709 "indexes": [{
710 "name": "tasks_by_membership",
711 "columns": ["project_id", "facility_membership_id"],
712 "unique": false
713 }]
714 }]
715 });
716 let upgraded = SyncClient::open_path_with_identity(
717 None,
718 &new_schema,
719 ClientLimits::default(),
720 path.to_str().expect("UTF-8 temp path"),
721 )
722 .expect("open upgraded schema");
723 let columns = upgraded
724 .conn
725 .prepare("PRAGMA table_info(tasks)")
726 .expect("prepare columns")
727 .query_map([], |row| row.get::<_, String>(1))
728 .expect("query columns")
729 .collect::<Result<Vec<_>, _>>()
730 .expect("collect columns");
731 assert!(columns.contains(&"facility_membership_id".to_owned()));
732 let index_count: i64 = upgraded
733 .conn
734 .query_row(
735 "SELECT COUNT(*) FROM sqlite_master WHERE type = 'index' AND name = 'tasks_by_membership'",
736 [],
737 |row| row.get(0),
738 )
739 .expect("query index");
740 assert_eq!(index_count, 1);
741 assert!(upgraded.subscription_state("legacy-sub").is_none());
742 drop(upgraded);
743 std::fs::remove_file(path).expect("remove temp database");
744 }
745
746 #[test]
747 fn migrates_pre_envelope_outcome_journal_additively() {
748 let path = std::env::temp_dir().join(format!(
749 "syncular-outcome-migration-{}.db",
750 uuid::Uuid::new_v4()
751 ));
752 let conn = Connection::open(&path).expect("open legacy database");
753 conn.execute_batch(
754 "CREATE TABLE _syncular_commit_outcomes (
755 seq INTEGER PRIMARY KEY AUTOINCREMENT,
756 client_commit_id TEXT NOT NULL UNIQUE,
757 status TEXT NOT NULL,
758 recorded_at_ms INTEGER NOT NULL,
759 results_json TEXT NOT NULL,
760 resolution TEXT NOT NULL DEFAULT 'active',
761 resolved_at_ms INTEGER,
762 replacement_client_commit_id TEXT);",
763 )
764 .expect("create legacy outcome journal");
765 drop(conn);
766 let schema = json!({
767 "version": 1,
768 "tables": [{
769 "name": "tasks",
770 "primaryKey": "id",
771 "columns": [
772 { "name": "id", "type": "string", "nullable": false },
773 { "name": "project_id", "type": "string", "nullable": false }
774 ],
775 "scopes": [{ "pattern": "project:{project_id}" }]
776 }]
777 });
778 let client = SyncClient::open_path(
779 "migration-native".to_owned(),
780 &schema,
781 ClientLimits::default(),
782 path.to_str().expect("UTF-8 temp path"),
783 )
784 .expect("migrate database");
785 let has_operations = client
786 .conn
787 .prepare("PRAGMA table_info(_syncular_commit_outcomes)")
788 .expect("prepare table info")
789 .query_map([], |row| row.get::<_, String>(1))
790 .expect("query table info")
791 .filter_map(Result::ok)
792 .any(|column| column == "operations_json");
793 assert!(has_operations);
794 drop(client);
795 std::fs::remove_file(path).expect("remove temp database");
796 }
797
798 #[test]
799 fn durable_conflict_outcome_and_resolution_survive_reopen() {
800 let path = std::env::temp_dir().join(format!(
801 "syncular-durable-outcome-{}.db",
802 uuid::Uuid::new_v4()
803 ));
804 let schema = json!({
805 "version": 1,
806 "tables": [{
807 "name": "tasks",
808 "primaryKey": "id",
809 "columns": [
810 { "name": "id", "type": "string", "nullable": false },
811 { "name": "project_id", "type": "string", "nullable": false }
812 ],
813 "scopes": [{ "pattern": "project:{project_id}" }]
814 }]
815 });
816 let conflict = ConflictRecord {
817 client_commit_id: "losing-commit".to_owned(),
818 op_index: 0,
819 table: "tasks".to_owned(),
820 row_id: "t1".to_owned(),
821 code: "sync.version_conflict".to_owned(),
822 message: "stale base version".to_owned(),
823 server_version: 2,
824 server_row: Map::from_iter([("id".to_owned(), json!("t1"))]),
825 operation: Some(CommitOperation {
826 table: "tasks".to_owned(),
827 row_id: "t1".to_owned(),
828 op: "upsert".to_owned(),
829 base_version: Some(1),
830 values: None,
831 changed_fields: None,
832 }),
833 };
834 let failed_operations = vec![
835 OutboxOp {
836 upsert: true,
837 table: "tasks".to_owned(),
838 row_id: "t1".to_owned(),
839 base_version: Some(1),
840 values: None,
841 changed_fields: None,
842 },
843 OutboxOp {
844 upsert: true,
845 table: "tasks".to_owned(),
846 row_id: "status-event-1".to_owned(),
847 base_version: Some(0),
848 values: None,
849 changed_fields: None,
850 },
851 ];
852
853 {
854 let mut first = SyncClient::open_path(
855 "durable-native".to_owned(),
856 &schema,
857 ClientLimits::default(),
858 path.to_str().expect("UTF-8 temp path"),
859 )
860 .expect("first open");
861 first
862 .begin_observation("test_outcome")
863 .expect("begin outcome");
864 first
865 .persist_commit_outcome(
866 "losing-commit",
867 CommitOutcomeStatus::Conflict,
868 &[CommitOperationOutcome::Conflict {
869 conflict: conflict.clone(),
870 }],
871 Some(&failed_operations),
872 )
873 .expect("persist outcome");
874 first.conflicts.push(conflict);
875 first
876 .finish_observation(
877 "test_outcome",
878 ChangeAccumulator {
879 conflicts: true,
880 outcomes: true,
881 ..ChangeAccumulator::default()
882 },
883 )
884 .expect("commit outcome");
885 }
886
887 {
888 let mut reopened = SyncClient::open_path(
889 "durable-native".to_owned(),
890 &schema,
891 ClientLimits::default(),
892 path.to_str().expect("UTF-8 temp path"),
893 )
894 .expect("reopen");
895 assert_eq!(reopened.conflicts().len(), 1);
896 let outcome = reopened
897 .commit_outcome("losing-commit")
898 .expect("read outcome")
899 .expect("outcome");
900 assert_eq!(outcome.status, CommitOutcomeStatus::Conflict);
901 let operations = outcome.operations.expect("aggregate envelope");
902 assert_eq!(operations.len(), 2);
903 assert_eq!(operations[1].row_id, "status-event-1");
904 let resolved = reopened
905 .resolve_commit_outcome(ResolveCommitOutcomeInput {
906 client_commit_id: "losing-commit".to_owned(),
907 resolution: CommitOutcomeResolution::ResolvedKeepServer,
908 replacement_client_commit_id: None,
909 })
910 .expect("resolve");
911 assert_eq!(
912 resolved.resolution,
913 CommitOutcomeResolution::ResolvedKeepServer
914 );
915 assert!(reopened.conflicts().is_empty());
916 }
917
918 let reopened = SyncClient::open_path(
919 "durable-native".to_owned(),
920 &schema,
921 ClientLimits::default(),
922 path.to_str().expect("UTF-8 temp path"),
923 )
924 .expect("second reopen");
925 assert!(reopened.conflicts().is_empty());
926 assert_eq!(
927 reopened
928 .commit_outcome("losing-commit")
929 .expect("read outcome")
930 .expect("outcome")
931 .resolution,
932 CommitOutcomeResolution::ResolvedKeepServer
933 );
934 drop(reopened);
935 std::fs::remove_file(path).expect("remove temp database");
936 }
937
938 #[test]
939 fn file_snapshot_reader_matches_owner_rows_revision_and_coverage() {
940 let path =
941 std::env::temp_dir().join(format!("syncular-read-sidecar-{}.db", uuid::Uuid::new_v4()));
942 let schema = json!({
943 "version": 1,
944 "tables": [{
945 "name": "tasks",
946 "primaryKey": "id",
947 "columns": [
948 { "name": "id", "type": "string", "nullable": false },
949 { "name": "project_id", "type": "string", "nullable": false }
950 ],
951 "scopes": [{ "pattern": "project:{project_id}" }]
952 }]
953 });
954 let mut client = SyncClient::open_path_with_identity(
955 Some("sidecar-client".to_owned()),
956 &schema,
957 ClientLimits::default(),
958 path.to_str().expect("UTF-8 temp path"),
959 )
960 .expect("open owner");
961 let base = WindowBase {
962 table: "tasks".to_owned(),
963 variable: "project_id".to_owned(),
964 fixed_scopes: Vec::new(),
965 params: None,
966 };
967 client
968 .set_window(&base, &["one".to_owned()])
969 .expect("set window");
970 client
971 .mutate(vec![Mutation::Upsert {
972 table: "tasks".to_owned(),
973 values: Map::from_iter([
974 ("id".to_owned(), Value::from("t1")),
975 ("project_id".to_owned(), Value::from("one")),
976 ]),
977 base_version: None,
978 }])
979 .expect("local mutate");
980
981 let coverage = [WindowCoverage {
982 base,
983 units: vec!["one".to_owned(), "missing".to_owned()],
984 }];
985 let owner = client
986 .query_snapshot(
987 "SELECT id, project_id, _sync_version AS server_version FROM tasks ORDER BY id",
988 &[],
989 &coverage,
990 )
991 .expect("owner snapshot");
992 let mut reader = FileQuerySnapshotReader::new(path.to_string_lossy());
993 let sidecar = reader
994 .query_snapshot(
995 "SELECT id, project_id, _sync_version AS server_version FROM tasks ORDER BY id",
996 &[],
997 &coverage,
998 )
999 .expect("sidecar snapshot");
1000
1001 assert_eq!(sidecar.revision, owner.revision);
1002 assert_eq!(sidecar.rows, owner.rows);
1003 assert_eq!(
1004 serde_json::to_value(&sidecar.coverage).expect("serialize sidecar coverage"),
1005 serde_json::to_value(&owner.coverage).expect("serialize owner coverage")
1006 );
1007 assert_eq!(sidecar.revision, "2");
1008 assert_eq!(sidecar.rows[0]["id"], "t1");
1009 assert_eq!(sidecar.rows[0]["server_version"], -1);
1010 assert!(!sidecar.coverage.complete);
1011 assert_eq!(sidecar.coverage.pending.len(), 1);
1012 assert_eq!(sidecar.coverage.missing.len(), 1);
1013
1014 drop(reader);
1015 drop(client);
1016 std::fs::remove_file(path).expect("remove temp database");
1017 }
1018
1019 #[test]
1020 fn local_fts_projection_tracks_optimistic_overlay_rebuilds() {
1021 let schema = json!({
1022 "version": 1,
1023 "tables": [{
1024 "name": "catalogue_codes",
1025 "primaryKey": "id",
1026 "columns": [
1027 { "name": "id", "type": "string", "nullable": false },
1028 { "name": "release_id", "type": "string", "nullable": false },
1029 { "name": "code", "type": "string", "nullable": false },
1030 { "name": "title", "type": "string", "nullable": false }
1031 ],
1032 "scopes": [{ "pattern": "release:{release_id}" }],
1033 "ftsIndexes": [{
1034 "name": "catalogue_codes_fts",
1035 "columns": ["code", "title"],
1036 "tokenize": "unicode61 remove_diacritics 2"
1037 }]
1038 }]
1039 });
1040 let mut client = SyncClient::new("fts-test".to_owned(), &schema, ClientLimits::default())
1041 .expect("FTS5 client");
1042 let search = |client: &SyncClient, query: &str| {
1043 client
1044 .query(
1045 "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",
1046 &[Value::from(query)],
1047 )
1048 .expect("FTS query")
1049 };
1050
1051 client
1052 .mutate(vec![Mutation::Upsert {
1053 table: "catalogue_codes".to_owned(),
1054 values: Map::from_iter([
1055 ("id".to_owned(), Value::from("c1")),
1056 ("release_id".to_owned(), Value::from("r1")),
1057 ("code".to_owned(), Value::from("A01")),
1058 ("title".to_owned(), Value::from("Cholera")),
1059 ]),
1060 base_version: None,
1061 }])
1062 .expect("insert code");
1063 assert_eq!(search(&client, "cholera").len(), 1);
1064
1065 client
1066 .mutate(vec![Mutation::Upsert {
1067 table: "catalogue_codes".to_owned(),
1068 values: Map::from_iter([
1069 ("id".to_owned(), Value::from("c1")),
1070 ("release_id".to_owned(), Value::from("r1")),
1071 ("code".to_owned(), Value::from("A01")),
1072 ("title".to_owned(), Value::from("Enteric infection")),
1073 ]),
1074 base_version: None,
1075 }])
1076 .expect("update code");
1077 assert!(search(&client, "cholera").is_empty());
1078 assert_eq!(search(&client, "enteric").len(), 1);
1079
1080 client
1081 .mutate(vec![Mutation::Delete {
1082 table: "catalogue_codes".to_owned(),
1083 row_id: "c1".to_owned(),
1084 base_version: None,
1085 }])
1086 .expect("delete code");
1087 assert!(search(&client, "enteric").is_empty());
1088 }
1089
1090 #[test]
1091 fn application_authorized_local_purge_is_exact_atomic_and_idempotent() {
1092 let schema = json!({
1093 "version": 1,
1094 "tables": [{
1095 "name": "patient_notes",
1096 "primaryKey": "id",
1097 "columns": [
1098 { "name": "id", "type": "string", "nullable": false },
1099 { "name": "practice_id", "type": "string", "nullable": false },
1100 { "name": "encryption_key_id", "type": "string", "nullable": false },
1101 { "name": "title", "type": "string", "nullable": false }
1102 ],
1103 "scopes": [{ "pattern": "practice:{practice_id}" }],
1104 "ftsIndexes": [{
1105 "name": "patient_notes_fts",
1106 "columns": ["title"],
1107 "tokenize": "unicode61 remove_diacritics 2"
1108 }]
1109 }]
1110 });
1111 let mut client = SyncClient::new(
1112 "local-purge-test".to_owned(),
1113 &schema,
1114 ClientLimits::default(),
1115 )
1116 .expect("local purge client");
1117 client
1118 .conn
1119 .execute(
1120 "INSERT INTO _syncular_base_patient_notes(id, practice_id, encryption_key_id, title, _syncular_version) VALUES
1121 ('target', 'practice-1', 'key-revoked', 'Target original', 1),
1122 ('unrelated', 'practice-1', 'key-held', 'Unrelated original', 1)",
1123 [],
1124 )
1125 .expect("seed base rows");
1126 client.overlay_dirty.set(true);
1127 client.rebuild_overlay_if_dirty();
1128
1129 let note = |id: &str, key_id: &str, title: &str| {
1130 Map::from_iter([
1131 ("id".to_owned(), Value::from(id)),
1132 ("practice_id".to_owned(), Value::from("practice-1")),
1133 ("encryption_key_id".to_owned(), Value::from(key_id)),
1134 ("title".to_owned(), Value::from(title)),
1135 ])
1136 };
1137 let doomed = client
1138 .mutate(vec![
1139 Mutation::Upsert {
1140 table: "patient_notes".to_owned(),
1141 values: note("target", "key-revoked", "Target changed"),
1142 base_version: None,
1143 },
1144 Mutation::Upsert {
1145 table: "patient_notes".to_owned(),
1146 values: note("unrelated", "key-held", "Unrelated changed"),
1147 base_version: None,
1148 },
1149 ])
1150 .expect("doomed commit");
1151 let kept = client
1152 .mutate(vec![Mutation::Upsert {
1153 table: "patient_notes".to_owned(),
1154 values: note("kept", "key-held", "Kept optimistic"),
1155 base_version: None,
1156 }])
1157 .expect("kept commit");
1158 client.drain_change_batches();
1159
1160 let input = LocalDataPurgeInput {
1161 purge_id: "purge-001".to_owned(),
1162 targets: vec![LocalDataPurgeTarget {
1163 table: "patient_notes".to_owned(),
1164 selectors: BTreeMap::from([(
1165 "encryption_key_id".to_owned(),
1166 vec!["key-revoked".to_owned()],
1167 )]),
1168 }],
1169 };
1170 assert_eq!(
1171 client.purge_local_data(&input).expect("apply purge"),
1172 LocalDataPurgeResult {
1173 already_applied: false,
1174 purged_rows: 1,
1175 dropped_commits: 1,
1176 }
1177 );
1178 let rows = client
1179 .query("SELECT id, title FROM patient_notes ORDER BY id", &[])
1180 .expect("visible rows");
1181 assert_eq!(rows.len(), 2);
1182 assert_eq!(rows[0]["id"], "kept");
1183 assert_eq!(rows[1]["id"], "unrelated");
1184 assert_eq!(rows[1]["title"], "Unrelated original");
1185 let fts = client
1186 .query(
1187 "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'",
1188 &[],
1189 )
1190 .expect("fts query");
1191 assert!(fts.is_empty());
1192 assert_eq!(client.outbox.len(), 1);
1193 assert_eq!(client.outbox[0].client_commit_id, kept);
1194 let outcome = client
1195 .commit_outcome(&doomed)
1196 .expect("read doomed outcome")
1197 .expect("doomed outcome");
1198 assert_eq!(outcome.status, CommitOutcomeStatus::Rejected);
1199 match &outcome.results[0] {
1200 CommitOperationOutcome::Error { rejection } => {
1201 assert_eq!(rejection.code, "client.local_data_purged");
1202 assert_eq!(rejection.client_commit_id, doomed);
1203 }
1204 other => panic!("expected local purge rejection, got {other:?}"),
1205 }
1206 assert_eq!(client.drain_change_batches().len(), 1);
1207 assert_eq!(
1208 client.purge_local_data(&input).expect("retry purge"),
1209 LocalDataPurgeResult {
1210 already_applied: true,
1211 purged_rows: 0,
1212 dropped_commits: 0,
1213 }
1214 );
1215 let conflicting = LocalDataPurgeInput {
1216 purge_id: input.purge_id.clone(),
1217 targets: vec![LocalDataPurgeTarget {
1218 table: "patient_notes".to_owned(),
1219 selectors: BTreeMap::from([(
1220 "encryption_key_id".to_owned(),
1221 vec!["key-held".to_owned()],
1222 )]),
1223 }],
1224 };
1225 assert!(client
1226 .purge_local_data(&conflicting)
1227 .expect_err("id collision must fail")
1228 .contains("already used with a different plan"));
1229 }
1230}
1231
1232impl SubState {
1233 fn name(self) -> &'static str {
1234 match self {
1235 SubState::Active => "active",
1236 SubState::Revoked => "revoked",
1237 SubState::Failed => "failed",
1238 }
1239 }
1240
1241 fn parse(value: &str) -> Self {
1242 match value {
1243 "revoked" => Self::Revoked,
1244 "failed" => Self::Failed,
1245 _ => Self::Active,
1246 }
1247 }
1248}
1249
1250#[derive(Debug, Clone)]
1251struct Subscription {
1252 id: String,
1253 table: String,
1254 requested: Vec<(String, Vec<String>)>,
1255 params: Option<String>,
1256 cursor: i64,
1257 bootstrap_state: Option<String>,
1259 state: SubState,
1260 reason_code: Option<String>,
1261 effective: Option<Vec<(String, Vec<String>)>>,
1264 synced_once: bool,
1265}
1266
1267#[derive(Debug, Clone)]
1268struct OutboxOp {
1269 upsert: bool,
1270 table: String,
1271 row_id: String,
1272 base_version: Option<i64>,
1273 values: Option<Map<String, Value>>,
1276 changed_fields: Option<Vec<String>>,
1278}
1279
1280impl From<&OutboxOp> for CommitOperation {
1281 fn from(operation: &OutboxOp) -> Self {
1282 Self {
1283 table: operation.table.clone(),
1284 row_id: operation.row_id.clone(),
1285 op: if operation.upsert { "upsert" } else { "delete" }.to_owned(),
1286 base_version: operation.base_version,
1287 values: operation.values.clone(),
1288 changed_fields: operation.changed_fields.clone(),
1289 }
1290 }
1291}
1292
1293#[derive(Debug, Clone)]
1294struct OutboxCommit {
1295 client_commit_id: String,
1296 ops: Vec<OutboxOp>,
1297}
1298
1299#[derive(Debug, Clone)]
1300struct CompiledLocalDataPurgeTarget {
1301 table: String,
1302 selectors: Vec<(String, Vec<String>)>,
1303}
1304
1305struct StoredCommitOutcomeRow {
1306 sequence: i64,
1307 client_commit_id: String,
1308 status: String,
1309 recorded_at_ms: i64,
1310 results_json: String,
1311 operations_json: Option<String>,
1312 resolution: String,
1313 resolved_at_ms: Option<i64>,
1314 replacement_client_commit_id: Option<String>,
1315}
1316
1317enum SectionError {
1320 FailClosed,
1321 Abort(String, String),
1322}
1323
1324struct RequestMeta {
1325 pushed_ids: Vec<String>,
1326 fresh: Vec<(String, bool)>,
1329 accept: u8,
1330 deferred_commits: usize,
1334}
1335
1336#[derive(Default)]
1337struct ChangeAccumulator {
1338 tables: BTreeMap<String, Option<BTreeSet<String>>>,
1340 windows: BTreeMap<(String, String), BTreeSet<String>>,
1341 status: bool,
1342 conflicts: bool,
1343 rejections: bool,
1344 outcomes: bool,
1345}
1346
1347impl ChangeAccumulator {
1348 fn table(&mut self, table: &str) {
1349 self.tables.insert(table.to_owned(), None);
1350 }
1351
1352 fn scope(&mut self, table: &str, key: String) {
1353 match self.tables.get_mut(table) {
1354 Some(None) => {}
1355 Some(Some(keys)) => {
1356 keys.insert(key);
1357 }
1358 None => {
1359 self.tables
1360 .insert(table.to_owned(), Some(BTreeSet::from([key])));
1361 }
1362 }
1363 }
1364
1365 fn window(&mut self, base_key: &str, table: &str, unit: &str) {
1366 self.windows
1367 .entry((base_key.to_owned(), table.to_owned()))
1368 .or_default()
1369 .insert(unit.to_owned());
1370 }
1371
1372 fn touched(&self) -> bool {
1373 !self.tables.is_empty()
1374 || !self.windows.is_empty()
1375 || self.status
1376 || self.conflicts
1377 || self.rejections
1378 || self.outcomes
1379 }
1380}
1381
1382const PUSH_OPS_PER_REQUEST: usize = 500;
1388const MAX_LOCAL_PURGE_TARGETS: usize = 64;
1389const MAX_LOCAL_PURGE_SELECTORS: usize = 8;
1390const MAX_LOCAL_PURGE_VALUES: usize = 128;
1391const MAX_LOCAL_PURGE_VALUE_LENGTH: usize = 256;
1392pub const SECURITY_PREFLIGHT_REQUIRED_CODE: &str = "client.security_preflight_required";
1393
1394pub struct SyncClient {
1395 conn: Connection,
1396 schema: ClientSchema,
1397 client_id: String,
1398 limits: ClientLimits,
1399 subs: Vec<Subscription>,
1400 outbox: Vec<OutboxCommit>,
1401 conflicts: Vec<ConflictRecord>,
1402 rejections: Vec<RejectionRecord>,
1403 schema_floor: Option<SchemaFloor>,
1404 lease_state: Option<LeaseState>,
1406 stopped: bool,
1408 upgrading: bool,
1410 sync_needed: bool,
1412 realtime_connected: bool,
1413 presence: HashMap<String, HashMap<String, PresencePeer>>,
1415 now_ms: Option<i64>,
1418 encryption: crate::values::EncryptionConfig,
1422 security_preflight: bool,
1425 insert_sql: RefCell<HashMap<String, String>>,
1431 overlay_dirty: Cell<bool>,
1435 #[cfg(test)]
1438 overlay_rebuild_count: Cell<usize>,
1439 #[cfg(test)]
1442 outcome_prune_count: Cell<usize>,
1443 change_queue: VecDeque<ClientChangeBatch>,
1445 sync_intent_queue: VecDeque<SyncIntent>,
1446 retry_delay_ms: u64,
1448 last_round: Option<DiagnosticLastRound>,
1449 last_change: Option<DiagnosticLastChange>,
1450}
1451
1452fn quote_ident(name: &str) -> String {
1453 format!("\"{}\"", name.replace('"', "\"\""))
1454}
1455
1456fn is_local_purge_code_like(value: &str) -> bool {
1457 let bytes = value.as_bytes();
1458 bytes.first().is_some_and(u8::is_ascii_alphanumeric)
1459 && bytes
1460 .iter()
1461 .all(|byte| byte.is_ascii_alphanumeric() || matches!(*byte, b'.' | b'_' | b':' | b'-'))
1462}
1463
1464fn base_table(name: &str) -> String {
1465 quote_ident(&format!("_syncular_base_{name}"))
1466}
1467
1468type PendingEvict = (String, String, Vec<(String, Vec<String>)>);
1473
1474fn window_base_key(base: &WindowBase) -> String {
1475 format!(
1476 "{}\0{}\0{}",
1477 base.table,
1478 base.variable,
1479 canonical_scope_json(&base.fixed_scopes)
1480 )
1481}
1482
1483fn unit_scopes(base: &WindowBase, unit: &str) -> Vec<(String, Vec<String>)> {
1485 let mut scopes = base.fixed_scopes.clone();
1486 scopes.retain(|(k, _)| k != &base.variable);
1487 scopes.push((base.variable.clone(), vec![unit.to_owned()]));
1488 scopes
1489}
1490
1491fn derive_sub_id(base: &WindowBase, unit: &str) -> String {
1495 let canonical = canonical_scope_json(&unit_scopes(base, unit));
1496 let digest = Sha256::digest(canonical.as_bytes());
1497 let hex = bytes_to_hex(&digest);
1498 format!("w:{}:{}", base.table, &hex[..16])
1499}
1500
1501fn visible_table(name: &str) -> String {
1502 quote_ident(name)
1503}
1504
1505const FTS_SOURCE_ID_COLUMN: &str = "_syncular_source_id";
1506
1507fn is_synced_table_name(name: &str) -> bool {
1510 if name.starts_with("sqlite_") {
1511 return false;
1512 }
1513 if name.starts_with("_syncular_base_") {
1514 return true; }
1516 !name.starts_with("_syncular_")
1518}
1519
1520fn blob_id_for(bytes: &[u8]) -> String {
1522 let digest = Sha256::digest(bytes);
1523 format!("sha256:{}", bytes_to_hex(&digest))
1524}
1525
1526enum RowParam<'a> {
1530 Cell(&'a Option<ColumnValue>),
1531 Version(i64),
1532}
1533
1534impl rusqlite::ToSql for RowParam<'_> {
1535 fn to_sql(&self) -> rusqlite::Result<ToSqlOutput<'_>> {
1536 Ok(match self {
1537 RowParam::Version(v) => ToSqlOutput::Owned(SqlValue::Integer(*v)),
1538 RowParam::Cell(cell) => match cell {
1539 None => ToSqlOutput::Owned(SqlValue::Null),
1540 Some(ColumnValue::String(s)) => ToSqlOutput::Borrowed(ValueRef::Text(s.as_bytes())),
1541 Some(ColumnValue::Integer(i)) => ToSqlOutput::Owned(SqlValue::Integer(*i)),
1542 Some(ColumnValue::Float(f)) => ToSqlOutput::Owned(SqlValue::Real(*f)),
1543 Some(ColumnValue::Boolean(b)) => {
1544 ToSqlOutput::Owned(SqlValue::Integer(i64::from(*b)))
1545 }
1546 Some(ColumnValue::Json(raw)) | Some(ColumnValue::BlobRef(raw)) => {
1547 ToSqlOutput::Borrowed(ValueRef::Text(raw.0.as_bytes()))
1548 }
1549 Some(ColumnValue::Bytes(b)) | Some(ColumnValue::Crdt(b)) => {
1551 ToSqlOutput::Borrowed(ValueRef::Blob(b))
1552 }
1553 },
1554 })
1555 }
1556}
1557
1558fn image_cell_param<'a>(column: &Column, value: ValueRef<'a>) -> Result<ToSqlOutput<'a>, String> {
1566 use ssp2::segment::ColumnType;
1567 let mismatch = || {
1568 Err(format!(
1569 "image column {:?} holds a value of the wrong type",
1570 column.name
1571 ))
1572 };
1573 match value {
1574 ValueRef::Null => {
1575 if !column.nullable {
1576 return Err(format!(
1577 "image column {:?} is NULL but not nullable",
1578 column.name
1579 ));
1580 }
1581 Ok(ToSqlOutput::Owned(SqlValue::Null))
1582 }
1583 ValueRef::Integer(i) => match column.ty {
1584 ColumnType::Integer => Ok(ToSqlOutput::Borrowed(value)),
1585 ColumnType::Boolean => Ok(ToSqlOutput::Owned(SqlValue::Integer(i64::from(i != 0)))),
1586 ColumnType::Float => Ok(ToSqlOutput::Owned(SqlValue::Real(i as f64))),
1587 _ => mismatch(),
1588 },
1589 ValueRef::Real(_) => match column.ty {
1590 ColumnType::Float => Ok(ToSqlOutput::Borrowed(value)),
1591 _ => mismatch(),
1592 },
1593 ValueRef::Text(t) => {
1594 std::str::from_utf8(t)
1595 .map_err(|_| format!("image column {:?} is not UTF-8", column.name))?;
1596 match column.ty {
1597 ColumnType::String | ColumnType::Json | ColumnType::BlobRef => {
1598 Ok(ToSqlOutput::Borrowed(value))
1599 }
1600 _ => mismatch(),
1601 }
1602 }
1603 ValueRef::Blob(_) => match column.ty {
1604 ColumnType::Bytes | ColumnType::Crdt => Ok(ToSqlOutput::Borrowed(value)),
1606 _ => mismatch(),
1607 },
1608 }
1609}
1610
1611fn sql_ref_to_json(column: &Column, value: rusqlite::types::ValueRef<'_>) -> Value {
1612 use rusqlite::types::ValueRef;
1613 match value {
1614 ValueRef::Null => Value::Null,
1615 ValueRef::Integer(i) => match column.ty {
1616 ssp2::segment::ColumnType::Boolean => Value::Bool(i != 0),
1617 ssp2::segment::ColumnType::Float => {
1618 serde_json::Number::from_f64(i as f64).map_or(Value::Null, Value::Number)
1619 }
1620 _ => Value::from(i),
1621 },
1622 ValueRef::Real(f) => serde_json::Number::from_f64(f).map_or(Value::Null, Value::Number),
1623 ValueRef::Text(t) => Value::from(String::from_utf8_lossy(t).into_owned()),
1624 ValueRef::Blob(b) => {
1625 let mut map = Map::new();
1626 map.insert("$bytes".to_owned(), Value::from(bytes_to_hex(b)));
1627 Value::Object(map)
1628 }
1629 }
1630}
1631
1632fn json_param_to_sql(value: &Value) -> Result<SqlValue, String> {
1636 Ok(match value {
1637 Value::Null => SqlValue::Null,
1638 Value::Bool(b) => SqlValue::Integer(i64::from(*b)),
1639 Value::Number(n) => {
1640 if let Some(i) = n.as_i64() {
1641 SqlValue::Integer(i)
1642 } else if let Some(f) = n.as_f64() {
1643 SqlValue::Real(f)
1644 } else {
1645 return Err(format!("query param number {n} is out of range"));
1646 }
1647 }
1648 Value::String(s) => SqlValue::Text(s.clone()),
1649 Value::Object(_) => {
1650 if let Some(hex) = value.get("$bytes").and_then(Value::as_str) {
1651 SqlValue::Blob(crate::values::hex_to_bytes(hex)?)
1652 } else if let Some(decimal) = value.get("$bigint").and_then(Value::as_str) {
1653 SqlValue::Integer(decimal.parse::<i64>().map_err(|_| {
1654 format!("query bigint param {decimal:?} is outside SQLite's i64 range")
1655 })?)
1656 } else {
1657 return Err(
1658 "query object param must be a {$bytes: hex} or {$bigint: decimal} value"
1659 .to_owned(),
1660 );
1661 }
1662 }
1663 Value::Array(_) => return Err("query array params are not supported".to_owned()),
1664 })
1665}
1666
1667fn sql_ref_to_json_dynamic(value: rusqlite::types::ValueRef<'_>) -> Value {
1672 use rusqlite::types::ValueRef;
1673 match value {
1674 ValueRef::Null => Value::Null,
1675 ValueRef::Integer(i) => {
1676 const MAX_SAFE_INTEGER: i64 = 9_007_199_254_740_991;
1680 if (-MAX_SAFE_INTEGER..=MAX_SAFE_INTEGER).contains(&i) {
1681 Value::from(i)
1682 } else {
1683 let mut map = Map::new();
1684 map.insert("$bigint".to_owned(), Value::from(i.to_string()));
1685 Value::Object(map)
1686 }
1687 }
1688 ValueRef::Real(f) => serde_json::Number::from_f64(f).map_or(Value::Null, Value::Number),
1689 ValueRef::Text(t) => Value::from(String::from_utf8_lossy(t).into_owned()),
1690 ValueRef::Blob(b) => {
1691 let mut map = Map::new();
1692 map.insert("$bytes".to_owned(), Value::from(bytes_to_hex(b)));
1693 Value::Object(map)
1694 }
1695 }
1696}
1697
1698fn query_connection(
1699 conn: &Connection,
1700 sql: &str,
1701 params: &[QueryValue],
1702) -> Result<Vec<QueryRow>, String> {
1703 crate::query_guard::assert_read_only_query(sql)?;
1704 let lowered_sql = crate::query_guard::lower_public_query_sql(sql);
1705 let bound: Vec<SqlValue> = params
1706 .iter()
1707 .map(json_param_to_sql)
1708 .collect::<Result<_, _>>()?;
1709 let mut stmt = conn.prepare(&lowered_sql).map_err(|e| e.to_string())?;
1710 let column_names: Vec<String> = stmt.column_names().into_iter().map(str::to_owned).collect();
1711 let bound_refs: Vec<&dyn rusqlite::ToSql> =
1712 bound.iter().map(|v| v as &dyn rusqlite::ToSql).collect();
1713 let mut sql_rows = stmt
1714 .query(bound_refs.as_slice())
1715 .map_err(|e| e.to_string())?;
1716 let mut out = Vec::new();
1717 while let Some(row) = sql_rows.next().map_err(|e| e.to_string())? {
1718 let mut record = Map::new();
1719 for (i, name) in column_names.iter().enumerate() {
1720 let value = row.get_ref(i).map_err(|e| e.to_string())?;
1721 record.insert(name.clone(), sql_ref_to_json_dynamic(value));
1722 }
1723 out.push(record);
1724 }
1725 Ok(out)
1726}
1727
1728fn persisted_window_state(conn: &Connection, base: &WindowBase) -> Result<WindowState, String> {
1729 let mut stmt = conn
1730 .prepare(
1731 "SELECT windows.unit, subscriptions.state_json
1732 FROM _syncular_windows AS windows
1733 JOIN _syncular_subscriptions AS subscriptions
1734 ON subscriptions.id = windows.sub_id
1735 WHERE windows.base = ?1
1736 ORDER BY windows.unit ASC",
1737 )
1738 .map_err(|error| error.to_string())?;
1739 let rows = stmt
1740 .query_map(rusqlite::params![window_base_key(base)], |row| {
1741 Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
1742 })
1743 .map_err(|error| error.to_string())?;
1744 let mut units = Vec::new();
1745 let mut pending = Vec::new();
1746 for row in rows {
1747 let (unit, raw) = row.map_err(|error| error.to_string())?;
1748 let state: Value = serde_json::from_str(&raw)
1749 .map_err(|error| format!("invalid persisted window subscription: {error}"))?;
1750 let is_pending = state.get("status").and_then(Value::as_str) != Some("active")
1751 || state.get("cursor").and_then(Value::as_i64).unwrap_or(-1) < 0
1752 || state
1753 .get("bootstrapState")
1754 .is_some_and(|value| !value.is_null());
1755 if is_pending {
1756 pending.push(unit.clone());
1757 }
1758 units.push(unit);
1759 }
1760 Ok(WindowState { units, pending })
1761}
1762
1763fn snapshot_connection(
1764 conn: &Connection,
1765 sql: &str,
1766 params: &[Value],
1767 coverage: &[WindowCoverage],
1768) -> Result<QuerySnapshot, String> {
1769 conn.execute_batch("SAVEPOINT syncular_snapshot_read")
1770 .map_err(|error| error.to_string())?;
1771 let result = (|| {
1772 let revision = conn
1773 .query_row(
1774 "SELECT value FROM _syncular_meta WHERE key = ?1",
1775 rusqlite::params![LOCAL_REVISION_KEY],
1776 |row| row.get::<_, String>(0),
1777 )
1778 .ok()
1779 .and_then(|value| value.parse::<u64>().ok())
1780 .unwrap_or(0);
1781 let rows = query_connection(conn, sql, params)?;
1782 let mut pending = Vec::new();
1783 let mut missing = Vec::new();
1784 for requested in coverage {
1785 let base_key = window_base_key(&requested.base);
1786 let state = persisted_window_state(conn, &requested.base)?;
1787 for unit in BTreeSet::from_iter(requested.units.iter().cloned()) {
1788 let reference = WindowUnitRef {
1789 base_key: base_key.clone(),
1790 unit: unit.clone(),
1791 };
1792 if !state.units.iter().any(|held| held == &unit) {
1793 missing.push(reference);
1794 } else if state.pending.iter().any(|held| held == &unit) {
1795 pending.push(reference);
1796 }
1797 }
1798 }
1799 Ok(QuerySnapshot {
1800 revision: revision.to_string(),
1801 rows,
1802 coverage: CoverageSnapshot {
1803 complete: pending.is_empty() && missing.is_empty(),
1804 pending,
1805 missing,
1806 },
1807 })
1808 })();
1809 match result {
1810 Ok(snapshot) => {
1811 conn.execute_batch("RELEASE syncular_snapshot_read")
1812 .map_err(|error| error.to_string())?;
1813 Ok(snapshot)
1814 }
1815 Err(error) => {
1816 let _ = conn.execute_batch(
1817 "ROLLBACK TO syncular_snapshot_read; RELEASE syncular_snapshot_read",
1818 );
1819 Err(error)
1820 }
1821 }
1822}
1823
1824pub struct FileQuerySnapshotReader {
1829 path: String,
1830 conn: Option<Connection>,
1831}
1832
1833impl FileQuerySnapshotReader {
1834 #[must_use]
1835 pub fn new(path: impl Into<String>) -> Self {
1836 Self {
1837 path: path.into(),
1838 conn: None,
1839 }
1840 }
1841
1842 fn connection(&mut self) -> Result<&Connection, String> {
1843 if self.conn.is_none() {
1844 let conn = Connection::open_with_flags(
1845 &self.path,
1846 OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_NO_MUTEX,
1847 )
1848 .map_err(|error| format!("open read sidecar {:?}: {error}", self.path))?;
1849 conn.busy_timeout(std::time::Duration::from_millis(250))
1850 .map_err(|error| error.to_string())?;
1851 self.conn = Some(conn);
1852 }
1853 self.conn
1854 .as_ref()
1855 .ok_or_else(|| "read sidecar connection missing".to_owned())
1856 }
1857
1858 pub fn query_snapshot(
1859 &mut self,
1860 sql: &str,
1861 params: &[Value],
1862 coverage: &[WindowCoverage],
1863 ) -> Result<QuerySnapshot, String> {
1864 snapshot_connection(self.connection()?, sql, params, coverage)
1865 }
1866}
1867
1868impl SyncClient {
1869 pub fn new_with_identity(
1870 client_id: Option<String>,
1871 schema_json: &Value,
1872 limits: ClientLimits,
1873 ) -> Result<Self, String> {
1874 let resolved = client_id.unwrap_or_else(|| uuid::Uuid::new_v4().to_string());
1875 let conn = Connection::open_in_memory().map_err(|e| e.to_string())?;
1876 Self::with_connection(resolved, schema_json, limits, conn)
1877 }
1878
1879 pub fn new(
1880 client_id: String,
1881 schema_json: &Value,
1882 limits: ClientLimits,
1883 ) -> Result<Self, String> {
1884 let conn = Connection::open_in_memory().map_err(|e| e.to_string())?;
1885 Self::with_connection(client_id, schema_json, limits, conn)
1886 }
1887
1888 pub fn open_path(
1894 client_id: String,
1895 schema_json: &Value,
1896 limits: ClientLimits,
1897 path: &str,
1898 ) -> Result<Self, String> {
1899 let conn = Connection::open(path).map_err(|e| format!("open db {path:?}: {e}"))?;
1900 Self::with_connection(client_id, schema_json, limits, conn)
1901 }
1902
1903 pub fn open_path_with_identity(
1904 client_id: Option<String>,
1905 schema_json: &Value,
1906 limits: ClientLimits,
1907 path: &str,
1908 ) -> Result<Self, String> {
1909 let conn = Connection::open(path).map_err(|e| format!("open db {path:?}: {e}"))?;
1910 conn.busy_timeout(std::time::Duration::from_millis(250))
1916 .map_err(|error| format!("configure db {path:?} busy timeout: {error}"))?;
1917 conn.pragma_update(None, "journal_mode", "WAL")
1918 .map_err(|error| format!("configure db {path:?} WAL mode: {error}"))?;
1919 let persisted = conn
1920 .query_row(
1921 "SELECT value FROM _syncular_meta WHERE key = 'clientId'",
1922 [],
1923 |row| row.get::<_, String>(0),
1924 )
1925 .ok();
1926 let resolved = persisted
1927 .clone()
1928 .or(client_id.clone())
1929 .unwrap_or_else(|| uuid::Uuid::new_v4().to_string());
1930 if let (Some(existing), Some(requested)) = (persisted, client_id) {
1931 if existing != requested {
1932 return Err(format!(
1933 "client.identity_mismatch: this database belongs to {existing:?}; refusing to rebind it to {requested:?}"
1934 ));
1935 }
1936 }
1937 Self::with_connection(resolved, schema_json, limits, conn)
1938 }
1939
1940 #[must_use]
1941 pub fn client_id(&self) -> &str {
1942 &self.client_id
1943 }
1944
1945 pub fn with_connection(
1952 client_id: String,
1953 schema_json: &Value,
1954 limits: ClientLimits,
1955 conn: Connection,
1956 ) -> Result<Self, String> {
1957 if limits.outcome_retention_max_entries == Some(0) {
1958 return Err(
1959 "sync.invalid_request: outcomeRetentionMaxEntries must be positive".to_owned(),
1960 );
1961 }
1962 let schema = parse_schema_json(schema_json)?;
1963 let mut client = SyncClient {
1964 conn,
1965 schema,
1966 client_id,
1967 limits,
1968 subs: Vec::new(),
1969 outbox: Vec::new(),
1970 conflicts: Vec::new(),
1971 rejections: Vec::new(),
1972 schema_floor: None,
1973 lease_state: None,
1974 stopped: false,
1975 upgrading: false,
1976 sync_needed: false,
1977 realtime_connected: false,
1978 presence: HashMap::new(),
1979 now_ms: None,
1980 encryption: crate::values::EncryptionConfig::default(),
1981 security_preflight: false,
1982 insert_sql: RefCell::new(HashMap::new()),
1983 overlay_dirty: Cell::new(false),
1984 #[cfg(test)]
1985 overlay_rebuild_count: Cell::new(0),
1986 #[cfg(test)]
1987 outcome_prune_count: Cell::new(0),
1988 change_queue: VecDeque::new(),
1989 sync_intent_queue: VecDeque::new(),
1990 retry_delay_ms: 250,
1991 last_round: None,
1992 last_change: None,
1993 };
1994 client
1998 .conn
1999 .set_prepared_statement_cache_capacity(64.max(client.schema.tables.len() * 4));
2000 client.create_bookkeeping_tables()?;
2004 match client.get_meta(CLIENT_ID_KEY) {
2005 Some(existing) if existing != client.client_id => {
2006 return Err(format!(
2007 "client.identity_mismatch: this database belongs to {existing:?}; refusing to rebind it to {:?}",
2008 client.client_id
2009 ));
2010 }
2011 None => client.set_meta(CLIENT_ID_KEY, &client.client_id),
2012 _ => {}
2013 }
2014 client.restore_persisted_state()?;
2015 let marker = client
2016 .get_meta(LOCAL_SCHEMA_VERSION_KEY)
2017 .and_then(|value| value.parse::<i32>().ok());
2018 match marker {
2019 None => {
2020 client.create_synced_tables()?;
2021 client.set_meta(LOCAL_SCHEMA_VERSION_KEY, &client.schema.version.to_string());
2022 }
2023 Some(version) if version == client.schema.version => {
2024 client.create_synced_tables()?;
2025 }
2026 Some(_) => client.run_schema_reset()?,
2027 }
2028 client.clear_satisfied_persisted_schema_floor();
2029 client.prune_unknown_subscriptions()?;
2030 if marker == Some(client.schema.version) && !client.outbox.is_empty() {
2031 client.overlay_dirty.set(true);
2034 client.rebuild_overlay();
2035 }
2036 client.enqueue_startup_sync_if_needed();
2042 Ok(client)
2043 }
2044
2045 pub fn set_now_ms(&mut self, now_ms: i64) {
2048 self.now_ms = Some(now_ms);
2049 }
2050
2051 pub fn set_encryption(&mut self, encryption: crate::values::EncryptionConfig) {
2055 self.encryption = encryption;
2056 }
2057
2058 #[must_use]
2059 pub fn security_lifecycle(&self) -> &'static str {
2060 if self.security_preflight {
2061 "preflight"
2062 } else {
2063 "active"
2064 }
2065 }
2066
2067 #[must_use]
2068 pub fn security_preflight(&self) -> bool {
2069 self.security_preflight
2070 }
2071
2072 pub fn begin_security_preflight(&mut self) {
2074 self.security_preflight = true;
2075 self.encryption = crate::values::EncryptionConfig::default();
2076 self.sync_intent_queue.clear();
2077 }
2078
2079 pub fn activate_security(
2081 &mut self,
2082 encryption: crate::values::EncryptionConfig,
2083 ) -> Result<(), String> {
2084 if !self.security_preflight {
2085 return Err(
2086 "sync.invalid_request: activateSecurity requires security preflight".to_owned(),
2087 );
2088 }
2089 self.encryption = encryption;
2090 self.security_preflight = false;
2091 self.enqueue_startup_sync_if_needed();
2092 Ok(())
2093 }
2094
2095 fn clock_now_ms(&self) -> i64 {
2096 self.now_ms.unwrap_or_else(|| {
2097 std::time::SystemTime::now()
2098 .duration_since(std::time::UNIX_EPOCH)
2099 .map(|d| d.as_millis() as i64)
2100 .unwrap_or(0)
2101 })
2102 }
2103
2104 fn create_bookkeeping_tables(&self) -> Result<(), String> {
2105 self.conn
2107 .execute_batch(
2108 "CREATE TABLE IF NOT EXISTS _syncular_outbox (
2109 seq INTEGER PRIMARY KEY AUTOINCREMENT,
2110 commit_id TEXT NOT NULL UNIQUE, ops_json TEXT NOT NULL);
2111 CREATE TABLE IF NOT EXISTS _syncular_commit_outcomes (
2112 seq INTEGER PRIMARY KEY AUTOINCREMENT,
2113 client_commit_id TEXT NOT NULL UNIQUE,
2114 status TEXT NOT NULL CHECK(status IN ('applied', 'cached', 'conflict', 'rejected')),
2115 recorded_at_ms INTEGER NOT NULL,
2116 results_json TEXT NOT NULL,
2117 operations_json TEXT,
2118 resolution TEXT NOT NULL DEFAULT 'active'
2119 CHECK(resolution IN ('active', 'resolved_keep_server', 'superseded', 'dismissed')),
2120 resolved_at_ms INTEGER,
2121 replacement_client_commit_id TEXT);
2122 CREATE INDEX IF NOT EXISTS _syncular_commit_outcomes_resolution_seq
2123 ON _syncular_commit_outcomes(resolution, seq);
2124 CREATE TABLE IF NOT EXISTS _syncular_subscriptions (
2125 id TEXT PRIMARY KEY, tbl TEXT NOT NULL, state_json TEXT NOT NULL);
2126 CREATE TABLE IF NOT EXISTS _syncular_meta (
2127 key TEXT PRIMARY KEY, value TEXT NOT NULL);
2128 CREATE TABLE IF NOT EXISTS _syncular_windows (
2129 base TEXT NOT NULL, unit TEXT NOT NULL, sub_id TEXT NOT NULL,
2130 PRIMARY KEY (base, unit));
2131 CREATE TABLE IF NOT EXISTS _syncular_window_pending_evict (
2132 sub_id TEXT PRIMARY KEY, tbl TEXT NOT NULL,
2133 effective_scopes TEXT NOT NULL);",
2134 )
2135 .map_err(|e| e.to_string())?;
2136 let _ = self
2139 .conn
2140 .execute_batch("ALTER TABLE _syncular_commit_outcomes ADD COLUMN operations_json TEXT");
2141 if self.get_meta(LOCAL_REVISION_KEY).is_none() {
2142 self.set_meta(LOCAL_REVISION_KEY, "0");
2143 }
2144 if self.schema_has_blobs() {
2149 self.conn
2150 .execute_batch(
2151 "CREATE TABLE IF NOT EXISTS _syncular_blobs (blob_id TEXT PRIMARY KEY,
2152 bytes BLOB NOT NULL, byte_length INTEGER NOT NULL,
2153 media_type TEXT, refcount INTEGER NOT NULL DEFAULT 0,
2154 created_at_ms INTEGER NOT NULL,
2155 last_used_ms INTEGER NOT NULL DEFAULT 0);
2156 CREATE TABLE IF NOT EXISTS _syncular_blob_uploads (blob_id TEXT PRIMARY KEY,
2157 media_type TEXT, created_at_ms INTEGER NOT NULL);",
2158 )
2159 .map_err(|e| e.to_string())?;
2160 let _ = self.conn.execute_batch(
2164 "ALTER TABLE _syncular_blobs ADD COLUMN last_used_ms INTEGER NOT NULL DEFAULT 0",
2165 );
2166 }
2167 Ok(())
2168 }
2169
2170 fn schema_has_blobs(&self) -> bool {
2172 self.schema
2173 .tables
2174 .iter()
2175 .any(|t| t.columns.iter().any(|c| c.ty == ColumnType::BlobRef))
2176 }
2177
2178 fn get_meta(&self, key: &str) -> Option<String> {
2181 self.conn
2182 .query_row(
2183 "SELECT value FROM _syncular_meta WHERE key = ?1",
2184 rusqlite::params![key],
2185 |row| row.get::<_, String>(0),
2186 )
2187 .ok()
2188 }
2189
2190 fn set_meta(&self, key: &str, value: &str) {
2191 let _ = self.conn.execute(
2192 "INSERT OR REPLACE INTO _syncular_meta (key, value) VALUES (?1, ?2)",
2193 rusqlite::params![key, value],
2194 );
2195 }
2196
2197 fn delete_meta(&self, key: &str) {
2198 let _ = self.conn.execute(
2199 "DELETE FROM _syncular_meta WHERE key = ?1",
2200 rusqlite::params![key],
2201 );
2202 }
2203
2204 fn restore_persisted_state(&mut self) -> Result<(), String> {
2205 self.subs = {
2206 let mut stmt = self
2207 .conn
2208 .prepare("SELECT id, tbl, state_json FROM _syncular_subscriptions ORDER BY id ASC")
2209 .map_err(|error| error.to_string())?;
2210 let rows = stmt
2211 .query_map([], |row| {
2212 Ok((
2213 row.get::<_, String>(0)?,
2214 row.get::<_, String>(1)?,
2215 row.get::<_, String>(2)?,
2216 ))
2217 })
2218 .map_err(|error| error.to_string())?;
2219 let mut subscriptions = Vec::new();
2220 for row in rows {
2221 let (id, table, raw) = row.map_err(|error| error.to_string())?;
2222 let state: Value = serde_json::from_str(&raw)
2223 .map_err(|error| format!("invalid persisted subscription {id:?}: {error}"))?;
2224 let requested = json_to_scope_map(
2225 state.get("requested").unwrap_or(&Value::Object(Map::new())),
2226 )?;
2227 let effective = state
2228 .get("effectiveScopes")
2229 .filter(|value| !value.is_null())
2230 .map(json_to_scope_map)
2231 .transpose()?;
2232 subscriptions.push(Subscription {
2233 id,
2234 table,
2235 requested,
2236 params: state
2237 .get("params")
2238 .and_then(Value::as_str)
2239 .map(str::to_owned),
2240 cursor: state.get("cursor").and_then(Value::as_i64).unwrap_or(-1),
2241 bootstrap_state: state
2242 .get("bootstrapState")
2243 .and_then(Value::as_str)
2244 .map(str::to_owned),
2245 state: SubState::parse(
2246 state
2247 .get("status")
2248 .and_then(Value::as_str)
2249 .unwrap_or("active"),
2250 ),
2251 reason_code: state
2252 .get("reasonCode")
2253 .and_then(Value::as_str)
2254 .map(str::to_owned),
2255 effective,
2256 synced_once: state
2257 .get("syncedOnce")
2258 .and_then(Value::as_bool)
2259 .unwrap_or(false),
2260 });
2261 }
2262 subscriptions
2263 };
2264
2265 self.outbox = {
2266 let mut stmt = self
2267 .conn
2268 .prepare("SELECT commit_id, ops_json FROM _syncular_outbox ORDER BY seq ASC")
2269 .map_err(|error| error.to_string())?;
2270 let rows = stmt
2271 .query_map([], |row| {
2272 Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
2273 })
2274 .map_err(|error| error.to_string())?;
2275 let mut commits = Vec::new();
2276 for row in rows {
2277 let (client_commit_id, raw) = row.map_err(|error| error.to_string())?;
2278 let entries: Vec<Value> = serde_json::from_str(&raw).map_err(|error| {
2279 format!("invalid persisted outbox {client_commit_id:?}: {error}")
2280 })?;
2281 let mut ops = Vec::with_capacity(entries.len());
2282 for entry in entries {
2283 let op = entry.get("op").and_then(Value::as_str).unwrap_or("delete");
2284 ops.push(OutboxOp {
2285 upsert: op == "upsert",
2286 table: entry
2287 .get("table")
2288 .and_then(Value::as_str)
2289 .ok_or_else(|| "persisted outbox operation missing table".to_owned())?
2290 .to_owned(),
2291 row_id: entry
2292 .get("rowId")
2293 .and_then(Value::as_str)
2294 .ok_or_else(|| "persisted outbox operation missing rowId".to_owned())?
2295 .to_owned(),
2296 base_version: entry.get("baseVersion").and_then(Value::as_i64),
2297 values: entry.get("values").and_then(Value::as_object).cloned(),
2298 changed_fields: entry.get("changedFields").and_then(Value::as_array).map(
2299 |values| {
2300 values
2301 .iter()
2302 .filter_map(Value::as_str)
2303 .map(str::to_owned)
2304 .collect()
2305 },
2306 ),
2307 });
2308 }
2309 commits.push(OutboxCommit {
2310 client_commit_id,
2311 ops,
2312 });
2313 }
2314 commits
2315 };
2316
2317 self.prune_commit_outcomes()?;
2318 let active = self.commit_outcomes(CommitOutcomeQuery {
2319 active_only: true,
2320 ..CommitOutcomeQuery::default()
2321 })?;
2322 self.conflicts = active
2323 .iter()
2324 .flat_map(|outcome| outcome.results.iter())
2325 .filter_map(|result| match result {
2326 CommitOperationOutcome::Conflict { conflict } => Some(conflict.clone()),
2327 _ => None,
2328 })
2329 .collect();
2330 self.rejections = active
2331 .iter()
2332 .flat_map(|outcome| outcome.results.iter())
2333 .filter_map(|result| match result {
2334 CommitOperationOutcome::Error { rejection } => Some(rejection.clone()),
2335 _ => None,
2336 })
2337 .collect();
2338
2339 self.lease_state = self
2340 .get_meta(LEASE_STATE_KEY)
2341 .map(|raw| serde_json::from_str(&raw))
2342 .transpose()
2343 .map_err(|error| format!("invalid persisted lease state: {error}"))?;
2344 self.schema_floor = self
2345 .get_meta(SCHEMA_FLOOR_KEY)
2346 .map(|raw| serde_json::from_str(&raw))
2347 .transpose()
2348 .map_err(|error| format!("invalid persisted schema floor: {error}"))?;
2349 self.stopped = self.schema_floor.is_some();
2350 Ok(())
2351 }
2352
2353 fn prune_unknown_subscriptions(&mut self) -> Result<(), String> {
2356 let valid_tables: BTreeSet<String> = self
2357 .schema
2358 .tables
2359 .iter()
2360 .map(|table| table.name.clone())
2361 .collect();
2362 let stale_ids: Vec<String> = self
2363 .subs
2364 .iter()
2365 .filter(|sub| !valid_tables.contains(&sub.table))
2366 .map(|sub| sub.id.clone())
2367 .collect();
2368 for id in &stale_ids {
2369 self.conn
2370 .execute(
2371 "DELETE FROM _syncular_windows WHERE sub_id = ?1",
2372 rusqlite::params![id],
2373 )
2374 .map_err(|error| error.to_string())?;
2375 self.conn
2376 .execute(
2377 "DELETE FROM _syncular_window_pending_evict WHERE sub_id = ?1",
2378 rusqlite::params![id],
2379 )
2380 .map_err(|error| error.to_string())?;
2381 self.conn
2382 .execute(
2383 "DELETE FROM _syncular_subscriptions WHERE id = ?1",
2384 rusqlite::params![id],
2385 )
2386 .map_err(|error| error.to_string())?;
2387 }
2388 self.subs.retain(|sub| valid_tables.contains(&sub.table));
2389 Ok(())
2390 }
2391
2392 #[must_use]
2393 pub fn local_revision(&self) -> u64 {
2394 self.get_meta(LOCAL_REVISION_KEY)
2395 .and_then(|value| value.parse().ok())
2396 .unwrap_or(0)
2397 }
2398
2399 #[must_use]
2400 pub fn status_snapshot(&self) -> SyncStatusSnapshot {
2401 SyncStatusSnapshot {
2402 current_schema_version: self.schema.version,
2403 outbox: self.outbox.len(),
2404 upgrading: self.upgrading,
2405 lease_state: self.lease_state.clone(),
2406 schema_floor: self.schema_floor.clone(),
2407 sync_needed: self.sync_needed,
2408 }
2409 }
2410
2411 pub fn diagnostics_snapshot(
2412 &self,
2413 request: &ClientDiagnosticsRequest,
2414 ) -> Result<ClientDiagnosticsSnapshot, String> {
2415 if request.expected_subscriptions.len() > MAX_DIAGNOSTIC_EXPECTED_SUBSCRIPTIONS {
2416 return Err(format!(
2417 "sync.invalid_request: diagnosticsSnapshot accepts at most {MAX_DIAGNOSTIC_EXPECTED_SUBSCRIPTIONS} expected subscriptions"
2418 ));
2419 }
2420 let mut subscriptions = BTreeMap::<String, DiagnosticSubscription>::new();
2421 for sub in &self.subs {
2422 let reset = sub.cursor < 0 && sub.reason_code.as_deref() == Some("sync.cursor_expired");
2423 let complete =
2424 sub.state == SubState::Active && sub.cursor >= 0 && sub.bootstrap_state.is_none();
2425 let state = match sub.state {
2426 SubState::Revoked => "revoked",
2427 SubState::Failed => "failed",
2428 SubState::Active if reset => "reset",
2429 SubState::Active if complete => "complete",
2430 SubState::Active => "bootstrapping",
2431 };
2432 subscriptions.insert(
2433 sub.id.clone(),
2434 DiagnosticSubscription {
2435 id: sub.id.clone(),
2436 table: sub.table.clone(),
2437 state: state.to_owned(),
2438 complete,
2439 cursor: Some(sub.cursor),
2440 reason_code: sub.reason_code.as_deref().map(Self::diagnostic_code),
2441 },
2442 );
2443 }
2444 for expected in &request.expected_subscriptions {
2445 if expected.id.is_empty() || expected.table.is_empty() {
2446 return Err("sync.invalid_request: diagnosticsSnapshot expected subscriptions require non-empty id and table strings".to_owned());
2447 }
2448 if subscriptions
2449 .get(&expected.id)
2450 .is_some_and(|registered| registered.table != expected.table)
2451 {
2452 subscriptions.insert(
2453 expected.id.clone(),
2454 DiagnosticSubscription {
2455 id: expected.id.clone(),
2456 table: expected.table.clone(),
2457 state: "failed".to_owned(),
2458 complete: false,
2459 cursor: None,
2460 reason_code: Some("client.subscription_intent_mismatch".to_owned()),
2461 },
2462 );
2463 } else {
2464 subscriptions.entry(expected.id.clone()).or_insert_with(|| {
2465 DiagnosticSubscription {
2466 id: expected.id.clone(),
2467 table: expected.table.clone(),
2468 state: "unregistered".to_owned(),
2469 complete: false,
2470 cursor: None,
2471 reason_code: None,
2472 }
2473 });
2474 }
2475 }
2476 let mut ordered_subscriptions = Vec::new();
2477 let mut included = BTreeSet::new();
2478 for expected in &request.expected_subscriptions {
2479 if included.insert(expected.id.clone()) {
2480 if let Some(subscription) = subscriptions.get(&expected.id) {
2481 ordered_subscriptions.push(subscription.clone());
2482 }
2483 }
2484 }
2485 for (id, subscription) in &subscriptions {
2486 if included.insert(id.clone()) {
2487 ordered_subscriptions.push(subscription.clone());
2488 }
2489 }
2490 let subscriptions_truncated =
2491 ordered_subscriptions.len() > MAX_DIAGNOSTIC_EXPECTED_SUBSCRIPTIONS;
2492 ordered_subscriptions.truncate(MAX_DIAGNOSTIC_EXPECTED_SUBSCRIPTIONS);
2493 let captured_at_ms = self.clock_now_ms();
2494 let lease = if let Some(error_code) = self
2495 .lease_state
2496 .as_ref()
2497 .and_then(|state| state.error_code.clone())
2498 {
2499 ClientDiagnosticsLease {
2500 state: "stopped".to_owned(),
2501 expires_at_ms: self
2502 .lease_state
2503 .as_ref()
2504 .and_then(|state| state.expires_at_ms),
2505 error_code: Some(Self::diagnostic_code(&error_code)),
2506 }
2507 } else if let Some(expires_at_ms) = self
2508 .lease_state
2509 .as_ref()
2510 .and_then(|state| state.expires_at_ms)
2511 {
2512 ClientDiagnosticsLease {
2513 state: if expires_at_ms <= captured_at_ms {
2514 "expired".to_owned()
2515 } else {
2516 "active".to_owned()
2517 },
2518 expires_at_ms: Some(expires_at_ms),
2519 error_code: None,
2520 }
2521 } else {
2522 ClientDiagnosticsLease {
2523 state: "none".to_owned(),
2524 expires_at_ms: None,
2525 error_code: None,
2526 }
2527 };
2528 let connectivity = match self.last_round.as_ref() {
2529 Some(round) if round.status == "succeeded" => "online",
2530 Some(round)
2531 if round.status == "failed"
2532 && round
2533 .error_code
2534 .as_deref()
2535 .is_some_and(Self::retryable_transport_code) =>
2536 {
2537 "offline"
2538 }
2539 _ => "unknown",
2540 };
2541 Ok(ClientDiagnosticsSnapshot {
2542 version: CLIENT_DIAGNOSTICS_VERSION,
2543 captured_at_ms,
2544 host: ClientDiagnosticsHost {
2545 kind: "direct".to_owned(),
2546 role: "single".to_owned(),
2547 connectivity: connectivity.to_owned(),
2548 realtime: if self.realtime_connected {
2549 "connected".to_owned()
2550 } else {
2551 "disconnected".to_owned()
2552 },
2553 },
2554 security_lifecycle: self.security_lifecycle().to_owned(),
2555 schema: ClientDiagnosticsSchema {
2556 current_version: self.schema.version,
2557 upgrading: self.upgrading,
2558 required_version: self
2559 .schema_floor
2560 .as_ref()
2561 .and_then(|floor| floor.required_schema_version),
2562 latest_version: self
2563 .schema_floor
2564 .as_ref()
2565 .and_then(|floor| floor.latest_schema_version),
2566 },
2567 replica: ClientDiagnosticsReplica {
2568 local_revision: self.local_revision().to_string(),
2569 sync_needed: self.sync_needed,
2570 pending_outbox: self.outbox.len(),
2571 },
2572 lease,
2573 subscriptions: ordered_subscriptions,
2574 subscriptions_truncated,
2575 last_round: self.last_round.clone(),
2576 last_change: self.last_change.clone(),
2577 storage: self.diagnostics_storage(),
2578 })
2579 }
2580
2581 fn diagnostics_storage(&self) -> ClientDiagnosticsStorage {
2582 let read = || -> Result<ClientDiagnosticsStorage, rusqlite::Error> {
2583 let page_count: i64 = self
2584 .conn
2585 .query_row("PRAGMA page_count", [], |row| row.get(0))?;
2586 let page_size: i64 = self
2587 .conn
2588 .query_row("PRAGMA page_size", [], |row| row.get(0))?;
2589 let outbox_bytes: i64 = self.conn.query_row(
2590 "SELECT COALESCE(SUM(LENGTH(ops_json)), 0) FROM _syncular_outbox",
2591 [],
2592 |row| row.get(0),
2593 )?;
2594 let (outcome_entries, outcome_bytes): (i64, i64) = self.conn.query_row(
2595 "SELECT COUNT(*), COALESCE(SUM(LENGTH(results_json) + COALESCE(LENGTH(operations_json), 0)), 0) FROM _syncular_commit_outcomes",
2596 [],
2597 |row| Ok((row.get(0)?, row.get(1)?)),
2598 )?;
2599 let blob_bytes = if self.schema_has_blobs() {
2600 self.conn.query_row(
2601 "SELECT COALESCE(SUM(byte_length), 0) FROM _syncular_blobs",
2602 [],
2603 |row| row.get(0),
2604 )?
2605 } else {
2606 0
2607 };
2608 let pressure = self
2609 .limits
2610 .blob_cache_max_bytes
2611 .is_some_and(|limit| blob_bytes > limit);
2612 Ok(ClientDiagnosticsStorage {
2613 status: if pressure { "pressure" } else { "healthy" }.to_owned(),
2614 database_bytes_approx: Some(page_count.saturating_mul(page_size).max(0)),
2615 pending_outbox_bytes_approx: Some(outbox_bytes.max(0)),
2616 retained_outcome_bytes_approx: Some(outcome_bytes.max(0)),
2617 retained_outcome_entries: Some(outcome_entries.max(0)),
2618 blob_cache_bytes_approx: Some(blob_bytes.max(0)),
2619 pressure_reason_code: pressure.then(|| "client.blob_cache_over_limit".to_owned()),
2620 })
2621 };
2622 read().unwrap_or_else(|_| ClientDiagnosticsStorage {
2623 status: "unreadable".to_owned(),
2624 database_bytes_approx: None,
2625 pending_outbox_bytes_approx: None,
2626 retained_outcome_bytes_approx: None,
2627 retained_outcome_entries: None,
2628 blob_cache_bytes_approx: None,
2629 pressure_reason_code: None,
2630 })
2631 }
2632
2633 pub fn drain_change_batches(&mut self) -> Vec<ClientChangeBatch> {
2634 self.change_queue.drain(..).collect()
2635 }
2636
2637 pub fn drain_sync_intents(&mut self) -> Vec<SyncIntent> {
2638 self.sync_intent_queue.drain(..).collect()
2639 }
2640
2641 fn schedule_background_retry(&mut self) {
2642 self.sync_intent_queue.push_back(SyncIntent::Background {
2643 delay_ms: self.retry_delay_ms,
2644 });
2645 self.retry_delay_ms = (self.retry_delay_ms * 2).min(30_000);
2646 }
2647
2648 fn reset_background_retry(&mut self) {
2649 self.retry_delay_ms = 250;
2650 }
2651
2652 fn retryable_transport_code(code: &str) -> bool {
2653 code == "transport.failed"
2654 || code == "transport.unavailable"
2655 || code == "sync.transport_failed"
2656 }
2657
2658 fn diagnostic_code(code: &str) -> String {
2659 let valid = !code.is_empty()
2660 && code.len() <= 96
2661 && code.contains('.')
2662 && code
2663 .bytes()
2664 .next()
2665 .is_some_and(|byte| byte.is_ascii_lowercase())
2666 && code.bytes().all(|byte| {
2667 byte.is_ascii_lowercase()
2668 || byte.is_ascii_digit()
2669 || matches!(byte, b'.' | b'_' | b'-')
2670 });
2671 if valid {
2672 code.to_owned()
2673 } else {
2674 "client.unknown_failure".to_owned()
2675 }
2676 }
2677
2678 fn set_sync_needed(&mut self, value: bool, interactive: bool) {
2679 if self.sync_needed != value {
2680 if self.begin_observation("syncular_status").is_ok() {
2681 self.sync_needed = value;
2682 let batch = ChangeAccumulator {
2683 status: true,
2684 ..ChangeAccumulator::default()
2685 };
2686 if self.finish_observation("syncular_status", batch).is_err() {
2687 self.rollback_observation("syncular_status");
2688 }
2689 } else {
2690 self.sync_needed = value;
2691 }
2692 }
2693 if value && interactive {
2694 self.sync_intent_queue.push_back(SyncIntent::Interactive);
2695 }
2696 }
2697
2698 fn begin_observation(&self, name: &str) -> Result<(), String> {
2699 self.conn
2700 .execute_batch(&format!("SAVEPOINT {name}"))
2701 .map_err(|error| error.to_string())
2702 }
2703
2704 fn rollback_observation(&self, name: &str) {
2705 let _ = self
2706 .conn
2707 .execute_batch(&format!("ROLLBACK TO {name}; RELEASE {name}"));
2708 }
2709
2710 fn finish_observation(&mut self, name: &str, batch: ChangeAccumulator) -> Result<(), String> {
2711 if !batch.touched() {
2712 self.conn
2713 .execute_batch(&format!("RELEASE {name}"))
2714 .map_err(|error| error.to_string())?;
2715 return Ok(());
2716 }
2717 let revision = self
2718 .local_revision()
2719 .checked_add(1)
2720 .ok_or_else(|| "local revision exhausted u64".to_owned())?;
2721 self.conn
2722 .execute(
2723 "INSERT OR REPLACE INTO _syncular_meta(key, value) VALUES (?1, ?2)",
2724 rusqlite::params![LOCAL_REVISION_KEY, revision.to_string()],
2725 )
2726 .map_err(|error| error.to_string())?;
2727 let status = batch.status.then(|| self.status_snapshot());
2728 let event = ClientChangeBatch {
2729 revision: revision.to_string(),
2730 tables: batch
2731 .tables
2732 .into_iter()
2733 .map(|(table, scope_keys)| TableChange {
2734 table,
2735 scope_keys: scope_keys.map(|keys| keys.into_iter().collect()),
2736 })
2737 .collect(),
2738 windows: batch
2739 .windows
2740 .into_iter()
2741 .map(|((base_key, table), units)| WindowChange {
2742 base_key,
2743 table,
2744 units: units.into_iter().collect(),
2745 })
2746 .collect(),
2747 status,
2748 conflicts_changed: batch.conflicts,
2749 rejections_changed: batch.rejections,
2750 outcomes_changed: batch.outcomes,
2751 };
2752 self.conn
2753 .execute_batch(&format!("RELEASE {name}"))
2754 .map_err(|error| error.to_string())?;
2755 let mut diagnostic_tables = event
2756 .tables
2757 .iter()
2758 .map(|entry| entry.table.clone())
2759 .collect::<BTreeSet<_>>()
2760 .into_iter()
2761 .collect::<Vec<_>>();
2762 let mut diagnostic_windows = event
2763 .windows
2764 .iter()
2765 .map(|entry| entry.table.clone())
2766 .collect::<BTreeSet<_>>()
2767 .into_iter()
2768 .collect::<Vec<_>>();
2769 let domains_truncated = diagnostic_tables.len() > MAX_DIAGNOSTIC_DOMAINS
2770 || diagnostic_windows.len() > MAX_DIAGNOSTIC_DOMAINS;
2771 diagnostic_tables.truncate(MAX_DIAGNOSTIC_DOMAINS);
2772 diagnostic_windows.truncate(MAX_DIAGNOSTIC_DOMAINS);
2773 self.last_change = Some(DiagnosticLastChange {
2774 revision: event.revision.clone(),
2775 recorded_at_ms: self.clock_now_ms(),
2776 tables: diagnostic_tables,
2777 windows: diagnostic_windows,
2778 domains_truncated,
2779 status_changed: event.status.is_some(),
2780 conflicts_changed: event.conflicts_changed,
2781 rejections_changed: event.rejections_changed,
2782 outcomes_changed: event.outcomes_changed,
2783 });
2784 self.change_queue.push_back(event);
2785 Ok(())
2786 }
2787
2788 fn record_scope_map(
2789 &self,
2790 batch: &mut ChangeAccumulator,
2791 table_name: &str,
2792 scopes: &[(String, Vec<String>)],
2793 ) {
2794 let Some(table) = self.schema.table(table_name) else {
2795 return;
2796 };
2797 for (variable, values) in scopes {
2798 let Some(scope) = table
2799 .scope_variables
2800 .iter()
2801 .find(|scope| &scope.variable == variable)
2802 else {
2803 continue;
2804 };
2805 for value in values {
2806 batch.scope(table_name, format!("{}:{value}", scope.prefix));
2807 }
2808 }
2809 }
2810
2811 fn record_row_scopes(
2813 &self,
2814 batch: &mut ChangeAccumulator,
2815 table_name: &str,
2816 row_id: &str,
2817 base: bool,
2818 ) -> bool {
2819 let Some(table) = self.schema.table(table_name) else {
2820 return false;
2821 };
2822 if table.scope_variables.is_empty() {
2823 return false;
2824 }
2825 let columns = table
2826 .scope_variables
2827 .iter()
2828 .map(|scope| quote_ident(&scope.column))
2829 .collect::<Vec<_>>()
2830 .join(", ");
2831 let full_table = if base {
2832 base_table(table_name)
2833 } else {
2834 visible_table(table_name)
2835 };
2836 let sql = format!(
2837 "SELECT {columns} FROM {full_table} WHERE CAST({} AS TEXT) = ?1 LIMIT 1",
2838 quote_ident(&table.primary_key)
2839 );
2840 let Ok(mut stmt) = self.conn.prepare(&sql) else {
2841 return false;
2842 };
2843 let values = stmt.query_row(rusqlite::params![row_id], |row| {
2844 let mut values = Vec::with_capacity(table.scope_variables.len());
2845 for index in 0..table.scope_variables.len() {
2846 values.push(row.get::<_, Option<String>>(index)?);
2847 }
2848 Ok(values)
2849 });
2850 let Ok(values) = values else {
2851 return false;
2852 };
2853 let mut recorded = false;
2854 for (scope, value) in table.scope_variables.iter().zip(values) {
2855 if let Some(value) = value {
2856 batch.scope(table_name, format!("{}:{value}", scope.prefix));
2857 recorded = true;
2858 }
2859 }
2860 recorded
2861 }
2862
2863 fn record_commit_changes(
2864 &self,
2865 batch: &mut ChangeAccumulator,
2866 tables: &[String],
2867 changes: &[ssp2::model::Change],
2868 ) {
2869 for change in changes {
2870 let Some(table_name) = tables.get(change.table_index as usize) else {
2871 continue;
2872 };
2873 let mut precise = self.record_row_scopes(batch, table_name, &change.row_id, true);
2874 if let Some(table) = self.schema.table(table_name) {
2875 for (variable, value) in &change.scopes {
2876 if let Some(scope) = table
2877 .scope_variables
2878 .iter()
2879 .find(|scope| &scope.variable == variable)
2880 {
2881 batch.scope(table_name, format!("{}:{value}", scope.prefix));
2882 precise = true;
2883 }
2884 }
2885 }
2886 if !precise {
2887 batch.table(table_name);
2888 }
2889 }
2890 }
2891
2892 fn scoped_rows_exist(&self, table_name: &str, effective: &[(String, Vec<String>)]) -> bool {
2893 if effective.is_empty() {
2894 return false;
2895 }
2896 let Some(table) = self.schema.table(table_name) else {
2897 return false;
2898 };
2899 let mut clauses = Vec::new();
2900 let mut params = Vec::new();
2901 for (variable, values) in effective {
2902 let Some(column) = table.scope_column(variable) else {
2903 return false;
2904 };
2905 if values.is_empty() {
2906 return false;
2907 }
2908 let placeholders = values
2909 .iter()
2910 .map(|value| {
2911 params.push(SqlValue::Text(value.clone()));
2912 "?"
2913 })
2914 .collect::<Vec<_>>()
2915 .join(", ");
2916 clauses.push(format!("{} IN ({placeholders})", quote_ident(column)));
2917 }
2918 let sql = format!(
2919 "SELECT 1 FROM {} WHERE {} LIMIT 1",
2920 base_table(table_name),
2921 clauses.join(" AND ")
2922 );
2923 self.conn
2924 .query_row(&sql, rusqlite::params_from_iter(params), |_| Ok(()))
2925 .is_ok()
2926 }
2927
2928 pub fn upgrading(&self) -> bool {
2930 self.upgrading
2931 }
2932
2933 pub fn recreate_with_schema(&mut self, schema_json: &Value) -> Result<(), String> {
2939 let new_schema = parse_schema_json(schema_json)?;
2940 let marker: Option<i32> = self
2941 .get_meta(LOCAL_SCHEMA_VERSION_KEY)
2942 .and_then(|v| v.parse().ok());
2943 self.schema = new_schema;
2944 if marker != Some(self.schema.version) {
2945 self.run_schema_reset()?;
2946 }
2947 self.prune_unknown_subscriptions()?;
2948 self.enqueue_startup_sync_if_needed();
2952 Ok(())
2953 }
2954
2955 fn enqueue_startup_sync_if_needed(&mut self) {
2956 let startup_work = !self.stopped
2957 && (!self.outbox.is_empty()
2958 || self.subs.iter().any(|sub| sub.state == SubState::Active));
2959 if startup_work {
2960 self.sync_needed = true;
2961 self.sync_intent_queue.push_back(SyncIntent::Interactive);
2962 }
2963 }
2964
2965 fn clear_satisfied_persisted_schema_floor(&mut self) {
2972 let satisfied = self
2973 .schema_floor
2974 .as_ref()
2975 .and_then(|floor| floor.required_schema_version)
2976 .is_some_and(|required| self.schema.version >= required);
2977 if !satisfied {
2978 return;
2979 }
2980 self.schema_floor = None;
2981 self.stopped = false;
2982 self.delete_meta(SCHEMA_FLOOR_KEY);
2983 }
2984
2985 fn run_schema_reset(&mut self) -> Result<(), String> {
2991 self.begin_observation("syncular_schema_reset")?;
2992 let mut batch = ChangeAccumulator::default();
2993 let result = self.run_schema_reset_observed(&mut batch);
2994 if let Err(error) = result {
2995 self.rollback_observation("syncular_schema_reset");
2996 return Err(error);
2997 }
2998 if let Err(error) = self.finish_observation("syncular_schema_reset", batch) {
2999 self.rollback_observation("syncular_schema_reset");
3000 return Err(error);
3001 }
3002 Ok(())
3003 }
3004
3005 fn run_schema_reset_observed(&mut self, batch: &mut ChangeAccumulator) -> Result<(), String> {
3006 self.upgrading = true;
3007 batch.status = true;
3008 for table in &self.schema.tables {
3009 batch.table(&table.name);
3010 }
3011 for (base_key, unit, table) in self.load_registered_window_units() {
3012 batch.window(&base_key, &table, &unit);
3013 }
3014 self.insert_sql.borrow_mut().clear();
3016 self.overlay_dirty.set(true);
3017 let virtual_tables: Vec<String> = {
3025 let mut stmt = self
3026 .conn
3027 .prepare(
3028 "SELECT name FROM sqlite_master WHERE type = 'table' AND sql LIKE 'CREATE VIRTUAL TABLE%'",
3029 )
3030 .map_err(|e| e.to_string())?;
3031 let rows = stmt
3032 .query_map([], |row| row.get::<_, String>(0))
3033 .map_err(|e| e.to_string())?;
3034 rows.filter_map(Result::ok)
3035 .filter(|name| is_synced_table_name(name))
3036 .collect()
3037 };
3038 for name in virtual_tables {
3039 self.conn
3040 .execute(&format!("DROP TABLE IF EXISTS {}", quote_ident(&name)), [])
3041 .map_err(|e| e.to_string())?;
3042 }
3043 let existing: Vec<String> = {
3044 let mut stmt = self
3045 .conn
3046 .prepare("SELECT name FROM sqlite_master WHERE type = 'table'")
3047 .map_err(|e| e.to_string())?;
3048 let rows = stmt
3049 .query_map([], |row| row.get::<_, String>(0))
3050 .map_err(|e| e.to_string())?;
3051 rows.filter_map(Result::ok)
3052 .filter(|name| is_synced_table_name(name))
3053 .collect()
3054 };
3055 for name in &existing {
3056 if let Some(table) = name.strip_prefix("_syncular_base_") {
3057 batch.table(table);
3058 } else if !name.starts_with("_syncular_") {
3059 batch.table(name);
3060 }
3061 }
3062 for name in existing {
3063 self.conn
3064 .execute(&format!("DROP TABLE IF EXISTS {}", quote_ident(&name)), [])
3065 .map_err(|e| e.to_string())?;
3066 }
3067 self.create_synced_tables()?;
3069 for sub in &mut self.subs {
3071 sub.cursor = -1;
3072 sub.bootstrap_state = None;
3073 sub.effective = None;
3074 sub.state = SubState::Active;
3075 sub.reason_code = None;
3076 sub.synced_once = false;
3077 }
3078 let subs = self.subs.clone();
3079 for sub in &subs {
3080 self.persist_sub(sub);
3081 }
3082 self.stopped = false;
3084 self.schema_floor = None;
3085 self.delete_meta(SCHEMA_FLOOR_KEY);
3086 self.set_meta(LOCAL_SCHEMA_VERSION_KEY, &self.schema.version.to_string());
3088 if self.drop_incompatible_outbox()? {
3092 batch.rejections = true;
3093 batch.status = true;
3094 batch.outcomes = true;
3095 }
3096 self.rebuild_overlay();
3098 Ok(())
3099 }
3100
3101 fn drop_incompatible_outbox(&mut self) -> Result<bool, String> {
3105 let schema = &self.schema;
3106 let incompatible = self
3107 .outbox
3108 .iter()
3109 .filter(|commit| {
3110 commit.ops.iter().any(|op| {
3111 if !op.upsert {
3112 return false;
3113 }
3114 match schema.table(&op.table) {
3115 None => true,
3116 Some(table) => op.values.as_ref().is_some_and(|values| {
3117 values
3118 .keys()
3119 .any(|key| !table.columns.iter().any(|c| &c.name == key))
3120 }),
3121 }
3122 })
3123 })
3124 .cloned()
3125 .collect::<Vec<_>>();
3126 if incompatible.is_empty() {
3127 return Ok(false);
3128 }
3129 let mut rejections = Vec::new();
3130 for commit in &incompatible {
3131 let results = commit
3132 .ops
3133 .iter()
3134 .enumerate()
3135 .map(|(op_index, operation)| {
3136 let rejection = RejectionRecord {
3137 client_commit_id: commit.client_commit_id.clone(),
3138 op_index: op_index as i32,
3139 code: OUTBOX_INCOMPATIBLE_CODE.to_owned(),
3140 message: "the persisted commit cannot encode under the current schema"
3141 .to_owned(),
3142 retryable: false,
3143 details: None,
3144 operation: Some(CommitOperation::from(operation)),
3145 };
3146 rejections.push(rejection.clone());
3147 CommitOperationOutcome::Error { rejection }
3148 })
3149 .collect::<Vec<_>>();
3150 self.persist_commit_outcome(
3151 &commit.client_commit_id,
3152 CommitOutcomeStatus::Rejected,
3153 &results,
3154 Some(&commit.ops),
3155 )?;
3156 self.delete_outbox_persisted(&commit.client_commit_id)?;
3157 }
3158 self.prune_commit_outcomes()?;
3159 let incompatible_ids = incompatible
3160 .iter()
3161 .map(|commit| commit.client_commit_id.as_str())
3162 .collect::<BTreeSet<_>>();
3163 self.outbox
3164 .retain(|commit| !incompatible_ids.contains(commit.client_commit_id.as_str()));
3165 self.rejections.extend(rejections);
3166 Ok(true)
3167 }
3168
3169 fn create_synced_tables(&self) -> Result<(), String> {
3172 for table in &self.schema.tables {
3173 for (full, index_prefix) in [
3177 (base_table(&table.name), "_syncular_base_"),
3178 (visible_table(&table.name), ""),
3179 ] {
3180 let mut cols: Vec<String> =
3181 table.columns.iter().map(|c| quote_ident(&c.name)).collect();
3182 cols.push("\"_syncular_version\" INTEGER NOT NULL".to_owned());
3183 let sql = format!(
3184 "CREATE TABLE IF NOT EXISTS {full} ({} , PRIMARY KEY ({}))",
3185 cols.join(", "),
3186 quote_ident(&table.primary_key)
3187 );
3188 self.conn.execute(&sql, []).map_err(|e| e.to_string())?;
3189 for index in &table.indexes {
3193 let unique = if index.unique { "UNIQUE " } else { "" };
3194 let index_name = quote_ident(&format!("{index_prefix}{}", index.name));
3195 let cols_sql = index
3196 .columns
3197 .iter()
3198 .map(|c| quote_ident(c))
3199 .collect::<Vec<_>>()
3200 .join(", ");
3201 let index_sql = format!(
3202 "CREATE {unique}INDEX IF NOT EXISTS {index_name} ON {full} ({cols_sql})"
3203 );
3204 self.conn
3205 .execute(&index_sql, [])
3206 .map_err(|e| e.to_string())?;
3207 }
3208 }
3209 }
3210 for table in &self.schema.tables {
3213 for index in &table.fts_indexes {
3214 self.create_fts_projection(table, index)?;
3215 }
3216 }
3217 Ok(())
3218 }
3219
3220 fn fts_projection_exists(&self, index: &FtsIndexSchema) -> Result<bool, String> {
3221 let count: i64 = self
3222 .conn
3223 .query_row(
3224 "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name=?1",
3225 rusqlite::params![index.name],
3226 |row| row.get(0),
3227 )
3228 .map_err(|e| e.to_string())?;
3229 Ok(count > 0)
3230 }
3231
3232 fn drop_fts_triggers(&self, index: &FtsIndexSchema) -> Result<(), String> {
3233 for suffix in ["ai", "ad", "au"] {
3234 self.conn
3235 .execute(
3236 &format!(
3237 "DROP TRIGGER IF EXISTS {}",
3238 quote_ident(&format!("{}_{suffix}", index.name))
3239 ),
3240 [],
3241 )
3242 .map_err(|e| e.to_string())?;
3243 }
3244 Ok(())
3245 }
3246
3247 fn create_fts_triggers(
3248 &self,
3249 table: &TableSchema,
3250 index: &FtsIndexSchema,
3251 ) -> Result<(), String> {
3252 let fts = quote_ident(&index.name);
3253 let source = visible_table(&table.name);
3254 let source_id = quote_ident(FTS_SOURCE_ID_COLUMN);
3255 let pk = quote_ident(&table.primary_key);
3256 let projection_columns = std::iter::once(source_id.clone())
3257 .chain(index.columns.iter().map(|column| quote_ident(column)))
3258 .collect::<Vec<_>>()
3259 .join(", ");
3260 let new_values = std::iter::once(format!("CAST(new.{pk} AS TEXT)"))
3261 .chain(
3262 index
3263 .columns
3264 .iter()
3265 .map(|column| format!("new.{}", quote_ident(column))),
3266 )
3267 .collect::<Vec<_>>()
3268 .join(", ");
3269 let delete_new = format!("DELETE FROM {fts} WHERE {source_id} = CAST(new.{pk} AS TEXT)");
3270 let delete_old = format!("DELETE FROM {fts} WHERE {source_id} = CAST(old.{pk} AS TEXT)");
3271 let insert_new = format!("INSERT INTO {fts} ({projection_columns}) VALUES ({new_values})");
3272 let sql = format!(
3273 "CREATE TRIGGER IF NOT EXISTS {ai} AFTER INSERT ON {source} BEGIN {delete_new}; {insert_new}; END;
3274 CREATE TRIGGER IF NOT EXISTS {ad} AFTER DELETE ON {source} BEGIN {delete_old}; END;
3275 CREATE TRIGGER IF NOT EXISTS {au} AFTER UPDATE ON {source} BEGIN {delete_old}; {delete_new}; {insert_new}; END;",
3276 ai = quote_ident(&format!("{}_ai", index.name)),
3277 ad = quote_ident(&format!("{}_ad", index.name)),
3278 au = quote_ident(&format!("{}_au", index.name)),
3279 );
3280 self.conn.execute_batch(&sql).map_err(|e| e.to_string())
3281 }
3282
3283 fn rebuild_fts_projection(
3284 &self,
3285 table: &TableSchema,
3286 index: &FtsIndexSchema,
3287 ) -> Result<(), String> {
3288 let fts = quote_ident(&index.name);
3289 let source_id = quote_ident(FTS_SOURCE_ID_COLUMN);
3290 let pk = quote_ident(&table.primary_key);
3291 let indexed_columns = index
3292 .columns
3293 .iter()
3294 .map(|column| quote_ident(column))
3295 .collect::<Vec<_>>();
3296 let projection_columns = std::iter::once(source_id)
3297 .chain(indexed_columns.iter().cloned())
3298 .collect::<Vec<_>>()
3299 .join(", ");
3300 let sql = format!(
3301 "DELETE FROM {fts}; INSERT INTO {fts} ({projection_columns}) SELECT CAST({pk} AS TEXT), {columns} FROM {source};",
3302 columns = indexed_columns.join(", "),
3303 source = visible_table(&table.name),
3304 );
3305 self.conn.execute_batch(&sql).map_err(|e| e.to_string())
3306 }
3307
3308 fn create_fts_projection(
3309 &self,
3310 table: &TableSchema,
3311 index: &FtsIndexSchema,
3312 ) -> Result<(), String> {
3313 let existed = self.fts_projection_exists(index)?;
3314 let tokenizer = index.tokenize.replace('\'', "''");
3315 let columns = index
3316 .columns
3317 .iter()
3318 .map(|column| quote_ident(column))
3319 .collect::<Vec<_>>()
3320 .join(", ");
3321 let sql = format!(
3322 "CREATE VIRTUAL TABLE IF NOT EXISTS {fts} USING fts5({source_id} UNINDEXED, {columns}, tokenize='{tokenizer}')",
3323 fts = quote_ident(&index.name),
3324 source_id = quote_ident(FTS_SOURCE_ID_COLUMN),
3325 );
3326 self.conn.execute(&sql, []).map_err(|error| {
3327 format!(
3328 "cannot create local FTS5 projection {:?}: {error}",
3329 index.name
3330 )
3331 })?;
3332 self.create_fts_triggers(table, index)?;
3333 if !existed {
3334 self.rebuild_fts_projection(table, index)?;
3335 }
3336 Ok(())
3337 }
3338
3339 fn persist_sub(&self, sub: &Subscription) {
3342 let state = serde_json::json!({
3343 "requested": scope_map_to_json(&sub.requested),
3344 "params": sub.params,
3345 "cursor": sub.cursor,
3346 "bootstrapState": sub.bootstrap_state,
3347 "status": sub.state.name(),
3348 "reasonCode": sub.reason_code,
3349 "effectiveScopes": sub.effective.as_ref().map(|e| scope_map_to_json(e)),
3350 "syncedOnce": sub.synced_once,
3351 });
3352 let _ = self.conn.execute(
3353 "INSERT OR REPLACE INTO _syncular_subscriptions (id, tbl, state_json) VALUES (?1, ?2, ?3)",
3354 rusqlite::params![sub.id, sub.table, state.to_string()],
3355 );
3356 }
3357
3358 fn persist_outbox_insert(&self, commit: &OutboxCommit) {
3359 let ops: Vec<Value> = commit
3360 .ops
3361 .iter()
3362 .map(|op| {
3363 serde_json::json!({
3364 "op": if op.upsert { "upsert" } else { "delete" },
3365 "table": op.table,
3366 "rowId": op.row_id,
3367 "baseVersion": op.base_version,
3368 "values": op.values.clone().map(Value::Object),
3369 "changedFields": op.changed_fields,
3370 })
3371 })
3372 .collect();
3373 let _ = self.conn.execute(
3374 "INSERT OR REPLACE INTO _syncular_outbox (commit_id, ops_json) VALUES (?1, ?2)",
3375 rusqlite::params![commit.client_commit_id, Value::Array(ops).to_string()],
3376 );
3377 }
3378
3379 fn delete_outbox_persisted(&self, client_commit_id: &str) -> Result<(), String> {
3380 self.conn
3381 .execute(
3382 "DELETE FROM _syncular_outbox WHERE commit_id = ?1",
3383 rusqlite::params![client_commit_id],
3384 )
3385 .map(|_| ())
3386 .map_err(|error| error.to_string())
3387 }
3388
3389 fn outcome_status_name(status: CommitOutcomeStatus) -> &'static str {
3390 match status {
3391 CommitOutcomeStatus::Applied => "applied",
3392 CommitOutcomeStatus::Cached => "cached",
3393 CommitOutcomeStatus::Conflict => "conflict",
3394 CommitOutcomeStatus::Rejected => "rejected",
3395 }
3396 }
3397
3398 fn outcome_resolution_name(resolution: CommitOutcomeResolution) -> &'static str {
3399 match resolution {
3400 CommitOutcomeResolution::Active => "active",
3401 CommitOutcomeResolution::ResolvedKeepServer => "resolved_keep_server",
3402 CommitOutcomeResolution::Superseded => "superseded",
3403 CommitOutcomeResolution::Dismissed => "dismissed",
3404 }
3405 }
3406
3407 fn parse_outcome_status(value: &str) -> Result<CommitOutcomeStatus, String> {
3408 match value {
3409 "applied" => Ok(CommitOutcomeStatus::Applied),
3410 "cached" => Ok(CommitOutcomeStatus::Cached),
3411 "conflict" => Ok(CommitOutcomeStatus::Conflict),
3412 "rejected" => Ok(CommitOutcomeStatus::Rejected),
3413 _ => Err(format!("invalid persisted commit outcome status {value:?}")),
3414 }
3415 }
3416
3417 fn parse_outcome_resolution(value: &str) -> Result<CommitOutcomeResolution, String> {
3418 match value {
3419 "active" => Ok(CommitOutcomeResolution::Active),
3420 "resolved_keep_server" => Ok(CommitOutcomeResolution::ResolvedKeepServer),
3421 "superseded" => Ok(CommitOutcomeResolution::Superseded),
3422 "dismissed" => Ok(CommitOutcomeResolution::Dismissed),
3423 _ => Err(format!(
3424 "invalid persisted commit outcome resolution {value:?}"
3425 )),
3426 }
3427 }
3428
3429 fn persist_commit_outcome(
3430 &self,
3431 client_commit_id: &str,
3432 status: CommitOutcomeStatus,
3433 results: &[CommitOperationOutcome],
3434 operations: Option<&[OutboxOp]>,
3435 ) -> Result<(), String> {
3436 let results_json = serde_json::to_string(results).map_err(|error| error.to_string())?;
3437 let operations_json = operations
3438 .map(|items| {
3439 serde_json::to_string(&items.iter().map(CommitOperation::from).collect::<Vec<_>>())
3440 })
3441 .transpose()
3442 .map_err(|error| error.to_string())?;
3443 self.conn
3444 .execute(
3445 "INSERT INTO _syncular_commit_outcomes (
3446 client_commit_id, status, recorded_at_ms, results_json,
3447 operations_json, resolution
3448 ) VALUES (?1, ?2, ?3, ?4, ?5, 'active')",
3449 rusqlite::params![
3450 client_commit_id,
3451 Self::outcome_status_name(status),
3452 self.clock_now_ms(),
3453 results_json,
3454 operations_json
3455 ],
3456 )
3457 .map(|_| ())
3458 .map_err(|error| error.to_string())
3459 }
3460
3461 fn outcome_from_row(row: StoredCommitOutcomeRow) -> Result<CommitOutcome, String> {
3462 let StoredCommitOutcomeRow {
3463 sequence,
3464 client_commit_id,
3465 status,
3466 recorded_at_ms,
3467 results_json,
3468 operations_json,
3469 resolution,
3470 resolved_at_ms,
3471 replacement_client_commit_id,
3472 } = row;
3473 Ok(CommitOutcome {
3474 sequence,
3475 client_commit_id,
3476 status: Self::parse_outcome_status(&status)?,
3477 recorded_at_ms,
3478 results: serde_json::from_str(&results_json)
3479 .map_err(|error| format!("invalid persisted commit outcome results: {error}"))?,
3480 operations: operations_json
3481 .map(|value| {
3482 serde_json::from_str(&value).map_err(|error| {
3483 format!("invalid persisted commit outcome operations: {error}")
3484 })
3485 })
3486 .transpose()?,
3487 resolution: Self::parse_outcome_resolution(&resolution)?,
3488 resolved_at_ms,
3489 replacement_client_commit_id,
3490 })
3491 }
3492
3493 pub fn commit_outcome(&self, client_commit_id: &str) -> Result<Option<CommitOutcome>, String> {
3494 let row = self
3495 .conn
3496 .query_row(
3497 "SELECT seq, client_commit_id, status, recorded_at_ms, results_json, operations_json,
3498 resolution, resolved_at_ms, replacement_client_commit_id
3499 FROM _syncular_commit_outcomes WHERE client_commit_id = ?1",
3500 rusqlite::params![client_commit_id],
3501 |row| {
3502 Ok(StoredCommitOutcomeRow {
3503 sequence: row.get(0)?,
3504 client_commit_id: row.get(1)?,
3505 status: row.get(2)?,
3506 recorded_at_ms: row.get(3)?,
3507 results_json: row.get(4)?,
3508 operations_json: row.get(5)?,
3509 resolution: row.get(6)?,
3510 resolved_at_ms: row.get(7)?,
3511 replacement_client_commit_id: row.get(8)?,
3512 })
3513 },
3514 )
3515 .optional()
3516 .map_err(|error| error.to_string())?;
3517 row.map(Self::outcome_from_row).transpose()
3518 }
3519
3520 pub fn commit_outcomes(&self, query: CommitOutcomeQuery) -> Result<Vec<CommitOutcome>, String> {
3521 if query.limit == Some(0) {
3522 return Err("sync.invalid_request: commit outcome limit must be positive".to_owned());
3523 }
3524 let mut sql = String::from(
3525 "SELECT seq, client_commit_id, status, recorded_at_ms, results_json, operations_json,
3526 resolution, resolved_at_ms, replacement_client_commit_id
3527 FROM _syncular_commit_outcomes",
3528 );
3529 if query.active_only {
3530 sql.push_str(" WHERE resolution = 'active' AND status IN ('conflict', 'rejected')");
3531 }
3532 sql.push_str(" ORDER BY seq DESC");
3533 if let Some(limit) = query.limit {
3534 sql.push_str(&format!(" LIMIT {limit}"));
3535 }
3536 let mut stmt = self.conn.prepare(&sql).map_err(|error| error.to_string())?;
3537 let rows = stmt
3538 .query_map([], |row| {
3539 Ok(StoredCommitOutcomeRow {
3540 sequence: row.get(0)?,
3541 client_commit_id: row.get(1)?,
3542 status: row.get(2)?,
3543 recorded_at_ms: row.get(3)?,
3544 results_json: row.get(4)?,
3545 operations_json: row.get(5)?,
3546 resolution: row.get(6)?,
3547 resolved_at_ms: row.get(7)?,
3548 replacement_client_commit_id: row.get(8)?,
3549 })
3550 })
3551 .map_err(|error| error.to_string())?;
3552 let mut outcomes = Vec::new();
3553 for row in rows {
3554 outcomes.push(Self::outcome_from_row(
3555 row.map_err(|error| error.to_string())?,
3556 )?);
3557 }
3558 Ok(outcomes)
3559 }
3560
3561 fn prune_commit_outcomes(&self) -> Result<(), String> {
3562 #[cfg(test)]
3563 self.outcome_prune_count
3564 .set(self.outcome_prune_count.get() + 1);
3565 let max_entries = self.limits.outcome_retention_max_entries.unwrap_or(1_000);
3566 let count = self
3567 .conn
3568 .query_row(
3569 "SELECT COUNT(*) FROM _syncular_commit_outcomes",
3570 [],
3571 |row| row.get::<_, i64>(0),
3572 )
3573 .map_err(|error| error.to_string())? as usize;
3574 let excess = count.saturating_sub(max_entries);
3575 if excess == 0 {
3576 return Ok(());
3577 }
3578 let mut stmt = self
3579 .conn
3580 .prepare(
3581 "SELECT seq FROM _syncular_commit_outcomes
3582 WHERE status IN ('applied', 'cached') OR resolution != 'active'
3583 ORDER BY seq ASC LIMIT ?1",
3584 )
3585 .map_err(|error| error.to_string())?;
3586 let rows = stmt
3587 .query_map(rusqlite::params![excess as i64], |row| row.get::<_, i64>(0))
3588 .map_err(|error| error.to_string())?;
3589 let sequences = rows
3590 .collect::<Result<Vec<_>, _>>()
3591 .map_err(|error| error.to_string())?;
3592 drop(stmt);
3593 for sequence in sequences {
3594 self.conn
3595 .execute(
3596 "DELETE FROM _syncular_commit_outcomes WHERE seq = ?1",
3597 rusqlite::params![sequence],
3598 )
3599 .map_err(|error| error.to_string())?;
3600 }
3601 Ok(())
3602 }
3603
3604 pub fn subscribe(
3607 &mut self,
3608 id: String,
3609 table: String,
3610 scopes: Vec<(String, Vec<String>)>,
3611 params: Option<String>,
3612 ) -> Result<(), String> {
3613 if self.schema.table(&table).is_none() {
3614 return Err(format!("unknown table {table:?}"));
3615 }
3616 if let Some(existing) = self.subs.iter().find(|subscription| subscription.id == id) {
3617 let same_intent = existing.table == table
3618 && canonical_scope_json(&existing.requested) == canonical_scope_json(&scopes)
3619 && existing.params == params;
3620 if same_intent {
3621 return Ok(());
3622 }
3623 return Err(
3624 "client.subscription_intent_mismatch: the subscription id is already registered for a different table, scopes, or params"
3625 .to_owned(),
3626 );
3627 }
3628 let sub = Subscription {
3629 id: id.clone(),
3630 table,
3631 requested: scopes,
3632 params,
3633 cursor: -1,
3634 bootstrap_state: None,
3635 state: SubState::Active,
3636 reason_code: None,
3637 effective: None,
3638 synced_once: false,
3639 };
3640 self.persist_sub(&sub);
3641 self.subs.push(sub);
3642 Ok(())
3643 }
3644
3645 pub fn unsubscribe(&mut self, id: &str) {
3646 self.subs.retain(|s| s.id != id);
3647 let _ = self.conn.execute(
3648 "DELETE FROM _syncular_subscriptions WHERE id = ?1",
3649 rusqlite::params![id],
3650 );
3651 }
3652
3653 pub fn set_window(
3661 &mut self,
3662 base: &WindowBase,
3663 units: &[String],
3664 ) -> Result<CommandEffects, String> {
3665 let table = self
3666 .schema
3667 .table(&base.table)
3668 .ok_or_else(|| format!("unknown table {:?}", base.table))?;
3669 if table.scope_column(&base.variable).is_none() {
3670 return Err(format!(
3671 "setWindow: table {:?} has no scope variable {:?} (§4.8)",
3672 base.table, base.variable
3673 ));
3674 }
3675 let base_key = window_base_key(base);
3676 let wanted: std::collections::HashSet<&String> = units.iter().collect();
3677 let live = self.load_window_units(&base_key);
3678 self.begin_observation("syncular_window")?;
3679 let mut batch = ChangeAccumulator::default();
3680 let mut changed = false;
3681
3682 for unit in units {
3684 if live.iter().any(|(u, _)| u == unit) {
3685 continue;
3686 }
3687 let sub_id = derive_sub_id(base, unit);
3688 self.delete_pending_evict(&sub_id);
3689 self.insert_window_unit(&base_key, unit, &sub_id);
3690 self.subscribe(
3691 sub_id,
3692 base.table.clone(),
3693 unit_scopes(base, unit),
3694 base.params.clone(),
3695 )?;
3696 batch.window(&base_key, &base.table, unit);
3697 changed = true;
3698 }
3699
3700 for (unit, sub_id) in live {
3702 if wanted.contains(&unit) {
3703 continue;
3704 }
3705 let effective = self
3706 .subs
3707 .iter()
3708 .find(|sub| sub.id == sub_id)
3709 .and_then(|sub| sub.effective.clone())
3710 .unwrap_or_else(|| unit_scopes(base, &unit));
3711 self.record_scope_map(&mut batch, &base.table, &effective);
3712 batch.window(&base_key, &base.table, &unit);
3713 self.evict_unit(&base_key, base, &unit, &sub_id);
3714 changed = true;
3715 }
3716 if let Err(error) = self.finish_observation("syncular_window", batch) {
3717 self.rollback_observation("syncular_window");
3718 return Err(error);
3719 }
3720 Ok(if changed {
3721 CommandEffects::interactive()
3722 } else {
3723 CommandEffects::none()
3724 })
3725 }
3726
3727 pub fn window_state(&self, base: &WindowBase) -> WindowState {
3732 let mut units = Vec::new();
3733 let mut pending = Vec::new();
3734 for (unit, sub_id) in self.load_window_units(&window_base_key(base)) {
3735 let is_pending = match self.subs.iter().find(|s| s.id == sub_id) {
3736 Some(sub) => {
3737 sub.state != SubState::Active || sub.cursor < 0 || sub.bootstrap_state.is_some()
3738 }
3739 None => true,
3740 };
3741 if is_pending {
3742 pending.push(unit.clone());
3743 }
3744 units.push(unit);
3745 }
3746 WindowState { units, pending }
3747 }
3748
3749 fn load_window_units(&self, base_key: &str) -> Vec<(String, String)> {
3750 let mut stmt = match self
3751 .conn
3752 .prepare("SELECT unit, sub_id FROM _syncular_windows WHERE base = ?1 ORDER BY unit ASC")
3753 {
3754 Ok(stmt) => stmt,
3755 Err(_) => return Vec::new(),
3756 };
3757 let rows = stmt.query_map(rusqlite::params![base_key], |row| {
3758 Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
3759 });
3760 match rows {
3761 Ok(rows) => rows.filter_map(Result::ok).collect(),
3762 Err(_) => Vec::new(),
3763 }
3764 }
3765
3766 fn load_registered_window_units(&self) -> Vec<(String, String, String)> {
3767 let mut stmt = match self.conn.prepare(
3768 "SELECT windows.base, windows.unit, subscriptions.tbl
3769 FROM _syncular_windows AS windows
3770 JOIN _syncular_subscriptions AS subscriptions
3771 ON subscriptions.id = windows.sub_id
3772 ORDER BY windows.base, windows.unit",
3773 ) {
3774 Ok(stmt) => stmt,
3775 Err(_) => return Vec::new(),
3776 };
3777 let rows = stmt.query_map([], |row| {
3778 Ok((
3779 row.get::<_, String>(0)?,
3780 row.get::<_, String>(1)?,
3781 row.get::<_, String>(2)?,
3782 ))
3783 });
3784 match rows {
3785 Ok(rows) => rows.filter_map(Result::ok).collect(),
3786 Err(_) => Vec::new(),
3787 }
3788 }
3789
3790 fn window_unit_by_sub_id(&self, sub_id: &str) -> Option<(String, String)> {
3791 self.conn
3792 .query_row(
3793 "SELECT base, unit FROM _syncular_windows WHERE sub_id = ?1 LIMIT 1",
3794 rusqlite::params![sub_id],
3795 |row| Ok((row.get(0)?, row.get(1)?)),
3796 )
3797 .ok()
3798 }
3799
3800 fn insert_window_unit(&self, base_key: &str, unit: &str, sub_id: &str) {
3801 let _ = self.conn.execute(
3802 "INSERT OR REPLACE INTO _syncular_windows(base, unit, sub_id) VALUES (?1, ?2, ?3)",
3803 rusqlite::params![base_key, unit, sub_id],
3804 );
3805 }
3806
3807 fn delete_window_unit(&self, base_key: &str, unit: &str) {
3808 let _ = self.conn.execute(
3809 "DELETE FROM _syncular_windows WHERE base = ?1 AND unit = ?2",
3810 rusqlite::params![base_key, unit],
3811 );
3812 }
3813
3814 fn evict_unit(&mut self, base_key: &str, base: &WindowBase, unit: &str, sub_id: &str) {
3821 let effective = self
3822 .subs
3823 .iter()
3824 .find(|s| s.id == sub_id)
3825 .and_then(|s| s.effective.clone())
3826 .unwrap_or_else(|| unit_scopes(base, unit));
3827 let pinned = self.pinned_row_ids(&base.table);
3828 let deferred = self
3829 .evict_scope_rows(&base.table, &effective, &pinned)
3830 .unwrap_or(false);
3831 self.delete_window_unit(base_key, unit);
3832 self.unsubscribe(sub_id);
3833 if deferred {
3834 self.save_pending_evict(sub_id, &base.table, &effective);
3835 } else {
3836 self.delete_pending_evict(sub_id);
3837 }
3838 self.rebuild_overlay();
3839 }
3840
3841 fn evict_scope_rows(
3845 &mut self,
3846 table_name: &str,
3847 effective: &[(String, Vec<String>)],
3848 pinned: &std::collections::HashSet<String>,
3849 ) -> Result<bool, ()> {
3850 if effective.is_empty() {
3851 return Ok(false);
3852 }
3853 let table = self.schema.table(table_name).ok_or(())?.clone();
3854 let mut clauses = Vec::new();
3855 let mut params: Vec<SqlValue> = Vec::new();
3856 for (variable, values) in effective {
3857 let column = table.scope_column(variable).ok_or(())?;
3858 let placeholders: Vec<String> = values
3859 .iter()
3860 .map(|v| {
3861 params.push(SqlValue::Text(v.clone()));
3862 "?".to_owned()
3863 })
3864 .collect();
3865 clauses.push(format!(
3866 "{} IN ({})",
3867 quote_ident(column),
3868 placeholders.join(", ")
3869 ));
3870 }
3871 let mut sql = format!(
3872 "DELETE FROM {} WHERE {}",
3873 base_table(table_name),
3874 clauses.join(" AND ")
3875 );
3876 if !pinned.is_empty() {
3877 let pk = quote_ident(&table.primary_key);
3878 let holes: Vec<String> = pinned
3879 .iter()
3880 .map(|id| {
3881 params.push(SqlValue::Text(id.clone()));
3882 "?".to_owned()
3883 })
3884 .collect();
3885 sql.push_str(&format!(" AND {} NOT IN ({})", pk, holes.join(", ")));
3886 }
3887 self.overlay_dirty.set(true);
3888 self.conn
3889 .execute(&sql, rusqlite::params_from_iter(params))
3890 .map_err(|_| ())?;
3891 if pinned.is_empty() {
3892 return Ok(false);
3893 }
3894 let mut where_params: Vec<SqlValue> = Vec::new();
3897 let mut where_clauses = Vec::new();
3898 for (variable, values) in effective {
3899 let column = table.scope_column(variable).ok_or(())?;
3900 let placeholders: Vec<String> = values
3901 .iter()
3902 .map(|v| {
3903 where_params.push(SqlValue::Text(v.clone()));
3904 "?".to_owned()
3905 })
3906 .collect();
3907 where_clauses.push(format!(
3908 "{} IN ({})",
3909 quote_ident(column),
3910 placeholders.join(", ")
3911 ));
3912 }
3913 let pk = quote_ident(&table.primary_key);
3914 let select = format!(
3915 "SELECT {} FROM {} WHERE {}",
3916 pk,
3917 base_table(table_name),
3918 where_clauses.join(" AND ")
3919 );
3920 let mut stmt = self.conn.prepare(&select).map_err(|_| ())?;
3921 let survivors: Vec<String> = stmt
3922 .query_map(rusqlite::params_from_iter(where_params), |row| {
3923 row.get::<_, String>(0)
3924 })
3925 .map_err(|_| ())?
3926 .filter_map(Result::ok)
3927 .collect();
3928 Ok(survivors.iter().any(|id| pinned.contains(id)))
3929 }
3930
3931 fn drain_pending_evictions(&mut self) {
3935 let pending = self.load_pending_evictions();
3936 if pending.is_empty() {
3937 return;
3938 }
3939 for (sub_id, table_name, effective) in pending {
3940 if self.schema.table(&table_name).is_none() {
3941 self.delete_pending_evict(&sub_id);
3942 continue;
3943 }
3944 let pinned = self.pinned_row_ids(&table_name);
3945 let deferred = self
3946 .evict_scope_rows(&table_name, &effective, &pinned)
3947 .unwrap_or(false);
3948 if !deferred {
3949 self.delete_pending_evict(&sub_id);
3950 }
3951 }
3952 self.rebuild_overlay_if_dirty();
3953 }
3954
3955 fn pinned_row_ids(&self, table: &str) -> std::collections::HashSet<String> {
3958 let mut pinned = std::collections::HashSet::new();
3959 for commit in &self.outbox {
3960 for op in &commit.ops {
3961 if op.table == table {
3962 pinned.insert(op.row_id.clone());
3963 }
3964 }
3965 }
3966 pinned
3967 }
3968
3969 fn save_pending_evict(&self, sub_id: &str, table: &str, effective: &[(String, Vec<String>)]) {
3970 let _ = self.conn.execute(
3971 "INSERT OR REPLACE INTO _syncular_window_pending_evict(sub_id, tbl, effective_scopes)
3972 VALUES (?1, ?2, ?3)",
3973 rusqlite::params![sub_id, table, scope_map_to_json(effective).to_string()],
3974 );
3975 }
3976
3977 fn delete_pending_evict(&self, sub_id: &str) {
3978 let _ = self.conn.execute(
3979 "DELETE FROM _syncular_window_pending_evict WHERE sub_id = ?1",
3980 rusqlite::params![sub_id],
3981 );
3982 }
3983
3984 fn load_pending_evictions(&self) -> Vec<PendingEvict> {
3985 let mut stmt = match self
3986 .conn
3987 .prepare("SELECT sub_id, tbl, effective_scopes FROM _syncular_window_pending_evict")
3988 {
3989 Ok(stmt) => stmt,
3990 Err(_) => return Vec::new(),
3991 };
3992 let rows = stmt.query_map([], |row| {
3993 Ok((
3994 row.get::<_, String>(0)?,
3995 row.get::<_, String>(1)?,
3996 row.get::<_, String>(2)?,
3997 ))
3998 });
3999 let mut out = Vec::new();
4000 if let Ok(rows) = rows {
4001 for entry in rows.filter_map(Result::ok) {
4002 let (sub_id, table, json) = entry;
4003 if let Ok(value) = serde_json::from_str::<Value>(&json) {
4004 if let Ok(effective) = json_to_scope_map(&value) {
4005 out.push((sub_id, table, effective));
4006 }
4007 }
4008 }
4009 }
4010 out
4011 }
4012
4013 pub fn mutate(&mut self, mutations: Vec<Mutation>) -> Result<String, String> {
4015 if mutations.is_empty() {
4016 return Err("a commit must contain at least one operation (§6.1)".to_owned());
4017 }
4018 let mut ops = Vec::with_capacity(mutations.len());
4019 for mutation in mutations {
4020 match mutation {
4021 Mutation::Upsert {
4022 table,
4023 values,
4024 base_version,
4025 } => {
4026 let schema_table = self
4027 .schema
4028 .table(&table)
4029 .ok_or_else(|| format!("unknown table {table:?}"))?;
4030 let values = normalize_values_casing(schema_table, values)?;
4034 let row_id = render_row_id_json(values.get(&schema_table.primary_key))?;
4035 encode_row_json(schema_table, &row_id, &values, &self.encryption)?;
4038 ops.push(OutboxOp {
4039 upsert: true,
4040 table,
4041 row_id,
4042 base_version,
4043 values: Some(values),
4044 changed_fields: None,
4045 });
4046 }
4047 Mutation::Delete {
4048 table,
4049 row_id,
4050 base_version,
4051 } => {
4052 if self.schema.table(&table).is_none() {
4053 return Err(format!("unknown table {table:?}"));
4054 }
4055 ops.push(OutboxOp {
4056 upsert: false,
4057 table,
4058 row_id,
4059 base_version,
4060 values: None,
4061 changed_fields: None,
4062 });
4063 }
4064 }
4065 }
4066 self.record_outbox_commit(ops)
4067 }
4068
4069 fn record_outbox_commit(&mut self, ops: Vec<OutboxOp>) -> Result<String, String> {
4070 let commit = OutboxCommit {
4071 client_commit_id: uuid::Uuid::new_v4().to_string(),
4072 ops,
4073 };
4074 self.begin_observation("syncular_mutation")?;
4075 let mut batch = ChangeAccumulator::default();
4076 for op in &commit.ops {
4077 let mut precise = self.record_row_scopes(&mut batch, &op.table, &op.row_id, false);
4078 if let Some(values) = &op.values {
4079 if let Some(table) = self.schema.table(&op.table) {
4080 for scope in &table.scope_variables {
4081 if let Some(Value::String(value)) = values.get(&scope.column) {
4082 batch.scope(&op.table, format!("{}:{value}", scope.prefix));
4083 precise = true;
4084 }
4085 }
4086 }
4087 }
4088 if !precise {
4089 batch.table(&op.table);
4090 }
4091 }
4092 self.persist_outbox_insert(&commit);
4093 let id = commit.client_commit_id.clone();
4094 self.outbox.push(commit);
4095 self.overlay_dirty.set(true);
4096 self.rebuild_overlay();
4097 batch.status = true;
4098 if let Err(error) = self.finish_observation("syncular_mutation", batch) {
4099 self.rollback_observation("syncular_mutation");
4100 return Err(error);
4101 }
4102 Ok(id)
4103 }
4104
4105 pub fn patch(
4110 &mut self,
4111 table: &str,
4112 row_id: &str,
4113 partial: Map<String, Value>,
4114 base_version: Option<i64>,
4115 ) -> Result<String, String> {
4116 let schema_table = self
4117 .schema
4118 .table(table)
4119 .ok_or_else(|| format!("unknown table {table:?}"))?;
4120 let partial = normalize_values_casing(schema_table, partial)?;
4121 let mut values = self
4122 .read_rows(table)?
4123 .into_iter()
4124 .find(|row| row.row_id == row_id)
4125 .map(|row| row.values)
4126 .ok_or_else(|| {
4127 format!(
4128 "sync.invalid_request: table {table:?} has no local row with primary key {row_id:?} to patch"
4129 )
4130 })?;
4131 let mut changed_fields = partial.keys().cloned().collect::<Vec<_>>();
4132 changed_fields.sort();
4133 values.extend(partial);
4134 let row_id_from_values = render_row_id_json(values.get(&schema_table.primary_key))?;
4135 if row_id_from_values != row_id {
4136 return Err("sync.invalid_request: patch cannot change the primary key".to_owned());
4137 }
4138 encode_row_json(schema_table, row_id, &values, &self.encryption)?;
4139 self.record_outbox_commit(vec![OutboxOp {
4140 upsert: true,
4141 table: table.to_owned(),
4142 row_id: row_id.to_owned(),
4143 base_version,
4144 values: Some(values),
4145 changed_fields: Some(changed_fields),
4146 }])
4147 }
4148
4149 pub fn pending_commit_ids(&self) -> Vec<String> {
4150 self.outbox
4151 .iter()
4152 .map(|c| c.client_commit_id.clone())
4153 .collect()
4154 }
4155
4156 pub fn conflicts(&self) -> &[ConflictRecord] {
4157 &self.conflicts
4158 }
4159
4160 pub fn rejections(&self) -> &[RejectionRecord] {
4161 &self.rejections
4162 }
4163
4164 pub fn resolve_commit_outcome(
4165 &mut self,
4166 input: ResolveCommitOutcomeInput,
4167 ) -> Result<CommitOutcome, String> {
4168 let current = self
4169 .commit_outcome(&input.client_commit_id)?
4170 .ok_or_else(|| {
4171 format!(
4172 "sync.outcome_not_found: no durable outcome exists for {:?}",
4173 input.client_commit_id
4174 )
4175 })?;
4176 if current.resolution != CommitOutcomeResolution::Active {
4177 return Ok(current);
4178 }
4179 if input.resolution == CommitOutcomeResolution::Active {
4180 return Err("sync.invalid_request: resolution must leave active state".to_owned());
4181 }
4182 match input.resolution {
4183 CommitOutcomeResolution::Superseded => {
4184 let replacement = input
4185 .replacement_client_commit_id
4186 .as_deref()
4187 .filter(|value| !value.is_empty() && *value != input.client_commit_id)
4188 .ok_or_else(|| {
4189 "sync.invalid_request: superseded outcomes require a distinct replacementClientCommitId"
4190 .to_owned()
4191 })?;
4192 let _ = replacement;
4193 }
4194 _ if input.replacement_client_commit_id.is_some() => {
4195 return Err(
4196 "sync.invalid_request: replacementClientCommitId is valid only for superseded outcomes"
4197 .to_owned(),
4198 );
4199 }
4200 _ => {}
4201 }
4202 let allowed = match current.status {
4203 CommitOutcomeStatus::Conflict => matches!(
4204 input.resolution,
4205 CommitOutcomeResolution::ResolvedKeepServer | CommitOutcomeResolution::Superseded
4206 ),
4207 CommitOutcomeStatus::Rejected => {
4208 input.resolution == CommitOutcomeResolution::Superseded
4209 }
4210 CommitOutcomeStatus::Applied | CommitOutcomeStatus::Cached => {
4211 input.resolution == CommitOutcomeResolution::Dismissed
4212 }
4213 };
4214 if !allowed {
4215 return Err(format!(
4216 "sync.invalid_request: resolution {:?} is invalid for {:?} outcome",
4217 input.resolution, current.status
4218 ));
4219 }
4220
4221 self.begin_observation("syncular_outcome_resolution")?;
4222 let result = (|| {
4223 self.conn
4224 .execute(
4225 "UPDATE _syncular_commit_outcomes
4226 SET resolution = ?1, resolved_at_ms = ?2,
4227 replacement_client_commit_id = ?3
4228 WHERE client_commit_id = ?4 AND resolution = 'active'",
4229 rusqlite::params![
4230 Self::outcome_resolution_name(input.resolution),
4231 self.clock_now_ms(),
4232 input.replacement_client_commit_id,
4233 input.client_commit_id
4234 ],
4235 )
4236 .map_err(|error| error.to_string())?;
4237 let resolved = self
4238 .commit_outcome(¤t.client_commit_id)?
4239 .ok_or_else(|| "sync.outcome_not_found: outcome disappeared".to_owned())?;
4240 self.prune_commit_outcomes()?;
4241 Ok(resolved)
4242 })();
4243 let resolved = match result {
4244 Ok(outcome) => outcome,
4245 Err(error) => {
4246 self.rollback_observation("syncular_outcome_resolution");
4247 return Err(error);
4248 }
4249 };
4250 self.conflicts
4251 .retain(|record| record.client_commit_id != current.client_commit_id);
4252 self.rejections
4253 .retain(|record| record.client_commit_id != current.client_commit_id);
4254 let batch = ChangeAccumulator {
4255 conflicts: current.status == CommitOutcomeStatus::Conflict,
4256 rejections: current.status == CommitOutcomeStatus::Rejected,
4257 outcomes: true,
4258 ..ChangeAccumulator::default()
4259 };
4260 if let Err(error) = self.finish_observation("syncular_outcome_resolution", batch) {
4261 self.rollback_observation("syncular_outcome_resolution");
4262 return Err(error);
4263 }
4264 Ok(resolved)
4265 }
4266
4267 pub fn schema_floor(&self) -> Option<&SchemaFloor> {
4268 self.schema_floor.as_ref()
4269 }
4270
4271 pub fn lease_state(&self) -> Option<&LeaseState> {
4273 self.lease_state.as_ref()
4274 }
4275
4276 fn record_lease_error(&mut self, code: &str) {
4279 if code != "sync.auth_lease_required" && code != "sync.auth_lease_revoked" {
4280 return;
4281 }
4282 let mut next = self.lease_state.clone().unwrap_or_default();
4283 next.error_code = Some(code.to_owned());
4284 self.set_lease_state(Some(next));
4285 }
4286
4287 fn set_lease_state(&mut self, next: Option<LeaseState>) {
4288 if self.lease_state == next {
4289 return;
4290 }
4291 if self.begin_observation("syncular_lease").is_err() {
4292 return;
4293 }
4294 self.lease_state = next;
4295 if let Some(lease) = &self.lease_state {
4296 if let Ok(json) = serde_json::to_string(lease) {
4297 self.set_meta(LEASE_STATE_KEY, &json);
4298 }
4299 } else {
4300 self.delete_meta(LEASE_STATE_KEY);
4301 }
4302 let batch = ChangeAccumulator {
4303 status: true,
4304 ..ChangeAccumulator::default()
4305 };
4306 if self.finish_observation("syncular_lease", batch).is_err() {
4307 self.rollback_observation("syncular_lease");
4308 }
4309 }
4310
4311 fn set_schema_floor(&mut self, next: Option<SchemaFloor>) {
4312 if self.schema_floor == next {
4313 return;
4314 }
4315 if self.begin_observation("syncular_schema_floor").is_err() {
4316 return;
4317 }
4318 self.schema_floor = next;
4319 self.stopped = self.schema_floor.is_some();
4320 if let Some(floor) = &self.schema_floor {
4321 if let Ok(json) = serde_json::to_string(floor) {
4322 self.set_meta(SCHEMA_FLOOR_KEY, &json);
4323 }
4324 } else {
4325 self.delete_meta(SCHEMA_FLOOR_KEY);
4326 }
4327 let batch = ChangeAccumulator {
4328 status: true,
4329 ..ChangeAccumulator::default()
4330 };
4331 if self
4332 .finish_observation("syncular_schema_floor", batch)
4333 .is_err()
4334 {
4335 self.rollback_observation("syncular_schema_floor");
4336 }
4337 }
4338
4339 fn set_upgrading(&mut self, value: bool) {
4340 if self.upgrading == value {
4341 return;
4342 }
4343 if self.begin_observation("syncular_upgrading").is_err() {
4344 return;
4345 }
4346 self.upgrading = value;
4347 let batch = ChangeAccumulator {
4348 status: true,
4349 ..ChangeAccumulator::default()
4350 };
4351 if self
4352 .finish_observation("syncular_upgrading", batch)
4353 .is_err()
4354 {
4355 self.rollback_observation("syncular_upgrading");
4356 }
4357 }
4358
4359 pub fn sync_needed(&self) -> bool {
4360 self.sync_needed
4361 }
4362
4363 pub fn subscription_state(&self, id: &str) -> Option<SubscriptionStateView> {
4364 let sub = self.subs.iter().find(|s| s.id == id)?;
4365 Some(SubscriptionStateView {
4366 id: sub.id.clone(),
4367 table: sub.table.clone(),
4368 status: sub.state.name().to_owned(),
4369 cursor: sub.cursor,
4370 has_resume_token: sub.bootstrap_state.is_some(),
4371 effective_scopes: sub.effective.as_ref().map(|e| scope_map_to_json(e)),
4372 reason_code: sub.reason_code.clone(),
4373 })
4374 }
4375
4376 pub fn read_rows(&self, table: &str) -> Result<Vec<RowState>, String> {
4377 let schema_table = self
4378 .schema
4379 .table(table)
4380 .ok_or_else(|| format!("unknown table {table:?}"))?;
4381 let sql = format!(
4382 "SELECT * FROM {} ORDER BY {} ASC",
4383 visible_table(table),
4384 quote_ident(&schema_table.primary_key)
4385 );
4386 let mut stmt = self.conn.prepare(&sql).map_err(|e| e.to_string())?;
4387 let mut rows = stmt.query([]).map_err(|e| e.to_string())?;
4388 let mut out = Vec::new();
4389 while let Some(row) = rows.next().map_err(|e| e.to_string())? {
4390 let mut values = Map::new();
4391 for (i, column) in schema_table.columns.iter().enumerate() {
4392 let value = row.get_ref(i).map_err(|e| e.to_string())?;
4393 values.insert(column.name.clone(), sql_ref_to_json(column, value));
4394 }
4395 let version: i64 = row
4396 .get(schema_table.columns.len())
4397 .map_err(|e| e.to_string())?;
4398 let row_id = match values.get(&schema_table.primary_key) {
4399 Some(Value::String(s)) => s.clone(),
4400 Some(Value::Number(n)) => n.to_string(),
4401 Some(Value::Bool(b)) => b.to_string(),
4402 other => format!("{}", other.cloned().unwrap_or(Value::Null)),
4403 };
4404 out.push(RowState {
4405 row_id,
4406 version,
4407 values,
4408 });
4409 }
4410 Ok(out)
4411 }
4412
4413 #[cfg(feature = "crdt-yjs")]
4429 fn crdt_column_bytes(
4430 &self,
4431 table: &str,
4432 row_id: &str,
4433 column: &str,
4434 ) -> Result<Option<Vec<u8>>, String> {
4435 let schema_table = self
4436 .schema
4437 .table(table)
4438 .ok_or_else(|| format!("unknown table {table:?}"))?;
4439 let col = schema_table
4440 .columns
4441 .iter()
4442 .find(|c| c.name == column)
4443 .ok_or_else(|| format!("table {table:?} has no column {column:?}"))?;
4444 if col.ty != ColumnType::Crdt {
4445 return Err(format!("column {column:?} is not a crdt column (§5.10.1)"));
4446 }
4447 let sql = format!(
4448 "SELECT {} FROM {} WHERE CAST({} AS TEXT) = ?1",
4449 quote_ident(column),
4450 visible_table(table),
4451 quote_ident(&schema_table.primary_key)
4452 );
4453 let bytes: Option<Vec<u8>> = self
4454 .conn
4455 .query_row(&sql, rusqlite::params![row_id], |row| {
4456 row.get::<_, Option<Vec<u8>>>(0)
4457 })
4458 .map_err(|e| match e {
4459 rusqlite::Error::QueryReturnedNoRows => "no such row".to_owned(),
4460 other => other.to_string(),
4461 })?;
4462 Ok(bytes)
4463 }
4464
4465 #[cfg(feature = "crdt-yjs")]
4469 pub fn crdt_text(
4470 &self,
4471 table: &str,
4472 row_id: &str,
4473 column: &str,
4474 name: &str,
4475 ) -> Result<String, String> {
4476 let bytes = self
4477 .crdt_column_bytes(table, row_id, column)?
4478 .unwrap_or_default();
4479 crate::crdt::text(&bytes, name)
4480 }
4481
4482 #[cfg(feature = "crdt-yjs")]
4486 pub fn crdt_insert_text(
4487 &mut self,
4488 table: &str,
4489 row_id: &str,
4490 column: &str,
4491 name: &str,
4492 index: u32,
4493 value: &str,
4494 ) -> Result<String, String> {
4495 let current = self
4496 .crdt_column_bytes(table, row_id, column)?
4497 .unwrap_or_default();
4498 let update = crate::crdt::insert_text(¤t, name, index, value)?;
4499 self.crdt_push_update(table, row_id, column, &update)
4500 }
4501
4502 #[cfg(feature = "crdt-yjs")]
4505 pub fn crdt_delete_text(
4506 &mut self,
4507 table: &str,
4508 row_id: &str,
4509 column: &str,
4510 name: &str,
4511 index: u32,
4512 len: u32,
4513 ) -> Result<String, String> {
4514 let current = self
4515 .crdt_column_bytes(table, row_id, column)?
4516 .unwrap_or_default();
4517 let update = crate::crdt::delete_text(¤t, name, index, len)?;
4518 self.crdt_push_update(table, row_id, column, &update)
4519 }
4520
4521 #[cfg(feature = "crdt-yjs")]
4526 pub fn crdt_apply_update(
4527 &mut self,
4528 table: &str,
4529 row_id: &str,
4530 column: &str,
4531 update: &[u8],
4532 ) -> Result<String, String> {
4533 let current = self
4534 .crdt_column_bytes(table, row_id, column)?
4535 .unwrap_or_default();
4536 let next = crate::crdt::apply_update(¤t, update)?;
4537 self.crdt_push_update(table, row_id, column, &next)
4538 }
4539
4540 #[cfg(feature = "crdt-yjs")]
4546 fn crdt_push_update(
4547 &mut self,
4548 table: &str,
4549 row_id: &str,
4550 column: &str,
4551 crdt_bytes: &[u8],
4552 ) -> Result<String, String> {
4553 let schema_table = self
4554 .schema
4555 .table(table)
4556 .ok_or_else(|| format!("unknown table {table:?}"))?
4557 .clone();
4558 let mut values: Map<String, Value> = self
4561 .read_rows(table)?
4562 .into_iter()
4563 .find(|r| r.row_id == row_id)
4564 .map(|r| r.values)
4565 .unwrap_or_else(|| {
4566 let mut map = Map::new();
4567 map.insert(
4568 schema_table.primary_key.clone(),
4569 Value::from(row_id.to_owned()),
4570 );
4571 map
4572 });
4573 let mut bytes_obj = Map::new();
4575 bytes_obj.insert("$bytes".to_owned(), Value::from(bytes_to_hex(crdt_bytes)));
4576 values.insert(column.to_owned(), Value::Object(bytes_obj));
4577 self.mutate(vec![Mutation::Upsert {
4578 table: table.to_owned(),
4579 values,
4580 base_version: None,
4581 }])
4582 }
4583
4584 pub fn query(&self, sql: &str, params: &[QueryValue]) -> Result<Vec<QueryRow>, String> {
4599 query_connection(&self.conn, sql, params)
4600 }
4601
4602 pub fn query_snapshot(
4604 &mut self,
4605 sql: &str,
4606 params: &[QueryValue],
4607 coverage: &[WindowCoverage],
4608 ) -> Result<QuerySnapshot, String> {
4609 snapshot_connection(&self.conn, sql, params, coverage)
4610 }
4611
4612 fn build_request(&self, url_capable: bool) -> (Message, RequestMeta) {
4615 let mut frames = vec![Frame::ReqHeader {
4616 client_id: self.client_id.clone(),
4617 schema_version: self.schema.version,
4618 }];
4619 let mut pushed_ids = Vec::new();
4620 let mut ops_in_request = 0usize;
4621 let mut deferred_commits = 0usize;
4622 for (index, commit) in self.outbox.iter().enumerate() {
4623 if ops_in_request > 0 && ops_in_request + commit.ops.len() > PUSH_OPS_PER_REQUEST {
4628 deferred_commits = self.outbox.len() - index;
4629 break;
4630 }
4631 ops_in_request += commit.ops.len();
4632 let operations = commit
4633 .ops
4634 .iter()
4635 .map(|op| {
4636 let payload = op.values.as_ref().and_then(|values| {
4637 let table = self.schema.table(&op.table)?;
4638 encode_row_json(table, &op.row_id, values, &self.encryption).ok()
4642 });
4643 ssp2::model::Operation {
4644 table: op.table.clone(),
4645 row_id: op.row_id.clone(),
4646 op: if op.upsert { Op::Upsert } else { Op::Delete },
4647 base_version: op.base_version,
4648 payload,
4649 }
4650 })
4651 .collect();
4652 frames.push(Frame::PushCommit {
4653 client_commit_id: commit.client_commit_id.clone(),
4654 operations,
4655 });
4656 pushed_ids.push(commit.client_commit_id.clone());
4657 }
4658 let accept = self.limits.accept.unwrap_or(if url_capable {
4661 DEFAULT_ACCEPT | ACCEPT_SIGNED_URLS
4662 } else {
4663 DEFAULT_ACCEPT
4664 });
4665 frames.push(Frame::PullHeader {
4666 limit_commits: self.limits.limit_commits.unwrap_or(0),
4667 limit_snapshot_rows: self.limits.limit_snapshot_rows.unwrap_or(0),
4668 max_snapshot_pages: self.limits.max_snapshot_pages.unwrap_or(0),
4669 accept,
4670 });
4671 let mut fresh = Vec::new();
4672 for sub in &self.subs {
4673 if sub.state != SubState::Active {
4674 continue;
4675 }
4676 let mut scopes = sub.requested.clone();
4677 sort_scope_map(&mut scopes);
4678 frames.push(Frame::Subscription {
4679 id: sub.id.clone(),
4680 table: sub.table.clone(),
4681 scopes,
4682 params: sub.params.clone().map(RawJson),
4683 cursor: sub.cursor,
4684 bootstrap_state: sub.bootstrap_state.clone().map(RawJson),
4685 });
4686 fresh.push((
4687 sub.id.clone(),
4688 sub.cursor < 0 && sub.bootstrap_state.is_none(),
4689 ));
4690 }
4691 let message = Message {
4692 msg_kind: MsgKind::Request,
4693 frames,
4694 };
4695 (
4696 message,
4697 RequestMeta {
4698 pushed_ids,
4699 fresh,
4700 accept,
4701 deferred_commits,
4702 },
4703 )
4704 }
4705
4706 pub fn sync(&mut self, transport: &mut dyn Transport) -> SyncOutcome {
4709 let started_at_ms = self.clock_now_ms();
4710 let outcome = self.sync_inner(transport);
4711 let completed_at_ms = self.clock_now_ms();
4712 self.last_round = Some(match &outcome {
4713 SyncOutcome::Ok(report) => DiagnosticLastRound {
4714 status: "succeeded".to_owned(),
4715 started_at_ms,
4716 completed_at_ms,
4717 duration_ms: completed_at_ms.saturating_sub(started_at_ms).max(0),
4718 counters: Some(DiagnosticRoundCounters {
4719 pushed: report.pushed,
4720 applied: report.applied.len(),
4721 rejected: report.rejected.len(),
4722 retryable: report.retryable.len(),
4723 conflicts: report.conflicts,
4724 commits_applied: report.commits_applied,
4725 segment_rows_applied: report.segment_rows_applied,
4726 bootstrapping: report.bootstrapping.len(),
4727 resets: report.resets.len(),
4728 revoked: report.revoked.len(),
4729 failed: report.failed.len(),
4730 deferred_commits: report.deferred_commits,
4731 }),
4732 error_code: None,
4733 },
4734 SyncOutcome::Failed { error_code, .. } => DiagnosticLastRound {
4735 status: "failed".to_owned(),
4736 started_at_ms,
4737 completed_at_ms,
4738 duration_ms: completed_at_ms.saturating_sub(started_at_ms).max(0),
4739 counters: None,
4740 error_code: Some(Self::diagnostic_code(error_code)),
4741 },
4742 });
4743 outcome
4744 }
4745
4746 fn sync_inner(&mut self, transport: &mut dyn Transport) -> SyncOutcome {
4747 if self.stopped {
4748 return SyncOutcome::Ok(SyncReport {
4751 schema_floor: self.schema_floor.clone(),
4752 ..SyncReport::default()
4753 });
4754 }
4755 self.set_sync_needed(false, false);
4758 if self.schema_has_blobs() {
4761 if let Err(TransportError { code, message }) = self.flush_blob_uploads(transport) {
4762 if Self::retryable_transport_code(&code) {
4763 self.schedule_background_retry();
4764 }
4765 return SyncOutcome::Failed {
4766 error_code: code,
4767 message,
4768 };
4769 }
4770 }
4771 let (message, meta) = self.build_request(transport.supports_url_fetch());
4772 let request_bytes = encode_message(&message);
4773 let round = if self.realtime_connected {
4777 transport.realtime_sync(&request_bytes)
4778 } else {
4779 transport.sync(&request_bytes)
4780 };
4781 let response_bytes = match round {
4782 Ok(bytes) => bytes,
4783 Err(TransportError { code, message }) => {
4784 self.record_lease_error(&code);
4787 if Self::retryable_transport_code(&code) {
4788 self.schedule_background_retry();
4789 }
4790 return SyncOutcome::Failed {
4791 error_code: code,
4792 message,
4793 };
4794 }
4795 };
4796 let response = match decode_message(&response_bytes) {
4797 Ok(message) => message,
4798 Err(error) => {
4799 return SyncOutcome::Failed {
4802 error_code: error.code.as_str().to_owned(),
4803 message: error.detail,
4804 };
4805 }
4806 };
4807 if response.msg_kind != MsgKind::Response {
4808 return SyncOutcome::Failed {
4809 error_code: "sync.invalid_request".to_owned(),
4810 message: "expected a response message".to_owned(),
4811 };
4812 }
4813 let mut outcome = self.process_response(transport, response, &meta);
4814 if let SyncOutcome::Ok(report) = &mut outcome {
4815 report.deferred_commits = meta.deferred_commits;
4816 }
4817 match &outcome {
4818 SyncOutcome::Ok(_) => self.reset_background_retry(),
4819 SyncOutcome::Failed { error_code, .. }
4820 if Self::retryable_transport_code(error_code) =>
4821 {
4822 self.schedule_background_retry();
4823 }
4824 SyncOutcome::Failed { .. } => {}
4825 }
4826 if meta.deferred_commits > 0 {
4827 self.set_sync_needed(true, true);
4830 }
4831 outcome
4832 }
4833
4834 pub fn sync_until_idle(
4835 &mut self,
4836 transport: &mut dyn Transport,
4837 max_rounds: Option<u32>,
4838 ) -> SyncOutcome {
4839 let rounds = max_rounds.unwrap_or(12).max(1);
4840 let mut aggregate = SyncReport::default();
4841 for _ in 0..rounds {
4842 match self.sync(transport) {
4843 SyncOutcome::Failed {
4844 error_code,
4845 message,
4846 } => {
4847 return SyncOutcome::Failed {
4848 error_code,
4849 message,
4850 };
4851 }
4852 SyncOutcome::Ok(report) => {
4853 aggregate.pushed += report.pushed;
4854 aggregate.applied.extend(report.applied.iter().cloned());
4855 aggregate.rejected.extend(report.rejected.iter().cloned());
4856 aggregate.retryable.extend(report.retryable.iter().cloned());
4857 aggregate.conflicts += report.conflicts;
4858 aggregate.commits_applied += report.commits_applied;
4859 aggregate.segment_rows_applied += report.segment_rows_applied;
4860 aggregate.bootstrapping = report.bootstrapping.clone();
4861 aggregate.resets.extend(report.resets.iter().cloned());
4862 aggregate.revoked.extend(report.revoked.iter().cloned());
4863 aggregate.failed.extend(report.failed.iter().cloned());
4864 aggregate.deferred_commits = report.deferred_commits;
4865 if report.schema_floor.is_some() {
4866 aggregate.schema_floor = report.schema_floor.clone();
4867 }
4868 let more = !report.bootstrapping.is_empty()
4874 || report.commits_applied > 0
4875 || report.segment_rows_applied > 0
4876 || !report.resets.is_empty()
4877 || self.sync_needed;
4878 if !more {
4879 break;
4880 }
4881 }
4882 }
4883 }
4884 SyncOutcome::Ok(aggregate)
4885 }
4886
4887 fn process_response(
4888 &mut self,
4889 transport: &mut dyn Transport,
4890 response: Message,
4891 meta: &RequestMeta,
4892 ) -> SyncOutcome {
4893 let mut report = SyncReport {
4894 pushed: meta.pushed_ids.len() as u32,
4895 ..SyncReport::default()
4896 };
4897 let mut rejection_details_by_commit: HashMap<String, BTreeMap<i32, RejectionDetails>> =
4898 HashMap::new();
4899 let pushed_ids = meta
4900 .pushed_ids
4901 .iter()
4902 .map(String::as_str)
4903 .collect::<HashSet<_>>();
4904 let mut last_final_push_result_id: Option<String> = None;
4905 for frame in &response.frames {
4906 match frame {
4907 Frame::PushResultDetails {
4908 client_commit_id,
4909 entries,
4910 } => {
4911 let details = rejection_details_by_commit
4912 .entry(client_commit_id.clone())
4913 .or_default();
4914 for entry in entries {
4915 let parsed = match RejectionDetails::parse(&entry.details.0) {
4916 Ok(value) => value,
4917 Err(message) => {
4918 return SyncOutcome::Failed {
4919 error_code: "sync.invalid_request".to_owned(),
4920 message,
4921 };
4922 }
4923 };
4924 details.insert(entry.op_index, parsed);
4925 }
4926 }
4927 Frame::PushResult {
4928 client_commit_id,
4929 status,
4930 results,
4931 ..
4932 } if pushed_ids.contains(client_commit_id.as_str())
4933 && Self::push_result_is_final(*status, results) =>
4934 {
4935 last_final_push_result_id = Some(client_commit_id.clone());
4936 }
4937 _ => {}
4938 }
4939 }
4940 let mut frames = response.frames.into_iter();
4941 match frames.next() {
4942 Some(Frame::RespHeader {
4943 required_schema_version,
4944 latest_schema_version,
4945 }) => {
4946 if let Some(required) = required_schema_version {
4947 let floor = SchemaFloor {
4950 required_schema_version: Some(required),
4951 latest_schema_version,
4952 };
4953 self.set_schema_floor(Some(floor.clone()));
4954 report.schema_floor = Some(floor);
4955 return SyncOutcome::Ok(report);
4956 }
4957 }
4958 _ => {
4959 return SyncOutcome::Failed {
4960 error_code: "sync.invalid_request".to_owned(),
4961 message: "response does not start with RESP_HEADER".to_owned(),
4962 };
4963 }
4964 }
4965
4966 let mut failure: Option<(String, String)> = None;
4967 while let Some(frame) = frames.next() {
4968 match frame {
4969 Frame::PushResult {
4970 client_commit_id,
4971 status,
4972 commit_seq: _,
4973 results,
4974 } => {
4975 let prune_outcomes =
4976 last_final_push_result_id.as_deref() == Some(&client_commit_id);
4977 self.handle_push_result(
4978 &client_commit_id,
4979 status,
4980 &results,
4981 rejection_details_by_commit.get(&client_commit_id),
4982 &mut report,
4983 prune_outcomes,
4984 );
4985 }
4986 Frame::PushResultDetails { .. } => {}
4987 Frame::SubStart {
4988 id,
4989 status,
4990 reason_code,
4991 effective_scopes,
4992 bootstrap: _,
4993 } => {
4994 let mut body = Vec::new();
4995 let mut sub_end: Option<(i64, Option<String>)> = None;
4996 for inner in frames.by_ref() {
4997 match inner {
4998 Frame::SubEnd {
4999 next_cursor,
5000 bootstrap_state,
5001 } => {
5002 sub_end = Some((next_cursor, bootstrap_state.map(|r| r.0)));
5003 break;
5004 }
5005 Frame::Unknown { .. } => {}
5006 other => body.push(other),
5007 }
5008 }
5009 let Some((next_cursor, bootstrap_state)) = sub_end else {
5010 failure = Some((
5011 "sync.invalid_request".to_owned(),
5012 "subscription section without SUB_END".to_owned(),
5013 ));
5014 break;
5015 };
5016 if let Err(SectionError::Abort(code, message)) = self.process_section(
5017 transport,
5018 &id,
5019 status,
5020 &reason_code,
5021 effective_scopes,
5022 body,
5023 next_cursor,
5024 bootstrap_state,
5025 meta,
5026 &mut report,
5027 ) {
5028 failure = Some((code, message));
5029 break;
5030 }
5031 }
5032 Frame::Lease {
5033 lease_id,
5034 expires_at_ms,
5035 } => {
5036 self.set_lease_state(Some(LeaseState {
5039 lease_id: Some(lease_id),
5040 expires_at_ms: Some(expires_at_ms),
5041 error_code: None,
5042 }));
5043 }
5044 Frame::Error { code, message, .. } => {
5045 failure = Some((code, message));
5048 break;
5049 }
5050 Frame::Unknown { .. } => {}
5051 _ => {
5052 failure = Some((
5053 "sync.invalid_request".to_owned(),
5054 "unexpected frame in response".to_owned(),
5055 ));
5056 break;
5057 }
5058 }
5059 }
5060
5061 if self.overlay_dirty.get() {
5066 self.rebuild_overlay();
5067 }
5068 self.reconcile_blob_refcounts(false);
5075
5076 if let Some((error_code, message)) = failure {
5077 return SyncOutcome::Failed {
5078 error_code,
5079 message,
5080 };
5081 }
5082 self.drain_pending_evictions();
5085 self.ack_after_pull(transport);
5086 if self.upgrading && report.bootstrapping.is_empty() {
5089 self.set_upgrading(false);
5090 }
5091 SyncOutcome::Ok(report)
5092 }
5093
5094 fn handle_push_result(
5097 &mut self,
5098 client_commit_id: &str,
5099 status: PushStatus,
5100 results: &[OpResult],
5101 rejection_details: Option<&BTreeMap<i32, RejectionDetails>>,
5102 report: &mut SyncReport,
5103 prune_outcomes: bool,
5104 ) {
5105 let Some(index) = self
5106 .outbox
5107 .iter()
5108 .position(|c| c.client_commit_id == client_commit_id)
5109 else {
5110 return;
5111 };
5112 if self.begin_observation("syncular_push_result").is_err() {
5113 return;
5114 }
5115 let mut batch = ChangeAccumulator::default();
5116 let operations = self.outbox[index].ops.clone();
5117 match status {
5118 PushStatus::Applied | PushStatus::Cached => {
5119 let journal_results = results
5122 .iter()
5123 .map(|result| {
5124 let op_index = match result {
5125 OpResult::Applied { op_index }
5126 | OpResult::Conflict { op_index, .. }
5127 | OpResult::Error { op_index, .. } => *op_index,
5128 };
5129 CommitOperationOutcome::Applied { op_index }
5130 })
5131 .collect::<Vec<_>>();
5132 let outcome_status = if status == PushStatus::Applied {
5133 CommitOutcomeStatus::Applied
5134 } else {
5135 CommitOutcomeStatus::Cached
5136 };
5137 let persisted = self
5138 .persist_commit_outcome(
5139 client_commit_id,
5140 outcome_status,
5141 &journal_results,
5142 None,
5143 )
5144 .and_then(|()| self.delete_outbox_persisted(client_commit_id));
5145 let persisted = if prune_outcomes {
5146 persisted.and_then(|()| self.prune_commit_outcomes())
5147 } else {
5148 persisted
5149 };
5150 if persisted.is_err() {
5151 self.rollback_observation("syncular_push_result");
5152 return;
5153 }
5154 report.applied.push(client_commit_id.to_owned());
5155 self.outbox.remove(index);
5156 self.overlay_dirty.set(true);
5157 batch.status = true;
5158 batch.outcomes = true;
5159 }
5160 PushStatus::Rejected => {
5161 if results.iter().any(|result| {
5162 matches!(
5163 result,
5164 OpResult::Error {
5165 code,
5166 retryable: true,
5167 ..
5168 } if code == "sync.idempotency_cache_miss"
5169 )
5170 }) {
5171 report.retryable.push(client_commit_id.to_owned());
5174 if self
5175 .finish_observation("syncular_push_result", batch)
5176 .is_err()
5177 {
5178 self.rollback_observation("syncular_push_result");
5179 }
5180 return;
5181 }
5182
5183 let mut journal_results = Vec::with_capacity(results.len());
5184 let mut conflicts = Vec::new();
5185 let mut rejections = Vec::new();
5186 for result in results {
5187 match result {
5188 OpResult::Applied { op_index } => {
5189 journal_results.push(CommitOperationOutcome::Applied {
5190 op_index: *op_index,
5191 });
5192 }
5193 OpResult::Conflict {
5194 op_index,
5195 code,
5196 message,
5197 server_version,
5198 server_row,
5199 } => {
5200 let operation = operations
5201 .get(*op_index as usize)
5202 .map(CommitOperation::from);
5203 let (table, row_id) = operation
5204 .as_ref()
5205 .map(|op| (op.table.clone(), op.row_id.clone()))
5206 .unwrap_or_default();
5207 let server_row_json = self
5208 .schema
5209 .table(&table)
5210 .and_then(|t| {
5211 decode_row_bytes(t, server_row, &self.encryption)
5212 .ok()
5213 .map(|row| (t, row))
5214 })
5215 .map(|(t, row)| {
5216 let mut map = Map::new();
5217 for (i, column) in t.columns.iter().enumerate() {
5218 map.insert(
5219 column.name.clone(),
5220 column_value_to_json(row.get(i).unwrap_or(&None)),
5221 );
5222 }
5223 map
5224 })
5225 .unwrap_or_default();
5226 let conflict = ConflictRecord {
5227 client_commit_id: client_commit_id.to_owned(),
5228 op_index: *op_index,
5229 table,
5230 row_id,
5231 code: code.clone(),
5232 message: message.clone(),
5233 server_version: *server_version,
5234 server_row: server_row_json,
5235 operation,
5236 };
5237 journal_results.push(CommitOperationOutcome::Conflict {
5238 conflict: conflict.clone(),
5239 });
5240 conflicts.push(conflict);
5241 }
5242 OpResult::Error {
5243 op_index,
5244 code,
5245 message,
5246 retryable,
5247 } => {
5248 let rejection = RejectionRecord {
5249 client_commit_id: client_commit_id.to_owned(),
5250 op_index: *op_index,
5251 code: code.clone(),
5252 message: message.clone(),
5253 retryable: *retryable,
5254 details: rejection_details
5255 .and_then(|details| details.get(op_index))
5256 .cloned(),
5257 operation: operations
5258 .get(*op_index as usize)
5259 .map(CommitOperation::from),
5260 };
5261 journal_results.push(CommitOperationOutcome::Error {
5262 rejection: rejection.clone(),
5263 });
5264 rejections.push(rejection);
5265 }
5266 }
5267 }
5268 let outcome_status = if conflicts.is_empty() {
5269 CommitOutcomeStatus::Rejected
5270 } else {
5271 CommitOutcomeStatus::Conflict
5272 };
5273 let persisted = self
5274 .persist_commit_outcome(
5275 client_commit_id,
5276 outcome_status,
5277 &journal_results,
5278 Some(&operations),
5279 )
5280 .and_then(|()| self.delete_outbox_persisted(client_commit_id));
5281 let persisted = if prune_outcomes {
5282 persisted.and_then(|()| self.prune_commit_outcomes())
5283 } else {
5284 persisted
5285 };
5286 if persisted.is_err() {
5287 self.rollback_observation("syncular_push_result");
5288 return;
5289 }
5290 report.conflicts += conflicts.len() as u32;
5291 report.rejected.push(client_commit_id.to_owned());
5292 batch.conflicts = !conflicts.is_empty();
5293 batch.rejections = !rejections.is_empty();
5294 batch.status = true;
5295 batch.outcomes = true;
5296 self.conflicts.extend(conflicts);
5297 self.rejections.extend(rejections);
5298 self.outbox.remove(index);
5299 self.overlay_dirty.set(true);
5300 }
5301 }
5302 if batch.status {
5303 for operation in &operations {
5304 if !self.record_row_scopes(&mut batch, &operation.table, &operation.row_id, false) {
5305 batch.table(&operation.table);
5306 }
5307 }
5308 }
5309 if self
5310 .finish_observation("syncular_push_result", batch)
5311 .is_err()
5312 {
5313 self.rollback_observation("syncular_push_result");
5314 }
5315 }
5316
5317 fn push_result_is_final(status: PushStatus, results: &[OpResult]) -> bool {
5318 status != PushStatus::Rejected
5319 || !results.iter().any(|result| {
5320 matches!(
5321 result,
5322 OpResult::Error {
5323 code,
5324 retryable: true,
5325 ..
5326 } if code == "sync.idempotency_cache_miss"
5327 )
5328 })
5329 }
5330
5331 #[allow(clippy::too_many_arguments)]
5334 fn process_section(
5335 &mut self,
5336 transport: &mut dyn Transport,
5337 id: &str,
5338 status: SubStatus,
5339 reason_code: &str,
5340 effective_scopes: Vec<(String, Vec<String>)>,
5341 body: Vec<Frame>,
5342 next_cursor: i64,
5343 bootstrap_state: Option<String>,
5344 meta: &RequestMeta,
5345 report: &mut SyncReport,
5346 ) -> Result<(), SectionError> {
5347 let Some(sub_index) = self.subs.iter().position(|s| s.id == id) else {
5348 return Ok(()); };
5350 match status {
5351 SubStatus::Revoked => {
5352 self.begin_observation("syncular_revocation")
5353 .map_err(|message| SectionError::Abort("storage.failed".to_owned(), message))?;
5354 let mut batch = ChangeAccumulator::default();
5355 let registered = self.window_unit_by_sub_id(id);
5356 let (table, effective) = {
5358 let sub = &self.subs[sub_index];
5359 (sub.table.clone(), sub.effective.clone().unwrap_or_default())
5360 };
5361 let purged = self.purge_scope_rows(&table, &effective);
5362 match purged {
5363 Ok(()) => {
5364 self.record_scope_map(&mut batch, &table, &effective);
5365 let sub = &mut self.subs[sub_index];
5366 sub.state = SubState::Revoked;
5367 sub.reason_code = Some(if reason_code.is_empty() {
5368 "sync.scope_revoked".to_owned()
5369 } else {
5370 reason_code.to_owned()
5371 });
5372 report.revoked.push(id.to_owned());
5373 let doomed_effective = effective;
5374 let sub_table = table;
5375 self.persist_sub(&self.subs[sub_index].clone());
5376 let dropped = self
5377 .drop_doomed_outbox(&sub_table, &doomed_effective)
5378 .map_err(|message| {
5379 SectionError::Abort("storage.failed".to_owned(), message)
5380 })?;
5381 if dropped {
5382 batch.status = true;
5383 batch.rejections = true;
5384 batch.outcomes = true;
5385 }
5386 self.reconcile_blob_refcounts(true);
5389 }
5390 Err(()) => {
5391 let sub = &mut self.subs[sub_index];
5394 sub.state = SubState::Failed;
5395 sub.reason_code = Some("sync.scope_revoked".to_owned());
5396 report.failed.push(id.to_owned());
5397 self.persist_sub(&self.subs[sub_index].clone());
5398 }
5399 }
5400 if let Some((base_key, unit)) = registered {
5401 batch.window(&base_key, &self.subs[sub_index].table, &unit);
5402 }
5403 self.rebuild_overlay_if_dirty();
5404 self.finish_observation("syncular_revocation", batch)
5405 .map_err(|message| SectionError::Abort("storage.failed".to_owned(), message))?;
5406 Ok(())
5407 }
5408 SubStatus::Reset => {
5409 self.begin_observation("syncular_reset")
5410 .map_err(|message| SectionError::Abort("storage.failed".to_owned(), message))?;
5411 let mut batch = ChangeAccumulator::default();
5412 let registered = self.window_unit_by_sub_id(id);
5413 let sub = &mut self.subs[sub_index];
5416 sub.cursor = -1;
5417 sub.bootstrap_state = None;
5418 report.resets.push(id.to_owned());
5419 self.persist_sub(&self.subs[sub_index].clone());
5420 if let Some((base_key, unit)) = registered {
5421 batch.window(&base_key, &self.subs[sub_index].table, &unit);
5422 }
5423 self.finish_observation("syncular_reset", batch)
5424 .map_err(|message| SectionError::Abort("storage.failed".to_owned(), message))?;
5425 Ok(())
5426 }
5427 SubStatus::Active => {
5428 let fresh = meta
5429 .fresh
5430 .iter()
5431 .find(|(fid, _)| fid == id)
5432 .map(|(_, f)| *f)
5433 .unwrap_or(false);
5434 let was_pending = self.subs[sub_index].cursor < 0
5435 || self.subs[sub_index].bootstrap_state.is_some();
5436 let registered = self.window_unit_by_sub_id(id);
5437 self.subs[sub_index].effective = Some(effective_scopes);
5439 self.begin_observation("syncular_section")
5440 .map_err(|message| SectionError::Abort("storage.failed".to_owned(), message))?;
5441 let mut batch = ChangeAccumulator::default();
5442 let outcome = self.apply_section_body(
5443 transport, sub_index, body, fresh, meta, report, &mut batch,
5444 );
5445 match outcome {
5446 Ok(()) => {
5447 let sub = &mut self.subs[sub_index];
5448 sub.cursor = next_cursor;
5451 sub.bootstrap_state = bootstrap_state;
5452 sub.synced_once = true;
5453 if sub.bootstrap_state.is_some() {
5454 report.bootstrapping.push(id.to_owned());
5455 }
5456 let completed =
5457 was_pending && sub.cursor >= 0 && sub.bootstrap_state.is_none();
5458 self.persist_sub(&self.subs[sub_index].clone());
5459 if completed {
5460 if let Some((base_key, unit)) = registered.clone() {
5461 batch.window(&base_key, &self.subs[sub_index].table, &unit);
5462 }
5463 }
5464 self.rebuild_overlay_if_dirty();
5465 self.finish_observation("syncular_section", batch)
5466 .map_err(|message| {
5467 SectionError::Abort("storage.failed".to_owned(), message)
5468 })?;
5469 Ok(())
5470 }
5471 Err(SectionError::FailClosed) => {
5472 self.rollback_observation("syncular_section");
5475 self.begin_observation("syncular_section_failure")
5476 .map_err(|message| {
5477 SectionError::Abort("storage.failed".to_owned(), message)
5478 })?;
5479 let mut failure_batch = ChangeAccumulator::default();
5480 let sub = &mut self.subs[sub_index];
5481 sub.state = SubState::Failed;
5482 sub.reason_code = Some("sync.scope_revoked".to_owned());
5483 report.failed.push(id.to_owned());
5484 self.persist_sub(&self.subs[sub_index].clone());
5485 if let Some((base_key, unit)) = registered {
5486 failure_batch.window(&base_key, &self.subs[sub_index].table, &unit);
5487 }
5488 self.finish_observation("syncular_section_failure", failure_batch)
5489 .map_err(|message| {
5490 SectionError::Abort("storage.failed".to_owned(), message)
5491 })?;
5492 Ok(())
5493 }
5494 Err(SectionError::Abort(code, message)) => {
5495 self.rollback_observation("syncular_section");
5498 Err(SectionError::Abort(code, message))
5499 }
5500 }
5501 }
5502 }
5503 }
5504
5505 #[allow(clippy::too_many_arguments)]
5509 fn apply_section_body(
5510 &mut self,
5511 transport: &mut dyn Transport,
5512 sub_index: usize,
5513 body: Vec<Frame>,
5514 fresh: bool,
5515 meta: &RequestMeta,
5516 report: &mut SyncReport,
5517 batch: &mut ChangeAccumulator,
5518 ) -> Result<(), SectionError> {
5519 let mut saw_segment = false;
5520 for frame in body {
5521 match frame {
5522 Frame::Commit {
5523 tables, changes, ..
5524 } => {
5525 self.record_commit_changes(batch, &tables, &changes);
5526 self.apply_commit_changes(&tables, &changes)
5527 .map_err(|(c, m)| SectionError::Abort(c, m))?;
5528 report.commits_applied += 1;
5529 }
5530 Frame::SegmentInline { payload } => {
5531 let segment = decode_rows_segment(&payload)
5532 .map_err(|e| SectionError::Abort(e.code.as_str().to_owned(), e.detail))?;
5533 let first = !saw_segment;
5534 saw_segment = true;
5535 let effective = self.subs[sub_index].effective.clone().unwrap_or_default();
5536 let cleared =
5537 fresh && first && self.scoped_rows_exist(&segment.table, &effective);
5538 let applied = self.apply_segment(sub_index, &segment, fresh && first)?;
5539 if applied > 0 || cleared {
5540 batch.table(&segment.table);
5541 }
5542 report.segment_rows_applied += applied;
5543 }
5544 Frame::SegmentRef {
5545 segment_id,
5546 media_type,
5547 table,
5548 row_count,
5549 as_of_commit_seq,
5550 scope_digest,
5551 row_cursor,
5552 next_row_cursor,
5553 url,
5554 url_expires_at_ms,
5555 ..
5556 } => {
5557 let advertised = match media_type {
5560 MediaType::Rows => {
5561 meta.accept & ACCEPT_EXTERNAL_ROWS != 0
5562 || meta.accept & ACCEPT_INLINE_ROWS != 0
5563 }
5564 MediaType::Sqlite => meta.accept & ACCEPT_SQLITE != 0,
5565 };
5566 if !advertised {
5567 return Err(SectionError::Abort(
5568 "sync.invalid_request".to_owned(),
5569 format!(
5570 "SEGMENT_REF mediaType {} was not advertised in accept (§4.2)",
5571 media_type.name()
5572 ),
5573 ));
5574 }
5575 let bytes = if let Some(url) = url {
5576 if meta.accept & ACCEPT_SIGNED_URLS == 0 {
5581 return Err(SectionError::Abort(
5582 "sync.invalid_request".to_owned(),
5583 "SEGMENT_REF carries a url but accept bit 3 was not advertised (§5.4)"
5584 .to_owned(),
5585 ));
5586 }
5587 if url_expires_at_ms.is_some_and(|exp| exp <= self.clock_now_ms()) {
5589 return Err(SectionError::Abort(
5590 "sync.segment_expired".to_owned(),
5591 format!(
5592 "signed URL for segment {segment_id} expired before fetch — re-pull mints fresh descriptors (§5.4)"
5593 ),
5594 ));
5595 }
5596 transport
5597 .fetch_url(&url)
5598 .map_err(|e| SectionError::Abort(e.code, e.message))?
5599 } else {
5600 let requested_scopes_json =
5601 canonical_scope_json(&self.subs[sub_index].requested);
5602 transport
5603 .download_segment(&SegmentRequest {
5604 segment_id: segment_id.clone(),
5605 table,
5606 requested_scopes_json,
5607 })
5608 .map_err(|e| SectionError::Abort(e.code, e.message))?
5609 };
5610 let digest = Sha256::digest(&bytes);
5612 let expected = segment_id
5613 .strip_prefix("sha256:")
5614 .unwrap_or(segment_id.as_str());
5615 if bytes_to_hex(&digest) != expected {
5616 return Err(SectionError::Abort(
5617 "sync.invalid_request".to_owned(),
5618 "segment bytes do not match the content address (§5.1)".to_owned(),
5619 ));
5620 }
5621 if media_type == MediaType::Sqlite {
5622 if row_cursor.is_some() || next_row_cursor.is_some() {
5625 return Err(SectionError::Abort(
5626 "sync.invalid_request".to_owned(),
5627 "sqlite segments are whole-table: rowCursor/nextRowCursor must be absent (§5.3)"
5628 .to_owned(),
5629 ));
5630 }
5631 let first = !saw_segment;
5632 saw_segment = true;
5633 let sub_table = self.subs[sub_index].table.clone();
5634 let effective = self.subs[sub_index].effective.clone().unwrap_or_default();
5635 let cleared =
5636 fresh && first && self.scoped_rows_exist(&sub_table, &effective);
5637 let applied = self.apply_sqlite_segment(
5638 sub_index,
5639 &bytes,
5640 fresh && first,
5641 row_count,
5642 as_of_commit_seq,
5643 &scope_digest,
5644 )?;
5645 if applied > 0 || cleared {
5646 batch.table(&sub_table);
5647 }
5648 report.segment_rows_applied += applied;
5649 } else {
5650 let segment = decode_rows_segment(&bytes).map_err(|e| {
5651 SectionError::Abort(e.code.as_str().to_owned(), e.detail)
5652 })?;
5653 let first = row_cursor.is_none();
5654 saw_segment = true;
5655 let effective = self.subs[sub_index].effective.clone().unwrap_or_default();
5656 let cleared =
5657 fresh && first && self.scoped_rows_exist(&segment.table, &effective);
5658 let applied = self.apply_segment(sub_index, &segment, fresh && first)?;
5659 if applied > 0 || cleared {
5660 batch.table(&segment.table);
5661 }
5662 report.segment_rows_applied += applied;
5663 }
5664 }
5665 Frame::Unknown { .. } => {}
5666 _ => {
5667 return Err(SectionError::Abort(
5668 "sync.invalid_request".to_owned(),
5669 "unexpected frame inside a subscription section".to_owned(),
5670 ));
5671 }
5672 }
5673 }
5674 Ok(())
5675 }
5676
5677 fn apply_commit_changes(
5678 &mut self,
5679 tables: &[String],
5680 changes: &[ssp2::model::Change],
5681 ) -> Result<(), (String, String)> {
5682 for change in changes {
5683 let table_name = tables.get(change.table_index as usize).ok_or_else(|| {
5684 (
5685 "sync.invalid_request".to_owned(),
5686 "change tableIndex out of range".to_owned(),
5687 )
5688 })?;
5689 let table = self.schema.table(table_name).ok_or_else(|| {
5690 (
5691 "sync.schema_mismatch".to_owned(),
5692 format!("change targets unknown table {table_name:?}"),
5693 )
5694 })?;
5695 match change.op {
5696 Op::Upsert => {
5697 let payload = change.row.as_ref().ok_or_else(|| {
5698 (
5699 "sync.invalid_request".to_owned(),
5700 "upsert change without row payload".to_owned(),
5701 )
5702 })?;
5703 let row = decode_row_bytes(table, payload, &self.encryption)
5705 .map_err(|m| ("sync.invalid_request".to_owned(), m))?;
5706 let version = change.row_version.unwrap_or(0);
5707 let table_name = table.name.clone();
5708 self.write_base_row(&table_name, &row, version)
5709 .map_err(|m| ("sync.invalid_request".to_owned(), m))?;
5710 }
5711 Op::Delete => {
5712 self.delete_base_row(table_name, &change.row_id)
5713 .map_err(|m| ("sync.invalid_request".to_owned(), m))?;
5714 }
5715 }
5716 }
5717 Ok(())
5718 }
5719
5720 fn apply_segment(
5725 &mut self,
5726 sub_index: usize,
5727 segment: &RowsSegment,
5728 first_fresh_page: bool,
5729 ) -> Result<u32, SectionError> {
5730 let (sub_table, effective) = {
5731 let sub = &self.subs[sub_index];
5732 (sub.table.clone(), sub.effective.clone().unwrap_or_default())
5733 };
5734 let table = self.schema.table(&sub_table).cloned().ok_or_else(|| {
5735 SectionError::Abort(
5736 "sync.schema_mismatch".to_owned(),
5737 format!("subscription table {sub_table:?} is not in the client schema"),
5738 )
5739 })?;
5740 let matches = segment.table == table.name
5745 && segment.schema_version == self.schema.version
5746 && segment.columns.len() == table.wire_columns.len()
5747 && segment
5748 .columns
5749 .iter()
5750 .zip(table.wire_columns.iter())
5751 .all(|(a, b)| a.name == b.name && a.ty == b.ty && a.nullable == b.nullable);
5752 if !matches {
5753 return Err(SectionError::Abort(
5754 "sync.schema_mismatch".to_owned(),
5755 "segment column table does not match the generated schema (§5.2)".to_owned(),
5756 ));
5757 }
5758 if first_fresh_page {
5759 self.purge_scope_rows(&table.name, &effective)
5763 .map_err(|()| SectionError::FailClosed)?;
5764 }
5765 let mut applied = 0u32;
5766 for block in &segment.blocks {
5767 for row in block {
5768 let decrypted;
5773 let values = if table.has_encrypted_columns() {
5774 let mut values = row.values.clone();
5775 crate::values::decrypt_segment_row(&table, &mut values, &self.encryption)
5776 .map_err(|m| SectionError::Abort("client.decrypt_failed".to_owned(), m))?;
5777 decrypted = values;
5778 &decrypted
5779 } else {
5780 &row.values
5781 };
5782 self.write_base_row(&table.name, values, row.server_version)
5785 .map_err(|m| SectionError::Abort("sync.invalid_request".to_owned(), m))?;
5786 applied += 1;
5787 }
5788 }
5789 Ok(applied)
5790 }
5791
5792 fn apply_sqlite_segment(
5800 &mut self,
5801 sub_index: usize,
5802 bytes: &[u8],
5803 first_fresh_page: bool,
5804 row_count: i64,
5805 as_of_commit_seq: i64,
5806 scope_digest: &str,
5807 ) -> Result<u32, SectionError> {
5808 let invalid = |detail: &str| {
5809 SectionError::Abort(
5810 "sync.invalid_request".to_owned(),
5811 format!("sqlite segment rejected: {detail} (§5.3)"),
5812 )
5813 };
5814 let (sub_table, effective) = {
5815 let sub = &self.subs[sub_index];
5816 (sub.table.clone(), sub.effective.clone().unwrap_or_default())
5817 };
5818 let table = self.schema.table(&sub_table).cloned().ok_or_else(|| {
5819 SectionError::Abort(
5820 "sync.schema_mismatch".to_owned(),
5821 format!("subscription table {sub_table:?} is not in the client schema"),
5822 )
5823 })?;
5824
5825 let path = std::env::temp_dir().join(format!("syncular-image-{}.db", uuid::Uuid::new_v4()));
5826 std::fs::write(&path, bytes).map_err(|_| invalid("image temp file write failed"))?;
5827 let img = match rusqlite::Connection::open_with_flags(
5828 &path,
5829 rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY,
5830 ) {
5831 Ok(conn) => conn,
5832 Err(_) => {
5833 let _ = std::fs::remove_file(&path);
5834 return Err(invalid("bytes do not open as a SQLite database"));
5835 }
5836 };
5837 let outcome = self.apply_sqlite_image(
5838 &img,
5839 &table,
5840 first_fresh_page,
5841 &effective,
5842 row_count,
5843 as_of_commit_seq,
5844 scope_digest,
5845 );
5846 drop(img);
5847 let _ = std::fs::remove_file(&path);
5848 outcome
5849 }
5850
5851 #[allow(clippy::too_many_arguments)]
5852 fn apply_sqlite_image(
5853 &mut self,
5854 img: &rusqlite::Connection,
5855 table: &crate::schema::TableSchema,
5856 first_fresh_page: bool,
5857 effective: &[(String, Vec<String>)],
5858 row_count: i64,
5859 as_of_commit_seq: i64,
5860 scope_digest: &str,
5861 ) -> Result<u32, SectionError> {
5862 let invalid = |detail: String| {
5863 SectionError::Abort(
5864 "sync.invalid_request".to_owned(),
5865 format!("sqlite segment rejected: {detail} (§5.3)"),
5866 )
5867 };
5868
5869 type MetaRow = (i64, String, i64, i64, String, i64, i64);
5872 let meta: MetaRow = img
5873 .query_row(
5874 "SELECT format, \"table\", \"schemaVersion\", \"asOfCommitSeq\",
5875 \"scopeDigest\", \"rowCount\",
5876 (SELECT count(*) FROM _syncular_segment)
5877 FROM _syncular_segment",
5878 [],
5879 |row| {
5880 Ok((
5881 row.get(0)?,
5882 row.get(1)?,
5883 row.get(2)?,
5884 row.get(3)?,
5885 row.get(4)?,
5886 row.get(5)?,
5887 row.get(6)?,
5888 ))
5889 },
5890 )
5891 .map_err(|_| invalid("missing or unreadable _syncular_segment metadata".to_owned()))?;
5892 let (format, meta_table, schema_version, pin, digest, meta_rows, meta_count) = meta;
5893 if meta_count != 1 {
5894 return Err(invalid(format!(
5895 "_syncular_segment must contain exactly one row, found {meta_count}"
5896 )));
5897 }
5898 if format != 1 {
5899 return Err(invalid(format!("format {format}")));
5900 }
5901 if meta_table != table.name {
5902 return Err(invalid(format!("image table {meta_table:?}")));
5903 }
5904 if schema_version != i64::from(self.schema.version) {
5905 return Err(invalid(format!("schemaVersion {schema_version}")));
5906 }
5907 if pin != as_of_commit_seq {
5908 return Err(invalid(format!("asOfCommitSeq {pin}")));
5909 }
5910 if digest != scope_digest {
5911 return Err(invalid("scopeDigest mismatch".to_owned()));
5912 }
5913 if meta_rows != row_count {
5914 return Err(invalid(format!("rowCount {meta_rows}")));
5915 }
5916
5917 let mut names: Vec<String> = Vec::new();
5919 {
5920 let mut stmt = img
5921 .prepare(&format!("PRAGMA table_info({})", quote_ident(&table.name)))
5922 .map_err(|_| invalid("image data table missing".to_owned()))?;
5923 let mut rows = stmt
5924 .query([])
5925 .map_err(|_| invalid("image data table unreadable".to_owned()))?;
5926 while let Some(row) = rows
5927 .next()
5928 .map_err(|_| invalid("image data table unreadable".to_owned()))?
5929 {
5930 names.push(
5931 row.get::<_, String>(1)
5932 .map_err(|_| invalid("image data table unreadable".to_owned()))?,
5933 );
5934 }
5935 }
5936 let mut expected: Vec<&str> = table.columns.iter().map(|c| c.name.as_str()).collect();
5937 expected.push("_syncular_version");
5938 if names.len() != expected.len() || names.iter().zip(expected.iter()).any(|(a, b)| a != b) {
5939 return Err(SectionError::Abort(
5940 "sync.schema_mismatch".to_owned(),
5941 "sqlite segment columns do not match the generated schema (§5.3)".to_owned(),
5942 ));
5943 }
5944
5945 if first_fresh_page {
5948 self.purge_scope_rows(&table.name, effective)
5949 .map_err(|()| SectionError::FailClosed)?;
5950 }
5951 self.overlay_dirty.set(true);
5957 let bulk_indexes: Vec<&crate::schema::IndexSchema> = if first_fresh_page {
5965 table.indexes.iter().filter(|i| !i.unique).collect()
5966 } else {
5967 Vec::new()
5968 };
5969 for index in &bulk_indexes {
5970 let index_name = quote_ident(&format!("_syncular_base_{}", index.name));
5971 self.conn
5972 .execute(&format!("DROP INDEX IF EXISTS {index_name}"), [])
5973 .map_err(|e| invalid(e.to_string()))?;
5974 }
5975 let insert = self.insert_row_sql(&base_table(&table.name), table);
5976 let applied = {
5977 let mut ins = self
5978 .conn
5979 .prepare_cached(&insert)
5980 .map_err(|e| invalid(e.to_string()))?;
5981 let column_list: Vec<String> = names.iter().map(|n| quote_ident(n)).collect();
5982 let mut stmt = img
5983 .prepare(&format!(
5984 "SELECT {} FROM {}",
5985 column_list.join(", "),
5986 quote_ident(&table.name)
5987 ))
5988 .map_err(|_| invalid("image data table unreadable".to_owned()))?;
5989 let mut rows = stmt
5990 .query([])
5991 .map_err(|_| invalid("image data table unreadable".to_owned()))?;
5992 let version_index = table.columns.len();
5993 let mut applied = 0u32;
5994 while let Some(row) = rows
5995 .next()
5996 .map_err(|_| invalid("image row unreadable".to_owned()))?
5997 {
5998 for (i, column) in table.columns.iter().enumerate() {
5999 let cell = row
6000 .get_ref(i)
6001 .map_err(|_| invalid("image row unreadable".to_owned()))?;
6002 let param = image_cell_param(column, cell).map_err(&invalid)?;
6003 ins.raw_bind_parameter(i + 1, param)
6004 .map_err(|e| invalid(e.to_string()))?;
6005 }
6006 let version: i64 = row
6007 .get(version_index)
6008 .map_err(|_| invalid("image row unreadable".to_owned()))?;
6009 if version < 1 {
6010 return Err(invalid(format!(
6011 "row _syncular_version must be >= 1, got {version}"
6012 )));
6013 }
6014 ins.raw_bind_parameter(version_index + 1, version)
6015 .map_err(|e| invalid(e.to_string()))?;
6016 ins.raw_execute().map_err(|e| invalid(e.to_string()))?;
6017 applied += 1;
6018 }
6019 applied
6020 };
6021 for index in &bulk_indexes {
6022 let index_name = quote_ident(&format!("_syncular_base_{}", index.name));
6023 let cols_sql = index
6024 .columns
6025 .iter()
6026 .map(|c| quote_ident(c))
6027 .collect::<Vec<_>>()
6028 .join(", ");
6029 self.conn
6030 .execute(
6031 &format!(
6032 "CREATE INDEX IF NOT EXISTS {index_name} ON {} ({cols_sql})",
6033 base_table(&table.name)
6034 ),
6035 [],
6036 )
6037 .map_err(|e| invalid(e.to_string()))?;
6038 }
6039 if i64::from(applied) != row_count {
6040 return Err(invalid(format!(
6041 "image holds {applied} rows, descriptor says {row_count}"
6042 )));
6043 }
6044 Ok(applied)
6045 }
6046
6047 fn compile_local_data_purge(
6050 &self,
6051 input: &LocalDataPurgeInput,
6052 ) -> Result<(Vec<CompiledLocalDataPurgeTarget>, String), String> {
6053 let invalid = |message: String| format!("sync.invalid_request: {message}");
6054 if input.purge_id.is_empty()
6055 || input.purge_id.len() > 128
6056 || !is_local_purge_code_like(&input.purge_id)
6057 {
6058 return Err(invalid(
6059 "local purge purgeId must be a 1–128 character code-like identifier".to_owned(),
6060 ));
6061 }
6062 if input.targets.is_empty() || input.targets.len() > MAX_LOCAL_PURGE_TARGETS {
6063 return Err(invalid(format!(
6064 "local purge needs between 1 and {MAX_LOCAL_PURGE_TARGETS} targets"
6065 )));
6066 }
6067
6068 let mut deduplicated: BTreeMap<
6069 String,
6070 (CompiledLocalDataPurgeTarget, LocalDataPurgeTarget),
6071 > = BTreeMap::new();
6072 for target in &input.targets {
6073 let table = self.schema.table(&target.table).ok_or_else(|| {
6074 invalid(format!(
6075 "local purge names unknown table {:?}",
6076 target.table
6077 ))
6078 })?;
6079 if target.selectors.is_empty() || target.selectors.len() > MAX_LOCAL_PURGE_SELECTORS {
6080 return Err(invalid(format!(
6081 "local purge target {:?} needs between 1 and {MAX_LOCAL_PURGE_SELECTORS} selectors",
6082 target.table
6083 )));
6084 }
6085 let mut selectors = Vec::with_capacity(target.selectors.len());
6086 let mut canonical_selectors = BTreeMap::new();
6087 for (column_name, raw_values) in &target.selectors {
6088 let Some((column_index, column)) = table
6089 .columns
6090 .iter()
6091 .enumerate()
6092 .find(|(_, column)| column.name == *column_name)
6093 else {
6094 return Err(invalid(format!(
6095 "local purge target {:?} names unknown column {:?}",
6096 target.table, column_name
6097 )));
6098 };
6099 let encrypted = table
6100 .encrypted_columns
6101 .iter()
6102 .any(|candidate| candidate.index == column_index);
6103 if column.ty != ColumnType::String || encrypted {
6104 return Err(invalid(format!(
6105 "local purge selector {:?}.{:?} must be a plaintext string column",
6106 target.table, column_name
6107 )));
6108 }
6109 if raw_values.is_empty() || raw_values.len() > MAX_LOCAL_PURGE_VALUES {
6110 return Err(invalid(format!(
6111 "local purge selector {:?}.{:?} needs between 1 and {MAX_LOCAL_PURGE_VALUES} values",
6112 target.table, column_name
6113 )));
6114 }
6115 let mut values = raw_values.clone();
6116 values.sort();
6117 values.dedup();
6118 if values.iter().any(|value| {
6119 value.is_empty()
6120 || value.len() > MAX_LOCAL_PURGE_VALUE_LENGTH
6121 || !is_local_purge_code_like(value)
6122 }) {
6123 return Err(invalid(format!(
6124 "local purge selector values must be 1–{MAX_LOCAL_PURGE_VALUE_LENGTH} character code-like identifiers"
6125 )));
6126 }
6127 selectors.push((column_name.clone(), values.clone()));
6128 canonical_selectors.insert(column_name.clone(), values);
6129 }
6130 let canonical = LocalDataPurgeTarget {
6131 table: target.table.clone(),
6132 selectors: canonical_selectors,
6133 };
6134 let key = serde_json::to_string(&canonical).map_err(|error| error.to_string())?;
6135 deduplicated.insert(
6136 key,
6137 (
6138 CompiledLocalDataPurgeTarget {
6139 table: target.table.clone(),
6140 selectors,
6141 },
6142 canonical,
6143 ),
6144 );
6145 }
6146 let targets = deduplicated
6147 .values()
6148 .map(|(compiled, _)| compiled.clone())
6149 .collect::<Vec<_>>();
6150 let canonical = deduplicated
6151 .values()
6152 .map(|(_, target)| target.clone())
6153 .collect::<Vec<_>>();
6154 let canonical_plan =
6155 serde_json::to_string(&canonical).map_err(|error| error.to_string())?;
6156 Ok((targets, canonical_plan))
6157 }
6158
6159 fn local_purge_base_row_ids(
6160 &self,
6161 targets: &[CompiledLocalDataPurgeTarget],
6162 ) -> Result<BTreeMap<String, BTreeSet<String>>, String> {
6163 let mut by_table: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
6164 for target in targets {
6165 let table = self
6166 .schema
6167 .table(&target.table)
6168 .ok_or_else(|| format!("sync.invalid_request: unknown table {:?}", target.table))?;
6169 let mut clauses = Vec::with_capacity(target.selectors.len());
6170 let mut params = Vec::new();
6171 for (column, values) in &target.selectors {
6172 clauses.push(format!(
6173 "{} IN ({})",
6174 quote_ident(column),
6175 values.iter().map(|_| "?").collect::<Vec<_>>().join(", ")
6176 ));
6177 params.extend(values.iter().cloned().map(SqlValue::Text));
6178 }
6179 let sql = format!(
6180 "SELECT CAST({} AS TEXT) FROM {} WHERE {}",
6181 quote_ident(&table.primary_key),
6182 base_table(&target.table),
6183 clauses.join(" AND ")
6184 );
6185 let mut statement = self.conn.prepare(&sql).map_err(|error| error.to_string())?;
6186 let rows = statement
6187 .query_map(rusqlite::params_from_iter(params), |row| {
6188 row.get::<_, String>(0)
6189 })
6190 .map_err(|error| error.to_string())?;
6191 let ids = by_table.entry(target.table.clone()).or_default();
6192 for row in rows {
6193 ids.insert(row.map_err(|error| error.to_string())?);
6194 }
6195 }
6196 Ok(by_table)
6197 }
6198
6199 fn local_purge_values_match(
6200 target: &CompiledLocalDataPurgeTarget,
6201 values: &Map<String, Value>,
6202 ) -> bool {
6203 target.selectors.iter().all(|(column, allowed)| {
6204 values
6205 .get(column)
6206 .and_then(Value::as_str)
6207 .is_some_and(|value| allowed.iter().any(|candidate| candidate == value))
6208 })
6209 }
6210
6211 pub fn purge_local_data(
6215 &mut self,
6216 input: &LocalDataPurgeInput,
6217 ) -> Result<LocalDataPurgeResult, String> {
6218 let (targets, canonical_plan) = self.compile_local_data_purge(input)?;
6219 let meta_key = format!("localPurge:{}", input.purge_id);
6220 let applied_plan = self
6221 .conn
6222 .query_row(
6223 "SELECT value FROM _syncular_meta WHERE key = ?1",
6224 rusqlite::params![meta_key],
6225 |row| row.get::<_, String>(0),
6226 )
6227 .optional()
6228 .map_err(|error| error.to_string())?;
6229 if let Some(applied_plan) = applied_plan {
6230 if applied_plan != canonical_plan {
6231 return Err(format!(
6232 "sync.invalid_request: local purge id {:?} was already used with a different plan",
6233 input.purge_id
6234 ));
6235 }
6236 return Ok(LocalDataPurgeResult {
6237 already_applied: true,
6238 purged_rows: 0,
6239 dropped_commits: 0,
6240 });
6241 }
6242
6243 let prior_outbox = self.outbox.clone();
6244 let prior_rejection_count = self.rejections.len();
6245 let prior_overlay_dirty = self.overlay_dirty.get();
6246 self.begin_observation("syncular_local_purge")?;
6247 let applied = (|| -> Result<(ChangeAccumulator, LocalDataPurgeResult), String> {
6248 let row_ids = self.local_purge_base_row_ids(&targets)?;
6249 let doomed = self
6250 .outbox
6251 .iter()
6252 .filter(|commit| {
6253 commit.ops.iter().any(|operation| {
6254 let matching_targets = targets
6255 .iter()
6256 .filter(|target| target.table == operation.table)
6257 .collect::<Vec<_>>();
6258 if matching_targets.is_empty() {
6259 return false;
6260 }
6261 if row_ids
6262 .get(&operation.table)
6263 .is_some_and(|ids| ids.contains(&operation.row_id))
6264 {
6265 return true;
6266 }
6267 operation.values.as_ref().is_some_and(|values| {
6268 matching_targets
6269 .iter()
6270 .any(|target| Self::local_purge_values_match(target, values))
6271 })
6272 })
6273 })
6274 .cloned()
6275 .collect::<Vec<_>>();
6276 let doomed_ids = doomed
6277 .iter()
6278 .map(|commit| commit.client_commit_id.clone())
6279 .collect::<BTreeSet<_>>();
6280 let mut batch = ChangeAccumulator::default();
6281 let mut rejections = Vec::new();
6282 for commit in &doomed {
6283 for operation in &commit.ops {
6284 batch.table(&operation.table);
6285 }
6286 let results = commit
6287 .ops
6288 .iter()
6289 .enumerate()
6290 .map(|(op_index, operation)| {
6291 let rejection = RejectionRecord {
6292 client_commit_id: commit.client_commit_id.clone(),
6293 op_index: op_index as i32,
6294 code: "client.local_data_purged".to_owned(),
6295 message: "the commit was dropped by an application-authorized local data purge".to_owned(),
6296 retryable: false,
6297 details: None,
6298 operation: Some(CommitOperation::from(operation)),
6299 };
6300 rejections.push(rejection.clone());
6301 CommitOperationOutcome::Error { rejection }
6302 })
6303 .collect::<Vec<_>>();
6304 self.persist_commit_outcome(
6305 &commit.client_commit_id,
6306 CommitOutcomeStatus::Rejected,
6307 &results,
6308 Some(&commit.ops),
6309 )?;
6310 self.delete_outbox_persisted(&commit.client_commit_id)?;
6311 }
6312 if !doomed.is_empty() {
6313 self.outbox
6314 .retain(|commit| !doomed_ids.contains(&commit.client_commit_id));
6315 self.rejections.extend(rejections);
6316 self.prune_commit_outcomes()?;
6317 self.overlay_dirty.set(true);
6318 batch.status = true;
6319 batch.rejections = true;
6320 batch.outcomes = true;
6321 }
6322
6323 let mut purged_rows = 0usize;
6324 for (table, ids) in &row_ids {
6325 if ids.is_empty() {
6326 continue;
6327 }
6328 batch.table(table);
6329 for row_id in ids {
6330 self.delete_base_row(table, row_id)?;
6331 purged_rows += 1;
6332 }
6333 }
6334 self.rebuild_overlay_if_dirty();
6335 self.reconcile_blob_refcounts(true);
6336 self.conn
6337 .execute(
6338 "INSERT INTO _syncular_meta(key, value) VALUES (?1, ?2)",
6339 rusqlite::params![meta_key, canonical_plan],
6340 )
6341 .map_err(|error| error.to_string())?;
6342 Ok((
6343 batch,
6344 LocalDataPurgeResult {
6345 already_applied: false,
6346 purged_rows,
6347 dropped_commits: doomed.len(),
6348 },
6349 ))
6350 })();
6351 let (batch, result) = match applied {
6352 Ok(value) => value,
6353 Err(error) => {
6354 self.rollback_observation("syncular_local_purge");
6355 self.outbox = prior_outbox;
6356 self.rejections.truncate(prior_rejection_count);
6357 self.overlay_dirty.set(prior_overlay_dirty);
6358 return Err(error);
6359 }
6360 };
6361 if let Err(error) = self.finish_observation("syncular_local_purge", batch) {
6362 self.rollback_observation("syncular_local_purge");
6363 self.outbox = prior_outbox;
6364 self.rejections.truncate(prior_rejection_count);
6365 self.overlay_dirty.set(prior_overlay_dirty);
6366 return Err(error);
6367 }
6368 Ok(result)
6369 }
6370
6371 fn purge_scope_rows(
6376 &mut self,
6377 table_name: &str,
6378 effective: &[(String, Vec<String>)],
6379 ) -> Result<(), ()> {
6380 if effective.is_empty() {
6381 return Ok(());
6382 }
6383 let table = self.schema.table(table_name).ok_or(())?.clone();
6384 let mut clauses = Vec::new();
6385 let mut params: Vec<SqlValue> = Vec::new();
6386 for (variable, values) in effective {
6387 let column = table.scope_column(variable).ok_or(())?;
6388 let placeholders: Vec<String> = values
6389 .iter()
6390 .map(|v| {
6391 params.push(SqlValue::Text(v.clone()));
6392 "?".to_owned()
6393 })
6394 .collect();
6395 clauses.push(format!(
6396 "{} IN ({})",
6397 quote_ident(column),
6398 placeholders.join(", ")
6399 ));
6400 }
6401 let sql = format!(
6402 "DELETE FROM {} WHERE {}",
6403 base_table(table_name),
6404 clauses.join(" AND ")
6405 );
6406 self.overlay_dirty.set(true);
6407 self.conn
6408 .execute(&sql, rusqlite::params_from_iter(params))
6409 .map_err(|_| ())?;
6410 Ok(())
6411 }
6412
6413 fn drop_doomed_outbox(
6416 &mut self,
6417 table_name: &str,
6418 effective: &[(String, Vec<String>)],
6419 ) -> Result<bool, String> {
6420 if effective.is_empty() {
6421 return Ok(false);
6422 }
6423 let Some(table) = self.schema.table(table_name).cloned() else {
6424 return Ok(false);
6425 };
6426 let mut mappings: Vec<(&str, &Vec<String>)> = Vec::new();
6427 for (variable, values) in effective {
6428 match table.scope_column(variable) {
6429 Some(column) => mappings.push((column, values)),
6430 None => return Ok(false), }
6432 }
6433 let doomed: Vec<OutboxCommit> = self
6434 .outbox
6435 .iter()
6436 .filter(|commit| {
6437 commit.ops.iter().any(|op| {
6438 op.upsert
6439 && op.table == table_name
6440 && op.values.as_ref().is_some_and(|values| {
6441 mappings
6442 .iter()
6443 .all(|(column, allowed)| match values.get(*column) {
6444 Some(Value::String(s)) => allowed.contains(s),
6445 Some(Value::Number(n)) => allowed.contains(&n.to_string()),
6446 _ => false,
6447 })
6448 })
6449 })
6450 })
6451 .cloned()
6452 .collect();
6453 if doomed.is_empty() {
6454 return Ok(false);
6455 }
6456 let mut rejections = Vec::new();
6457 for commit in &doomed {
6458 let results = commit
6459 .ops
6460 .iter()
6461 .enumerate()
6462 .map(|(op_index, operation)| {
6463 let rejection = RejectionRecord {
6464 client_commit_id: commit.client_commit_id.clone(),
6465 op_index: op_index as i32,
6466 code: "sync.scope_revoked".to_owned(),
6467 message: "the commit was dropped because its effective scope was revoked"
6468 .to_owned(),
6469 retryable: false,
6470 details: None,
6471 operation: Some(CommitOperation::from(operation)),
6472 };
6473 rejections.push(rejection.clone());
6474 CommitOperationOutcome::Error { rejection }
6475 })
6476 .collect::<Vec<_>>();
6477 self.persist_commit_outcome(
6478 &commit.client_commit_id,
6479 CommitOutcomeStatus::Rejected,
6480 &results,
6481 Some(&commit.ops),
6482 )?;
6483 self.delete_outbox_persisted(&commit.client_commit_id)?;
6484 }
6485 self.prune_commit_outcomes()?;
6486 let doomed_ids = doomed
6487 .iter()
6488 .map(|commit| commit.client_commit_id.as_str())
6489 .collect::<BTreeSet<_>>();
6490 self.outbox
6491 .retain(|commit| !doomed_ids.contains(commit.client_commit_id.as_str()));
6492 self.rejections.extend(rejections);
6493 self.overlay_dirty.set(true);
6494 Ok(true)
6495 }
6496
6497 pub fn upload_blob(
6504 &mut self,
6505 bytes: &[u8],
6506 media_type: Option<String>,
6507 name: Option<String>,
6508 ) -> Result<Value, String> {
6509 let blob_id = blob_id_for(bytes);
6510 let now = self.clock_now_ms();
6511 self.conn
6512 .execute(
6513 "INSERT INTO _syncular_blobs(blob_id, bytes, byte_length, media_type, refcount, created_at_ms, last_used_ms) VALUES (?,?,?,?,0,?,?)
6514 ON CONFLICT(blob_id) DO UPDATE SET last_used_ms = excluded.last_used_ms",
6515 rusqlite::params![blob_id, bytes, bytes.len() as i64, media_type, now, now],
6516 )
6517 .map_err(|e| e.to_string())?;
6518 self.conn
6519 .execute(
6520 "INSERT OR IGNORE INTO _syncular_blob_uploads(blob_id, media_type, created_at_ms) VALUES (?,?,?)",
6521 rusqlite::params![blob_id, media_type, now],
6522 )
6523 .map_err(|e| e.to_string())?;
6524 self.enforce_blob_cache_cap();
6527 let mut obj = Map::new();
6528 obj.insert("blobId".to_owned(), Value::from(blob_id));
6529 obj.insert("byteLength".to_owned(), Value::from(bytes.len() as i64));
6530 if let Some(mt) = media_type {
6531 obj.insert("mediaType".to_owned(), Value::from(mt));
6532 }
6533 if let Some(n) = name {
6534 obj.insert("name".to_owned(), Value::from(n));
6535 }
6536 Ok(Value::Object(obj))
6537 }
6538
6539 pub fn fetch_blob(
6543 &mut self,
6544 transport: &mut dyn Transport,
6545 blob_id_or_ref: &str,
6546 ) -> Result<Value, (String, String)> {
6547 let simple = |m: String| ("client.failed".to_owned(), m);
6548 let blob_id = if blob_id_or_ref.starts_with("sha256:") {
6549 blob_id_or_ref.to_owned()
6550 } else {
6551 let value: Value = serde_json::from_str(blob_id_or_ref)
6552 .map_err(|_| simple("blob ref is not JSON".to_owned()))?;
6553 value
6554 .get("blobId")
6555 .and_then(Value::as_str)
6556 .ok_or_else(|| simple("blob ref has no blobId".to_owned()))?
6557 .to_owned()
6558 };
6559 if let Some(cached) = self.get_cached_blob(&blob_id).map_err(simple)? {
6560 return Ok(cached);
6561 }
6562 let bytes = match transport
6568 .blob_download(&blob_id)
6569 .map_err(|e| (e.code, e.message))?
6570 {
6571 BlobDownload::Bytes(bytes) => bytes,
6572 BlobDownload::Url {
6573 url,
6574 url_expires_at_ms,
6575 } => {
6576 if url_expires_at_ms.is_some_and(|exp| exp <= self.clock_now_ms()) {
6578 return Err((
6579 "sync.segment_expired".to_owned(),
6580 format!(
6581 "blob url for {blob_id} expired before fetch — re-request mints a fresh url (§5.9.5)"
6582 ),
6583 ));
6584 }
6585 transport
6586 .fetch_blob_url(&url)
6587 .map_err(|e| (e.code, e.message))?
6588 }
6589 };
6590 if blob_id_for(&bytes) != blob_id {
6592 return Err(simple(format!(
6593 "blob content address mismatch for {blob_id}"
6594 )));
6595 }
6596 let now = self.clock_now_ms();
6597 self.conn
6598 .execute(
6599 "INSERT OR IGNORE INTO _syncular_blobs(blob_id, bytes, byte_length, media_type, refcount, created_at_ms, last_used_ms) VALUES (?,?,?,NULL,0,?,?)",
6600 rusqlite::params![blob_id, bytes, bytes.len() as i64, now, now],
6601 )
6602 .map_err(|e| simple(e.to_string()))?;
6603 self.enforce_blob_cache_cap();
6604 self.get_cached_blob(&blob_id)
6605 .map_err(simple)?
6606 .ok_or_else(|| simple("blob cache write failed".to_owned()))
6607 }
6608
6609 fn get_cached_blob(&self, blob_id: &str) -> Result<Option<Value>, String> {
6610 let _ = self.conn.execute(
6613 "UPDATE _syncular_blobs SET last_used_ms = ? WHERE blob_id = ?",
6614 rusqlite::params![self.clock_now_ms(), blob_id],
6615 );
6616 let mut stmt = self
6617 .conn
6618 .prepare("SELECT bytes, byte_length, media_type FROM _syncular_blobs WHERE blob_id = ?")
6619 .map_err(|e| e.to_string())?;
6620 let mut rows = stmt
6621 .query(rusqlite::params![blob_id])
6622 .map_err(|e| e.to_string())?;
6623 if let Some(row) = rows.next().map_err(|e| e.to_string())? {
6624 let bytes: Vec<u8> = row.get(0).map_err(|e| e.to_string())?;
6625 let byte_length: i64 = row.get(1).map_err(|e| e.to_string())?;
6626 let media_type: Option<String> = row.get(2).map_err(|e| e.to_string())?;
6627 let mut obj = Map::new();
6628 obj.insert("blobId".to_owned(), Value::from(blob_id.to_owned()));
6629 obj.insert("byteLength".to_owned(), Value::from(byte_length));
6630 let mut bytes_obj = Map::new();
6631 bytes_obj.insert("$bytes".to_owned(), Value::from(bytes_to_hex(&bytes)));
6632 obj.insert("bytes".to_owned(), Value::Object(bytes_obj));
6633 if let Some(mt) = media_type {
6634 obj.insert("mediaType".to_owned(), Value::from(mt));
6635 }
6636 return Ok(Some(Value::Object(obj)));
6637 }
6638 Ok(None)
6639 }
6640
6641 fn flush_blob_uploads(&mut self, transport: &mut dyn Transport) -> Result<(), TransportError> {
6643 let pending: Vec<(String, Option<String>)> = {
6644 let mut stmt = self
6645 .conn
6646 .prepare(
6647 "SELECT blob_id, media_type FROM _syncular_blob_uploads ORDER BY created_at_ms",
6648 )
6649 .map_err(|e| TransportError::new("client.failed", e.to_string()))?;
6650 let rows = stmt
6651 .query_map([], |row| {
6652 Ok((row.get::<_, String>(0)?, row.get::<_, Option<String>>(1)?))
6653 })
6654 .map_err(|e| TransportError::new("client.failed", e.to_string()))?;
6655 rows.filter_map(Result::ok).collect()
6656 };
6657 for (blob_id, media_type) in pending {
6658 let bytes: Option<Vec<u8>> = self
6659 .conn
6660 .query_row(
6661 "SELECT bytes FROM _syncular_blobs WHERE blob_id = ?",
6662 rusqlite::params![blob_id],
6663 |row| row.get(0),
6664 )
6665 .ok();
6666 if let Some(bytes) = bytes {
6667 self.upload_one(transport, &blob_id, &bytes, media_type.as_deref())?;
6668 }
6669 let _ = self.conn.execute(
6670 "DELETE FROM _syncular_blob_uploads WHERE blob_id = ?",
6671 rusqlite::params![blob_id],
6672 );
6673 }
6674 Ok(())
6675 }
6676
6677 fn upload_one(
6684 &self,
6685 transport: &mut dyn Transport,
6686 blob_id: &str,
6687 bytes: &[u8],
6688 media_type: Option<&str>,
6689 ) -> Result<(), TransportError> {
6690 match transport.blob_upload_grant(blob_id, bytes.len() as u64, media_type)? {
6691 BlobUploadGrant::Present => return Ok(()), BlobUploadGrant::Url {
6693 url,
6694 url_expires_at_ms,
6695 } => {
6696 let live = url_expires_at_ms.is_none_or(|exp| exp > self.clock_now_ms());
6697 if live && transport.blob_put_url(&url, bytes, media_type).is_ok() {
6698 return Ok(());
6699 }
6700 }
6702 BlobUploadGrant::None => {
6703 }
6705 }
6706 transport.blob_upload(blob_id, bytes, media_type)
6707 }
6708
6709 fn enforce_blob_cache_cap(&self) {
6718 let Some(max_bytes) = self.limits.blob_cache_max_bytes else {
6719 return;
6720 };
6721 let mut total: i64 = self
6722 .conn
6723 .query_row(
6724 "SELECT COALESCE(SUM(byte_length), 0) FROM _syncular_blobs",
6725 [],
6726 |row| row.get(0),
6727 )
6728 .unwrap_or(0);
6729 if total <= max_bytes {
6730 return;
6731 }
6732 let candidates: Vec<(String, i64)> = {
6733 let Ok(mut stmt) = self.conn.prepare(
6734 "SELECT blob_id, byte_length FROM _syncular_blobs
6735 WHERE refcount = 0
6736 AND blob_id NOT IN (SELECT blob_id FROM _syncular_blob_uploads)
6737 ORDER BY last_used_ms ASC, created_at_ms ASC",
6738 ) else {
6739 return;
6740 };
6741 let Ok(rows) = stmt.query_map([], |row| {
6742 Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)?))
6743 }) else {
6744 return;
6745 };
6746 rows.filter_map(Result::ok).collect()
6747 };
6748 for (blob_id, byte_length) in candidates {
6749 if total <= max_bytes {
6750 break;
6751 }
6752 let _ = self.conn.execute(
6753 "DELETE FROM _syncular_blobs WHERE blob_id = ?",
6754 rusqlite::params![blob_id],
6755 );
6756 total -= byte_length;
6757 }
6758 }
6759
6760 fn reconcile_blob_refcounts(&mut self, delete_orphans: bool) {
6764 if !self.schema_has_blobs() {
6765 return;
6766 }
6767 let mut counts: std::collections::HashMap<String, i64> = std::collections::HashMap::new();
6768 for table in self.schema.tables.clone() {
6769 let blob_cols: Vec<String> = table
6770 .columns
6771 .iter()
6772 .filter(|c| c.ty == ColumnType::BlobRef)
6773 .map(|c| c.name.clone())
6774 .collect();
6775 for column in blob_cols {
6776 let sql = format!(
6777 "SELECT {} FROM {} WHERE {} IS NOT NULL",
6778 quote_ident(&column),
6779 base_table(&table.name),
6780 quote_ident(&column)
6781 );
6782 let Ok(mut stmt) = self.conn.prepare(&sql) else {
6783 continue;
6784 };
6785 let Ok(rows) = stmt.query_map([], |row| row.get::<_, Option<String>>(0)) else {
6786 continue;
6787 };
6788 for raw in rows.flatten().flatten() {
6789 if let Ok(value) = serde_json::from_str::<Value>(&raw) {
6790 if let Some(id) = value.get("blobId").and_then(Value::as_str) {
6791 *counts.entry(id.to_owned()).or_insert(0) += 1;
6792 }
6793 }
6794 }
6795 }
6796 }
6797 let _ = self
6798 .conn
6799 .execute("UPDATE _syncular_blobs SET refcount = 0", []);
6800 for (blob_id, count) in &counts {
6801 let _ = self.conn.execute(
6802 "UPDATE _syncular_blobs SET refcount = ? WHERE blob_id = ?",
6803 rusqlite::params![count, blob_id],
6804 );
6805 }
6806 if delete_orphans {
6807 let _ = self.conn.execute(
6808 "DELETE FROM _syncular_blobs WHERE refcount = 0 AND blob_id NOT IN (SELECT blob_id FROM _syncular_blob_uploads)",
6809 [],
6810 );
6811 }
6812 }
6813
6814 fn insert_row_sql(&self, full_table: &str, table: &crate::schema::TableSchema) -> String {
6819 if let Some(sql) = self.insert_sql.borrow().get(full_table) {
6820 return sql.clone();
6821 }
6822 let mut columns: Vec<String> = table.columns.iter().map(|c| quote_ident(&c.name)).collect();
6823 columns.push(quote_ident("_syncular_version"));
6824 let placeholders: Vec<&str> = columns.iter().map(|_| "?").collect();
6825 let primary_key = quote_ident(&table.primary_key);
6826 let updates = columns
6827 .iter()
6828 .filter(|column| **column != primary_key)
6829 .map(|column| format!("{column}=excluded.{column}"))
6830 .collect::<Vec<_>>()
6831 .join(", ");
6832 let sql = format!(
6833 "INSERT INTO {full_table} ({}) VALUES ({}) ON CONFLICT ({primary_key}) DO UPDATE SET {updates}",
6834 columns.join(", "),
6835 placeholders.join(", ")
6836 );
6837 self.insert_sql
6838 .borrow_mut()
6839 .insert(full_table.to_owned(), sql.clone());
6840 sql
6841 }
6842
6843 fn write_base_row(&self, table_name: &str, row: &Row, version: i64) -> Result<(), String> {
6844 self.overlay_dirty.set(true);
6845 self.write_row(&base_table(table_name), table_name, row, version)
6846 }
6847
6848 fn write_row(
6849 &self,
6850 full_table: &str,
6851 table_name: &str,
6852 row: &Row,
6853 version: i64,
6854 ) -> Result<(), String> {
6855 let table = self
6856 .schema
6857 .table(table_name)
6858 .ok_or_else(|| format!("unknown table {table_name:?}"))?;
6859 let sql = self.insert_row_sql(full_table, table);
6860 let mut stmt = self.conn.prepare_cached(&sql).map_err(|e| e.to_string())?;
6861 let params = row
6862 .iter()
6863 .map(RowParam::Cell)
6864 .chain(std::iter::once(RowParam::Version(version)));
6865 stmt.execute(rusqlite::params_from_iter(params))
6866 .map_err(|e| e.to_string())?;
6867 Ok(())
6868 }
6869
6870 fn delete_base_row(&self, table_name: &str, row_id: &str) -> Result<(), String> {
6871 let table = self
6872 .schema
6873 .table(table_name)
6874 .ok_or_else(|| format!("unknown table {table_name:?}"))?;
6875 self.overlay_dirty.set(true);
6876 let sql = format!(
6877 "DELETE FROM {} WHERE CAST({} AS TEXT) = ?1",
6878 base_table(table_name),
6879 quote_ident(&table.primary_key)
6880 );
6881 let mut stmt = self.conn.prepare_cached(&sql).map_err(|e| e.to_string())?;
6882 stmt.execute(rusqlite::params![row_id])
6883 .map_err(|e| e.to_string())?;
6884 Ok(())
6885 }
6886
6887 fn rebuild_overlay_if_dirty(&mut self) {
6890 if self.overlay_dirty.get() {
6891 self.rebuild_overlay();
6892 }
6893 }
6894
6895 fn rebuild_overlay(&mut self) {
6899 #[cfg(test)]
6900 self.overlay_rebuild_count
6901 .set(self.overlay_rebuild_count.get() + 1);
6902 self.exec("SAVEPOINT syncular_overlay");
6903 for table in self.schema.tables.clone() {
6904 for index in &table.fts_indexes {
6905 let _ = self.drop_fts_triggers(index);
6906 }
6907 let visible = visible_table(&table.name);
6908 let base = base_table(&table.name);
6909 self.exec(&format!("DELETE FROM {visible}"));
6910 self.exec(&format!("INSERT INTO {visible} SELECT * FROM {base}"));
6911 }
6912 for commit in self.outbox.clone() {
6913 for op in &commit.ops {
6914 let Some(table) = self.schema.table(&op.table).cloned() else {
6915 continue;
6916 };
6917 if op.upsert {
6918 let Some(values) = op.values.as_ref() else {
6919 continue;
6920 };
6921 let mut row: Row = Vec::with_capacity(table.columns.len());
6922 let mut ok = true;
6923 for column in &table.columns {
6924 match json_to_column_value(column, values.get(&column.name)) {
6925 Ok(v) => row.push(v),
6926 Err(_) => {
6927 ok = false;
6928 break;
6929 }
6930 }
6931 }
6932 if ok {
6933 let _ = self.write_row(&visible_table(&table.name), &table.name, &row, -1);
6934 }
6935 } else {
6936 let sql = format!(
6937 "DELETE FROM {} WHERE CAST({} AS TEXT) = ?1",
6938 visible_table(&table.name),
6939 quote_ident(&table.primary_key)
6940 );
6941 let _ = self.conn.execute(&sql, rusqlite::params![op.row_id]);
6942 }
6943 }
6944 }
6945 for table in self.schema.tables.clone() {
6946 for index in &table.fts_indexes {
6947 let _ = self.rebuild_fts_projection(&table, index);
6948 let _ = self.create_fts_triggers(&table, index);
6949 }
6950 }
6951 self.exec("RELEASE syncular_overlay");
6952 self.overlay_dirty.set(false);
6953 }
6954
6955 fn exec(&self, sql: &str) {
6956 let _ = self.conn.execute_batch(sql);
6957 }
6958
6959 pub fn connect_realtime(&mut self, transport: &mut dyn Transport) -> Result<(), String> {
6962 transport
6963 .realtime_connect_for_client(&self.client_id)
6964 .map_err(|e| format!("{}: {}", e.code, e.message))?;
6965 self.realtime_connected = true;
6966 Ok(())
6967 }
6968
6969 pub fn disconnect_realtime(&mut self, transport: &mut dyn Transport) {
6970 let _ = transport.realtime_close();
6971 self.realtime_connected = false;
6972 self.presence.clear(); }
6974
6975 pub fn set_presence(
6981 &mut self,
6982 transport: &mut dyn Transport,
6983 scope_key: &str,
6984 doc: Option<&Value>,
6985 ) -> Result<(), String> {
6986 if !self.realtime_connected {
6987 return Err("setPresence requires a connected realtime socket (§8.6)".to_string());
6988 }
6989 let text = encode_presence_publish(scope_key, doc);
6990 transport
6991 .realtime_send(&text)
6992 .map_err(|e| format!("{}: {}", e.code, e.message))
6993 }
6994
6995 pub fn presence(&self, scope_key: &str) -> Vec<PresencePeer> {
6997 self.presence
6998 .get(scope_key)
6999 .map(|peers| peers.values().cloned().collect())
7000 .unwrap_or_default()
7001 }
7002
7003 fn apply_presence(
7005 &mut self,
7006 scope_key: String,
7007 kind: Option<PresenceKind>,
7008 actor_id: Option<String>,
7009 client_id: Option<String>,
7010 doc: Option<Value>,
7011 error: Option<String>,
7012 ) {
7013 if error.is_some() {
7016 return;
7017 }
7018 let (Some(kind), Some(actor_id), Some(client_id)) = (kind, actor_id, client_id) else {
7019 return;
7020 };
7021 let peer_key = format!("{actor_id} {client_id}");
7022 match kind {
7023 PresenceKind::Leave => {
7024 if let Some(peers) = self.presence.get_mut(&scope_key) {
7025 peers.remove(&peer_key);
7026 if peers.is_empty() {
7027 self.presence.remove(&scope_key);
7028 }
7029 }
7030 }
7031 _ => {
7032 let doc = match doc {
7033 Some(Value::Object(_)) => doc.unwrap(),
7034 _ => return,
7035 };
7036 self.presence.entry(scope_key).or_default().insert(
7037 peer_key,
7038 PresencePeer {
7039 actor_id,
7040 client_id,
7041 doc,
7042 },
7043 );
7044 }
7045 }
7046 }
7047
7048 pub fn on_realtime_text(&mut self, text: &str) {
7050 match parse_control(text) {
7051 Ok(ControlMessage::Hello { requires_sync, .. }) => {
7052 if requires_sync {
7053 self.set_sync_needed(true, true);
7055 }
7056 }
7057 Ok(ControlMessage::Presence {
7058 scope_key,
7059 kind,
7060 actor_id,
7061 client_id,
7062 doc,
7063 error,
7064 ..
7065 }) => {
7066 self.apply_presence(scope_key, kind, actor_id, client_id, doc, error);
7067 }
7068 Ok(ControlMessage::Wake { .. }) => {
7069 self.set_sync_needed(true, true);
7071 }
7072 _ => {}
7073 }
7074 }
7075
7076 pub fn on_realtime_binary(&mut self, transport: &mut dyn Transport, bytes: &[u8]) {
7079 if self.stopped {
7080 return;
7081 }
7082 let message = match decode_message(bytes) {
7083 Ok(m) if m.msg_kind == MsgKind::Response => m,
7084 _ => {
7085 self.set_sync_needed(true, true);
7086 return;
7087 }
7088 };
7089 let mut frames = message.frames.into_iter();
7090 let mut applied_cursor: Option<i64> = None;
7091 let mut any_covered = false;
7092 let mut dropped = false;
7093 while let Some(frame) = frames.next() {
7094 let Frame::SubStart {
7095 id,
7096 status,
7097 effective_scopes,
7098 ..
7099 } = frame
7100 else {
7101 continue;
7102 };
7103 let mut body = Vec::new();
7104 let mut next_cursor: Option<i64> = None;
7105 for inner in frames.by_ref() {
7106 match inner {
7107 Frame::SubEnd {
7108 next_cursor: nc, ..
7109 } => {
7110 next_cursor = Some(nc);
7111 break;
7112 }
7113 Frame::Unknown { .. } => {}
7114 other => body.push(other),
7115 }
7116 }
7117 let Some(next_cursor) = next_cursor else {
7118 dropped = true;
7119 break;
7120 };
7121 let Some(sub_index) = self.subs.iter().position(|s| s.id == id) else {
7122 dropped = true;
7123 continue;
7124 };
7125 let sub = &self.subs[sub_index];
7126 if status != SubStatus::Active
7129 || sub.state != SubState::Active
7130 || sub.bootstrap_state.is_some()
7131 || !sub.synced_once
7132 {
7133 dropped = true;
7134 continue;
7135 }
7136 if next_cursor <= sub.cursor {
7137 any_covered = true;
7139 continue;
7140 }
7141 let previous_effective = self.subs[sub_index].effective.clone();
7142 let previous_cursor = self.subs[sub_index].cursor;
7143 if self.begin_observation("syncular_delta").is_err() {
7144 dropped = true;
7145 continue;
7146 }
7147 self.subs[sub_index].effective = Some(effective_scopes);
7148 let mut batch = ChangeAccumulator::default();
7149 let mut failed = false;
7150 for inner in body {
7151 if let Frame::Commit {
7152 tables, changes, ..
7153 } = inner
7154 {
7155 self.record_commit_changes(&mut batch, &tables, &changes);
7156 if self.apply_commit_changes(&tables, &changes).is_err() {
7157 failed = true;
7158 break;
7159 }
7160 }
7161 }
7162 if failed {
7163 self.rollback_observation("syncular_delta");
7164 self.subs[sub_index].effective = previous_effective;
7165 self.subs[sub_index].cursor = previous_cursor;
7166 self.overlay_dirty.set(true);
7167 self.rebuild_overlay();
7168 dropped = true;
7169 continue;
7170 }
7171 let sub = &mut self.subs[sub_index];
7172 sub.cursor = next_cursor;
7173 self.persist_sub(&self.subs[sub_index].clone());
7174 self.rebuild_overlay_if_dirty();
7175 if self.finish_observation("syncular_delta", batch).is_err() {
7176 self.rollback_observation("syncular_delta");
7177 self.subs[sub_index].effective = previous_effective;
7178 self.subs[sub_index].cursor = previous_cursor;
7179 self.overlay_dirty.set(true);
7180 self.rebuild_overlay();
7181 dropped = true;
7182 continue;
7183 }
7184 applied_cursor = Some(applied_cursor.map_or(next_cursor, |c| c.max(next_cursor)));
7185 }
7186 if let Some(cursor) = applied_cursor {
7187 self.reconcile_blob_refcounts(false);
7188 let ack = format!("{{\"type\":\"ack\",\"cursor\":{cursor}}}");
7190 let _ = transport.realtime_send(&ack);
7191 } else if !any_covered || dropped {
7192 self.set_sync_needed(true, true);
7194 }
7195 }
7196
7197 fn ack_after_pull(&mut self, transport: &mut dyn Transport) {
7201 if !self.realtime_connected {
7202 return;
7203 }
7204 let floor = self
7205 .subs
7206 .iter()
7207 .filter(|s| {
7208 s.state == SubState::Active
7209 && s.bootstrap_state.is_none()
7210 && s.synced_once
7211 && s.cursor >= 0
7212 })
7213 .map(|s| s.cursor)
7214 .min();
7215 if let Some(cursor) = floor {
7216 let ack = format!("{{\"type\":\"ack\",\"cursor\":{cursor}}}");
7217 let _ = transport.realtime_send(&ack);
7218 }
7219 }
7220}