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