1use std::cell::{Cell, RefCell};
11use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet, VecDeque};
12
13use rusqlite::types::{ToSqlOutput, Value as SqlValue, ValueRef};
14use rusqlite::{Connection, OpenFlags, OptionalExtension};
15use serde::{Deserialize, Serialize};
16use serde_json::{Map, Value};
17use sha2::{Digest, Sha256};
18use ssp2::decode::WIRE_VERSION;
19use ssp2::model::{Frame, MediaType, Message, MsgKind, Op, OpResult, PushStatus, SubStatus};
20use ssp2::primitives::RawJson;
21use ssp2::segment::{decode_rows_segment, Column, ColumnType, ColumnValue, Row, RowsSegment};
22use ssp2::{
23 decode_message, encode_message, encode_presence_publish, parse_control, ControlMessage,
24 PresenceKind,
25};
26
27use crate::api::{
28 ClientChangeBatch, ClientDiagnosticsHost, ClientDiagnosticsLease, ClientDiagnosticsReplica,
29 ClientDiagnosticsRequest, ClientDiagnosticsSchema, ClientDiagnosticsSnapshot,
30 ClientDiagnosticsStorage, ClientLimits, CommandEffects, CommitOperation,
31 CommitOperationOutcome, CommitOutcome, CommitOutcomeQuery, CommitOutcomeResolution,
32 CommitOutcomeStatus, ConflictRecord, CoverageSnapshot, DiagnosticLastChange,
33 DiagnosticLastRound, DiagnosticRoundCounters, DiagnosticSubscription, FetchedBlob, LeaseState,
34 LocalDataPurgeInput, LocalDataPurgeResult, LocalDataPurgeTarget, LocalDataRebootstrapInput,
35 LocalDataRebootstrapResult, Mutation, PresencePeer, QueryRow, QuerySnapshot, QueryValue,
36 RejectionDetails, RejectionRecord, ResolveCommitOutcomeInput, RowState, SchemaFloor,
37 SubscriptionStateView, SyncIntent, SyncOutcome, SyncReport, SyncStatusSnapshot, TableChange,
38 WindowBase, WindowChange, WindowCoverage, WindowState, WindowUnitRef,
39 CLIENT_DIAGNOSTICS_VERSION, MAX_DIAGNOSTIC_EXPECTED_SUBSCRIPTIONS,
40};
41#[cfg(feature = "bench-internals")]
42use crate::bench::{Phase, Recorder};
43use crate::schema::{parse_schema_json, ClientSchema, FtsIndexSchema, TableSchema};
44use crate::transport::{BlobDownload, BlobUploadGrant, SegmentRequest, Transport, TransportError};
45use crate::values::{
46 bytes_to_hex, canonical_scope_json, column_value_to_json, decode_row_bytes, encode_row_json,
47 json_to_column_value, json_to_scope_map, normalize_values_casing, render_row_id_json,
48 scope_map_to_json, sort_scope_map,
49};
50
51const DEFAULT_ACCEPT: u8 = 0b0111;
56const ACCEPT_INLINE_ROWS: u8 = 1 << 0;
57const ACCEPT_EXTERNAL_ROWS: u8 = 1 << 1;
58const ACCEPT_SQLITE: u8 = 1 << 2;
59const ACCEPT_SIGNED_URLS: u8 = 1 << 3;
60const MAX_DIAGNOSTIC_DOMAINS: usize = 256;
61
62const LOCAL_SCHEMA_VERSION_KEY: &str = "localSchemaVersion";
64const LOCAL_REVISION_KEY: &str = "localRevision";
65const CLIENT_ID_KEY: &str = "clientId";
66const LEASE_STATE_KEY: &str = "leaseState";
67const SCHEMA_FLOOR_KEY: &str = "schemaFloor";
68const LOG_EPOCH_KEY: &str = "logEpoch";
69const SECURITY_PREFLIGHT_PENDING_KEY: &str = "securityPreflightPending";
74const LOCAL_REBOOTSTRAP_RECEIPT_VERSION: u8 = 2;
75const MAX_JS_SAFE_INTEGER: u64 = 9_007_199_254_740_991;
76const OUTBOX_INCOMPATIBLE_CODE: &str = "sync.outbox_incompatible";
79
80#[derive(Debug, Serialize, Deserialize)]
81#[serde(rename_all = "camelCase", deny_unknown_fields)]
82struct PersistedLocalDataRebootstrapReceipt {
83 version: u8,
84 retained_commits: u64,
85 reset_subscriptions: u64,
86}
87
88fn invalid_local_rebootstrap_receipt() -> String {
89 "sync.local_corrupt: persisted local rebootstrap receipt is invalid".to_owned()
90}
91
92fn encode_local_rebootstrap_receipt(
93 retained_commits: usize,
94 reset_subscriptions: usize,
95) -> Result<String, String> {
96 let retained_commits =
97 u64::try_from(retained_commits).map_err(|_| invalid_local_rebootstrap_receipt())?;
98 let reset_subscriptions =
99 u64::try_from(reset_subscriptions).map_err(|_| invalid_local_rebootstrap_receipt())?;
100 if retained_commits > MAX_JS_SAFE_INTEGER || reset_subscriptions > MAX_JS_SAFE_INTEGER {
101 return Err(invalid_local_rebootstrap_receipt());
102 }
103 serde_json::to_string(&PersistedLocalDataRebootstrapReceipt {
104 version: LOCAL_REBOOTSTRAP_RECEIPT_VERSION,
105 retained_commits,
106 reset_subscriptions,
107 })
108 .map_err(|_| invalid_local_rebootstrap_receipt())
109}
110
111fn decode_local_rebootstrap_receipt(value: &str) -> Result<(usize, usize), String> {
112 if value == "v1" {
114 return Ok((0, 0));
115 }
116 let receipt: PersistedLocalDataRebootstrapReceipt =
117 serde_json::from_str(value).map_err(|_| invalid_local_rebootstrap_receipt())?;
118 if receipt.version != LOCAL_REBOOTSTRAP_RECEIPT_VERSION
119 || receipt.retained_commits > MAX_JS_SAFE_INTEGER
120 || receipt.reset_subscriptions > MAX_JS_SAFE_INTEGER
121 {
122 return Err(invalid_local_rebootstrap_receipt());
123 }
124 Ok((
125 usize::try_from(receipt.retained_commits)
126 .map_err(|_| invalid_local_rebootstrap_receipt())?,
127 usize::try_from(receipt.reset_subscriptions)
128 .map_err(|_| invalid_local_rebootstrap_receipt())?,
129 ))
130}
131
132#[derive(Debug, Clone, Copy, PartialEq, Eq)]
133enum SubState {
134 Active,
135 Revoked,
136 Failed,
137}
138
139#[cfg(test)]
140mod observation_tests {
141 use super::*;
142 use crate::native_transport::HostTransport;
143 use serde_json::json;
144
145 fn client() -> SyncClient {
146 SyncClient::new(
147 "retry-test".to_owned(),
148 &json!({
149 "version": 1,
150 "tables": [{
151 "name": "tasks",
152 "primaryKey": "id",
153 "columns": [
154 { "name": "id", "type": "string", "nullable": false },
155 { "name": "project_id", "type": "string", "nullable": false }
156 ],
157 "scopes": [{ "pattern": "project:{project_id}" }]
158 }]
159 }),
160 ClientLimits::default(),
161 )
162 .expect("test client")
163 }
164
165 #[test]
166 fn blob_staging_failures_preserve_durable_state_and_allow_retry() {
167 let schema = json!({"version":1,"tables":[{"name":"attachments","primaryKey":"id","columns":[
168 {"name":"id","type":"string","nullable":false},
169 {"name":"file","type":"blob_ref","nullable":true}],"scopes":[]}]});
170 for fault in ["pin", "commit", "body"] {
171 for state in ["absent", "cached", "pinned"] {
172 let path = std::env::temp_dir().join(format!(
173 "syncular-blob-stage-{}.sqlite",
174 uuid::Uuid::new_v4()
175 ));
176 let mut client = SyncClient::open_path(
177 "stage-test".into(),
178 &schema,
179 Default::default(),
180 path.to_str().unwrap(),
181 )
182 .unwrap();
183 let bytes = b"atomic stage";
184 if state != "absent" {
185 client
186 .upload_blob(bytes, Some("text/plain".into()), None)
187 .unwrap();
188 if state == "cached" {
189 client
190 .conn
191 .execute("DELETE FROM _syncular_blob_uploads", [])
192 .unwrap();
193 }
194 client
195 .conn
196 .execute("UPDATE _syncular_blobs SET last_used_ms = 1", [])
197 .unwrap();
198 }
199 let bodies = client.query("SELECT * FROM _syncular_blobs", &[]).unwrap();
200 let pins = client
201 .query("SELECT * FROM _syncular_blob_uploads", &[])
202 .unwrap();
203 client.conn.execute_batch(match fault {
204 "body" => "CREATE TRIGGER fail_stage BEFORE INSERT ON _syncular_blobs BEGIN SELECT RAISE(ABORT, 'injected stage failure'); END",
205 "pin" => "CREATE TRIGGER fail_stage BEFORE INSERT ON _syncular_blob_uploads BEGIN SELECT RAISE(ABORT, 'injected stage failure'); END",
206 _ => "PRAGMA foreign_keys = ON; CREATE TABLE stage_parent(id INTEGER PRIMARY KEY); CREATE TABLE stage_child(id INTEGER REFERENCES stage_parent(id) DEFERRABLE INITIALLY DEFERRED); CREATE TRIGGER fail_stage BEFORE INSERT ON _syncular_blob_uploads BEGIN INSERT INTO stage_child VALUES(1); END",
207 }).unwrap();
208 let error = client
209 .upload_blob(bytes, Some("application/octet-stream".into()), None)
210 .unwrap_err();
211 assert!(
212 error.contains(if fault == "commit" {
213 "FOREIGN KEY"
214 } else {
215 "injected stage failure"
216 }),
217 "{fault}/{state}: {error}"
218 );
219 assert!(
220 client.conn.is_autocommit(),
221 "failed staging must close its transaction"
222 );
223 let immediate_bodies = client.query("SELECT * FROM _syncular_blobs", &[]).unwrap();
224 let immediate_pins = client
225 .query("SELECT * FROM _syncular_blob_uploads", &[])
226 .unwrap();
227 drop(client);
228 let mut reopened = SyncClient::open_path(
229 "stage-test".into(),
230 &schema,
231 Default::default(),
232 path.to_str().unwrap(),
233 )
234 .unwrap();
235 let durable_bodies = reopened
236 .query("SELECT * FROM _syncular_blobs", &[])
237 .unwrap();
238 let durable_pins = reopened
239 .query("SELECT * FROM _syncular_blob_uploads", &[])
240 .unwrap();
241 reopened
242 .conn
243 .execute_batch("DROP TRIGGER fail_stage")
244 .unwrap();
245 let retried = reopened.upload_blob(bytes, None, None).unwrap();
246 assert_eq!(reopened.upload_blob(bytes, None, None).unwrap(), retried);
247 assert_eq!(
248 reopened
249 .query("SELECT * FROM _syncular_blobs", &[])
250 .unwrap()
251 .len(),
252 1
253 );
254 assert_eq!(
255 reopened
256 .query("SELECT * FROM _syncular_blob_uploads", &[])
257 .unwrap()
258 .len(),
259 1
260 );
261 if fault == "commit" {
262 assert!(reopened
263 .query("SELECT * FROM stage_child", &[])
264 .unwrap()
265 .is_empty());
266 }
267 drop(reopened);
268 std::fs::remove_file(&path).unwrap();
269 assert_eq!(
270 durable_bodies, bodies,
271 "{fault}/{state}: reopened body state"
272 );
273 assert_eq!(durable_pins, pins, "{fault}/{state}: reopened pin state");
274 assert_eq!(
275 immediate_bodies, bodies,
276 "{fault}/{state}: immediate body state"
277 );
278 assert_eq!(immediate_pins, pins, "{fault}/{state}: immediate pin state");
279 }
280 }
281 }
282
283 #[test]
284 fn blob_upload_failures_preserve_pins_and_original_commits_after_file_reopen() {
285 let schema = json!({"version":1,"tables":[{"name":"attachments","primaryKey":"id","columns":[
286 {"name":"id","type":"string","nullable":false},
287 {"name":"file","type":"blob_ref","nullable":true}],"scopes":[]}]});
288 for fault in [
289 "missing",
290 "body-type",
291 "length",
292 "hash",
293 "pin-metadata",
294 "read",
295 "pin-delete",
296 ] {
297 let path = std::env::temp_dir().join(format!(
298 "syncular-blob-upload-{}.sqlite",
299 uuid::Uuid::new_v4()
300 ));
301 let mut client = SyncClient::open_path(
302 "upload-test".into(),
303 &schema,
304 Default::default(),
305 path.to_str().unwrap(),
306 )
307 .unwrap();
308 let bytes = b"durable upload";
309 let ref_value = client
310 .upload_blob(bytes, Some("text/plain".into()), None)
311 .unwrap();
312 let commit = client
313 .mutate(vec![Mutation::Upsert {
314 table: "attachments".into(),
315 values: Map::from_iter([
316 ("id".into(), Value::from("pending")),
317 (
318 "file".into(),
319 Value::from(format!(
320 "{{\"blobId\":\"{}\",\"byteLength\":{}}}",
321 blob_id_for(bytes),
322 bytes.len()
323 )),
324 ),
325 ]),
326 base_version: None,
327 }])
328 .unwrap();
329 client.conn.execute_batch("CREATE TABLE saved_bodies AS SELECT * FROM _syncular_blobs; CREATE TABLE saved_uploads AS SELECT * FROM _syncular_blob_uploads").unwrap();
330 client.conn.execute_batch(match fault {
331 "missing" => "DELETE FROM _syncular_blobs",
332 "body-type" => "UPDATE _syncular_blobs SET bytes = 'wrong type'",
333 "length" => "UPDATE _syncular_blobs SET byte_length = byte_length + 1",
334 "hash" => "UPDATE _syncular_blobs SET bytes = zeroblob(byte_length)",
335 "pin-metadata" => "UPDATE _syncular_blob_uploads SET media_type = x'ff'",
336 "read" => "ALTER TABLE _syncular_blobs RENAME TO unavailable_bodies",
337 _ => "CREATE TRIGGER fail_pin_delete BEFORE DELETE ON _syncular_blob_uploads BEGIN SELECT RAISE(ABORT, 'injected pin deletion failure'); END",
338 }).unwrap();
339 let pins = client
340 .query("SELECT * FROM _syncular_blob_uploads", &[])
341 .unwrap();
342 let mut transport = CountingRealtimeTransport::default();
343 let error = client.flush_blob_uploads(&mut transport).unwrap_err();
344 assert_eq!(
345 error.code,
346 if matches!(fault, "read" | "pin-delete") {
347 "client.failed"
348 } else {
349 "sync.local_corrupt"
350 },
351 "{fault}"
352 );
353 assert_eq!(
354 transport.blob_uploads.len(),
355 usize::from(fault == "pin-delete"),
356 "{fault}: corrupt bytes must never be uploaded"
357 );
358 assert_eq!(client.pending_commit_ids(), std::slice::from_ref(&commit));
359 assert_eq!(
360 client
361 .query("SELECT * FROM _syncular_blob_uploads", &[])
362 .unwrap(),
363 pins
364 );
365 if fault == "read" {
367 client
368 .conn
369 .execute_batch("ALTER TABLE unavailable_bodies RENAME TO _syncular_blobs")
370 .unwrap();
371 }
372 drop(client);
373 let mut reopened = SyncClient::open_path(
374 "upload-test".into(),
375 &schema,
376 Default::default(),
377 path.to_str().unwrap(),
378 )
379 .unwrap();
380 assert_eq!(
381 reopened.pending_commit_ids(),
382 std::slice::from_ref(&commit),
383 "{fault}: original durable commit"
384 );
385 assert_eq!(
386 reopened
387 .query("SELECT * FROM _syncular_blob_uploads", &[])
388 .unwrap(),
389 pins,
390 "{fault}: durable pin"
391 );
392 if fault == "pin-delete" {
393 reopened
394 .conn
395 .execute_batch("DROP TRIGGER fail_pin_delete")
396 .unwrap();
397 }
398 reopened.conn.execute_batch("DELETE FROM _syncular_blobs; INSERT INTO _syncular_blobs SELECT * FROM saved_bodies; DELETE FROM _syncular_blob_uploads; INSERT INTO _syncular_blob_uploads SELECT * FROM saved_uploads").unwrap();
399 transport.blob_uploads.clear();
400 reopened.flush_blob_uploads(&mut transport).unwrap();
401 assert_eq!(
402 transport.blob_uploads,
403 [(blob_id_for(bytes), bytes.to_vec())]
404 );
405 assert!(reopened
406 .query("SELECT * FROM _syncular_blob_uploads", &[])
407 .unwrap()
408 .is_empty());
409 assert_eq!(
410 reopened.pending_commit_ids(),
411 [commit],
412 "upload success alone must not drain the metadata commit"
413 );
414 assert_eq!(
415 reopened
416 .get_cached_blob_bytes(ref_value["blobId"].as_str().unwrap())
417 .unwrap()
418 .unwrap()
419 .bytes,
420 bytes
421 );
422 drop(reopened);
423 std::fs::remove_file(path).unwrap();
424 }
425 }
426
427 #[test]
428 fn incoming_frame_transactions_preserve_prefix_cursor_and_revision_after_reopen() {
429 let schema = json!({"version":1,"tables":[{"name":"tasks","primaryKey":"id","columns":[
430 {"name":"id","type":"string","nullable":false},{"name":"project_id","type":"string","nullable":false}],
431 "scopes":[{"pattern":"project:{project_id}"}]}]});
432 for realtime in [false, true] {
433 for fault in [
434 "none",
435 "row",
436 "revision",
437 "commit",
438 "cursor",
439 "error",
440 "missing-end",
441 ] {
442 if realtime && fault == "missing-end" {
443 continue;
444 }
445 let path = std::env::temp_dir()
446 .join(format!("syncular-frame-{}.sqlite", uuid::Uuid::new_v4()));
447 let mut client = SyncClient::open_path(
448 "frame-test".into(),
449 &schema,
450 Default::default(),
451 path.to_str().unwrap(),
452 )
453 .unwrap();
454 client.set_meta(LOG_EPOCH_KEY, "epoch-1");
455 client
456 .subscribe(
457 "tasks".into(),
458 "tasks".into(),
459 vec![("project_id".into(), vec!["p1".into()])],
460 None,
461 )
462 .unwrap();
463 client.subs[0].cursor = 0;
464 client.subs[0].synced_once = true;
465 client.subs[0].effective = Some(vec![("project_id".into(), vec!["p1".into()])]);
466 client.persist_sub(&client.subs[0]).unwrap();
467 client.realtime_connected = true;
468 client.drain_change_batches();
469 let mut transport = CountingRealtimeTransport::default();
470 let (_, meta) = client.build_request(false);
471 let mut response = Message {
472 wire_version: WIRE_VERSION,
473 msg_kind: MsgKind::Response,
474 frames: vec![
475 Frame::RespHeader {
476 required_schema_version: None,
477 latest_schema_version: None,
478 log_epoch: Some("epoch-1".into()),
479 reset_required: Some(false),
480 },
481 Frame::SubStart {
482 id: "tasks".into(),
483 status: SubStatus::Active,
484 reason_code: "".into(),
485 effective_scopes: vec![("project_id".into(), vec!["p1".into()])],
486 bootstrap: false,
487 },
488 ],
489 };
490 for (index, id) in ["first", "second"].iter().enumerate() {
491 let values = Map::from_iter([
492 ("id".into(), Value::from(*id)),
493 ("project_id".into(), Value::from("p1")),
494 ]);
495 response.frames.push(Frame::Commit {
496 commit_seq: index as i64 + 1,
497 actor_id: "writer".into(),
498 created_at_ms: 1,
499 tables: vec!["tasks".into()],
500 changes: vec![ssp2::model::Change {
501 table_index: 0,
502 row_id: (*id).into(),
503 op: Op::Upsert,
504 row_version: Some(index as i64 + 1),
505 scopes: vec![("project_id".into(), "p1".into())],
506 row: Some(
507 encode_row_json(
508 client.schema.table("tasks").unwrap(),
509 id,
510 &values,
511 &client.encryption,
512 )
513 .unwrap(),
514 ),
515 }],
516 });
517 }
518 response.frames.push(Frame::SubEnd {
519 next_cursor: 2,
520 bootstrap_state: None,
521 });
522 let valid = response.clone();
523 match fault {
524 "row" => {
525 let Frame::Commit {changes, ..} = &mut response.frames[3] else {panic!("second frame")};
526 let mut malformed = changes[0].clone(); malformed.row_id = "malformed".into(); malformed.row = Some(vec![]); changes.push(malformed);
527 },
528 "revision" => client.conn.execute_batch("CREATE TRIGGER fail_frame BEFORE INSERT ON _syncular_meta WHEN NEW.key = 'localRevision' AND NEW.value = '2' AND EXISTS (SELECT 1 FROM _syncular_base_tasks WHERE id = 'second') BEGIN SELECT RAISE(FAIL, 'injected revision failure'); END").unwrap(),
529 "commit" => client.conn.execute_batch("PRAGMA foreign_keys = ON; CREATE TABLE frame_parent(id INTEGER PRIMARY KEY); CREATE TABLE frame_child(id INTEGER REFERENCES frame_parent(id) DEFERRABLE INITIALLY DEFERRED); CREATE TRIGGER fail_frame AFTER INSERT ON _syncular_base_tasks WHEN NEW.id = 'second' BEGIN INSERT INTO frame_child VALUES(1); END").unwrap(),
530 "cursor" => client.conn.execute_batch("CREATE TRIGGER fail_frame BEFORE INSERT ON _syncular_subscriptions BEGIN SELECT RAISE(FAIL, 'injected cursor failure'); END").unwrap(),
531 "error" => {response.frames.truncate(3);response.frames.push(Frame::Error {code:"sync.invalid_request".into(),message:"later error".into(),category:"protocol".into(),retryable:false,recommended_action:"retry".into(),details:None});},
532 "missing-end" => {response.frames.truncate(3);},
533 _ => {}
534 }
535 if realtime {
536 client.on_realtime_binary(&mut transport, &encode_message(&response));
537 } else {
538 assert_eq!(
539 matches!(
540 client.process_response(&mut transport, response, &meta),
541 SyncOutcome::Ok(_)
542 ),
543 fault == "none",
544 "{fault}"
545 );
546 }
547 assert!(
548 client.conn.is_autocommit(),
549 "no open transaction after {realtime}/{fault}"
550 );
551 let count = if matches!(fault, "none" | "cursor") {
552 2
553 } else {
554 1
555 };
556 let expected_revision = count + u64::from(realtime && fault != "none");
557 assert_eq!(
558 client.local_revision(),
559 expected_revision,
560 "{realtime}/{fault}"
561 );
562 let batches = client.drain_change_batches();
563 assert_eq!(
564 batches.len(),
565 expected_revision as usize,
566 "{realtime}/{fault}"
567 );
568 let row_batches: Vec<_> = batches
569 .iter()
570 .filter(|batch| !batch.tables.is_empty())
571 .collect();
572 assert_eq!(row_batches.len(), count as usize);
573 assert_eq!(
574 row_batches
575 .iter()
576 .map(|b| b.revision.clone())
577 .collect::<Vec<_>>(),
578 (1..=count).map(|r| r.to_string()).collect::<Vec<_>>()
579 );
580 for table in ["tasks", "_syncular_base_tasks"] {
581 assert_eq!(
582 client
583 .conn
584 .query_row(&format!("SELECT COUNT(*) FROM {table}"), [], |row| row
585 .get::<_, u64>(0))
586 .unwrap(),
587 count,
588 "{realtime}/{fault}/{table}"
589 );
590 }
591 assert_eq!(client.subs[0].cursor, if fault == "none" { 2 } else { 0 });
592 assert_eq!(
593 transport
594 .messages
595 .iter()
596 .filter(|m| m.contains("\"ack\""))
597 .count(),
598 usize::from(fault == "none")
599 );
600 if matches!(fault, "revision" | "commit" | "cursor") {
601 client
602 .conn
603 .execute_batch("DROP TRIGGER fail_frame")
604 .unwrap();
605 }
606 drop(client);
607 let mut reopened = SyncClient::open_path(
608 "frame-test".into(),
609 &schema,
610 Default::default(),
611 path.to_str().unwrap(),
612 )
613 .unwrap();
614 assert_eq!(reopened.local_revision(), expected_revision);
615 assert_eq!(
616 reopened
617 .query("SELECT id FROM tasks ORDER BY id", &[])
618 .unwrap()
619 .len(),
620 count as usize
621 );
622 assert_eq!(reopened.subs[0].cursor, if fault == "none" { 2 } else { 0 });
623 let (_, meta) = reopened.build_request(false);
624 assert!(matches!(
625 reopened.process_response(&mut transport, valid, &meta),
626 SyncOutcome::Ok(_)
627 ));
628 assert_eq!(
629 reopened
630 .query("SELECT id FROM tasks ORDER BY id", &[])
631 .unwrap()
632 .len(),
633 2
634 );
635 assert_eq!(reopened.subs[0].cursor, 2);
636 drop(reopened);
637 std::fs::remove_file(path).unwrap();
638 }
639 }
640 }
641
642 #[test]
643 fn failed_subscription_control_persistence_rolls_back_rows_outbox_and_memory() {
644 for status in [SubStatus::Reset, SubStatus::Revoked] {
645 for fault in ["subscription", "revision"] {
646 let mut client = client();
647 client
648 .subscribe(
649 "tasks".into(),
650 "tasks".into(),
651 vec![("project_id".into(), vec!["p1".into()])],
652 None,
653 )
654 .unwrap();
655 client.subs[0].cursor = 41;
656 client.subs[0].synced_once = true;
657 client.subs[0].effective = Some(vec![("project_id".into(), vec!["p1".into()])]);
658 client.persist_sub(&client.subs[0]).unwrap();
659 client
660 .mutate(vec![Mutation::Upsert {
661 table: "tasks".into(),
662 values: Map::from_iter([
663 ("id".into(), Value::from("pending")),
664 ("project_id".into(), Value::from("p1")),
665 ]),
666 base_version: None,
667 }])
668 .unwrap();
669 let ids = client.pending_commit_ids();
670 let rows = client.query("SELECT * FROM tasks", &[]).unwrap();
671 let revision = client.local_revision();
672 client.drain_change_batches();
673 let (_, meta) = client.build_request(false);
674 client.conn.execute_batch(if fault == "subscription" {
675 "CREATE TRIGGER fail_control BEFORE INSERT ON _syncular_subscriptions BEGIN SELECT RAISE(FAIL, 'injected subscription failure'); END"
676 } else {
677 "CREATE TRIGGER fail_control BEFORE INSERT ON _syncular_meta WHEN NEW.key = 'localRevision' BEGIN SELECT RAISE(FAIL, 'injected revision failure'); END"
678 }).unwrap();
679 let mut transport = CountingRealtimeTransport::default();
680 let mut report = SyncReport::default();
681 if status == SubStatus::Reset {
683 client.conn.execute("INSERT INTO _syncular_windows(base,unit,sub_id) VALUES ('base','p1','tasks')", []).unwrap();
684 }
685 let result = client.process_section(
686 &mut transport,
687 "tasks",
688 status,
689 "sync.scope_revoked",
690 vec![],
691 vec![],
692 Some((42, None)),
693 &meta,
694 &mut report,
695 );
696 assert!(result.is_err(), "{status:?}/{fault}");
697 assert!(client.conn.is_autocommit());
698 assert_eq!(client.subs[0].cursor, 41);
699 assert_eq!(client.subs[0].state, SubState::Active);
700 assert_eq!(client.pending_commit_ids(), ids);
701 assert!(client.commit_outcome(&ids[0]).unwrap().is_none());
702 assert!(client.rejections.is_empty());
703 assert_eq!(client.query("SELECT * FROM tasks", &[]).unwrap(), rows);
704 assert_eq!(client.local_revision(), revision);
705 assert!(client.drain_change_batches().is_empty());
706 assert!(report.revoked.is_empty() && report.resets.is_empty());
707 }
708 }
709 }
710
711 #[test]
712 fn rows_segment_blocks_commit_independently_and_clear_with_the_first_block() {
713 let mut client = client();
714 client
715 .subscribe(
716 "tasks".into(),
717 "tasks".into(),
718 vec![("project_id".into(), vec!["p1".into()])],
719 None,
720 )
721 .unwrap();
722 client.subs[0].effective = Some(vec![("project_id".into(), vec!["p1".into()])]);
723 client.conn.execute_batch("INSERT INTO _syncular_base_tasks VALUES ('stale','p1',1); INSERT INTO tasks VALUES ('stale','p1',1);
724 CREATE TRIGGER fail_block BEFORE INSERT ON _syncular_base_tasks WHEN NEW.id = 'bad' BEGIN SELECT RAISE(FAIL,'injected later block failure'); END").unwrap();
725 let row = |id: &str| ssp2::segment::SegmentRow {
726 server_version: 1,
727 values: vec![
728 Some(ColumnValue::String(id.into())),
729 Some(ColumnValue::String("p1".into())),
730 ],
731 };
732 let segment = RowsSegment {
733 table: "tasks".into(),
734 schema_version: 1,
735 columns: client.schema.table("tasks").unwrap().wire_columns.clone(),
736 blocks: vec![vec![row("first")], vec![row("second"), row("bad")]],
737 };
738 assert!(client.apply_segment(0, &segment, true).is_err());
739 assert!(client.conn.is_autocommit());
740 assert_eq!(
741 client
742 .query("SELECT id FROM tasks ORDER BY id", &[])
743 .unwrap(),
744 vec![Map::from_iter([("id".into(), Value::from("first"))])]
745 );
746 assert_eq!(client.local_revision(), 1);
747 assert_eq!(client.drain_change_batches().len(), 1);
748 assert_eq!(client.subs[0].cursor, -1);
749 client
750 .conn
751 .execute_batch("DROP TRIGGER fail_block")
752 .unwrap();
753 assert!(matches!(client.apply_segment(0, &segment, true), Ok(3)));
754 assert_eq!(client.local_revision(), 3);
755 assert_eq!(client.drain_change_batches().len(), 2);
756 }
757
758 #[test]
759 fn diagnostic_storage_reads_fresh_aggregates_after_writes_rollback_and_schema_changes() {
760 assert_eq!(
761 client().diagnostics_storage().blob_cache_bytes_approx,
762 Some(0)
763 );
764 let client = SyncClient::new(
765 "diagnostic-storage".to_owned(),
766 &json!({
767 "version": 1, "tables": [{"name": "attachments", "primaryKey": "id",
768 "columns": [{"name": "id", "type": "string", "nullable": false},
769 {"name": "body", "type": "blob_ref", "nullable": false}], "scopes": []}]
770 }),
771 ClientLimits {
772 blob_cache_max_bytes: Some(5),
773 ..Default::default()
774 },
775 )
776 .unwrap();
777 let empty = client.diagnostics_storage();
778 assert_eq!(empty.status, "healthy");
779 assert_eq!(empty.pending_outbox_bytes_approx, Some(0));
780 assert_eq!(empty.retained_outcome_entries, Some(0));
781 assert_eq!(empty.retained_outcome_bytes_approx, Some(0));
782 assert_eq!(empty.blob_cache_bytes_approx, Some(0));
783 client.conn.execute_batch("INSERT INTO _syncular_outbox(commit_id, ops_json) VALUES ('one', '[]');
784 INSERT INTO _syncular_commit_outcomes(client_commit_id,status,recorded_at_ms,results_json,operations_json)
785 VALUES ('one','applied',0,'[]',NULL),('two','rejected',0,'[]','[]');
786 INSERT INTO _syncular_blobs(blob_id,bytes,byte_length,refcount,last_used_ms,created_at_ms)
787 VALUES ('body',zeroblob(8),8,1,0,0);").unwrap();
788 let written = client.diagnostics_storage();
789 assert_eq!(written.status, "pressure");
790 assert_eq!(written.pending_outbox_bytes_approx, Some(2));
791 assert_eq!(written.retained_outcome_entries, Some(2));
792 assert_eq!(written.retained_outcome_bytes_approx, Some(6));
793 assert_eq!(written.blob_cache_bytes_approx, Some(8));
794 assert_eq!(
795 written.pressure_reason_code.as_deref(),
796 Some("client.blob_cache_over_limit")
797 );
798 client
799 .conn
800 .execute_batch(
801 "BEGIN; UPDATE _syncular_outbox SET ops_json = '[1,2]';
802 DELETE FROM _syncular_commit_outcomes; DELETE FROM _syncular_blobs;",
803 )
804 .unwrap();
805 let staged = client.diagnostics_storage();
806 assert_eq!(staged.pending_outbox_bytes_approx, Some(5));
807 assert_eq!(staged.retained_outcome_entries, Some(0));
808 assert_eq!(staged.blob_cache_bytes_approx, Some(0));
809 assert_eq!(staged.status, "healthy");
810 client.conn.execute_batch("ROLLBACK").unwrap();
811 assert_eq!(client.diagnostics_storage(), written);
812 client.conn.execute_batch("ALTER TABLE _syncular_commit_outcomes RENAME COLUMN results_json TO hidden_results").unwrap();
813 let unreadable = client.diagnostics_storage();
814 assert_eq!(unreadable.status, "unreadable");
815 assert!(unreadable.database_bytes_approx.is_none());
816 assert!(unreadable.pending_outbox_bytes_approx.is_none());
817 assert!(unreadable.retained_outcome_entries.is_none());
818 assert!(unreadable.retained_outcome_bytes_approx.is_none());
819 assert!(unreadable.blob_cache_bytes_approx.is_none());
820 client.conn.execute_batch("ALTER TABLE _syncular_commit_outcomes RENAME COLUMN hidden_results TO results_json").unwrap();
821 let restored = client.diagnostics_storage();
822 assert_eq!(restored.status, written.status);
823 assert_eq!(
824 restored.retained_outcome_bytes_approx,
825 written.retained_outcome_bytes_approx
826 );
827 client.conn.set_prepared_statement_cache_capacity(0);
828 assert_eq!(client.diagnostics_storage(), restored);
829 }
830
831 #[test]
832 fn background_retry_deadlines_back_off_and_reset() {
833 let mut client = client();
834 client.schedule_background_retry();
835 assert!(matches!(
836 client.drain_sync_intents().as_slice(),
837 [SyncIntent::Background { delay_ms: 250 }]
838 ));
839 client.schedule_background_retry();
840 assert!(matches!(
841 client.drain_sync_intents().as_slice(),
842 [SyncIntent::Background { delay_ms: 500 }]
843 ));
844 client.reset_background_retry();
845 client.schedule_background_retry();
846 assert!(matches!(
847 client.drain_sync_intents().as_slice(),
848 [SyncIntent::Background { delay_ms: 250 }]
849 ));
850 }
851
852 #[test]
853 fn log_epoch_reset_requests_an_immediate_follow_up_round() {
854 let mut client = client();
855 client
856 .subscribe(
857 "epoch-tasks".to_owned(),
858 "tasks".to_owned(),
859 vec![("project_id".to_owned(), vec!["p1".to_owned()])],
860 None,
861 )
862 .expect("subscribe");
863 client.drain_sync_intents();
864
865 client
866 .run_log_epoch_reset("epoch-2")
867 .expect("reset partition log epoch");
868
869 assert!(matches!(
870 client.drain_sync_intents().as_slice(),
871 [SyncIntent::Interactive]
872 ));
873 }
874
875 #[derive(Default)]
876 struct CountingRealtimeTransport {
877 connects: usize,
878 closes: usize,
879 messages: Vec<String>,
880 blob_uploads: Vec<(String, Vec<u8>)>,
881 blob_download: Option<Vec<u8>>,
882 }
883
884 impl Transport for CountingRealtimeTransport {
885 fn blob_upload(
886 &mut self,
887 blob_id: &str,
888 bytes: &[u8],
889 _media_type: Option<&str>,
890 ) -> Result<(), TransportError> {
891 self.blob_uploads.push((blob_id.to_owned(), bytes.to_vec()));
892 Ok(())
893 }
894
895 fn blob_download(&mut self, _blob_id: &str) -> Result<BlobDownload, TransportError> {
896 self.blob_download
897 .clone()
898 .map(BlobDownload::Bytes)
899 .ok_or_else(|| TransportError::new("blob.not_found", "missing blob"))
900 }
901
902 fn sync(&mut self, _request: &[u8]) -> Result<Vec<u8>, TransportError> {
903 Err(TransportError::new("sync.transport_failed", "offline"))
904 }
905
906 fn realtime_sync(&mut self, _request: &[u8]) -> Result<Vec<u8>, TransportError> {
907 Err(TransportError::new("sync.transport_failed", "offline"))
908 }
909
910 fn download_segment(
911 &mut self,
912 _request: &SegmentRequest,
913 ) -> Result<Vec<u8>, TransportError> {
914 Err(TransportError::new("sync.transport_failed", "offline"))
915 }
916
917 fn realtime_connect(&mut self) -> Result<(), TransportError> {
918 self.connects += 1;
919 Ok(())
920 }
921
922 fn realtime_send(&mut self, text: &str) -> Result<(), TransportError> {
923 self.messages.push(text.to_owned());
924 Ok(())
925 }
926
927 fn realtime_close(&mut self) -> Result<(), TransportError> {
928 self.closes += 1;
929 Ok(())
930 }
931 }
932
933 #[test]
934 fn blob_results_own_downloaded_bytes() {
935 let schema = json!({"version":1,"tables":[{"name":"attachments","primaryKey":"id","columns":[
936 {"name":"id","type":"string","nullable":false},
937 {"name":"file","type":"blob_ref","nullable":true}],"scopes":[]}]});
938 let bytes = b"owned typed result".to_vec();
939 let blob_id = blob_id_for(&bytes);
940 let mut transport = CountingRealtimeTransport {
941 blob_download: Some(bytes.clone()),
942 ..Default::default()
943 };
944 let result = {
945 let mut client =
946 SyncClient::new("owned-result".to_owned(), &schema, ClientLimits::default())
947 .unwrap();
948 let result = client.fetch_blob_bytes(&mut transport, &blob_id).unwrap();
949 assert_eq!(result.blob_id, blob_id);
950 assert_eq!(result.byte_length, bytes.len() as i64);
951 assert_eq!(result.bytes, bytes);
952 assert_eq!(result.media_type, None);
953 assert_eq!(
954 client
955 .conn
956 .query_row(
957 "SELECT refcount FROM _syncular_blobs WHERE blob_id = ?",
958 rusqlite::params![blob_id],
959 |row| row.get::<_, i64>(0),
960 )
961 .unwrap(),
962 0
963 );
964 result
965 };
966 assert_eq!(result.bytes, bytes);
967
968 let mut client =
969 SyncClient::new("blob-errors".to_owned(), &schema, ClientLimits::default()).unwrap();
970 assert_eq!(
971 client
972 .fetch_blob_bytes(&mut transport, "not json")
973 .unwrap_err(),
974 (
975 "client.failed".to_owned(),
976 "blob ref is not JSON".to_owned()
977 )
978 );
979 }
980
981 #[test]
982 fn targeted_blob_reconciliation_counts_valid_refs_and_preserves_other_bodies() {
983 let schema = json!({"version":1,"tables":[
984 {"name":"attachments","primaryKey":"id","columns":[
985 {"name":"id","type":"string","nullable":false},
986 {"name":"body","type":"blob_ref","nullable":true},
987 {"name":"preview","type":"blob_ref","nullable":true}],"scopes":[]},
988 {"name":"avatars","primaryKey":"id","columns":[
989 {"name":"id","type":"string","nullable":false},
990 {"name":"photo","type":"blob_ref","nullable":true}],"scopes":[]}
991 ]});
992 let bytes = b"referenced above cap".to_vec();
993 let blob_id = blob_id_for(&bytes);
994 let reference = json!({"blobId": blob_id, "byteLength": bytes.len()}).to_string();
995 let mut client = SyncClient::new(
996 "targeted-refcounts".to_owned(),
997 &schema,
998 ClientLimits {
999 blob_cache_max_bytes: Some(1),
1000 ..Default::default()
1001 },
1002 )
1003 .unwrap();
1004 client.conn.execute(
1005 "INSERT INTO attachments VALUES ('one', ?, ?, 0), ('two', ?, 'malformed', 0), ('three', 12, ?, 0)",
1006 rusqlite::params![reference, reference, reference, reference],
1007 ).unwrap();
1008 client
1009 .conn
1010 .execute(
1011 "INSERT INTO avatars VALUES ('one', ?, 0), ('two', '{\"blobId\":12}', 0)",
1012 rusqlite::params![reference],
1013 )
1014 .unwrap();
1015 client.conn.execute(
1016 "INSERT INTO _syncular_blobs(blob_id, bytes, byte_length, refcount, created_at_ms, last_used_ms) VALUES ('sha256:other', X'02', 1, 77, 0, 0)",
1017 [],
1018 ).unwrap();
1019 let mut transport = CountingRealtimeTransport {
1020 blob_download: Some(bytes.clone()),
1021 ..Default::default()
1022 };
1023
1024 assert_eq!(
1025 client
1026 .fetch_blob_bytes(&mut transport, &blob_id)
1027 .unwrap()
1028 .bytes,
1029 bytes
1030 );
1031 let rows = client
1032 .conn
1033 .prepare("SELECT blob_id, refcount FROM _syncular_blobs ORDER BY blob_id")
1034 .unwrap()
1035 .query_map([], |row| {
1036 Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)?))
1037 })
1038 .unwrap()
1039 .collect::<Result<Vec<_>, _>>()
1040 .unwrap();
1041 assert_eq!(rows, vec![(blob_id, 5), ("sha256:other".to_owned(), 77)]);
1042 }
1043
1044 #[test]
1045 fn realtime_connection_ownership_is_idempotent() {
1046 let mut client = client();
1047 let mut transport = CountingRealtimeTransport {
1048 connects: 0,
1049 closes: 0,
1050 ..Default::default()
1051 };
1052 client
1053 .connect_realtime(&mut transport)
1054 .expect("first connect");
1055 client
1056 .connect_realtime(&mut transport)
1057 .expect("idempotent connect");
1058 assert_eq!(transport.connects, 1);
1059 client.disconnect_realtime(&mut transport);
1060 client.disconnect_realtime(&mut transport);
1061 assert_eq!(transport.closes, 1);
1062 client
1063 .connect_realtime(&mut transport)
1064 .expect("deliberate reconnect");
1065 assert_eq!(transport.connects, 2);
1066 }
1067
1068 #[test]
1069 fn local_rebootstrap_is_atomic_idempotent_and_preserves_offline_work() {
1070 let mut client = client();
1071 client
1072 .subscribe(
1073 "repair-tasks".to_owned(),
1074 "tasks".to_owned(),
1075 vec![("project_id".to_owned(), vec!["p1".to_owned()])],
1076 None,
1077 )
1078 .expect("subscribe");
1079 {
1080 let sub = client
1081 .subs
1082 .iter_mut()
1083 .find(|sub| sub.id == "repair-tasks")
1084 .expect("subscription");
1085 sub.cursor = 42;
1086 sub.synced_once = true;
1087 let persisted = sub.clone();
1088 client.persist_sub(&persisted).unwrap();
1089 }
1090 for table in ["_syncular_base_tasks", "tasks"] {
1091 client
1092 .conn
1093 .execute(
1094 &format!(
1095 "INSERT INTO {table}(id, project_id, _syncular_version) VALUES (?1, ?2, 1)"
1096 ),
1097 rusqlite::params!["server-row", "p1"],
1098 )
1099 .expect("seed server row");
1100 }
1101 let pending = client
1102 .mutate(vec![Mutation::Upsert {
1103 table: "tasks".to_owned(),
1104 values: Map::from_iter([
1105 ("id".to_owned(), Value::from("offline-row")),
1106 ("project_id".to_owned(), Value::from("p1")),
1107 ]),
1108 base_version: None,
1109 }])
1110 .expect("queue offline work");
1111 client.drain_change_batches();
1112 client.drain_sync_intents();
1113
1114 assert_eq!(
1115 client
1116 .rebootstrap_local_data(&LocalDataRebootstrapInput {
1117 rebootstrap_id: "support-case-001".to_owned(),
1118 })
1119 .expect("rebootstrap"),
1120 LocalDataRebootstrapResult {
1121 already_applied: false,
1122 retained_commits: 1,
1123 reset_subscriptions: 1,
1124 }
1125 );
1126 let visible_ids = client
1127 .conn
1128 .prepare("SELECT id FROM tasks ORDER BY id")
1129 .expect("prepare visible ids")
1130 .query_map([], |row| row.get::<_, String>(0))
1131 .expect("query visible ids")
1132 .collect::<Result<Vec<_>, _>>()
1133 .expect("collect visible ids");
1134 assert_eq!(visible_ids, vec!["offline-row"]);
1135 assert_eq!(client.pending_commit_ids(), vec![pending]);
1136 assert_eq!(
1137 client
1138 .subscription_state("repair-tasks")
1139 .expect("subscription")
1140 .cursor,
1141 -1
1142 );
1143 assert!(client.upgrading());
1144 assert!(client.sync_needed());
1145 assert!(matches!(
1146 client.drain_sync_intents().as_slice(),
1147 [SyncIntent::Interactive]
1148 ));
1149 assert_eq!(client.drain_change_batches().len(), 1);
1150
1151 assert_eq!(
1152 client
1153 .rebootstrap_local_data(&LocalDataRebootstrapInput {
1154 rebootstrap_id: "support-case-001".to_owned(),
1155 })
1156 .expect("idempotent retry"),
1157 LocalDataRebootstrapResult {
1158 already_applied: true,
1159 retained_commits: 1,
1160 reset_subscriptions: 1,
1161 }
1162 );
1163 assert!(client.drain_change_batches().is_empty());
1164 }
1165
1166 #[test]
1167 fn local_rebootstrap_receipt_codec_is_bounded_and_legacy_compatible() {
1168 let encoded = encode_local_rebootstrap_receipt(3, 4).expect("encode receipt");
1169 assert_eq!(
1170 decode_local_rebootstrap_receipt(&encoded).expect("decode receipt"),
1171 (3, 4)
1172 );
1173 assert_eq!(
1174 decode_local_rebootstrap_receipt("v1").expect("legacy marker"),
1175 (0, 0)
1176 );
1177 for malformed in [
1178 "",
1179 "{}",
1180 r#"{"version":3,"retainedCommits":1,"resetSubscriptions":1}"#,
1181 r#"{"version":2,"retainedCommits":1,"resetSubscriptions":1,"extra":true}"#,
1182 r#"{"version":2,"retainedCommits":9007199254740992,"resetSubscriptions":1}"#,
1183 ] {
1184 assert_eq!(
1185 decode_local_rebootstrap_receipt(malformed)
1186 .expect_err("malformed receipt must fail"),
1187 "sync.local_corrupt: persisted local rebootstrap receipt is invalid"
1188 );
1189 }
1190 }
1191
1192 #[test]
1193 fn local_rebootstrap_replays_the_original_receipt_after_reopen() {
1194 let path = std::env::temp_dir().join(format!(
1195 "syncular-rebootstrap-receipt-{}.db",
1196 uuid::Uuid::new_v4()
1197 ));
1198 let schema = json!({
1199 "version": 1,
1200 "tables": [{
1201 "name": "tasks",
1202 "primaryKey": "id",
1203 "columns": [
1204 { "name": "id", "type": "string", "nullable": false },
1205 { "name": "project_id", "type": "string", "nullable": false }
1206 ],
1207 "scopes": [{ "pattern": "project:{project_id}" }]
1208 }]
1209 });
1210 let path_string = path.to_str().expect("UTF-8 temp path");
1211
1212 {
1213 let mut first = SyncClient::open_path(
1214 "repair-restart-client".to_owned(),
1215 &schema,
1216 ClientLimits::default(),
1217 path_string,
1218 )
1219 .expect("first open");
1220 first
1221 .subscribe(
1222 "repair-tasks".to_owned(),
1223 "tasks".to_owned(),
1224 vec![("project_id".to_owned(), vec!["p1".to_owned()])],
1225 None,
1226 )
1227 .expect("subscribe");
1228 first
1229 .mutate(vec![Mutation::Upsert {
1230 table: "tasks".to_owned(),
1231 values: Map::from_iter([
1232 ("id".to_owned(), Value::from("offline-row")),
1233 ("project_id".to_owned(), Value::from("p1")),
1234 ]),
1235 base_version: None,
1236 }])
1237 .expect("queue offline work");
1238 assert_eq!(
1239 first
1240 .rebootstrap_local_data(&LocalDataRebootstrapInput {
1241 rebootstrap_id: "restart-receipt".to_owned(),
1242 })
1243 .expect("first rebootstrap"),
1244 LocalDataRebootstrapResult {
1245 already_applied: false,
1246 retained_commits: 1,
1247 reset_subscriptions: 1,
1248 }
1249 );
1250 }
1251
1252 let mut reopened = SyncClient::open_path(
1253 "repair-restart-client".to_owned(),
1254 &schema,
1255 ClientLimits::default(),
1256 path_string,
1257 )
1258 .expect("reopen");
1259 reopened.drain_change_batches();
1260 reopened.drain_sync_intents();
1261 assert_eq!(
1262 reopened
1263 .rebootstrap_local_data(&LocalDataRebootstrapInput {
1264 rebootstrap_id: "restart-receipt".to_owned(),
1265 })
1266 .expect("receipt replay"),
1267 LocalDataRebootstrapResult {
1268 already_applied: true,
1269 retained_commits: 1,
1270 reset_subscriptions: 1,
1271 }
1272 );
1273 assert!(reopened.drain_change_batches().is_empty());
1274 assert!(reopened.drain_sync_intents().is_empty());
1275 drop(reopened);
1276 std::fs::remove_file(path).expect("remove temp database");
1277 }
1278
1279 #[test]
1280 fn local_rebootstrap_fails_closed_on_malformed_or_unreadable_receipts() {
1281 let mut malformed = client();
1282 malformed
1283 .subscribe(
1284 "repair-tasks".to_owned(),
1285 "tasks".to_owned(),
1286 vec![("project_id".to_owned(), vec!["p1".to_owned()])],
1287 None,
1288 )
1289 .expect("subscribe");
1290 malformed.set_meta("localRebootstrap:malformed", "{\"version\":2}");
1291 malformed.drain_change_batches();
1292 malformed.drain_sync_intents();
1293 let malformed_error = malformed
1294 .rebootstrap_local_data(&LocalDataRebootstrapInput {
1295 rebootstrap_id: "malformed".to_owned(),
1296 })
1297 .expect_err("malformed receipt must fail closed");
1298 assert_eq!(
1299 malformed_error,
1300 "sync.local_corrupt: persisted local rebootstrap receipt is invalid"
1301 );
1302 assert!(!malformed.upgrading());
1303 assert_eq!(
1304 malformed
1305 .subscription_state("repair-tasks")
1306 .expect("unchanged subscription")
1307 .cursor,
1308 -1
1309 );
1310 assert!(malformed.drain_change_batches().is_empty());
1311 assert!(malformed.drain_sync_intents().is_empty());
1312
1313 let mut unreadable = client();
1314 unreadable
1315 .conn
1316 .execute(
1317 "INSERT INTO tasks(id, project_id, _syncular_version) VALUES (?1, ?2, 1)",
1318 rusqlite::params!["server-row", "p1"],
1319 )
1320 .expect("seed visible row");
1321 unreadable
1322 .conn
1323 .execute("DROP TABLE _syncular_meta", [])
1324 .expect("break marker storage");
1325 let unreadable_error = unreadable
1326 .rebootstrap_local_data(&LocalDataRebootstrapInput {
1327 rebootstrap_id: "unreadable".to_owned(),
1328 })
1329 .expect_err("unreadable marker storage must fail closed");
1330 assert_eq!(
1331 unreadable_error,
1332 "sync.local_corrupt: persisted local rebootstrap receipt is unreadable"
1333 );
1334 let visible_rows = unreadable
1335 .conn
1336 .query_row("SELECT COUNT(*) FROM tasks", [], |row| row.get::<_, i64>(0))
1337 .expect("visible projection remains");
1338 assert_eq!(visible_rows, 1);
1339 assert!(!unreadable.upgrading());
1340 assert!(unreadable.drain_change_batches().is_empty());
1341 assert!(unreadable.drain_sync_intents().is_empty());
1342 }
1343
1344 #[test]
1345 fn local_rebootstrap_cannot_bypass_schema_floor() {
1346 let mut client = client();
1347 client.set_schema_floor(Some(SchemaFloor {
1348 required_schema_version: Some(2),
1349 latest_schema_version: Some(2),
1350 }));
1351 let error = client
1352 .rebootstrap_local_data(&LocalDataRebootstrapInput {
1353 rebootstrap_id: "blocked-floor".to_owned(),
1354 })
1355 .expect_err("schema floor must block repair");
1356 assert!(error.contains("cannot bypass an active schema-floor stop"));
1357 }
1358
1359 #[test]
1360 fn batched_push_acknowledgements_rebuild_overlay_once_per_response() {
1361 let mut client = client();
1362 client.set_meta(LOG_EPOCH_KEY, "epoch-1");
1363 const COMMIT_COUNT: usize = 32;
1364
1365 for index in 0..COMMIT_COUNT {
1366 client
1367 .mutate(vec![Mutation::Upsert {
1368 table: "tasks".to_owned(),
1369 values: Map::from_iter([
1370 ("id".to_owned(), Value::from(format!("task-{index}"))),
1371 ("project_id".to_owned(), Value::from("project-1")),
1372 ]),
1373 base_version: None,
1374 }])
1375 .expect("queue commit");
1376 }
1377
1378 let (_, request_meta) = client.build_request(false);
1379 assert_eq!(request_meta.pushed_ids.len(), COMMIT_COUNT);
1380 let mut frames = vec![Frame::RespHeader {
1381 required_schema_version: None,
1382 latest_schema_version: None,
1383 log_epoch: Some("epoch-1".to_owned()),
1384 reset_required: Some(false),
1385 }];
1386 frames.extend(request_meta.pushed_ids.iter().enumerate().map(
1387 |(index, client_commit_id)| Frame::PushResult {
1388 client_commit_id: client_commit_id.clone(),
1389 status: PushStatus::Applied,
1390 commit_seq: Some(index as i64 + 1),
1391 results: vec![OpResult::Applied { op_index: 0 }],
1392 },
1393 ));
1394 let response = Message {
1395 wire_version: WIRE_VERSION,
1396 msg_kind: MsgKind::Response,
1397 frames,
1398 };
1399 let mut transport =
1400 HostTransport::new_from_config(&json!({})).expect("no-network host transport");
1401
1402 client.overlay_rebuild_count.set(0);
1403 client.outcome_prune_count.set(0);
1404 let outcome = client.process_response(&mut transport, response, &request_meta);
1405 assert!(matches!(outcome, SyncOutcome::Ok(_)));
1406 assert!(client.pending_commit_ids().is_empty());
1407 assert_eq!(
1408 client.overlay_rebuild_count.get(),
1409 1,
1410 "one response must reconcile its acknowledged commits with one overlay rebuild"
1411 );
1412 assert_eq!(
1413 client.outcome_prune_count.get(),
1414 1,
1415 "one response must enforce outcome retention once"
1416 );
1417 }
1418
1419 #[test]
1420 fn clean_commit_mirroring_matches_full_replay_with_fts_and_unique_indexes() {
1421 let schema = json!({ "version": 1, "tables": [{ "name": "tasks", "primaryKey": "id",
1422 "columns": [
1423 {"name":"id", "type":"string", "nullable":false},
1424 {"name":"project_id", "type":"string", "nullable":false},
1425 {"name":"title", "type":"string", "nullable":false}
1426 ], "scopes": [{"pattern":"project:{project_id}"}],
1427 "indexes": [{"name":"unique_title", "columns":["title"], "unique":true}],
1428 "ftsIndexes": [{"name":"tasks_fts", "columns":["title"], "tokenize":"unicode61"}]
1429 }] });
1430 let mut incremental =
1431 SyncClient::new("mirror".to_owned(), &schema, ClientLimits::default()).unwrap();
1432 let mut reference =
1433 SyncClient::new("mirror".to_owned(), &schema, ClientLimits::default()).unwrap();
1434 let tables = vec!["tasks".to_owned()];
1435 for index in 0..32 {
1436 let id = format!("task-{}", index % 8);
1437 let values = Map::from_iter([
1438 ("id".to_owned(), Value::from(id.clone())),
1439 (
1440 "project_id".to_owned(),
1441 Value::from(format!("p{}", index % 2)),
1442 ),
1443 ("title".to_owned(), Value::from(format!("needle{index}"))),
1444 ]);
1445 let payload = encode_row_json(
1446 incremental.schema.table("tasks").unwrap(),
1447 &id,
1448 &values,
1449 &incremental.encryption,
1450 )
1451 .unwrap();
1452 let change = ssp2::model::Change {
1453 table_index: 0,
1454 row_id: id,
1455 op: if index % 5 == 0 {
1456 Op::Delete
1457 } else {
1458 Op::Upsert
1459 },
1460 row_version: Some(index + 1),
1461 scopes: vec![("project_id".to_owned(), format!("p{}", index % 2))],
1462 row: Some(payload),
1463 };
1464 reference.overlay_dirty.set(true);
1465 for instance in [&mut incremental, &mut reference] {
1466 instance.begin_observation("mirror_test").unwrap();
1467 let mut batch = ChangeAccumulator::default();
1468 instance.record_commit_changes(&mut batch, &tables, std::slice::from_ref(&change));
1469 instance
1470 .apply_commit_changes(&tables, std::slice::from_ref(&change))
1471 .unwrap();
1472 instance.rebuild_overlay_if_dirty();
1473 instance.finish_observation("mirror_test", batch).unwrap();
1474 }
1475 for sql in [
1476 "SELECT * FROM tasks ORDER BY id",
1477 "SELECT _syncular_source_id, title FROM tasks_fts ORDER BY _syncular_source_id",
1478 ] {
1479 assert_eq!(
1480 incremental.query(sql, &[]).unwrap(),
1481 reference.query(sql, &[]).unwrap()
1482 );
1483 }
1484 assert_eq!(incremental.local_revision(), reference.local_revision());
1485 assert_eq!(
1486 serde_json::to_value(incremental.drain_change_batches()).unwrap(),
1487 serde_json::to_value(reference.drain_change_batches()).unwrap()
1488 );
1489 }
1490 assert_eq!(incremental.overlay_rebuild_count.get(), 0);
1491 assert_eq!(reference.overlay_rebuild_count.get(), 32);
1492 }
1493
1494 #[test]
1495 fn row_id_lookups_preserve_text_matching_and_seek_lossless_keys() {
1496 let cases = [
1497 (
1498 "string",
1499 vec![
1500 json!("1"),
1501 json!("01"),
1502 json!(""),
1503 json!("nul\u{0}id"),
1504 json!("é"),
1505 ],
1506 ),
1507 (
1508 "integer",
1509 vec![
1510 json!(i64::MIN),
1511 json!(-1),
1512 json!(0),
1513 json!(1),
1514 json!(i64::MAX),
1515 ],
1516 ),
1517 ("boolean", vec![json!(false), json!(true)]),
1518 ("json", vec![json!("null"), json!("1"), json!("{\"a\":1}")]),
1519 (
1520 "float",
1521 vec![
1522 json!(1.0),
1523 json!(1.0000000000000002),
1524 json!(-1.0),
1525 json!(1e20),
1526 ],
1527 ),
1528 ];
1529 for (kind, ids) in cases {
1530 let schema = json!({"version":1,"tables":[{"name":"tasks","primaryKey":"id",
1531 "columns":[{"name":"id","type":kind,"nullable":false},
1532 {"name":"project_id","type":"string","nullable":false}],
1533 "scopes":[{"pattern":"project:{project_id}"}]}]});
1534 let mut client =
1535 SyncClient::new("row-id-lookup".into(), &schema, ClientLimits::default()).unwrap();
1536 for (index, id) in ids.iter().enumerate() {
1537 let table = client.schema.table("tasks").unwrap();
1538 client
1539 .write_base_row(
1540 "tasks",
1541 &vec![
1542 json_to_column_value(&table.columns[0], Some(id)).unwrap(),
1543 Some(ColumnValue::String(format!("p{index}"))),
1544 ],
1545 1,
1546 )
1547 .unwrap();
1548 }
1549 client.rebuild_overlay_if_dirty();
1550 for base in [true, false] {
1551 let full = if base {
1552 base_table("tasks")
1553 } else {
1554 visible_table("tasks")
1555 };
1556 let predicate = row_id_predicate(client.schema.table("tasks").unwrap());
1557 for row_id in [
1558 "1",
1559 "01",
1560 "1.0",
1561 "1.0000000000000002",
1562 "+1",
1563 "1e0",
1564 " 1",
1565 "-1",
1566 "0",
1567 "9223372036854775807",
1568 "-9223372036854775808",
1569 "true",
1570 "false",
1571 "",
1572 "nul\u{0}id",
1573 "é",
1574 "null",
1575 "{\"a\":1}",
1576 ] {
1577 let old_sql =
1578 format!("SELECT project_id FROM {full} WHERE CAST(id AS TEXT) = ?1");
1579 let new_sql = format!("SELECT project_id FROM {full} WHERE {predicate}");
1580 let old = client
1581 .conn
1582 .prepare(&old_sql)
1583 .unwrap()
1584 .query_map([row_id], |row| row.get::<_, String>(0))
1585 .unwrap()
1586 .collect::<Result<Vec<_>, _>>()
1587 .unwrap();
1588 let new = client
1589 .conn
1590 .prepare_cached(&new_sql)
1591 .unwrap()
1592 .query_map([row_id], |row| row.get::<_, String>(0))
1593 .unwrap()
1594 .collect::<Result<Vec<_>, _>>()
1595 .unwrap();
1596 assert_eq!(old, new, "{kind}, base={base}, row_id={row_id:?}");
1597 if kind == "float" && row_id == "1.0" {
1600 assert_eq!(old.len(), 2);
1601 }
1602 let mut batch = ChangeAccumulator::default();
1603 assert_eq!(
1604 client.record_row_scopes(&mut batch, "tasks", row_id, base),
1605 !old.is_empty()
1606 );
1607 let expected = old.first().map(|scope| {
1608 BTreeMap::from([(
1609 "tasks".to_owned(),
1610 Some(BTreeSet::from([format!("project:{scope}")])),
1611 )])
1612 });
1613 assert_eq!(batch.tables, expected.unwrap_or_default());
1614 client
1615 .conn
1616 .execute_batch("SAVEPOINT lookup_delete")
1617 .unwrap();
1618 if base {
1619 client.delete_base_row("tasks", row_id).unwrap();
1620 } else {
1621 client.apply_outbox_ops(&[OutboxOp {
1622 upsert: false,
1623 table: "tasks".into(),
1624 row_id: row_id.into(),
1625 base_version: None,
1626 values: None,
1627 changed_fields: None,
1628 }]);
1629 }
1630 let remaining: i64 = client
1631 .conn
1632 .query_row(&format!("SELECT count(*) FROM {full}"), [], |row| {
1633 row.get(0)
1634 })
1635 .unwrap();
1636 assert_eq!(remaining, ids.len() as i64 - old.len() as i64);
1637 client
1638 .conn
1639 .execute_batch("ROLLBACK TO lookup_delete; RELEASE lookup_delete")
1640 .unwrap();
1641 client.overlay_dirty.set(false);
1642 }
1643 for (predicate, expected) in [
1644 ("CAST(id AS TEXT) = ?1".to_owned(), "SCAN"),
1645 (predicate, if kind == "float" { "SCAN" } else { "SEARCH" }),
1646 ] {
1647 let sql = format!("EXPLAIN QUERY PLAN SELECT project_id FROM {full} WHERE {predicate} LIMIT 1");
1648 let details = client
1649 .conn
1650 .prepare(&sql)
1651 .unwrap()
1652 .query_map(["1"], |row| row.get::<_, String>(3))
1653 .unwrap()
1654 .collect::<Result<Vec<_>, _>>()
1655 .unwrap();
1656 assert!(
1657 details.iter().any(|detail| detail.contains(expected)),
1658 "{kind}: {details:?}"
1659 );
1660 }
1661 }
1662 }
1663 }
1664
1665 #[test]
1666 fn pending_row_reconciliation_matches_full_fifo_replay() {
1667 for unique in [false, true] {
1668 let schema = json!({"version": 1, "tables": (["tasks", "notes"].map(|name| json!({
1669 "name": name, "primaryKey": "id", "columns": [
1670 {"name":"id", "type":"string", "nullable":false},
1671 {"name":"project_id", "type":"string", "nullable":false},
1672 {"name":"title", "type":"string", "nullable":false}],
1673 "scopes": [{"pattern":"project:{project_id}"}],
1674 "indexes": [{"name":format!("{name}_title"), "columns":["title"], "unique":unique || name == "notes"}],
1675 "ftsIndexes": [{"name":format!("{name}_fts"), "columns":["title"], "tokenize":"unicode61"}]
1676 })))});
1677 let mut incremental =
1678 SyncClient::new("pending".into(), &schema, ClientLimits::default()).unwrap();
1679 let mut reference =
1680 SyncClient::new("pending".into(), &schema, ClientLimits::default()).unwrap();
1681 let tables = vec!["tasks".to_owned(), "notes".to_owned()];
1682 for instance in [&mut incremental, &mut reference] {
1683 for table in &tables {
1684 for index in 0..12 {
1685 instance
1686 .write_base_row(
1687 table,
1688 &vec![
1689 Some(ColumnValue::String(format!("row{index}"))),
1690 Some(ColumnValue::String("p0".into())),
1691 Some(ColumnValue::String(format!("base{index}"))),
1692 ],
1693 1,
1694 )
1695 .unwrap();
1696 }
1697 }
1698 instance.rebuild_overlay_if_dirty();
1699 }
1700 for index in 0..80 {
1701 let mutations = (0..3)
1702 .map(|offset| {
1703 let table = tables[(index + offset) % 2].clone();
1704 let id = format!("row{}", (index + offset) % 16);
1705 if (index + offset) % 7 == 0 {
1706 Mutation::Delete {
1707 table,
1708 row_id: id,
1709 base_version: None,
1710 }
1711 } else {
1712 Mutation::Upsert {
1713 table,
1714 values: Map::from_iter([
1715 ("id".into(), Value::from(id)),
1716 ("project_id".into(), Value::from(format!("p{}", index % 3))),
1717 (
1718 "title".into(),
1719 Value::from(format!("needle{}", (index + offset) % 5)),
1720 ),
1721 ]),
1722 base_version: None,
1723 }
1724 }
1725 })
1726 .collect::<Vec<_>>();
1727 for instance in [&mut incremental, &mut reference] {
1728 instance.mutate(mutations.clone()).unwrap();
1729 }
1730 let changes = (0..3)
1733 .map(|offset| {
1734 let table_index = if index % 4 == 0 { offset % 2 } else { 0 };
1735 let id = format!("row{}", (index + offset) % 16);
1736 let values = Map::from_iter([
1737 ("id".into(), Value::from(id.clone())),
1738 (
1739 "project_id".into(),
1740 Value::from(format!("p{}", (index + 1) % 3)),
1741 ),
1742 (
1743 "title".into(),
1744 Value::from(format!("remote{index}_{offset}")),
1745 ),
1746 ]);
1747 ssp2::model::Change {
1748 table_index: table_index as u16,
1749 row_id: id.clone(),
1750 op: if (index + offset) % 5 == 0 {
1751 Op::Delete
1752 } else {
1753 Op::Upsert
1754 },
1755 row_version: Some(index as i64 + 2),
1756 scopes: vec![],
1757 row: Some(
1758 encode_row_json(
1759 incremental.schema.table(&tables[table_index]).unwrap(),
1760 &id,
1761 &values,
1762 &incremental.encryption,
1763 )
1764 .unwrap(),
1765 ),
1766 }
1767 })
1768 .collect::<Vec<_>>();
1769 reference.overlay_dirty.set(true);
1770 incremental.apply_commit_frame(&tables, &changes).unwrap();
1771 reference.apply_commit_frame(&tables, &changes).unwrap();
1772 for table in &tables {
1773 for sql in [format!("SELECT * FROM {table} ORDER BY id"),
1774 format!("SELECT * FROM _syncular_base_{table} ORDER BY id"),
1775 format!("SELECT _syncular_source_id, title FROM {table}_fts ORDER BY _syncular_source_id"),
1776 format!("SELECT _syncular_source_id FROM {table}_fts WHERE {table}_fts MATCH 'needle1' ORDER BY _syncular_source_id")] {
1777 assert_eq!(incremental.query(&sql, &[]).unwrap(), reference.query(&sql, &[]).unwrap(), "unique={unique}, frame={index}: {sql}");
1778 }
1779 }
1780 assert_eq!(incremental.local_revision(), reference.local_revision());
1781 assert_eq!(
1782 serde_json::to_value(incremental.drain_change_batches()).unwrap(),
1783 serde_json::to_value(reference.drain_change_batches()).unwrap()
1784 );
1785 }
1786 assert_eq!(
1787 incremental.overlay_rebuild_count.get(),
1788 if unique { 81 } else { 21 }
1789 );
1790 assert_eq!(reference.overlay_rebuild_count.get(), 81);
1791 }
1792 }
1793
1794 #[test]
1795 fn remote_unique_values_reconsider_pending_writes_on_other_rows() {
1796 let schema = json!({"version": 1, "tables": [{"name":"tasks", "primaryKey":"id",
1797 "columns":[{"name":"id", "type":"string", "nullable":false},
1798 {"name":"title", "type":"string", "nullable":false}],
1799 "scopes":[], "indexes":[{"name":"unique_title", "columns":["title"], "unique":true}]}]});
1800 let mut client =
1801 SyncClient::new("unique-pending".into(), &schema, ClientLimits::default()).unwrap();
1802 for (id, title) in [("a", "original"), ("b", "occupied")] {
1803 client
1804 .write_base_row(
1805 "tasks",
1806 &vec![
1807 Some(ColumnValue::String(id.into())),
1808 Some(ColumnValue::String(title.into())),
1809 ],
1810 1,
1811 )
1812 .unwrap();
1813 }
1814 client.rebuild_overlay_if_dirty();
1815 let pending = client
1816 .mutate(vec![Mutation::Upsert {
1817 table: "tasks".into(),
1818 values: Map::from_iter([
1819 ("id".into(), Value::from("a")),
1820 ("title".into(), Value::from("occupied")),
1821 ]),
1822 base_version: None,
1823 }])
1824 .unwrap();
1825 assert_eq!(
1826 client
1827 .conn
1828 .query_row("SELECT title FROM tasks WHERE id = 'a'", [], |row| row
1829 .get::<_, String>(
1830 0
1831 ))
1832 .unwrap(),
1833 "original"
1834 );
1835 for (title, expected) in [("available", "occupied"), ("occupied", "original")] {
1836 let change = ssp2::model::Change {
1837 table_index: 0,
1838 row_id: "b".into(),
1839 op: Op::Upsert,
1840 row_version: Some(2),
1841 scopes: vec![],
1842 row: Some(
1843 encode_row_json(
1844 client.schema.table("tasks").unwrap(),
1845 "b",
1846 &Map::from_iter([
1847 ("id".into(), Value::from("b")),
1848 ("title".into(), Value::from(title)),
1849 ]),
1850 &client.encryption,
1851 )
1852 .unwrap(),
1853 ),
1854 };
1855 client
1856 .apply_commit_frame(&["tasks".into()], &[change])
1857 .unwrap();
1858 assert_eq!(
1859 client
1860 .conn
1861 .query_row("SELECT title FROM tasks WHERE id = 'a'", [], |row| row
1862 .get::<_, String>(
1863 0
1864 ))
1865 .unwrap(),
1866 expected
1867 );
1868 assert_eq!(client.pending_commit_ids(), vec![pending.clone()]);
1869 }
1870 assert_eq!(client.overlay_rebuild_count.get(), 3);
1871 }
1872
1873 #[test]
1874 fn pending_row_reconciliation_failure_preserves_queue_and_frame_state() {
1875 for failure in ["visible", "revision", "commit"] {
1876 let mut client = client();
1877 let id = client
1878 .mutate(vec![Mutation::Upsert {
1879 table: "tasks".into(),
1880 values: Map::from_iter([
1881 ("id".into(), Value::from("t1")),
1882 ("project_id".into(), Value::from("pending")),
1883 ]),
1884 base_version: None,
1885 }])
1886 .unwrap();
1887 client.drain_change_batches();
1888 let revision = client.local_revision();
1889 let before = client.query("SELECT * FROM tasks", &[]).unwrap();
1890 let trigger = match failure {
1891 "visible" => "CREATE TRIGGER fail_pending BEFORE INSERT ON tasks WHEN NEW.project_id = 'remote' BEGIN SELECT RAISE(ABORT, 'injected visible failure'); END".to_owned(),
1892 "revision" => format!("CREATE TRIGGER fail_pending BEFORE INSERT ON _syncular_meta WHEN NEW.key = '{LOCAL_REVISION_KEY}' BEGIN SELECT RAISE(ABORT, 'injected revision failure'); END"),
1893 _ => "PRAGMA foreign_keys = ON; CREATE TABLE pending_parent(id INTEGER PRIMARY KEY); CREATE TABLE pending_child(id INTEGER REFERENCES pending_parent(id) DEFERRABLE INITIALLY DEFERRED); CREATE TRIGGER fail_pending AFTER INSERT ON _syncular_base_tasks BEGIN INSERT INTO pending_child VALUES(1); END".to_owned(),
1894 };
1895 client.conn.execute_batch(&trigger).unwrap();
1896 let change = ssp2::model::Change {
1897 table_index: 0,
1898 row_id: "t1".into(),
1899 op: Op::Upsert,
1900 row_version: Some(2),
1901 scopes: vec![],
1902 row: Some(
1903 encode_row_json(
1904 client.schema.table("tasks").unwrap(),
1905 "t1",
1906 &Map::from_iter([
1907 ("id".into(), Value::from("t1")),
1908 ("project_id".into(), Value::from("remote")),
1909 ]),
1910 &client.encryption,
1911 )
1912 .unwrap(),
1913 ),
1914 };
1915 assert!(client
1916 .apply_commit_frame(&["tasks".into()], std::slice::from_ref(&change))
1917 .is_err());
1918 assert!(client.conn.is_autocommit());
1919 assert!(!client.overlay_dirty.get());
1920 assert_eq!(client.local_revision(), revision);
1921 assert!(client.drain_change_batches().is_empty());
1922 assert_eq!(client.pending_commit_ids(), vec![id.clone()]);
1923 assert_eq!(client.query("SELECT * FROM tasks", &[]).unwrap(), before);
1924 assert!(client
1925 .query("SELECT * FROM _syncular_base_tasks", &[])
1926 .unwrap()
1927 .is_empty());
1928 client
1929 .conn
1930 .execute_batch("DROP TRIGGER fail_pending")
1931 .unwrap();
1932 client
1933 .apply_commit_frame(&["tasks".into()], &[change])
1934 .unwrap();
1935 assert_eq!(client.local_revision(), revision + 1);
1936 assert_eq!(client.pending_commit_ids(), vec![id]);
1937 assert_eq!(client.query("SELECT * FROM tasks", &[]).unwrap(), before);
1938 assert_eq!(
1939 client
1940 .conn
1941 .query_row("SELECT project_id FROM _syncular_base_tasks", [], |row| row
1942 .get::<_, String>(0))
1943 .unwrap(),
1944 "remote"
1945 );
1946 }
1947 }
1948
1949 #[test]
1950 fn appended_overlay_matches_full_fifo_replay_with_constraints_and_fts() {
1951 let schema = json!({"version": 1, "tables": [{"name": "tasks", "primaryKey": "id",
1952 "columns": [
1953 {"name":"id", "type":"string", "nullable":false},
1954 {"name":"project_id", "type":"string", "nullable":false},
1955 {"name":"title", "type":"string", "nullable":false}],
1956 "scopes": [{"pattern":"project:{project_id}"}],
1957 "indexes": [{"name":"unique_title", "columns":["title"], "unique":true}],
1958 "ftsIndexes": [{"name":"tasks_fts", "columns":["title"], "tokenize":"unicode61"}]}]});
1959 let mut incremental =
1960 SyncClient::new("append".to_owned(), &schema, ClientLimits::default()).unwrap();
1961 let mut reference =
1962 SyncClient::new("append".to_owned(), &schema, ClientLimits::default()).unwrap();
1963 for instance in [&mut incremental, &mut reference] {
1964 for index in 0..16 {
1965 instance
1966 .write_base_row(
1967 "tasks",
1968 &vec![
1969 Some(ColumnValue::String(format!("task{index}"))),
1970 Some(ColumnValue::String("p0".to_owned())),
1971 Some(ColumnValue::String(format!("base{index}"))),
1972 ],
1973 1,
1974 )
1975 .unwrap();
1976 }
1977 instance.rebuild_overlay_if_dirty();
1978 instance.overlay_rebuild_count.set(0);
1979 }
1980 for index in 0..100 {
1981 let mutations = (0..1 + index % 4)
1982 .map(|offset| {
1983 let id = format!("task{}", (index + offset) % 24);
1984 if (index + offset) % 7 == 0 {
1985 Mutation::Delete {
1986 table: "tasks".to_owned(),
1987 row_id: id,
1988 base_version: None,
1989 }
1990 } else {
1991 Mutation::Upsert {
1992 table: "tasks".to_owned(),
1993 values: Map::from_iter([
1994 ("id".to_owned(), Value::from(id)),
1995 (
1996 "project_id".to_owned(),
1997 Value::from(format!("p{}", index % 2)),
1998 ),
1999 (
2000 "title".to_owned(),
2001 Value::from(format!("needle{}", (index + offset) % 5)),
2002 ),
2003 ]),
2004 base_version: None,
2005 }
2006 }
2007 })
2008 .collect::<Vec<_>>();
2009 reference.overlay_dirty.set(true);
2010 incremental.mutate(mutations.clone()).unwrap();
2011 reference.mutate(mutations).unwrap();
2012 for sql in ["SELECT * FROM tasks ORDER BY id", "SELECT _syncular_source_id, title FROM tasks_fts ORDER BY _syncular_source_id",
2013 "SELECT _syncular_source_id FROM tasks_fts WHERE tasks_fts MATCH 'needle1' ORDER BY _syncular_source_id"] {
2014 assert_eq!(incremental.query(sql, &[]).unwrap(), reference.query(sql, &[]).unwrap(), "commit {index}: {sql}");
2015 }
2016 assert_eq!(incremental.local_revision(), reference.local_revision());
2017 assert_eq!(
2018 serde_json::to_value(incremental.drain_change_batches()).unwrap(),
2019 serde_json::to_value(reference.drain_change_batches()).unwrap()
2020 );
2021 }
2022 assert_eq!(incremental.outbox.len(), 100);
2023 assert_eq!(incremental.overlay_rebuild_count.get(), 0);
2024 assert_eq!(reference.overlay_rebuild_count.get(), 100);
2025 }
2026
2027 #[test]
2028 fn failed_append_rolls_back_durable_queue_visible_rows_and_memory() {
2029 for fail_at in ["outbox", "revision"] {
2030 let mut client = client();
2031 let values = Map::from_iter([
2032 ("id".to_owned(), Value::from("t1")),
2033 ("project_id".to_owned(), Value::from("p1")),
2034 ]);
2035 let mutation = Mutation::Upsert {
2036 table: "tasks".to_owned(),
2037 values,
2038 base_version: None,
2039 };
2040 let trigger = if fail_at == "outbox" {
2041 "CREATE TRIGGER fail_append BEFORE INSERT ON _syncular_outbox BEGIN SELECT RAISE(FAIL, 'injected outbox failure'); END".to_owned()
2042 } else {
2043 format!("CREATE TRIGGER fail_append BEFORE INSERT ON _syncular_meta WHEN NEW.key = '{LOCAL_REVISION_KEY}' BEGIN SELECT RAISE(FAIL, 'injected revision failure'); END")
2044 };
2045 client.conn.execute_batch(&trigger).unwrap();
2046 let revision = client.local_revision();
2047 assert!(client.mutate(vec![mutation.clone()]).is_err(), "{fail_at}");
2048 assert_eq!(client.local_revision(), revision);
2049 assert!(client.outbox.is_empty());
2050 assert!(client.query("SELECT * FROM tasks", &[]).unwrap().is_empty());
2051 assert_eq!(
2052 client
2053 .conn
2054 .query_row("SELECT count(*) FROM _syncular_outbox", [], |row| row
2055 .get::<_, i64>(0))
2056 .unwrap(),
2057 0
2058 );
2059 assert!(client.drain_change_batches().is_empty());
2060 assert!(!client.overlay_dirty.get());
2061 client
2062 .conn
2063 .execute_batch("DROP TRIGGER fail_append")
2064 .unwrap();
2065 client.mutate(vec![mutation]).unwrap();
2066 assert_eq!(client.outbox.len(), 1);
2067 assert_eq!(client.query("SELECT * FROM tasks", &[]).unwrap().len(), 1);
2068 }
2069 }
2070
2071 #[test]
2072 fn mirrored_commit_failure_rolls_back_base_visible_and_revision() {
2073 let mut client = client();
2074 let tables = vec!["tasks".to_owned(), "missing".to_owned()];
2075 let values = Map::from_iter([
2076 ("id".to_owned(), Value::from("t1")),
2077 ("project_id".to_owned(), Value::from("p1")),
2078 ]);
2079 let payload = encode_row_json(
2080 client.schema.table("tasks").unwrap(),
2081 "t1",
2082 &values,
2083 &client.encryption,
2084 )
2085 .unwrap();
2086 let first = ssp2::model::Change {
2087 table_index: 0,
2088 row_id: "t1".to_owned(),
2089 op: Op::Upsert,
2090 row_version: Some(1),
2091 scopes: vec![("project_id".to_owned(), "p1".to_owned())],
2092 row: Some(payload),
2093 };
2094 let mut invalid = first.clone();
2095 invalid.table_index = 1;
2096 let revision = client.local_revision();
2097 client.begin_observation("failed_mirror").unwrap();
2098 assert!(client
2099 .apply_commit_changes(&tables, &[first, invalid])
2100 .is_err());
2101 client.rollback_observation("failed_mirror");
2102 client.rebuild_overlay_if_dirty();
2103 assert!(client.query("SELECT * FROM tasks", &[]).unwrap().is_empty());
2104 assert_eq!(
2105 client
2106 .conn
2107 .query_row("SELECT count(*) FROM _syncular_base_tasks", [], |row| row
2108 .get::<_, i64>(
2109 0
2110 ))
2111 .unwrap(),
2112 0
2113 );
2114 assert_eq!(client.local_revision(), revision);
2115 assert!(client.drain_change_batches().is_empty());
2116 }
2117
2118 #[test]
2119 fn successful_acknowledgement_runs_are_atomic_and_preserve_completed_runs() {
2120 for failure in ["journal", "revision", "commit", "later-frame"] {
2121 let mut client = client();
2122 client.set_meta(LOG_EPOCH_KEY, "epoch-1");
2123 for id in ["first", "second"] {
2124 client
2125 .mutate(vec![Mutation::Upsert {
2126 table: "tasks".to_owned(),
2127 values: Map::from_iter([
2128 ("id".to_owned(), Value::from(id)),
2129 ("project_id".to_owned(), Value::from("p1")),
2130 ]),
2131 base_version: None,
2132 }])
2133 .unwrap();
2134 }
2135 let (_, meta) = client.build_request(false);
2136 client
2138 .mutate(vec![Mutation::Upsert {
2139 table: "tasks".to_owned(),
2140 values: Map::from_iter([
2141 ("id".to_owned(), Value::from("later")),
2142 ("project_id".to_owned(), Value::from("p1")),
2143 ]),
2144 base_version: None,
2145 }])
2146 .unwrap();
2147 let ids = client.pending_commit_ids();
2148 let rows = client
2149 .query("SELECT * FROM tasks ORDER BY id", &[])
2150 .unwrap();
2151 let revision = client.local_revision();
2152 client.drain_change_batches();
2153 if failure != "later-frame" {
2154 client.conn.execute_batch(match failure {
2155 "journal" => "CREATE TRIGGER fail_run BEFORE INSERT ON _syncular_commit_outcomes WHEN (SELECT count(*) FROM _syncular_commit_outcomes) = 1 BEGIN SELECT RAISE(FAIL, 'injected second journal failure'); END",
2156 "revision" => "CREATE TRIGGER fail_run BEFORE INSERT ON _syncular_meta WHEN NEW.key = 'localRevision' AND (SELECT count(*) FROM _syncular_commit_outcomes) = 2 BEGIN SELECT RAISE(FAIL, 'injected run revision failure'); END",
2157 _ => "PRAGMA foreign_keys = ON; CREATE TABLE ack_parent(id INTEGER PRIMARY KEY); CREATE TABLE ack_child(id INTEGER REFERENCES ack_parent(id) DEFERRABLE INITIALLY DEFERRED); CREATE TRIGGER fail_run AFTER INSERT ON _syncular_commit_outcomes WHEN (SELECT count(*) FROM _syncular_commit_outcomes) = 2 BEGIN INSERT INTO ack_child VALUES (1); END",
2158 }).unwrap();
2159 }
2160 let mut response = Message {
2161 wire_version: WIRE_VERSION,
2162 msg_kind: MsgKind::Response,
2163 frames: vec![Frame::RespHeader {
2164 required_schema_version: None,
2165 latest_schema_version: None,
2166 log_epoch: Some("epoch-1".to_owned()),
2167 reset_required: Some(false),
2168 }],
2169 };
2170 for id in [&ids[0], &ids[0], "unknown", &ids[1], &ids[2]] {
2171 response.frames.push(Frame::PushResult {
2172 client_commit_id: id.to_owned(),
2173 status: PushStatus::Applied,
2174 commit_seq: Some(1),
2175 results: vec![OpResult::Applied { op_index: 0 }],
2176 });
2177 }
2178 if failure == "later-frame" {
2179 response.frames.push(Frame::Error {
2180 code: "sync.invalid_request".to_owned(),
2181 message: "later error".to_owned(),
2182 category: "protocol".to_owned(),
2183 retryable: false,
2184 recommended_action: "retry".to_owned(),
2185 details: None,
2186 });
2187 }
2188 let mut transport = HostTransport::new_from_config(&json!({})).unwrap();
2189 let outcome = client.process_response(&mut transport, response.clone(), &meta);
2190 assert!(
2191 matches!(outcome, SyncOutcome::Failed { error_code, .. } if error_code == if failure == "later-frame" { "sync.invalid_request" } else { "client.outcome_persistence_failed" })
2192 );
2193 if failure == "later-frame" {
2194 assert_eq!(client.pending_commit_ids(), ids[2..]);
2195 assert!(client.commit_outcome(&ids[0]).unwrap().is_some());
2196 assert!(client.commit_outcome(&ids[1]).unwrap().is_some());
2197 let batches = client.drain_change_batches();
2198 assert_eq!(batches.len(), 1);
2199 assert_eq!(batches[0].status.as_ref().unwrap().outbox, 1);
2200 assert_eq!(client.local_revision(), revision + 1);
2201 response.frames.pop();
2202 } else {
2203 assert_eq!(client.pending_commit_ids(), ids);
2204 assert!(client.commit_outcome(&ids[0]).unwrap().is_none());
2205 assert!(client.commit_outcome(&ids[1]).unwrap().is_none());
2206 assert_eq!(client.local_revision(), revision);
2207 assert!(client.drain_change_batches().is_empty());
2208 assert_eq!(
2209 client
2210 .query("SELECT * FROM tasks ORDER BY id", &[])
2211 .unwrap(),
2212 rows
2213 );
2214 client.conn.execute_batch("DROP TRIGGER fail_run").unwrap();
2215 }
2216 for frame in &mut response.frames {
2217 if let Frame::PushResult { status, .. } = frame {
2218 *status = PushStatus::Cached;
2219 }
2220 }
2221 let SyncOutcome::Ok(report) = client.process_response(&mut transport, response, &meta)
2222 else {
2223 panic!("retry failed");
2224 };
2225 assert_eq!(
2226 report.applied,
2227 if failure == "later-frame" {
2228 vec![]
2229 } else {
2230 ids[..2].to_vec()
2231 }
2232 );
2233 assert_eq!(client.pending_commit_ids(), ids[2..]);
2234 let batches = client.drain_change_batches();
2235 assert_eq!(batches.len(), usize::from(failure != "later-frame"));
2236 if let Some(batch) = batches.first() {
2237 assert_eq!(batch.status.as_ref().unwrap().outbox, 1);
2238 }
2239 assert_eq!(client.local_revision(), revision + 1);
2240 }
2241 }
2242
2243 #[test]
2244 fn failed_push_result_preserves_memory_disk_and_retry_identity() {
2245 for failure in ["revision", "journal", "commit"] {
2246 for status in ["applied", "cached", "rejected", "conflict"] {
2247 let mut client = client();
2248 client.set_meta(LOG_EPOCH_KEY, "epoch-1");
2249 for id in ["first", "later"] {
2250 client
2251 .mutate(vec![Mutation::Upsert {
2252 table: "tasks".to_owned(),
2253 values: Map::from_iter([
2254 ("id".to_owned(), Value::from(id)),
2255 ("project_id".to_owned(), Value::from("p1")),
2256 ]),
2257 base_version: None,
2258 }])
2259 .unwrap();
2260 }
2261 let (_, meta) = client.build_request(false);
2262 let ids = client.pending_commit_ids();
2263 let rows = client
2264 .query("SELECT * FROM tasks ORDER BY id", &[])
2265 .unwrap();
2266 let revision = client.local_revision();
2267 client.drain_change_batches();
2268 let (push_status, results) = match status {
2269 "applied" => (PushStatus::Applied, vec![OpResult::Applied { op_index: 0 }]),
2270 "cached" => (PushStatus::Cached, vec![OpResult::Applied { op_index: 0 }]),
2271 "conflict" => (
2272 PushStatus::Rejected,
2273 vec![OpResult::Conflict {
2274 op_index: 0,
2275 code: "sync.version_conflict".to_owned(),
2276 message: "conflict".to_owned(),
2277 server_version: 1,
2278 server_row: encode_row_json(
2279 client.schema.table("tasks").unwrap(),
2280 "first",
2281 &Map::from_iter([
2282 ("id".to_owned(), Value::from("first")),
2283 ("project_id".to_owned(), Value::from("p1")),
2284 ]),
2285 &client.encryption,
2286 )
2287 .unwrap(),
2288 }],
2289 ),
2290 _ => (
2291 PushStatus::Rejected,
2292 vec![OpResult::Error {
2293 op_index: 0,
2294 code: "sync.validation_failed".to_owned(),
2295 message: "rejected".to_owned(),
2296 retryable: false,
2297 }],
2298 ),
2299 };
2300 let response = Message {
2301 wire_version: WIRE_VERSION,
2302 msg_kind: MsgKind::Response,
2303 frames: vec![
2304 Frame::RespHeader {
2305 required_schema_version: None,
2306 latest_schema_version: None,
2307 log_epoch: Some("epoch-1".to_owned()),
2308 reset_required: Some(false),
2309 },
2310 Frame::PushResult {
2311 client_commit_id: ids[0].clone(),
2312 status: push_status,
2313 commit_seq: Some(1),
2314 results,
2315 },
2316 ],
2317 };
2318 client.conn.execute_batch(match failure {
2319 "journal" => "CREATE TRIGGER fail_ack BEFORE INSERT ON _syncular_commit_outcomes BEGIN SELECT RAISE(FAIL, 'injected journal failure'); END",
2320 "revision" => "CREATE TRIGGER fail_ack BEFORE INSERT ON _syncular_meta WHEN NEW.key = 'localRevision' BEGIN SELECT RAISE(FAIL, 'injected revision failure'); END",
2321 _ => "PRAGMA foreign_keys = ON; CREATE TABLE ack_parent(id INTEGER PRIMARY KEY); CREATE TABLE ack_child(id INTEGER REFERENCES ack_parent(id) DEFERRABLE INITIALLY DEFERRED); CREATE TRIGGER fail_ack AFTER INSERT ON _syncular_commit_outcomes BEGIN INSERT INTO ack_child VALUES (1); END",
2322 }).unwrap();
2323 let mut transport = HostTransport::new_from_config(&json!({})).unwrap();
2324 let outcome = client.process_response(&mut transport, response.clone(), &meta);
2325 assert_eq!(
2326 client.pending_commit_ids(),
2327 ids,
2328 "{failure}/{status}: pending identity"
2329 );
2330 assert_eq!(
2331 client.local_revision(),
2332 revision,
2333 "{failure}/{status}: revision"
2334 );
2335 assert!(client.commit_outcome(&ids[0]).unwrap().is_none());
2336 assert!(client.conflicts().is_empty());
2337 assert!(client.rejections().is_empty());
2338 assert!(client.drain_change_batches().is_empty());
2339 assert_eq!(
2340 client
2341 .query("SELECT * FROM tasks ORDER BY id", &[])
2342 .unwrap(),
2343 rows
2344 );
2345 assert!(
2346 matches!(outcome, SyncOutcome::Failed { error_code, .. } if error_code == "client.outcome_persistence_failed"),
2347 "{failure}/{status}: failed round"
2348 );
2349 client.conn.execute_batch("DROP TRIGGER fail_ack").unwrap();
2350 assert!(matches!(
2351 client.process_response(&mut transport, response, &meta),
2352 SyncOutcome::Ok(_)
2353 ));
2354 assert_eq!(client.pending_commit_ids(), ids[1..]);
2355 assert!(client.commit_outcome(&ids[0]).unwrap().is_some());
2356 assert_eq!(client.drain_change_batches().len(), 1);
2357 }
2358 }
2359 }
2360
2361 #[test]
2362 fn mixed_push_results_reconcile_and_prune_once_per_response() {
2363 let mut client = client();
2364 client.set_meta(LOG_EPOCH_KEY, "epoch-1");
2365 for index in 0..4 {
2366 client
2367 .mutate(vec![Mutation::Upsert {
2368 table: "tasks".to_owned(),
2369 values: Map::from_iter([
2370 ("id".to_owned(), Value::from(format!("task-{index}"))),
2371 ("project_id".to_owned(), Value::from("project-1")),
2372 ]),
2373 base_version: None,
2374 }])
2375 .expect("queue commit");
2376 }
2377
2378 let (_, request_meta) = client.build_request(false);
2379 let ids = &request_meta.pushed_ids;
2380 assert_eq!(ids.len(), 4);
2381 let response = Message {
2382 wire_version: WIRE_VERSION,
2383 msg_kind: MsgKind::Response,
2384 frames: vec![
2385 Frame::RespHeader {
2386 required_schema_version: None,
2387 latest_schema_version: None,
2388 log_epoch: Some("epoch-1".to_owned()),
2389 reset_required: Some(false),
2390 },
2391 Frame::PushResult {
2392 client_commit_id: ids[0].clone(),
2393 status: PushStatus::Applied,
2394 commit_seq: Some(1),
2395 results: vec![OpResult::Applied { op_index: 0 }],
2396 },
2397 Frame::PushResult {
2398 client_commit_id: ids[1].clone(),
2399 status: PushStatus::Cached,
2400 commit_seq: Some(2),
2401 results: vec![OpResult::Applied { op_index: 0 }],
2402 },
2403 Frame::PushResult {
2404 client_commit_id: ids[2].clone(),
2405 status: PushStatus::Rejected,
2406 commit_seq: None,
2407 results: vec![OpResult::Error {
2408 op_index: 0,
2409 code: "sync.validation_failed".to_owned(),
2410 message: "rejected".to_owned(),
2411 retryable: false,
2412 }],
2413 },
2414 Frame::PushResult {
2415 client_commit_id: ids[3].clone(),
2416 status: PushStatus::Rejected,
2417 commit_seq: None,
2418 results: vec![OpResult::Error {
2419 op_index: 0,
2420 code: "sync.idempotency_cache_miss".to_owned(),
2421 message: "retry".to_owned(),
2422 retryable: true,
2423 }],
2424 },
2425 ],
2426 };
2427 let mut transport =
2428 HostTransport::new_from_config(&json!({})).expect("no-network host transport");
2429
2430 client.overlay_rebuild_count.set(0);
2431 client.outcome_prune_count.set(0);
2432 let outcome = client.process_response(&mut transport, response, &request_meta);
2433 let SyncOutcome::Ok(report) = outcome else {
2434 panic!("mixed push-result response failed");
2435 };
2436
2437 assert_eq!(report.applied, ids[..2]);
2438 assert_eq!(report.rejected, ids[2..3]);
2439 assert_eq!(report.retryable, ids[3..4]);
2440 assert_eq!(client.pending_commit_ids(), ids[3..4]);
2441 assert_eq!(
2442 client
2443 .query("SELECT id FROM tasks ORDER BY id", &[])
2444 .expect("query visible overlay"),
2445 vec![Map::from_iter([("id".to_owned(), Value::from("task-3"))])]
2446 );
2447 assert_eq!(client.overlay_rebuild_count.get(), 1);
2448 assert_eq!(client.outcome_prune_count.get(), 1);
2449 }
2450
2451 #[test]
2452 fn secondary_unique_collision_preserves_existing_synced_row() {
2453 let client = SyncClient::new(
2454 "unique-upsert-test".to_owned(),
2455 &json!({
2456 "version": 1,
2457 "tables": [{
2458 "name": "tasks",
2459 "primaryKey": "id",
2460 "columns": [
2461 { "name": "id", "type": "string", "nullable": false },
2462 { "name": "project_id", "type": "string", "nullable": false },
2463 { "name": "title", "type": "string", "nullable": false }
2464 ],
2465 "scopes": [{ "pattern": "project:{project_id}" }],
2466 "indexes": [{
2467 "name": "tasks_by_project_title",
2468 "columns": ["project_id", "title"],
2469 "unique": true
2470 }]
2471 }]
2472 }),
2473 ClientLimits::default(),
2474 )
2475 .expect("test client");
2476 let table = client.schema.table("tasks").expect("tasks table");
2477 let sql = client.insert_row_sql(&base_table("tasks"), table);
2478
2479 client
2480 .conn
2481 .execute(&sql, rusqlite::params!["t1", "p1", "original", 1])
2482 .expect("insert first row");
2483 client
2484 .conn
2485 .execute(&sql, rusqlite::params!["t1", "p1", "updated", 2])
2486 .expect("update same primary key");
2487 client
2488 .conn
2489 .execute(&sql, rusqlite::params!["t2", "p1", "original", 1])
2490 .expect("insert second row");
2491 assert!(client
2492 .conn
2493 .execute(&sql, rusqlite::params!["t3", "p1", "original", 2])
2494 .is_err());
2495
2496 let rows = client
2497 .conn
2498 .prepare("SELECT id, title FROM _syncular_base_tasks ORDER BY id")
2499 .expect("prepare rows")
2500 .query_map([], |row| {
2501 Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
2502 })
2503 .expect("query rows")
2504 .collect::<Result<Vec<_>, _>>()
2505 .expect("collect rows");
2506 assert_eq!(
2507 rows,
2508 vec![
2509 ("t1".to_owned(), "updated".to_owned()),
2510 ("t2".to_owned(), "original".to_owned())
2511 ]
2512 );
2513 }
2514
2515 #[test]
2516 fn reopening_active_subscriptions_emits_a_catch_up_intent() {
2517 let path = std::env::temp_dir().join(format!(
2518 "syncular-startup-intent-{}.db",
2519 uuid::Uuid::new_v4()
2520 ));
2521 let schema = json!({
2522 "version": 1,
2523 "tables": [{
2524 "name": "tasks",
2525 "primaryKey": "id",
2526 "columns": [
2527 { "name": "id", "type": "string", "nullable": false },
2528 { "name": "project_id", "type": "string", "nullable": false }
2529 ],
2530 "scopes": [{ "pattern": "project:{project_id}" }]
2531 }]
2532 });
2533
2534 {
2535 let mut first = SyncClient::open_path_with_identity(
2536 None,
2537 &schema,
2538 ClientLimits::default(),
2539 path.to_str().expect("UTF-8 temp path"),
2540 )
2541 .expect("first open");
2542 first
2543 .set_window(
2544 &WindowBase {
2545 table: "tasks".to_owned(),
2546 variable: "project_id".to_owned(),
2547 fixed_scopes: Vec::new(),
2548 params: None,
2549 },
2550 &["persisted".to_owned()],
2551 )
2552 .expect("persist window");
2553 }
2554
2555 let mut reopened = SyncClient::open_path_with_identity(
2556 None,
2557 &schema,
2558 ClientLimits::default(),
2559 path.to_str().expect("UTF-8 temp path"),
2560 )
2561 .expect("reopen");
2562 assert!(reopened.sync_needed());
2563 assert!(matches!(
2564 reopened.drain_sync_intents().as_slice(),
2565 [SyncIntent::Interactive]
2566 ));
2567 drop(reopened);
2568 std::fs::remove_file(path).expect("remove temp database");
2569 }
2570
2571 #[test]
2572 fn reopening_preserves_immutable_subscription_identity_and_progress() {
2573 let path = std::env::temp_dir().join(format!(
2574 "syncular-subscription-identity-{}.db",
2575 uuid::Uuid::new_v4()
2576 ));
2577 let schema = json!({
2578 "version": 1,
2579 "tables": [
2580 {
2581 "name": "tasks",
2582 "primaryKey": "id",
2583 "columns": [
2584 { "name": "id", "type": "string", "nullable": false },
2585 { "name": "project_id", "type": "string", "nullable": false }
2586 ],
2587 "scopes": [{ "pattern": "project:{project_id}" }]
2588 },
2589 {
2590 "name": "docs",
2591 "primaryKey": "id",
2592 "columns": [
2593 { "name": "id", "type": "string", "nullable": false },
2594 { "name": "org_id", "type": "string", "nullable": false },
2595 { "name": "project_id", "type": "string", "nullable": false }
2596 ],
2597 "scopes": [
2598 { "pattern": "org:{org_id}" },
2599 { "pattern": "project:{projectId}", "column": "project_id" }
2600 ]
2601 }
2602 ]
2603 });
2604
2605 {
2606 let mut first = SyncClient::open_path_with_identity(
2607 None,
2608 &schema,
2609 ClientLimits::default(),
2610 path.to_str().expect("UTF-8 temp path"),
2611 )
2612 .expect("first open");
2613 first
2614 .subscribe(
2615 "stable-subscription".to_owned(),
2616 "tasks".to_owned(),
2617 vec![(
2618 "project_id".to_owned(),
2619 vec!["p2".to_owned(), "p1".to_owned()],
2620 )],
2621 Some(r#"{"view":"v1"}"#.to_owned()),
2622 )
2623 .expect("persist subscription");
2624 let persisted = {
2625 let subscription = first
2626 .subs
2627 .iter_mut()
2628 .find(|subscription| subscription.id == "stable-subscription")
2629 .expect("subscription");
2630 subscription.cursor = 41;
2631 subscription.bootstrap_state = Some("resume-token".to_owned());
2632 subscription.effective = Some(vec![(
2633 "project_id".to_owned(),
2634 vec!["p1".to_owned(), "p2".to_owned()],
2635 )]);
2636 subscription.synced_once = true;
2637 subscription.clone()
2638 };
2639 first.persist_sub(&persisted).unwrap();
2640 }
2641
2642 let mut reopened = SyncClient::open_path_with_identity(
2643 None,
2644 &schema,
2645 ClientLimits::default(),
2646 path.to_str().expect("UTF-8 temp path"),
2647 )
2648 .expect("reopen");
2649 let progress = reopened
2650 .subscription_state("stable-subscription")
2651 .expect("persisted state");
2652 assert_eq!(progress.cursor, 41);
2653 assert!(progress.has_resume_token);
2654
2655 reopened
2656 .subscribe(
2657 "stable-subscription".to_owned(),
2658 "tasks".to_owned(),
2659 vec![(
2660 "project_id".to_owned(),
2661 vec!["p1".to_owned(), "p2".to_owned(), "p1".to_owned()],
2662 )],
2663 Some(r#"{"view":"v1"}"#.to_owned()),
2664 )
2665 .expect("canonical intent is idempotent");
2666 assert_eq!(
2667 reopened
2668 .subscription_state("stable-subscription")
2669 .expect("unchanged state")
2670 .cursor,
2671 progress.cursor
2672 );
2673
2674 for (table, scopes, params) in [
2675 (
2676 "tasks",
2677 vec![("project_id".to_owned(), vec!["p1".to_owned()])],
2678 Some(r#"{"view":"v1"}"#.to_owned()),
2679 ),
2680 (
2681 "tasks",
2682 vec![(
2683 "project_id".to_owned(),
2684 vec!["p2".to_owned(), "p1".to_owned()],
2685 )],
2686 Some(r#"{"view":"v2"}"#.to_owned()),
2687 ),
2688 (
2689 "docs",
2690 vec![
2691 ("org_id".to_owned(), vec!["o1".to_owned()]),
2692 ("projectId".to_owned(), vec!["p1".to_owned()]),
2693 ],
2694 Some(r#"{"view":"v1"}"#.to_owned()),
2695 ),
2696 ] {
2697 let error = reopened
2698 .subscribe(
2699 "stable-subscription".to_owned(),
2700 table.to_owned(),
2701 scopes,
2702 params,
2703 )
2704 .expect_err("identity rebind must fail");
2705 assert!(error.starts_with("client.subscription_intent_mismatch:"));
2706 assert_eq!(
2707 reopened
2708 .subscription_state("stable-subscription")
2709 .expect("unchanged state")
2710 .cursor,
2711 progress.cursor
2712 );
2713 }
2714
2715 drop(reopened);
2716 std::fs::remove_file(path).expect("remove temp database");
2717 }
2718
2719 #[test]
2720 fn reopening_clears_a_schema_floor_the_running_app_already_satisfies() {
2721 let path = std::env::temp_dir().join(format!(
2722 "syncular-satisfied-schema-floor-{}.db",
2723 uuid::Uuid::new_v4()
2724 ));
2725 let schema = json!({
2726 "version": 23,
2727 "tables": [{
2728 "name": "tasks",
2729 "primaryKey": "id",
2730 "columns": [
2731 { "name": "id", "type": "string", "nullable": false },
2732 { "name": "project_id", "type": "string", "nullable": false }
2733 ],
2734 "scopes": [{ "pattern": "project:{project_id}" }]
2735 }]
2736 });
2737
2738 {
2739 let mut first = SyncClient::open_path_with_identity(
2740 None,
2741 &schema,
2742 ClientLimits::default(),
2743 path.to_str().expect("UTF-8 temp path"),
2744 )
2745 .expect("first open");
2746 first
2747 .subscribe(
2748 "tasks".to_owned(),
2749 "tasks".to_owned(),
2750 vec![("project_id".to_owned(), vec!["p1".to_owned()])],
2751 None,
2752 )
2753 .expect("persist subscription");
2754 first.set_schema_floor(Some(SchemaFloor {
2755 required_schema_version: Some(22),
2756 latest_schema_version: Some(22),
2757 }));
2758 }
2759
2760 let mut reopened = SyncClient::open_path_with_identity(
2761 None,
2762 &schema,
2763 ClientLimits::default(),
2764 path.to_str().expect("UTF-8 temp path"),
2765 )
2766 .expect("reopen");
2767 assert!(reopened.schema_floor().is_none());
2768 assert!(reopened.get_meta(SCHEMA_FLOOR_KEY).is_none());
2769 assert!(reopened.sync_needed());
2770 assert!(matches!(
2771 reopened.drain_sync_intents().as_slice(),
2772 [SyncIntent::Interactive]
2773 ));
2774 drop(reopened);
2775 std::fs::remove_file(path).expect("remove temp database");
2776 }
2777
2778 #[test]
2779 fn reopening_keeps_an_unsatisfied_schema_floor_stopped() {
2780 let path = std::env::temp_dir().join(format!(
2781 "syncular-unsatisfied-schema-floor-{}.db",
2782 uuid::Uuid::new_v4()
2783 ));
2784 let schema = json!({
2785 "version": 1,
2786 "tables": [{
2787 "name": "tasks",
2788 "primaryKey": "id",
2789 "columns": [
2790 { "name": "id", "type": "string", "nullable": false },
2791 { "name": "project_id", "type": "string", "nullable": false }
2792 ],
2793 "scopes": [{ "pattern": "project:{project_id}" }]
2794 }]
2795 });
2796
2797 {
2798 let mut first = SyncClient::open_path_with_identity(
2799 None,
2800 &schema,
2801 ClientLimits::default(),
2802 path.to_str().expect("UTF-8 temp path"),
2803 )
2804 .expect("first open");
2805 first
2806 .subscribe(
2807 "tasks".to_owned(),
2808 "tasks".to_owned(),
2809 vec![("project_id".to_owned(), vec!["p1".to_owned()])],
2810 None,
2811 )
2812 .expect("persist subscription");
2813 first.set_schema_floor(Some(SchemaFloor {
2814 required_schema_version: Some(2),
2815 latest_schema_version: Some(2),
2816 }));
2817 }
2818
2819 let reopened = SyncClient::open_path_with_identity(
2820 None,
2821 &schema,
2822 ClientLimits::default(),
2823 path.to_str().expect("UTF-8 temp path"),
2824 )
2825 .expect("reopen");
2826 assert_eq!(
2827 reopened.schema_floor(),
2828 Some(&SchemaFloor {
2829 required_schema_version: Some(2),
2830 latest_schema_version: Some(2),
2831 })
2832 );
2833 assert!(!reopened.sync_needed());
2834 drop(reopened);
2835 std::fs::remove_file(path).expect("remove temp database");
2836 }
2837
2838 #[test]
2839 fn schema_bump_precedes_index_ddl_and_prunes_removed_subscriptions() {
2840 let path = std::env::temp_dir().join(format!(
2841 "syncular-indexed-column-bump-{}.db",
2842 uuid::Uuid::new_v4()
2843 ));
2844 let old_schema = json!({
2845 "version": 1,
2846 "tables": [
2847 {
2848 "name": "tasks",
2849 "primaryKey": "id",
2850 "columns": [
2851 { "name": "id", "type": "string", "nullable": false },
2852 { "name": "project_id", "type": "string", "nullable": false }
2853 ],
2854 "scopes": [{ "pattern": "project:{project_id}" }]
2855 },
2856 {
2857 "name": "legacy",
2858 "primaryKey": "id",
2859 "columns": [
2860 { "name": "id", "type": "string", "nullable": false },
2861 { "name": "project_id", "type": "string", "nullable": false }
2862 ],
2863 "scopes": [{ "pattern": "project:{project_id}" }]
2864 }
2865 ]
2866 });
2867 {
2868 let mut first = SyncClient::open_path_with_identity(
2869 None,
2870 &old_schema,
2871 ClientLimits::default(),
2872 path.to_str().expect("UTF-8 temp path"),
2873 )
2874 .expect("open old schema");
2875 first
2876 .subscribe(
2877 "legacy-sub".to_owned(),
2878 "legacy".to_owned(),
2879 vec![("project_id".to_owned(), vec!["p1".to_owned()])],
2880 None,
2881 )
2882 .expect("persist legacy subscription");
2883 }
2884
2885 let new_schema = json!({
2886 "version": 2,
2887 "tables": [{
2888 "name": "tasks",
2889 "primaryKey": "id",
2890 "columns": [
2891 { "name": "id", "type": "string", "nullable": false },
2892 { "name": "project_id", "type": "string", "nullable": false },
2893 { "name": "facility_membership_id", "type": "string", "nullable": true }
2894 ],
2895 "scopes": [{ "pattern": "project:{project_id}" }],
2896 "indexes": [{
2897 "name": "tasks_by_membership",
2898 "columns": ["project_id", "facility_membership_id"],
2899 "unique": false
2900 }]
2901 }]
2902 });
2903 let upgraded = SyncClient::open_path_with_identity(
2904 None,
2905 &new_schema,
2906 ClientLimits::default(),
2907 path.to_str().expect("UTF-8 temp path"),
2908 )
2909 .expect("open upgraded schema");
2910 let columns = upgraded
2911 .conn
2912 .prepare("PRAGMA table_info(tasks)")
2913 .expect("prepare columns")
2914 .query_map([], |row| row.get::<_, String>(1))
2915 .expect("query columns")
2916 .collect::<Result<Vec<_>, _>>()
2917 .expect("collect columns");
2918 assert!(columns.contains(&"facility_membership_id".to_owned()));
2919 let index_count: i64 = upgraded
2920 .conn
2921 .query_row(
2922 "SELECT COUNT(*) FROM sqlite_master WHERE type = 'index' AND name = 'tasks_by_membership'",
2923 [],
2924 |row| row.get(0),
2925 )
2926 .expect("query index");
2927 assert_eq!(index_count, 1);
2928 assert!(upgraded.subscription_state("legacy-sub").is_none());
2929 drop(upgraded);
2930 std::fs::remove_file(path).expect("remove temp database");
2931 }
2932
2933 #[test]
2934 fn migrates_pre_envelope_outcome_journal_additively() {
2935 let path = std::env::temp_dir().join(format!(
2936 "syncular-outcome-migration-{}.db",
2937 uuid::Uuid::new_v4()
2938 ));
2939 let conn = Connection::open(&path).expect("open legacy database");
2940 conn.execute_batch(
2941 "CREATE TABLE _syncular_commit_outcomes (
2942 seq INTEGER PRIMARY KEY AUTOINCREMENT,
2943 client_commit_id TEXT NOT NULL UNIQUE,
2944 status TEXT NOT NULL,
2945 recorded_at_ms INTEGER NOT NULL,
2946 results_json TEXT NOT NULL,
2947 resolution TEXT NOT NULL DEFAULT 'active',
2948 resolved_at_ms INTEGER,
2949 replacement_client_commit_id TEXT);",
2950 )
2951 .expect("create legacy outcome journal");
2952 drop(conn);
2953 let schema = json!({
2954 "version": 1,
2955 "tables": [{
2956 "name": "tasks",
2957 "primaryKey": "id",
2958 "columns": [
2959 { "name": "id", "type": "string", "nullable": false },
2960 { "name": "project_id", "type": "string", "nullable": false }
2961 ],
2962 "scopes": [{ "pattern": "project:{project_id}" }]
2963 }]
2964 });
2965 let client = SyncClient::open_path(
2966 "migration-native".to_owned(),
2967 &schema,
2968 ClientLimits::default(),
2969 path.to_str().expect("UTF-8 temp path"),
2970 )
2971 .expect("migrate database");
2972 let has_operations = client
2973 .conn
2974 .prepare("PRAGMA table_info(_syncular_commit_outcomes)")
2975 .expect("prepare table info")
2976 .query_map([], |row| row.get::<_, String>(1))
2977 .expect("query table info")
2978 .filter_map(Result::ok)
2979 .any(|column| column == "operations_json");
2980 assert!(has_operations);
2981 drop(client);
2982 std::fs::remove_file(path).expect("remove temp database");
2983 }
2984
2985 #[test]
2986 fn durable_conflict_outcome_and_resolution_survive_reopen() {
2987 let path = std::env::temp_dir().join(format!(
2988 "syncular-durable-outcome-{}.db",
2989 uuid::Uuid::new_v4()
2990 ));
2991 let schema = json!({
2992 "version": 1,
2993 "tables": [{
2994 "name": "tasks",
2995 "primaryKey": "id",
2996 "columns": [
2997 { "name": "id", "type": "string", "nullable": false },
2998 { "name": "project_id", "type": "string", "nullable": false }
2999 ],
3000 "scopes": [{ "pattern": "project:{project_id}" }]
3001 }]
3002 });
3003 let conflict = ConflictRecord {
3004 client_commit_id: "losing-commit".to_owned(),
3005 op_index: 0,
3006 table: "tasks".to_owned(),
3007 row_id: "t1".to_owned(),
3008 code: "sync.version_conflict".to_owned(),
3009 message: "stale base version".to_owned(),
3010 server_version: 2,
3011 server_row: Map::from_iter([("id".to_owned(), json!("t1"))]),
3012 operation: Some(CommitOperation {
3013 table: "tasks".to_owned(),
3014 row_id: "t1".to_owned(),
3015 op: "upsert".to_owned(),
3016 base_version: Some(1),
3017 values: None,
3018 changed_fields: None,
3019 }),
3020 };
3021 let failed_operations = vec![
3022 OutboxOp {
3023 upsert: true,
3024 table: "tasks".to_owned(),
3025 row_id: "t1".to_owned(),
3026 base_version: Some(1),
3027 values: None,
3028 changed_fields: None,
3029 },
3030 OutboxOp {
3031 upsert: true,
3032 table: "tasks".to_owned(),
3033 row_id: "status-event-1".to_owned(),
3034 base_version: Some(0),
3035 values: None,
3036 changed_fields: None,
3037 },
3038 ];
3039
3040 {
3041 let mut first = SyncClient::open_path(
3042 "durable-native".to_owned(),
3043 &schema,
3044 ClientLimits::default(),
3045 path.to_str().expect("UTF-8 temp path"),
3046 )
3047 .expect("first open");
3048 first
3049 .begin_observation("test_outcome")
3050 .expect("begin outcome");
3051 first
3052 .persist_commit_outcome(
3053 "losing-commit",
3054 CommitOutcomeStatus::Conflict,
3055 &[CommitOperationOutcome::Conflict {
3056 conflict: conflict.clone(),
3057 }],
3058 Some(&failed_operations),
3059 )
3060 .expect("persist outcome");
3061 first.conflicts.push(conflict);
3062 first
3063 .finish_observation(
3064 "test_outcome",
3065 ChangeAccumulator {
3066 conflicts: true,
3067 outcomes: true,
3068 ..ChangeAccumulator::default()
3069 },
3070 )
3071 .expect("commit outcome");
3072 }
3073
3074 {
3075 let mut reopened = SyncClient::open_path(
3076 "durable-native".to_owned(),
3077 &schema,
3078 ClientLimits::default(),
3079 path.to_str().expect("UTF-8 temp path"),
3080 )
3081 .expect("reopen");
3082 assert_eq!(reopened.conflicts().len(), 1);
3083 let outcome = reopened
3084 .commit_outcome("losing-commit")
3085 .expect("read outcome")
3086 .expect("outcome");
3087 assert_eq!(outcome.status, CommitOutcomeStatus::Conflict);
3088 let operations = outcome.operations.expect("aggregate envelope");
3089 assert_eq!(operations.len(), 2);
3090 assert_eq!(operations[1].row_id, "status-event-1");
3091 let resolved = reopened
3092 .resolve_commit_outcome(ResolveCommitOutcomeInput {
3093 client_commit_id: "losing-commit".to_owned(),
3094 resolution: CommitOutcomeResolution::ResolvedKeepServer,
3095 replacement_client_commit_id: None,
3096 })
3097 .expect("resolve");
3098 assert_eq!(
3099 resolved.resolution,
3100 CommitOutcomeResolution::ResolvedKeepServer
3101 );
3102 assert!(reopened.conflicts().is_empty());
3103 }
3104
3105 let reopened = SyncClient::open_path(
3106 "durable-native".to_owned(),
3107 &schema,
3108 ClientLimits::default(),
3109 path.to_str().expect("UTF-8 temp path"),
3110 )
3111 .expect("second reopen");
3112 assert!(reopened.conflicts().is_empty());
3113 assert_eq!(
3114 reopened
3115 .commit_outcome("losing-commit")
3116 .expect("read outcome")
3117 .expect("outcome")
3118 .resolution,
3119 CommitOutcomeResolution::ResolvedKeepServer
3120 );
3121 drop(reopened);
3122 std::fs::remove_file(path).expect("remove temp database");
3123 }
3124
3125 #[test]
3126 fn file_snapshot_reader_matches_owner_rows_revision_and_coverage() {
3127 let path =
3128 std::env::temp_dir().join(format!("syncular-read-sidecar-{}.db", uuid::Uuid::new_v4()));
3129 let schema = json!({
3130 "version": 1,
3131 "tables": [{
3132 "name": "tasks",
3133 "primaryKey": "id",
3134 "columns": [
3135 { "name": "id", "type": "string", "nullable": false },
3136 { "name": "project_id", "type": "string", "nullable": false }
3137 ],
3138 "scopes": [{ "pattern": "project:{project_id}" }]
3139 }]
3140 });
3141 let mut client = SyncClient::open_path_with_identity(
3142 Some("sidecar-client".to_owned()),
3143 &schema,
3144 ClientLimits::default(),
3145 path.to_str().expect("UTF-8 temp path"),
3146 )
3147 .expect("open owner");
3148 let base = WindowBase {
3149 table: "tasks".to_owned(),
3150 variable: "project_id".to_owned(),
3151 fixed_scopes: Vec::new(),
3152 params: None,
3153 };
3154 client
3155 .set_window(&base, &["one".to_owned()])
3156 .expect("set window");
3157 client
3158 .mutate(vec![Mutation::Upsert {
3159 table: "tasks".to_owned(),
3160 values: Map::from_iter([
3161 ("id".to_owned(), Value::from("t1")),
3162 ("project_id".to_owned(), Value::from("one")),
3163 ]),
3164 base_version: None,
3165 }])
3166 .expect("local mutate");
3167
3168 let coverage = [WindowCoverage {
3169 base,
3170 units: vec!["one".to_owned(), "missing".to_owned()],
3171 }];
3172 let owner = client
3173 .query_snapshot(
3174 "SELECT id, project_id, _sync_version AS server_version FROM tasks ORDER BY id",
3175 &[],
3176 &coverage,
3177 )
3178 .expect("owner snapshot");
3179 let mut reader = FileQuerySnapshotReader::new(path.to_string_lossy());
3180 let sidecar = reader
3181 .query_snapshot(
3182 "SELECT id, project_id, _sync_version AS server_version FROM tasks ORDER BY id",
3183 &[],
3184 &coverage,
3185 )
3186 .expect("sidecar snapshot");
3187
3188 assert_eq!(sidecar.revision, owner.revision);
3189 assert_eq!(sidecar.rows, owner.rows);
3190 assert_eq!(
3191 serde_json::to_value(&sidecar.coverage).expect("serialize sidecar coverage"),
3192 serde_json::to_value(&owner.coverage).expect("serialize owner coverage")
3193 );
3194 assert_eq!(sidecar.revision, "2");
3195 assert_eq!(sidecar.rows[0]["id"], "t1");
3196 assert_eq!(sidecar.rows[0]["server_version"], -1);
3197 assert!(!sidecar.coverage.complete);
3198 assert_eq!(sidecar.coverage.pending.len(), 1);
3199 assert_eq!(sidecar.coverage.missing.len(), 1);
3200
3201 drop(reader);
3202 drop(client);
3203 std::fs::remove_file(path).expect("remove temp database");
3204 }
3205
3206 #[test]
3207 fn local_fts_projection_tracks_optimistic_overlay_rebuilds() {
3208 let schema = json!({
3209 "version": 1,
3210 "tables": [{
3211 "name": "catalogue_codes",
3212 "primaryKey": "id",
3213 "columns": [
3214 { "name": "id", "type": "string", "nullable": false },
3215 { "name": "release_id", "type": "string", "nullable": false },
3216 { "name": "code", "type": "string", "nullable": false },
3217 { "name": "title", "type": "string", "nullable": false }
3218 ],
3219 "scopes": [{ "pattern": "release:{release_id}" }],
3220 "ftsIndexes": [{
3221 "name": "catalogue_codes_fts",
3222 "columns": ["code", "title"],
3223 "tokenize": "unicode61 remove_diacritics 2"
3224 }]
3225 }]
3226 });
3227 let mut client = SyncClient::new("fts-test".to_owned(), &schema, ClientLimits::default())
3228 .expect("FTS5 client");
3229 let insert_trigger: String = client
3230 .conn
3231 .query_row(
3232 "SELECT sql FROM sqlite_master WHERE type='trigger' AND name='catalogue_codes_fts_ai'",
3233 [],
3234 |row| row.get(0),
3235 )
3236 .expect("insert trigger");
3237 let replace_guard: String = client
3238 .conn
3239 .query_row(
3240 "SELECT sql FROM sqlite_master WHERE type='trigger' AND name='catalogue_codes_fts_bi'",
3241 [],
3242 |row| row.get(0),
3243 )
3244 .expect("replace guard");
3245 assert!(!insert_trigger.contains("DELETE FROM"));
3246 assert!(replace_guard.contains("BEFORE INSERT"));
3247 assert!(replace_guard.contains("WHEN EXISTS"));
3248 let search = |client: &SyncClient, query: &str| {
3249 client
3250 .query(
3251 "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",
3252 &[Value::from(query)],
3253 )
3254 .expect("FTS query")
3255 };
3256
3257 client
3258 .mutate(vec![Mutation::Upsert {
3259 table: "catalogue_codes".to_owned(),
3260 values: Map::from_iter([
3261 ("id".to_owned(), Value::from("c1")),
3262 ("release_id".to_owned(), Value::from("r1")),
3263 ("code".to_owned(), Value::from("A01")),
3264 ("title".to_owned(), Value::from("Cholera")),
3265 ]),
3266 base_version: None,
3267 }])
3268 .expect("insert code");
3269 assert_eq!(search(&client, "cholera").len(), 1);
3270
3271 client
3272 .mutate(vec![Mutation::Upsert {
3273 table: "catalogue_codes".to_owned(),
3274 values: Map::from_iter([
3275 ("id".to_owned(), Value::from("c1")),
3276 ("release_id".to_owned(), Value::from("r1")),
3277 ("code".to_owned(), Value::from("A01")),
3278 ("title".to_owned(), Value::from("Enteric infection")),
3279 ]),
3280 base_version: None,
3281 }])
3282 .expect("update code");
3283 assert!(search(&client, "cholera").is_empty());
3284 assert_eq!(search(&client, "enteric").len(), 1);
3285
3286 client
3287 .mutate(vec![Mutation::Delete {
3288 table: "catalogue_codes".to_owned(),
3289 row_id: "c1".to_owned(),
3290 base_version: None,
3291 }])
3292 .expect("delete code");
3293 assert!(search(&client, "enteric").is_empty());
3294 }
3295
3296 #[test]
3297 fn application_authorized_local_purge_is_exact_atomic_and_idempotent() {
3298 let schema = json!({
3299 "version": 1,
3300 "tables": [{
3301 "name": "patient_notes",
3302 "primaryKey": "id",
3303 "columns": [
3304 { "name": "id", "type": "string", "nullable": false },
3305 { "name": "practice_id", "type": "string", "nullable": false },
3306 { "name": "encryption_key_id", "type": "string", "nullable": false },
3307 { "name": "title", "type": "string", "nullable": false }
3308 ],
3309 "scopes": [{ "pattern": "practice:{practice_id}" }],
3310 "ftsIndexes": [{
3311 "name": "patient_notes_fts",
3312 "columns": ["title"],
3313 "tokenize": "unicode61 remove_diacritics 2"
3314 }]
3315 }]
3316 });
3317 let mut client = SyncClient::new(
3318 "local-purge-test".to_owned(),
3319 &schema,
3320 ClientLimits::default(),
3321 )
3322 .expect("local purge client");
3323 client
3324 .conn
3325 .execute(
3326 "INSERT INTO _syncular_base_patient_notes(id, practice_id, encryption_key_id, title, _syncular_version) VALUES
3327 ('target', 'practice-1', 'key-revoked', 'Target original', 1),
3328 ('unrelated', 'practice-1', 'key-held', 'Unrelated original', 1)",
3329 [],
3330 )
3331 .expect("seed base rows");
3332 client.overlay_dirty.set(true);
3333 client.rebuild_overlay_if_dirty();
3334
3335 let note = |id: &str, key_id: &str, title: &str| {
3336 Map::from_iter([
3337 ("id".to_owned(), Value::from(id)),
3338 ("practice_id".to_owned(), Value::from("practice-1")),
3339 ("encryption_key_id".to_owned(), Value::from(key_id)),
3340 ("title".to_owned(), Value::from(title)),
3341 ])
3342 };
3343 let doomed = client
3344 .mutate(vec![
3345 Mutation::Upsert {
3346 table: "patient_notes".to_owned(),
3347 values: note("target", "key-revoked", "Target changed"),
3348 base_version: None,
3349 },
3350 Mutation::Upsert {
3351 table: "patient_notes".to_owned(),
3352 values: note("unrelated", "key-held", "Unrelated changed"),
3353 base_version: None,
3354 },
3355 ])
3356 .expect("doomed commit");
3357 let kept = client
3358 .mutate(vec![Mutation::Upsert {
3359 table: "patient_notes".to_owned(),
3360 values: note("kept", "key-held", "Kept optimistic"),
3361 base_version: None,
3362 }])
3363 .expect("kept commit");
3364 client.drain_change_batches();
3365
3366 let input = LocalDataPurgeInput {
3367 purge_id: "purge-001".to_owned(),
3368 targets: vec![LocalDataPurgeTarget {
3369 table: "patient_notes".to_owned(),
3370 selectors: BTreeMap::from([(
3371 "encryption_key_id".to_owned(),
3372 vec!["key-revoked".to_owned()],
3373 )]),
3374 }],
3375 };
3376 assert_eq!(
3377 client.purge_local_data(&input).expect("apply purge"),
3378 LocalDataPurgeResult {
3379 already_applied: false,
3380 purged_rows: 1,
3381 dropped_commits: 1,
3382 }
3383 );
3384 let rows = client
3385 .query("SELECT id, title FROM patient_notes ORDER BY id", &[])
3386 .expect("visible rows");
3387 assert_eq!(rows.len(), 2);
3388 assert_eq!(rows[0]["id"], "kept");
3389 assert_eq!(rows[1]["id"], "unrelated");
3390 assert_eq!(rows[1]["title"], "Unrelated original");
3391 let fts = client
3392 .query(
3393 "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'",
3394 &[],
3395 )
3396 .expect("fts query");
3397 assert!(fts.is_empty());
3398 assert_eq!(client.outbox.len(), 1);
3399 assert_eq!(client.outbox[0].client_commit_id, kept);
3400 let outcome = client
3401 .commit_outcome(&doomed)
3402 .expect("read doomed outcome")
3403 .expect("doomed outcome");
3404 assert_eq!(outcome.status, CommitOutcomeStatus::Rejected);
3405 match &outcome.results[0] {
3406 CommitOperationOutcome::Error { rejection } => {
3407 assert_eq!(rejection.code, "client.local_data_purged");
3408 assert_eq!(rejection.client_commit_id, doomed);
3409 }
3410 other => panic!("expected local purge rejection, got {other:?}"),
3411 }
3412 assert_eq!(client.drain_change_batches().len(), 1);
3413 assert_eq!(
3414 client.purge_local_data(&input).expect("retry purge"),
3415 LocalDataPurgeResult {
3416 already_applied: true,
3417 purged_rows: 0,
3418 dropped_commits: 0,
3419 }
3420 );
3421 let conflicting = LocalDataPurgeInput {
3422 purge_id: input.purge_id.clone(),
3423 targets: vec![LocalDataPurgeTarget {
3424 table: "patient_notes".to_owned(),
3425 selectors: BTreeMap::from([(
3426 "encryption_key_id".to_owned(),
3427 vec!["key-held".to_owned()],
3428 )]),
3429 }],
3430 };
3431 assert!(client
3432 .purge_local_data(&conflicting)
3433 .expect_err("id collision must fail")
3434 .contains("already used with a different plan"));
3435 }
3436}
3437
3438impl SubState {
3439 fn name(self) -> &'static str {
3440 match self {
3441 SubState::Active => "active",
3442 SubState::Revoked => "revoked",
3443 SubState::Failed => "failed",
3444 }
3445 }
3446
3447 fn parse(value: &str) -> Self {
3448 match value {
3449 "revoked" => Self::Revoked,
3450 "failed" => Self::Failed,
3451 _ => Self::Active,
3452 }
3453 }
3454}
3455
3456#[derive(Debug, Clone)]
3457struct Subscription {
3458 id: String,
3459 table: String,
3460 requested: Vec<(String, Vec<String>)>,
3461 params: Option<String>,
3462 cursor: i64,
3463 bootstrap_state: Option<String>,
3465 state: SubState,
3466 reason_code: Option<String>,
3467 effective: Option<Vec<(String, Vec<String>)>>,
3470 synced_once: bool,
3471}
3472
3473#[derive(Debug, Clone)]
3474struct OutboxOp {
3475 upsert: bool,
3476 table: String,
3477 row_id: String,
3478 base_version: Option<i64>,
3479 values: Option<Map<String, Value>>,
3482 changed_fields: Option<Vec<String>>,
3484}
3485
3486impl From<&OutboxOp> for CommitOperation {
3487 fn from(operation: &OutboxOp) -> Self {
3488 Self {
3489 table: operation.table.clone(),
3490 row_id: operation.row_id.clone(),
3491 op: if operation.upsert { "upsert" } else { "delete" }.to_owned(),
3492 base_version: operation.base_version,
3493 values: operation.values.clone(),
3494 changed_fields: operation.changed_fields.clone(),
3495 }
3496 }
3497}
3498
3499#[derive(Debug, Clone)]
3500struct OutboxCommit {
3501 client_commit_id: String,
3502 ops: Vec<OutboxOp>,
3503}
3504
3505#[derive(Debug, Clone)]
3506struct CompiledLocalDataPurgeTarget {
3507 table: String,
3508 selectors: Vec<(String, Vec<String>)>,
3509}
3510
3511struct StoredCommitOutcomeRow {
3512 sequence: i64,
3513 client_commit_id: String,
3514 status: String,
3515 recorded_at_ms: i64,
3516 results_json: String,
3517 operations_json: Option<String>,
3518 resolution: String,
3519 resolved_at_ms: Option<i64>,
3520 replacement_client_commit_id: Option<String>,
3521}
3522
3523enum SectionError {
3526 FailClosed,
3527 Abort(String, String),
3528}
3529
3530struct RequestMeta {
3531 pushed_ids: Vec<String>,
3532 fresh: Vec<(String, bool)>,
3535 accept: u8,
3536 deferred_commits: usize,
3540}
3541
3542#[derive(Default)]
3543struct ChangeAccumulator {
3544 tables: BTreeMap<String, Option<BTreeSet<String>>>,
3546 windows: BTreeMap<(String, String), BTreeSet<String>>,
3547 status: bool,
3548 conflicts: bool,
3549 rejections: bool,
3550 outcomes: bool,
3551}
3552
3553impl ChangeAccumulator {
3554 fn table(&mut self, table: &str) {
3555 self.tables.insert(table.to_owned(), None);
3556 }
3557
3558 fn scope(&mut self, table: &str, key: String) {
3559 match self.tables.get_mut(table) {
3560 Some(None) => {}
3561 Some(Some(keys)) => {
3562 keys.insert(key);
3563 }
3564 None => {
3565 self.tables
3566 .insert(table.to_owned(), Some(BTreeSet::from([key])));
3567 }
3568 }
3569 }
3570
3571 fn window(&mut self, base_key: &str, table: &str, unit: &str) {
3572 self.windows
3573 .entry((base_key.to_owned(), table.to_owned()))
3574 .or_default()
3575 .insert(unit.to_owned());
3576 }
3577
3578 fn touched(&self) -> bool {
3579 !self.tables.is_empty()
3580 || !self.windows.is_empty()
3581 || self.status
3582 || self.conflicts
3583 || self.rejections
3584 || self.outcomes
3585 }
3586}
3587
3588const PUSH_OPS_PER_REQUEST: usize = 500;
3594const MAX_LOCAL_PURGE_TARGETS: usize = 64;
3595const MAX_LOCAL_PURGE_SELECTORS: usize = 8;
3596const MAX_LOCAL_PURGE_VALUES: usize = 128;
3597const MAX_LOCAL_PURGE_VALUE_LENGTH: usize = 256;
3598pub const SECURITY_PREFLIGHT_REQUIRED_CODE: &str = "client.security_preflight_required";
3599
3600pub struct SyncClient {
3601 #[cfg(feature = "bench-internals")]
3602 benchmark_phases: Recorder,
3603 conn: Connection,
3604 schema: ClientSchema,
3605 client_id: String,
3606 limits: ClientLimits,
3607 subs: Vec<Subscription>,
3608 outbox: Vec<OutboxCommit>,
3609 conflicts: Vec<ConflictRecord>,
3610 rejections: Vec<RejectionRecord>,
3611 schema_floor: Option<SchemaFloor>,
3612 lease_state: Option<LeaseState>,
3614 stopped: bool,
3616 upgrading: bool,
3618 sync_needed: bool,
3620 realtime_connected: bool,
3621 presence: HashMap<String, HashMap<String, PresencePeer>>,
3623 now_ms: Option<i64>,
3626 encryption: crate::values::EncryptionConfig,
3630 security_preflight: bool,
3633 insert_sql: RefCell<HashMap<String, String>>,
3639 overlay_dirty: Cell<bool>,
3643 #[cfg(test)]
3646 overlay_rebuild_count: Cell<usize>,
3647 #[cfg(test)]
3650 outcome_prune_count: Cell<usize>,
3651 change_queue: VecDeque<ClientChangeBatch>,
3653 sync_intent_queue: VecDeque<SyncIntent>,
3654 retry_delay_ms: u64,
3656 last_round: Option<DiagnosticLastRound>,
3657 last_change: Option<DiagnosticLastChange>,
3658}
3659
3660fn quote_ident(name: &str) -> String {
3661 format!("\"{}\"", name.replace('"', "\"\""))
3662}
3663
3664fn row_id_predicate(table: &TableSchema) -> String {
3666 let key = quote_ident(&table.primary_key);
3667 let text_match = format!("CAST({key} AS TEXT) = ?1");
3668 match table.columns[table.pk_index].ty {
3669 ColumnType::String | ColumnType::Json => {
3670 format!("{key} = ?1 AND {text_match}")
3671 }
3672 ColumnType::Integer | ColumnType::Boolean => {
3676 format!("{key} = +CAST(?1 AS INTEGER) AND {text_match}")
3677 }
3678 _ => text_match,
3681 }
3682}
3683
3684fn is_local_operation_code_like(value: &str) -> bool {
3685 let bytes = value.as_bytes();
3686 bytes.first().is_some_and(u8::is_ascii_alphanumeric)
3687 && bytes
3688 .iter()
3689 .all(|byte| byte.is_ascii_alphanumeric() || matches!(*byte, b'.' | b'_' | b':' | b'-'))
3690}
3691
3692fn base_table(name: &str) -> String {
3693 quote_ident(&format!("_syncular_base_{name}"))
3694}
3695
3696type PendingEvict = (String, String, Vec<(String, Vec<String>)>);
3701
3702fn window_base_key(base: &WindowBase) -> String {
3703 format!(
3704 "{}\0{}\0{}",
3705 base.table,
3706 base.variable,
3707 canonical_scope_json(&base.fixed_scopes)
3708 )
3709}
3710
3711fn unit_scopes(base: &WindowBase, unit: &str) -> Vec<(String, Vec<String>)> {
3713 let mut scopes = base.fixed_scopes.clone();
3714 scopes.retain(|(k, _)| k != &base.variable);
3715 scopes.push((base.variable.clone(), vec![unit.to_owned()]));
3716 scopes
3717}
3718
3719fn derive_sub_id(base: &WindowBase, unit: &str) -> String {
3723 let canonical = canonical_scope_json(&unit_scopes(base, unit));
3724 let digest = Sha256::digest(canonical.as_bytes());
3725 let hex = bytes_to_hex(&digest);
3726 format!("w:{}:{}", base.table, &hex[..16])
3727}
3728
3729fn visible_table(name: &str) -> String {
3730 quote_ident(name)
3731}
3732
3733const FTS_SOURCE_ID_COLUMN: &str = "_syncular_source_id";
3734
3735fn is_synced_table_name(name: &str) -> bool {
3738 if name.starts_with("sqlite_") {
3739 return false;
3740 }
3741 if name.starts_with("_syncular_base_") {
3742 return true; }
3744 !name.starts_with("_syncular_")
3746}
3747
3748fn blob_id_for(bytes: &[u8]) -> String {
3750 let digest = Sha256::digest(bytes);
3751 format!("sha256:{}", bytes_to_hex(&digest))
3752}
3753
3754enum RowParam<'a> {
3758 Cell(&'a Option<ColumnValue>),
3759 Version(i64),
3760}
3761
3762impl rusqlite::ToSql for RowParam<'_> {
3763 fn to_sql(&self) -> rusqlite::Result<ToSqlOutput<'_>> {
3764 Ok(match self {
3765 RowParam::Version(v) => ToSqlOutput::Owned(SqlValue::Integer(*v)),
3766 RowParam::Cell(cell) => match cell {
3767 None => ToSqlOutput::Owned(SqlValue::Null),
3768 Some(ColumnValue::String(s)) => ToSqlOutput::Borrowed(ValueRef::Text(s.as_bytes())),
3769 Some(ColumnValue::Integer(i)) => ToSqlOutput::Owned(SqlValue::Integer(*i)),
3770 Some(ColumnValue::Float(f)) => ToSqlOutput::Owned(SqlValue::Real(*f)),
3771 Some(ColumnValue::Boolean(b)) => {
3772 ToSqlOutput::Owned(SqlValue::Integer(i64::from(*b)))
3773 }
3774 Some(ColumnValue::Json(raw)) | Some(ColumnValue::BlobRef(raw)) => {
3775 ToSqlOutput::Borrowed(ValueRef::Text(raw.0.as_bytes()))
3776 }
3777 Some(ColumnValue::Bytes(b)) | Some(ColumnValue::Crdt(b)) => {
3779 ToSqlOutput::Borrowed(ValueRef::Blob(b))
3780 }
3781 },
3782 })
3783 }
3784}
3785
3786fn image_cell_param<'a>(column: &Column, value: ValueRef<'a>) -> Result<ToSqlOutput<'a>, String> {
3794 use ssp2::segment::ColumnType;
3795 let mismatch = || {
3796 Err(format!(
3797 "image column {:?} holds a value of the wrong type",
3798 column.name
3799 ))
3800 };
3801 match value {
3802 ValueRef::Null => {
3803 if !column.nullable {
3804 return Err(format!(
3805 "image column {:?} is NULL but not nullable",
3806 column.name
3807 ));
3808 }
3809 Ok(ToSqlOutput::Owned(SqlValue::Null))
3810 }
3811 ValueRef::Integer(i) => match column.ty {
3812 ColumnType::Integer => Ok(ToSqlOutput::Borrowed(value)),
3813 ColumnType::Boolean => Ok(ToSqlOutput::Owned(SqlValue::Integer(i64::from(i != 0)))),
3814 ColumnType::Float => Ok(ToSqlOutput::Owned(SqlValue::Real(i as f64))),
3815 _ => mismatch(),
3816 },
3817 ValueRef::Real(_) => match column.ty {
3818 ColumnType::Float => Ok(ToSqlOutput::Borrowed(value)),
3819 _ => mismatch(),
3820 },
3821 ValueRef::Text(t) => {
3822 std::str::from_utf8(t)
3823 .map_err(|_| format!("image column {:?} is not UTF-8", column.name))?;
3824 match column.ty {
3825 ColumnType::String | ColumnType::Json | ColumnType::BlobRef => {
3826 Ok(ToSqlOutput::Borrowed(value))
3827 }
3828 _ => mismatch(),
3829 }
3830 }
3831 ValueRef::Blob(_) => match column.ty {
3832 ColumnType::Bytes | ColumnType::Crdt => Ok(ToSqlOutput::Borrowed(value)),
3834 _ => mismatch(),
3835 },
3836 }
3837}
3838
3839fn sql_ref_to_json(column: &Column, value: rusqlite::types::ValueRef<'_>) -> Value {
3840 use rusqlite::types::ValueRef;
3841 match value {
3842 ValueRef::Null => Value::Null,
3843 ValueRef::Integer(i) => match column.ty {
3844 ssp2::segment::ColumnType::Boolean => Value::Bool(i != 0),
3845 ssp2::segment::ColumnType::Float => {
3846 serde_json::Number::from_f64(i as f64).map_or(Value::Null, Value::Number)
3847 }
3848 _ => Value::from(i),
3849 },
3850 ValueRef::Real(f) => serde_json::Number::from_f64(f).map_or(Value::Null, Value::Number),
3851 ValueRef::Text(t) => Value::from(String::from_utf8_lossy(t).into_owned()),
3852 ValueRef::Blob(b) => {
3853 let mut map = Map::new();
3854 map.insert("$bytes".to_owned(), Value::from(bytes_to_hex(b)));
3855 Value::Object(map)
3856 }
3857 }
3858}
3859
3860fn json_param_to_sql(value: &Value) -> Result<SqlValue, String> {
3864 Ok(match value {
3865 Value::Null => SqlValue::Null,
3866 Value::Bool(b) => SqlValue::Integer(i64::from(*b)),
3867 Value::Number(n) => {
3868 if let Some(i) = n.as_i64() {
3869 SqlValue::Integer(i)
3870 } else if let Some(f) = n.as_f64() {
3871 SqlValue::Real(f)
3872 } else {
3873 return Err(format!("query param number {n} is out of range"));
3874 }
3875 }
3876 Value::String(s) => SqlValue::Text(s.clone()),
3877 Value::Object(_) => {
3878 if let Some(hex) = value.get("$bytes").and_then(Value::as_str) {
3879 SqlValue::Blob(crate::values::hex_to_bytes(hex)?)
3880 } else if let Some(decimal) = value.get("$bigint").and_then(Value::as_str) {
3881 SqlValue::Integer(decimal.parse::<i64>().map_err(|_| {
3882 format!("query bigint param {decimal:?} is outside SQLite's i64 range")
3883 })?)
3884 } else {
3885 return Err(
3886 "query object param must be a {$bytes: hex} or {$bigint: decimal} value"
3887 .to_owned(),
3888 );
3889 }
3890 }
3891 Value::Array(_) => return Err("query array params are not supported".to_owned()),
3892 })
3893}
3894
3895fn sql_ref_to_json_dynamic(value: rusqlite::types::ValueRef<'_>) -> Value {
3900 use rusqlite::types::ValueRef;
3901 match value {
3902 ValueRef::Null => Value::Null,
3903 ValueRef::Integer(i) => {
3904 const MAX_SAFE_INTEGER: i64 = 9_007_199_254_740_991;
3908 if (-MAX_SAFE_INTEGER..=MAX_SAFE_INTEGER).contains(&i) {
3909 Value::from(i)
3910 } else {
3911 let mut map = Map::new();
3912 map.insert("$bigint".to_owned(), Value::from(i.to_string()));
3913 Value::Object(map)
3914 }
3915 }
3916 ValueRef::Real(f) => serde_json::Number::from_f64(f).map_or(Value::Null, Value::Number),
3917 ValueRef::Text(t) => Value::from(String::from_utf8_lossy(t).into_owned()),
3918 ValueRef::Blob(b) => {
3919 let mut map = Map::new();
3920 map.insert("$bytes".to_owned(), Value::from(bytes_to_hex(b)));
3921 Value::Object(map)
3922 }
3923 }
3924}
3925
3926fn query_connection(
3927 conn: &Connection,
3928 sql: &str,
3929 params: &[QueryValue],
3930) -> Result<Vec<QueryRow>, String> {
3931 crate::query_guard::assert_read_only_query(sql)?;
3932 let lowered_sql = crate::query_guard::lower_public_query_sql(sql);
3933 let bound: Vec<SqlValue> = params
3934 .iter()
3935 .map(json_param_to_sql)
3936 .collect::<Result<_, _>>()?;
3937 let mut stmt = conn.prepare(&lowered_sql).map_err(|e| e.to_string())?;
3938 let column_names: Vec<String> = stmt.column_names().into_iter().map(str::to_owned).collect();
3939 let bound_refs: Vec<&dyn rusqlite::ToSql> =
3940 bound.iter().map(|v| v as &dyn rusqlite::ToSql).collect();
3941 let mut sql_rows = stmt
3942 .query(bound_refs.as_slice())
3943 .map_err(|e| e.to_string())?;
3944 let mut out = Vec::new();
3945 while let Some(row) = sql_rows.next().map_err(|e| e.to_string())? {
3946 let mut record = Map::new();
3947 for (i, name) in column_names.iter().enumerate() {
3948 let value = row.get_ref(i).map_err(|e| e.to_string())?;
3949 record.insert(name.clone(), sql_ref_to_json_dynamic(value));
3950 }
3951 out.push(record);
3952 }
3953 Ok(out)
3954}
3955
3956fn persisted_window_state(conn: &Connection, base: &WindowBase) -> Result<WindowState, String> {
3957 let mut stmt = conn
3958 .prepare(
3959 "SELECT windows.unit, subscriptions.state_json
3960 FROM _syncular_windows AS windows
3961 JOIN _syncular_subscriptions AS subscriptions
3962 ON subscriptions.id = windows.sub_id
3963 WHERE windows.base = ?1
3964 ORDER BY windows.unit ASC",
3965 )
3966 .map_err(|error| error.to_string())?;
3967 let rows = stmt
3968 .query_map(rusqlite::params![window_base_key(base)], |row| {
3969 Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
3970 })
3971 .map_err(|error| error.to_string())?;
3972 let mut units = Vec::new();
3973 let mut pending = Vec::new();
3974 for row in rows {
3975 let (unit, raw) = row.map_err(|error| error.to_string())?;
3976 let state: Value = serde_json::from_str(&raw)
3977 .map_err(|error| format!("invalid persisted window subscription: {error}"))?;
3978 let is_pending = state.get("status").and_then(Value::as_str) != Some("active")
3979 || state.get("cursor").and_then(Value::as_i64).unwrap_or(-1) < 0
3980 || state
3981 .get("bootstrapState")
3982 .is_some_and(|value| !value.is_null());
3983 if is_pending {
3984 pending.push(unit.clone());
3985 }
3986 units.push(unit);
3987 }
3988 Ok(WindowState { units, pending })
3989}
3990
3991fn snapshot_connection(
3992 conn: &Connection,
3993 sql: &str,
3994 params: &[Value],
3995 coverage: &[WindowCoverage],
3996) -> Result<QuerySnapshot, String> {
3997 conn.execute_batch("SAVEPOINT syncular_snapshot_read")
3998 .map_err(|error| error.to_string())?;
3999 let result = (|| {
4000 let revision = conn
4001 .query_row(
4002 "SELECT value FROM _syncular_meta WHERE key = ?1",
4003 rusqlite::params![LOCAL_REVISION_KEY],
4004 |row| row.get::<_, String>(0),
4005 )
4006 .ok()
4007 .and_then(|value| value.parse::<u64>().ok())
4008 .unwrap_or(0);
4009 let rows = query_connection(conn, sql, params)?;
4010 let mut pending = Vec::new();
4011 let mut missing = Vec::new();
4012 for requested in coverage {
4013 let base_key = window_base_key(&requested.base);
4014 let state = persisted_window_state(conn, &requested.base)?;
4015 for unit in BTreeSet::from_iter(requested.units.iter().cloned()) {
4016 let reference = WindowUnitRef {
4017 base_key: base_key.clone(),
4018 unit: unit.clone(),
4019 };
4020 if !state.units.iter().any(|held| held == &unit) {
4021 missing.push(reference);
4022 } else if state.pending.iter().any(|held| held == &unit) {
4023 pending.push(reference);
4024 }
4025 }
4026 }
4027 Ok(QuerySnapshot {
4028 revision: revision.to_string(),
4029 rows,
4030 coverage: CoverageSnapshot {
4031 complete: pending.is_empty() && missing.is_empty(),
4032 pending,
4033 missing,
4034 },
4035 })
4036 })();
4037 match result {
4038 Ok(snapshot) => {
4039 conn.execute_batch("RELEASE syncular_snapshot_read")
4040 .map_err(|error| error.to_string())?;
4041 Ok(snapshot)
4042 }
4043 Err(error) => {
4044 let _ = conn.execute_batch(
4045 "ROLLBACK TO syncular_snapshot_read; RELEASE syncular_snapshot_read",
4046 );
4047 Err(error)
4048 }
4049 }
4050}
4051
4052pub struct FileQuerySnapshotReader {
4057 path: String,
4058 conn: Option<Connection>,
4059}
4060
4061impl FileQuerySnapshotReader {
4062 #[must_use]
4063 pub fn new(path: impl Into<String>) -> Self {
4064 Self {
4065 path: path.into(),
4066 conn: None,
4067 }
4068 }
4069
4070 fn connection(&mut self) -> Result<&Connection, String> {
4071 if self.conn.is_none() {
4072 let conn = Connection::open_with_flags(
4073 &self.path,
4074 OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_NO_MUTEX,
4075 )
4076 .map_err(|error| format!("open read sidecar {:?}: {error}", self.path))?;
4077 conn.busy_timeout(std::time::Duration::from_millis(250))
4078 .map_err(|error| error.to_string())?;
4079 self.conn = Some(conn);
4080 }
4081 self.conn
4082 .as_ref()
4083 .ok_or_else(|| "read sidecar connection missing".to_owned())
4084 }
4085
4086 pub fn query_snapshot(
4087 &mut self,
4088 sql: &str,
4089 params: &[Value],
4090 coverage: &[WindowCoverage],
4091 ) -> Result<QuerySnapshot, String> {
4092 snapshot_connection(self.connection()?, sql, params, coverage)
4093 }
4094}
4095
4096impl SyncClient {
4097 pub fn new_with_identity(
4098 client_id: Option<String>,
4099 schema_json: &Value,
4100 limits: ClientLimits,
4101 ) -> Result<Self, String> {
4102 let resolved = client_id.unwrap_or_else(|| uuid::Uuid::new_v4().to_string());
4103 let conn = Connection::open_in_memory().map_err(|e| e.to_string())?;
4104 Self::with_connection(resolved, schema_json, limits, conn)
4105 }
4106
4107 pub fn new(
4108 client_id: String,
4109 schema_json: &Value,
4110 limits: ClientLimits,
4111 ) -> Result<Self, String> {
4112 let conn = Connection::open_in_memory().map_err(|e| e.to_string())?;
4113 Self::with_connection(client_id, schema_json, limits, conn)
4114 }
4115
4116 pub fn open_path(
4122 client_id: String,
4123 schema_json: &Value,
4124 limits: ClientLimits,
4125 path: &str,
4126 ) -> Result<Self, String> {
4127 let conn = Connection::open(path).map_err(|e| format!("open db {path:?}: {e}"))?;
4128 Self::with_connection(client_id, schema_json, limits, conn)
4129 }
4130
4131 pub fn open_path_with_identity(
4132 client_id: Option<String>,
4133 schema_json: &Value,
4134 limits: ClientLimits,
4135 path: &str,
4136 ) -> Result<Self, String> {
4137 let conn = Connection::open(path).map_err(|e| format!("open db {path:?}: {e}"))?;
4138 conn.busy_timeout(std::time::Duration::from_millis(250))
4144 .map_err(|error| format!("configure db {path:?} busy timeout: {error}"))?;
4145 conn.pragma_update(None, "journal_mode", "WAL")
4146 .map_err(|error| format!("configure db {path:?} WAL mode: {error}"))?;
4147 let persisted = conn
4148 .query_row(
4149 "SELECT value FROM _syncular_meta WHERE key = 'clientId'",
4150 [],
4151 |row| row.get::<_, String>(0),
4152 )
4153 .ok();
4154 let resolved = persisted
4155 .clone()
4156 .or(client_id.clone())
4157 .unwrap_or_else(|| uuid::Uuid::new_v4().to_string());
4158 if let (Some(existing), Some(requested)) = (persisted, client_id) {
4159 if existing != requested {
4160 return Err(format!(
4161 "client.identity_mismatch: this database belongs to {existing:?}; refusing to rebind it to {requested:?}"
4162 ));
4163 }
4164 }
4165 Self::with_connection(resolved, schema_json, limits, conn)
4166 }
4167
4168 #[must_use]
4169 pub fn client_id(&self) -> &str {
4170 &self.client_id
4171 }
4172
4173 pub fn with_connection(
4180 client_id: String,
4181 schema_json: &Value,
4182 limits: ClientLimits,
4183 conn: Connection,
4184 ) -> Result<Self, String> {
4185 if limits.outcome_retention_max_entries == Some(0) {
4186 return Err(
4187 "sync.invalid_request: outcomeRetentionMaxEntries must be positive".to_owned(),
4188 );
4189 }
4190 let schema = parse_schema_json(schema_json)?;
4191 let mut client = SyncClient {
4192 #[cfg(feature = "bench-internals")]
4193 benchmark_phases: Recorder::default(),
4194 conn,
4195 schema,
4196 client_id,
4197 limits,
4198 subs: Vec::new(),
4199 outbox: Vec::new(),
4200 conflicts: Vec::new(),
4201 rejections: Vec::new(),
4202 schema_floor: None,
4203 lease_state: None,
4204 stopped: false,
4205 upgrading: false,
4206 sync_needed: false,
4207 realtime_connected: false,
4208 presence: HashMap::new(),
4209 now_ms: None,
4210 encryption: crate::values::EncryptionConfig::default(),
4211 security_preflight: false,
4212 insert_sql: RefCell::new(HashMap::new()),
4213 overlay_dirty: Cell::new(false),
4214 #[cfg(test)]
4215 overlay_rebuild_count: Cell::new(0),
4216 #[cfg(test)]
4217 outcome_prune_count: Cell::new(0),
4218 change_queue: VecDeque::new(),
4219 sync_intent_queue: VecDeque::new(),
4220 retry_delay_ms: 250,
4221 last_round: None,
4222 last_change: None,
4223 };
4224 client
4228 .conn
4229 .set_prepared_statement_cache_capacity(64.max(client.schema.tables.len() * 4));
4230 client.create_bookkeeping_tables()?;
4234 match client.get_meta(CLIENT_ID_KEY) {
4235 Some(existing) if existing != client.client_id => {
4236 return Err(format!(
4237 "client.identity_mismatch: this database belongs to {existing:?}; refusing to rebind it to {:?}",
4238 client.client_id
4239 ));
4240 }
4241 None => client.set_meta(CLIENT_ID_KEY, &client.client_id),
4242 _ => {}
4243 }
4244 client.restore_persisted_state()?;
4245 let marker = client
4246 .get_meta(LOCAL_SCHEMA_VERSION_KEY)
4247 .and_then(|value| value.parse::<i32>().ok());
4248 match marker {
4249 None => {
4250 client.create_synced_tables()?;
4251 client.set_meta(LOCAL_SCHEMA_VERSION_KEY, &client.schema.version.to_string());
4252 }
4253 Some(version) if version == client.schema.version => {
4254 client.create_synced_tables()?;
4255 }
4256 Some(_) => client.run_schema_reset()?,
4257 }
4258 client.clear_satisfied_persisted_schema_floor();
4259 client.prune_unknown_subscriptions()?;
4260 if marker == Some(client.schema.version) && !client.outbox.is_empty() {
4261 client.overlay_dirty.set(true);
4264 client.rebuild_overlay();
4265 }
4266 client.enqueue_startup_sync_if_needed();
4272 Ok(client)
4273 }
4274
4275 pub fn set_now_ms(&mut self, now_ms: i64) {
4278 self.now_ms = Some(now_ms);
4279 }
4280
4281 pub fn set_encryption(&mut self, encryption: crate::values::EncryptionConfig) {
4285 self.encryption = encryption;
4286 }
4287
4288 #[must_use]
4289 pub fn security_lifecycle(&self) -> &'static str {
4290 if self.security_preflight {
4291 "preflight"
4292 } else {
4293 "active"
4294 }
4295 }
4296
4297 #[must_use]
4298 pub fn security_preflight(&self) -> bool {
4299 self.security_preflight
4300 }
4301
4302 pub fn begin_security_preflight(&mut self) {
4309 self.seal_security_on_teardown();
4310 self.set_meta(SECURITY_PREFLIGHT_PENDING_KEY, "1");
4313 }
4314
4315 pub fn seal_security_on_teardown(&mut self) {
4325 self.security_preflight = true;
4326 self.encryption = crate::values::EncryptionConfig::default();
4327 self.sync_intent_queue.clear();
4328 }
4329
4330 pub fn activate_security(
4332 &mut self,
4333 encryption: crate::values::EncryptionConfig,
4334 ) -> Result<(), String> {
4335 if !self.security_preflight {
4336 return Err(
4337 "sync.invalid_request: activateSecurity requires security preflight".to_owned(),
4338 );
4339 }
4340 self.encryption = encryption;
4341 self.security_preflight = false;
4342 self.delete_meta(SECURITY_PREFLIGHT_PENDING_KEY);
4343 self.enqueue_startup_sync_if_needed();
4344 Ok(())
4345 }
4346
4347 fn clock_now_ms(&self) -> i64 {
4348 self.now_ms.unwrap_or_else(|| {
4349 std::time::SystemTime::now()
4350 .duration_since(std::time::UNIX_EPOCH)
4351 .map(|d| d.as_millis() as i64)
4352 .unwrap_or(0)
4353 })
4354 }
4355
4356 fn create_bookkeeping_tables(&self) -> Result<(), String> {
4357 self.conn
4359 .execute_batch(
4360 "CREATE TABLE IF NOT EXISTS _syncular_outbox (
4361 seq INTEGER PRIMARY KEY AUTOINCREMENT,
4362 commit_id TEXT NOT NULL UNIQUE, ops_json TEXT NOT NULL);
4363 CREATE TABLE IF NOT EXISTS _syncular_commit_outcomes (
4364 seq INTEGER PRIMARY KEY AUTOINCREMENT,
4365 client_commit_id TEXT NOT NULL UNIQUE,
4366 status TEXT NOT NULL CHECK(status IN ('applied', 'cached', 'conflict', 'rejected')),
4367 recorded_at_ms INTEGER NOT NULL,
4368 results_json TEXT NOT NULL,
4369 operations_json TEXT,
4370 resolution TEXT NOT NULL DEFAULT 'active'
4371 CHECK(resolution IN ('active', 'resolved_keep_server', 'superseded', 'dismissed')),
4372 resolved_at_ms INTEGER,
4373 replacement_client_commit_id TEXT);
4374 CREATE INDEX IF NOT EXISTS _syncular_commit_outcomes_resolution_seq
4375 ON _syncular_commit_outcomes(resolution, seq);
4376 CREATE TABLE IF NOT EXISTS _syncular_subscriptions (
4377 id TEXT PRIMARY KEY, tbl TEXT NOT NULL, state_json TEXT NOT NULL);
4378 CREATE TABLE IF NOT EXISTS _syncular_meta (
4379 key TEXT PRIMARY KEY, value TEXT NOT NULL);
4380 CREATE TABLE IF NOT EXISTS _syncular_windows (
4381 base TEXT NOT NULL, unit TEXT NOT NULL, sub_id TEXT NOT NULL,
4382 PRIMARY KEY (base, unit));
4383 CREATE TABLE IF NOT EXISTS _syncular_window_pending_evict (
4384 sub_id TEXT PRIMARY KEY, tbl TEXT NOT NULL,
4385 effective_scopes TEXT NOT NULL);
4386 DROP TRIGGER IF EXISTS _syncular_blob_commit_insert;
4387 DROP TRIGGER IF EXISTS _syncular_blob_commit_update;
4388 DROP TRIGGER IF EXISTS _syncular_blob_commit_delete;
4389 DROP TRIGGER IF EXISTS _syncular_blob_commit_cache;",
4390 )
4391 .map_err(|e| e.to_string())?;
4392 let _ = self
4395 .conn
4396 .execute_batch("ALTER TABLE _syncular_commit_outcomes ADD COLUMN operations_json TEXT");
4397 if self.get_meta(LOCAL_REVISION_KEY).is_none() {
4398 self.set_meta(LOCAL_REVISION_KEY, "0");
4399 }
4400 if self.schema_has_blobs() {
4405 self.conn
4406 .execute_batch(
4407 "CREATE TABLE IF NOT EXISTS _syncular_blobs (blob_id TEXT PRIMARY KEY,
4408 bytes BLOB NOT NULL, byte_length INTEGER NOT NULL,
4409 media_type TEXT, refcount INTEGER NOT NULL DEFAULT 0,
4410 created_at_ms INTEGER NOT NULL,
4411 last_used_ms INTEGER NOT NULL DEFAULT 0);
4412 CREATE TABLE IF NOT EXISTS _syncular_blob_uploads (blob_id TEXT PRIMARY KEY,
4413 media_type TEXT, created_at_ms INTEGER NOT NULL);",
4414 )
4415 .map_err(|e| e.to_string())?;
4416 let _ = self.conn.execute_batch(
4420 "ALTER TABLE _syncular_blobs ADD COLUMN last_used_ms INTEGER NOT NULL DEFAULT 0",
4421 );
4422 let columns: Vec<(&str, &str)> = self
4423 .schema
4424 .tables
4425 .iter()
4426 .flat_map(|table| {
4427 table
4428 .columns
4429 .iter()
4430 .filter(|column| column.ty == ColumnType::BlobRef)
4431 .map(move |column| (table.name.as_str(), column.name.as_str()))
4432 })
4433 .collect();
4434 let columns = serde_json::to_string(&columns)
4435 .map_err(|e| e.to_string())?
4436 .replace('\'', "''");
4437 let references = format!("SELECT o.commit_id, b.blob_id
4438 FROM _syncular_outbox o JOIN json_each(o.ops_json) op
4439 JOIN json_each(op.value, '$.values') v JOIN json_each('{columns}') c
4440 JOIN _syncular_blobs b ON b.blob_id = CASE WHEN v.type = 'text' AND json_valid(v.value) THEN json_extract(v.value, '$.blobId') END
4441 WHERE json_extract(op.value, '$.op') = 'upsert'
4442 AND json_extract(op.value, '$.table') = json_extract(c.value, '$[0]')
4443 AND v.key = json_extract(c.value, '$[1]')");
4444 let transaction = self
4445 .conn
4446 .unchecked_transaction()
4447 .map_err(|e| e.to_string())?;
4448 transaction.execute_batch(&format!("
4449 CREATE TABLE IF NOT EXISTS _syncular_blob_commit_refs(commit_id TEXT NOT NULL, blob_id TEXT NOT NULL, PRIMARY KEY(commit_id, blob_id));
4450 CREATE INDEX IF NOT EXISTS _syncular_blob_commit_refs_body ON _syncular_blob_commit_refs(blob_id);
4451 CREATE TRIGGER _syncular_blob_commit_insert AFTER INSERT ON _syncular_outbox BEGIN
4452 INSERT OR IGNORE INTO _syncular_blob_commit_refs {references} AND o.commit_id = NEW.commit_id; END;
4453 CREATE TRIGGER _syncular_blob_commit_update AFTER UPDATE OF ops_json ON _syncular_outbox BEGIN
4454 DELETE FROM _syncular_blob_commit_refs WHERE commit_id = OLD.commit_id;
4455 INSERT OR IGNORE INTO _syncular_blob_commit_refs {references} AND o.commit_id = NEW.commit_id; END;
4456 CREATE TRIGGER _syncular_blob_commit_delete BEFORE DELETE ON _syncular_outbox BEGIN
4457 DELETE FROM _syncular_blob_uploads WHERE blob_id IN (SELECT blob_id FROM _syncular_blob_commit_refs WHERE commit_id = OLD.commit_id)
4458 AND NOT EXISTS (SELECT 1 FROM _syncular_blob_commit_refs r WHERE r.blob_id = _syncular_blob_uploads.blob_id AND r.commit_id != OLD.commit_id);
4459 DELETE FROM _syncular_blob_commit_refs WHERE commit_id = OLD.commit_id; END;
4460 CREATE TRIGGER _syncular_blob_commit_cache AFTER INSERT ON _syncular_blobs BEGIN
4461 INSERT OR IGNORE INTO _syncular_blob_commit_refs {references} AND b.blob_id = NEW.blob_id; END;
4462 INSERT OR IGNORE INTO _syncular_blob_commit_refs {references};
4463 ")).map_err(|e| e.to_string())?;
4464 transaction.commit().map_err(|e| e.to_string())?;
4465 }
4466 Ok(())
4467 }
4468
4469 fn schema_has_blobs(&self) -> bool {
4471 self.schema
4472 .tables
4473 .iter()
4474 .any(|t| t.columns.iter().any(|c| c.ty == ColumnType::BlobRef))
4475 }
4476
4477 fn get_meta(&self, key: &str) -> Option<String> {
4480 self.conn
4481 .query_row(
4482 "SELECT value FROM _syncular_meta WHERE key = ?1",
4483 rusqlite::params![key],
4484 |row| row.get::<_, String>(0),
4485 )
4486 .ok()
4487 }
4488
4489 fn get_meta_strict(&self, key: &str) -> Result<Option<String>, String> {
4490 self.conn
4491 .query_row(
4492 "SELECT value FROM _syncular_meta WHERE key = ?1",
4493 rusqlite::params![key],
4494 |row| row.get::<_, String>(0),
4495 )
4496 .optional()
4497 .map_err(|_| {
4498 "sync.local_corrupt: persisted local rebootstrap receipt is unreadable".to_owned()
4499 })
4500 }
4501
4502 fn set_meta(&self, key: &str, value: &str) {
4503 let _ = self.conn.execute(
4504 "INSERT OR REPLACE INTO _syncular_meta (key, value) VALUES (?1, ?2)",
4505 rusqlite::params![key, value],
4506 );
4507 }
4508
4509 fn delete_meta(&self, key: &str) {
4510 let _ = self.conn.execute(
4511 "DELETE FROM _syncular_meta WHERE key = ?1",
4512 rusqlite::params![key],
4513 );
4514 }
4515
4516 fn restore_persisted_state(&mut self) -> Result<(), String> {
4517 self.subs = {
4518 let mut stmt = self
4519 .conn
4520 .prepare("SELECT id, tbl, state_json FROM _syncular_subscriptions ORDER BY id ASC")
4521 .map_err(|error| error.to_string())?;
4522 let rows = stmt
4523 .query_map([], |row| {
4524 Ok((
4525 row.get::<_, String>(0)?,
4526 row.get::<_, String>(1)?,
4527 row.get::<_, String>(2)?,
4528 ))
4529 })
4530 .map_err(|error| error.to_string())?;
4531 let mut subscriptions = Vec::new();
4532 for row in rows {
4533 let (id, table, raw) = row.map_err(|error| error.to_string())?;
4534 let state: Value = serde_json::from_str(&raw)
4535 .map_err(|error| format!("invalid persisted subscription {id:?}: {error}"))?;
4536 let requested = json_to_scope_map(
4537 state.get("requested").unwrap_or(&Value::Object(Map::new())),
4538 )?;
4539 let effective = state
4540 .get("effectiveScopes")
4541 .filter(|value| !value.is_null())
4542 .map(json_to_scope_map)
4543 .transpose()?;
4544 subscriptions.push(Subscription {
4545 id,
4546 table,
4547 requested,
4548 params: state
4549 .get("params")
4550 .and_then(Value::as_str)
4551 .map(str::to_owned),
4552 cursor: state.get("cursor").and_then(Value::as_i64).unwrap_or(-1),
4553 bootstrap_state: state
4554 .get("bootstrapState")
4555 .and_then(Value::as_str)
4556 .map(str::to_owned),
4557 state: SubState::parse(
4558 state
4559 .get("status")
4560 .and_then(Value::as_str)
4561 .unwrap_or("active"),
4562 ),
4563 reason_code: state
4564 .get("reasonCode")
4565 .and_then(Value::as_str)
4566 .map(str::to_owned),
4567 effective,
4568 synced_once: state
4569 .get("syncedOnce")
4570 .and_then(Value::as_bool)
4571 .unwrap_or(false),
4572 });
4573 }
4574 subscriptions
4575 };
4576
4577 self.outbox = {
4578 let mut stmt = self
4579 .conn
4580 .prepare("SELECT commit_id, ops_json FROM _syncular_outbox ORDER BY seq ASC")
4581 .map_err(|error| error.to_string())?;
4582 let rows = stmt
4583 .query_map([], |row| {
4584 Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
4585 })
4586 .map_err(|error| error.to_string())?;
4587 let mut commits = Vec::new();
4588 for row in rows {
4589 let (client_commit_id, raw) = row.map_err(|error| error.to_string())?;
4590 let entries: Vec<Value> = serde_json::from_str(&raw).map_err(|error| {
4591 format!("invalid persisted outbox {client_commit_id:?}: {error}")
4592 })?;
4593 let mut ops = Vec::with_capacity(entries.len());
4594 for entry in entries {
4595 let op = entry.get("op").and_then(Value::as_str).unwrap_or("delete");
4596 ops.push(OutboxOp {
4597 upsert: op == "upsert",
4598 table: entry
4599 .get("table")
4600 .and_then(Value::as_str)
4601 .ok_or_else(|| "persisted outbox operation missing table".to_owned())?
4602 .to_owned(),
4603 row_id: entry
4604 .get("rowId")
4605 .and_then(Value::as_str)
4606 .ok_or_else(|| "persisted outbox operation missing rowId".to_owned())?
4607 .to_owned(),
4608 base_version: entry.get("baseVersion").and_then(Value::as_i64),
4609 values: entry.get("values").and_then(Value::as_object).cloned(),
4610 changed_fields: entry.get("changedFields").and_then(Value::as_array).map(
4611 |values| {
4612 values
4613 .iter()
4614 .filter_map(Value::as_str)
4615 .map(str::to_owned)
4616 .collect()
4617 },
4618 ),
4619 });
4620 }
4621 commits.push(OutboxCommit {
4622 client_commit_id,
4623 ops,
4624 });
4625 }
4626 commits
4627 };
4628
4629 self.prune_commit_outcomes()?;
4630 let active = self.commit_outcomes(CommitOutcomeQuery {
4631 active_only: true,
4632 ..CommitOutcomeQuery::default()
4633 })?;
4634 self.conflicts = active
4635 .iter()
4636 .flat_map(|outcome| outcome.results.iter())
4637 .filter_map(|result| match result {
4638 CommitOperationOutcome::Conflict { conflict } => Some(conflict.clone()),
4639 _ => None,
4640 })
4641 .collect();
4642 self.rejections = active
4643 .iter()
4644 .flat_map(|outcome| outcome.results.iter())
4645 .filter_map(|result| match result {
4646 CommitOperationOutcome::Error { rejection } => Some(rejection.clone()),
4647 _ => None,
4648 })
4649 .collect();
4650
4651 self.lease_state = self
4652 .get_meta(LEASE_STATE_KEY)
4653 .map(|raw| serde_json::from_str(&raw))
4654 .transpose()
4655 .map_err(|error| format!("invalid persisted lease state: {error}"))?;
4656 self.schema_floor = self
4657 .get_meta(SCHEMA_FLOOR_KEY)
4658 .map(|raw| serde_json::from_str(&raw))
4659 .transpose()
4660 .map_err(|error| format!("invalid persisted schema floor: {error}"))?;
4661 self.stopped = self.schema_floor.is_some();
4662 if self.get_meta(SECURITY_PREFLIGHT_PENDING_KEY).as_deref() == Some("1") {
4665 self.security_preflight = true;
4666 }
4667 Ok(())
4668 }
4669
4670 fn prune_unknown_subscriptions(&mut self) -> Result<(), String> {
4673 let valid_tables: BTreeSet<String> = self
4674 .schema
4675 .tables
4676 .iter()
4677 .map(|table| table.name.clone())
4678 .collect();
4679 let stale_ids: Vec<String> = self
4680 .subs
4681 .iter()
4682 .filter(|sub| !valid_tables.contains(&sub.table))
4683 .map(|sub| sub.id.clone())
4684 .collect();
4685 for id in &stale_ids {
4686 self.conn
4687 .execute(
4688 "DELETE FROM _syncular_windows WHERE sub_id = ?1",
4689 rusqlite::params![id],
4690 )
4691 .map_err(|error| error.to_string())?;
4692 self.conn
4693 .execute(
4694 "DELETE FROM _syncular_window_pending_evict WHERE sub_id = ?1",
4695 rusqlite::params![id],
4696 )
4697 .map_err(|error| error.to_string())?;
4698 self.conn
4699 .execute(
4700 "DELETE FROM _syncular_subscriptions WHERE id = ?1",
4701 rusqlite::params![id],
4702 )
4703 .map_err(|error| error.to_string())?;
4704 }
4705 self.subs.retain(|sub| valid_tables.contains(&sub.table));
4706 Ok(())
4707 }
4708
4709 #[must_use]
4710 pub fn local_revision(&self) -> u64 {
4711 self.get_meta(LOCAL_REVISION_KEY)
4712 .and_then(|value| value.parse().ok())
4713 .unwrap_or(0)
4714 }
4715
4716 #[must_use]
4717 pub fn status_snapshot(&self) -> SyncStatusSnapshot {
4718 SyncStatusSnapshot {
4719 current_schema_version: self.schema.version,
4720 outbox: self.outbox.len(),
4721 upgrading: self.upgrading,
4722 lease_state: self.lease_state.clone(),
4723 schema_floor: self.schema_floor.clone(),
4724 sync_needed: self.sync_needed,
4725 }
4726 }
4727
4728 pub fn diagnostics_snapshot(
4729 &self,
4730 request: &ClientDiagnosticsRequest,
4731 ) -> Result<ClientDiagnosticsSnapshot, String> {
4732 if request.expected_subscriptions.len() > MAX_DIAGNOSTIC_EXPECTED_SUBSCRIPTIONS {
4733 return Err(format!(
4734 "sync.invalid_request: diagnosticsSnapshot accepts at most {MAX_DIAGNOSTIC_EXPECTED_SUBSCRIPTIONS} expected subscriptions"
4735 ));
4736 }
4737 let mut subscriptions = BTreeMap::<String, DiagnosticSubscription>::new();
4738 for sub in &self.subs {
4739 let reset = sub.cursor < 0 && sub.reason_code.as_deref() == Some("sync.cursor_expired");
4740 let complete =
4741 sub.state == SubState::Active && sub.cursor >= 0 && sub.bootstrap_state.is_none();
4742 let state = match sub.state {
4743 SubState::Revoked => "revoked",
4744 SubState::Failed => "failed",
4745 SubState::Active if reset => "reset",
4746 SubState::Active if complete => "complete",
4747 SubState::Active => "bootstrapping",
4748 };
4749 subscriptions.insert(
4750 sub.id.clone(),
4751 DiagnosticSubscription {
4752 id: sub.id.clone(),
4753 table: sub.table.clone(),
4754 state: state.to_owned(),
4755 complete,
4756 cursor: Some(sub.cursor),
4757 reason_code: sub.reason_code.as_deref().map(Self::diagnostic_code),
4758 },
4759 );
4760 }
4761 for expected in &request.expected_subscriptions {
4762 if expected.id.is_empty() || expected.table.is_empty() {
4763 return Err("sync.invalid_request: diagnosticsSnapshot expected subscriptions require non-empty id and table strings".to_owned());
4764 }
4765 if subscriptions
4766 .get(&expected.id)
4767 .is_some_and(|registered| registered.table != expected.table)
4768 {
4769 subscriptions.insert(
4770 expected.id.clone(),
4771 DiagnosticSubscription {
4772 id: expected.id.clone(),
4773 table: expected.table.clone(),
4774 state: "failed".to_owned(),
4775 complete: false,
4776 cursor: None,
4777 reason_code: Some("client.subscription_intent_mismatch".to_owned()),
4778 },
4779 );
4780 } else {
4781 subscriptions.entry(expected.id.clone()).or_insert_with(|| {
4782 DiagnosticSubscription {
4783 id: expected.id.clone(),
4784 table: expected.table.clone(),
4785 state: "unregistered".to_owned(),
4786 complete: false,
4787 cursor: None,
4788 reason_code: None,
4789 }
4790 });
4791 }
4792 }
4793 let mut ordered_subscriptions = Vec::new();
4794 let mut included = BTreeSet::new();
4795 for expected in &request.expected_subscriptions {
4796 if included.insert(expected.id.clone()) {
4797 if let Some(subscription) = subscriptions.get(&expected.id) {
4798 ordered_subscriptions.push(subscription.clone());
4799 }
4800 }
4801 }
4802 for (id, subscription) in &subscriptions {
4803 if included.insert(id.clone()) {
4804 ordered_subscriptions.push(subscription.clone());
4805 }
4806 }
4807 let subscriptions_truncated =
4808 ordered_subscriptions.len() > MAX_DIAGNOSTIC_EXPECTED_SUBSCRIPTIONS;
4809 ordered_subscriptions.truncate(MAX_DIAGNOSTIC_EXPECTED_SUBSCRIPTIONS);
4810 let captured_at_ms = self.clock_now_ms();
4811 let lease = if let Some(error_code) = self
4812 .lease_state
4813 .as_ref()
4814 .and_then(|state| state.error_code.clone())
4815 {
4816 ClientDiagnosticsLease {
4817 state: "stopped".to_owned(),
4818 expires_at_ms: self
4819 .lease_state
4820 .as_ref()
4821 .and_then(|state| state.expires_at_ms),
4822 error_code: Some(Self::diagnostic_code(&error_code)),
4823 }
4824 } else if let Some(expires_at_ms) = self
4825 .lease_state
4826 .as_ref()
4827 .and_then(|state| state.expires_at_ms)
4828 {
4829 ClientDiagnosticsLease {
4830 state: if expires_at_ms <= captured_at_ms {
4831 "expired".to_owned()
4832 } else {
4833 "active".to_owned()
4834 },
4835 expires_at_ms: Some(expires_at_ms),
4836 error_code: None,
4837 }
4838 } else {
4839 ClientDiagnosticsLease {
4840 state: "none".to_owned(),
4841 expires_at_ms: None,
4842 error_code: None,
4843 }
4844 };
4845 let connectivity = match self.last_round.as_ref() {
4846 Some(round) if round.status == "succeeded" => "online",
4847 Some(round)
4848 if round.status == "failed"
4849 && round
4850 .error_code
4851 .as_deref()
4852 .is_some_and(Self::retryable_transport_code) =>
4853 {
4854 "offline"
4855 }
4856 _ => "unknown",
4857 };
4858 Ok(ClientDiagnosticsSnapshot {
4859 version: CLIENT_DIAGNOSTICS_VERSION,
4860 captured_at_ms,
4861 host: ClientDiagnosticsHost {
4862 kind: "direct".to_owned(),
4863 role: "single".to_owned(),
4864 connectivity: connectivity.to_owned(),
4865 realtime: if self.realtime_connected {
4866 "connected".to_owned()
4867 } else {
4868 "disconnected".to_owned()
4869 },
4870 },
4871 security_lifecycle: self.security_lifecycle().to_owned(),
4872 schema: ClientDiagnosticsSchema {
4873 current_version: self.schema.version,
4874 upgrading: self.upgrading,
4875 required_version: self
4876 .schema_floor
4877 .as_ref()
4878 .and_then(|floor| floor.required_schema_version),
4879 latest_version: self
4880 .schema_floor
4881 .as_ref()
4882 .and_then(|floor| floor.latest_schema_version),
4883 },
4884 replica: ClientDiagnosticsReplica {
4885 local_revision: self.local_revision().to_string(),
4886 sync_needed: self.sync_needed,
4887 pending_outbox: self.outbox.len(),
4888 },
4889 lease,
4890 subscriptions: ordered_subscriptions,
4891 subscriptions_truncated,
4892 last_round: self.last_round.clone(),
4893 last_change: self.last_change.clone(),
4894 storage: self.diagnostics_storage(),
4895 })
4896 }
4897
4898 fn diagnostics_storage(&self) -> ClientDiagnosticsStorage {
4899 let read = || -> Result<ClientDiagnosticsStorage, rusqlite::Error> {
4900 let page_count: i64 = self
4901 .conn
4902 .query_row("PRAGMA page_count", [], |row| row.get(0))?;
4903 let page_size: i64 = self
4904 .conn
4905 .query_row("PRAGMA page_size", [], |row| row.get(0))?;
4906 let outbox_bytes: i64 = self
4908 .conn
4909 .prepare_cached("SELECT COALESCE(SUM(LENGTH(ops_json)), 0) FROM _syncular_outbox")?
4910 .query_row([], |row| row.get(0))?;
4911 let (outcome_entries, outcome_bytes): (i64, i64) = self.conn.prepare_cached(
4912 "SELECT COUNT(*), COALESCE(SUM(LENGTH(results_json) + COALESCE(LENGTH(operations_json), 0)), 0) FROM _syncular_commit_outcomes",
4913 )?.query_row([], |row| Ok((row.get(0)?, row.get(1)?)))?;
4914 let blob_bytes = if self.schema_has_blobs() {
4915 self.conn
4916 .prepare_cached("SELECT COALESCE(SUM(byte_length), 0) FROM _syncular_blobs")?
4917 .query_row([], |row| row.get(0))?
4918 } else {
4919 0
4920 };
4921 let pressure = self
4922 .limits
4923 .blob_cache_max_bytes
4924 .is_some_and(|limit| blob_bytes > limit);
4925 Ok(ClientDiagnosticsStorage {
4926 status: if pressure { "pressure" } else { "healthy" }.to_owned(),
4927 database_bytes_approx: Some(page_count.saturating_mul(page_size).max(0)),
4928 pending_outbox_bytes_approx: Some(outbox_bytes.max(0)),
4929 retained_outcome_bytes_approx: Some(outcome_bytes.max(0)),
4930 retained_outcome_entries: Some(outcome_entries.max(0)),
4931 blob_cache_bytes_approx: Some(blob_bytes.max(0)),
4932 pressure_reason_code: pressure.then(|| "client.blob_cache_over_limit".to_owned()),
4933 })
4934 };
4935 read().unwrap_or_else(|_| ClientDiagnosticsStorage {
4936 status: "unreadable".to_owned(),
4937 database_bytes_approx: None,
4938 pending_outbox_bytes_approx: None,
4939 retained_outcome_bytes_approx: None,
4940 retained_outcome_entries: None,
4941 blob_cache_bytes_approx: None,
4942 pressure_reason_code: None,
4943 })
4944 }
4945
4946 pub fn drain_change_batches(&mut self) -> Vec<ClientChangeBatch> {
4947 self.change_queue.drain(..).collect()
4948 }
4949
4950 pub fn drain_sync_intents(&mut self) -> Vec<SyncIntent> {
4951 self.sync_intent_queue.drain(..).collect()
4952 }
4953
4954 fn schedule_background_retry(&mut self) {
4955 self.sync_intent_queue.push_back(SyncIntent::Background {
4956 delay_ms: self.retry_delay_ms,
4957 });
4958 self.retry_delay_ms = (self.retry_delay_ms * 2).min(30_000);
4959 }
4960
4961 fn reset_background_retry(&mut self) {
4962 self.retry_delay_ms = 250;
4963 }
4964
4965 fn retryable_transport_code(code: &str) -> bool {
4966 code == "transport.failed"
4967 || code == "transport.unavailable"
4968 || code == "sync.transport_failed"
4969 }
4970
4971 fn diagnostic_code(code: &str) -> String {
4972 let valid = !code.is_empty()
4973 && code.len() <= 96
4974 && code.contains('.')
4975 && code
4976 .bytes()
4977 .next()
4978 .is_some_and(|byte| byte.is_ascii_lowercase())
4979 && code.bytes().all(|byte| {
4980 byte.is_ascii_lowercase()
4981 || byte.is_ascii_digit()
4982 || matches!(byte, b'.' | b'_' | b'-')
4983 });
4984 if valid {
4985 code.to_owned()
4986 } else {
4987 "client.unknown_failure".to_owned()
4988 }
4989 }
4990
4991 fn set_sync_needed(&mut self, value: bool, interactive: bool) {
4992 if self.sync_needed != value {
4993 if self.begin_observation("syncular_status").is_ok() {
4994 self.sync_needed = value;
4995 let batch = ChangeAccumulator {
4996 status: true,
4997 ..ChangeAccumulator::default()
4998 };
4999 if self.finish_observation("syncular_status", batch).is_err() {
5000 self.rollback_observation("syncular_status");
5001 }
5002 } else {
5003 self.sync_needed = value;
5004 }
5005 }
5006 if value && interactive {
5007 self.sync_intent_queue.push_back(SyncIntent::Interactive);
5008 }
5009 }
5010
5011 fn begin_observation(&self, name: &str) -> Result<(), String> {
5012 self.conn
5013 .execute_batch(&format!("SAVEPOINT {name}"))
5014 .map_err(|error| error.to_string())
5015 }
5016
5017 fn rollback_observation(&self, name: &str) {
5018 let _ = self
5019 .conn
5020 .execute_batch(&format!("ROLLBACK TO {name}; RELEASE {name}"));
5021 }
5022
5023 fn finish_observation(&mut self, name: &str, batch: ChangeAccumulator) -> Result<(), String> {
5024 #[cfg(feature = "bench-internals")]
5025 let _phase = self.benchmark_phases.start(Phase::ObservationCommit);
5026 if !batch.touched() {
5027 self.conn
5028 .execute_batch(&format!("RELEASE {name}"))
5029 .map_err(|error| error.to_string())?;
5030 return Ok(());
5031 }
5032 let revision = self
5033 .local_revision()
5034 .checked_add(1)
5035 .ok_or_else(|| "local revision exhausted u64".to_owned())?;
5036 self.conn
5037 .execute(
5038 "INSERT OR REPLACE INTO _syncular_meta(key, value) VALUES (?1, ?2)",
5039 rusqlite::params![LOCAL_REVISION_KEY, revision.to_string()],
5040 )
5041 .map_err(|error| error.to_string())?;
5042 let status = batch.status.then(|| self.status_snapshot());
5043 let event = ClientChangeBatch {
5044 revision: revision.to_string(),
5045 tables: batch
5046 .tables
5047 .into_iter()
5048 .map(|(table, scope_keys)| TableChange {
5049 table,
5050 scope_keys: scope_keys.map(|keys| keys.into_iter().collect()),
5051 })
5052 .collect(),
5053 windows: batch
5054 .windows
5055 .into_iter()
5056 .map(|((base_key, table), units)| WindowChange {
5057 base_key,
5058 table,
5059 units: units.into_iter().collect(),
5060 })
5061 .collect(),
5062 status,
5063 conflicts_changed: batch.conflicts,
5064 rejections_changed: batch.rejections,
5065 outcomes_changed: batch.outcomes,
5066 };
5067 self.conn
5068 .execute_batch(&format!("RELEASE {name}"))
5069 .map_err(|error| error.to_string())?;
5070 let mut diagnostic_tables = event
5071 .tables
5072 .iter()
5073 .map(|entry| entry.table.clone())
5074 .collect::<BTreeSet<_>>()
5075 .into_iter()
5076 .collect::<Vec<_>>();
5077 let mut diagnostic_windows = event
5078 .windows
5079 .iter()
5080 .map(|entry| entry.table.clone())
5081 .collect::<BTreeSet<_>>()
5082 .into_iter()
5083 .collect::<Vec<_>>();
5084 let domains_truncated = diagnostic_tables.len() > MAX_DIAGNOSTIC_DOMAINS
5085 || diagnostic_windows.len() > MAX_DIAGNOSTIC_DOMAINS;
5086 diagnostic_tables.truncate(MAX_DIAGNOSTIC_DOMAINS);
5087 diagnostic_windows.truncate(MAX_DIAGNOSTIC_DOMAINS);
5088 self.last_change = Some(DiagnosticLastChange {
5089 revision: event.revision.clone(),
5090 recorded_at_ms: self.clock_now_ms(),
5091 tables: diagnostic_tables,
5092 windows: diagnostic_windows,
5093 domains_truncated,
5094 status_changed: event.status.is_some(),
5095 conflicts_changed: event.conflicts_changed,
5096 rejections_changed: event.rejections_changed,
5097 outcomes_changed: event.outcomes_changed,
5098 });
5099 self.change_queue.push_back(event);
5100 Ok(())
5101 }
5102
5103 fn record_scope_map(
5104 &self,
5105 batch: &mut ChangeAccumulator,
5106 table_name: &str,
5107 scopes: &[(String, Vec<String>)],
5108 ) {
5109 let Some(table) = self.schema.table(table_name) else {
5110 return;
5111 };
5112 for (variable, values) in scopes {
5113 let Some(scope) = table
5114 .scope_variables
5115 .iter()
5116 .find(|scope| &scope.variable == variable)
5117 else {
5118 continue;
5119 };
5120 for value in values {
5121 batch.scope(table_name, format!("{}:{value}", scope.prefix));
5122 }
5123 }
5124 }
5125
5126 fn record_row_scopes(
5128 &self,
5129 batch: &mut ChangeAccumulator,
5130 table_name: &str,
5131 row_id: &str,
5132 base: bool,
5133 ) -> bool {
5134 let Some(table) = self.schema.table(table_name) else {
5135 return false;
5136 };
5137 if table.scope_variables.is_empty() {
5138 return false;
5139 }
5140 let columns = table
5141 .scope_variables
5142 .iter()
5143 .map(|scope| quote_ident(&scope.column))
5144 .collect::<Vec<_>>()
5145 .join(", ");
5146 let full_table = if base {
5147 base_table(table_name)
5148 } else {
5149 visible_table(table_name)
5150 };
5151 let sql = format!(
5152 "SELECT {columns} FROM {full_table} WHERE {} LIMIT 1",
5153 row_id_predicate(table)
5154 );
5155 let Ok(mut stmt) = self.conn.prepare_cached(&sql) else {
5156 return false;
5157 };
5158 let values = stmt.query_row(rusqlite::params![row_id], |row| {
5159 let mut values = Vec::with_capacity(table.scope_variables.len());
5160 for index in 0..table.scope_variables.len() {
5161 values.push(row.get::<_, Option<String>>(index)?);
5162 }
5163 Ok(values)
5164 });
5165 let Ok(values) = values else {
5166 return false;
5167 };
5168 let mut recorded = false;
5169 for (scope, value) in table.scope_variables.iter().zip(values) {
5170 if let Some(value) = value {
5171 batch.scope(table_name, format!("{}:{value}", scope.prefix));
5172 recorded = true;
5173 }
5174 }
5175 recorded
5176 }
5177
5178 fn record_commit_changes(
5179 &self,
5180 batch: &mut ChangeAccumulator,
5181 tables: &[String],
5182 changes: &[ssp2::model::Change],
5183 ) {
5184 #[cfg(feature = "bench-internals")]
5185 let _phase = self.benchmark_phases.start(Phase::ObservationPrepare);
5186 for change in changes {
5187 let Some(table_name) = tables.get(change.table_index as usize) else {
5188 continue;
5189 };
5190 let mut precise = self.record_row_scopes(batch, table_name, &change.row_id, true);
5191 if let Some(table) = self.schema.table(table_name) {
5192 for (variable, value) in &change.scopes {
5193 if let Some(scope) = table
5194 .scope_variables
5195 .iter()
5196 .find(|scope| &scope.variable == variable)
5197 {
5198 batch.scope(table_name, format!("{}:{value}", scope.prefix));
5199 precise = true;
5200 }
5201 }
5202 }
5203 if !precise {
5204 batch.table(table_name);
5205 }
5206 }
5207 }
5208
5209 fn scoped_rows_exist(&self, table_name: &str, effective: &[(String, Vec<String>)]) -> bool {
5210 if effective.is_empty() {
5211 return false;
5212 }
5213 let Some(table) = self.schema.table(table_name) else {
5214 return false;
5215 };
5216 let mut clauses = Vec::new();
5217 let mut params = Vec::new();
5218 for (variable, values) in effective {
5219 let Some(column) = table.scope_column(variable) else {
5220 return false;
5221 };
5222 if values.is_empty() {
5223 return false;
5224 }
5225 let placeholders = values
5226 .iter()
5227 .map(|value| {
5228 params.push(SqlValue::Text(value.clone()));
5229 "?"
5230 })
5231 .collect::<Vec<_>>()
5232 .join(", ");
5233 clauses.push(format!("{} IN ({placeholders})", quote_ident(column)));
5234 }
5235 let sql = format!(
5236 "SELECT 1 FROM {} WHERE {} LIMIT 1",
5237 base_table(table_name),
5238 clauses.join(" AND ")
5239 );
5240 self.conn
5241 .query_row(&sql, rusqlite::params_from_iter(params), |_| Ok(()))
5242 .is_ok()
5243 }
5244
5245 pub fn upgrading(&self) -> bool {
5247 self.upgrading
5248 }
5249
5250 pub fn recreate_with_schema(&mut self, schema_json: &Value) -> Result<(), String> {
5256 let new_schema = parse_schema_json(schema_json)?;
5257 let marker: Option<i32> = self
5258 .get_meta(LOCAL_SCHEMA_VERSION_KEY)
5259 .and_then(|v| v.parse().ok());
5260 self.schema = new_schema;
5261 self.create_bookkeeping_tables()?;
5262 if marker != Some(self.schema.version) {
5263 self.run_schema_reset()?;
5264 }
5265 self.prune_unknown_subscriptions()?;
5266 self.enqueue_startup_sync_if_needed();
5270 Ok(())
5271 }
5272
5273 fn enqueue_startup_sync_if_needed(&mut self) {
5274 let startup_work = !self.stopped
5275 && (!self.outbox.is_empty()
5276 || self.subs.iter().any(|sub| sub.state == SubState::Active));
5277 if startup_work {
5278 self.sync_needed = true;
5279 self.sync_intent_queue.push_back(SyncIntent::Interactive);
5280 }
5281 }
5282
5283 fn clear_satisfied_persisted_schema_floor(&mut self) {
5290 let satisfied = self
5291 .schema_floor
5292 .as_ref()
5293 .and_then(|floor| floor.required_schema_version)
5294 .is_some_and(|required| self.schema.version >= required);
5295 if !satisfied {
5296 return;
5297 }
5298 self.schema_floor = None;
5299 self.stopped = false;
5300 self.delete_meta(SCHEMA_FLOOR_KEY);
5301 }
5302
5303 fn run_schema_reset(&mut self) -> Result<(), String> {
5309 self.begin_observation("syncular_schema_reset")?;
5310 let mut batch = ChangeAccumulator::default();
5311 let result = self.run_schema_reset_observed(&mut batch, true);
5312 if let Err(error) = result {
5313 self.rollback_observation("syncular_schema_reset");
5314 return Err(error);
5315 }
5316 if let Err(error) = self.finish_observation("syncular_schema_reset", batch) {
5317 self.rollback_observation("syncular_schema_reset");
5318 return Err(error);
5319 }
5320 Ok(())
5321 }
5322
5323 fn run_log_epoch_reset(&mut self, log_epoch: &str) -> Result<Vec<String>, String> {
5324 let resets = self.subs.iter().map(|sub| sub.id.clone()).collect();
5325 self.begin_observation("syncular_log_epoch_reset")?;
5326 let mut batch = ChangeAccumulator::default();
5327 let result = self.run_schema_reset_observed(&mut batch, false);
5328 if let Err(error) = result {
5329 self.rollback_observation("syncular_log_epoch_reset");
5330 return Err(error);
5331 }
5332 self.set_meta(LOG_EPOCH_KEY, log_epoch);
5333 self.sync_needed = true;
5334 batch.status = true;
5335 if let Err(error) = self.finish_observation("syncular_log_epoch_reset", batch) {
5336 self.rollback_observation("syncular_log_epoch_reset");
5337 return Err(error);
5338 }
5339 self.sync_intent_queue.push_back(SyncIntent::Interactive);
5340 Ok(resets)
5341 }
5342
5343 fn run_schema_reset_observed(
5344 &mut self,
5345 batch: &mut ChangeAccumulator,
5346 drop_incompatible: bool,
5347 ) -> Result<(), String> {
5348 self.upgrading = true;
5349 batch.status = true;
5350 for table in &self.schema.tables {
5351 batch.table(&table.name);
5352 }
5353 for (base_key, unit, table) in self.load_registered_window_units() {
5354 batch.window(&base_key, &table, &unit);
5355 }
5356 self.insert_sql.borrow_mut().clear();
5358 self.overlay_dirty.set(true);
5359 let virtual_tables: Vec<String> = {
5367 let mut stmt = self
5368 .conn
5369 .prepare(
5370 "SELECT name FROM sqlite_master WHERE type = 'table' AND sql LIKE 'CREATE VIRTUAL TABLE%'",
5371 )
5372 .map_err(|e| e.to_string())?;
5373 let rows = stmt
5374 .query_map([], |row| row.get::<_, String>(0))
5375 .map_err(|e| e.to_string())?;
5376 rows.filter_map(Result::ok)
5377 .filter(|name| is_synced_table_name(name))
5378 .collect()
5379 };
5380 for name in virtual_tables {
5381 self.conn
5382 .execute(&format!("DROP TABLE IF EXISTS {}", quote_ident(&name)), [])
5383 .map_err(|e| e.to_string())?;
5384 }
5385 let existing: Vec<String> = {
5386 let mut stmt = self
5387 .conn
5388 .prepare("SELECT name FROM sqlite_master WHERE type = 'table'")
5389 .map_err(|e| e.to_string())?;
5390 let rows = stmt
5391 .query_map([], |row| row.get::<_, String>(0))
5392 .map_err(|e| e.to_string())?;
5393 rows.filter_map(Result::ok)
5394 .filter(|name| is_synced_table_name(name))
5395 .collect()
5396 };
5397 for name in &existing {
5398 if let Some(table) = name.strip_prefix("_syncular_base_") {
5399 batch.table(table);
5400 } else if !name.starts_with("_syncular_") {
5401 batch.table(name);
5402 }
5403 }
5404 for name in existing {
5405 self.conn
5406 .execute(&format!("DROP TABLE IF EXISTS {}", quote_ident(&name)), [])
5407 .map_err(|e| e.to_string())?;
5408 }
5409 self.create_synced_tables()?;
5411 for sub in &mut self.subs {
5413 sub.cursor = -1;
5414 sub.bootstrap_state = None;
5415 sub.effective = None;
5416 sub.state = SubState::Active;
5417 sub.reason_code = None;
5418 sub.synced_once = false;
5419 }
5420 let subs = self.subs.clone();
5421 for sub in &subs {
5422 self.persist_sub(sub)?;
5423 }
5424 self.stopped = false;
5426 self.schema_floor = None;
5427 self.delete_meta(SCHEMA_FLOOR_KEY);
5428 self.set_meta(LOCAL_SCHEMA_VERSION_KEY, &self.schema.version.to_string());
5430 if drop_incompatible && self.drop_incompatible_outbox()? {
5434 batch.rejections = true;
5435 batch.status = true;
5436 batch.outcomes = true;
5437 }
5438 self.rebuild_overlay();
5440 Ok(())
5441 }
5442
5443 fn drop_incompatible_outbox(&mut self) -> Result<bool, String> {
5447 let schema = &self.schema;
5448 let incompatible = self
5449 .outbox
5450 .iter()
5451 .filter(|commit| {
5452 commit.ops.iter().any(|op| {
5453 if !op.upsert {
5454 return false;
5455 }
5456 match schema.table(&op.table) {
5457 None => true,
5458 Some(table) => op.values.as_ref().is_some_and(|values| {
5459 values
5460 .keys()
5461 .any(|key| !table.columns.iter().any(|c| &c.name == key))
5462 }),
5463 }
5464 })
5465 })
5466 .cloned()
5467 .collect::<Vec<_>>();
5468 if incompatible.is_empty() {
5469 return Ok(false);
5470 }
5471 let mut rejections = Vec::new();
5472 for commit in &incompatible {
5473 let results = commit
5474 .ops
5475 .iter()
5476 .enumerate()
5477 .map(|(op_index, operation)| {
5478 let rejection = RejectionRecord {
5479 client_commit_id: commit.client_commit_id.clone(),
5480 op_index: op_index as i32,
5481 code: OUTBOX_INCOMPATIBLE_CODE.to_owned(),
5482 message: "the persisted commit cannot encode under the current schema"
5483 .to_owned(),
5484 retryable: false,
5485 details: None,
5486 operation: Some(CommitOperation::from(operation)),
5487 };
5488 rejections.push(rejection.clone());
5489 CommitOperationOutcome::Error { rejection }
5490 })
5491 .collect::<Vec<_>>();
5492 self.persist_commit_outcome(
5493 &commit.client_commit_id,
5494 CommitOutcomeStatus::Rejected,
5495 &results,
5496 Some(&commit.ops),
5497 )?;
5498 self.delete_outbox_persisted(&commit.client_commit_id)?;
5499 }
5500 self.prune_commit_outcomes()?;
5501 let incompatible_ids = incompatible
5502 .iter()
5503 .map(|commit| commit.client_commit_id.as_str())
5504 .collect::<BTreeSet<_>>();
5505 self.outbox
5506 .retain(|commit| !incompatible_ids.contains(commit.client_commit_id.as_str()));
5507 self.rejections.extend(rejections);
5508 Ok(true)
5509 }
5510
5511 fn create_synced_tables(&self) -> Result<(), String> {
5514 for table in &self.schema.tables {
5515 for (full, index_prefix) in [
5519 (base_table(&table.name), "_syncular_base_"),
5520 (visible_table(&table.name), ""),
5521 ] {
5522 let mut cols: Vec<String> =
5523 table.columns.iter().map(|c| quote_ident(&c.name)).collect();
5524 cols.push("\"_syncular_version\" INTEGER NOT NULL".to_owned());
5525 let sql = format!(
5526 "CREATE TABLE IF NOT EXISTS {full} ({} , PRIMARY KEY ({}))",
5527 cols.join(", "),
5528 quote_ident(&table.primary_key)
5529 );
5530 self.conn.execute(&sql, []).map_err(|e| e.to_string())?;
5531 for index in &table.indexes {
5535 let unique = if index.unique { "UNIQUE " } else { "" };
5536 let index_name = quote_ident(&format!("{index_prefix}{}", index.name));
5537 let cols_sql = index
5538 .columns
5539 .iter()
5540 .map(|c| quote_ident(c))
5541 .collect::<Vec<_>>()
5542 .join(", ");
5543 let index_sql = format!(
5544 "CREATE {unique}INDEX IF NOT EXISTS {index_name} ON {full} ({cols_sql})"
5545 );
5546 self.conn
5547 .execute(&index_sql, [])
5548 .map_err(|e| e.to_string())?;
5549 }
5550 }
5551 }
5552 for table in &self.schema.tables {
5555 for index in &table.fts_indexes {
5556 self.create_fts_projection(table, index)?;
5557 }
5558 }
5559 Ok(())
5560 }
5561
5562 fn fts_projection_exists(&self, index: &FtsIndexSchema) -> Result<bool, String> {
5563 let count: i64 = self
5564 .conn
5565 .query_row(
5566 "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name=?1",
5567 rusqlite::params![index.name],
5568 |row| row.get(0),
5569 )
5570 .map_err(|e| e.to_string())?;
5571 Ok(count > 0)
5572 }
5573
5574 fn drop_fts_triggers(&self, index: &FtsIndexSchema) -> Result<(), String> {
5575 for suffix in ["ai", "ad", "au"] {
5576 self.conn
5577 .execute(
5578 &format!(
5579 "DROP TRIGGER IF EXISTS {}",
5580 quote_ident(&format!("{}_{suffix}", index.name))
5581 ),
5582 [],
5583 )
5584 .map_err(|e| e.to_string())?;
5585 }
5586 Ok(())
5587 }
5588
5589 fn create_fts_triggers(
5590 &self,
5591 table: &TableSchema,
5592 index: &FtsIndexSchema,
5593 ) -> Result<(), String> {
5594 let fts = quote_ident(&index.name);
5595 let source = visible_table(&table.name);
5596 let source_id = quote_ident(FTS_SOURCE_ID_COLUMN);
5597 let pk = quote_ident(&table.primary_key);
5598 let projection_columns = std::iter::once(source_id.clone())
5599 .chain(index.columns.iter().map(|column| quote_ident(column)))
5600 .collect::<Vec<_>>()
5601 .join(", ");
5602 let new_values = std::iter::once(format!("CAST(new.{pk} AS TEXT)"))
5603 .chain(
5604 index
5605 .columns
5606 .iter()
5607 .map(|column| format!("new.{}", quote_ident(column))),
5608 )
5609 .collect::<Vec<_>>()
5610 .join(", ");
5611 let delete_new = format!("DELETE FROM {fts} WHERE {source_id} = CAST(new.{pk} AS TEXT)");
5612 let delete_old = format!("DELETE FROM {fts} WHERE {source_id} = CAST(old.{pk} AS TEXT)");
5613 let insert_new = format!("INSERT INTO {fts} ({projection_columns}) VALUES ({new_values})");
5614 let bi = quote_ident(&format!("{}_bi", index.name));
5615 let ai = quote_ident(&format!("{}_ai", index.name));
5616 let ad = quote_ident(&format!("{}_ad", index.name));
5617 let au = quote_ident(&format!("{}_au", index.name));
5618 let replacement_exists = format!("EXISTS (SELECT 1 FROM {source} WHERE {pk} = new.{pk})");
5619 let sql = format!(
5620 "DROP TRIGGER IF EXISTS {bi};
5621 DROP TRIGGER IF EXISTS {ai};
5622 DROP TRIGGER IF EXISTS {ad};
5623 DROP TRIGGER IF EXISTS {au};
5624 CREATE TRIGGER {bi} BEFORE INSERT ON {source} WHEN {replacement_exists} BEGIN {delete_new}; END;
5625 CREATE TRIGGER {ai} AFTER INSERT ON {source} BEGIN {insert_new}; END;
5626 CREATE TRIGGER {ad} AFTER DELETE ON {source} BEGIN {delete_old}; END;
5627 CREATE TRIGGER {au} AFTER UPDATE ON {source} BEGIN {delete_old}; {delete_new}; {insert_new}; END;",
5628 );
5629 self.conn.execute_batch(&sql).map_err(|e| e.to_string())
5630 }
5631
5632 fn rebuild_fts_projection(
5633 &self,
5634 table: &TableSchema,
5635 index: &FtsIndexSchema,
5636 ) -> Result<(), String> {
5637 let fts = quote_ident(&index.name);
5638 let source_id = quote_ident(FTS_SOURCE_ID_COLUMN);
5639 let pk = quote_ident(&table.primary_key);
5640 let indexed_columns = index
5641 .columns
5642 .iter()
5643 .map(|column| quote_ident(column))
5644 .collect::<Vec<_>>();
5645 let projection_columns = std::iter::once(source_id)
5646 .chain(indexed_columns.iter().cloned())
5647 .collect::<Vec<_>>()
5648 .join(", ");
5649 let sql = format!(
5650 "DELETE FROM {fts}; INSERT INTO {fts} ({projection_columns}) SELECT CAST({pk} AS TEXT), {columns} FROM {source};",
5651 columns = indexed_columns.join(", "),
5652 source = visible_table(&table.name),
5653 );
5654 self.conn.execute_batch(&sql).map_err(|e| e.to_string())
5655 }
5656
5657 fn create_fts_projection(
5658 &self,
5659 table: &TableSchema,
5660 index: &FtsIndexSchema,
5661 ) -> Result<(), String> {
5662 let existed = self.fts_projection_exists(index)?;
5663 let tokenizer = index.tokenize.replace('\'', "''");
5664 let columns = index
5665 .columns
5666 .iter()
5667 .map(|column| quote_ident(column))
5668 .collect::<Vec<_>>()
5669 .join(", ");
5670 let sql = format!(
5671 "CREATE VIRTUAL TABLE IF NOT EXISTS {fts} USING fts5({source_id} UNINDEXED, {columns}, tokenize='{tokenizer}')",
5672 fts = quote_ident(&index.name),
5673 source_id = quote_ident(FTS_SOURCE_ID_COLUMN),
5674 );
5675 self.conn.execute(&sql, []).map_err(|error| {
5676 format!(
5677 "cannot create local FTS5 projection {:?}: {error}",
5678 index.name
5679 )
5680 })?;
5681 self.create_fts_triggers(table, index)?;
5682 if !existed {
5683 self.rebuild_fts_projection(table, index)?;
5684 }
5685 Ok(())
5686 }
5687
5688 fn persist_sub(&self, sub: &Subscription) -> Result<(), String> {
5691 let state = serde_json::json!({
5692 "requested": scope_map_to_json(&sub.requested),
5693 "params": sub.params,
5694 "cursor": sub.cursor,
5695 "bootstrapState": sub.bootstrap_state,
5696 "status": sub.state.name(),
5697 "reasonCode": sub.reason_code,
5698 "effectiveScopes": sub.effective.as_ref().map(|e| scope_map_to_json(e)),
5699 "syncedOnce": sub.synced_once,
5700 });
5701 self.conn.execute(
5702 "INSERT OR REPLACE INTO _syncular_subscriptions (id, tbl, state_json) VALUES (?1, ?2, ?3)",
5703 rusqlite::params![sub.id, sub.table, state.to_string()],
5704 ).map(|_| ()).map_err(|error| error.to_string())
5705 }
5706
5707 fn persist_outbox_insert(&self, commit: &OutboxCommit) -> Result<(), String> {
5708 let ops: Vec<Value> = commit
5709 .ops
5710 .iter()
5711 .map(|op| {
5712 serde_json::json!({
5713 "op": if op.upsert { "upsert" } else { "delete" },
5714 "table": op.table,
5715 "rowId": op.row_id,
5716 "baseVersion": op.base_version,
5717 "values": op.values.clone().map(Value::Object),
5718 "changedFields": op.changed_fields,
5719 })
5720 })
5721 .collect();
5722 self.conn
5723 .execute(
5724 "INSERT OR REPLACE INTO _syncular_outbox (commit_id, ops_json) VALUES (?1, ?2)",
5725 rusqlite::params![commit.client_commit_id, Value::Array(ops).to_string()],
5726 )
5727 .map(|_| ())
5728 .map_err(|error| error.to_string())
5729 }
5730
5731 fn delete_outbox_persisted(&self, client_commit_id: &str) -> Result<(), String> {
5732 self.conn
5733 .execute(
5734 "DELETE FROM _syncular_outbox WHERE commit_id = ?1",
5735 rusqlite::params![client_commit_id],
5736 )
5737 .map(|_| ())
5738 .map_err(|error| error.to_string())
5739 }
5740
5741 fn outcome_status_name(status: CommitOutcomeStatus) -> &'static str {
5742 match status {
5743 CommitOutcomeStatus::Applied => "applied",
5744 CommitOutcomeStatus::Cached => "cached",
5745 CommitOutcomeStatus::Conflict => "conflict",
5746 CommitOutcomeStatus::Rejected => "rejected",
5747 }
5748 }
5749
5750 fn outcome_resolution_name(resolution: CommitOutcomeResolution) -> &'static str {
5751 match resolution {
5752 CommitOutcomeResolution::Active => "active",
5753 CommitOutcomeResolution::ResolvedKeepServer => "resolved_keep_server",
5754 CommitOutcomeResolution::Superseded => "superseded",
5755 CommitOutcomeResolution::Dismissed => "dismissed",
5756 }
5757 }
5758
5759 fn parse_outcome_status(value: &str) -> Result<CommitOutcomeStatus, String> {
5760 match value {
5761 "applied" => Ok(CommitOutcomeStatus::Applied),
5762 "cached" => Ok(CommitOutcomeStatus::Cached),
5763 "conflict" => Ok(CommitOutcomeStatus::Conflict),
5764 "rejected" => Ok(CommitOutcomeStatus::Rejected),
5765 _ => Err(format!("invalid persisted commit outcome status {value:?}")),
5766 }
5767 }
5768
5769 fn parse_outcome_resolution(value: &str) -> Result<CommitOutcomeResolution, String> {
5770 match value {
5771 "active" => Ok(CommitOutcomeResolution::Active),
5772 "resolved_keep_server" => Ok(CommitOutcomeResolution::ResolvedKeepServer),
5773 "superseded" => Ok(CommitOutcomeResolution::Superseded),
5774 "dismissed" => Ok(CommitOutcomeResolution::Dismissed),
5775 _ => Err(format!(
5776 "invalid persisted commit outcome resolution {value:?}"
5777 )),
5778 }
5779 }
5780
5781 fn persist_commit_outcome(
5782 &self,
5783 client_commit_id: &str,
5784 status: CommitOutcomeStatus,
5785 results: &[CommitOperationOutcome],
5786 operations: Option<&[OutboxOp]>,
5787 ) -> Result<(), String> {
5788 let results_json = serde_json::to_string(results).map_err(|error| error.to_string())?;
5789 let operations_json = operations
5790 .map(|items| {
5791 serde_json::to_string(&items.iter().map(CommitOperation::from).collect::<Vec<_>>())
5792 })
5793 .transpose()
5794 .map_err(|error| error.to_string())?;
5795 self.conn
5796 .execute(
5797 "INSERT INTO _syncular_commit_outcomes (
5798 client_commit_id, status, recorded_at_ms, results_json,
5799 operations_json, resolution
5800 ) VALUES (?1, ?2, ?3, ?4, ?5, 'active')",
5801 rusqlite::params![
5802 client_commit_id,
5803 Self::outcome_status_name(status),
5804 self.clock_now_ms(),
5805 results_json,
5806 operations_json
5807 ],
5808 )
5809 .map(|_| ())
5810 .map_err(|error| error.to_string())
5811 }
5812
5813 fn outcome_from_row(row: StoredCommitOutcomeRow) -> Result<CommitOutcome, String> {
5814 let StoredCommitOutcomeRow {
5815 sequence,
5816 client_commit_id,
5817 status,
5818 recorded_at_ms,
5819 results_json,
5820 operations_json,
5821 resolution,
5822 resolved_at_ms,
5823 replacement_client_commit_id,
5824 } = row;
5825 Ok(CommitOutcome {
5826 sequence,
5827 client_commit_id,
5828 status: Self::parse_outcome_status(&status)?,
5829 recorded_at_ms,
5830 results: serde_json::from_str(&results_json)
5831 .map_err(|error| format!("invalid persisted commit outcome results: {error}"))?,
5832 operations: operations_json
5833 .map(|value| {
5834 serde_json::from_str(&value).map_err(|error| {
5835 format!("invalid persisted commit outcome operations: {error}")
5836 })
5837 })
5838 .transpose()?,
5839 resolution: Self::parse_outcome_resolution(&resolution)?,
5840 resolved_at_ms,
5841 replacement_client_commit_id,
5842 })
5843 }
5844
5845 pub fn commit_outcome(&self, client_commit_id: &str) -> Result<Option<CommitOutcome>, String> {
5846 let row = self
5847 .conn
5848 .query_row(
5849 "SELECT seq, client_commit_id, status, recorded_at_ms, results_json, operations_json,
5850 resolution, resolved_at_ms, replacement_client_commit_id
5851 FROM _syncular_commit_outcomes WHERE client_commit_id = ?1",
5852 rusqlite::params![client_commit_id],
5853 |row| {
5854 Ok(StoredCommitOutcomeRow {
5855 sequence: row.get(0)?,
5856 client_commit_id: row.get(1)?,
5857 status: row.get(2)?,
5858 recorded_at_ms: row.get(3)?,
5859 results_json: row.get(4)?,
5860 operations_json: row.get(5)?,
5861 resolution: row.get(6)?,
5862 resolved_at_ms: row.get(7)?,
5863 replacement_client_commit_id: row.get(8)?,
5864 })
5865 },
5866 )
5867 .optional()
5868 .map_err(|error| error.to_string())?;
5869 row.map(Self::outcome_from_row).transpose()
5870 }
5871
5872 pub fn commit_outcomes(&self, query: CommitOutcomeQuery) -> Result<Vec<CommitOutcome>, String> {
5873 if query.limit == Some(0) {
5874 return Err("sync.invalid_request: commit outcome limit must be positive".to_owned());
5875 }
5876 let mut sql = String::from(
5877 "SELECT seq, client_commit_id, status, recorded_at_ms, results_json, operations_json,
5878 resolution, resolved_at_ms, replacement_client_commit_id
5879 FROM _syncular_commit_outcomes",
5880 );
5881 if query.active_only {
5882 sql.push_str(" WHERE resolution = 'active' AND status IN ('conflict', 'rejected')");
5883 }
5884 sql.push_str(" ORDER BY seq DESC");
5885 if let Some(limit) = query.limit {
5886 sql.push_str(&format!(" LIMIT {limit}"));
5887 }
5888 let mut stmt = self.conn.prepare(&sql).map_err(|error| error.to_string())?;
5889 let rows = stmt
5890 .query_map([], |row| {
5891 Ok(StoredCommitOutcomeRow {
5892 sequence: row.get(0)?,
5893 client_commit_id: row.get(1)?,
5894 status: row.get(2)?,
5895 recorded_at_ms: row.get(3)?,
5896 results_json: row.get(4)?,
5897 operations_json: row.get(5)?,
5898 resolution: row.get(6)?,
5899 resolved_at_ms: row.get(7)?,
5900 replacement_client_commit_id: row.get(8)?,
5901 })
5902 })
5903 .map_err(|error| error.to_string())?;
5904 let mut outcomes = Vec::new();
5905 for row in rows {
5906 outcomes.push(Self::outcome_from_row(
5907 row.map_err(|error| error.to_string())?,
5908 )?);
5909 }
5910 Ok(outcomes)
5911 }
5912
5913 fn prune_commit_outcomes(&self) -> Result<(), String> {
5914 #[cfg(test)]
5915 self.outcome_prune_count
5916 .set(self.outcome_prune_count.get() + 1);
5917 let max_entries = self.limits.outcome_retention_max_entries.unwrap_or(1_000);
5918 let count = self
5919 .conn
5920 .query_row(
5921 "SELECT COUNT(*) FROM _syncular_commit_outcomes",
5922 [],
5923 |row| row.get::<_, i64>(0),
5924 )
5925 .map_err(|error| error.to_string())? as usize;
5926 let excess = count.saturating_sub(max_entries);
5927 if excess == 0 {
5928 return Ok(());
5929 }
5930 let mut stmt = self
5931 .conn
5932 .prepare(
5933 "SELECT seq FROM _syncular_commit_outcomes
5934 WHERE status IN ('applied', 'cached') OR resolution != 'active'
5935 ORDER BY seq ASC LIMIT ?1",
5936 )
5937 .map_err(|error| error.to_string())?;
5938 let rows = stmt
5939 .query_map(rusqlite::params![excess as i64], |row| row.get::<_, i64>(0))
5940 .map_err(|error| error.to_string())?;
5941 let sequences = rows
5942 .collect::<Result<Vec<_>, _>>()
5943 .map_err(|error| error.to_string())?;
5944 drop(stmt);
5945 for sequence in sequences {
5946 self.conn
5947 .execute(
5948 "DELETE FROM _syncular_commit_outcomes WHERE seq = ?1",
5949 rusqlite::params![sequence],
5950 )
5951 .map_err(|error| error.to_string())?;
5952 }
5953 Ok(())
5954 }
5955
5956 pub fn subscribe(
5959 &mut self,
5960 id: String,
5961 table: String,
5962 scopes: Vec<(String, Vec<String>)>,
5963 params: Option<String>,
5964 ) -> Result<(), String> {
5965 if self.schema.table(&table).is_none() {
5966 return Err(format!("unknown table {table:?}"));
5967 }
5968 if let Some(existing) = self.subs.iter().find(|subscription| subscription.id == id) {
5969 let same_intent = existing.table == table
5970 && canonical_scope_json(&existing.requested) == canonical_scope_json(&scopes)
5971 && existing.params == params;
5972 if same_intent {
5973 return Ok(());
5974 }
5975 return Err(
5976 "client.subscription_intent_mismatch: the subscription id is already registered for a different table, scopes, or params"
5977 .to_owned(),
5978 );
5979 }
5980 let sub = Subscription {
5981 id: id.clone(),
5982 table,
5983 requested: scopes,
5984 params,
5985 cursor: -1,
5986 bootstrap_state: None,
5987 state: SubState::Active,
5988 reason_code: None,
5989 effective: None,
5990 synced_once: false,
5991 };
5992 self.persist_sub(&sub)?;
5993 self.subs.push(sub);
5994 Ok(())
5995 }
5996
5997 pub fn unsubscribe(&mut self, id: &str) {
5998 self.subs.retain(|s| s.id != id);
5999 let _ = self.conn.execute(
6000 "DELETE FROM _syncular_subscriptions WHERE id = ?1",
6001 rusqlite::params![id],
6002 );
6003 }
6004
6005 pub fn set_window(
6013 &mut self,
6014 base: &WindowBase,
6015 units: &[String],
6016 ) -> Result<CommandEffects, String> {
6017 let table = self
6018 .schema
6019 .table(&base.table)
6020 .ok_or_else(|| format!("unknown table {:?}", base.table))?;
6021 if table.scope_column(&base.variable).is_none() {
6022 return Err(format!(
6023 "setWindow: table {:?} has no scope variable {:?} (§4.8)",
6024 base.table, base.variable
6025 ));
6026 }
6027 let base_key = window_base_key(base);
6028 let wanted: std::collections::HashSet<&String> = units.iter().collect();
6029 let live = self.load_window_units(&base_key);
6030 self.begin_observation("syncular_window")?;
6031 let mut batch = ChangeAccumulator::default();
6032 let mut changed = false;
6033
6034 for unit in units {
6036 if live.iter().any(|(u, _)| u == unit) {
6037 continue;
6038 }
6039 let sub_id = derive_sub_id(base, unit);
6040 self.delete_pending_evict(&sub_id);
6041 self.insert_window_unit(&base_key, unit, &sub_id);
6042 self.subscribe(
6043 sub_id,
6044 base.table.clone(),
6045 unit_scopes(base, unit),
6046 base.params.clone(),
6047 )?;
6048 batch.window(&base_key, &base.table, unit);
6049 changed = true;
6050 }
6051
6052 for (unit, sub_id) in live {
6054 if wanted.contains(&unit) {
6055 continue;
6056 }
6057 let effective = self
6058 .subs
6059 .iter()
6060 .find(|sub| sub.id == sub_id)
6061 .and_then(|sub| sub.effective.clone())
6062 .unwrap_or_else(|| unit_scopes(base, &unit));
6063 self.record_scope_map(&mut batch, &base.table, &effective);
6064 batch.window(&base_key, &base.table, &unit);
6065 self.evict_unit(&base_key, base, &unit, &sub_id);
6066 changed = true;
6067 }
6068 if let Err(error) = self.finish_observation("syncular_window", batch) {
6069 self.rollback_observation("syncular_window");
6070 return Err(error);
6071 }
6072 Ok(if changed {
6073 CommandEffects::interactive()
6074 } else {
6075 CommandEffects::none()
6076 })
6077 }
6078
6079 pub fn window_state(&self, base: &WindowBase) -> WindowState {
6084 let mut units = Vec::new();
6085 let mut pending = Vec::new();
6086 for (unit, sub_id) in self.load_window_units(&window_base_key(base)) {
6087 let is_pending = match self.subs.iter().find(|s| s.id == sub_id) {
6088 Some(sub) => {
6089 sub.state != SubState::Active || sub.cursor < 0 || sub.bootstrap_state.is_some()
6090 }
6091 None => true,
6092 };
6093 if is_pending {
6094 pending.push(unit.clone());
6095 }
6096 units.push(unit);
6097 }
6098 WindowState { units, pending }
6099 }
6100
6101 fn load_window_units(&self, base_key: &str) -> Vec<(String, String)> {
6102 let mut stmt = match self
6103 .conn
6104 .prepare("SELECT unit, sub_id FROM _syncular_windows WHERE base = ?1 ORDER BY unit ASC")
6105 {
6106 Ok(stmt) => stmt,
6107 Err(_) => return Vec::new(),
6108 };
6109 let rows = stmt.query_map(rusqlite::params![base_key], |row| {
6110 Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
6111 });
6112 match rows {
6113 Ok(rows) => rows.filter_map(Result::ok).collect(),
6114 Err(_) => Vec::new(),
6115 }
6116 }
6117
6118 fn load_registered_window_units(&self) -> Vec<(String, String, String)> {
6119 let mut stmt = match self.conn.prepare(
6120 "SELECT windows.base, windows.unit, subscriptions.tbl
6121 FROM _syncular_windows AS windows
6122 JOIN _syncular_subscriptions AS subscriptions
6123 ON subscriptions.id = windows.sub_id
6124 ORDER BY windows.base, windows.unit",
6125 ) {
6126 Ok(stmt) => stmt,
6127 Err(_) => return Vec::new(),
6128 };
6129 let rows = stmt.query_map([], |row| {
6130 Ok((
6131 row.get::<_, String>(0)?,
6132 row.get::<_, String>(1)?,
6133 row.get::<_, String>(2)?,
6134 ))
6135 });
6136 match rows {
6137 Ok(rows) => rows.filter_map(Result::ok).collect(),
6138 Err(_) => Vec::new(),
6139 }
6140 }
6141
6142 fn window_unit_by_sub_id(&self, sub_id: &str) -> Option<(String, String)> {
6143 self.conn
6144 .query_row(
6145 "SELECT base, unit FROM _syncular_windows WHERE sub_id = ?1 LIMIT 1",
6146 rusqlite::params![sub_id],
6147 |row| Ok((row.get(0)?, row.get(1)?)),
6148 )
6149 .ok()
6150 }
6151
6152 fn insert_window_unit(&self, base_key: &str, unit: &str, sub_id: &str) {
6153 let _ = self.conn.execute(
6154 "INSERT OR REPLACE INTO _syncular_windows(base, unit, sub_id) VALUES (?1, ?2, ?3)",
6155 rusqlite::params![base_key, unit, sub_id],
6156 );
6157 }
6158
6159 fn delete_window_unit(&self, base_key: &str, unit: &str) {
6160 let _ = self.conn.execute(
6161 "DELETE FROM _syncular_windows WHERE base = ?1 AND unit = ?2",
6162 rusqlite::params![base_key, unit],
6163 );
6164 }
6165
6166 fn evict_unit(&mut self, base_key: &str, base: &WindowBase, unit: &str, sub_id: &str) {
6173 let effective = self
6174 .subs
6175 .iter()
6176 .find(|s| s.id == sub_id)
6177 .and_then(|s| s.effective.clone())
6178 .unwrap_or_else(|| unit_scopes(base, unit));
6179 let pinned = self.pinned_row_ids(&base.table);
6180 let deferred = self
6181 .evict_scope_rows(&base.table, &effective, &pinned)
6182 .unwrap_or(false);
6183 self.delete_window_unit(base_key, unit);
6184 self.unsubscribe(sub_id);
6185 if deferred {
6186 self.save_pending_evict(sub_id, &base.table, &effective);
6187 } else {
6188 self.delete_pending_evict(sub_id);
6189 }
6190 self.rebuild_overlay();
6191 }
6192
6193 fn evict_scope_rows(
6197 &mut self,
6198 table_name: &str,
6199 effective: &[(String, Vec<String>)],
6200 pinned: &std::collections::HashSet<String>,
6201 ) -> Result<bool, ()> {
6202 if effective.is_empty() {
6203 return Ok(false);
6204 }
6205 let table = self.schema.table(table_name).ok_or(())?.clone();
6206 let mut clauses = Vec::new();
6207 let mut params: Vec<SqlValue> = Vec::new();
6208 for (variable, values) in effective {
6209 let column = table.scope_column(variable).ok_or(())?;
6210 let placeholders: Vec<String> = values
6211 .iter()
6212 .map(|v| {
6213 params.push(SqlValue::Text(v.clone()));
6214 "?".to_owned()
6215 })
6216 .collect();
6217 clauses.push(format!(
6218 "{} IN ({})",
6219 quote_ident(column),
6220 placeholders.join(", ")
6221 ));
6222 }
6223 let mut sql = format!(
6224 "DELETE FROM {} WHERE {}",
6225 base_table(table_name),
6226 clauses.join(" AND ")
6227 );
6228 if !pinned.is_empty() {
6229 let pk = quote_ident(&table.primary_key);
6230 let holes: Vec<String> = pinned
6231 .iter()
6232 .map(|id| {
6233 params.push(SqlValue::Text(id.clone()));
6234 "?".to_owned()
6235 })
6236 .collect();
6237 sql.push_str(&format!(" AND {} NOT IN ({})", pk, holes.join(", ")));
6238 }
6239 self.overlay_dirty.set(true);
6240 self.conn
6241 .execute(&sql, rusqlite::params_from_iter(params))
6242 .map_err(|_| ())?;
6243 if pinned.is_empty() {
6244 return Ok(false);
6245 }
6246 let mut where_params: Vec<SqlValue> = Vec::new();
6249 let mut where_clauses = Vec::new();
6250 for (variable, values) in effective {
6251 let column = table.scope_column(variable).ok_or(())?;
6252 let placeholders: Vec<String> = values
6253 .iter()
6254 .map(|v| {
6255 where_params.push(SqlValue::Text(v.clone()));
6256 "?".to_owned()
6257 })
6258 .collect();
6259 where_clauses.push(format!(
6260 "{} IN ({})",
6261 quote_ident(column),
6262 placeholders.join(", ")
6263 ));
6264 }
6265 let pk = quote_ident(&table.primary_key);
6266 let select = format!(
6267 "SELECT {} FROM {} WHERE {}",
6268 pk,
6269 base_table(table_name),
6270 where_clauses.join(" AND ")
6271 );
6272 let mut stmt = self.conn.prepare(&select).map_err(|_| ())?;
6273 let survivors: Vec<String> = stmt
6274 .query_map(rusqlite::params_from_iter(where_params), |row| {
6275 row.get::<_, String>(0)
6276 })
6277 .map_err(|_| ())?
6278 .filter_map(Result::ok)
6279 .collect();
6280 Ok(survivors.iter().any(|id| pinned.contains(id)))
6281 }
6282
6283 fn drain_pending_evictions(&mut self) {
6287 let pending = self.load_pending_evictions();
6288 if pending.is_empty() {
6289 return;
6290 }
6291 for (sub_id, table_name, effective) in pending {
6292 if self.schema.table(&table_name).is_none() {
6293 self.delete_pending_evict(&sub_id);
6294 continue;
6295 }
6296 let pinned = self.pinned_row_ids(&table_name);
6297 let deferred = self
6298 .evict_scope_rows(&table_name, &effective, &pinned)
6299 .unwrap_or(false);
6300 if !deferred {
6301 self.delete_pending_evict(&sub_id);
6302 }
6303 }
6304 self.rebuild_overlay_if_dirty();
6305 }
6306
6307 fn pinned_row_ids(&self, table: &str) -> std::collections::HashSet<String> {
6310 let mut pinned = std::collections::HashSet::new();
6311 for commit in &self.outbox {
6312 for op in &commit.ops {
6313 if op.table == table {
6314 pinned.insert(op.row_id.clone());
6315 }
6316 }
6317 }
6318 pinned
6319 }
6320
6321 fn save_pending_evict(&self, sub_id: &str, table: &str, effective: &[(String, Vec<String>)]) {
6322 let _ = self.conn.execute(
6323 "INSERT OR REPLACE INTO _syncular_window_pending_evict(sub_id, tbl, effective_scopes)
6324 VALUES (?1, ?2, ?3)",
6325 rusqlite::params![sub_id, table, scope_map_to_json(effective).to_string()],
6326 );
6327 }
6328
6329 fn delete_pending_evict(&self, sub_id: &str) {
6330 let _ = self.conn.execute(
6331 "DELETE FROM _syncular_window_pending_evict WHERE sub_id = ?1",
6332 rusqlite::params![sub_id],
6333 );
6334 }
6335
6336 fn load_pending_evictions(&self) -> Vec<PendingEvict> {
6337 let mut stmt = match self
6338 .conn
6339 .prepare("SELECT sub_id, tbl, effective_scopes FROM _syncular_window_pending_evict")
6340 {
6341 Ok(stmt) => stmt,
6342 Err(_) => return Vec::new(),
6343 };
6344 let rows = stmt.query_map([], |row| {
6345 Ok((
6346 row.get::<_, String>(0)?,
6347 row.get::<_, String>(1)?,
6348 row.get::<_, String>(2)?,
6349 ))
6350 });
6351 let mut out = Vec::new();
6352 if let Ok(rows) = rows {
6353 for entry in rows.filter_map(Result::ok) {
6354 let (sub_id, table, json) = entry;
6355 if let Ok(value) = serde_json::from_str::<Value>(&json) {
6356 if let Ok(effective) = json_to_scope_map(&value) {
6357 out.push((sub_id, table, effective));
6358 }
6359 }
6360 }
6361 }
6362 out
6363 }
6364
6365 pub fn mutate(&mut self, mutations: Vec<Mutation>) -> Result<String, String> {
6367 if mutations.is_empty() {
6368 return Err("a commit must contain at least one operation (§6.1)".to_owned());
6369 }
6370 let mut ops = Vec::with_capacity(mutations.len());
6371 for mutation in mutations {
6372 match mutation {
6373 Mutation::Upsert {
6374 table,
6375 values,
6376 base_version,
6377 } => {
6378 let schema_table = self
6379 .schema
6380 .table(&table)
6381 .ok_or_else(|| format!("unknown table {table:?}"))?;
6382 let values = normalize_values_casing(schema_table, values)?;
6386 let row_id = render_row_id_json(values.get(&schema_table.primary_key))?;
6387 encode_row_json(schema_table, &row_id, &values, &self.encryption)?;
6390 ops.push(OutboxOp {
6391 upsert: true,
6392 table,
6393 row_id,
6394 base_version,
6395 values: Some(values),
6396 changed_fields: None,
6397 });
6398 }
6399 Mutation::Delete {
6400 table,
6401 row_id,
6402 base_version,
6403 } => {
6404 if self.schema.table(&table).is_none() {
6405 return Err(format!("unknown table {table:?}"));
6406 }
6407 ops.push(OutboxOp {
6408 upsert: false,
6409 table,
6410 row_id,
6411 base_version,
6412 values: None,
6413 changed_fields: None,
6414 });
6415 }
6416 }
6417 }
6418 self.record_outbox_commit(ops)
6419 }
6420
6421 fn record_outbox_commit(&mut self, ops: Vec<OutboxOp>) -> Result<String, String> {
6422 let commit = OutboxCommit {
6423 client_commit_id: uuid::Uuid::new_v4().to_string(),
6424 ops,
6425 };
6426 self.begin_observation("syncular_mutation")?;
6427 let mut batch = ChangeAccumulator::default();
6428 for op in &commit.ops {
6429 let mut precise = self.record_row_scopes(&mut batch, &op.table, &op.row_id, false);
6430 if let Some(values) = &op.values {
6431 if let Some(table) = self.schema.table(&op.table) {
6432 for scope in &table.scope_variables {
6433 if let Some(Value::String(value)) = values.get(&scope.column) {
6434 batch.scope(&op.table, format!("{}:{value}", scope.prefix));
6435 precise = true;
6436 }
6437 }
6438 }
6439 }
6440 if !precise {
6441 batch.table(&op.table);
6442 }
6443 }
6444 if let Err(error) = self.persist_outbox_insert(&commit) {
6445 self.rollback_observation("syncular_mutation");
6446 return Err(error);
6447 }
6448 let was_dirty = self.overlay_dirty.get();
6449 if !was_dirty {
6452 self.apply_outbox_ops(&commit.ops);
6453 }
6454 let id = commit.client_commit_id.clone();
6455 self.outbox.push(commit);
6456 if was_dirty {
6457 self.rebuild_overlay();
6458 }
6459 batch.status = true;
6460 if let Err(error) = self.finish_observation("syncular_mutation", batch) {
6461 self.rollback_observation("syncular_mutation");
6462 self.outbox.pop();
6463 self.overlay_dirty.set(was_dirty);
6464 return Err(error);
6465 }
6466 Ok(id)
6467 }
6468
6469 pub fn patch(
6474 &mut self,
6475 table: &str,
6476 row_id: &str,
6477 partial: Map<String, Value>,
6478 base_version: Option<i64>,
6479 ) -> Result<String, String> {
6480 let schema_table = self
6481 .schema
6482 .table(table)
6483 .ok_or_else(|| format!("unknown table {table:?}"))?;
6484 let partial = normalize_values_casing(schema_table, partial)?;
6485 let mut values = self
6486 .read_rows(table)?
6487 .into_iter()
6488 .find(|row| row.row_id == row_id)
6489 .map(|row| row.values)
6490 .ok_or_else(|| {
6491 format!(
6492 "sync.invalid_request: table {table:?} has no local row with primary key {row_id:?} to patch"
6493 )
6494 })?;
6495 let mut changed_fields = partial.keys().cloned().collect::<Vec<_>>();
6496 changed_fields.sort();
6497 values.extend(partial);
6498 let row_id_from_values = render_row_id_json(values.get(&schema_table.primary_key))?;
6499 if row_id_from_values != row_id {
6500 return Err("sync.invalid_request: patch cannot change the primary key".to_owned());
6501 }
6502 encode_row_json(schema_table, row_id, &values, &self.encryption)?;
6503 self.record_outbox_commit(vec![OutboxOp {
6504 upsert: true,
6505 table: table.to_owned(),
6506 row_id: row_id.to_owned(),
6507 base_version,
6508 values: Some(values),
6509 changed_fields: Some(changed_fields),
6510 }])
6511 }
6512
6513 pub fn pending_commit_ids(&self) -> Vec<String> {
6514 self.outbox
6515 .iter()
6516 .map(|c| c.client_commit_id.clone())
6517 .collect()
6518 }
6519
6520 pub fn conflicts(&self) -> &[ConflictRecord] {
6521 &self.conflicts
6522 }
6523
6524 pub fn rejections(&self) -> &[RejectionRecord] {
6525 &self.rejections
6526 }
6527
6528 pub fn resolve_commit_outcome(
6529 &mut self,
6530 input: ResolveCommitOutcomeInput,
6531 ) -> Result<CommitOutcome, String> {
6532 let current = self
6533 .commit_outcome(&input.client_commit_id)?
6534 .ok_or_else(|| {
6535 format!(
6536 "sync.outcome_not_found: no durable outcome exists for {:?}",
6537 input.client_commit_id
6538 )
6539 })?;
6540 if current.resolution != CommitOutcomeResolution::Active {
6541 return Ok(current);
6542 }
6543 if input.resolution == CommitOutcomeResolution::Active {
6544 return Err("sync.invalid_request: resolution must leave active state".to_owned());
6545 }
6546 match input.resolution {
6547 CommitOutcomeResolution::Superseded => {
6548 let replacement = input
6549 .replacement_client_commit_id
6550 .as_deref()
6551 .filter(|value| !value.is_empty() && *value != input.client_commit_id)
6552 .ok_or_else(|| {
6553 "sync.invalid_request: superseded outcomes require a distinct replacementClientCommitId"
6554 .to_owned()
6555 })?;
6556 let _ = replacement;
6557 }
6558 _ if input.replacement_client_commit_id.is_some() => {
6559 return Err(
6560 "sync.invalid_request: replacementClientCommitId is valid only for superseded outcomes"
6561 .to_owned(),
6562 );
6563 }
6564 _ => {}
6565 }
6566 let allowed = match current.status {
6567 CommitOutcomeStatus::Conflict => matches!(
6568 input.resolution,
6569 CommitOutcomeResolution::ResolvedKeepServer | CommitOutcomeResolution::Superseded
6570 ),
6571 CommitOutcomeStatus::Rejected => {
6572 input.resolution == CommitOutcomeResolution::Superseded
6573 }
6574 CommitOutcomeStatus::Applied | CommitOutcomeStatus::Cached => {
6575 input.resolution == CommitOutcomeResolution::Dismissed
6576 }
6577 };
6578 if !allowed {
6579 return Err(format!(
6580 "sync.invalid_request: resolution {:?} is invalid for {:?} outcome",
6581 input.resolution, current.status
6582 ));
6583 }
6584
6585 self.begin_observation("syncular_outcome_resolution")?;
6586 let result = (|| {
6587 self.conn
6588 .execute(
6589 "UPDATE _syncular_commit_outcomes
6590 SET resolution = ?1, resolved_at_ms = ?2,
6591 replacement_client_commit_id = ?3
6592 WHERE client_commit_id = ?4 AND resolution = 'active'",
6593 rusqlite::params![
6594 Self::outcome_resolution_name(input.resolution),
6595 self.clock_now_ms(),
6596 input.replacement_client_commit_id,
6597 input.client_commit_id
6598 ],
6599 )
6600 .map_err(|error| error.to_string())?;
6601 let resolved = self
6602 .commit_outcome(¤t.client_commit_id)?
6603 .ok_or_else(|| "sync.outcome_not_found: outcome disappeared".to_owned())?;
6604 self.prune_commit_outcomes()?;
6605 Ok(resolved)
6606 })();
6607 let resolved = match result {
6608 Ok(outcome) => outcome,
6609 Err(error) => {
6610 self.rollback_observation("syncular_outcome_resolution");
6611 return Err(error);
6612 }
6613 };
6614 self.conflicts
6615 .retain(|record| record.client_commit_id != current.client_commit_id);
6616 self.rejections
6617 .retain(|record| record.client_commit_id != current.client_commit_id);
6618 let batch = ChangeAccumulator {
6619 conflicts: current.status == CommitOutcomeStatus::Conflict,
6620 rejections: current.status == CommitOutcomeStatus::Rejected,
6621 outcomes: true,
6622 ..ChangeAccumulator::default()
6623 };
6624 if let Err(error) = self.finish_observation("syncular_outcome_resolution", batch) {
6625 self.rollback_observation("syncular_outcome_resolution");
6626 return Err(error);
6627 }
6628 Ok(resolved)
6629 }
6630
6631 pub fn schema_floor(&self) -> Option<&SchemaFloor> {
6632 self.schema_floor.as_ref()
6633 }
6634
6635 pub fn lease_state(&self) -> Option<&LeaseState> {
6637 self.lease_state.as_ref()
6638 }
6639
6640 fn record_lease_error(&mut self, code: &str) {
6643 if code != "sync.auth_lease_required" && code != "sync.auth_lease_revoked" {
6644 return;
6645 }
6646 let mut next = self.lease_state.clone().unwrap_or_default();
6647 next.error_code = Some(code.to_owned());
6648 self.set_lease_state(Some(next));
6649 }
6650
6651 fn set_lease_state(&mut self, next: Option<LeaseState>) {
6652 if self.lease_state == next {
6653 return;
6654 }
6655 if self.begin_observation("syncular_lease").is_err() {
6656 return;
6657 }
6658 self.lease_state = next;
6659 if let Some(lease) = &self.lease_state {
6660 if let Ok(json) = serde_json::to_string(lease) {
6661 self.set_meta(LEASE_STATE_KEY, &json);
6662 }
6663 } else {
6664 self.delete_meta(LEASE_STATE_KEY);
6665 }
6666 let batch = ChangeAccumulator {
6667 status: true,
6668 ..ChangeAccumulator::default()
6669 };
6670 if self.finish_observation("syncular_lease", batch).is_err() {
6671 self.rollback_observation("syncular_lease");
6672 }
6673 }
6674
6675 fn set_schema_floor(&mut self, next: Option<SchemaFloor>) {
6676 if self.schema_floor == next {
6677 return;
6678 }
6679 if self.begin_observation("syncular_schema_floor").is_err() {
6680 return;
6681 }
6682 self.schema_floor = next;
6683 self.stopped = self.schema_floor.is_some();
6684 if let Some(floor) = &self.schema_floor {
6685 if let Ok(json) = serde_json::to_string(floor) {
6686 self.set_meta(SCHEMA_FLOOR_KEY, &json);
6687 }
6688 } else {
6689 self.delete_meta(SCHEMA_FLOOR_KEY);
6690 }
6691 let batch = ChangeAccumulator {
6692 status: true,
6693 ..ChangeAccumulator::default()
6694 };
6695 if self
6696 .finish_observation("syncular_schema_floor", batch)
6697 .is_err()
6698 {
6699 self.rollback_observation("syncular_schema_floor");
6700 }
6701 }
6702
6703 fn set_upgrading(&mut self, value: bool) {
6704 if self.upgrading == value {
6705 return;
6706 }
6707 if self.begin_observation("syncular_upgrading").is_err() {
6708 return;
6709 }
6710 self.upgrading = value;
6711 let batch = ChangeAccumulator {
6712 status: true,
6713 ..ChangeAccumulator::default()
6714 };
6715 if self
6716 .finish_observation("syncular_upgrading", batch)
6717 .is_err()
6718 {
6719 self.rollback_observation("syncular_upgrading");
6720 }
6721 }
6722
6723 pub fn sync_needed(&self) -> bool {
6724 self.sync_needed
6725 }
6726
6727 pub fn subscription_state(&self, id: &str) -> Option<SubscriptionStateView> {
6728 let sub = self.subs.iter().find(|s| s.id == id)?;
6729 Some(SubscriptionStateView {
6730 id: sub.id.clone(),
6731 table: sub.table.clone(),
6732 status: sub.state.name().to_owned(),
6733 cursor: sub.cursor,
6734 has_resume_token: sub.bootstrap_state.is_some(),
6735 effective_scopes: sub.effective.as_ref().map(|e| scope_map_to_json(e)),
6736 reason_code: sub.reason_code.clone(),
6737 })
6738 }
6739
6740 pub fn read_rows(&self, table: &str) -> Result<Vec<RowState>, String> {
6741 let schema_table = self
6742 .schema
6743 .table(table)
6744 .ok_or_else(|| format!("unknown table {table:?}"))?;
6745 let sql = format!(
6746 "SELECT * FROM {} ORDER BY {} ASC",
6747 visible_table(table),
6748 quote_ident(&schema_table.primary_key)
6749 );
6750 let mut stmt = self.conn.prepare(&sql).map_err(|e| e.to_string())?;
6751 let mut rows = stmt.query([]).map_err(|e| e.to_string())?;
6752 let mut out = Vec::new();
6753 while let Some(row) = rows.next().map_err(|e| e.to_string())? {
6754 let mut values = Map::new();
6755 for (i, column) in schema_table.columns.iter().enumerate() {
6756 let value = row.get_ref(i).map_err(|e| e.to_string())?;
6757 values.insert(column.name.clone(), sql_ref_to_json(column, value));
6758 }
6759 let version: i64 = row
6760 .get(schema_table.columns.len())
6761 .map_err(|e| e.to_string())?;
6762 let row_id = match values.get(&schema_table.primary_key) {
6763 Some(Value::String(s)) => s.clone(),
6764 Some(Value::Number(n)) => n.to_string(),
6765 Some(Value::Bool(b)) => b.to_string(),
6766 other => format!("{}", other.cloned().unwrap_or(Value::Null)),
6767 };
6768 out.push(RowState {
6769 row_id,
6770 version,
6771 values,
6772 });
6773 }
6774 Ok(out)
6775 }
6776
6777 #[cfg(feature = "crdt-yjs")]
6793 fn crdt_column_bytes(
6794 &self,
6795 table: &str,
6796 row_id: &str,
6797 column: &str,
6798 ) -> Result<Option<Vec<u8>>, String> {
6799 let schema_table = self
6800 .schema
6801 .table(table)
6802 .ok_or_else(|| format!("unknown table {table:?}"))?;
6803 let col = schema_table
6804 .columns
6805 .iter()
6806 .find(|c| c.name == column)
6807 .ok_or_else(|| format!("table {table:?} has no column {column:?}"))?;
6808 if col.ty != ColumnType::Crdt {
6809 return Err(format!("column {column:?} is not a crdt column (§5.10.1)"));
6810 }
6811 let sql = format!(
6812 "SELECT {} FROM {} WHERE {}",
6813 quote_ident(column),
6814 visible_table(table),
6815 row_id_predicate(schema_table)
6816 );
6817 let bytes: Option<Vec<u8>> = self
6818 .conn
6819 .prepare_cached(&sql)
6820 .map_err(|error| error.to_string())?
6821 .query_row(rusqlite::params![row_id], |row| {
6822 row.get::<_, Option<Vec<u8>>>(0)
6823 })
6824 .map_err(|e| match e {
6825 rusqlite::Error::QueryReturnedNoRows => "no such row".to_owned(),
6826 other => other.to_string(),
6827 })?;
6828 Ok(bytes)
6829 }
6830
6831 #[cfg(feature = "crdt-yjs")]
6835 pub fn crdt_text(
6836 &self,
6837 table: &str,
6838 row_id: &str,
6839 column: &str,
6840 name: &str,
6841 ) -> Result<String, String> {
6842 let bytes = self
6843 .crdt_column_bytes(table, row_id, column)?
6844 .unwrap_or_default();
6845 crate::crdt::text(&bytes, name)
6846 }
6847
6848 #[cfg(feature = "crdt-yjs")]
6852 pub fn crdt_insert_text(
6853 &mut self,
6854 table: &str,
6855 row_id: &str,
6856 column: &str,
6857 name: &str,
6858 index: u32,
6859 value: &str,
6860 ) -> Result<String, String> {
6861 let current = self
6862 .crdt_column_bytes(table, row_id, column)?
6863 .unwrap_or_default();
6864 let update = crate::crdt::insert_text(¤t, name, index, value)?;
6865 self.crdt_push_update(table, row_id, column, &update)
6866 }
6867
6868 #[cfg(feature = "crdt-yjs")]
6871 pub fn crdt_delete_text(
6872 &mut self,
6873 table: &str,
6874 row_id: &str,
6875 column: &str,
6876 name: &str,
6877 index: u32,
6878 len: u32,
6879 ) -> Result<String, String> {
6880 let current = self
6881 .crdt_column_bytes(table, row_id, column)?
6882 .unwrap_or_default();
6883 let update = crate::crdt::delete_text(¤t, name, index, len)?;
6884 self.crdt_push_update(table, row_id, column, &update)
6885 }
6886
6887 #[cfg(feature = "crdt-yjs")]
6892 pub fn crdt_apply_update(
6893 &mut self,
6894 table: &str,
6895 row_id: &str,
6896 column: &str,
6897 update: &[u8],
6898 ) -> Result<String, String> {
6899 let current = self
6900 .crdt_column_bytes(table, row_id, column)?
6901 .unwrap_or_default();
6902 let next = crate::crdt::apply_update(¤t, update)?;
6903 self.crdt_push_update(table, row_id, column, &next)
6904 }
6905
6906 #[cfg(feature = "crdt-yjs")]
6912 fn crdt_push_update(
6913 &mut self,
6914 table: &str,
6915 row_id: &str,
6916 column: &str,
6917 crdt_bytes: &[u8],
6918 ) -> Result<String, String> {
6919 let schema_table = self
6920 .schema
6921 .table(table)
6922 .ok_or_else(|| format!("unknown table {table:?}"))?
6923 .clone();
6924 let mut values: Map<String, Value> = self
6927 .read_rows(table)?
6928 .into_iter()
6929 .find(|r| r.row_id == row_id)
6930 .map(|r| r.values)
6931 .unwrap_or_else(|| {
6932 let mut map = Map::new();
6933 map.insert(
6934 schema_table.primary_key.clone(),
6935 Value::from(row_id.to_owned()),
6936 );
6937 map
6938 });
6939 let mut bytes_obj = Map::new();
6941 bytes_obj.insert("$bytes".to_owned(), Value::from(bytes_to_hex(crdt_bytes)));
6942 values.insert(column.to_owned(), Value::Object(bytes_obj));
6943 self.mutate(vec![Mutation::Upsert {
6944 table: table.to_owned(),
6945 values,
6946 base_version: None,
6947 }])
6948 }
6949
6950 pub fn query(&self, sql: &str, params: &[QueryValue]) -> Result<Vec<QueryRow>, String> {
6965 query_connection(&self.conn, sql, params)
6966 }
6967
6968 #[cfg(feature = "bench-internals")]
6970 #[doc(hidden)]
6971 pub fn benchmark_connection(&mut self) -> &mut Connection {
6972 &mut self.conn
6973 }
6974
6975 #[cfg(feature = "bench-internals")]
6977 #[doc(hidden)]
6978 pub fn benchmark_phases(
6979 &self,
6980 enabled: Option<bool>,
6981 reset: bool,
6982 ) -> Result<Option<Value>, String> {
6983 self.benchmark_phases.configure(enabled, reset)?;
6984 Ok(self.benchmark_phases.snapshot())
6985 }
6986
6987 pub fn query_snapshot(
6989 &mut self,
6990 sql: &str,
6991 params: &[QueryValue],
6992 coverage: &[WindowCoverage],
6993 ) -> Result<QuerySnapshot, String> {
6994 snapshot_connection(&self.conn, sql, params, coverage)
6995 }
6996
6997 fn build_request(&self, url_capable: bool) -> (Message, RequestMeta) {
7000 #[cfg(feature = "bench-internals")]
7001 let _phase = self.benchmark_phases.start(Phase::RequestPrepare);
7002 let log_epoch = self.get_meta(LOG_EPOCH_KEY);
7003 let mut frames = vec![Frame::ReqHeader {
7004 client_id: self.client_id.clone(),
7005 schema_version: self.schema.version,
7006 log_epoch: log_epoch.clone(),
7007 }];
7008 let mut pushed_ids = Vec::new();
7009 let mut ops_in_request = 0usize;
7010 let mut deferred_commits = 0usize;
7011 #[cfg(feature = "bench-internals")]
7012 let outbox_phase = self.benchmark_phases.start(Phase::OutboxEncode);
7013 for (index, commit) in self
7014 .outbox
7015 .iter()
7016 .take(if log_epoch.is_some() {
7017 self.outbox.len()
7018 } else {
7019 0
7020 })
7021 .enumerate()
7022 {
7023 if ops_in_request > 0 && ops_in_request + commit.ops.len() > PUSH_OPS_PER_REQUEST {
7028 deferred_commits = self.outbox.len() - index;
7029 break;
7030 }
7031 ops_in_request += commit.ops.len();
7032 let operations = commit
7033 .ops
7034 .iter()
7035 .map(|op| {
7036 let payload = op.values.as_ref().and_then(|values| {
7037 let table = self.schema.table(&op.table)?;
7038 encode_row_json(table, &op.row_id, values, &self.encryption).ok()
7042 });
7043 ssp2::model::Operation {
7044 table: op.table.clone(),
7045 row_id: op.row_id.clone(),
7046 op: if op.upsert { Op::Upsert } else { Op::Delete },
7047 base_version: op.base_version,
7048 payload,
7049 }
7050 })
7051 .collect();
7052 frames.push(Frame::PushCommit {
7053 client_commit_id: commit.client_commit_id.clone(),
7054 operations,
7055 });
7056 pushed_ids.push(commit.client_commit_id.clone());
7057 }
7058 #[cfg(feature = "bench-internals")]
7059 drop(outbox_phase);
7060 let accept = self.limits.accept.unwrap_or(if url_capable {
7063 DEFAULT_ACCEPT | ACCEPT_SIGNED_URLS
7064 } else {
7065 DEFAULT_ACCEPT
7066 });
7067 frames.push(Frame::PullHeader {
7068 limit_commits: self.limits.limit_commits.unwrap_or(0),
7069 limit_snapshot_rows: self.limits.limit_snapshot_rows.unwrap_or(0),
7070 max_snapshot_pages: self.limits.max_snapshot_pages.unwrap_or(0),
7071 accept,
7072 });
7073 let mut fresh = Vec::new();
7074 for sub in &self.subs {
7075 if sub.state != SubState::Active {
7076 continue;
7077 }
7078 let mut scopes = sub.requested.clone();
7079 sort_scope_map(&mut scopes);
7080 frames.push(Frame::Subscription {
7081 id: sub.id.clone(),
7082 table: sub.table.clone(),
7083 scopes,
7084 params: sub.params.clone().map(RawJson),
7085 cursor: sub.cursor,
7086 bootstrap_state: sub.bootstrap_state.clone().map(RawJson),
7087 });
7088 fresh.push((
7089 sub.id.clone(),
7090 sub.cursor < 0 && sub.bootstrap_state.is_none(),
7091 ));
7092 }
7093 let message = Message {
7094 wire_version: WIRE_VERSION,
7095 msg_kind: MsgKind::Request,
7096 frames,
7097 };
7098 (
7099 message,
7100 RequestMeta {
7101 pushed_ids,
7102 fresh,
7103 accept,
7104 deferred_commits,
7105 },
7106 )
7107 }
7108
7109 pub fn sync(&mut self, transport: &mut dyn Transport) -> SyncOutcome {
7112 let started_at_ms = self.clock_now_ms();
7113 let outcome = self.sync_inner(transport);
7114 let completed_at_ms = self.clock_now_ms();
7115 self.last_round = Some(match &outcome {
7116 SyncOutcome::Ok(report) => DiagnosticLastRound {
7117 status: "succeeded".to_owned(),
7118 started_at_ms,
7119 completed_at_ms,
7120 duration_ms: completed_at_ms.saturating_sub(started_at_ms).max(0),
7121 counters: Some(DiagnosticRoundCounters {
7122 pushed: report.pushed,
7123 applied: report.applied.len(),
7124 rejected: report.rejected.len(),
7125 retryable: report.retryable.len(),
7126 conflicts: report.conflicts,
7127 commits_applied: report.commits_applied,
7128 segment_rows_applied: report.segment_rows_applied,
7129 bootstrapping: report.bootstrapping.len(),
7130 resets: report.resets.len(),
7131 revoked: report.revoked.len(),
7132 failed: report.failed.len(),
7133 deferred_commits: report.deferred_commits,
7134 }),
7135 error_code: None,
7136 },
7137 SyncOutcome::Failed { error_code, .. } => DiagnosticLastRound {
7138 status: "failed".to_owned(),
7139 started_at_ms,
7140 completed_at_ms,
7141 duration_ms: completed_at_ms.saturating_sub(started_at_ms).max(0),
7142 counters: None,
7143 error_code: Some(Self::diagnostic_code(error_code)),
7144 },
7145 });
7146 outcome
7147 }
7148
7149 fn sync_inner(&mut self, transport: &mut dyn Transport) -> SyncOutcome {
7150 if self.stopped {
7151 return SyncOutcome::Ok(SyncReport {
7154 schema_floor: self.schema_floor.clone(),
7155 ..SyncReport::default()
7156 });
7157 }
7158 self.set_sync_needed(false, false);
7161 if self.get_meta(LOG_EPOCH_KEY).is_some() && self.schema_has_blobs() {
7164 if let Err(TransportError { code, message }) = self.flush_blob_uploads(transport) {
7165 if Self::retryable_transport_code(&code) {
7166 self.schedule_background_retry();
7167 }
7168 return SyncOutcome::Failed {
7169 error_code: code,
7170 message,
7171 };
7172 }
7173 }
7174 let (message, meta) = self.build_request(transport.supports_url_fetch());
7175 let request_bytes = {
7176 #[cfg(feature = "bench-internals")]
7177 let _phase = self.benchmark_phases.start(Phase::RequestEncode);
7178 encode_message(&message)
7179 };
7180 let round = if self.realtime_connected {
7184 transport.realtime_sync(&request_bytes)
7185 } else {
7186 transport.sync(&request_bytes)
7187 };
7188 let response_bytes = match round {
7189 Ok(bytes) => bytes,
7190 Err(TransportError { code, message }) => {
7191 self.record_lease_error(&code);
7194 if Self::retryable_transport_code(&code) {
7195 self.schedule_background_retry();
7196 }
7197 return SyncOutcome::Failed {
7198 error_code: code,
7199 message,
7200 };
7201 }
7202 };
7203 #[cfg(feature = "bench-internals")]
7204 let decode_phase = self.benchmark_phases.start(Phase::ResponseDecode);
7205 let response = match decode_message(&response_bytes) {
7206 Ok(message) => message,
7207 Err(error) => {
7208 return SyncOutcome::Failed {
7211 error_code: error.code.as_str().to_owned(),
7212 message: error.detail,
7213 };
7214 }
7215 };
7216 #[cfg(feature = "bench-internals")]
7217 drop(decode_phase);
7218 if response.msg_kind != MsgKind::Response {
7219 return SyncOutcome::Failed {
7220 error_code: "sync.invalid_request".to_owned(),
7221 message: "expected a response message".to_owned(),
7222 };
7223 }
7224 let mut outcome = self.process_response(transport, response, &meta);
7225 if let SyncOutcome::Ok(report) = &mut outcome {
7226 report.deferred_commits = meta.deferred_commits;
7227 }
7228 match &outcome {
7229 SyncOutcome::Ok(_) => self.reset_background_retry(),
7230 SyncOutcome::Failed { error_code, .. }
7231 if Self::retryable_transport_code(error_code) =>
7232 {
7233 self.schedule_background_retry();
7234 }
7235 SyncOutcome::Failed { .. } => {}
7236 }
7237 if meta.deferred_commits > 0 {
7238 self.set_sync_needed(true, true);
7241 }
7242 outcome
7243 }
7244
7245 pub fn sync_until_idle(
7246 &mut self,
7247 transport: &mut dyn Transport,
7248 max_rounds: Option<u32>,
7249 ) -> SyncOutcome {
7250 let rounds = max_rounds.unwrap_or(12).max(1);
7251 let mut aggregate = SyncReport::default();
7252 for _ in 0..rounds {
7253 match self.sync(transport) {
7254 SyncOutcome::Failed {
7255 error_code,
7256 message,
7257 } => {
7258 return SyncOutcome::Failed {
7259 error_code,
7260 message,
7261 };
7262 }
7263 SyncOutcome::Ok(report) => {
7264 aggregate.pushed += report.pushed;
7265 aggregate.applied.extend(report.applied.iter().cloned());
7266 aggregate.rejected.extend(report.rejected.iter().cloned());
7267 aggregate.retryable.extend(report.retryable.iter().cloned());
7268 aggregate.conflicts += report.conflicts;
7269 aggregate.commits_applied += report.commits_applied;
7270 aggregate.segment_rows_applied += report.segment_rows_applied;
7271 aggregate.bootstrapping = report.bootstrapping.clone();
7272 aggregate.resets.extend(report.resets.iter().cloned());
7273 aggregate.revoked.extend(report.revoked.iter().cloned());
7274 aggregate.failed.extend(report.failed.iter().cloned());
7275 aggregate.deferred_commits = report.deferred_commits;
7276 if report.schema_floor.is_some() {
7277 aggregate.schema_floor = report.schema_floor.clone();
7278 }
7279 let more = !report.bootstrapping.is_empty()
7285 || report.commits_applied > 0
7286 || report.segment_rows_applied > 0
7287 || !report.resets.is_empty()
7288 || self.sync_needed;
7289 if !more {
7290 break;
7291 }
7292 }
7293 }
7294 }
7295 SyncOutcome::Ok(aggregate)
7296 }
7297
7298 fn process_response(
7299 &mut self,
7300 transport: &mut dyn Transport,
7301 response: Message,
7302 meta: &RequestMeta,
7303 ) -> SyncOutcome {
7304 #[cfg(feature = "bench-internals")]
7305 let _phase = self.benchmark_phases.start(Phase::ResponseApply);
7306 let mut report = SyncReport {
7307 pushed: meta.pushed_ids.len() as u32,
7308 ..SyncReport::default()
7309 };
7310 let mut rejection_details_by_commit: HashMap<String, BTreeMap<i32, RejectionDetails>> =
7311 HashMap::new();
7312 let pushed_ids = meta
7313 .pushed_ids
7314 .iter()
7315 .map(String::as_str)
7316 .collect::<HashSet<_>>();
7317 let mut last_final_push_result_id: Option<String> = None;
7318 for frame in &response.frames {
7319 match frame {
7320 Frame::PushResultDetails {
7321 client_commit_id,
7322 entries,
7323 } => {
7324 let details = rejection_details_by_commit
7325 .entry(client_commit_id.clone())
7326 .or_default();
7327 for entry in entries {
7328 let parsed = match RejectionDetails::parse(&entry.details.0) {
7329 Ok(value) => value,
7330 Err(message) => {
7331 return SyncOutcome::Failed {
7332 error_code: "sync.invalid_request".to_owned(),
7333 message,
7334 };
7335 }
7336 };
7337 details.insert(entry.op_index, parsed);
7338 }
7339 }
7340 Frame::PushResult {
7341 client_commit_id,
7342 status,
7343 results,
7344 ..
7345 } if pushed_ids.contains(client_commit_id.as_str())
7346 && Self::push_result_is_final(*status, results) =>
7347 {
7348 last_final_push_result_id = Some(client_commit_id.clone());
7349 }
7350 _ => {}
7351 }
7352 }
7353 let mut frames = response.frames.into_iter().peekable();
7354 if response.wire_version < 2 {
7355 return SyncOutcome::Failed {
7356 error_code: "client.invalid_host_response".to_owned(),
7357 message: "server response does not carry wire version 2 log-epoch state".to_owned(),
7358 };
7359 }
7360 match frames.next() {
7361 Some(Frame::RespHeader {
7362 required_schema_version,
7363 latest_schema_version,
7364 log_epoch,
7365 reset_required,
7366 }) => {
7367 let Some(log_epoch) = log_epoch else {
7368 return SyncOutcome::Failed {
7369 error_code: "client.invalid_host_response".to_owned(),
7370 message: "response header omits logEpoch".to_owned(),
7371 };
7372 };
7373 let Some(reset_required) = reset_required else {
7374 return SyncOutcome::Failed {
7375 error_code: "client.invalid_host_response".to_owned(),
7376 message: "response header omits resetRequired".to_owned(),
7377 };
7378 };
7379 if let Some(required) = required_schema_version {
7380 let floor = SchemaFloor {
7383 required_schema_version: Some(required),
7384 latest_schema_version,
7385 };
7386 self.set_schema_floor(Some(floor.clone()));
7387 report.schema_floor = Some(floor);
7388 return SyncOutcome::Ok(report);
7389 }
7390 if reset_required {
7391 if frames.next().is_some() {
7392 return SyncOutcome::Failed {
7393 error_code: "client.invalid_host_response".to_owned(),
7394 message: "log-epoch reset response contains body frames".to_owned(),
7395 };
7396 }
7397 match self.run_log_epoch_reset(&log_epoch) {
7398 Ok(resets) => {
7399 report.resets = resets;
7400 return SyncOutcome::Ok(report);
7401 }
7402 Err(message) => {
7403 return SyncOutcome::Failed {
7404 error_code: "sync.local_corrupt".to_owned(),
7405 message,
7406 };
7407 }
7408 }
7409 }
7410 if self.get_meta(LOG_EPOCH_KEY).as_deref() != Some(log_epoch.as_str()) {
7411 return SyncOutcome::Failed {
7412 error_code: "client.invalid_host_response".to_owned(),
7413 message: "server changed logEpoch without requiring a reset".to_owned(),
7414 };
7415 }
7416 }
7417 _ => {
7418 return SyncOutcome::Failed {
7419 error_code: "sync.invalid_request".to_owned(),
7420 message: "response does not start with RESP_HEADER".to_owned(),
7421 };
7422 }
7423 }
7424
7425 let mut failure: Option<(String, String)> = None;
7426 while let Some(frame) = frames.next() {
7427 match frame {
7428 frame @ Frame::PushResult { .. } => {
7429 let successful = matches!(
7430 &frame,
7431 Frame::PushResult {
7432 status: PushStatus::Applied | PushStatus::Cached,
7433 ..
7434 }
7435 );
7436 let mut results = vec![frame];
7437 if successful {
7438 while matches!(
7439 frames.peek(),
7440 Some(Frame::PushResult {
7441 status: PushStatus::Applied | PushStatus::Cached,
7442 ..
7443 })
7444 ) {
7445 if let Some(next) = frames.next() {
7446 results.push(next);
7447 }
7448 }
7449 }
7450 if self
7451 .handle_push_results(
7452 &results,
7453 &pushed_ids,
7454 &rejection_details_by_commit,
7455 &mut report,
7456 last_final_push_result_id.as_deref(),
7457 )
7458 .is_err()
7459 {
7460 failure = Some((
7461 "client.outcome_persistence_failed".to_owned(),
7462 "local commit outcome could not be persisted".to_owned(),
7463 ));
7464 break;
7465 }
7466 }
7467 Frame::PushResultDetails { .. } => {}
7468 Frame::SubStart {
7469 id,
7470 status,
7471 reason_code,
7472 effective_scopes,
7473 bootstrap: _,
7474 } => {
7475 let mut body = Vec::new();
7476 let mut sub_end: Option<(i64, Option<String>)> = None;
7477 for inner in frames.by_ref() {
7478 match inner {
7479 Frame::SubEnd {
7480 next_cursor,
7481 bootstrap_state,
7482 } => {
7483 sub_end = Some((next_cursor, bootstrap_state.map(|r| r.0)));
7484 break;
7485 }
7486 Frame::Unknown { .. } => {}
7487 other => body.push(other),
7488 }
7489 }
7490 if let Err(SectionError::Abort(code, message)) = self.process_section(
7491 transport,
7492 &id,
7493 status,
7494 &reason_code,
7495 effective_scopes,
7496 body,
7497 sub_end,
7498 meta,
7499 &mut report,
7500 ) {
7501 failure = Some((code, message));
7502 break;
7503 }
7504 }
7505 Frame::Lease {
7506 lease_id,
7507 expires_at_ms,
7508 } => {
7509 self.set_lease_state(Some(LeaseState {
7512 lease_id: Some(lease_id),
7513 expires_at_ms: Some(expires_at_ms),
7514 error_code: None,
7515 }));
7516 }
7517 Frame::Error { code, message, .. } => {
7518 failure = Some((code, message));
7521 break;
7522 }
7523 Frame::Unknown { .. } => {}
7524 _ => {
7525 failure = Some((
7526 "sync.invalid_request".to_owned(),
7527 "unexpected frame in response".to_owned(),
7528 ));
7529 break;
7530 }
7531 }
7532 }
7533
7534 if self.overlay_dirty.get() {
7539 self.rebuild_overlay();
7540 }
7541 self.reconcile_blob_refcounts(false, None);
7548
7549 if let Some((error_code, message)) = failure {
7550 return SyncOutcome::Failed {
7551 error_code,
7552 message,
7553 };
7554 }
7555 self.drain_pending_evictions();
7558 self.ack_after_pull(transport);
7559 if self.upgrading && report.bootstrapping.is_empty() {
7562 self.set_upgrading(false);
7563 }
7564 SyncOutcome::Ok(report)
7565 }
7566
7567 fn handle_push_results(
7570 &mut self,
7571 frames: &[Frame],
7572 pushed_ids: &HashSet<&str>,
7573 rejection_details_by_commit: &HashMap<String, BTreeMap<i32, RejectionDetails>>,
7574 report: &mut SyncReport,
7575 last_final_push_result_id: Option<&str>,
7576 ) -> Result<(), String> {
7577 self.begin_observation("syncular_push_results")?;
7578 let conflict_count = self.conflicts.len();
7579 let rejection_count = self.rejections.len();
7580 let applied_count = report.applied.len();
7581 let rejected_count = report.rejected.len();
7582 let retryable_count = report.retryable.len();
7583 let was_dirty = self.overlay_dirty.get();
7584 let mut removed = Vec::new();
7585 let mut batch = ChangeAccumulator::default();
7586 let persisted = (|| {
7587 for frame in frames {
7588 let Frame::PushResult {
7589 client_commit_id,
7590 status,
7591 results,
7592 ..
7593 } = frame
7594 else {
7595 return Err("unexpected acknowledgement frame".to_owned());
7596 };
7597 if !pushed_ids.contains(client_commit_id.as_str()) {
7598 continue;
7599 }
7600 let Some(index) = self
7601 .outbox
7602 .iter()
7603 .position(|commit| commit.client_commit_id == *client_commit_id)
7604 else {
7605 continue;
7606 };
7607 let status = *status;
7608 let rejection_details = rejection_details_by_commit.get(client_commit_id);
7609 let operations = self.outbox[index].ops.clone();
7610 match status {
7611 PushStatus::Applied | PushStatus::Cached => {
7612 let journal_results = results
7615 .iter()
7616 .map(|result| {
7617 let op_index = match result {
7618 OpResult::Applied { op_index }
7619 | OpResult::Conflict { op_index, .. }
7620 | OpResult::Error { op_index, .. } => *op_index,
7621 };
7622 CommitOperationOutcome::Applied { op_index }
7623 })
7624 .collect::<Vec<_>>();
7625 let outcome_status = if status == PushStatus::Applied {
7626 CommitOutcomeStatus::Applied
7627 } else {
7628 CommitOutcomeStatus::Cached
7629 };
7630 let persisted = self
7631 .persist_commit_outcome(
7632 client_commit_id,
7633 outcome_status,
7634 &journal_results,
7635 None,
7636 )
7637 .and_then(|()| self.delete_outbox_persisted(client_commit_id));
7638 persisted?;
7639 self.overlay_dirty.set(true);
7640 batch.status = true;
7641 batch.outcomes = true;
7642 }
7643 PushStatus::Rejected => {
7644 if results.iter().any(|result| {
7645 matches!(
7646 result,
7647 OpResult::Error {
7648 code,
7649 retryable: true,
7650 ..
7651 } if code == "sync.idempotency_cache_miss"
7652 )
7653 }) {
7654 report.retryable.push(client_commit_id.to_owned());
7657 continue;
7658 }
7659
7660 let mut journal_results = Vec::with_capacity(results.len());
7661 let mut conflicts = Vec::new();
7662 let mut rejections = Vec::new();
7663 for result in results {
7664 match result {
7665 OpResult::Applied { op_index } => {
7666 journal_results.push(CommitOperationOutcome::Applied {
7667 op_index: *op_index,
7668 });
7669 }
7670 OpResult::Conflict {
7671 op_index,
7672 code,
7673 message,
7674 server_version,
7675 server_row,
7676 } => {
7677 let operation = operations
7678 .get(*op_index as usize)
7679 .map(CommitOperation::from);
7680 let (table, row_id) = operation
7681 .as_ref()
7682 .map(|op| (op.table.clone(), op.row_id.clone()))
7683 .unwrap_or_default();
7684 let server_row_json = self
7685 .schema
7686 .table(&table)
7687 .and_then(|t| {
7688 decode_row_bytes(t, server_row, &self.encryption)
7689 .ok()
7690 .map(|row| (t, row))
7691 })
7692 .map(|(t, row)| {
7693 let mut map = Map::new();
7694 for (i, column) in t.columns.iter().enumerate() {
7695 map.insert(
7696 column.name.clone(),
7697 column_value_to_json(
7698 row.get(i).unwrap_or(&None),
7699 ),
7700 );
7701 }
7702 map
7703 })
7704 .unwrap_or_default();
7705 let conflict = ConflictRecord {
7706 client_commit_id: client_commit_id.to_owned(),
7707 op_index: *op_index,
7708 table,
7709 row_id,
7710 code: code.clone(),
7711 message: message.clone(),
7712 server_version: *server_version,
7713 server_row: server_row_json,
7714 operation,
7715 };
7716 journal_results.push(CommitOperationOutcome::Conflict {
7717 conflict: conflict.clone(),
7718 });
7719 conflicts.push(conflict);
7720 }
7721 OpResult::Error {
7722 op_index,
7723 code,
7724 message,
7725 retryable,
7726 } => {
7727 let rejection = RejectionRecord {
7728 client_commit_id: client_commit_id.to_owned(),
7729 op_index: *op_index,
7730 code: code.clone(),
7731 message: message.clone(),
7732 retryable: *retryable,
7733 details: rejection_details
7734 .and_then(|details| details.get(op_index))
7735 .cloned(),
7736 operation: operations
7737 .get(*op_index as usize)
7738 .map(CommitOperation::from),
7739 };
7740 journal_results.push(CommitOperationOutcome::Error {
7741 rejection: rejection.clone(),
7742 });
7743 rejections.push(rejection);
7744 }
7745 }
7746 }
7747 let outcome_status = if conflicts.is_empty() {
7748 CommitOutcomeStatus::Rejected
7749 } else {
7750 CommitOutcomeStatus::Conflict
7751 };
7752 let persisted = self
7753 .persist_commit_outcome(
7754 client_commit_id,
7755 outcome_status,
7756 &journal_results,
7757 Some(&operations),
7758 )
7759 .and_then(|()| self.delete_outbox_persisted(client_commit_id));
7760 persisted?;
7761 batch.conflicts = !conflicts.is_empty();
7762 batch.rejections = !rejections.is_empty();
7763 batch.status = true;
7764 batch.outcomes = true;
7765 self.conflicts.extend(conflicts);
7766 self.rejections.extend(rejections);
7767 self.overlay_dirty.set(true);
7768 }
7769 }
7770 removed.push((index, self.outbox.remove(index)));
7771 if batch.status {
7772 for operation in &operations {
7773 if !self.record_row_scopes(
7774 &mut batch,
7775 &operation.table,
7776 &operation.row_id,
7777 false,
7778 ) {
7779 batch.table(&operation.table);
7780 }
7781 }
7782 }
7783 match status {
7784 PushStatus::Applied | PushStatus::Cached => {
7785 report.applied.push(client_commit_id.to_owned())
7786 }
7787 PushStatus::Rejected => report.rejected.push(client_commit_id.to_owned()),
7788 }
7789 }
7790 if batch.outcomes && frames.iter().any(|frame| matches!(frame, Frame::PushResult { client_commit_id, .. } if Some(client_commit_id.as_str()) == last_final_push_result_id)) {
7791 self.prune_commit_outcomes()?;
7792 }
7793 self.finish_observation("syncular_push_results", batch)
7794 })();
7795 if let Err(error) = persisted {
7796 self.rollback_observation("syncular_push_results");
7797 for (index, commit) in removed.into_iter().rev() {
7798 self.outbox.insert(index, commit);
7799 }
7800 self.conflicts.truncate(conflict_count);
7801 self.rejections.truncate(rejection_count);
7802 self.overlay_dirty.set(was_dirty);
7803 report.applied.truncate(applied_count);
7804 report.rejected.truncate(rejected_count);
7805 report.retryable.truncate(retryable_count);
7806 return Err(error);
7807 }
7808 report.conflicts += (self.conflicts.len() - conflict_count) as u32;
7809 Ok(())
7810 }
7811
7812 fn push_result_is_final(status: PushStatus, results: &[OpResult]) -> bool {
7813 status != PushStatus::Rejected
7814 || !results.iter().any(|result| {
7815 matches!(
7816 result,
7817 OpResult::Error {
7818 code,
7819 retryable: true,
7820 ..
7821 } if code == "sync.idempotency_cache_miss"
7822 )
7823 })
7824 }
7825
7826 #[allow(clippy::too_many_arguments)]
7829 fn process_section(
7830 &mut self,
7831 transport: &mut dyn Transport,
7832 id: &str,
7833 status: SubStatus,
7834 reason_code: &str,
7835 effective_scopes: Vec<(String, Vec<String>)>,
7836 body: Vec<Frame>,
7837 sub_end: Option<(i64, Option<String>)>,
7838 meta: &RequestMeta,
7839 report: &mut SyncReport,
7840 ) -> Result<(), SectionError> {
7841 let Some(sub_index) = self.subs.iter().position(|s| s.id == id) else {
7842 return Ok(()); };
7844 if status != SubStatus::Active && sub_end.is_none() {
7845 return Err(SectionError::Abort(
7846 "sync.invalid_request".into(),
7847 "subscription section without SUB_END".into(),
7848 ));
7849 }
7850 match status {
7851 SubStatus::Revoked => {
7852 self.begin_observation("syncular_revocation")
7853 .map_err(|message| SectionError::Abort("storage.failed".to_owned(), message))?;
7854 let previous = self.subs[sub_index].clone();
7855 let was_dirty = self.overlay_dirty.get();
7856 let previous_outbox = self.outbox.clone();
7857 let rejection_count = self.rejections.len();
7858 let revoked_count = report.revoked.len();
7859 let failed_count = report.failed.len();
7860 let result =
7861 (|| {
7862 let mut batch = ChangeAccumulator::default();
7863 let registered = self.window_unit_by_sub_id(id);
7864 let (table, effective) = {
7866 let sub = &self.subs[sub_index];
7867 (sub.table.clone(), sub.effective.clone().unwrap_or_default())
7868 };
7869 let purged = self.purge_scope_rows(&table, &effective);
7870 match purged {
7871 Ok(()) => {
7872 self.record_scope_map(&mut batch, &table, &effective);
7873 let sub = &mut self.subs[sub_index];
7874 sub.state = SubState::Revoked;
7875 sub.reason_code = Some(if reason_code.is_empty() {
7876 "sync.scope_revoked".to_owned()
7877 } else {
7878 reason_code.to_owned()
7879 });
7880 report.revoked.push(id.to_owned());
7881 let doomed_effective = effective;
7882 let sub_table = table;
7883 self.persist_sub(&self.subs[sub_index].clone()).map_err(
7884 |message| SectionError::Abort("storage.failed".into(), message),
7885 )?;
7886 let dropped = self
7887 .drop_doomed_outbox(&sub_table, &doomed_effective)
7888 .map_err(|message| {
7889 SectionError::Abort("storage.failed".to_owned(), message)
7890 })?;
7891 if dropped {
7892 batch.status = true;
7893 batch.rejections = true;
7894 batch.outcomes = true;
7895 }
7896 self.reconcile_blob_refcounts(true, None);
7899 }
7900 Err(()) => {
7901 let sub = &mut self.subs[sub_index];
7904 sub.state = SubState::Failed;
7905 sub.reason_code = Some("sync.scope_revoked".to_owned());
7906 report.failed.push(id.to_owned());
7907 self.persist_sub(&self.subs[sub_index].clone()).map_err(
7908 |message| SectionError::Abort("storage.failed".into(), message),
7909 )?;
7910 }
7911 }
7912 if let Some((base_key, unit)) = registered {
7913 batch.window(&base_key, &self.subs[sub_index].table, &unit);
7914 }
7915 self.rebuild_overlay_if_dirty();
7916 self.finish_observation("syncular_revocation", batch)
7917 .map_err(|message| {
7918 SectionError::Abort("storage.failed".to_owned(), message)
7919 })?;
7920 Ok(())
7921 })();
7922 if result.is_err() {
7923 self.rollback_observation("syncular_revocation");
7924 self.subs[sub_index] = previous;
7925 self.overlay_dirty.set(was_dirty);
7926 self.outbox = previous_outbox;
7927 self.rejections.truncate(rejection_count);
7928 report.revoked.truncate(revoked_count);
7929 report.failed.truncate(failed_count);
7930 }
7931 result
7932 }
7933 SubStatus::Reset => {
7934 self.begin_observation("syncular_reset")
7935 .map_err(|message| SectionError::Abort("storage.failed".to_owned(), message))?;
7936 let previous = self.subs[sub_index].clone();
7937 let was_dirty = self.overlay_dirty.get();
7938 let reset_count = report.resets.len();
7939 let result = (|| {
7940 let mut batch = ChangeAccumulator::default();
7941 let registered = self.window_unit_by_sub_id(id);
7942 let sub = &mut self.subs[sub_index];
7945 sub.cursor = -1;
7946 sub.bootstrap_state = None;
7947 report.resets.push(id.to_owned());
7948 self.persist_sub(&self.subs[sub_index].clone())
7949 .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
7950 if let Some((base_key, unit)) = registered {
7951 batch.window(&base_key, &self.subs[sub_index].table, &unit);
7952 }
7953 self.finish_observation("syncular_reset", batch)
7954 .map_err(|message| {
7955 SectionError::Abort("storage.failed".to_owned(), message)
7956 })?;
7957 Ok(())
7958 })();
7959 if result.is_err() {
7960 self.rollback_observation("syncular_reset");
7961 self.subs[sub_index] = previous;
7962 self.overlay_dirty.set(was_dirty);
7963 report.resets.truncate(reset_count);
7964 }
7965 result
7966 }
7967 SubStatus::Active => {
7968 let fresh = meta
7969 .fresh
7970 .iter()
7971 .find(|(fid, _)| fid == id)
7972 .map(|(_, fresh)| *fresh)
7973 .unwrap_or(false);
7974 let previous = self.subs[sub_index].clone();
7975 self.subs[sub_index].effective = Some(effective_scopes);
7976 let outcome = self
7977 .apply_section_body(transport, sub_index, body, fresh, meta, report)
7978 .and_then(|()| {
7979 let (cursor, bootstrap_state) = sub_end.ok_or_else(|| {
7980 SectionError::Abort(
7981 "sync.invalid_request".into(),
7982 "subscription section without SUB_END".into(),
7983 )
7984 })?;
7985 self.apply_sub_end(sub_index, cursor, bootstrap_state)
7986 .map_err(|message| {
7987 SectionError::Abort("storage.failed".into(), message)
7988 })
7989 });
7990 match outcome {
7991 Ok(()) => {
7992 if self.subs[sub_index].bootstrap_state.is_some() {
7993 report.bootstrapping.push(id.to_owned());
7994 }
7995 Ok(())
7996 }
7997 Err(SectionError::FailClosed) => {
7998 self.subs[sub_index] = previous.clone();
7999 self.begin_observation("syncular_section_failure")
8000 .map_err(|message| {
8001 SectionError::Abort("storage.failed".into(), message)
8002 })?;
8003 let persisted = (|| {
8004 let mut batch = ChangeAccumulator::default();
8005 if let Some((base_key, unit)) = self.window_unit_by_sub_id(id) {
8006 batch.window(&base_key, &self.subs[sub_index].table, &unit);
8007 }
8008 self.subs[sub_index].state = SubState::Failed;
8009 self.subs[sub_index].reason_code = Some("sync.scope_revoked".into());
8010 self.persist_sub(&self.subs[sub_index])?;
8011 self.finish_observation("syncular_section_failure", batch)
8012 })();
8013 if let Err(message) = persisted {
8014 self.rollback_observation("syncular_section_failure");
8015 self.subs[sub_index] = previous;
8016 return Err(SectionError::Abort("storage.failed".into(), message));
8017 }
8018 report.failed.push(id.to_owned());
8019 Ok(())
8020 }
8021 Err(error) => {
8022 self.subs[sub_index] = previous;
8025 Err(error)
8026 }
8027 }
8028 }
8029 }
8030 }
8031
8032 fn apply_sub_end(
8033 &mut self,
8034 sub_index: usize,
8035 cursor: i64,
8036 bootstrap_state: Option<String>,
8037 ) -> Result<(), String> {
8038 #[cfg(feature = "bench-internals")]
8039 let _phase = self.benchmark_phases.start(Phase::CursorPersist);
8040 let previous = self.subs[sub_index].clone();
8041 self.begin_observation("syncular_sub_end")?;
8042 let persisted = (|| {
8043 let mut batch = ChangeAccumulator::default();
8044 let completed = (previous.cursor < 0 || previous.bootstrap_state.is_some())
8045 && cursor >= 0
8046 && bootstrap_state.is_none();
8047 if completed {
8048 if let Some((base_key, unit)) = self.window_unit_by_sub_id(&previous.id) {
8049 batch.window(&base_key, &previous.table, &unit);
8050 }
8051 }
8052 let sub = &mut self.subs[sub_index];
8053 sub.cursor = cursor;
8054 sub.bootstrap_state = bootstrap_state;
8055 sub.synced_once = true;
8056 self.persist_sub(&self.subs[sub_index])?;
8057 self.finish_observation("syncular_sub_end", batch)
8058 })();
8059 if persisted.is_err() {
8060 self.rollback_observation("syncular_sub_end");
8061 self.subs[sub_index] = previous;
8062 }
8063 persisted
8064 }
8065
8066 fn apply_section_body(
8067 &mut self,
8068 transport: &mut dyn Transport,
8069 sub_index: usize,
8070 body: Vec<Frame>,
8071 fresh: bool,
8072 meta: &RequestMeta,
8073 report: &mut SyncReport,
8074 ) -> Result<(), SectionError> {
8075 let mut saw_segment = false;
8076 for frame in body {
8077 match frame {
8078 Frame::Commit {
8079 tables, changes, ..
8080 } => {
8081 self.apply_commit_frame(&tables, &changes)
8082 .map_err(|(c, m)| SectionError::Abort(c, m))?;
8083 report.commits_applied += 1;
8084 }
8085 Frame::SegmentInline { payload } => {
8086 let segment = decode_rows_segment(&payload)
8087 .map_err(|e| SectionError::Abort(e.code.as_str().to_owned(), e.detail))?;
8088 let first = !saw_segment;
8089 saw_segment = true;
8090 let applied = self.apply_segment(sub_index, &segment, fresh && first)?;
8091 report.segment_rows_applied += applied;
8092 }
8093 Frame::SegmentRef {
8094 segment_id,
8095 media_type,
8096 table,
8097 row_count,
8098 as_of_commit_seq,
8099 scope_digest,
8100 row_cursor,
8101 next_row_cursor,
8102 url,
8103 url_expires_at_ms,
8104 ..
8105 } => {
8106 let advertised = match media_type {
8109 MediaType::Rows => {
8110 meta.accept & ACCEPT_EXTERNAL_ROWS != 0
8111 || meta.accept & ACCEPT_INLINE_ROWS != 0
8112 }
8113 MediaType::Sqlite => meta.accept & ACCEPT_SQLITE != 0,
8114 };
8115 if !advertised {
8116 return Err(SectionError::Abort(
8117 "sync.invalid_request".to_owned(),
8118 format!(
8119 "SEGMENT_REF mediaType {} was not advertised in accept (§4.2)",
8120 media_type.name()
8121 ),
8122 ));
8123 }
8124 let bytes = if let Some(url) = url {
8125 if meta.accept & ACCEPT_SIGNED_URLS == 0 {
8130 return Err(SectionError::Abort(
8131 "sync.invalid_request".to_owned(),
8132 "SEGMENT_REF carries a url but accept bit 3 was not advertised (§5.4)"
8133 .to_owned(),
8134 ));
8135 }
8136 if url_expires_at_ms.is_some_and(|exp| exp <= self.clock_now_ms()) {
8138 return Err(SectionError::Abort(
8139 "sync.segment_expired".to_owned(),
8140 format!(
8141 "signed URL for segment {segment_id} expired before fetch — re-pull mints fresh descriptors (§5.4)"
8142 ),
8143 ));
8144 }
8145 transport
8146 .fetch_url(&url)
8147 .map_err(|e| SectionError::Abort(e.code, e.message))?
8148 } else {
8149 let requested_scopes_json =
8150 canonical_scope_json(&self.subs[sub_index].requested);
8151 transport
8152 .download_segment(&SegmentRequest {
8153 segment_id: segment_id.clone(),
8154 table,
8155 requested_scopes_json,
8156 })
8157 .map_err(|e| SectionError::Abort(e.code, e.message))?
8158 };
8159 let digest = Sha256::digest(&bytes);
8161 let expected = segment_id
8162 .strip_prefix("sha256:")
8163 .unwrap_or(segment_id.as_str());
8164 if bytes_to_hex(&digest) != expected {
8165 return Err(SectionError::Abort(
8166 "sync.invalid_request".to_owned(),
8167 "segment bytes do not match the content address (§5.1)".to_owned(),
8168 ));
8169 }
8170 if media_type == MediaType::Sqlite {
8171 if row_cursor.is_some() || next_row_cursor.is_some() {
8174 return Err(SectionError::Abort(
8175 "sync.invalid_request".to_owned(),
8176 "sqlite segments are whole-table: rowCursor/nextRowCursor must be absent (§5.3)"
8177 .to_owned(),
8178 ));
8179 }
8180 let first = !saw_segment;
8181 saw_segment = true;
8182 let applied = self.apply_sqlite_segment(
8183 sub_index,
8184 &bytes,
8185 fresh && first,
8186 row_count,
8187 as_of_commit_seq,
8188 &scope_digest,
8189 )?;
8190 report.segment_rows_applied += applied;
8191 } else {
8192 let segment = decode_rows_segment(&bytes).map_err(|e| {
8193 SectionError::Abort(e.code.as_str().to_owned(), e.detail)
8194 })?;
8195 let first = row_cursor.is_none();
8196 saw_segment = true;
8197 let applied = self.apply_segment(sub_index, &segment, fresh && first)?;
8198 report.segment_rows_applied += applied;
8199 }
8200 }
8201 Frame::Unknown { .. } => {}
8202 Frame::Error { code, message, .. } => {
8203 return Err(SectionError::Abort(code, message))
8204 }
8205 _ => {
8206 return Err(SectionError::Abort(
8207 "sync.invalid_request".to_owned(),
8208 "unexpected frame inside a subscription section".to_owned(),
8209 ));
8210 }
8211 }
8212 }
8213 Ok(())
8214 }
8215
8216 fn apply_commit_frame(
8218 &mut self,
8219 tables: &[String],
8220 changes: &[ssp2::model::Change],
8221 ) -> Result<(), (String, String)> {
8222 #[cfg(feature = "bench-internals")]
8223 let _phase = self.benchmark_phases.start(Phase::CommitApply);
8224 let was_dirty = self.overlay_dirty.get();
8225 self.begin_observation("syncular_commit")
8226 .map_err(|message| ("storage.failed".into(), message))?;
8227 let applied = (|| {
8228 let mut batch = ChangeAccumulator::default();
8229 self.record_commit_changes(&mut batch, tables, changes);
8230 self.apply_commit_changes(tables, changes)?;
8231 self.rebuild_overlay_if_dirty();
8232 self.finish_observation("syncular_commit", batch)
8233 .map_err(|message| ("storage.failed".into(), message))
8234 })();
8235 if applied.is_err() {
8236 self.rollback_observation("syncular_commit");
8237 self.overlay_dirty.set(was_dirty);
8238 }
8239 applied
8240 }
8241
8242 fn apply_commit_changes(
8243 &mut self,
8244 tables: &[String],
8245 changes: &[ssp2::model::Change],
8246 ) -> Result<(), (String, String)> {
8247 let mirror_visible = !self.overlay_dirty.get()
8252 && (self.outbox.is_empty()
8253 || changes.iter().all(|change| {
8254 tables
8255 .get(change.table_index as usize)
8256 .and_then(|name| self.schema.table(name))
8257 .is_some_and(|table| !table.indexes.iter().any(|index| index.unique))
8258 }));
8259 for change in changes {
8260 let table_name = tables.get(change.table_index as usize).ok_or_else(|| {
8261 (
8262 "sync.invalid_request".to_owned(),
8263 "change tableIndex out of range".to_owned(),
8264 )
8265 })?;
8266 let table = self.schema.table(table_name).ok_or_else(|| {
8267 (
8268 "sync.schema_mismatch".to_owned(),
8269 format!("change targets unknown table {table_name:?}"),
8270 )
8271 })?;
8272 match change.op {
8273 Op::Upsert => {
8274 let payload = change.row.as_ref().ok_or_else(|| {
8275 (
8276 "sync.invalid_request".to_owned(),
8277 "upsert change without row payload".to_owned(),
8278 )
8279 })?;
8280 let row = {
8282 #[cfg(feature = "bench-internals")]
8283 let _phase = self.benchmark_phases.start(Phase::RowDecode);
8284 decode_row_bytes(table, payload, &self.encryption)
8285 .map_err(|m| ("sync.invalid_request".to_owned(), m))?
8286 };
8287 let version = change.row_version.unwrap_or(0);
8288 let table_name = table.name.clone();
8289 self.write_base_row(&table_name, &row, version)
8290 .map_err(|m| ("sync.invalid_request".to_owned(), m))?;
8291 if mirror_visible {
8292 self.write_row(&visible_table(&table_name), &table_name, &row, version)
8293 .map_err(|m| ("sync.invalid_request".to_owned(), m))?;
8294 }
8295 }
8296 Op::Delete => {
8297 self.delete_base_row(table_name, &change.row_id)
8298 .map_err(|m| ("sync.invalid_request".to_owned(), m))?;
8299 if mirror_visible {
8300 let sql = format!(
8301 "DELETE FROM {} WHERE {}",
8302 visible_table(table_name),
8303 row_id_predicate(table)
8304 );
8305 self.conn
8306 .prepare_cached(&sql)
8307 .and_then(|mut statement| {
8308 statement.execute(rusqlite::params![change.row_id])
8309 })
8310 .map_err(|error| {
8311 ("sync.invalid_request".to_owned(), error.to_string())
8312 })?;
8313 }
8314 }
8315 }
8316 }
8317 if mirror_visible {
8318 if !self.outbox.is_empty() {
8319 let changed_rows: HashSet<(&str, &str)> = changes
8320 .iter()
8321 .map(|change| {
8322 (
8323 tables[change.table_index as usize].as_str(),
8324 change.row_id.as_str(),
8325 )
8326 })
8327 .collect();
8328 self.apply_outbox_ops(
8329 self.outbox
8330 .iter()
8331 .flat_map(|commit| &commit.ops)
8332 .filter(|op| {
8333 changed_rows.contains(&(op.table.as_str(), op.row_id.as_str()))
8334 }),
8335 );
8336 }
8337 self.overlay_dirty.set(false);
8338 }
8339 Ok(())
8340 }
8341
8342 fn apply_segment(
8347 &mut self,
8348 sub_index: usize,
8349 segment: &RowsSegment,
8350 first_fresh_page: bool,
8351 ) -> Result<u32, SectionError> {
8352 let (sub_table, effective) = {
8353 let sub = &self.subs[sub_index];
8354 (sub.table.clone(), sub.effective.clone().unwrap_or_default())
8355 };
8356 let table = self.schema.table(&sub_table).cloned().ok_or_else(|| {
8357 SectionError::Abort(
8358 "sync.schema_mismatch".to_owned(),
8359 format!("subscription table {sub_table:?} is not in the client schema"),
8360 )
8361 })?;
8362 let matches = segment.table == table.name
8367 && segment.schema_version == self.schema.version
8368 && segment.columns.len() == table.wire_columns.len()
8369 && segment
8370 .columns
8371 .iter()
8372 .zip(table.wire_columns.iter())
8373 .all(|(a, b)| a.name == b.name && a.ty == b.ty && a.nullable == b.nullable);
8374 if !matches {
8375 return Err(SectionError::Abort(
8376 "sync.schema_mismatch".to_owned(),
8377 "segment column table does not match the generated schema (§5.2)".to_owned(),
8378 ));
8379 }
8380 let mut applied = 0u32;
8381 let empty = Vec::new();
8382 let blocks = if segment.blocks.is_empty() {
8383 std::slice::from_ref(&empty)
8384 } else {
8385 &segment.blocks
8386 };
8387 for (index, block) in blocks.iter().enumerate() {
8388 let clear = first_fresh_page && index == 0;
8389 if !clear && block.is_empty() {
8390 continue;
8391 }
8392 let was_dirty = self.overlay_dirty.get();
8393 self.begin_observation("syncular_segment_block")
8394 .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
8395 let outcome = (|| {
8396 let mut batch = ChangeAccumulator::default();
8397 if !block.is_empty() || (clear && self.scoped_rows_exist(&table.name, &effective)) {
8398 batch.table(&table.name);
8399 }
8400 if clear {
8401 self.purge_scope_rows(&table.name, &effective)
8402 .map_err(|()| SectionError::FailClosed)?;
8403 }
8404 for row in block {
8405 let decrypted;
8406 let values = if table.has_encrypted_columns() {
8407 let mut values = row.values.clone();
8408 crate::values::decrypt_segment_row(&table, &mut values, &self.encryption)
8409 .map_err(|message| {
8410 SectionError::Abort("client.decrypt_failed".into(), message)
8411 })?;
8412 decrypted = values;
8413 &decrypted
8414 } else {
8415 &row.values
8416 };
8417 self.write_base_row(&table.name, values, row.server_version)
8418 .map_err(|message| {
8419 SectionError::Abort("sync.invalid_request".into(), message)
8420 })?;
8421 }
8422 self.rebuild_overlay_if_dirty();
8423 self.finish_observation("syncular_segment_block", batch)
8424 .map_err(|message| SectionError::Abort("storage.failed".into(), message))
8425 })();
8426 if let Err(error) = outcome {
8427 self.rollback_observation("syncular_segment_block");
8428 self.overlay_dirty.set(was_dirty);
8429 return Err(error);
8430 }
8431 applied += block.len() as u32;
8432 }
8433 Ok(applied)
8434 }
8435
8436 fn apply_sqlite_segment(
8444 &mut self,
8445 sub_index: usize,
8446 bytes: &[u8],
8447 first_fresh_page: bool,
8448 row_count: i64,
8449 as_of_commit_seq: i64,
8450 scope_digest: &str,
8451 ) -> Result<u32, SectionError> {
8452 let invalid = |detail: &str| {
8453 SectionError::Abort(
8454 "sync.invalid_request".to_owned(),
8455 format!("sqlite segment rejected: {detail} (§5.3)"),
8456 )
8457 };
8458 let (sub_table, effective) = {
8459 let sub = &self.subs[sub_index];
8460 (sub.table.clone(), sub.effective.clone().unwrap_or_default())
8461 };
8462 let table = self.schema.table(&sub_table).cloned().ok_or_else(|| {
8463 SectionError::Abort(
8464 "sync.schema_mismatch".to_owned(),
8465 format!("subscription table {sub_table:?} is not in the client schema"),
8466 )
8467 })?;
8468
8469 let path = std::env::temp_dir().join(format!("syncular-image-{}.db", uuid::Uuid::new_v4()));
8470 std::fs::write(&path, bytes).map_err(|_| invalid("image temp file write failed"))?;
8471 let img = match rusqlite::Connection::open_with_flags(
8472 &path,
8473 rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY,
8474 ) {
8475 Ok(conn) => conn,
8476 Err(_) => {
8477 let _ = std::fs::remove_file(&path);
8478 return Err(invalid("bytes do not open as a SQLite database"));
8479 }
8480 };
8481 let was_dirty = self.overlay_dirty.get();
8482 let outcome = (|| {
8483 self.begin_observation("syncular_image")
8484 .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
8485 let mut batch = ChangeAccumulator::default();
8486 let cleared = first_fresh_page && self.scoped_rows_exist(&table.name, &effective);
8487 let result = self.apply_sqlite_image(
8488 &img,
8489 &table,
8490 first_fresh_page,
8491 &effective,
8492 row_count,
8493 as_of_commit_seq,
8494 scope_digest,
8495 )?;
8496 if result > 0 || cleared {
8497 batch.table(&table.name);
8498 }
8499 self.rebuild_overlay_if_dirty();
8500 self.finish_observation("syncular_image", batch)
8501 .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
8502 Ok(result)
8503 })();
8504 if outcome.is_err() {
8505 self.rollback_observation("syncular_image");
8506 self.overlay_dirty.set(was_dirty);
8507 }
8508 drop(img);
8509 let _ = std::fs::remove_file(&path);
8510 outcome
8511 }
8512
8513 #[allow(clippy::too_many_arguments)]
8514 fn apply_sqlite_image(
8515 &mut self,
8516 img: &rusqlite::Connection,
8517 table: &crate::schema::TableSchema,
8518 first_fresh_page: bool,
8519 effective: &[(String, Vec<String>)],
8520 row_count: i64,
8521 as_of_commit_seq: i64,
8522 scope_digest: &str,
8523 ) -> Result<u32, SectionError> {
8524 let invalid = |detail: String| {
8525 SectionError::Abort(
8526 "sync.invalid_request".to_owned(),
8527 format!("sqlite segment rejected: {detail} (§5.3)"),
8528 )
8529 };
8530
8531 type MetaRow = (i64, String, i64, i64, String, i64, i64);
8534 let meta: MetaRow = img
8535 .query_row(
8536 "SELECT format, \"table\", \"schemaVersion\", \"asOfCommitSeq\",
8537 \"scopeDigest\", \"rowCount\",
8538 (SELECT count(*) FROM _syncular_segment)
8539 FROM _syncular_segment",
8540 [],
8541 |row| {
8542 Ok((
8543 row.get(0)?,
8544 row.get(1)?,
8545 row.get(2)?,
8546 row.get(3)?,
8547 row.get(4)?,
8548 row.get(5)?,
8549 row.get(6)?,
8550 ))
8551 },
8552 )
8553 .map_err(|_| invalid("missing or unreadable _syncular_segment metadata".to_owned()))?;
8554 let (format, meta_table, schema_version, pin, digest, meta_rows, meta_count) = meta;
8555 if meta_count != 1 {
8556 return Err(invalid(format!(
8557 "_syncular_segment must contain exactly one row, found {meta_count}"
8558 )));
8559 }
8560 if format != 1 {
8561 return Err(invalid(format!("format {format}")));
8562 }
8563 if meta_table != table.name {
8564 return Err(invalid(format!("image table {meta_table:?}")));
8565 }
8566 if schema_version != i64::from(self.schema.version) {
8567 return Err(invalid(format!("schemaVersion {schema_version}")));
8568 }
8569 if pin != as_of_commit_seq {
8570 return Err(invalid(format!("asOfCommitSeq {pin}")));
8571 }
8572 if digest != scope_digest {
8573 return Err(invalid("scopeDigest mismatch".to_owned()));
8574 }
8575 if meta_rows != row_count {
8576 return Err(invalid(format!("rowCount {meta_rows}")));
8577 }
8578
8579 let mut names: Vec<String> = Vec::new();
8581 {
8582 let mut stmt = img
8583 .prepare(&format!("PRAGMA table_info({})", quote_ident(&table.name)))
8584 .map_err(|_| invalid("image data table missing".to_owned()))?;
8585 let mut rows = stmt
8586 .query([])
8587 .map_err(|_| invalid("image data table unreadable".to_owned()))?;
8588 while let Some(row) = rows
8589 .next()
8590 .map_err(|_| invalid("image data table unreadable".to_owned()))?
8591 {
8592 names.push(
8593 row.get::<_, String>(1)
8594 .map_err(|_| invalid("image data table unreadable".to_owned()))?,
8595 );
8596 }
8597 }
8598 let mut expected: Vec<&str> = table.columns.iter().map(|c| c.name.as_str()).collect();
8599 expected.push("_syncular_version");
8600 if names.len() != expected.len() || names.iter().zip(expected.iter()).any(|(a, b)| a != b) {
8601 return Err(SectionError::Abort(
8602 "sync.schema_mismatch".to_owned(),
8603 "sqlite segment columns do not match the generated schema (§5.3)".to_owned(),
8604 ));
8605 }
8606
8607 if first_fresh_page {
8610 self.purge_scope_rows(&table.name, effective)
8611 .map_err(|()| SectionError::FailClosed)?;
8612 }
8613 self.overlay_dirty.set(true);
8619 let bulk_indexes: Vec<&crate::schema::IndexSchema> = if first_fresh_page {
8627 table.indexes.iter().filter(|i| !i.unique).collect()
8628 } else {
8629 Vec::new()
8630 };
8631 for index in &bulk_indexes {
8632 let index_name = quote_ident(&format!("_syncular_base_{}", index.name));
8633 self.conn
8634 .execute(&format!("DROP INDEX IF EXISTS {index_name}"), [])
8635 .map_err(|e| invalid(e.to_string()))?;
8636 }
8637 let insert = self.insert_row_sql(&base_table(&table.name), table);
8638 let applied = {
8639 let mut ins = self
8640 .conn
8641 .prepare_cached(&insert)
8642 .map_err(|e| invalid(e.to_string()))?;
8643 let column_list: Vec<String> = names.iter().map(|n| quote_ident(n)).collect();
8644 let mut stmt = img
8645 .prepare(&format!(
8646 "SELECT {} FROM {}",
8647 column_list.join(", "),
8648 quote_ident(&table.name)
8649 ))
8650 .map_err(|_| invalid("image data table unreadable".to_owned()))?;
8651 let mut rows = stmt
8652 .query([])
8653 .map_err(|_| invalid("image data table unreadable".to_owned()))?;
8654 let version_index = table.columns.len();
8655 let mut applied = 0u32;
8656 while let Some(row) = rows
8657 .next()
8658 .map_err(|_| invalid("image row unreadable".to_owned()))?
8659 {
8660 for (i, column) in table.columns.iter().enumerate() {
8661 let cell = row
8662 .get_ref(i)
8663 .map_err(|_| invalid("image row unreadable".to_owned()))?;
8664 let param = image_cell_param(column, cell).map_err(&invalid)?;
8665 ins.raw_bind_parameter(i + 1, param)
8666 .map_err(|e| invalid(e.to_string()))?;
8667 }
8668 let version: i64 = row
8669 .get(version_index)
8670 .map_err(|_| invalid("image row unreadable".to_owned()))?;
8671 if version < 1 {
8672 return Err(invalid(format!(
8673 "row _syncular_version must be >= 1, got {version}"
8674 )));
8675 }
8676 ins.raw_bind_parameter(version_index + 1, version)
8677 .map_err(|e| invalid(e.to_string()))?;
8678 ins.raw_execute().map_err(|e| invalid(e.to_string()))?;
8679 applied += 1;
8680 }
8681 applied
8682 };
8683 for index in &bulk_indexes {
8684 let index_name = quote_ident(&format!("_syncular_base_{}", index.name));
8685 let cols_sql = index
8686 .columns
8687 .iter()
8688 .map(|c| quote_ident(c))
8689 .collect::<Vec<_>>()
8690 .join(", ");
8691 self.conn
8692 .execute(
8693 &format!(
8694 "CREATE INDEX IF NOT EXISTS {index_name} ON {} ({cols_sql})",
8695 base_table(&table.name)
8696 ),
8697 [],
8698 )
8699 .map_err(|e| invalid(e.to_string()))?;
8700 }
8701 if i64::from(applied) != row_count {
8702 return Err(invalid(format!(
8703 "image holds {applied} rows, descriptor says {row_count}"
8704 )));
8705 }
8706 Ok(applied)
8707 }
8708
8709 fn compile_local_data_purge(
8712 &self,
8713 input: &LocalDataPurgeInput,
8714 ) -> Result<(Vec<CompiledLocalDataPurgeTarget>, String), String> {
8715 let invalid = |message: String| format!("sync.invalid_request: {message}");
8716 if input.purge_id.is_empty()
8717 || input.purge_id.len() > 128
8718 || !is_local_operation_code_like(&input.purge_id)
8719 {
8720 return Err(invalid(
8721 "local purge purgeId must be a 1–128 character code-like identifier".to_owned(),
8722 ));
8723 }
8724 if input.targets.is_empty() || input.targets.len() > MAX_LOCAL_PURGE_TARGETS {
8725 return Err(invalid(format!(
8726 "local purge needs between 1 and {MAX_LOCAL_PURGE_TARGETS} targets"
8727 )));
8728 }
8729
8730 let mut deduplicated: BTreeMap<
8731 String,
8732 (CompiledLocalDataPurgeTarget, LocalDataPurgeTarget),
8733 > = BTreeMap::new();
8734 for target in &input.targets {
8735 let table = self.schema.table(&target.table).ok_or_else(|| {
8736 invalid(format!(
8737 "local purge names unknown table {:?}",
8738 target.table
8739 ))
8740 })?;
8741 if target.selectors.is_empty() || target.selectors.len() > MAX_LOCAL_PURGE_SELECTORS {
8742 return Err(invalid(format!(
8743 "local purge target {:?} needs between 1 and {MAX_LOCAL_PURGE_SELECTORS} selectors",
8744 target.table
8745 )));
8746 }
8747 let mut selectors = Vec::with_capacity(target.selectors.len());
8748 let mut canonical_selectors = BTreeMap::new();
8749 for (column_name, raw_values) in &target.selectors {
8750 let Some((column_index, column)) = table
8751 .columns
8752 .iter()
8753 .enumerate()
8754 .find(|(_, column)| column.name == *column_name)
8755 else {
8756 return Err(invalid(format!(
8757 "local purge target {:?} names unknown column {:?}",
8758 target.table, column_name
8759 )));
8760 };
8761 let encrypted = table
8762 .encrypted_columns
8763 .iter()
8764 .any(|candidate| candidate.index == column_index);
8765 if column.ty != ColumnType::String || encrypted {
8766 return Err(invalid(format!(
8767 "local purge selector {:?}.{:?} must be a plaintext string column",
8768 target.table, column_name
8769 )));
8770 }
8771 if raw_values.is_empty() || raw_values.len() > MAX_LOCAL_PURGE_VALUES {
8772 return Err(invalid(format!(
8773 "local purge selector {:?}.{:?} needs between 1 and {MAX_LOCAL_PURGE_VALUES} values",
8774 target.table, column_name
8775 )));
8776 }
8777 let mut values = raw_values.clone();
8778 values.sort();
8779 values.dedup();
8780 if values.iter().any(|value| {
8781 value.is_empty()
8782 || value.len() > MAX_LOCAL_PURGE_VALUE_LENGTH
8783 || !is_local_operation_code_like(value)
8784 }) {
8785 return Err(invalid(format!(
8786 "local purge selector values must be 1–{MAX_LOCAL_PURGE_VALUE_LENGTH} character code-like identifiers"
8787 )));
8788 }
8789 selectors.push((column_name.clone(), values.clone()));
8790 canonical_selectors.insert(column_name.clone(), values);
8791 }
8792 let canonical = LocalDataPurgeTarget {
8793 table: target.table.clone(),
8794 selectors: canonical_selectors,
8795 };
8796 let key = serde_json::to_string(&canonical).map_err(|error| error.to_string())?;
8797 deduplicated.insert(
8798 key,
8799 (
8800 CompiledLocalDataPurgeTarget {
8801 table: target.table.clone(),
8802 selectors,
8803 },
8804 canonical,
8805 ),
8806 );
8807 }
8808 let targets = deduplicated
8809 .values()
8810 .map(|(compiled, _)| compiled.clone())
8811 .collect::<Vec<_>>();
8812 let canonical = deduplicated
8813 .values()
8814 .map(|(_, target)| target.clone())
8815 .collect::<Vec<_>>();
8816 let canonical_plan =
8817 serde_json::to_string(&canonical).map_err(|error| error.to_string())?;
8818 Ok((targets, canonical_plan))
8819 }
8820
8821 fn local_purge_base_row_ids(
8822 &self,
8823 targets: &[CompiledLocalDataPurgeTarget],
8824 ) -> Result<BTreeMap<String, BTreeSet<String>>, String> {
8825 let mut by_table: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
8826 for target in targets {
8827 let table = self
8828 .schema
8829 .table(&target.table)
8830 .ok_or_else(|| format!("sync.invalid_request: unknown table {:?}", target.table))?;
8831 let mut clauses = Vec::with_capacity(target.selectors.len());
8832 let mut params = Vec::new();
8833 for (column, values) in &target.selectors {
8834 clauses.push(format!(
8835 "{} IN ({})",
8836 quote_ident(column),
8837 values.iter().map(|_| "?").collect::<Vec<_>>().join(", ")
8838 ));
8839 params.extend(values.iter().cloned().map(SqlValue::Text));
8840 }
8841 let sql = format!(
8842 "SELECT CAST({} AS TEXT) FROM {} WHERE {}",
8843 quote_ident(&table.primary_key),
8844 base_table(&target.table),
8845 clauses.join(" AND ")
8846 );
8847 let mut statement = self.conn.prepare(&sql).map_err(|error| error.to_string())?;
8848 let rows = statement
8849 .query_map(rusqlite::params_from_iter(params), |row| {
8850 row.get::<_, String>(0)
8851 })
8852 .map_err(|error| error.to_string())?;
8853 let ids = by_table.entry(target.table.clone()).or_default();
8854 for row in rows {
8855 ids.insert(row.map_err(|error| error.to_string())?);
8856 }
8857 }
8858 Ok(by_table)
8859 }
8860
8861 fn local_purge_values_match(
8862 target: &CompiledLocalDataPurgeTarget,
8863 values: &Map<String, Value>,
8864 ) -> bool {
8865 target.selectors.iter().all(|(column, allowed)| {
8866 values
8867 .get(column)
8868 .and_then(Value::as_str)
8869 .is_some_and(|value| allowed.iter().any(|candidate| candidate == value))
8870 })
8871 }
8872
8873 pub fn purge_local_data(
8877 &mut self,
8878 input: &LocalDataPurgeInput,
8879 ) -> Result<LocalDataPurgeResult, String> {
8880 let (targets, canonical_plan) = self.compile_local_data_purge(input)?;
8881 let meta_key = format!("localPurge:{}", input.purge_id);
8882 let applied_plan = self
8883 .conn
8884 .query_row(
8885 "SELECT value FROM _syncular_meta WHERE key = ?1",
8886 rusqlite::params![meta_key],
8887 |row| row.get::<_, String>(0),
8888 )
8889 .optional()
8890 .map_err(|error| error.to_string())?;
8891 if let Some(applied_plan) = applied_plan {
8892 if applied_plan != canonical_plan {
8893 return Err(format!(
8894 "sync.invalid_request: local purge id {:?} was already used with a different plan",
8895 input.purge_id
8896 ));
8897 }
8898 return Ok(LocalDataPurgeResult {
8899 already_applied: true,
8900 purged_rows: 0,
8901 dropped_commits: 0,
8902 });
8903 }
8904
8905 let prior_outbox = self.outbox.clone();
8906 let prior_rejection_count = self.rejections.len();
8907 let prior_overlay_dirty = self.overlay_dirty.get();
8908 self.begin_observation("syncular_local_purge")?;
8909 let applied = (|| -> Result<(ChangeAccumulator, LocalDataPurgeResult), String> {
8910 let row_ids = self.local_purge_base_row_ids(&targets)?;
8911 let doomed = self
8912 .outbox
8913 .iter()
8914 .filter(|commit| {
8915 commit.ops.iter().any(|operation| {
8916 let matching_targets = targets
8917 .iter()
8918 .filter(|target| target.table == operation.table)
8919 .collect::<Vec<_>>();
8920 if matching_targets.is_empty() {
8921 return false;
8922 }
8923 if row_ids
8924 .get(&operation.table)
8925 .is_some_and(|ids| ids.contains(&operation.row_id))
8926 {
8927 return true;
8928 }
8929 operation.values.as_ref().is_some_and(|values| {
8930 matching_targets
8931 .iter()
8932 .any(|target| Self::local_purge_values_match(target, values))
8933 })
8934 })
8935 })
8936 .cloned()
8937 .collect::<Vec<_>>();
8938 let doomed_ids = doomed
8939 .iter()
8940 .map(|commit| commit.client_commit_id.clone())
8941 .collect::<BTreeSet<_>>();
8942 let mut batch = ChangeAccumulator::default();
8943 let mut rejections = Vec::new();
8944 for commit in &doomed {
8945 for operation in &commit.ops {
8946 batch.table(&operation.table);
8947 }
8948 let results = commit
8949 .ops
8950 .iter()
8951 .enumerate()
8952 .map(|(op_index, operation)| {
8953 let rejection = RejectionRecord {
8954 client_commit_id: commit.client_commit_id.clone(),
8955 op_index: op_index as i32,
8956 code: "client.local_data_purged".to_owned(),
8957 message: "the commit was dropped by an application-authorized local data purge".to_owned(),
8958 retryable: false,
8959 details: None,
8960 operation: Some(CommitOperation::from(operation)),
8961 };
8962 rejections.push(rejection.clone());
8963 CommitOperationOutcome::Error { rejection }
8964 })
8965 .collect::<Vec<_>>();
8966 self.persist_commit_outcome(
8967 &commit.client_commit_id,
8968 CommitOutcomeStatus::Rejected,
8969 &results,
8970 Some(&commit.ops),
8971 )?;
8972 self.delete_outbox_persisted(&commit.client_commit_id)?;
8973 }
8974 if !doomed.is_empty() {
8975 self.outbox
8976 .retain(|commit| !doomed_ids.contains(&commit.client_commit_id));
8977 self.rejections.extend(rejections);
8978 self.prune_commit_outcomes()?;
8979 self.overlay_dirty.set(true);
8980 batch.status = true;
8981 batch.rejections = true;
8982 batch.outcomes = true;
8983 }
8984
8985 let mut purged_rows = 0usize;
8986 for (table, ids) in &row_ids {
8987 if ids.is_empty() {
8988 continue;
8989 }
8990 batch.table(table);
8991 for row_id in ids {
8992 self.delete_base_row(table, row_id)?;
8993 purged_rows += 1;
8994 }
8995 }
8996 self.rebuild_overlay_if_dirty();
8997 self.reconcile_blob_refcounts(true, None);
8998 self.conn
8999 .execute(
9000 "INSERT INTO _syncular_meta(key, value) VALUES (?1, ?2)",
9001 rusqlite::params![meta_key, canonical_plan],
9002 )
9003 .map_err(|error| error.to_string())?;
9004 Ok((
9005 batch,
9006 LocalDataPurgeResult {
9007 already_applied: false,
9008 purged_rows,
9009 dropped_commits: doomed.len(),
9010 },
9011 ))
9012 })();
9013 let (batch, result) = match applied {
9014 Ok(value) => value,
9015 Err(error) => {
9016 self.rollback_observation("syncular_local_purge");
9017 self.outbox = prior_outbox;
9018 self.rejections.truncate(prior_rejection_count);
9019 self.overlay_dirty.set(prior_overlay_dirty);
9020 return Err(error);
9021 }
9022 };
9023 if let Err(error) = self.finish_observation("syncular_local_purge", batch) {
9024 self.rollback_observation("syncular_local_purge");
9025 self.outbox = prior_outbox;
9026 self.rejections.truncate(prior_rejection_count);
9027 self.overlay_dirty.set(prior_overlay_dirty);
9028 return Err(error);
9029 }
9030 Ok(result)
9031 }
9032
9033 pub fn rebootstrap_local_data(
9039 &mut self,
9040 input: &LocalDataRebootstrapInput,
9041 ) -> Result<LocalDataRebootstrapResult, String> {
9042 if input.rebootstrap_id.is_empty()
9043 || input.rebootstrap_id.len() > 128
9044 || !is_local_operation_code_like(&input.rebootstrap_id)
9045 {
9046 return Err(
9047 "sync.invalid_request: local rebootstrap rebootstrapId must be a 1–128 character code-like identifier"
9048 .to_owned(),
9049 );
9050 }
9051 let meta_key = format!("localRebootstrap:{}", input.rebootstrap_id);
9052 if let Some(persisted) = self.get_meta_strict(&meta_key)? {
9053 let (retained_commits, reset_subscriptions) =
9054 decode_local_rebootstrap_receipt(&persisted)?;
9055 return Ok(LocalDataRebootstrapResult {
9056 already_applied: true,
9057 retained_commits,
9058 reset_subscriptions,
9059 });
9060 }
9061 if self.stopped || self.schema_floor.is_some() {
9062 return Err(
9063 "sync.invalid_request: local rebootstrap cannot bypass an active schema-floor stop; update the application first"
9064 .to_owned(),
9065 );
9066 }
9067
9068 let retained_commits = self.outbox.len();
9069 let reset_subscriptions = self.subs.len();
9070 let prior_subs = self.subs.clone();
9071 let prior_upgrading = self.upgrading;
9072 let prior_stopped = self.stopped;
9073 let prior_schema_floor = self.schema_floor.clone();
9074 let prior_overlay_dirty = self.overlay_dirty.get();
9075 let prior_sync_needed = self.sync_needed;
9076 let prior_sync_intents = self.sync_intent_queue.clone();
9077 let receipt = encode_local_rebootstrap_receipt(retained_commits, reset_subscriptions)?;
9078
9079 self.begin_observation("syncular_local_rebootstrap")?;
9080 let mut batch = ChangeAccumulator::default();
9081 let applied = (|| -> Result<(), String> {
9082 self.run_schema_reset_observed(&mut batch, false)?;
9083 self.conn
9084 .execute(
9085 "INSERT INTO _syncular_meta(key, value) VALUES (?1, ?2)",
9086 rusqlite::params![meta_key, receipt],
9087 )
9088 .map_err(|error| error.to_string())?;
9089 self.sync_needed = true;
9090 self.sync_intent_queue.push_back(SyncIntent::Interactive);
9091 batch.status = true;
9092 Ok(())
9093 })();
9094 if let Err(error) = applied {
9095 self.rollback_observation("syncular_local_rebootstrap");
9096 self.subs = prior_subs;
9097 self.upgrading = prior_upgrading;
9098 self.stopped = prior_stopped;
9099 self.schema_floor = prior_schema_floor;
9100 self.overlay_dirty.set(prior_overlay_dirty);
9101 self.sync_needed = prior_sync_needed;
9102 self.sync_intent_queue = prior_sync_intents;
9103 return Err(error);
9104 }
9105 if let Err(error) = self.finish_observation("syncular_local_rebootstrap", batch) {
9106 self.rollback_observation("syncular_local_rebootstrap");
9107 self.subs = prior_subs;
9108 self.upgrading = prior_upgrading;
9109 self.stopped = prior_stopped;
9110 self.schema_floor = prior_schema_floor;
9111 self.overlay_dirty.set(prior_overlay_dirty);
9112 self.sync_needed = prior_sync_needed;
9113 self.sync_intent_queue = prior_sync_intents;
9114 return Err(error);
9115 }
9116
9117 Ok(LocalDataRebootstrapResult {
9118 already_applied: false,
9119 retained_commits,
9120 reset_subscriptions,
9121 })
9122 }
9123
9124 fn purge_scope_rows(
9129 &mut self,
9130 table_name: &str,
9131 effective: &[(String, Vec<String>)],
9132 ) -> Result<(), ()> {
9133 if effective.is_empty() {
9134 return Ok(());
9135 }
9136 let table = self.schema.table(table_name).ok_or(())?.clone();
9137 let mut clauses = Vec::new();
9138 let mut params: Vec<SqlValue> = Vec::new();
9139 for (variable, values) in effective {
9140 let column = table.scope_column(variable).ok_or(())?;
9141 let placeholders: Vec<String> = values
9142 .iter()
9143 .map(|v| {
9144 params.push(SqlValue::Text(v.clone()));
9145 "?".to_owned()
9146 })
9147 .collect();
9148 clauses.push(format!(
9149 "{} IN ({})",
9150 quote_ident(column),
9151 placeholders.join(", ")
9152 ));
9153 }
9154 let sql = format!(
9155 "DELETE FROM {} WHERE {}",
9156 base_table(table_name),
9157 clauses.join(" AND ")
9158 );
9159 self.overlay_dirty.set(true);
9160 self.conn
9161 .execute(&sql, rusqlite::params_from_iter(params))
9162 .map_err(|_| ())?;
9163 Ok(())
9164 }
9165
9166 fn drop_doomed_outbox(
9169 &mut self,
9170 table_name: &str,
9171 effective: &[(String, Vec<String>)],
9172 ) -> Result<bool, String> {
9173 if effective.is_empty() {
9174 return Ok(false);
9175 }
9176 let Some(table) = self.schema.table(table_name).cloned() else {
9177 return Ok(false);
9178 };
9179 let mut mappings: Vec<(&str, &Vec<String>)> = Vec::new();
9180 for (variable, values) in effective {
9181 match table.scope_column(variable) {
9182 Some(column) => mappings.push((column, values)),
9183 None => return Ok(false), }
9185 }
9186 let doomed: Vec<OutboxCommit> = self
9187 .outbox
9188 .iter()
9189 .filter(|commit| {
9190 commit.ops.iter().any(|op| {
9191 op.upsert
9192 && op.table == table_name
9193 && op.values.as_ref().is_some_and(|values| {
9194 mappings
9195 .iter()
9196 .all(|(column, allowed)| match values.get(*column) {
9197 Some(Value::String(s)) => allowed.contains(s),
9198 Some(Value::Number(n)) => allowed.contains(&n.to_string()),
9199 _ => false,
9200 })
9201 })
9202 })
9203 })
9204 .cloned()
9205 .collect();
9206 if doomed.is_empty() {
9207 return Ok(false);
9208 }
9209 let mut rejections = Vec::new();
9210 for commit in &doomed {
9211 let results = commit
9212 .ops
9213 .iter()
9214 .enumerate()
9215 .map(|(op_index, operation)| {
9216 let rejection = RejectionRecord {
9217 client_commit_id: commit.client_commit_id.clone(),
9218 op_index: op_index as i32,
9219 code: "sync.scope_revoked".to_owned(),
9220 message: "the commit was dropped because its effective scope was revoked"
9221 .to_owned(),
9222 retryable: false,
9223 details: None,
9224 operation: Some(CommitOperation::from(operation)),
9225 };
9226 rejections.push(rejection.clone());
9227 CommitOperationOutcome::Error { rejection }
9228 })
9229 .collect::<Vec<_>>();
9230 self.persist_commit_outcome(
9231 &commit.client_commit_id,
9232 CommitOutcomeStatus::Rejected,
9233 &results,
9234 Some(&commit.ops),
9235 )?;
9236 self.delete_outbox_persisted(&commit.client_commit_id)?;
9237 }
9238 self.prune_commit_outcomes()?;
9239 let doomed_ids = doomed
9240 .iter()
9241 .map(|commit| commit.client_commit_id.as_str())
9242 .collect::<BTreeSet<_>>();
9243 self.outbox
9244 .retain(|commit| !doomed_ids.contains(commit.client_commit_id.as_str()));
9245 self.rejections.extend(rejections);
9246 self.overlay_dirty.set(true);
9247 Ok(true)
9248 }
9249
9250 pub fn upload_blob(
9257 &mut self,
9258 bytes: &[u8],
9259 media_type: Option<String>,
9260 name: Option<String>,
9261 ) -> Result<Value, String> {
9262 let blob_id = blob_id_for(bytes);
9263 let now = self.clock_now_ms();
9264 let transaction = self.conn.transaction().map_err(|e| e.to_string())?;
9265 transaction
9266 .execute(
9267 "INSERT INTO _syncular_blobs(blob_id, bytes, byte_length, media_type, refcount, created_at_ms, last_used_ms) VALUES (?,?,?,?,0,?,?)
9268 ON CONFLICT(blob_id) DO UPDATE SET last_used_ms = excluded.last_used_ms",
9269 rusqlite::params![blob_id, bytes, bytes.len() as i64, media_type, now, now],
9270 )
9271 .map_err(|e| e.to_string())?;
9272 transaction
9273 .execute(
9274 "INSERT OR IGNORE INTO _syncular_blob_uploads(blob_id, media_type, created_at_ms) VALUES (?,?,?)",
9275 rusqlite::params![blob_id, media_type, now],
9276 )
9277 .map_err(|e| e.to_string())?;
9278 transaction.commit().map_err(|e| e.to_string())?;
9279 self.enforce_blob_cache_cap();
9282 let mut obj = Map::new();
9283 obj.insert("blobId".to_owned(), Value::from(blob_id));
9284 obj.insert("byteLength".to_owned(), Value::from(bytes.len() as i64));
9285 if let Some(mt) = media_type {
9286 obj.insert("mediaType".to_owned(), Value::from(mt));
9287 }
9288 if let Some(n) = name {
9289 obj.insert("name".to_owned(), Value::from(n));
9290 }
9291 Ok(Value::Object(obj))
9292 }
9293
9294 pub fn fetch_blob_bytes(
9298 &mut self,
9299 transport: &mut dyn Transport,
9300 blob_id_or_ref: &str,
9301 ) -> Result<FetchedBlob, (String, String)> {
9302 let simple = |m: String| ("client.failed".to_owned(), m);
9303 let blob_id = if blob_id_or_ref.starts_with("sha256:") {
9304 blob_id_or_ref.to_owned()
9305 } else {
9306 let value: Value = serde_json::from_str(blob_id_or_ref)
9307 .map_err(|_| simple("blob ref is not JSON".to_owned()))?;
9308 value
9309 .get("blobId")
9310 .and_then(Value::as_str)
9311 .ok_or_else(|| simple("blob ref has no blobId".to_owned()))?
9312 .to_owned()
9313 };
9314 if let Some(cached) = self.get_cached_blob_bytes(&blob_id).map_err(simple)? {
9315 return Ok(cached);
9316 }
9317 #[cfg(feature = "bench-internals")]
9323 let download_phase = self.benchmark_phases.start(Phase::BlobDownload);
9324 let bytes = match transport
9325 .blob_download(&blob_id)
9326 .map_err(|e| (e.code, e.message))?
9327 {
9328 BlobDownload::Bytes(bytes) => bytes,
9329 BlobDownload::Url {
9330 url,
9331 url_expires_at_ms,
9332 } => {
9333 if url_expires_at_ms.is_some_and(|exp| exp <= self.clock_now_ms()) {
9335 return Err((
9336 "sync.segment_expired".to_owned(),
9337 format!(
9338 "blob url for {blob_id} expired before fetch — re-request mints a fresh url (§5.9.5)"
9339 ),
9340 ));
9341 }
9342 transport
9343 .fetch_blob_url(&url)
9344 .map_err(|e| (e.code, e.message))?
9345 }
9346 };
9347 #[cfg(feature = "bench-internals")]
9348 drop(download_phase);
9349 #[cfg(feature = "bench-internals")]
9350 let validate_phase = self.benchmark_phases.start(Phase::BlobValidate);
9351 if blob_id_for(&bytes) != blob_id {
9353 return Err(simple(format!(
9354 "blob content address mismatch for {blob_id}"
9355 )));
9356 }
9357 #[cfg(feature = "bench-internals")]
9358 drop(validate_phase);
9359 #[cfg(feature = "bench-internals")]
9360 let insert_phase = self.benchmark_phases.start(Phase::BlobCacheInsert);
9361 let now = self.clock_now_ms();
9362 self.conn
9363 .execute(
9364 "INSERT OR IGNORE INTO _syncular_blobs(blob_id, bytes, byte_length, media_type, refcount, created_at_ms, last_used_ms) VALUES (?,?,?,NULL,0,?,?)",
9365 rusqlite::params![blob_id, bytes, bytes.len() as i64, now, now],
9366 )
9367 .map_err(|e| simple(e.to_string()))?;
9368 self.reconcile_blob_refcounts(false, Some(&blob_id));
9371 self.enforce_blob_cache_cap();
9372 #[cfg(feature = "bench-internals")]
9373 drop(insert_phase);
9374 self.get_cached_blob_bytes(&blob_id)
9375 .map_err(simple)?
9376 .ok_or_else(|| simple("blob cache write failed".to_owned()))
9377 }
9378
9379 fn get_cached_blob_bytes(&self, blob_id: &str) -> Result<Option<FetchedBlob>, String> {
9380 #[cfg(feature = "bench-internals")]
9381 let _phase = self.benchmark_phases.start(Phase::BlobCacheRead);
9382 let _ = self.conn.execute(
9385 "UPDATE _syncular_blobs SET last_used_ms = ? WHERE blob_id = ?",
9386 rusqlite::params![self.clock_now_ms(), blob_id],
9387 );
9388 let mut stmt = self
9389 .conn
9390 .prepare("SELECT bytes, byte_length, media_type FROM _syncular_blobs WHERE blob_id = ?")
9391 .map_err(|e| e.to_string())?;
9392 let mut rows = stmt
9393 .query(rusqlite::params![blob_id])
9394 .map_err(|e| e.to_string())?;
9395 if let Some(row) = rows.next().map_err(|e| e.to_string())? {
9396 let bytes: Vec<u8> = row.get(0).map_err(|e| e.to_string())?;
9397 let byte_length: i64 = row.get(1).map_err(|e| e.to_string())?;
9398 let media_type: Option<String> = row.get(2).map_err(|e| e.to_string())?;
9399 return Ok(Some(FetchedBlob {
9400 blob_id: blob_id.to_owned(),
9401 byte_length,
9402 bytes,
9403 media_type,
9404 }));
9405 }
9406 Ok(None)
9407 }
9408
9409 fn flush_blob_uploads(&mut self, transport: &mut dyn Transport) -> Result<(), TransportError> {
9411 let pending: Vec<(String, Option<String>)> = {
9412 let mut stmt = self
9413 .conn
9414 .prepare(
9415 "SELECT blob_id, media_type FROM (
9416 SELECT blob_id, media_type, created_at_ms FROM _syncular_blob_uploads
9417 UNION ALL
9418 SELECT DISTINCT r.blob_id, b.media_type, 9223372036854775807 AS created_at_ms
9419 FROM _syncular_blob_commit_refs r LEFT JOIN _syncular_blobs b ON b.blob_id = r.blob_id
9420 WHERE NOT EXISTS (SELECT 1 FROM _syncular_blob_uploads u WHERE u.blob_id = r.blob_id)
9421 ) ORDER BY created_at_ms, blob_id",
9422 )
9423 .map_err(|e| TransportError::new("client.failed", e.to_string()))?;
9424 let rows = stmt
9425 .query_map([], |row| {
9426 Ok((row.get::<_, String>(0)?, row.get::<_, Option<String>>(1)?))
9427 })
9428 .map_err(|e| TransportError::new("client.failed", e.to_string()))?;
9429 rows.collect::<Result<_, _>>()
9430 .map_err(|error| match error {
9431 rusqlite::Error::InvalidColumnType(..)
9432 | rusqlite::Error::FromSqlConversionFailure(..) => TransportError::new(
9433 "sync.local_corrupt",
9434 "Pending blob upload metadata is invalid",
9435 ),
9436 _ => TransportError::new("client.failed", error.to_string()),
9437 })?
9438 };
9439 for (blob_id, media_type) in pending {
9440 let stored: Option<(SqlValue, SqlValue)> = self
9441 .conn
9442 .query_row(
9443 "SELECT bytes, byte_length FROM _syncular_blobs WHERE blob_id = ?",
9444 rusqlite::params![blob_id],
9445 |row| Ok((row.get(0)?, row.get(1)?)),
9446 )
9447 .optional()
9448 .map_err(|e| TransportError::new("client.failed", e.to_string()))?;
9449 let Some((SqlValue::Blob(bytes), SqlValue::Integer(byte_length))) = stored else {
9450 return Err(TransportError::new(
9451 "sync.local_corrupt",
9452 "Pending blob upload body is missing or corrupt",
9453 ));
9454 };
9455 if byte_length != bytes.len() as i64 || blob_id_for(&bytes) != blob_id {
9456 return Err(TransportError::new(
9457 "sync.local_corrupt",
9458 "Pending blob upload body is missing or corrupt",
9459 ));
9460 }
9461 self.upload_one(transport, &blob_id, &bytes, media_type.as_deref())?;
9462 self.conn
9463 .execute(
9464 "DELETE FROM _syncular_blob_uploads WHERE blob_id = ?",
9465 rusqlite::params![blob_id],
9466 )
9467 .map_err(|e| TransportError::new("client.failed", e.to_string()))?;
9468 }
9469 Ok(())
9470 }
9471
9472 fn upload_one(
9479 &self,
9480 transport: &mut dyn Transport,
9481 blob_id: &str,
9482 bytes: &[u8],
9483 media_type: Option<&str>,
9484 ) -> Result<(), TransportError> {
9485 match transport.blob_upload_grant(blob_id, bytes.len() as u64, media_type)? {
9486 BlobUploadGrant::Present => return Ok(()), BlobUploadGrant::Url {
9488 url,
9489 url_expires_at_ms,
9490 } => {
9491 let live = url_expires_at_ms.is_none_or(|exp| exp > self.clock_now_ms());
9492 if live && transport.blob_put_url(&url, bytes, media_type).is_ok() {
9493 return Ok(());
9494 }
9495 }
9497 BlobUploadGrant::None => {
9498 }
9500 }
9501 transport.blob_upload(blob_id, bytes, media_type)
9502 }
9503
9504 fn enforce_blob_cache_cap(&self) {
9513 let Some(max_bytes) = self.limits.blob_cache_max_bytes else {
9514 return;
9515 };
9516 let mut total: i64 = self
9517 .conn
9518 .query_row(
9519 "SELECT COALESCE(SUM(byte_length), 0) FROM _syncular_blobs",
9520 [],
9521 |row| row.get(0),
9522 )
9523 .unwrap_or(0);
9524 if total <= max_bytes {
9525 return;
9526 }
9527 let candidates: Vec<(String, i64)> = {
9528 let Ok(mut stmt) = self.conn.prepare(
9529 "SELECT blob_id, byte_length FROM _syncular_blobs
9530 WHERE refcount = 0
9531 AND blob_id NOT IN (SELECT blob_id FROM _syncular_blob_uploads)
9532 AND blob_id NOT IN (SELECT blob_id FROM _syncular_blob_commit_refs)
9533 ORDER BY last_used_ms ASC, created_at_ms ASC",
9534 ) else {
9535 return;
9536 };
9537 let Ok(rows) = stmt.query_map([], |row| {
9538 Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)?))
9539 }) else {
9540 return;
9541 };
9542 rows.filter_map(Result::ok).collect()
9543 };
9544 for (blob_id, byte_length) in candidates {
9545 if total <= max_bytes {
9546 break;
9547 }
9548 let _ = self.conn.execute(
9549 "DELETE FROM _syncular_blobs WHERE blob_id = ?",
9550 rusqlite::params![blob_id],
9551 );
9552 total -= byte_length;
9553 }
9554 }
9555
9556 fn reconcile_blob_refcounts(&mut self, delete_orphans: bool, blob_id: Option<&str>) {
9561 #[cfg(feature = "bench-internals")]
9562 let _phase = self.benchmark_phases.start(Phase::BlobReconcile);
9563 if !self.schema_has_blobs() {
9564 return;
9565 }
9566 self.rebuild_overlay_if_dirty();
9569 if let Some(blob_id) = blob_id {
9570 let mut count = 0_i64;
9571 for table in self.schema.tables.clone() {
9572 for column in table.columns.iter().filter(|c| c.ty == ColumnType::BlobRef) {
9573 let identifier = quote_ident(&column.name);
9574 let sql = format!(
9575 "SELECT count(*) FROM {} WHERE CASE WHEN typeof({identifier}) = 'text' AND json_valid({identifier}) THEN json_extract({identifier}, '$.blobId') END = ?",
9576 quote_ident(&table.name),
9577 );
9578 if let Ok(column_count) =
9579 self.conn
9580 .query_row(&sql, rusqlite::params![blob_id], |row| row.get::<_, i64>(0))
9581 {
9582 count += column_count;
9583 }
9584 }
9585 }
9586 let _ = self.conn.execute(
9587 "UPDATE _syncular_blobs SET refcount = ? WHERE blob_id = ?",
9588 rusqlite::params![count, blob_id],
9589 );
9590 return;
9591 }
9592 let mut counts: std::collections::HashMap<String, i64> = std::collections::HashMap::new();
9593 for table in self.schema.tables.clone() {
9594 let blob_cols: Vec<String> = table
9595 .columns
9596 .iter()
9597 .filter(|c| c.ty == ColumnType::BlobRef)
9598 .map(|c| c.name.clone())
9599 .collect();
9600 for column in blob_cols {
9601 let sql = format!(
9602 "SELECT {} FROM {} WHERE {} IS NOT NULL",
9603 quote_ident(&column),
9604 quote_ident(&table.name),
9605 quote_ident(&column)
9606 );
9607 let Ok(mut stmt) = self.conn.prepare(&sql) else {
9608 continue;
9609 };
9610 let Ok(rows) = stmt.query_map([], |row| row.get::<_, Option<String>>(0)) else {
9611 continue;
9612 };
9613 for raw in rows.flatten().flatten() {
9614 if let Ok(value) = serde_json::from_str::<Value>(&raw) {
9615 if let Some(id) = value.get("blobId").and_then(Value::as_str) {
9616 *counts.entry(id.to_owned()).or_insert(0) += 1;
9617 }
9618 }
9619 }
9620 }
9621 }
9622 let _ = self
9623 .conn
9624 .execute("UPDATE _syncular_blobs SET refcount = 0", []);
9625 for (blob_id, count) in &counts {
9626 let _ = self.conn.execute(
9627 "UPDATE _syncular_blobs SET refcount = ? WHERE blob_id = ?",
9628 rusqlite::params![count, blob_id],
9629 );
9630 }
9631 if delete_orphans {
9632 let _ = self.conn.execute(
9633 "DELETE FROM _syncular_blobs WHERE refcount = 0 AND blob_id NOT IN (SELECT blob_id FROM _syncular_blob_uploads)
9634 AND blob_id NOT IN (SELECT blob_id FROM _syncular_blob_commit_refs)",
9635 [],
9636 );
9637 }
9638 }
9639
9640 fn insert_row_sql(&self, full_table: &str, table: &crate::schema::TableSchema) -> String {
9645 if let Some(sql) = self.insert_sql.borrow().get(full_table) {
9646 return sql.clone();
9647 }
9648 let mut columns: Vec<String> = table.columns.iter().map(|c| quote_ident(&c.name)).collect();
9649 columns.push(quote_ident("_syncular_version"));
9650 let placeholders: Vec<&str> = columns.iter().map(|_| "?").collect();
9651 let primary_key = quote_ident(&table.primary_key);
9652 let updates = columns
9653 .iter()
9654 .filter(|column| **column != primary_key)
9655 .map(|column| format!("{column}=excluded.{column}"))
9656 .collect::<Vec<_>>()
9657 .join(", ");
9658 let sql = format!(
9659 "INSERT INTO {full_table} ({}) VALUES ({}) ON CONFLICT ({primary_key}) DO UPDATE SET {updates}",
9660 columns.join(", "),
9661 placeholders.join(", ")
9662 );
9663 self.insert_sql
9664 .borrow_mut()
9665 .insert(full_table.to_owned(), sql.clone());
9666 sql
9667 }
9668
9669 fn write_base_row(&self, table_name: &str, row: &Row, version: i64) -> Result<(), String> {
9670 self.overlay_dirty.set(true);
9671 self.write_row(&base_table(table_name), table_name, row, version)
9672 }
9673
9674 fn write_row(
9675 &self,
9676 full_table: &str,
9677 table_name: &str,
9678 row: &Row,
9679 version: i64,
9680 ) -> Result<(), String> {
9681 #[cfg(feature = "bench-internals")]
9682 let _phase = self.benchmark_phases.start(Phase::RowWrite);
9683 let table = self
9684 .schema
9685 .table(table_name)
9686 .ok_or_else(|| format!("unknown table {table_name:?}"))?;
9687 let sql = self.insert_row_sql(full_table, table);
9688 let mut stmt = self.conn.prepare_cached(&sql).map_err(|e| e.to_string())?;
9689 let params = row
9690 .iter()
9691 .map(RowParam::Cell)
9692 .chain(std::iter::once(RowParam::Version(version)));
9693 stmt.execute(rusqlite::params_from_iter(params))
9694 .map_err(|e| e.to_string())?;
9695 Ok(())
9696 }
9697
9698 fn delete_base_row(&self, table_name: &str, row_id: &str) -> Result<(), String> {
9699 let table = self
9700 .schema
9701 .table(table_name)
9702 .ok_or_else(|| format!("unknown table {table_name:?}"))?;
9703 self.overlay_dirty.set(true);
9704 let sql = format!(
9705 "DELETE FROM {} WHERE {}",
9706 base_table(table_name),
9707 row_id_predicate(table)
9708 );
9709 let mut stmt = self.conn.prepare_cached(&sql).map_err(|e| e.to_string())?;
9710 stmt.execute(rusqlite::params![row_id])
9711 .map_err(|e| e.to_string())?;
9712 Ok(())
9713 }
9714
9715 fn rebuild_overlay_if_dirty(&mut self) {
9718 if self.overlay_dirty.get() {
9719 self.rebuild_overlay();
9720 }
9721 }
9722
9723 fn rebuild_overlay(&mut self) {
9727 #[cfg(feature = "bench-internals")]
9728 let _phase = self.benchmark_phases.start(Phase::OverlayRebuild);
9729 #[cfg(test)]
9730 self.overlay_rebuild_count
9731 .set(self.overlay_rebuild_count.get() + 1);
9732 self.exec("SAVEPOINT syncular_overlay");
9733 for table in self.schema.tables.clone() {
9734 for index in &table.fts_indexes {
9735 let _ = self.drop_fts_triggers(index);
9736 }
9737 let visible = visible_table(&table.name);
9738 let base = base_table(&table.name);
9739 self.exec(&format!("DELETE FROM {visible}"));
9740 self.exec(&format!("INSERT INTO {visible} SELECT * FROM {base}"));
9741 }
9742 for commit in &self.outbox {
9743 self.apply_outbox_ops(&commit.ops);
9744 }
9745 for table in self.schema.tables.clone() {
9746 for index in &table.fts_indexes {
9747 let _ = self.rebuild_fts_projection(&table, index);
9748 let _ = self.create_fts_triggers(&table, index);
9749 }
9750 }
9751 self.exec("RELEASE syncular_overlay");
9752 self.overlay_dirty.set(false);
9753 }
9754
9755 fn apply_outbox_ops<'a>(&self, ops: impl IntoIterator<Item = &'a OutboxOp>) {
9756 #[cfg(feature = "bench-internals")]
9757 let mut phase = self.benchmark_phases.start(Phase::PendingReplay);
9758 for op in ops {
9759 #[cfg(feature = "bench-internals")]
9760 if let Some(phase) = &mut phase {
9761 phase.unit();
9762 }
9763 let Some(table) = self.schema.table(&op.table) else {
9764 continue;
9765 };
9766 if op.upsert {
9767 let Some(values) = op.values.as_ref() else {
9768 continue;
9769 };
9770 let mut row: Row = Vec::with_capacity(table.columns.len());
9771 let mut ok = true;
9772 for column in &table.columns {
9773 match json_to_column_value(column, values.get(&column.name)) {
9774 Ok(v) => row.push(v),
9775 Err(_) => {
9776 ok = false;
9777 break;
9778 }
9779 }
9780 }
9781 if ok {
9782 let _ = self.write_row(&visible_table(&table.name), &table.name, &row, -1);
9783 }
9784 } else {
9785 let sql = format!(
9786 "DELETE FROM {} WHERE {}",
9787 visible_table(&table.name),
9788 row_id_predicate(table)
9789 );
9790 let _ = self
9791 .conn
9792 .prepare_cached(&sql)
9793 .and_then(|mut statement| statement.execute(rusqlite::params![op.row_id]));
9794 }
9795 }
9796 }
9797
9798 fn exec(&self, sql: &str) {
9799 let _ = self.conn.execute_batch(sql);
9800 }
9801
9802 pub fn connect_realtime(&mut self, transport: &mut dyn Transport) -> Result<(), String> {
9805 if self.realtime_connected {
9806 return Ok(());
9807 }
9808 transport
9809 .realtime_connect_for_client(&self.client_id)
9810 .map_err(|e| format!("{}: {}", e.code, e.message))?;
9811 self.realtime_connected = true;
9812 Ok(())
9813 }
9814
9815 pub fn disconnect_realtime(&mut self, transport: &mut dyn Transport) {
9816 if !self.realtime_connected {
9817 return;
9818 }
9819 let _ = transport.realtime_close();
9820 self.realtime_connected = false;
9821 self.presence.clear(); }
9823
9824 pub fn set_presence(
9830 &mut self,
9831 transport: &mut dyn Transport,
9832 scope_key: &str,
9833 doc: Option<&Value>,
9834 ) -> Result<(), String> {
9835 if !self.realtime_connected {
9836 return Err("setPresence requires a connected realtime socket (§8.6)".to_string());
9837 }
9838 let text = encode_presence_publish(scope_key, doc);
9839 transport
9840 .realtime_send(&text)
9841 .map_err(|e| format!("{}: {}", e.code, e.message))
9842 }
9843
9844 pub fn presence(&self, scope_key: &str) -> Vec<PresencePeer> {
9846 self.presence
9847 .get(scope_key)
9848 .map(|peers| peers.values().cloned().collect())
9849 .unwrap_or_default()
9850 }
9851
9852 fn apply_presence(
9854 &mut self,
9855 scope_key: String,
9856 kind: Option<PresenceKind>,
9857 actor_id: Option<String>,
9858 client_id: Option<String>,
9859 doc: Option<Value>,
9860 error: Option<String>,
9861 ) {
9862 if error.is_some() {
9865 return;
9866 }
9867 let (Some(kind), Some(actor_id), Some(client_id)) = (kind, actor_id, client_id) else {
9868 return;
9869 };
9870 let peer_key = format!("{actor_id} {client_id}");
9871 match kind {
9872 PresenceKind::Leave => {
9873 if let Some(peers) = self.presence.get_mut(&scope_key) {
9874 peers.remove(&peer_key);
9875 if peers.is_empty() {
9876 self.presence.remove(&scope_key);
9877 }
9878 }
9879 }
9880 _ => {
9881 let doc = match doc {
9882 Some(Value::Object(_)) => doc.unwrap(),
9883 _ => return,
9884 };
9885 self.presence.entry(scope_key).or_default().insert(
9886 peer_key,
9887 PresencePeer {
9888 actor_id,
9889 client_id,
9890 doc,
9891 },
9892 );
9893 }
9894 }
9895 }
9896
9897 pub fn on_realtime_text(&mut self, text: &str) {
9899 match parse_control(text) {
9900 Ok(ControlMessage::Hello { requires_sync, .. }) => {
9901 if requires_sync {
9902 self.set_sync_needed(true, true);
9904 }
9905 }
9906 Ok(ControlMessage::Presence {
9907 scope_key,
9908 kind,
9909 actor_id,
9910 client_id,
9911 doc,
9912 error,
9913 ..
9914 }) => {
9915 self.apply_presence(scope_key, kind, actor_id, client_id, doc, error);
9916 }
9917 Ok(ControlMessage::Wake { .. }) => {
9918 self.set_sync_needed(true, true);
9920 }
9921 _ => {}
9922 }
9923 }
9924
9925 pub fn on_realtime_binary(&mut self, transport: &mut dyn Transport, bytes: &[u8]) {
9928 if self.stopped {
9929 return;
9930 }
9931 #[cfg(feature = "bench-internals")]
9932 let decode_phase = self.benchmark_phases.start(Phase::ResponseDecode);
9933 let message = match decode_message(bytes) {
9934 Ok(m) if m.msg_kind == MsgKind::Response => m,
9935 _ => {
9936 self.set_sync_needed(true, true);
9937 return;
9938 }
9939 };
9940 #[cfg(feature = "bench-internals")]
9941 drop(decode_phase);
9942 let mut frames = message.frames.into_iter();
9943 let mut applied_cursor: Option<i64> = None;
9944 let mut any_covered = false;
9945 let mut dropped = false;
9946 while let Some(frame) = frames.next() {
9947 let Frame::SubStart {
9948 id,
9949 status,
9950 effective_scopes,
9951 ..
9952 } = frame
9953 else {
9954 continue;
9955 };
9956 let mut body = Vec::new();
9957 let mut next_cursor: Option<i64> = None;
9958 for inner in frames.by_ref() {
9959 match inner {
9960 Frame::SubEnd {
9961 next_cursor: nc, ..
9962 } => {
9963 next_cursor = Some(nc);
9964 break;
9965 }
9966 Frame::Unknown { .. } => {}
9967 other => body.push(other),
9968 }
9969 }
9970 let Some(sub_index) = self.subs.iter().position(|s| s.id == id) else {
9971 dropped = true;
9972 continue;
9973 };
9974 let sub = &self.subs[sub_index];
9975 if status != SubStatus::Active
9978 || sub.state != SubState::Active
9979 || sub.bootstrap_state.is_some()
9980 || !sub.synced_once
9981 {
9982 dropped = true;
9983 continue;
9984 }
9985 if next_cursor.is_some_and(|cursor| cursor <= sub.cursor) {
9986 any_covered = true;
9988 continue;
9989 }
9990 let previous_effective = self.subs[sub_index].effective.clone();
9991 let previous_cursor = self.subs[sub_index].cursor;
9992 self.subs[sub_index].effective = Some(effective_scopes);
9993 let mut failed = false;
9994 for inner in body {
9995 match inner {
9996 Frame::Commit {
9997 tables, changes, ..
9998 } => {
9999 if self.apply_commit_frame(&tables, &changes).is_err() {
10000 failed = true;
10001 break;
10002 }
10003 }
10004 _ => {
10005 failed = true;
10006 break;
10007 }
10008 }
10009 }
10010 let committed = next_cursor.filter(|_| !failed).and_then(|cursor| {
10011 self.apply_sub_end(sub_index, cursor, None)
10012 .ok()
10013 .map(|()| cursor)
10014 });
10015 let Some(next_cursor) = committed else {
10016 self.subs[sub_index].effective = previous_effective;
10017 self.subs[sub_index].cursor = previous_cursor;
10018 dropped = true;
10019 continue;
10020 };
10021 applied_cursor = Some(applied_cursor.map_or(next_cursor, |c| c.max(next_cursor)));
10022 }
10023 if let Some(cursor) = applied_cursor {
10024 self.reconcile_blob_refcounts(false, None);
10025 let ack = format!("{{\"type\":\"ack\",\"cursor\":{cursor}}}");
10027 let _ = transport.realtime_send(&ack);
10028 } else if !any_covered || dropped {
10029 self.set_sync_needed(true, true);
10031 }
10032 }
10033
10034 fn ack_after_pull(&mut self, transport: &mut dyn Transport) {
10038 if !self.realtime_connected {
10039 return;
10040 }
10041 let floor = self
10042 .subs
10043 .iter()
10044 .filter(|s| {
10045 s.state == SubState::Active
10046 && s.bootstrap_state.is_none()
10047 && s.synced_once
10048 && s.cursor >= 0
10049 })
10050 .map(|s| s.cursor)
10051 .min();
10052 if let Some(cursor) = floor {
10053 let ack = format!("{{\"type\":\"ack\",\"cursor\":{cursor}}}");
10054 let _ = transport.realtime_send(&ack);
10055 }
10056 }
10057}