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