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