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