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