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