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