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