1use std::collections::BTreeSet;
28
29use rusqlite::types::ValueRef;
30use rusqlite::{Connection, OptionalExtension};
31use serde::Serialize;
32use serde_json::{json, Map, Value};
33use ssp2::segment::{Column, ColumnType};
34
35use crate::client::{meta_delete, meta_get, meta_set, quote_ident, sql_ref_to_json};
36use crate::schema::ClientSchema;
37use crate::values::{bytes_to_hex, column_value_to_json, json_to_column_value};
38
39pub const LOCAL_SCHEMA_DESCRIPTOR_KEY: &str = "localSchemaDescriptor";
41pub const PREVIOUS_VERSION_CONTEXT_KEY: &str = "previousVersionContext";
45pub const PREVIOUS_VERSION_AUDIT_KEY: &str = "previousVersionAudit";
48
49pub const PREVIOUS_VERSION_CONTAINER_SUFFIX: &str = ".prev-context";
52
53const CONTAINER_TABLE: &str = "syncular_prev_context";
55const CONTAINER_META_TABLE: &str = "_syncular_prev_context_meta";
57
58const DESCRIPTOR_VERSION: i64 = 1;
59const RECORD_VERSION: i64 = 1;
60const COPY_BATCH_ROWS: i64 = 500;
62
63#[derive(Debug, Clone, Copy, PartialEq, Eq)]
65pub struct PreviousVersionContextConfig {
66 pub enabled: bool,
68 pub max_bytes: i64,
69 pub max_rows: i64,
70 pub max_tables: i64,
71 pub max_row_bytes: i64,
72 pub max_age_ms: i64,
77}
78
79impl Default for PreviousVersionContextConfig {
80 fn default() -> Self {
81 Self {
82 enabled: false,
83 max_bytes: 8 * 1024 * 1024,
84 max_rows: 20_000,
85 max_tables: 32,
86 max_row_bytes: 1024 * 1024,
87 max_age_ms: 24 * 60 * 60 * 1000,
88 }
89 }
90}
91
92impl PreviousVersionContextConfig {
93 pub fn validate(&self) -> Result<(), String> {
96 let bounds = [
97 ("maxBytes", self.max_bytes),
98 ("maxRows", self.max_rows),
99 ("maxTables", self.max_tables),
100 ("maxRowBytes", self.max_row_bytes),
101 ("maxAgeMs", self.max_age_ms),
102 ];
103 for (name, value) in bounds {
104 if value <= 0 || u64::try_from(value).map_or(true, |v| v > MAX_SAFE_INTEGER) {
105 return Err(format!(
106 "sync.invalid_request: previousVersionContext.{name} must be a positive safe integer"
107 ));
108 }
109 }
110 Ok(())
111 }
112}
113
114const MAX_SAFE_INTEGER: u64 = 9_007_199_254_740_991;
117
118fn local_corrupt(what: &str) -> String {
119 format!("sync.local_corrupt: persisted {what} is invalid")
120}
121
122pub fn previous_version_container_path(replica_path: &str) -> String {
125 format!("{replica_path}{PREVIOUS_VERSION_CONTAINER_SUFFIX}")
126}
127
128#[derive(Debug, Clone, PartialEq, Eq)]
133pub struct DescriptorColumn {
134 pub name: String,
135 pub ty: ColumnType,
138}
139
140#[derive(Debug, Clone, PartialEq, Eq)]
141pub struct DescriptorTable {
142 pub name: String,
143 pub primary_key: String,
144 pub columns: Vec<DescriptorColumn>,
145}
146
147#[derive(Debug, Clone, PartialEq, Eq)]
148pub struct LocalSchemaDescriptor {
149 pub version: i32,
150 pub tables: Vec<DescriptorTable>,
151}
152
153impl LocalSchemaDescriptor {
154 pub fn table(&self, name: &str) -> Option<&DescriptorTable> {
155 self.tables.iter().find(|table| table.name == name)
156 }
157}
158
159pub fn build_local_schema_descriptor(schema: &ClientSchema) -> LocalSchemaDescriptor {
162 LocalSchemaDescriptor {
163 version: schema.version,
164 tables: schema
165 .tables
166 .iter()
167 .map(|table| DescriptorTable {
168 name: table.name.clone(),
169 primary_key: table.primary_key.clone(),
170 columns: table
171 .columns
172 .iter()
173 .map(|column| DescriptorColumn {
174 name: column.name.clone(),
175 ty: column.ty,
176 })
177 .collect(),
178 })
179 .collect(),
180 }
181}
182
183fn encode_local_schema_descriptor(descriptor: &LocalSchemaDescriptor) -> String {
184 let tables = descriptor
185 .tables
186 .iter()
187 .map(|table| {
188 let columns = table
189 .columns
190 .iter()
191 .map(|column| json!({ "name": column.name, "type": column.ty.name() }))
192 .collect::<Vec<_>>();
193 json!({
194 "name": table.name,
195 "primaryKey": table.primary_key,
196 "columns": columns,
197 })
198 })
199 .collect::<Vec<_>>();
200 json!({
201 "v": DESCRIPTOR_VERSION,
202 "version": descriptor.version,
203 "tables": tables,
204 })
205 .to_string()
206}
207
208fn has_exact_keys(object: &Map<String, Value>, expected: &[&str]) -> bool {
211 object.len() == expected.len() && expected.iter().all(|key| object.contains_key(*key))
212}
213
214fn as_count(value: Option<&Value>) -> Option<i64> {
216 value
217 .and_then(Value::as_i64)
218 .filter(|value| *value >= 0 && u64::try_from(*value).is_ok_and(|v| v <= MAX_SAFE_INTEGER))
219}
220
221fn decode_descriptor_column(value: &Value) -> Result<DescriptorColumn, String> {
222 let Value::Object(column) = value else {
223 return Err(local_corrupt("schema descriptor"));
224 };
225 if !has_exact_keys(column, &["name", "type"]) {
226 return Err(local_corrupt("schema descriptor"));
227 }
228 let (Some(Value::String(name)), Some(Value::String(type_name))) =
229 (column.get("name"), column.get("type"))
230 else {
231 return Err(local_corrupt("schema descriptor"));
232 };
233 let ty = ColumnType::from_name(type_name).ok_or_else(|| local_corrupt("schema descriptor"))?;
234 Ok(DescriptorColumn {
235 name: name.clone(),
236 ty,
237 })
238}
239
240fn decode_descriptor_table(value: &Value) -> Result<DescriptorTable, String> {
241 let Value::Object(table) = value else {
242 return Err(local_corrupt("schema descriptor"));
243 };
244 if !has_exact_keys(table, &["columns", "name", "primaryKey"]) {
245 return Err(local_corrupt("schema descriptor"));
246 }
247 let (Some(Value::String(name)), Some(Value::String(primary_key)), Some(Value::Array(columns))) = (
248 table.get("name"),
249 table.get("primaryKey"),
250 table.get("columns"),
251 ) else {
252 return Err(local_corrupt("schema descriptor"));
253 };
254 Ok(DescriptorTable {
255 name: name.clone(),
256 primary_key: primary_key.clone(),
257 columns: columns
258 .iter()
259 .map(decode_descriptor_column)
260 .collect::<Result<Vec<_>, _>>()?,
261 })
262}
263
264pub fn decode_local_schema_descriptor(raw: &str) -> Result<LocalSchemaDescriptor, String> {
266 let parsed: Value =
267 serde_json::from_str(raw).map_err(|_| local_corrupt("schema descriptor"))?;
268 let Value::Object(record) = &parsed else {
269 return Err(local_corrupt("schema descriptor"));
270 };
271 if !has_exact_keys(record, &["tables", "v", "version"])
272 || as_count(record.get("v")) != Some(DESCRIPTOR_VERSION)
273 {
274 return Err(local_corrupt("schema descriptor"));
275 }
276 let version = as_count(record.get("version"))
277 .and_then(|value| i32::try_from(value).ok())
278 .ok_or_else(|| local_corrupt("schema descriptor"))?;
279 let Some(Value::Array(tables)) = record.get("tables") else {
280 return Err(local_corrupt("schema descriptor"));
281 };
282 Ok(LocalSchemaDescriptor {
283 version,
284 tables: tables
285 .iter()
286 .map(decode_descriptor_table)
287 .collect::<Result<Vec<_>, _>>()?,
288 })
289}
290
291pub fn set_local_schema_descriptor(
295 conn: &Connection,
296 schema: &ClientSchema,
297) -> rusqlite::Result<()> {
298 conn.execute(
299 "INSERT OR REPLACE INTO _syncular_meta(key,value) VALUES (?1,?2)",
300 rusqlite::params![
301 LOCAL_SCHEMA_DESCRIPTOR_KEY,
302 encode_local_schema_descriptor(&build_local_schema_descriptor(schema))
303 ],
304 )?;
305 Ok(())
306}
307
308pub fn load_local_schema_descriptor(
311 conn: &Connection,
312) -> rusqlite::Result<Option<LocalSchemaDescriptor>> {
313 Ok(meta_get(conn, LOCAL_SCHEMA_DESCRIPTOR_KEY)?
314 .and_then(|raw| decode_local_schema_descriptor(&raw).ok()))
315}
316
317pub fn drop_previous_version_container(conn: &Connection) -> Result<(), String> {
323 conn.execute_batch(&format!(
324 "DROP TABLE IF EXISTS {CONTAINER_TABLE}; DROP TABLE IF EXISTS {CONTAINER_META_TABLE};"
325 ))
326 .map_err(|error| error.to_string())
327}
328
329#[derive(Debug, Clone, PartialEq, Eq)]
331pub struct PreviousVersionRecord {
332 pub previous_version: i32,
333 pub current_version: i32,
334 pub tables: Vec<DescriptorTable>,
335 pub rows: i64,
336 pub bytes: i64,
337 pub created_at_ms: i64,
338}
339
340impl PreviousVersionRecord {
341 pub fn table(&self, name: &str) -> Option<&DescriptorTable> {
343 self.tables.iter().find(|table| table.name == name)
344 }
345}
346
347fn decode_record(raw: &str) -> Result<PreviousVersionRecord, String> {
348 let parsed: Value =
349 serde_json::from_str(raw).map_err(|_| local_corrupt("previous-version context"))?;
350 let Value::Object(record) = &parsed else {
351 return Err(local_corrupt("previous-version context"));
352 };
353 if !has_exact_keys(
354 record,
355 &[
356 "bytes",
357 "createdAtMs",
358 "currentVersion",
359 "previousVersion",
360 "rows",
361 "tables",
362 "v",
363 ],
364 ) || as_count(record.get("v")) != Some(RECORD_VERSION)
365 {
366 return Err(local_corrupt("previous-version context"));
367 }
368 let count = |key: &str| {
369 as_count(record.get(key))
370 .and_then(|value| i32::try_from(value).ok())
371 .ok_or_else(|| local_corrupt("previous-version context"))
372 };
373 let Some(Value::Array(tables)) = record.get("tables") else {
374 return Err(local_corrupt("previous-version context"));
375 };
376 Ok(PreviousVersionRecord {
377 previous_version: count("previousVersion")?,
378 current_version: count("currentVersion")?,
379 tables: tables
380 .iter()
381 .map(decode_descriptor_table)
382 .collect::<Result<Vec<_>, _>>()?,
383 rows: as_count(record.get("rows"))
384 .ok_or_else(|| local_corrupt("previous-version context"))?,
385 bytes: as_count(record.get("bytes"))
386 .ok_or_else(|| local_corrupt("previous-version context"))?,
387 created_at_ms: as_count(record.get("createdAtMs"))
388 .ok_or_else(|| local_corrupt("previous-version context"))?,
389 })
390}
391
392fn encode_record(record: &PreviousVersionRecord) -> String {
393 let tables = record
394 .tables
395 .iter()
396 .map(|table| {
397 let columns = table
398 .columns
399 .iter()
400 .map(|column| json!({ "name": column.name, "type": column.ty.name() }))
401 .collect::<Vec<_>>();
402 json!({
403 "name": table.name,
404 "primaryKey": table.primary_key,
405 "columns": columns,
406 })
407 })
408 .collect::<Vec<_>>();
409 json!({
410 "v": RECORD_VERSION,
411 "previousVersion": record.previous_version,
412 "currentVersion": record.current_version,
413 "tables": tables,
414 "rows": record.rows,
415 "bytes": record.bytes,
416 "createdAtMs": record.created_at_ms,
417 })
418 .to_string()
419}
420
421fn write_record(conn: &Connection, record: &PreviousVersionRecord) -> Result<(), String> {
422 conn.execute_batch(&format!(
423 "CREATE TABLE IF NOT EXISTS {CONTAINER_META_TABLE} (
424 id INTEGER PRIMARY KEY CHECK (id = 1),
425 record TEXT NOT NULL);
426 DELETE FROM {CONTAINER_META_TABLE};"
427 ))
428 .map_err(|error| error.to_string())?;
429 conn.execute(
430 &format!("INSERT INTO {CONTAINER_META_TABLE}(id, record) VALUES (1, ?1)"),
431 rusqlite::params![encode_record(record)],
432 )
433 .map_err(|error| error.to_string())?;
434 Ok(())
435}
436
437pub fn read_previous_version_container(
439 conn: &Connection,
440) -> Result<Option<PreviousVersionRecord>, String> {
441 let present: i64 = conn
442 .query_row(
443 "SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = ?1",
444 rusqlite::params![CONTAINER_META_TABLE],
445 |row| row.get(0),
446 )
447 .map_err(|error| error.to_string())?;
448 if present == 0 {
449 return Ok(None);
450 }
451 let raw: Option<String> = conn
452 .query_row(
453 &format!("SELECT record FROM {CONTAINER_META_TABLE} WHERE id = 1"),
454 [],
455 |row| row.get(0),
456 )
457 .optional()
458 .map_err(|error| error.to_string())?;
459 Ok(raw.and_then(|raw| decode_record(&raw).ok()))
460}
461
462pub fn read_previous_version_rows(
465 conn: &Connection,
466 table: &DescriptorTable,
467 row_ids: &[String],
468 limit: i64,
469) -> Result<(Vec<Map<String, Value>>, bool), String> {
470 let filter = if row_ids.is_empty() {
471 String::new()
472 } else {
473 let placeholders = (0..row_ids.len())
474 .map(|index| format!("?{}", index + 2))
475 .collect::<Vec<_>>()
476 .join(", ");
477 format!(" AND row_id IN ({placeholders})")
478 };
479 let mut params: Vec<rusqlite::types::Value> =
480 vec![rusqlite::types::Value::Text(table.name.clone())];
481 params.extend(
482 row_ids
483 .iter()
484 .map(|row_id| rusqlite::types::Value::Text(row_id.clone())),
485 );
486 params.push(rusqlite::types::Value::Integer(limit + 1));
487 let limit_placeholder = params.len();
488 let mut stmt = conn
489 .prepare(&format!(
490 "SELECT payload FROM {CONTAINER_TABLE}
491 WHERE tbl = ?1{filter} ORDER BY row_id ASC LIMIT ?{limit_placeholder}"
492 ))
493 .map_err(|error| error.to_string())?;
494 let payloads = stmt
495 .query_map(rusqlite::params_from_iter(params), |row| {
496 row.get::<_, String>(0)
497 })
498 .map_err(|error| error.to_string())?
499 .collect::<Result<Vec<_>, _>>()
500 .map_err(|error| error.to_string())?;
501 let truncated = payloads.len() as i64 > limit;
502 let visible = if truncated {
503 limit as usize
504 } else {
505 payloads.len()
506 };
507 let mut rows = Vec::with_capacity(visible);
508 for payload in payloads.into_iter().take(visible) {
509 rows.push(payload_to_row(table, &payload)?);
510 }
511 Ok((rows, truncated))
512}
513
514#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
524pub struct CaptureMeasurement {
525 pub tables: i64,
526 pub rows: i64,
527 pub bytes: i64,
528}
529
530#[derive(Debug, Clone, Copy, PartialEq, Eq)]
531pub enum PreviousVersionRefusalReason {
532 NoPreviousDescriptor,
533 CaptureExceededBudget,
534}
535
536impl PreviousVersionRefusalReason {
537 pub fn name(self) -> &'static str {
538 match self {
539 Self::NoPreviousDescriptor => "no-previous-descriptor",
540 Self::CaptureExceededBudget => "capture-exceeded-budget",
541 }
542 }
543
544 fn as_read_reason(self) -> PreviousVersionReason {
545 match self {
546 Self::NoPreviousDescriptor => PreviousVersionReason::NoPreviousDescriptor,
547 Self::CaptureExceededBudget => PreviousVersionReason::CaptureExceededBudget,
548 }
549 }
550}
551
552#[derive(Debug, Clone, Copy, PartialEq, Eq)]
553pub enum CaptureOutcome {
554 Captured(CaptureMeasurement),
555 Refused {
556 reason: PreviousVersionRefusalReason,
557 measurement: CaptureMeasurement,
558 },
559}
560
561fn sum_bytes_expression(columns: &[DescriptorColumn]) -> String {
562 columns
563 .iter()
564 .map(|column| {
565 format!(
566 "COALESCE(LENGTH(CAST({} AS BLOB)), 0)",
567 quote_ident(&column.name)
568 )
569 })
570 .collect::<Vec<_>>()
571 .join(" + ")
572}
573
574fn render_row_id(value: ValueRef<'_>) -> String {
575 match value {
576 ValueRef::Null => String::new(),
577 ValueRef::Integer(value) => value.to_string(),
578 ValueRef::Real(value) => value.to_string(),
579 ValueRef::Text(value) => String::from_utf8_lossy(value).into_owned(),
580 ValueRef::Blob(value) => bytes_to_hex(value),
581 }
582}
583
584fn column_ref(column: &DescriptorColumn) -> Column {
587 Column {
588 name: column.name.clone(),
589 ty: column.ty,
590 nullable: true,
591 }
592}
593
594fn payload_to_row(table: &DescriptorTable, payload: &str) -> Result<Map<String, Value>, String> {
595 let parsed: Value =
596 serde_json::from_str(payload).map_err(|_| local_corrupt("previous-version payload"))?;
597 let Value::Object(record) = parsed else {
598 return Err(local_corrupt("previous-version payload"));
599 };
600 let mut row = Map::new();
601 for column in &table.columns {
602 let value = json_to_column_value(&column_ref(column), record.get(&column.name))
603 .map_err(|_| local_corrupt("previous-version payload"))?;
604 row.insert(column.name.clone(), column_value_to_json(&value));
605 }
606 Ok(row)
607}
608
609pub fn capture_previous_version(
612 replica: &Connection,
613 container: &mut Connection,
614 descriptor: &LocalSchemaDescriptor,
615 current_version: i32,
616 config: &PreviousVersionContextConfig,
617 now_ms: i64,
618) -> Result<CaptureOutcome, String> {
619 let discovered: BTreeSet<String> = {
620 let mut stmt = replica
621 .prepare(
622 "SELECT name FROM sqlite_master WHERE type = 'table'
623 AND name NOT LIKE '_syncular_%' AND name NOT LIKE 'sqlite_%'",
624 )
625 .map_err(|error| error.to_string())?;
626 let names = stmt
627 .query_map([], |row| row.get::<_, String>(0))
628 .map_err(|error| error.to_string())?
629 .collect::<Result<BTreeSet<_>, _>>()
630 .map_err(|error| error.to_string())?;
631 names
632 };
633 let tables: Vec<&DescriptorTable> = descriptor
636 .tables
637 .iter()
638 .filter(|table| discovered.contains(&table.name))
639 .collect();
640 let refused = |rows: i64, bytes: i64| CaptureOutcome::Refused {
641 reason: PreviousVersionRefusalReason::CaptureExceededBudget,
642 measurement: CaptureMeasurement {
643 tables: tables.len() as i64,
644 rows,
645 bytes,
646 },
647 };
648 if tables.len() as i64 > config.max_tables {
649 return Ok(refused(0, 0));
650 }
651
652 let mut measured_rows = 0i64;
655 let mut per_table_rows: Vec<i64> = Vec::with_capacity(tables.len());
656 for table in &tables {
657 let count: i64 = replica
658 .query_row(
659 &format!(
660 "SELECT COUNT(*) FROM (SELECT 1 FROM {} LIMIT ?1)",
661 quote_ident(&table.name)
662 ),
663 rusqlite::params![config.max_rows + 1],
664 |row| row.get(0),
665 )
666 .map_err(|error| error.to_string())?;
667 measured_rows += count;
668 per_table_rows.push(count);
669 if measured_rows > config.max_rows {
670 return Ok(refused(measured_rows, 0));
671 }
672 }
673
674 let mut measured_bytes = 0i64;
677 for (index, table) in tables.iter().enumerate() {
678 if table.columns.is_empty() {
679 continue;
680 }
681 let byte_sum = sum_bytes_expression(&table.columns);
682 let oversized: Option<i64> = replica
683 .query_row(
684 &format!(
685 "SELECT 1 FROM {} WHERE ({byte_sum}) > ?1 LIMIT 1",
686 quote_ident(&table.name)
687 ),
688 rusqlite::params![config.max_row_bytes],
689 |row| row.get(0),
690 )
691 .optional()
692 .map_err(|error| error.to_string())?;
693 if oversized.is_some() {
694 return Ok(refused(measured_rows, measured_bytes));
695 }
696 let total: i64 = replica
697 .query_row(
698 &format!(
699 "SELECT COALESCE(SUM(bytes), 0) FROM (
700 SELECT ({byte_sum}) AS bytes FROM {} LIMIT ?1
701 )",
702 quote_ident(&table.name)
703 ),
704 rusqlite::params![per_table_rows[index]],
705 |row| row.get(0),
706 )
707 .map_err(|error| error.to_string())?;
708 measured_bytes += total;
709 if measured_bytes > config.max_bytes {
710 return Ok(refused(measured_rows, measured_bytes));
711 }
712 }
713
714 let mut copied_rows = 0i64;
716 let transaction = container.transaction().map_err(|error| error.to_string())?;
717 drop_previous_version_container(&transaction)?;
718 transaction
719 .execute_batch(&format!(
720 "CREATE TABLE {CONTAINER_TABLE} (
721 tbl TEXT NOT NULL,
722 row_id TEXT NOT NULL,
723 payload TEXT NOT NULL,
724 PRIMARY KEY (tbl, row_id))"
725 ))
726 .map_err(|error| error.to_string())?;
727 for table in &tables {
728 let select_columns = table
729 .columns
730 .iter()
731 .map(|column| quote_ident(&column.name))
732 .collect::<Vec<_>>()
733 .join(", ");
734 let primary_key_index = table
735 .columns
736 .iter()
737 .position(|column| column.name == table.primary_key);
738 let select_sql = format!(
739 "SELECT rowid, {select_columns} FROM {} WHERE rowid > ?1 ORDER BY rowid ASC LIMIT ?2",
740 quote_ident(&table.name)
741 );
742 let mut last_row_id = -1i64;
743 loop {
744 let batch: Vec<(i64, String, String)> = {
745 let mut stmt = replica
746 .prepare(&select_sql)
747 .map_err(|error| error.to_string())?;
748 let mut rows = stmt
749 .query(rusqlite::params![last_row_id, COPY_BATCH_ROWS])
750 .map_err(|error| error.to_string())?;
751 let mut batch = Vec::new();
752 while let Some(row) = rows.next().map_err(|error| error.to_string())? {
753 let row_id = primary_key_index.map_or_else(String::new, |index| {
754 row.get_ref(index + 1)
755 .map(render_row_id)
756 .unwrap_or_default()
757 });
758 let mut payload = Map::new();
759 for (index, column) in table.columns.iter().enumerate() {
760 let raw = row.get_ref(index + 1).map_err(|error| error.to_string())?;
761 payload.insert(
762 column.name.clone(),
763 sql_ref_to_json(&column_ref(column), raw),
764 );
765 }
766 batch.push((
767 row.get::<_, i64>(0).map_err(|error| error.to_string())?,
768 row_id,
769 Value::Object(payload).to_string(),
770 ));
771 }
772 batch
773 };
774 if batch.is_empty() {
775 break;
776 }
777 {
778 let mut insert = transaction
779 .prepare(&format!(
780 "INSERT INTO {CONTAINER_TABLE}(tbl, row_id, payload) VALUES (?1, ?2, ?3)"
781 ))
782 .map_err(|error| error.to_string())?;
783 for (_, row_id, payload) in &batch {
784 insert
785 .execute(rusqlite::params![table.name, row_id, payload])
786 .map_err(|error| error.to_string())?;
787 }
788 }
789 copied_rows += batch.len() as i64;
790 last_row_id = batch.last().map_or(last_row_id, |entry| entry.0);
791 }
792 }
793 write_record(
794 &transaction,
795 &PreviousVersionRecord {
796 previous_version: descriptor.version,
797 current_version,
798 tables: tables.iter().map(|table| (*table).clone()).collect(),
799 rows: copied_rows,
800 bytes: measured_bytes,
801 created_at_ms: now_ms,
802 },
803 )?;
804 transaction.commit().map_err(|error| error.to_string())?;
805 Ok(CaptureOutcome::Captured(CaptureMeasurement {
806 tables: tables.len() as i64,
807 rows: copied_rows,
808 bytes: measured_bytes,
809 }))
810}
811
812fn write_previous_version_refusal(
818 conn: &Connection,
819 reason: PreviousVersionRefusalReason,
820 measurement: CaptureMeasurement,
821) -> Result<(), String> {
822 meta_set(
823 conn,
824 PREVIOUS_VERSION_CONTEXT_KEY,
825 &json!({
826 "v": 1,
827 "reason": reason.name(),
828 "tables": measurement.tables,
829 "rows": measurement.rows,
830 "bytes": measurement.bytes,
831 })
832 .to_string(),
833 )
834 .map_err(|error| error.to_string())
835}
836
837fn remove_previous_version_files(path: &str) {
844 for suffix in ["", "-wal", "-shm"] {
845 let _ = std::fs::remove_file(format!("{path}{suffix}"));
846 }
847}
848
849pub fn sweep_previous_version_container(
858 replica: &Connection,
859 replica_path: &str,
860) -> Result<(), String> {
861 let path = previous_version_container_path(replica_path);
862 if !std::path::Path::new(&path).exists() {
863 return Ok(());
864 }
865 if let Ok(container) = Connection::open(&path) {
866 let _ = drop_previous_version_container(&container);
867 let _ = container.close();
868 }
869 remove_previous_version_files(&path);
870 meta_delete(replica, PREVIOUS_VERSION_CONTEXT_KEY).map_err(|error| error.to_string())
871}
872
873pub fn capture_previous_version_from_replica(
877 replica: &Connection,
878 replica_path: &str,
879 previous_version: i32,
880 current_version: i32,
881 config: &PreviousVersionContextConfig,
882 now_ms: i64,
883) -> Result<CaptureOutcome, String> {
884 let path = previous_version_container_path(replica_path);
885 let descriptor = load_local_schema_descriptor(replica)
886 .map_err(|error| error.to_string())?
887 .filter(|descriptor| descriptor.version == previous_version);
888 let Some(descriptor) = descriptor else {
889 let measurement = CaptureMeasurement::default();
892 write_previous_version_refusal(
893 replica,
894 PreviousVersionRefusalReason::NoPreviousDescriptor,
895 measurement,
896 )?;
897 return Ok(CaptureOutcome::Refused {
898 reason: PreviousVersionRefusalReason::NoPreviousDescriptor,
899 measurement,
900 });
901 };
902 let mut container = Connection::open(&path)
903 .map_err(|error| format!("open previous-version container {path:?}: {error}"))?;
904 let outcome = capture_previous_version(
905 replica,
906 &mut container,
907 &descriptor,
908 current_version,
909 config,
910 now_ms,
911 );
912 let _ = container.close();
913 match outcome {
914 Ok(CaptureOutcome::Captured(measurement)) => {
915 meta_delete(replica, PREVIOUS_VERSION_CONTEXT_KEY)
916 .map_err(|error| error.to_string())?;
917 Ok(CaptureOutcome::Captured(measurement))
918 }
919 Ok(CaptureOutcome::Refused {
920 reason,
921 measurement,
922 }) => {
923 write_previous_version_refusal(replica, reason, measurement)?;
924 remove_previous_version_files(&path);
925 Ok(CaptureOutcome::Refused {
926 reason,
927 measurement,
928 })
929 }
930 Err(error) => {
931 remove_previous_version_files(&path);
932 Err(error)
933 }
934 }
935}
936
937pub const PREVIOUS_VERSION_DEFAULT_LIMIT: i64 = 50;
939pub const PREVIOUS_VERSION_MAX_LIMIT: i64 = 200;
940
941#[derive(Debug, Clone, PartialEq, Serialize)]
944#[serde(rename_all = "camelCase")]
945pub struct PreviousVersionSnapshot {
946 pub state: &'static str,
947 pub available: bool,
948 #[serde(skip_serializing_if = "Option::is_none")]
949 pub previous_version: Option<i32>,
950 pub current_version: i32,
951 #[serde(skip_serializing_if = "Option::is_none")]
952 pub reason: Option<PreviousVersionReason>,
953 pub rows: Vec<Map<String, Value>>,
954 pub truncated: bool,
955}
956
957impl PreviousVersionSnapshot {
958 fn unavailable(current_version: i32, reason: PreviousVersionReason) -> Self {
959 Self {
960 state: PREVIOUS_VERSION_STATE,
961 available: false,
962 previous_version: None,
963 current_version,
964 reason: Some(reason),
965 rows: Vec::new(),
966 truncated: false,
967 }
968 }
969}
970
971pub const PREVIOUS_VERSION_STATE: &str = "previousVersion";
973
974#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
976#[serde(rename_all = "kebab-case")]
977pub enum PreviousVersionReason {
978 NotConfigured,
979 NoPreviousDescriptor,
980 CaptureExceededBudget,
981 CoverageComplete,
982 Expired,
983 LeaseInactive,
984 ScopeRevoked,
985}
986
987#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
991pub struct PreviousVersionLifecycle {
992 pub lease_inactive: bool,
994 pub scope_revoked: bool,
996 pub coverage_complete: bool,
998}
999
1000#[derive(Debug, Clone, Default, PartialEq, Eq)]
1002pub struct PreviousVersionReadSpec {
1003 pub table: String,
1004 pub row_ids: Vec<String>,
1005 pub limit: Option<i64>,
1006}
1007
1008pub fn resolve_previous_version_limit(limit: Option<i64>) -> Result<i64, String> {
1010 match limit {
1011 None => Ok(PREVIOUS_VERSION_DEFAULT_LIMIT),
1012 Some(limit) if limit >= 1 => Ok(limit.min(PREVIOUS_VERSION_MAX_LIMIT)),
1013 Some(_) => Err(
1014 "sync.invalid_request: previousVersionSnapshot limit must be a positive number"
1015 .to_owned(),
1016 ),
1017 }
1018}
1019
1020pub fn read_previous_version_record(
1023 replica_path: &str,
1024) -> Result<Option<PreviousVersionRecord>, String> {
1025 let path = previous_version_container_path(replica_path);
1026 if !std::path::Path::new(&path).exists() {
1027 return Ok(None);
1028 }
1029 let container = Connection::open(&path)
1030 .map_err(|error| format!("open previous-version container {path:?}: {error}"))?;
1031 let record = read_previous_version_container(&container);
1032 let _ = container.close();
1033 Ok(record.unwrap_or(None))
1036}
1037
1038pub fn previous_version_snapshot(
1046 replica: &Connection,
1047 replica_path: Option<&str>,
1048 config: Option<&PreviousVersionContextConfig>,
1049 lifecycle: PreviousVersionLifecycle,
1050 now_ms: i64,
1051 current_version: i32,
1052 spec: &PreviousVersionReadSpec,
1053) -> Result<PreviousVersionSnapshot, String> {
1054 let Some(config) = config.filter(|config| config.enabled) else {
1055 return Ok(PreviousVersionSnapshot::unavailable(
1056 current_version,
1057 PreviousVersionReason::NotConfigured,
1058 ));
1059 };
1060 let record = match replica_path {
1061 Some(replica_path) => read_previous_version_record(replica_path)?,
1062 None => None,
1063 };
1064 let discard = |reason: PreviousVersionReason| {
1065 if let Some(replica_path) = replica_path {
1066 discard_previous_version(replica, replica_path)?;
1067 }
1068 Ok(PreviousVersionSnapshot::unavailable(
1069 current_version,
1070 reason,
1071 ))
1072 };
1073 if lifecycle.lease_inactive {
1074 return discard(PreviousVersionReason::LeaseInactive);
1075 }
1076 if lifecycle.scope_revoked {
1077 return discard(PreviousVersionReason::ScopeRevoked);
1078 }
1079 if lifecycle.coverage_complete {
1080 return discard(PreviousVersionReason::CoverageComplete);
1081 }
1082 let Some(record) = record else {
1083 let reason = stored_previous_version_refusal(replica)
1084 .map_err(|error| error.to_string())?
1085 .unwrap_or(PreviousVersionRefusalReason::NoPreviousDescriptor);
1086 return Ok(PreviousVersionSnapshot::unavailable(
1087 current_version,
1088 reason.as_read_reason(),
1089 ));
1090 };
1091 if record.current_version != current_version {
1092 return discard(PreviousVersionReason::NoPreviousDescriptor);
1093 }
1094 if now_ms - record.created_at_ms > config.max_age_ms {
1095 return discard(PreviousVersionReason::Expired);
1096 }
1097 let Some(table) = record.table(&spec.table) else {
1098 return Err(format!(
1099 "sync.invalid_request: previousVersionSnapshot names unknown previous table {:?}",
1100 spec.table
1101 ));
1102 };
1103 let Some(replica_path) = replica_path else {
1104 return Ok(PreviousVersionSnapshot::unavailable(
1105 current_version,
1106 PreviousVersionReason::NoPreviousDescriptor,
1107 ));
1108 };
1109 let path = previous_version_container_path(replica_path);
1110 let container = Connection::open(&path)
1111 .map_err(|error| format!("open previous-version container {path:?}: {error}"))?;
1112 let (rows, truncated) = read_previous_version_rows(
1113 &container,
1114 table,
1115 &spec.row_ids,
1116 resolve_previous_version_limit(spec.limit)?,
1117 )?;
1118 let _ = container.close();
1119 Ok(PreviousVersionSnapshot {
1120 state: PREVIOUS_VERSION_STATE,
1121 available: true,
1122 previous_version: Some(record.previous_version),
1123 current_version,
1124 reason: None,
1125 rows,
1126 truncated,
1127 })
1128}
1129
1130pub fn stored_previous_version_refusal(
1134 replica: &Connection,
1135) -> rusqlite::Result<Option<PreviousVersionRefusalReason>> {
1136 Ok(meta_get(replica, PREVIOUS_VERSION_CONTEXT_KEY)?
1137 .and_then(|raw| decode_previous_version_refusal(&raw)))
1138}
1139
1140fn decode_previous_version_refusal(raw: &str) -> Option<PreviousVersionRefusalReason> {
1141 let parsed: Value = serde_json::from_str(raw).ok()?;
1142 let Value::Object(record) = &parsed else {
1143 return None;
1144 };
1145 if as_count(record.get("v")) != Some(1) {
1146 return None;
1147 }
1148 match record.get("reason").and_then(Value::as_str) {
1149 Some("no-previous-descriptor") => Some(PreviousVersionRefusalReason::NoPreviousDescriptor),
1150 Some("capture-exceeded-budget") => {
1151 Some(PreviousVersionRefusalReason::CaptureExceededBudget)
1152 }
1153 _ => None,
1154 }
1155}
1156
1157#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
1158#[serde(rename_all = "kebab-case")]
1159pub enum PreviousVersionAuditReason {
1160 UnknownTable,
1161 UnknownColumn,
1162 MissingRequiredColumn,
1163}
1164
1165impl PreviousVersionAuditReason {
1166 #[must_use]
1167 pub fn name(self) -> &'static str {
1168 match self {
1169 Self::UnknownTable => "unknown-table",
1170 Self::UnknownColumn => "unknown-column",
1171 Self::MissingRequiredColumn => "missing-required-column",
1172 }
1173 }
1174}
1175
1176#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
1179#[serde(rename_all = "camelCase")]
1180pub struct PreviousVersionAuditEntry {
1181 pub commit_id: String,
1182 pub table: String,
1183 pub reason: PreviousVersionAuditReason,
1184 #[serde(skip_serializing_if = "Option::is_none")]
1185 pub column: Option<String>,
1186}
1187
1188#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
1191#[serde(rename_all = "camelCase")]
1192pub struct PreviousVersionAudit {
1193 pub v: i64,
1194 pub at_ms: i64,
1195 pub from_version: i32,
1196 pub to_version: i32,
1197 pub pending: i64,
1198 pub encodable: i64,
1199 pub truncated: bool,
1200 pub incompatible: Vec<PreviousVersionAuditEntry>,
1201}
1202
1203pub const MAX_AUDIT_INCOMPATIBLE: usize = 200;
1204
1205#[derive(Debug, Clone, PartialEq, Eq)]
1208pub struct PendingCommitAudit<'a> {
1209 pub commit_id: &'a str,
1210 pub operations: Vec<(&'a str, Vec<&'a str>, bool)>,
1211}
1212
1213pub fn first_incompatibility<'a>(
1217 schema: &'a ClientSchema,
1218 operations: &[(&'a str, Vec<&'a str>, bool)],
1219) -> Option<(&'a str, PreviousVersionAuditReason, Option<&'a str>)> {
1220 for (table_name, value_keys, insert) in operations {
1221 let Some(table) = schema.table(table_name) else {
1222 return Some((*table_name, PreviousVersionAuditReason::UnknownTable, None));
1223 };
1224 for key in value_keys {
1225 if !table.columns.iter().any(|column| column.name == *key) {
1226 return Some((
1227 *table_name,
1228 PreviousVersionAuditReason::UnknownColumn,
1229 Some(*key),
1230 ));
1231 }
1232 }
1233 if *insert {
1236 if let Some(column) = table
1237 .columns
1238 .iter()
1239 .find(|column| !column.nullable && !value_keys.contains(&column.name.as_str()))
1240 {
1241 return Some((
1242 *table_name,
1243 PreviousVersionAuditReason::MissingRequiredColumn,
1244 Some(column.name.as_str()),
1245 ));
1246 }
1247 }
1248 }
1249 None
1250}
1251
1252pub fn build_previous_version_audit<'a>(
1255 schema: &'a ClientSchema,
1256 pending: &[PendingCommitAudit<'a>],
1257 from_version: i32,
1258 to_version: i32,
1259 at_ms: i64,
1260) -> PreviousVersionAudit {
1261 let mut incompatible = Vec::new();
1262 let mut incompatible_total = 0usize;
1263 let mut encodable = 0i64;
1264 for commit in pending {
1265 let Some((table, reason, column)) = first_incompatibility(schema, &commit.operations)
1266 else {
1267 encodable += 1;
1268 continue;
1269 };
1270 incompatible_total += 1;
1271 if incompatible.len() < MAX_AUDIT_INCOMPATIBLE {
1272 incompatible.push(PreviousVersionAuditEntry {
1273 commit_id: commit.commit_id.to_owned(),
1274 table: table.to_owned(),
1275 reason,
1276 column: column.map(str::to_owned),
1277 });
1278 }
1279 }
1280 PreviousVersionAudit {
1281 v: 1,
1282 at_ms,
1283 from_version,
1284 to_version,
1285 pending: pending.len() as i64,
1286 encodable,
1287 truncated: incompatible_total > MAX_AUDIT_INCOMPATIBLE,
1288 incompatible,
1289 }
1290}
1291
1292pub fn write_previous_version_audit(
1293 replica: &Connection,
1294 audit: &PreviousVersionAudit,
1295) -> Result<(), String> {
1296 let encoded = serde_json::to_string(audit).map_err(|error| error.to_string())?;
1297 meta_set(replica, PREVIOUS_VERSION_AUDIT_KEY, &encoded).map_err(|error| error.to_string())
1298}
1299
1300pub fn stored_previous_version_audit(
1303 replica: &Connection,
1304) -> rusqlite::Result<Option<PreviousVersionAudit>> {
1305 Ok(meta_get(replica, PREVIOUS_VERSION_AUDIT_KEY)?
1306 .and_then(|raw| decode_previous_version_audit(&raw)))
1307}
1308
1309fn decode_previous_version_audit(raw: &str) -> Option<PreviousVersionAudit> {
1310 let parsed: Value = serde_json::from_str(raw).ok()?;
1311 let Value::Object(record) = &parsed else {
1312 return None;
1313 };
1314 if !has_exact_keys(
1315 record,
1316 &[
1317 "atMs",
1318 "encodable",
1319 "fromVersion",
1320 "incompatible",
1321 "pending",
1322 "toVersion",
1323 "truncated",
1324 "v",
1325 ],
1326 ) || as_count(record.get("v")) != Some(1)
1327 || record.get("truncated").and_then(Value::as_bool).is_none()
1328 {
1329 return None;
1330 }
1331 let count = |key: &str| as_count(record.get(key));
1332 let Some(Value::Array(entries)) = record.get("incompatible") else {
1333 return None;
1334 };
1335 let mut incompatible = Vec::with_capacity(entries.len());
1336 for entry in entries {
1337 let Value::Object(entry) = entry else {
1338 return None;
1339 };
1340 let (Some(Value::String(commit_id)), Some(Value::String(table))) =
1341 (entry.get("commitId"), entry.get("table"))
1342 else {
1343 return None;
1344 };
1345 let reason = match entry.get("reason").and_then(Value::as_str) {
1346 Some("unknown-table") => PreviousVersionAuditReason::UnknownTable,
1347 Some("unknown-column") => PreviousVersionAuditReason::UnknownColumn,
1348 Some("missing-required-column") => PreviousVersionAuditReason::MissingRequiredColumn,
1349 _ => return None,
1350 };
1351 let column = match entry.get("column") {
1352 None | Some(Value::Null) => None,
1353 Some(Value::String(column)) => Some(column.clone()),
1354 Some(_) => return None,
1355 };
1356 incompatible.push(PreviousVersionAuditEntry {
1357 commit_id: commit_id.clone(),
1358 table: table.clone(),
1359 reason,
1360 column,
1361 });
1362 }
1363 Some(PreviousVersionAudit {
1364 v: 1,
1365 at_ms: count("atMs")?,
1366 from_version: i32::try_from(count("fromVersion")?).ok()?,
1367 to_version: i32::try_from(count("toVersion")?).ok()?,
1368 pending: count("pending")?,
1369 encodable: count("encodable")?,
1370 truncated: record.get("truncated").and_then(Value::as_bool)?,
1371 incompatible,
1372 })
1373}
1374
1375#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1376pub struct PreviousVersionDiscardOutcome {
1377 pub present: bool,
1378 pub discarded: bool,
1379}
1380
1381pub fn reconcile_previous_version_at_boot(
1393 replica: &Connection,
1394 replica_path: &str,
1395 current_version: i32,
1396 max_age_ms: Option<i64>,
1397 now_ms: i64,
1398) -> Result<(), String> {
1399 let path = previous_version_container_path(replica_path);
1400 if !std::path::Path::new(&path).exists() {
1401 return Ok(());
1402 }
1403 let record = match Connection::open(&path) {
1404 Ok(container) => {
1405 let record = read_previous_version_container(&container).unwrap_or(None);
1406 let _ = container.close();
1407 record
1408 }
1409 Err(_) => None,
1411 };
1412 let stale = match &record {
1416 None => true,
1418 Some(record) => {
1419 record.current_version != current_version
1420 || max_age_ms.is_some_and(|max_age_ms| now_ms - record.created_at_ms > max_age_ms)
1421 }
1422 };
1423 if !stale {
1424 return Ok(());
1425 }
1426 discard_previous_version(replica, replica_path)?;
1427 Ok(())
1428}
1429
1430pub fn discard_previous_version(
1436 replica: &Connection,
1437 replica_path: &str,
1438) -> Result<PreviousVersionDiscardOutcome, String> {
1439 let path = previous_version_container_path(replica_path);
1440 let mut present = std::path::Path::new(&path).exists();
1441 if present {
1442 if let Ok(container) = Connection::open(&path) {
1443 let _ = drop_previous_version_container(&container);
1444 let _ = container.close();
1445 }
1446 remove_previous_version_files(&path);
1447 }
1448 if meta_get(replica, PREVIOUS_VERSION_CONTEXT_KEY)
1449 .map_err(|error| error.to_string())?
1450 .is_some()
1451 || meta_get(replica, PREVIOUS_VERSION_AUDIT_KEY)
1452 .map_err(|error| error.to_string())?
1453 .is_some()
1454 {
1455 present = true;
1456 }
1457 meta_delete(replica, PREVIOUS_VERSION_CONTEXT_KEY).map_err(|error| error.to_string())?;
1458 meta_delete(replica, PREVIOUS_VERSION_AUDIT_KEY).map_err(|error| error.to_string())?;
1459 Ok(PreviousVersionDiscardOutcome {
1460 present,
1461 discarded: present,
1462 })
1463}
1464#[cfg(test)]
1465mod tests {
1466 use std::path::{Path, PathBuf};
1473
1474 use serde_json::json;
1475
1476 use super::*;
1477 use crate::api::{ClientLimits, Mutation};
1478 use crate::client::{SyncClient, LOCAL_SCHEMA_VERSION_KEY};
1479 use crate::schema::parse_schema_json;
1480
1481 fn enabled_config() -> PreviousVersionContextConfig {
1482 PreviousVersionContextConfig {
1483 enabled: true,
1484 ..Default::default()
1485 }
1486 }
1487
1488 #[test]
1489 fn previous_version_snapshot_reasons_use_injected_lifecycle_and_discard() {
1490 let temp = TempFiles::new("read-reasons");
1491 let replica_path = temp.replica().to_str().expect("utf-8 path").to_owned();
1492 seed_replica(temp.replica(), 2);
1493 let replica = Connection::open(temp.replica()).expect("open replica");
1494 let container_path = previous_version_container_path(&replica_path);
1495 let config = enabled_config();
1496 let spec = PreviousVersionReadSpec {
1497 table: "things".to_owned(),
1498 ..Default::default()
1499 };
1500 let lifecycle = PreviousVersionLifecycle::default();
1501
1502 for config in [None, Some(&PreviousVersionContextConfig::default())] {
1504 let snapshot = previous_version_snapshot(
1505 &replica,
1506 Some(&replica_path),
1507 config,
1508 lifecycle,
1509 0,
1510 2,
1511 &spec,
1512 )
1513 .expect("snapshot");
1514 assert!(!snapshot.available);
1515 assert_eq!(snapshot.reason, Some(PreviousVersionReason::NotConfigured));
1516 assert_eq!(snapshot.state, "previousVersion");
1517 assert_eq!(snapshot.current_version, 2);
1518 assert!(snapshot.previous_version.is_none());
1519 assert!(snapshot.rows.is_empty());
1520 }
1521
1522 let refused =
1524 capture_previous_version_from_replica(&replica, &replica_path, 3, 2, &config, 1_000)
1525 .expect("capture");
1526 assert!(matches!(
1527 refused,
1528 CaptureOutcome::Refused {
1529 reason: PreviousVersionRefusalReason::NoPreviousDescriptor,
1530 ..
1531 }
1532 ));
1533 let snapshot = previous_version_snapshot(
1534 &replica,
1535 Some(&replica_path),
1536 Some(&config),
1537 lifecycle,
1538 1_000,
1539 2,
1540 &spec,
1541 )
1542 .expect("snapshot");
1543 assert_eq!(
1544 snapshot.reason,
1545 Some(PreviousVersionReason::NoPreviousDescriptor)
1546 );
1547
1548 let tiny = PreviousVersionContextConfig {
1550 max_bytes: 1,
1551 enabled: true,
1552 ..Default::default()
1553 };
1554 capture_previous_version_from_replica(&replica, &replica_path, 1, 2, &tiny, 1_000)
1555 .expect("capture");
1556 let snapshot = previous_version_snapshot(
1557 &replica,
1558 Some(&replica_path),
1559 Some(&config),
1560 lifecycle,
1561 1_000,
1562 2,
1563 &spec,
1564 )
1565 .expect("snapshot");
1566 assert_eq!(
1567 snapshot.reason,
1568 Some(PreviousVersionReason::CaptureExceededBudget)
1569 );
1570 assert!(!Path::new(&container_path).exists());
1571
1572 let recapture = || {
1574 let outcome = capture_previous_version_from_replica(
1575 &replica,
1576 &replica_path,
1577 1,
1578 2,
1579 &config,
1580 1_000,
1581 )
1582 .expect("capture");
1583 assert!(matches!(outcome, CaptureOutcome::Captured(_)));
1584 };
1585 recapture();
1586 let snapshot = previous_version_snapshot(
1587 &replica,
1588 Some(&replica_path),
1589 Some(&config),
1590 lifecycle,
1591 1_000,
1592 2,
1593 &spec,
1594 )
1595 .expect("snapshot");
1596 assert!(snapshot.available);
1597 assert_eq!(snapshot.previous_version, Some(1));
1598 assert_eq!(snapshot.current_version, 2);
1599 assert_eq!(snapshot.reason, None);
1600 assert_eq!(snapshot.state, "previousVersion");
1601 assert!(!snapshot.truncated);
1602 assert_eq!(snapshot.rows.len(), 2);
1603 assert_eq!(snapshot.rows[0]["s"], json!("hello"));
1604 assert_eq!(snapshot.rows[0]["b"], json!(true));
1605 assert_eq!(snapshot.rows[0]["by"], json!({ "$bytes": "010203" }));
1606
1607 let selected = PreviousVersionReadSpec {
1609 table: "things".to_owned(),
1610 row_ids: vec!["r2".to_owned()],
1611 limit: Some(1),
1612 };
1613 let snapshot = previous_version_snapshot(
1614 &replica,
1615 Some(&replica_path),
1616 Some(&config),
1617 lifecycle,
1618 1_000,
1619 2,
1620 &selected,
1621 )
1622 .expect("snapshot");
1623 assert_eq!(snapshot.rows.len(), 1);
1624 assert_eq!(snapshot.rows[0]["id"], json!("r2"));
1625 let unknown = PreviousVersionReadSpec {
1626 table: "absent".to_owned(),
1627 ..Default::default()
1628 };
1629 assert!(previous_version_snapshot(
1630 &replica,
1631 Some(&replica_path),
1632 Some(&config),
1633 lifecycle,
1634 1_000,
1635 2,
1636 &unknown
1637 )
1638 .expect_err("unknown previous table")
1639 .contains("unknown previous table"));
1640
1641 for (lifecycle, expected) in [
1644 (
1645 PreviousVersionLifecycle {
1646 lease_inactive: true,
1647 ..Default::default()
1648 },
1649 PreviousVersionReason::LeaseInactive,
1650 ),
1651 (
1652 PreviousVersionLifecycle {
1653 scope_revoked: true,
1654 ..Default::default()
1655 },
1656 PreviousVersionReason::ScopeRevoked,
1657 ),
1658 (
1659 PreviousVersionLifecycle {
1660 coverage_complete: true,
1661 ..Default::default()
1662 },
1663 PreviousVersionReason::CoverageComplete,
1664 ),
1665 ] {
1666 recapture();
1667 assert!(Path::new(&container_path).exists());
1668 let snapshot = previous_version_snapshot(
1669 &replica,
1670 Some(&replica_path),
1671 Some(&config),
1672 lifecycle,
1673 1_000,
1674 2,
1675 &spec,
1676 )
1677 .expect("snapshot");
1678 assert!(!snapshot.available, "{expected:?}");
1679 assert_eq!(snapshot.reason, Some(expected));
1680 assert!(snapshot.rows.is_empty());
1681 assert!(
1682 !Path::new(&container_path).exists(),
1683 "{expected:?} must physically remove the container"
1684 );
1685 assert_eq!(
1686 meta_get(&replica, PREVIOUS_VERSION_CONTEXT_KEY).unwrap(),
1687 None
1688 );
1689 }
1690 }
1691
1692 #[test]
1693 fn previous_version_ttl_applies_at_read_and_at_boot_with_injected_now() {
1694 let temp = TempFiles::new("ttl");
1695 let replica_path = temp.replica().to_str().expect("utf-8 path").to_owned();
1696 seed_replica(temp.replica(), 1);
1697 let replica = Connection::open(temp.replica()).expect("open replica");
1698 let container_path = previous_version_container_path(&replica_path);
1699 let config = enabled_config();
1700 let max_age_ms = config.max_age_ms;
1701 let spec = PreviousVersionReadSpec {
1702 table: "things".to_owned(),
1703 ..Default::default()
1704 };
1705 let lifecycle = PreviousVersionLifecycle::default();
1706 let capture_at = || {
1707 let outcome = capture_previous_version_from_replica(
1708 &replica,
1709 &replica_path,
1710 1,
1711 2,
1712 &config,
1713 1_000,
1714 )
1715 .expect("capture");
1716 assert!(matches!(outcome, CaptureOutcome::Captured(_)));
1717 };
1718
1719 capture_at();
1722 let snapshot = previous_version_snapshot(
1723 &replica,
1724 Some(&replica_path),
1725 Some(&config),
1726 lifecycle,
1727 1_000 + max_age_ms,
1728 2,
1729 &spec,
1730 )
1731 .expect("snapshot");
1732 assert!(snapshot.available);
1733 let snapshot = previous_version_snapshot(
1734 &replica,
1735 Some(&replica_path),
1736 Some(&config),
1737 lifecycle,
1738 1_001 + max_age_ms,
1739 2,
1740 &spec,
1741 )
1742 .expect("snapshot");
1743 assert_eq!(snapshot.reason, Some(PreviousVersionReason::Expired));
1744 assert!(!Path::new(&container_path).exists());
1745
1746 capture_at();
1751 reconcile_previous_version_at_boot(
1752 &replica,
1753 &replica_path,
1754 2,
1755 None,
1756 1_000 + max_age_ms * 10,
1757 )
1758 .expect("boot reconcile without a TTL");
1759 assert!(Path::new(&container_path).exists());
1760
1761 reconcile_previous_version_at_boot(
1763 &replica,
1764 &replica_path,
1765 2,
1766 Some(max_age_ms),
1767 1_000 + max_age_ms,
1768 )
1769 .expect("boot reconcile");
1770 assert!(Path::new(&container_path).exists());
1771 reconcile_previous_version_at_boot(
1772 &replica,
1773 &replica_path,
1774 2,
1775 Some(max_age_ms),
1776 1_001 + max_age_ms,
1777 )
1778 .expect("boot reconcile");
1779 assert!(!Path::new(&container_path).exists());
1780 }
1781
1782 #[test]
1783 fn previous_version_audit_flags_insert_missing_required_column() {
1784 let temp = TempFiles::new("audit-missing-required");
1785 seed_replica(temp.replica(), 1);
1786 let replica = Connection::open(temp.replica()).expect("open replica");
1787 let schema = schema(2);
1788
1789 let pending = vec![
1790 PendingCommitAudit {
1792 commit_id: "c1",
1793 operations: vec![("things", vec!["id", "s"], true)],
1794 },
1795 PendingCommitAudit {
1797 commit_id: "c2",
1798 operations: vec![("things", vec!["id", "s"], false)],
1799 },
1800 PendingCommitAudit {
1802 commit_id: "c3",
1803 operations: vec![("things", vec!["id", "project_id"], true)],
1804 },
1805 PendingCommitAudit {
1807 commit_id: "c4",
1808 operations: vec![("things", vec!["id", "removed"], true)],
1809 },
1810 ];
1811 let audit = build_previous_version_audit(&schema, &pending, 1, 2, 7);
1812 assert_eq!(audit.pending, 4);
1813 assert_eq!(audit.encodable, 2);
1814 assert_eq!(
1815 audit.incompatible,
1816 vec![
1817 PreviousVersionAuditEntry {
1818 commit_id: "c1".to_owned(),
1819 table: "things".to_owned(),
1820 reason: PreviousVersionAuditReason::MissingRequiredColumn,
1821 column: Some("project_id".to_owned()),
1822 },
1823 PreviousVersionAuditEntry {
1824 commit_id: "c4".to_owned(),
1825 table: "things".to_owned(),
1826 reason: PreviousVersionAuditReason::UnknownColumn,
1827 column: Some("removed".to_owned()),
1828 },
1829 ]
1830 );
1831 assert!(serde_json::to_string(&audit)
1832 .expect("encode audit")
1833 .contains("\"reason\":\"missing-required-column\""));
1834
1835 write_previous_version_audit(&replica, &audit).unwrap();
1836 assert_eq!(
1837 stored_previous_version_audit(&replica).unwrap(),
1838 Some(audit)
1839 );
1840 }
1841
1842 #[test]
1843 fn previous_version_audit_records_typed_reasons_and_round_trips() {
1844 let temp = TempFiles::new("audit");
1845 seed_replica(temp.replica(), 1);
1846 let replica = Connection::open(temp.replica()).expect("open replica");
1847 let schema = schema(2);
1848
1849 let pending = vec![
1850 PendingCommitAudit {
1851 commit_id: "c1",
1852 operations: vec![("things", vec!["id", "removed"], false)],
1853 },
1854 PendingCommitAudit {
1855 commit_id: "c2",
1856 operations: vec![("gone", vec![], false)],
1857 },
1858 PendingCommitAudit {
1859 commit_id: "c3",
1860 operations: vec![("things", vec!["id"], false)],
1861 },
1862 ];
1863 let audit = build_previous_version_audit(&schema, &pending, 1, 2, 42);
1864 assert_eq!(audit.v, 1);
1865 assert_eq!(audit.at_ms, 42);
1866 assert_eq!(audit.from_version, 1);
1867 assert_eq!(audit.to_version, 2);
1868 assert_eq!(audit.pending, 3);
1869 assert_eq!(audit.encodable, 1);
1870 assert!(!audit.truncated);
1871 assert_eq!(audit.incompatible.len(), 2);
1872 assert_eq!(
1873 audit.incompatible[0],
1874 PreviousVersionAuditEntry {
1875 commit_id: "c1".to_owned(),
1876 table: "things".to_owned(),
1877 reason: PreviousVersionAuditReason::UnknownColumn,
1878 column: Some("removed".to_owned()),
1879 }
1880 );
1881 assert_eq!(
1882 audit.incompatible[1],
1883 PreviousVersionAuditEntry {
1884 commit_id: "c2".to_owned(),
1885 table: "gone".to_owned(),
1886 reason: PreviousVersionAuditReason::UnknownTable,
1887 column: None,
1888 }
1889 );
1890 let encoded = serde_json::to_string(&audit).expect("encode audit");
1892 for forbidden in ["operations", "values", "payload", "envelope", "row_id"] {
1893 assert!(!encoded.contains(forbidden), "{encoded}");
1894 }
1895 assert!(encoded.contains("\"reason\":\"unknown-column\""));
1896 assert!(encoded.contains("\"reason\":\"unknown-table\""));
1897
1898 write_previous_version_audit(&replica, &audit).unwrap();
1899 assert_eq!(
1900 stored_previous_version_audit(&replica).unwrap(),
1901 Some(audit.clone())
1902 );
1903 meta_set(&replica, PREVIOUS_VERSION_AUDIT_KEY, "{\"v\":1}").unwrap();
1905 assert_eq!(stored_previous_version_audit(&replica).unwrap(), None);
1906 meta_set(
1907 &replica,
1908 PREVIOUS_VERSION_AUDIT_KEY,
1909 "{\"v\":1,\"atMs\":0,\"fromVersion\":1,\"toVersion\":2,\"pending\":0,\"encodable\":0,\"truncated\":false,\"incompatible\":[{\"commitId\":\"c\",\"table\":\"t\",\"reason\":\"other\"}]}",
1910 ).unwrap();
1911 assert_eq!(stored_previous_version_audit(&replica).unwrap(), None);
1912
1913 let many = (0..MAX_AUDIT_INCOMPATIBLE + 5)
1915 .map(|_index| PendingCommitAudit {
1916 commit_id: "c",
1917 operations: vec![("gone", vec![], false)],
1918 })
1919 .collect::<Vec<_>>();
1920 let audit = build_previous_version_audit(&schema, &many, 1, 2, 0);
1921 assert_eq!(audit.pending, MAX_AUDIT_INCOMPATIBLE as i64 + 5);
1922 assert_eq!(audit.encodable, 0);
1923 assert_eq!(audit.incompatible.len(), MAX_AUDIT_INCOMPATIBLE);
1924 assert!(audit.truncated);
1925 }
1926
1927 #[test]
1928 fn previous_version_limit_defaults_and_ceiling_match_ts() {
1929 assert_eq!(resolve_previous_version_limit(None), Ok(50));
1930 assert_eq!(resolve_previous_version_limit(Some(7)), Ok(7));
1931 assert_eq!(resolve_previous_version_limit(Some(999)), Ok(200));
1932 assert!(resolve_previous_version_limit(Some(0)).is_err());
1933 assert!(resolve_previous_version_limit(Some(-1)).is_err());
1934 }
1935
1936 struct TempFiles {
1939 paths: Vec<PathBuf>,
1940 }
1941
1942 impl TempFiles {
1943 fn new(label: &str) -> Self {
1944 let path = std::env::temp_dir().join(format!(
1945 "syncular-prev-context-{label}-{}",
1946 uuid::Uuid::new_v4()
1947 ));
1948 let paths = vec![
1949 path.clone(),
1950 PathBuf::from(previous_version_container_path(
1951 path.to_str().expect("utf-8 temp path"),
1952 )),
1953 ];
1954 Self { paths }
1955 }
1956
1957 fn replica(&self) -> &Path {
1958 &self.paths[0]
1959 }
1960 }
1961
1962 impl Drop for TempFiles {
1963 fn drop(&mut self) {
1964 for path in &self.paths {
1965 let _ = std::fs::remove_file(path);
1966 let _ = std::fs::remove_file(format!("{}-wal", path.display()));
1967 let _ = std::fs::remove_file(format!("{}-shm", path.display()));
1968 }
1969 }
1970 }
1971
1972 fn schema_json(version: i32) -> Value {
1973 json!({
1974 "version": version,
1975 "tables": [{
1976 "name": "things",
1977 "primaryKey": "id",
1978 "columns": [
1979 { "name": "id", "type": "string", "nullable": false },
1980 { "name": "project_id", "type": "string", "nullable": false },
1981 { "name": "s", "type": "string", "nullable": true },
1982 { "name": "i", "type": "integer", "nullable": true },
1983 { "name": "f", "type": "float", "nullable": true },
1984 { "name": "b", "type": "boolean", "nullable": true },
1985 { "name": "j", "type": "json", "nullable": true },
1986 { "name": "by", "type": "bytes", "nullable": true },
1987 { "name": "cr", "type": "crdt", "nullable": true },
1988 { "name": "br", "type": "blob_ref", "nullable": true },
1989 { "name": "meta", "type": "string", "nullable": true }
1990 ],
1991 "scopes": []
1992 }, {
1993 "name": "notes",
1994 "primaryKey": "id",
1995 "columns": [
1996 { "name": "id", "type": "string", "nullable": false },
1997 { "name": "body", "type": "string", "nullable": true }
1998 ],
1999 "scopes": []
2000 }]
2001 })
2002 }
2003
2004 fn schema(version: i32) -> ClientSchema {
2005 parse_schema_json(&schema_json(version)).expect("valid test schema")
2006 }
2007
2008 fn seed_replica(path: &Path, rows: i64) {
2010 let replica = Connection::open(path).expect("open replica");
2011 replica
2012 .execute_batch(
2013 "CREATE TABLE _syncular_meta (key TEXT PRIMARY KEY, value TEXT NOT NULL);
2014 CREATE TABLE things (id, project_id, s, i, f, b, j, by, cr, br, meta);
2015 CREATE TABLE notes (id, body);",
2016 )
2017 .expect("create local tables");
2018 for index in 0..rows {
2019 replica
2020 .execute(
2021 "INSERT INTO things(rowid, id, project_id, s, i, f, b, j, by, cr, br, meta)
2022 VALUES (?1, ?2, 'p1', 'hello', 7, 1.5, 1, '{\"a\":1}',
2023 x'010203', x'0908', 'blob:xyz', 'm1')",
2024 rusqlite::params![index + 1, format!("r{}", index + 1)],
2025 )
2026 .expect("insert row");
2027 }
2028 replica
2029 .execute("INSERT INTO notes(id, body) VALUES ('n1', 'note')", [])
2030 .expect("insert note");
2031 set_local_schema_descriptor(&replica, &schema(1)).expect("descriptor");
2034 meta_set(&replica, LOCAL_SCHEMA_VERSION_KEY, "1").unwrap();
2035 }
2036
2037 fn descriptor() -> LocalSchemaDescriptor {
2038 build_local_schema_descriptor(&schema(1))
2039 }
2040
2041 #[test]
2042 fn previous_version_descriptor_round_trips_and_rejects_unknown_shapes() {
2043 let conn = Connection::open_in_memory().expect("open in-memory");
2044 conn.execute_batch(
2045 "CREATE TABLE _syncular_meta (key TEXT PRIMARY KEY, value TEXT NOT NULL)",
2046 )
2047 .expect("create meta");
2048 let schema = schema(4);
2049 set_local_schema_descriptor(&conn, &schema).expect("descriptor");
2050
2051 let stored = meta_get(&conn, LOCAL_SCHEMA_DESCRIPTOR_KEY)
2052 .unwrap()
2053 .expect("descriptor written");
2054 let parsed: Value = serde_json::from_str(&stored).expect("descriptor is JSON");
2055 assert_eq!(parsed.as_object().expect("object").len(), 3, "{stored}");
2056 assert_eq!(parsed["v"], json!(1));
2057 assert_eq!(parsed["version"], json!(4));
2058 let tables = parsed["tables"].as_array().expect("tables array");
2059 assert_eq!(tables.len(), 2);
2060 assert_eq!(tables[0]["name"], json!("things"));
2061 assert_eq!(tables[0]["primaryKey"], json!("id"));
2062 assert_eq!(
2063 tables[0]["columns"][1],
2064 json!({ "name": "project_id", "type": "string" })
2065 );
2066 assert_eq!(
2067 tables[0]["columns"][5],
2068 json!({ "name": "b", "type": "boolean" })
2069 );
2070 assert_eq!(
2071 tables[0]["columns"][7],
2072 json!({ "name": "by", "type": "bytes" })
2073 );
2074
2075 assert_eq!(
2077 decode_local_schema_descriptor(&stored).expect("strict decode"),
2078 build_local_schema_descriptor(&schema)
2079 );
2080 assert_eq!(
2081 load_local_schema_descriptor(&conn)
2082 .unwrap()
2083 .expect("loaded"),
2084 build_local_schema_descriptor(&schema)
2085 );
2086
2087 for raw in [
2089 r#"{"v":1,"version":4,"tables":[],"extra":1}"#,
2090 r#"{"v":2,"version":4,"tables":[]}"#,
2091 r#"{"v":1,"version":4}"#,
2092 r#"{"v":1,"version":-1,"tables":[]}"#,
2093 r#"{"v":1,"version":4,"tables":{}}"#,
2094 r#"{"v":1,"version":4,"tables":[{"name":"t","primaryKey":"id"}]}"#,
2095 r#"{"v":1,"version":4,"tables":[{"name":"t","primaryKey":"id","columns":[{"name":"a","type":"nope"}]}]}"#,
2096 r#"{"v":1,"version":4,"tables":[{"name":"t","primaryKey":"id","columns":[]}],"x":0}"#,
2097 "not json",
2098 ] {
2099 assert!(
2100 decode_local_schema_descriptor(raw).is_err(),
2101 "must reject {raw}"
2102 );
2103 }
2104
2105 meta_set(&conn, LOCAL_SCHEMA_DESCRIPTOR_KEY, "{broken").unwrap();
2107 assert_eq!(load_local_schema_descriptor(&conn).unwrap(), None);
2108 }
2109
2110 #[test]
2111 fn previous_version_capture_writes_sibling_container_with_readable_rows() {
2112 let temp = TempFiles::new("capture");
2113 let replica_path = temp.replica().to_str().expect("utf-8 path").to_owned();
2114 seed_replica(temp.replica(), 2);
2115 let replica = Connection::open(temp.replica()).expect("open replica");
2116
2117 let outcome = capture_previous_version_from_replica(
2118 &replica,
2119 &replica_path,
2120 1,
2121 2,
2122 &PreviousVersionContextConfig::default(),
2123 1_700_000_000_000,
2124 )
2125 .expect("capture");
2126 let CaptureOutcome::Captured(measurement) = outcome else {
2127 panic!("expected a capture, got {outcome:?}");
2128 };
2129 assert_eq!(measurement.tables, 2);
2130 assert_eq!(measurement.rows, 3);
2131 assert!(measurement.bytes > 0);
2132
2133 let container_path = previous_version_container_path(&replica_path);
2135 assert!(Path::new(&container_path).exists());
2136 let visible: i64 = replica
2137 .query_row(
2138 "SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = ?1",
2139 rusqlite::params![CONTAINER_TABLE],
2140 |row| row.get(0),
2141 )
2142 .expect("count replica tables");
2143 assert_eq!(visible, 0);
2144
2145 let container = Connection::open(&container_path).expect("open container");
2146 let record = read_previous_version_container(&container)
2147 .expect("read record")
2148 .expect("record present");
2149 assert_eq!(record.previous_version, 1);
2150 assert_eq!(record.current_version, 2);
2151 assert_eq!(record.rows, 3);
2152 assert_eq!(record.bytes, measurement.bytes);
2153 assert_eq!(record.created_at_ms, 1_700_000_000_000);
2154 assert_eq!(record.tables.len(), 2);
2155 assert_eq!(record.tables[0].columns.len(), 11);
2156
2157 let table = record.table("things").expect("things in the record");
2158 let (rows, truncated) =
2159 read_previous_version_rows(&container, table, &[], 50).expect("read rows");
2160 assert!(!truncated);
2161 assert_eq!(rows.len(), 2);
2162 let row = &rows[0];
2163 assert_eq!(row["id"], json!("r1"));
2164 assert_eq!(row["s"], json!("hello"));
2165 assert_eq!(row["i"], json!(7));
2166 assert_eq!(row["f"], json!(1.5));
2167 assert_eq!(row["b"], json!(true));
2168 assert_eq!(row["j"], json!("{\"a\":1}"));
2169 assert_eq!(row["br"], json!("blob:xyz"));
2170 assert_eq!(row["by"], json!({ "$bytes": "010203" }));
2171 assert_eq!(row["cr"], json!({ "$bytes": "0908" }));
2172 assert!(!row.contains_key("_syncular_version"));
2173 assert!(!row.contains_key("_sync_version"));
2174
2175 let (selected, _) =
2177 read_previous_version_rows(&container, table, &["r2".to_owned()], 50).expect("select");
2178 assert_eq!(selected.len(), 1);
2179 assert_eq!(selected[0]["id"], json!("r2"));
2180 let (missing, _) =
2181 read_previous_version_rows(&container, table, &["absent".to_owned()], 50)
2182 .expect("select missing");
2183 assert!(missing.is_empty());
2184 let (limited, truncated) =
2185 read_previous_version_rows(&container, table, &[], 0).expect("limit 0");
2186 assert!(limited.is_empty());
2187 assert!(truncated);
2188 }
2189
2190 #[test]
2191 fn previous_version_each_budget_refuses_and_leaves_no_file() {
2192 let temp = TempFiles::new("budget");
2193 let replica_path = temp.replica().to_str().expect("utf-8 path").to_owned();
2194 seed_replica(temp.replica(), 2);
2195 let replica = Connection::open(temp.replica()).expect("open replica");
2196 let container_path = previous_version_container_path(&replica_path);
2197
2198 let cases = [
2199 (
2200 "maxTables",
2201 PreviousVersionContextConfig {
2202 max_tables: 1,
2203 ..Default::default()
2204 },
2205 0,
2206 false,
2207 ),
2208 (
2209 "maxRows",
2210 PreviousVersionContextConfig {
2211 max_rows: 1,
2212 ..Default::default()
2213 },
2214 2,
2215 false,
2216 ),
2217 (
2218 "maxBytes",
2219 PreviousVersionContextConfig {
2220 max_bytes: 1,
2221 ..Default::default()
2222 },
2223 3,
2224 true,
2225 ),
2226 (
2227 "maxRowBytes",
2228 PreviousVersionContextConfig {
2229 max_row_bytes: 1,
2230 ..Default::default()
2231 },
2232 3,
2233 false,
2234 ),
2235 ];
2236 for (label, config, expected_rows, bytes_measured) in cases {
2237 let outcome =
2238 capture_previous_version_from_replica(&replica, &replica_path, 1, 2, &config, 1)
2239 .unwrap_or_else(|error| panic!("{label}: {error}"));
2240 let CaptureOutcome::Refused {
2241 reason,
2242 measurement,
2243 } = outcome
2244 else {
2245 panic!("{label}: expected a refusal, got {outcome:?}");
2246 };
2247 assert_eq!(
2248 reason,
2249 PreviousVersionRefusalReason::CaptureExceededBudget,
2250 "{label}"
2251 );
2252 assert_eq!(measurement.tables, 2, "{label}");
2253 assert_eq!(measurement.rows, expected_rows, "{label}");
2254 if bytes_measured {
2255 assert!(measurement.bytes > 1, "{label}");
2256 } else {
2257 assert_eq!(measurement.bytes, 0, "{label}");
2258 }
2259 assert!(!Path::new(&container_path).exists(), "{label}");
2261 let refusal = meta_get(&replica, PREVIOUS_VERSION_CONTEXT_KEY)
2262 .unwrap()
2263 .unwrap_or_else(|| panic!("{label}: refusal recorded"));
2264 assert!(refusal.contains("capture-exceeded-budget"), "{refusal}");
2265 }
2266 }
2267
2268 #[test]
2269 fn previous_version_missing_or_stale_descriptor_refuses_without_a_file() {
2270 let temp = TempFiles::new("no-descriptor");
2271 let replica_path = temp.replica().to_str().expect("utf-8 path").to_owned();
2272 seed_replica(temp.replica(), 1);
2273 let replica = Connection::open(temp.replica()).expect("open replica");
2274 let container_path = previous_version_container_path(&replica_path);
2275
2276 meta_delete(&replica, LOCAL_SCHEMA_DESCRIPTOR_KEY).unwrap();
2278 let outcome = capture_previous_version_from_replica(
2279 &replica,
2280 &replica_path,
2281 1,
2282 2,
2283 &PreviousVersionContextConfig::default(),
2284 1,
2285 )
2286 .expect("capture");
2287 assert_eq!(
2288 outcome,
2289 CaptureOutcome::Refused {
2290 reason: PreviousVersionRefusalReason::NoPreviousDescriptor,
2291 measurement: CaptureMeasurement::default(),
2292 }
2293 );
2294 assert!(!Path::new(&container_path).exists());
2295 let refusal = meta_get(&replica, PREVIOUS_VERSION_CONTEXT_KEY)
2296 .unwrap()
2297 .expect("refusal");
2298 assert!(refusal.contains("no-previous-descriptor"), "{refusal}");
2299
2300 meta_set(
2302 &replica,
2303 LOCAL_SCHEMA_DESCRIPTOR_KEY,
2304 &encode_local_schema_descriptor(&descriptor()),
2305 )
2306 .unwrap();
2307 let outcome = capture_previous_version_from_replica(
2308 &replica,
2309 &replica_path,
2310 3,
2311 4,
2312 &PreviousVersionContextConfig::default(),
2313 1,
2314 )
2315 .expect("capture");
2316 assert!(matches!(
2317 outcome,
2318 CaptureOutcome::Refused {
2319 reason: PreviousVersionRefusalReason::NoPreviousDescriptor,
2320 ..
2321 }
2322 ));
2323 assert!(!Path::new(&container_path).exists());
2324 }
2325
2326 #[test]
2327 fn previous_version_discard_removes_the_container_path() {
2328 let temp = TempFiles::new("discard");
2329 let replica_path = temp.replica().to_str().expect("utf-8 path").to_owned();
2330 seed_replica(temp.replica(), 1);
2331 let replica = Connection::open(temp.replica()).expect("open replica");
2332 let container_path = previous_version_container_path(&replica_path);
2333
2334 let outcome = capture_previous_version_from_replica(
2335 &replica,
2336 &replica_path,
2337 1,
2338 2,
2339 &PreviousVersionContextConfig::default(),
2340 1,
2341 )
2342 .expect("capture");
2343 assert!(matches!(outcome, CaptureOutcome::Captured(_)));
2344 assert!(Path::new(&container_path).exists());
2345 meta_set(&replica, PREVIOUS_VERSION_AUDIT_KEY, "{\"v\":1}").unwrap();
2346
2347 let discarded = discard_previous_version(&replica, &replica_path).expect("discard");
2348 assert!(discarded.present);
2349 assert!(discarded.discarded);
2350 assert!(!Path::new(&container_path).exists());
2352 assert!(!Path::new(&format!("{container_path}-wal")).exists());
2353 assert!(!Path::new(&format!("{container_path}-shm")).exists());
2354 assert_eq!(
2355 meta_get(&replica, PREVIOUS_VERSION_CONTEXT_KEY).unwrap(),
2356 None
2357 );
2358 assert_eq!(
2359 meta_get(&replica, PREVIOUS_VERSION_AUDIT_KEY).unwrap(),
2360 None
2361 );
2362
2363 let again = discard_previous_version(&replica, &replica_path).expect("discard again");
2365 assert!(!again.present);
2366 assert!(!again.discarded);
2367 }
2368
2369 #[test]
2370 fn previous_version_torn_container_never_blocks_sweep_discard_or_open() {
2371 let temp = TempFiles::new("torn");
2372 let replica_path = temp.replica().to_str().expect("utf-8 path").to_owned();
2373 let container_path = previous_version_container_path(&replica_path);
2374
2375 std::fs::write(&container_path, b"not a sqlite database").expect("write garbage");
2378 let replica = Connection::open(temp.replica()).expect("open replica");
2379 replica
2380 .execute_batch(
2381 "CREATE TABLE _syncular_meta (key TEXT PRIMARY KEY, value TEXT NOT NULL)",
2382 )
2383 .expect("create meta");
2384
2385 sweep_previous_version_container(&replica, &replica_path)
2387 .expect("a torn container must not fail the sweep");
2388 assert!(!Path::new(&container_path).exists());
2389
2390 std::fs::write(&container_path, b"not a sqlite database").expect("write garbage again");
2392 let discarded = discard_previous_version(&replica, &replica_path)
2393 .expect("a torn container must not fail the discard");
2394 assert!(discarded.present);
2395 assert!(discarded.discarded);
2396 assert!(!Path::new(&container_path).exists());
2397
2398 std::fs::write(&container_path, b"not a sqlite database")
2401 .expect("write garbage a third time");
2402 drop(replica);
2403 std::fs::remove_file(temp.replica()).expect("remove the bare replica");
2404 seed_replica(temp.replica(), 1);
2405 {
2406 SyncClient::open_path(
2407 "pvc".to_owned(),
2408 &schema_json(2),
2409 ClientLimits::default(),
2410 &replica_path,
2411 )
2412 .expect("a torn container must not block the client open");
2413 }
2414 assert!(!Path::new(&container_path).exists());
2415 }
2416
2417 #[test]
2418 fn previous_version_same_version_open_discards_a_container_it_did_not_bump_to() {
2419 let temp = TempFiles::new("same-version-orphan");
2425 let replica_path = temp.replica().to_str().expect("utf-8 path").to_owned();
2426 seed_replica(temp.replica(), 1);
2427 let replica = Connection::open(temp.replica()).expect("open replica");
2428 let container_path = previous_version_container_path(&replica_path);
2429
2430 let outcome = capture_previous_version_from_replica(
2431 &replica,
2432 &replica_path,
2433 1,
2434 2,
2435 &PreviousVersionContextConfig::default(),
2436 1,
2437 )
2438 .expect("capture");
2439 assert!(matches!(outcome, CaptureOutcome::Captured(_)));
2440 assert!(Path::new(&container_path).exists());
2441 assert_eq!(
2443 meta_get(&replica, LOCAL_SCHEMA_VERSION_KEY)
2444 .unwrap()
2445 .as_deref(),
2446 Some("1")
2447 );
2448 drop(replica);
2449
2450 {
2453 let client = SyncClient::open_path(
2454 "pvc".to_owned(),
2455 &schema_json(1),
2456 ClientLimits::default(),
2457 &replica_path,
2458 )
2459 .expect("the old-schema open must succeed");
2460 let rows = client
2461 .query("SELECT COUNT(*) AS c FROM things", &[])
2462 .expect("the client still serves ordinary queries");
2463 assert_eq!(rows.len(), 1);
2464 }
2465 assert!(!Path::new(&container_path).exists());
2467 let inspect = Connection::open(&replica_path).expect("inspect after open");
2468 assert_eq!(
2469 meta_get(&inspect, PREVIOUS_VERSION_CONTEXT_KEY).unwrap(),
2470 None
2471 );
2472 assert_eq!(
2473 meta_get(&inspect, PREVIOUS_VERSION_AUDIT_KEY).unwrap(),
2474 None
2475 );
2476 assert_eq!(
2477 inspect
2478 .query_row(
2479 "SELECT COUNT(*) FROM sqlite_master WHERE name = ?1",
2480 rusqlite::params![CONTAINER_TABLE],
2481 |row| row.get::<_, i64>(0),
2482 )
2483 .expect("replica table count"),
2484 0
2485 );
2486
2487 let temp = TempFiles::new("same-version-live");
2490 let replica_path = temp.replica().to_str().expect("utf-8 path").to_owned();
2491 seed_replica(temp.replica(), 1);
2492 let replica = Connection::open(temp.replica()).expect("open replica");
2493 capture_previous_version_from_replica(
2494 &replica,
2495 &replica_path,
2496 1,
2497 1,
2498 &PreviousVersionContextConfig::default(),
2499 1,
2500 )
2501 .expect("capture");
2502 drop(replica);
2503 let container_path = previous_version_container_path(&replica_path);
2504 assert!(Path::new(&container_path).exists());
2505 SyncClient::open_path(
2506 "pvc".to_owned(),
2507 &schema_json(1),
2508 ClientLimits::default(),
2509 &replica_path,
2510 )
2511 .expect("open v1 beside a matching container");
2512 assert!(Path::new(&container_path).exists());
2513 }
2514
2515 #[test]
2516 fn previous_version_client_writes_descriptor_and_captures_on_bump() {
2517 let temp = TempFiles::new("client");
2518 let replica_path = temp.replica().to_str().expect("utf-8 path").to_owned();
2519 let container_path = previous_version_container_path(&replica_path);
2520
2521 {
2524 let mut client = SyncClient::open_path(
2525 "pvc".to_owned(),
2526 &schema_json(1),
2527 ClientLimits::default(),
2528 &replica_path,
2529 )
2530 .expect("open v1");
2531 client
2532 .mutate(vec![Mutation::Upsert {
2533 table: "things".to_owned(),
2534 values: Map::from_iter([
2535 ("id".to_owned(), json!("r1")),
2536 ("project_id".to_owned(), json!("p1")),
2537 ("s".to_owned(), json!("hello")),
2538 ]),
2539 base_version: None,
2540 }])
2541 .expect("seed a local row");
2542 }
2543 {
2544 let inspect = Connection::open(&replica_path).expect("inspect v1");
2545 let descriptor = load_local_schema_descriptor(&inspect)
2546 .unwrap()
2547 .expect("descriptor after install");
2548 assert_eq!(descriptor.version, 1);
2549 assert_eq!(descriptor.tables.len(), 2);
2550 assert_eq!(
2551 descriptor.table("things").expect("things").columns.len(),
2552 11
2553 );
2554 }
2555 assert!(!Path::new(&container_path).exists());
2556
2557 {
2559 let inspect = Connection::open(&replica_path).expect("inspect");
2560 meta_delete(&inspect, LOCAL_SCHEMA_DESCRIPTOR_KEY).unwrap();
2561 }
2562 {
2563 let _client = SyncClient::open_path(
2564 "pvc".to_owned(),
2565 &schema_json(1),
2566 ClientLimits::default(),
2567 &replica_path,
2568 )
2569 .expect("reopen v1");
2570 }
2571 {
2572 let inspect = Connection::open(&replica_path).expect("inspect v1 again");
2573 assert_eq!(
2574 load_local_schema_descriptor(&inspect)
2575 .unwrap()
2576 .expect("backfilled")
2577 .version,
2578 1
2579 );
2580 }
2581
2582 {
2585 let limits = ClientLimits {
2586 previous_version_context: Some(PreviousVersionContextConfig {
2587 enabled: true,
2588 ..Default::default()
2589 }),
2590 ..Default::default()
2591 };
2592 SyncClient::open_path("pvc".to_owned(), &schema_json(2), limits, &replica_path)
2593 .expect("bump to v2");
2594 }
2595 assert!(Path::new(&container_path).exists());
2596 {
2597 let inspect = Connection::open(&replica_path).expect("inspect v2");
2598 assert_eq!(
2599 load_local_schema_descriptor(&inspect)
2600 .unwrap()
2601 .expect("descriptor after bump")
2602 .version,
2603 2
2604 );
2605 assert_eq!(
2606 inspect
2607 .query_row(
2608 "SELECT COUNT(*) FROM sqlite_master WHERE name = ?1",
2609 rusqlite::params![CONTAINER_TABLE],
2610 |row| row.get::<_, i64>(0),
2611 )
2612 .expect("replica table count"),
2613 0
2614 );
2615 }
2616 {
2617 let container = Connection::open(&container_path).expect("open container");
2618 let record = read_previous_version_container(&container)
2619 .expect("read record")
2620 .expect("captured record");
2621 assert_eq!(record.previous_version, 1);
2622 assert_eq!(record.current_version, 2);
2623 assert_eq!(record.rows, 1);
2624 let (rows, _) = read_previous_version_rows(
2625 &container,
2626 record.table("things").expect("things"),
2627 &[],
2628 50,
2629 )
2630 .expect("read rows");
2631 assert_eq!(rows.len(), 1);
2632 assert_eq!(rows[0]["s"], json!("hello"));
2633 }
2634
2635 {
2637 let inspect = Connection::open(&replica_path).expect("inspect v2");
2638 let outcome = discard_previous_version(&inspect, &replica_path).expect("discard");
2639 assert!(outcome.present && outcome.discarded);
2640 }
2641 assert!(!Path::new(&container_path).exists());
2642 }
2643}