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