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