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