Skip to main content

syncular_client/
previous_version.rs

1//! RFC 0005 previous-version context — phase 1 (the storage half).
2//!
3//! A schema bump wipes the local replica (§7.4.3), so the rows the app could
4//! see a moment ago are gone before the server can re-bootstrap them. This
5//! module persists the SEMANTIC schema descriptor (D1), captures a bounded,
6//! typed, read-only copy of the pre-reset rows into a SECOND database file
7//! beside the replica (D2/D3/A4), and discards that file.
8//!
9//! Three rules shape the implementation:
10//!
11//! - Storage is a sibling FILE `<replica path>.prev-context`, never a table in
12//!   the replica connection. The guarantee is stated narrowly: an older
13//!   client's ordinary query connection does not attach this file, so no SQL it
14//!   runs can reach the container. It is NOT protection against arbitrary
15//!   filesystem access, and the filename is a code constant (not a secret).
16//! - The capture is typed by the OLD schema's [`LocalSchemaDescriptor`]. SQLite
17//!   affinity cannot recover a semantic type (`boolean` and `integer` are both
18//!   INTEGER; `string`, `json` and `blob_ref` are all TEXT), so a
19//!   descriptor-free capture would be undecodable. It is NEVER inferred.
20//! - Every budget is measured BEFORE any row is materialized. An over-budget
21//!   capture stores nothing and leaves no file behind.
22//!
23//! Phase 2 (commands, bridges, conformance) layers the read surface, the
24//! compatibility audit and the lifetime triggers on top; this module owns only
25//! the descriptor, the container file and its capture/discard semantics.
26
27use 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
39/// `_syncular_meta` key holding the persisted [`LocalSchemaDescriptor`] (D1).
40pub const LOCAL_SCHEMA_DESCRIPTOR_KEY: &str = "localSchemaDescriptor";
41/// `_syncular_meta` key holding only a capture REFUSAL. The successful record
42/// lives inside the container file, so this key is absent when a container is
43/// present. It stays in the replica because it is small and typed.
44pub const PREVIOUS_VERSION_CONTEXT_KEY: &str = "previousVersionContext";
45/// `_syncular_meta` key holding the pre-reset compatibility audit (D6, phase 2).
46/// Declared here because a discard clears it.
47pub const PREVIOUS_VERSION_AUDIT_KEY: &str = "previousVersionAudit";
48
49/// RFC 0005 D3: the container is the sibling file `<replica path>.prev-context`.
50/// The name is code-derived and never persisted in the replica database.
51pub const PREVIOUS_VERSION_CONTAINER_SUFFIX: &str = ".prev-context";
52
53/// The one non-reserved row table INSIDE the container file (D3).
54const CONTAINER_TABLE: &str = "syncular_prev_context";
55/// Container-local metadata table holding the [`PreviousVersionRecord`].
56const CONTAINER_META_TABLE: &str = "_syncular_prev_context_meta";
57
58const DESCRIPTOR_VERSION: i64 = 1;
59const RECORD_VERSION: i64 = 1;
60/// Rows copied per `INSERT` batch (A4 step 5).
61const COPY_BATCH_ROWS: i64 = 500;
62
63/// D2/D8/A4: capture bounds. Every bound is measured before materializing.
64#[derive(Debug, Clone, Copy, PartialEq, Eq)]
65pub struct PreviousVersionContextConfig {
66    /// Feature flag, default off. A feature flag, never a security control.
67    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    /// D7/A1 TTL. Enforced ONLY by an aware binary, at boot and at read. It is
73    /// a hygiene bound for aware binaries and says NOTHING about an unaware
74    /// same-schema rollback, which runs none of this code and therefore leaves
75    /// the container in place indefinitely.
76    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    /// D8: every bound is a positive safe integer — the same validation the TS
94    /// core runs on `previousVersionContext`, with the same error code.
95    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
114/// JavaScript's `Number.MAX_SAFE_INTEGER`; a Rust host crosses a JSON boundary,
115/// so a bound past it could not survive the round trip.
116const 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
122/// The sibling container path for a file-backed replica. In-memory replicas
123/// have no path and therefore no container (the feature resolves as off).
124pub fn previous_version_container_path(replica_path: &str) -> String {
125    format!("{replica_path}{PREVIOUS_VERSION_CONTAINER_SUFFIX}")
126}
127
128// ---------------------------------------------------------------------------
129// Descriptor (D1)
130// ---------------------------------------------------------------------------
131
132#[derive(Debug, Clone, PartialEq, Eq)]
133pub struct DescriptorColumn {
134    pub name: String,
135    /// The SEMANTIC local type (`localColumnType`): for an encrypted column
136    /// that is its declared app type, because the local mirror is plaintext.
137    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
159/// D1: the semantic local types of every table in the running generated
160/// schema, built from the compiled LOCAL columns — never from SQLite affinity.
161pub 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
208/// Strict shape comparison: exactly these keys, nothing else. Equivalent to
209/// the TS core's sorted-key/length check.
210fn 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
214/// A non-negative safe integer, mirroring the TS `isCount` predicate.
215fn 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
264/// Strict decode: an unknown shape is corruption, never a best guess.
265pub 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
291/// D1: persist the descriptor. Callers write this beside every write of
292/// [`LOCAL_SCHEMA_VERSION_KEY`]; a descriptor without a matching marker is
293/// treated as absent at reset time.
294pub 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
308/// Load and strictly decode the stored descriptor; `None` when absent/corrupt.
309pub fn load_local_schema_descriptor(conn: &Connection) -> Option<LocalSchemaDescriptor> {
310    let raw = meta_get(conn, LOCAL_SCHEMA_DESCRIPTOR_KEY)?;
311    decode_local_schema_descriptor(&raw).ok()
312}
313
314// ---------------------------------------------------------------------------
315// Container file (D3)
316// ---------------------------------------------------------------------------
317
318/// Drop both container tables. Also the unconditional orphan sweep (D5).
319pub fn drop_previous_version_container(conn: &Connection) -> Result<(), String> {
320    conn.execute_batch(&format!(
321        "DROP TABLE IF EXISTS {CONTAINER_TABLE}; DROP TABLE IF EXISTS {CONTAINER_META_TABLE};"
322    ))
323    .map_err(|error| error.to_string())
324}
325
326/// Successful capture record, stored INSIDE the container file.
327#[derive(Debug, Clone, PartialEq, Eq)]
328pub struct PreviousVersionRecord {
329    pub previous_version: i32,
330    pub current_version: i32,
331    pub tables: Vec<DescriptorTable>,
332    pub rows: i64,
333    pub bytes: i64,
334    pub created_at_ms: i64,
335}
336
337impl PreviousVersionRecord {
338    /// The captured shape of one table — what a read needs to decode payloads.
339    pub fn table(&self, name: &str) -> Option<&DescriptorTable> {
340        self.tables.iter().find(|table| table.name == name)
341    }
342}
343
344fn decode_record(raw: &str) -> Result<PreviousVersionRecord, String> {
345    let parsed: Value =
346        serde_json::from_str(raw).map_err(|_| local_corrupt("previous-version context"))?;
347    let Value::Object(record) = &parsed else {
348        return Err(local_corrupt("previous-version context"));
349    };
350    if !has_exact_keys(
351        record,
352        &[
353            "bytes",
354            "createdAtMs",
355            "currentVersion",
356            "previousVersion",
357            "rows",
358            "tables",
359            "v",
360        ],
361    ) || as_count(record.get("v")) != Some(RECORD_VERSION)
362    {
363        return Err(local_corrupt("previous-version context"));
364    }
365    let count = |key: &str| {
366        as_count(record.get(key))
367            .and_then(|value| i32::try_from(value).ok())
368            .ok_or_else(|| local_corrupt("previous-version context"))
369    };
370    let Some(Value::Array(tables)) = record.get("tables") else {
371        return Err(local_corrupt("previous-version context"));
372    };
373    Ok(PreviousVersionRecord {
374        previous_version: count("previousVersion")?,
375        current_version: count("currentVersion")?,
376        tables: tables
377            .iter()
378            .map(decode_descriptor_table)
379            .collect::<Result<Vec<_>, _>>()?,
380        rows: as_count(record.get("rows"))
381            .ok_or_else(|| local_corrupt("previous-version context"))?,
382        bytes: as_count(record.get("bytes"))
383            .ok_or_else(|| local_corrupt("previous-version context"))?,
384        created_at_ms: as_count(record.get("createdAtMs"))
385            .ok_or_else(|| local_corrupt("previous-version context"))?,
386    })
387}
388
389fn encode_record(record: &PreviousVersionRecord) -> String {
390    let tables = record
391        .tables
392        .iter()
393        .map(|table| {
394            let columns = table
395                .columns
396                .iter()
397                .map(|column| json!({ "name": column.name, "type": column.ty.name() }))
398                .collect::<Vec<_>>();
399            json!({
400                "name": table.name,
401                "primaryKey": table.primary_key,
402                "columns": columns,
403            })
404        })
405        .collect::<Vec<_>>();
406    json!({
407        "v": RECORD_VERSION,
408        "previousVersion": record.previous_version,
409        "currentVersion": record.current_version,
410        "tables": tables,
411        "rows": record.rows,
412        "bytes": record.bytes,
413        "createdAtMs": record.created_at_ms,
414    })
415    .to_string()
416}
417
418fn write_record(conn: &Connection, record: &PreviousVersionRecord) -> Result<(), String> {
419    conn.execute_batch(&format!(
420        "CREATE TABLE IF NOT EXISTS {CONTAINER_META_TABLE} (
421           id INTEGER PRIMARY KEY CHECK (id = 1),
422           record TEXT NOT NULL);
423         DELETE FROM {CONTAINER_META_TABLE};"
424    ))
425    .map_err(|error| error.to_string())?;
426    conn.execute(
427        &format!("INSERT INTO {CONTAINER_META_TABLE}(id, record) VALUES (1, ?1)"),
428        rusqlite::params![encode_record(record)],
429    )
430    .map_err(|error| error.to_string())?;
431    Ok(())
432}
433
434/// Read the container's own metadata record; `None` when absent/corrupt.
435pub fn read_previous_version_container(
436    conn: &Connection,
437) -> Result<Option<PreviousVersionRecord>, String> {
438    let present: i64 = conn
439        .query_row(
440            "SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = ?1",
441            rusqlite::params![CONTAINER_META_TABLE],
442            |row| row.get(0),
443        )
444        .map_err(|error| error.to_string())?;
445    if present == 0 {
446        return Ok(None);
447    }
448    let raw: Option<String> = conn
449        .query_row(
450            &format!("SELECT record FROM {CONTAINER_META_TABLE} WHERE id = 1"),
451            [],
452            |row| row.get(0),
453        )
454        .optional()
455        .map_err(|error| error.to_string())?;
456    Ok(raw.and_then(|raw| decode_record(&raw).ok()))
457}
458
459/// D7: read one previous table from the container, `limit + 1` rows so the
460/// caller can report truncation. `rowIds` empty means every row of the table.
461pub fn read_previous_version_rows(
462    conn: &Connection,
463    table: &DescriptorTable,
464    row_ids: &[String],
465    limit: i64,
466) -> Result<(Vec<Map<String, Value>>, bool), String> {
467    let filter = if row_ids.is_empty() {
468        String::new()
469    } else {
470        let placeholders = (0..row_ids.len())
471            .map(|index| format!("?{}", index + 2))
472            .collect::<Vec<_>>()
473            .join(", ");
474        format!(" AND row_id IN ({placeholders})")
475    };
476    let mut params: Vec<rusqlite::types::Value> =
477        vec![rusqlite::types::Value::Text(table.name.clone())];
478    params.extend(
479        row_ids
480            .iter()
481            .map(|row_id| rusqlite::types::Value::Text(row_id.clone())),
482    );
483    params.push(rusqlite::types::Value::Integer(limit + 1));
484    let limit_placeholder = params.len();
485    let mut stmt = conn
486        .prepare(&format!(
487            "SELECT payload FROM {CONTAINER_TABLE}
488               WHERE tbl = ?1{filter} ORDER BY row_id ASC LIMIT ?{limit_placeholder}"
489        ))
490        .map_err(|error| error.to_string())?;
491    let payloads = stmt
492        .query_map(rusqlite::params_from_iter(params), |row| {
493            row.get::<_, String>(0)
494        })
495        .map_err(|error| error.to_string())?
496        .collect::<Result<Vec<_>, _>>()
497        .map_err(|error| error.to_string())?;
498    let truncated = payloads.len() as i64 > limit;
499    let visible = if truncated {
500        limit as usize
501    } else {
502        payloads.len()
503    };
504    let mut rows = Vec::with_capacity(visible);
505    for payload in payloads.into_iter().take(visible) {
506        rows.push(payload_to_row(table, &payload)?);
507    }
508    Ok((rows, truncated))
509}
510
511// ---------------------------------------------------------------------------
512// Bounded capture (D2 / A4)
513// ---------------------------------------------------------------------------
514
515/// D2/A4: measure the capture BEFORE materializing anything. Ordered probes,
516/// aborting on the first violation: table count; per-table row count with a
517/// `LIMIT maxRows + 1` early-abort probe; a single-row probe for rows larger
518/// than `maxRowBytes`; a bounded `SUM` over a `LIMIT`ed subquery. Only then is
519/// anything copied.
520#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
521pub struct CaptureMeasurement {
522    pub tables: i64,
523    pub rows: i64,
524    pub bytes: i64,
525}
526
527#[derive(Debug, Clone, Copy, PartialEq, Eq)]
528pub enum PreviousVersionRefusalReason {
529    NoPreviousDescriptor,
530    CaptureExceededBudget,
531}
532
533impl PreviousVersionRefusalReason {
534    pub fn name(self) -> &'static str {
535        match self {
536            Self::NoPreviousDescriptor => "no-previous-descriptor",
537            Self::CaptureExceededBudget => "capture-exceeded-budget",
538        }
539    }
540
541    fn as_read_reason(self) -> PreviousVersionReason {
542        match self {
543            Self::NoPreviousDescriptor => PreviousVersionReason::NoPreviousDescriptor,
544            Self::CaptureExceededBudget => PreviousVersionReason::CaptureExceededBudget,
545        }
546    }
547}
548
549#[derive(Debug, Clone, Copy, PartialEq, Eq)]
550pub enum CaptureOutcome {
551    Captured(CaptureMeasurement),
552    Refused {
553        reason: PreviousVersionRefusalReason,
554        measurement: CaptureMeasurement,
555    },
556}
557
558fn sum_bytes_expression(columns: &[DescriptorColumn]) -> String {
559    columns
560        .iter()
561        .map(|column| {
562            format!(
563                "COALESCE(LENGTH(CAST({} AS BLOB)), 0)",
564                quote_ident(&column.name)
565            )
566        })
567        .collect::<Vec<_>>()
568        .join(" + ")
569}
570
571fn render_row_id(value: ValueRef<'_>) -> String {
572    match value {
573        ValueRef::Null => String::new(),
574        ValueRef::Integer(value) => value.to_string(),
575        ValueRef::Real(value) => value.to_string(),
576        ValueRef::Text(value) => String::from_utf8_lossy(value).into_owned(),
577        ValueRef::Blob(value) => bytes_to_hex(value),
578    }
579}
580
581/// A descriptor column as the row-value codec's column shape (`nullable` is
582/// irrelevant to the local read, which is where the type is recovered).
583fn column_ref(column: &DescriptorColumn) -> Column {
584    Column {
585        name: column.name.clone(),
586        ty: column.ty,
587        nullable: true,
588    }
589}
590
591fn payload_to_row(table: &DescriptorTable, payload: &str) -> Result<Map<String, Value>, String> {
592    let parsed: Value =
593        serde_json::from_str(payload).map_err(|_| local_corrupt("previous-version payload"))?;
594    let Value::Object(record) = parsed else {
595        return Err(local_corrupt("previous-version payload"));
596    };
597    let mut row = Map::new();
598    for column in &table.columns {
599        let value = json_to_column_value(&column_ref(column), record.get(&column.name))
600            .map_err(|_| local_corrupt("previous-version payload"))?;
601        row.insert(column.name.clone(), column_value_to_json(&value));
602    }
603    Ok(row)
604}
605
606/// D3/D5 steps 3-5: copy the descriptor's tables into the container file, in
607/// one container transaction and [`COPY_BATCH_ROWS`] batches.
608pub fn capture_previous_version(
609    replica: &Connection,
610    container: &mut Connection,
611    descriptor: &LocalSchemaDescriptor,
612    current_version: i32,
613    config: &PreviousVersionContextConfig,
614    now_ms: i64,
615) -> Result<CaptureOutcome, String> {
616    let discovered: BTreeSet<String> = {
617        let mut stmt = replica
618            .prepare(
619                "SELECT name FROM sqlite_master WHERE type = 'table'
620                   AND name NOT LIKE '_syncular_%' AND name NOT LIKE 'sqlite_%'",
621            )
622            .map_err(|error| error.to_string())?;
623        let names = stmt
624            .query_map([], |row| row.get::<_, String>(0))
625            .map_err(|error| error.to_string())?
626            .collect::<Result<BTreeSet<_>, _>>()
627            .map_err(|error| error.to_string())?;
628        names
629    };
630    // Only descriptor-known tables carry semantic types; those are the tables
631    // the copy would hold, and the count the table budget bounds.
632    let tables: Vec<&DescriptorTable> = descriptor
633        .tables
634        .iter()
635        .filter(|table| discovered.contains(&table.name))
636        .collect();
637    let refused = |rows: i64, bytes: i64| CaptureOutcome::Refused {
638        reason: PreviousVersionRefusalReason::CaptureExceededBudget,
639        measurement: CaptureMeasurement {
640            tables: tables.len() as i64,
641            rows,
642            bytes,
643        },
644    };
645    if tables.len() as i64 > config.max_tables {
646        return Ok(refused(0, 0));
647    }
648
649    // Probe 2: rows per table, stopping at maxRows + 1 rather than counting a
650    // hundred-million-row table in full.
651    let mut measured_rows = 0i64;
652    let mut per_table_rows: Vec<i64> = Vec::with_capacity(tables.len());
653    for table in &tables {
654        let count: i64 = replica
655            .query_row(
656                &format!(
657                    "SELECT COUNT(*) FROM (SELECT 1 FROM {} LIMIT ?1)",
658                    quote_ident(&table.name)
659                ),
660                rusqlite::params![config.max_rows + 1],
661                |row| row.get(0),
662            )
663            .map_err(|error| error.to_string())?;
664        measured_rows += count;
665        per_table_rows.push(count);
666        if measured_rows > config.max_rows {
667            return Ok(refused(measured_rows, 0));
668        }
669    }
670
671    // Probes 3-4: one oversized row is rejected before it is ever loaded, then
672    // the byte total is summed over a bounded scan only.
673    let mut measured_bytes = 0i64;
674    for (index, table) in tables.iter().enumerate() {
675        if table.columns.is_empty() {
676            continue;
677        }
678        let byte_sum = sum_bytes_expression(&table.columns);
679        let oversized: Option<i64> = replica
680            .query_row(
681                &format!(
682                    "SELECT 1 FROM {} WHERE ({byte_sum}) > ?1 LIMIT 1",
683                    quote_ident(&table.name)
684                ),
685                rusqlite::params![config.max_row_bytes],
686                |row| row.get(0),
687            )
688            .optional()
689            .map_err(|error| error.to_string())?;
690        if oversized.is_some() {
691            return Ok(refused(measured_rows, measured_bytes));
692        }
693        let total: i64 = replica
694            .query_row(
695                &format!(
696                    "SELECT COALESCE(SUM(bytes), 0) FROM (
697                       SELECT ({byte_sum}) AS bytes FROM {} LIMIT ?1
698                     )",
699                    quote_ident(&table.name)
700                ),
701                rusqlite::params![per_table_rows[index]],
702                |row| row.get(0),
703            )
704            .map_err(|error| error.to_string())?;
705        measured_bytes += total;
706        if measured_bytes > config.max_bytes {
707            return Ok(refused(measured_rows, measured_bytes));
708        }
709    }
710
711    // Step 5: copy in batches, so no step holds a whole table in memory.
712    let mut copied_rows = 0i64;
713    let transaction = container.transaction().map_err(|error| error.to_string())?;
714    drop_previous_version_container(&transaction)?;
715    transaction
716        .execute_batch(&format!(
717            "CREATE TABLE {CONTAINER_TABLE} (
718               tbl TEXT NOT NULL,
719               row_id TEXT NOT NULL,
720               payload TEXT NOT NULL,
721               PRIMARY KEY (tbl, row_id))"
722        ))
723        .map_err(|error| error.to_string())?;
724    for table in &tables {
725        let select_columns = table
726            .columns
727            .iter()
728            .map(|column| quote_ident(&column.name))
729            .collect::<Vec<_>>()
730            .join(", ");
731        let primary_key_index = table
732            .columns
733            .iter()
734            .position(|column| column.name == table.primary_key);
735        let select_sql = format!(
736            "SELECT rowid, {select_columns} FROM {} WHERE rowid > ?1 ORDER BY rowid ASC LIMIT ?2",
737            quote_ident(&table.name)
738        );
739        let mut last_row_id = -1i64;
740        loop {
741            let batch: Vec<(i64, String, String)> = {
742                let mut stmt = replica
743                    .prepare(&select_sql)
744                    .map_err(|error| error.to_string())?;
745                let mut rows = stmt
746                    .query(rusqlite::params![last_row_id, COPY_BATCH_ROWS])
747                    .map_err(|error| error.to_string())?;
748                let mut batch = Vec::new();
749                while let Some(row) = rows.next().map_err(|error| error.to_string())? {
750                    let row_id = primary_key_index.map_or_else(String::new, |index| {
751                        row.get_ref(index + 1)
752                            .map(render_row_id)
753                            .unwrap_or_default()
754                    });
755                    let mut payload = Map::new();
756                    for (index, column) in table.columns.iter().enumerate() {
757                        let raw = row.get_ref(index + 1).map_err(|error| error.to_string())?;
758                        payload.insert(
759                            column.name.clone(),
760                            sql_ref_to_json(&column_ref(column), raw),
761                        );
762                    }
763                    batch.push((
764                        row.get::<_, i64>(0).map_err(|error| error.to_string())?,
765                        row_id,
766                        Value::Object(payload).to_string(),
767                    ));
768                }
769                batch
770            };
771            if batch.is_empty() {
772                break;
773            }
774            {
775                let mut insert = transaction
776                    .prepare(&format!(
777                        "INSERT INTO {CONTAINER_TABLE}(tbl, row_id, payload) VALUES (?1, ?2, ?3)"
778                    ))
779                    .map_err(|error| error.to_string())?;
780                for (_, row_id, payload) in &batch {
781                    insert
782                        .execute(rusqlite::params![table.name, row_id, payload])
783                        .map_err(|error| error.to_string())?;
784                }
785            }
786            copied_rows += batch.len() as i64;
787            last_row_id = batch.last().map_or(last_row_id, |entry| entry.0);
788        }
789    }
790    write_record(
791        &transaction,
792        &PreviousVersionRecord {
793            previous_version: descriptor.version,
794            current_version,
795            tables: tables.iter().map(|table| (*table).clone()).collect(),
796            rows: copied_rows,
797            bytes: measured_bytes,
798            created_at_ms: now_ms,
799        },
800    )?;
801    transaction.commit().map_err(|error| error.to_string())?;
802    Ok(CaptureOutcome::Captured(CaptureMeasurement {
803        tables: tables.len() as i64,
804        rows: copied_rows,
805        bytes: measured_bytes,
806    }))
807}
808
809// ---------------------------------------------------------------------------
810// Durable refusal (the read surface for a capture that stored nothing)
811// ---------------------------------------------------------------------------
812
813/// Record a refusal in `_syncular_meta`, with counts only — never row content.
814fn write_previous_version_refusal(
815    conn: &Connection,
816    reason: PreviousVersionRefusalReason,
817    measurement: CaptureMeasurement,
818) {
819    meta_set(
820        conn,
821        PREVIOUS_VERSION_CONTEXT_KEY,
822        &json!({
823            "v": 1,
824            "reason": reason.name(),
825            "tables": measurement.tables,
826            "rows": measurement.rows,
827            "bytes": measurement.bytes,
828        })
829        .to_string(),
830    );
831}
832
833// ---------------------------------------------------------------------------
834// File-level lifecycle: sweep, capture orchestration, discard
835// ---------------------------------------------------------------------------
836
837/// Best-effort physical removal of the container file. The connection MUST be
838/// closed first; the `-wal`/`-shm` siblings go with it.
839fn remove_previous_version_files(path: &str) {
840    for suffix in ["", "-wal", "-shm"] {
841        let _ = std::fs::remove_file(format!("{path}{suffix}"));
842    }
843}
844
845/// D5 step 1: the unconditional orphan sweep. Drops the container tables,
846/// removes the file, and clears a durable refusal recorded for it, so a
847/// crash-interrupted reset cannot leave a container beside a matching marker.
848/// A no-op when no container file exists.
849///
850/// Everything here is best-effort: the file is the thing being deleted, so a
851/// torn or unreadable container must not refuse its own removal and must not
852/// block the client's open.
853pub fn sweep_previous_version_container(
854    replica: &Connection,
855    replica_path: &str,
856) -> Result<(), String> {
857    let path = previous_version_container_path(replica_path);
858    if !std::path::Path::new(&path).exists() {
859        return Ok(());
860    }
861    if let Ok(container) = Connection::open(&path) {
862        let _ = drop_previous_version_container(&container);
863        let _ = container.close();
864    }
865    remove_previous_version_files(&path);
866    meta_delete(replica, PREVIOUS_VERSION_CONTEXT_KEY);
867    Ok(())
868}
869
870/// D5 steps 2-5: load the stored descriptor, then capture into the container
871/// file. A refused or failed capture stores nothing, records its reason, and
872/// leaves NO container file behind.
873pub fn capture_previous_version_from_replica(
874    replica: &Connection,
875    replica_path: &str,
876    previous_version: i32,
877    current_version: i32,
878    config: &PreviousVersionContextConfig,
879    now_ms: i64,
880) -> Result<CaptureOutcome, String> {
881    let path = previous_version_container_path(replica_path);
882    let descriptor = load_local_schema_descriptor(replica)
883        .filter(|descriptor| descriptor.version == previous_version);
884    let Some(descriptor) = descriptor else {
885        // D1: no fallback and no inference — a database last opened by an
886        // unaware binary has no descriptor, and the capture is refused.
887        let measurement = CaptureMeasurement::default();
888        write_previous_version_refusal(
889            replica,
890            PreviousVersionRefusalReason::NoPreviousDescriptor,
891            measurement,
892        );
893        return Ok(CaptureOutcome::Refused {
894            reason: PreviousVersionRefusalReason::NoPreviousDescriptor,
895            measurement,
896        });
897    };
898    let mut container = Connection::open(&path)
899        .map_err(|error| format!("open previous-version container {path:?}: {error}"))?;
900    let outcome = capture_previous_version(
901        replica,
902        &mut container,
903        &descriptor,
904        current_version,
905        config,
906        now_ms,
907    );
908    let _ = container.close();
909    match outcome {
910        Ok(CaptureOutcome::Captured(measurement)) => {
911            meta_delete(replica, PREVIOUS_VERSION_CONTEXT_KEY);
912            Ok(CaptureOutcome::Captured(measurement))
913        }
914        Ok(CaptureOutcome::Refused {
915            reason,
916            measurement,
917        }) => {
918            write_previous_version_refusal(replica, reason, measurement);
919            remove_previous_version_files(&path);
920            Ok(CaptureOutcome::Refused {
921                reason,
922                measurement,
923            })
924        }
925        Err(error) => {
926            remove_previous_version_files(&path);
927            Err(error)
928        }
929    }
930}
931
932/// D7: `limit` defaults to 50 and is capped at 200.
933pub const PREVIOUS_VERSION_DEFAULT_LIMIT: i64 = 50;
934pub const PREVIOUS_VERSION_MAX_LIMIT: i64 = 200;
935
936/// D7: the read surface. `state` is always `previousVersion`, never
937/// `complete`; the container never contributes to `querySnapshot().coverage`.
938#[derive(Debug, Clone, PartialEq, Serialize)]
939#[serde(rename_all = "camelCase")]
940pub struct PreviousVersionSnapshot {
941    pub state: &'static str,
942    pub available: bool,
943    #[serde(skip_serializing_if = "Option::is_none")]
944    pub previous_version: Option<i32>,
945    pub current_version: i32,
946    #[serde(skip_serializing_if = "Option::is_none")]
947    pub reason: Option<PreviousVersionReason>,
948    pub rows: Vec<Map<String, Value>>,
949    pub truncated: bool,
950}
951
952impl PreviousVersionSnapshot {
953    fn unavailable(current_version: i32, reason: PreviousVersionReason) -> Self {
954        Self {
955            state: PREVIOUS_VERSION_STATE,
956            available: false,
957            previous_version: None,
958            current_version,
959            reason: Some(reason),
960            rows: Vec::new(),
961            truncated: false,
962        }
963    }
964}
965
966/// The one value `state` ever takes (D7).
967pub const PREVIOUS_VERSION_STATE: &str = "previousVersion";
968
969/// Every reason the read surface can name.
970#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
971#[serde(rename_all = "kebab-case")]
972pub enum PreviousVersionReason {
973    NotConfigured,
974    NoPreviousDescriptor,
975    CaptureExceededBudget,
976    CoverageComplete,
977    Expired,
978    LeaseInactive,
979    ScopeRevoked,
980}
981
982/// D7: the in-memory lifecycle facts the read surface consults. The client
983/// computes them from its own state; the module owns the decision so it can be
984/// tested with injected time and injected lifecycle state.
985#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
986pub struct PreviousVersionLifecycle {
987    /// §7.3.5 lease error or expiry.
988    pub lease_inactive: bool,
989    /// §3.3 scope revocation (any revoked subscription).
990    pub scope_revoked: bool,
991    /// §7.4.5 replacement coverage complete.
992    pub coverage_complete: bool,
993}
994
995/// D7 read spec.
996#[derive(Debug, Clone, Default, PartialEq, Eq)]
997pub struct PreviousVersionReadSpec {
998    pub table: String,
999    pub row_ids: Vec<String>,
1000    pub limit: Option<i64>,
1001}
1002
1003/// D7: `limit` defaults to 50, must be positive, and is capped at 200.
1004pub fn resolve_previous_version_limit(limit: Option<i64>) -> Result<i64, String> {
1005    match limit {
1006        None => Ok(PREVIOUS_VERSION_DEFAULT_LIMIT),
1007        Some(limit) if limit >= 1 => Ok(limit.min(PREVIOUS_VERSION_MAX_LIMIT)),
1008        Some(_) => Err(
1009            "sync.invalid_request: previousVersionSnapshot limit must be a positive number"
1010                .to_owned(),
1011        ),
1012    }
1013}
1014
1015/// Read the container's own metadata record, opening the sibling file for that
1016/// window only. `None` when there is no file or the record is unreadable.
1017pub fn read_previous_version_record(
1018    replica_path: &str,
1019) -> Result<Option<PreviousVersionRecord>, String> {
1020    let path = previous_version_container_path(replica_path);
1021    if !std::path::Path::new(&path).exists() {
1022        return Ok(None);
1023    }
1024    let container = Connection::open(&path)
1025        .map_err(|error| format!("open previous-version container {path:?}: {error}"))?;
1026    let record = read_previous_version_container(&container);
1027    let _ = container.close();
1028    // A container this module cannot read is treated as absent metadata, which
1029    // the boot reconcile discards on the next open.
1030    Ok(record.unwrap_or(None))
1031}
1032
1033/// D7: the read surface. `config` is the resolved feature config (`None` or
1034/// `enabled: false` ⇒ not configured); `lifecycle` is computed by the client
1035/// from in-memory state; `now_ms` is the injected clock.
1036///
1037/// Lease stop/expiry, scope revocation, coverage completion and the TTL all
1038/// DISCARD the container and close the read with their own reason — closing
1039/// reads without discarding would leave the plaintext behind.
1040pub fn previous_version_snapshot(
1041    replica: &Connection,
1042    replica_path: Option<&str>,
1043    config: Option<&PreviousVersionContextConfig>,
1044    lifecycle: PreviousVersionLifecycle,
1045    now_ms: i64,
1046    current_version: i32,
1047    spec: &PreviousVersionReadSpec,
1048) -> Result<PreviousVersionSnapshot, String> {
1049    let Some(config) = config.filter(|config| config.enabled) else {
1050        return Ok(PreviousVersionSnapshot::unavailable(
1051            current_version,
1052            PreviousVersionReason::NotConfigured,
1053        ));
1054    };
1055    let record = match replica_path {
1056        Some(replica_path) => read_previous_version_record(replica_path)?,
1057        None => None,
1058    };
1059    let discard = |reason: PreviousVersionReason| {
1060        if let Some(replica_path) = replica_path {
1061            discard_previous_version(replica, replica_path)?;
1062        }
1063        Ok(PreviousVersionSnapshot::unavailable(
1064            current_version,
1065            reason,
1066        ))
1067    };
1068    if lifecycle.lease_inactive {
1069        return discard(PreviousVersionReason::LeaseInactive);
1070    }
1071    if lifecycle.scope_revoked {
1072        return discard(PreviousVersionReason::ScopeRevoked);
1073    }
1074    if lifecycle.coverage_complete {
1075        return discard(PreviousVersionReason::CoverageComplete);
1076    }
1077    let Some(record) = record else {
1078        let reason = stored_previous_version_refusal(replica)
1079            .unwrap_or(PreviousVersionRefusalReason::NoPreviousDescriptor);
1080        return Ok(PreviousVersionSnapshot::unavailable(
1081            current_version,
1082            reason.as_read_reason(),
1083        ));
1084    };
1085    if record.current_version != current_version {
1086        return discard(PreviousVersionReason::NoPreviousDescriptor);
1087    }
1088    if now_ms - record.created_at_ms > config.max_age_ms {
1089        return discard(PreviousVersionReason::Expired);
1090    }
1091    let Some(table) = record.table(&spec.table) else {
1092        return Err(format!(
1093            "sync.invalid_request: previousVersionSnapshot names unknown previous table {:?}",
1094            spec.table
1095        ));
1096    };
1097    let Some(replica_path) = replica_path else {
1098        return Ok(PreviousVersionSnapshot::unavailable(
1099            current_version,
1100            PreviousVersionReason::NoPreviousDescriptor,
1101        ));
1102    };
1103    let path = previous_version_container_path(replica_path);
1104    let container = Connection::open(&path)
1105        .map_err(|error| format!("open previous-version container {path:?}: {error}"))?;
1106    let (rows, truncated) = read_previous_version_rows(
1107        &container,
1108        table,
1109        &spec.row_ids,
1110        resolve_previous_version_limit(spec.limit)?,
1111    )?;
1112    let _ = container.close();
1113    Ok(PreviousVersionSnapshot {
1114        state: PREVIOUS_VERSION_STATE,
1115        available: true,
1116        previous_version: Some(record.previous_version),
1117        current_version,
1118        reason: None,
1119        rows,
1120        truncated,
1121    })
1122}
1123
1124/// Decode the durable refusal; `None` when absent or corrupt. The successful
1125/// record lives inside the container file, so this key is absent whenever a
1126/// container is present.
1127pub fn stored_previous_version_refusal(
1128    replica: &Connection,
1129) -> Option<PreviousVersionRefusalReason> {
1130    let raw = meta_get(replica, PREVIOUS_VERSION_CONTEXT_KEY)?;
1131    let parsed: Value = serde_json::from_str(&raw).ok()?;
1132    let Value::Object(record) = &parsed else {
1133        return None;
1134    };
1135    if as_count(record.get("v")) != Some(1) {
1136        return None;
1137    }
1138    match record.get("reason").and_then(Value::as_str) {
1139        Some("no-previous-descriptor") => Some(PreviousVersionRefusalReason::NoPreviousDescriptor),
1140        Some("capture-exceeded-budget") => {
1141            Some(PreviousVersionRefusalReason::CaptureExceededBudget)
1142        }
1143        _ => None,
1144    }
1145}
1146
1147#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
1148#[serde(rename_all = "kebab-case")]
1149pub enum PreviousVersionAuditReason {
1150    UnknownTable,
1151    UnknownColumn,
1152    MissingRequiredColumn,
1153}
1154
1155impl PreviousVersionAuditReason {
1156    #[must_use]
1157    pub fn name(self) -> &'static str {
1158        match self {
1159            Self::UnknownTable => "unknown-table",
1160            Self::UnknownColumn => "unknown-column",
1161            Self::MissingRequiredColumn => "missing-required-column",
1162        }
1163    }
1164}
1165
1166/// D6: one incompatible commit. Records the commit id, table, typed reason and
1167/// offending column ONLY — never an operation, row value or envelope field.
1168#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
1169#[serde(rename_all = "camelCase")]
1170pub struct PreviousVersionAuditEntry {
1171    pub commit_id: String,
1172    pub table: String,
1173    pub reason: PreviousVersionAuditReason,
1174    #[serde(skip_serializing_if = "Option::is_none")]
1175    pub column: Option<String>,
1176}
1177
1178/// D6: the advisory pre-reset compatibility audit. Bounded at
1179/// [`MAX_AUDIT_INCOMPATIBLE`] entries.
1180#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
1181#[serde(rename_all = "camelCase")]
1182pub struct PreviousVersionAudit {
1183    pub v: i64,
1184    pub at_ms: i64,
1185    pub from_version: i32,
1186    pub to_version: i32,
1187    pub pending: i64,
1188    pub encodable: i64,
1189    pub truncated: bool,
1190    pub incompatible: Vec<PreviousVersionAuditEntry>,
1191}
1192
1193pub const MAX_AUDIT_INCOMPATIBLE: usize = 200;
1194
1195/// One pending outbox commit as the audit needs it: its id, and `(table, value
1196/// keys, insert intent)` per operation.
1197#[derive(Debug, Clone, PartialEq, Eq)]
1198pub struct PendingCommitAudit<'a> {
1199    pub commit_id: &'a str,
1200    pub operations: Vec<(&'a str, Vec<&'a str>, bool)>,
1201}
1202
1203/// The first reason these operations cannot re-encode under `schema`, mirroring
1204/// the TS `firstIncompatibility`. Shared with the §7.4.4 send-time drop so the
1205/// audit and the drop never disagree about what is incompatible.
1206pub fn first_incompatibility<'a>(
1207    schema: &'a ClientSchema,
1208    operations: &[(&'a str, Vec<&'a str>, bool)],
1209) -> Option<(&'a str, PreviousVersionAuditReason, Option<&'a str>)> {
1210    for (table_name, value_keys, insert) in operations {
1211        let Some(table) = schema.table(table_name) else {
1212            return Some((*table_name, PreviousVersionAuditReason::UnknownTable, None));
1213        };
1214        for key in value_keys {
1215            if !table.columns.iter().any(|column| column.name == *key) {
1216                return Some((
1217                    *table_name,
1218                    PreviousVersionAuditReason::UnknownColumn,
1219                    Some(*key),
1220                ));
1221            }
1222        }
1223        // §7.1: an insert-intent operation presents every current column, so a
1224        // non-nullable column it lacks leaves the commit unencodable.
1225        if *insert {
1226            if let Some(column) = table
1227                .columns
1228                .iter()
1229                .find(|column| !column.nullable && !value_keys.contains(&column.name.as_str()))
1230            {
1231                return Some((
1232                    *table_name,
1233                    PreviousVersionAuditReason::MissingRequiredColumn,
1234                    Some(column.name.as_str()),
1235                ));
1236            }
1237        }
1238    }
1239    None
1240}
1241
1242/// D6: classify pending outbox commits against the NEW compiled schema, before
1243/// the wipe. Advisory only — it drops nothing.
1244pub fn build_previous_version_audit<'a>(
1245    schema: &'a ClientSchema,
1246    pending: &[PendingCommitAudit<'a>],
1247    from_version: i32,
1248    to_version: i32,
1249    at_ms: i64,
1250) -> PreviousVersionAudit {
1251    let mut incompatible = Vec::new();
1252    let mut incompatible_total = 0usize;
1253    let mut encodable = 0i64;
1254    for commit in pending {
1255        let Some((table, reason, column)) = first_incompatibility(schema, &commit.operations)
1256        else {
1257            encodable += 1;
1258            continue;
1259        };
1260        incompatible_total += 1;
1261        if incompatible.len() < MAX_AUDIT_INCOMPATIBLE {
1262            incompatible.push(PreviousVersionAuditEntry {
1263                commit_id: commit.commit_id.to_owned(),
1264                table: table.to_owned(),
1265                reason,
1266                column: column.map(str::to_owned),
1267            });
1268        }
1269    }
1270    PreviousVersionAudit {
1271        v: 1,
1272        at_ms,
1273        from_version,
1274        to_version,
1275        pending: pending.len() as i64,
1276        encodable,
1277        truncated: incompatible_total > MAX_AUDIT_INCOMPATIBLE,
1278        incompatible,
1279    }
1280}
1281
1282pub fn write_previous_version_audit(replica: &Connection, audit: &PreviousVersionAudit) {
1283    if let Ok(encoded) = serde_json::to_string(audit) {
1284        meta_set(replica, PREVIOUS_VERSION_AUDIT_KEY, &encoded);
1285    }
1286}
1287
1288/// Strict decode; `None` when absent or malformed.
1289pub fn stored_previous_version_audit(replica: &Connection) -> Option<PreviousVersionAudit> {
1290    let raw = meta_get(replica, PREVIOUS_VERSION_AUDIT_KEY)?;
1291    let parsed: Value = serde_json::from_str(&raw).ok()?;
1292    let Value::Object(record) = &parsed else {
1293        return None;
1294    };
1295    if !has_exact_keys(
1296        record,
1297        &[
1298            "atMs",
1299            "encodable",
1300            "fromVersion",
1301            "incompatible",
1302            "pending",
1303            "toVersion",
1304            "truncated",
1305            "v",
1306        ],
1307    ) || as_count(record.get("v")) != Some(1)
1308        || record.get("truncated").and_then(Value::as_bool).is_none()
1309    {
1310        return None;
1311    }
1312    let count = |key: &str| as_count(record.get(key));
1313    let Some(Value::Array(entries)) = record.get("incompatible") else {
1314        return None;
1315    };
1316    let mut incompatible = Vec::with_capacity(entries.len());
1317    for entry in entries {
1318        let Value::Object(entry) = entry else {
1319            return None;
1320        };
1321        let (Some(Value::String(commit_id)), Some(Value::String(table))) =
1322            (entry.get("commitId"), entry.get("table"))
1323        else {
1324            return None;
1325        };
1326        let reason = match entry.get("reason").and_then(Value::as_str) {
1327            Some("unknown-table") => PreviousVersionAuditReason::UnknownTable,
1328            Some("unknown-column") => PreviousVersionAuditReason::UnknownColumn,
1329            Some("missing-required-column") => PreviousVersionAuditReason::MissingRequiredColumn,
1330            _ => return None,
1331        };
1332        let column = match entry.get("column") {
1333            None | Some(Value::Null) => None,
1334            Some(Value::String(column)) => Some(column.clone()),
1335            Some(_) => return None,
1336        };
1337        incompatible.push(PreviousVersionAuditEntry {
1338            commit_id: commit_id.clone(),
1339            table: table.clone(),
1340            reason,
1341            column,
1342        });
1343    }
1344    Some(PreviousVersionAudit {
1345        v: 1,
1346        at_ms: count("atMs")?,
1347        from_version: i32::try_from(count("fromVersion")?).ok()?,
1348        to_version: i32::try_from(count("toVersion")?).ok()?,
1349        pending: count("pending")?,
1350        encodable: count("encodable")?,
1351        truncated: record.get("truncated").and_then(Value::as_bool)?,
1352        incompatible,
1353    })
1354}
1355
1356#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1357pub struct PreviousVersionDiscardOutcome {
1358    pub present: bool,
1359    pub discarded: bool,
1360}
1361
1362/// D9 boot hygiene: a container whose metadata is missing/undecodable, or
1363/// whose recorded `currentVersion` is not the running generated schema version,
1364/// is discarded before anything can read it.
1365///
1366/// This is the ONE gap the reset sweep cannot see. The capture commits in the
1367/// container's OWN transaction, independent of the replica's savepoint, so a
1368/// crash after that commit but before the replica commits leaves a container
1369/// whose `currentVersion` is the NEW schema beside the OLD marker. The next
1370/// open at the old schema version takes the same-version branch — no reset, no
1371/// sweep — and would otherwise serve that container. Idempotent: no container
1372/// file means no-op.
1373pub fn reconcile_previous_version_at_boot(
1374    replica: &Connection,
1375    replica_path: &str,
1376    current_version: i32,
1377    max_age_ms: Option<i64>,
1378    now_ms: i64,
1379) -> Result<(), String> {
1380    let path = previous_version_container_path(replica_path);
1381    if !std::path::Path::new(&path).exists() {
1382        return Ok(());
1383    }
1384    let record = match Connection::open(&path) {
1385        Ok(container) => {
1386            let record = read_previous_version_container(&container).unwrap_or(None);
1387            let _ = container.close();
1388            record
1389        }
1390        // Unreadable is the same decision as unknown metadata: discard it.
1391        Err(_) => None,
1392    };
1393    // D7/A1: the TTL is applied by an AWARE binary only (`max_age_ms` is set
1394    // when the feature is configured). It makes no claim about an unaware
1395    // same-schema rollback, which runs none of this code.
1396    let stale = match &record {
1397        // Missing or undecodable metadata: nothing can read it safely.
1398        None => true,
1399        Some(record) => {
1400            record.current_version != current_version
1401                || max_age_ms.is_some_and(|max_age_ms| now_ms - record.created_at_ms > max_age_ms)
1402        }
1403    };
1404    if !stale {
1405        return Ok(());
1406    }
1407    discard_previous_version(replica, replica_path)?;
1408    Ok(())
1409}
1410
1411/// D7/D9/A2: the executable discard — drop the container and both metadata
1412/// records and remove the FILE. Idempotent: a no-op succeeds, and a container
1413/// that cannot even be opened (torn file) is still removed and reported as
1414/// discarded rather than failing. This is the RFC 0006 key-loss contract: a
1415/// throw here would block reactivation while the plaintext is gone.
1416pub fn discard_previous_version(
1417    replica: &Connection,
1418    replica_path: &str,
1419) -> Result<PreviousVersionDiscardOutcome, String> {
1420    let path = previous_version_container_path(replica_path);
1421    let mut present = std::path::Path::new(&path).exists();
1422    if present {
1423        if let Ok(container) = Connection::open(&path) {
1424            let _ = drop_previous_version_container(&container);
1425            let _ = container.close();
1426        }
1427        remove_previous_version_files(&path);
1428    }
1429    if meta_get(replica, PREVIOUS_VERSION_CONTEXT_KEY).is_some()
1430        || meta_get(replica, PREVIOUS_VERSION_AUDIT_KEY).is_some()
1431    {
1432        present = true;
1433    }
1434    meta_delete(replica, PREVIOUS_VERSION_CONTEXT_KEY);
1435    meta_delete(replica, PREVIOUS_VERSION_AUDIT_KEY);
1436    Ok(PreviousVersionDiscardOutcome {
1437        present,
1438        discarded: present,
1439    })
1440}
1441#[cfg(test)]
1442mod tests {
1443    //! RFC 0005 phase-1 tests: descriptor round-trip and strict decode, the
1444    //! container in the sibling FILE with readable typed rows, every budget
1445    //! aborting with no file left, the no-descriptor refusal, and a discard
1446    //! that removes the PATH (read directly from storage, never through the
1447    //! feature's own read API).
1448
1449    use std::path::{Path, PathBuf};
1450
1451    use serde_json::json;
1452
1453    use super::*;
1454    use crate::api::{ClientLimits, Mutation};
1455    use crate::client::{SyncClient, LOCAL_SCHEMA_VERSION_KEY};
1456    use crate::schema::parse_schema_json;
1457
1458    fn enabled_config() -> PreviousVersionContextConfig {
1459        PreviousVersionContextConfig {
1460            enabled: true,
1461            ..Default::default()
1462        }
1463    }
1464
1465    #[test]
1466    fn previous_version_snapshot_reasons_use_injected_lifecycle_and_discard() {
1467        let temp = TempFiles::new("read-reasons");
1468        let replica_path = temp.replica().to_str().expect("utf-8 path").to_owned();
1469        seed_replica(temp.replica(), 2);
1470        let replica = Connection::open(temp.replica()).expect("open replica");
1471        let container_path = previous_version_container_path(&replica_path);
1472        let config = enabled_config();
1473        let spec = PreviousVersionReadSpec {
1474            table: "things".to_owned(),
1475            ..Default::default()
1476        };
1477        let lifecycle = PreviousVersionLifecycle::default();
1478
1479        // 1. Off and disabled are the same read outcome.
1480        for config in [None, Some(&PreviousVersionContextConfig::default())] {
1481            let snapshot = previous_version_snapshot(
1482                &replica,
1483                Some(&replica_path),
1484                config,
1485                lifecycle,
1486                0,
1487                2,
1488                &spec,
1489            )
1490            .expect("snapshot");
1491            assert!(!snapshot.available);
1492            assert_eq!(snapshot.reason, Some(PreviousVersionReason::NotConfigured));
1493            assert_eq!(snapshot.state, "previousVersion");
1494            assert_eq!(snapshot.current_version, 2);
1495            assert!(snapshot.previous_version.is_none());
1496            assert!(snapshot.rows.is_empty());
1497        }
1498
1499        // 2. A capture refused for want of a descriptor is a durable reason.
1500        let refused =
1501            capture_previous_version_from_replica(&replica, &replica_path, 3, 2, &config, 1_000)
1502                .expect("capture");
1503        assert!(matches!(
1504            refused,
1505            CaptureOutcome::Refused {
1506                reason: PreviousVersionRefusalReason::NoPreviousDescriptor,
1507                ..
1508            }
1509        ));
1510        let snapshot = previous_version_snapshot(
1511            &replica,
1512            Some(&replica_path),
1513            Some(&config),
1514            lifecycle,
1515            1_000,
1516            2,
1517            &spec,
1518        )
1519        .expect("snapshot");
1520        assert_eq!(
1521            snapshot.reason,
1522            Some(PreviousVersionReason::NoPreviousDescriptor)
1523        );
1524
1525        // 3. A budget refusal keeps its measured reason too.
1526        let tiny = PreviousVersionContextConfig {
1527            max_bytes: 1,
1528            enabled: true,
1529            ..Default::default()
1530        };
1531        capture_previous_version_from_replica(&replica, &replica_path, 1, 2, &tiny, 1_000)
1532            .expect("capture");
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::CaptureExceededBudget)
1546        );
1547        assert!(!Path::new(&container_path).exists());
1548
1549        // 4. A successful capture is readable with typed rows.
1550        let recapture = || {
1551            let outcome = capture_previous_version_from_replica(
1552                &replica,
1553                &replica_path,
1554                1,
1555                2,
1556                &config,
1557                1_000,
1558            )
1559            .expect("capture");
1560            assert!(matches!(outcome, CaptureOutcome::Captured(_)));
1561        };
1562        recapture();
1563        let snapshot = previous_version_snapshot(
1564            &replica,
1565            Some(&replica_path),
1566            Some(&config),
1567            lifecycle,
1568            1_000,
1569            2,
1570            &spec,
1571        )
1572        .expect("snapshot");
1573        assert!(snapshot.available);
1574        assert_eq!(snapshot.previous_version, Some(1));
1575        assert_eq!(snapshot.current_version, 2);
1576        assert_eq!(snapshot.reason, None);
1577        assert_eq!(snapshot.state, "previousVersion");
1578        assert!(!snapshot.truncated);
1579        assert_eq!(snapshot.rows.len(), 2);
1580        assert_eq!(snapshot.rows[0]["s"], json!("hello"));
1581        assert_eq!(snapshot.rows[0]["b"], json!(true));
1582        assert_eq!(snapshot.rows[0]["by"], json!({ "$bytes": "010203" }));
1583
1584        // Row selection, and an unknown previous table is a loud request error.
1585        let selected = PreviousVersionReadSpec {
1586            table: "things".to_owned(),
1587            row_ids: vec!["r2".to_owned()],
1588            limit: Some(1),
1589        };
1590        let snapshot = previous_version_snapshot(
1591            &replica,
1592            Some(&replica_path),
1593            Some(&config),
1594            lifecycle,
1595            1_000,
1596            2,
1597            &selected,
1598        )
1599        .expect("snapshot");
1600        assert_eq!(snapshot.rows.len(), 1);
1601        assert_eq!(snapshot.rows[0]["id"], json!("r2"));
1602        let unknown = PreviousVersionReadSpec {
1603            table: "absent".to_owned(),
1604            ..Default::default()
1605        };
1606        assert!(previous_version_snapshot(
1607            &replica,
1608            Some(&replica_path),
1609            Some(&config),
1610            lifecycle,
1611            1_000,
1612            2,
1613            &unknown
1614        )
1615        .expect_err("unknown previous table")
1616        .contains("unknown previous table"));
1617
1618        // 5. Lease stop/expiry, scope revocation and coverage completion each
1619        // DISCARD the container and close the read with their own reason.
1620        for (lifecycle, expected) in [
1621            (
1622                PreviousVersionLifecycle {
1623                    lease_inactive: true,
1624                    ..Default::default()
1625                },
1626                PreviousVersionReason::LeaseInactive,
1627            ),
1628            (
1629                PreviousVersionLifecycle {
1630                    scope_revoked: true,
1631                    ..Default::default()
1632                },
1633                PreviousVersionReason::ScopeRevoked,
1634            ),
1635            (
1636                PreviousVersionLifecycle {
1637                    coverage_complete: true,
1638                    ..Default::default()
1639                },
1640                PreviousVersionReason::CoverageComplete,
1641            ),
1642        ] {
1643            recapture();
1644            assert!(Path::new(&container_path).exists());
1645            let snapshot = previous_version_snapshot(
1646                &replica,
1647                Some(&replica_path),
1648                Some(&config),
1649                lifecycle,
1650                1_000,
1651                2,
1652                &spec,
1653            )
1654            .expect("snapshot");
1655            assert!(!snapshot.available, "{expected:?}");
1656            assert_eq!(snapshot.reason, Some(expected));
1657            assert!(snapshot.rows.is_empty());
1658            assert!(
1659                !Path::new(&container_path).exists(),
1660                "{expected:?} must physically remove the container"
1661            );
1662            assert_eq!(meta_get(&replica, PREVIOUS_VERSION_CONTEXT_KEY), None);
1663        }
1664    }
1665
1666    #[test]
1667    fn previous_version_ttl_applies_at_read_and_at_boot_with_injected_now() {
1668        let temp = TempFiles::new("ttl");
1669        let replica_path = temp.replica().to_str().expect("utf-8 path").to_owned();
1670        seed_replica(temp.replica(), 1);
1671        let replica = Connection::open(temp.replica()).expect("open replica");
1672        let container_path = previous_version_container_path(&replica_path);
1673        let config = enabled_config();
1674        let max_age_ms = config.max_age_ms;
1675        let spec = PreviousVersionReadSpec {
1676            table: "things".to_owned(),
1677            ..Default::default()
1678        };
1679        let lifecycle = PreviousVersionLifecycle::default();
1680        let capture_at = || {
1681            let outcome = capture_previous_version_from_replica(
1682                &replica,
1683                &replica_path,
1684                1,
1685                2,
1686                &config,
1687                1_000,
1688            )
1689            .expect("capture");
1690            assert!(matches!(outcome, CaptureOutcome::Captured(_)));
1691        };
1692
1693        // At the boundary the capture is still good; one millisecond later the
1694        // read expires it and removes the file.
1695        capture_at();
1696        let snapshot = previous_version_snapshot(
1697            &replica,
1698            Some(&replica_path),
1699            Some(&config),
1700            lifecycle,
1701            1_000 + max_age_ms,
1702            2,
1703            &spec,
1704        )
1705        .expect("snapshot");
1706        assert!(snapshot.available);
1707        let snapshot = previous_version_snapshot(
1708            &replica,
1709            Some(&replica_path),
1710            Some(&config),
1711            lifecycle,
1712            1_001 + max_age_ms,
1713            2,
1714            &spec,
1715        )
1716        .expect("snapshot");
1717        assert_eq!(snapshot.reason, Some(PreviousVersionReason::Expired));
1718        assert!(!Path::new(&container_path).exists());
1719
1720        // Boot: no TTL without an aware binary (`max_age_ms` is None when the
1721        // feature is off) — the container is NOT deleted by an unaware boot
1722        // path. This asserts the limitation: the TTL is an aware-binary
1723        // hygiene bound and nothing else.
1724        capture_at();
1725        reconcile_previous_version_at_boot(
1726            &replica,
1727            &replica_path,
1728            2,
1729            None,
1730            1_000 + max_age_ms * 10,
1731        )
1732        .expect("boot reconcile without a TTL");
1733        assert!(Path::new(&container_path).exists());
1734
1735        // Boot: aware, within the TTL → kept; beyond it → discarded.
1736        reconcile_previous_version_at_boot(
1737            &replica,
1738            &replica_path,
1739            2,
1740            Some(max_age_ms),
1741            1_000 + max_age_ms,
1742        )
1743        .expect("boot reconcile");
1744        assert!(Path::new(&container_path).exists());
1745        reconcile_previous_version_at_boot(
1746            &replica,
1747            &replica_path,
1748            2,
1749            Some(max_age_ms),
1750            1_001 + max_age_ms,
1751        )
1752        .expect("boot reconcile");
1753        assert!(!Path::new(&container_path).exists());
1754    }
1755
1756    #[test]
1757    fn previous_version_audit_flags_insert_missing_required_column() {
1758        let temp = TempFiles::new("audit-missing-required");
1759        seed_replica(temp.replica(), 1);
1760        let replica = Connection::open(temp.replica()).expect("open replica");
1761        let schema = schema(2);
1762
1763        let pending = vec![
1764            // Insert intent lacking the non-nullable `project_id`.
1765            PendingCommitAudit {
1766                commit_id: "c1",
1767                operations: vec![("things", vec!["id", "s"], true)],
1768            },
1769            // A sparse patch may omit it; the server already holds the row.
1770            PendingCommitAudit {
1771                commit_id: "c2",
1772                operations: vec![("things", vec!["id", "s"], false)],
1773            },
1774            // An insert presenting every non-nullable column; the rest are NULL.
1775            PendingCommitAudit {
1776                commit_id: "c3",
1777                operations: vec![("things", vec!["id", "project_id"], true)],
1778            },
1779            // The unknown column is reported before the missing one.
1780            PendingCommitAudit {
1781                commit_id: "c4",
1782                operations: vec![("things", vec!["id", "removed"], true)],
1783            },
1784        ];
1785        let audit = build_previous_version_audit(&schema, &pending, 1, 2, 7);
1786        assert_eq!(audit.pending, 4);
1787        assert_eq!(audit.encodable, 2);
1788        assert_eq!(
1789            audit.incompatible,
1790            vec![
1791                PreviousVersionAuditEntry {
1792                    commit_id: "c1".to_owned(),
1793                    table: "things".to_owned(),
1794                    reason: PreviousVersionAuditReason::MissingRequiredColumn,
1795                    column: Some("project_id".to_owned()),
1796                },
1797                PreviousVersionAuditEntry {
1798                    commit_id: "c4".to_owned(),
1799                    table: "things".to_owned(),
1800                    reason: PreviousVersionAuditReason::UnknownColumn,
1801                    column: Some("removed".to_owned()),
1802                },
1803            ]
1804        );
1805        assert!(serde_json::to_string(&audit)
1806            .expect("encode audit")
1807            .contains("\"reason\":\"missing-required-column\""));
1808
1809        write_previous_version_audit(&replica, &audit);
1810        assert_eq!(stored_previous_version_audit(&replica), Some(audit));
1811    }
1812
1813    #[test]
1814    fn previous_version_audit_records_typed_reasons_and_round_trips() {
1815        let temp = TempFiles::new("audit");
1816        seed_replica(temp.replica(), 1);
1817        let replica = Connection::open(temp.replica()).expect("open replica");
1818        let schema = schema(2);
1819
1820        let pending = vec![
1821            PendingCommitAudit {
1822                commit_id: "c1",
1823                operations: vec![("things", vec!["id", "removed"], false)],
1824            },
1825            PendingCommitAudit {
1826                commit_id: "c2",
1827                operations: vec![("gone", vec![], false)],
1828            },
1829            PendingCommitAudit {
1830                commit_id: "c3",
1831                operations: vec![("things", vec!["id"], false)],
1832            },
1833        ];
1834        let audit = build_previous_version_audit(&schema, &pending, 1, 2, 42);
1835        assert_eq!(audit.v, 1);
1836        assert_eq!(audit.at_ms, 42);
1837        assert_eq!(audit.from_version, 1);
1838        assert_eq!(audit.to_version, 2);
1839        assert_eq!(audit.pending, 3);
1840        assert_eq!(audit.encodable, 1);
1841        assert!(!audit.truncated);
1842        assert_eq!(audit.incompatible.len(), 2);
1843        assert_eq!(
1844            audit.incompatible[0],
1845            PreviousVersionAuditEntry {
1846                commit_id: "c1".to_owned(),
1847                table: "things".to_owned(),
1848                reason: PreviousVersionAuditReason::UnknownColumn,
1849                column: Some("removed".to_owned()),
1850            }
1851        );
1852        assert_eq!(
1853            audit.incompatible[1],
1854            PreviousVersionAuditEntry {
1855                commit_id: "c2".to_owned(),
1856                table: "gone".to_owned(),
1857                reason: PreviousVersionAuditReason::UnknownTable,
1858                column: None,
1859            }
1860        );
1861        // The record carries a typed reason and NO envelope, operation or value.
1862        let encoded = serde_json::to_string(&audit).expect("encode audit");
1863        for forbidden in ["operations", "values", "payload", "envelope", "row_id"] {
1864            assert!(!encoded.contains(forbidden), "{encoded}");
1865        }
1866        assert!(encoded.contains("\"reason\":\"unknown-column\""));
1867        assert!(encoded.contains("\"reason\":\"unknown-table\""));
1868
1869        write_previous_version_audit(&replica, &audit);
1870        assert_eq!(stored_previous_version_audit(&replica), Some(audit.clone()));
1871        // Strict decode: a malformed record reads as absent.
1872        meta_set(&replica, PREVIOUS_VERSION_AUDIT_KEY, "{\"v\":1}");
1873        assert_eq!(stored_previous_version_audit(&replica), None);
1874        meta_set(
1875            &replica,
1876            PREVIOUS_VERSION_AUDIT_KEY,
1877            "{\"v\":1,\"atMs\":0,\"fromVersion\":1,\"toVersion\":2,\"pending\":0,\"encodable\":0,\"truncated\":false,\"incompatible\":[{\"commitId\":\"c\",\"table\":\"t\",\"reason\":\"other\"}]}",
1878        );
1879        assert_eq!(stored_previous_version_audit(&replica), None);
1880
1881        // Bounded at MAX_AUDIT_INCOMPATIBLE entries with `truncated`.
1882        let many = (0..MAX_AUDIT_INCOMPATIBLE + 5)
1883            .map(|_index| PendingCommitAudit {
1884                commit_id: "c",
1885                operations: vec![("gone", vec![], false)],
1886            })
1887            .collect::<Vec<_>>();
1888        let audit = build_previous_version_audit(&schema, &many, 1, 2, 0);
1889        assert_eq!(audit.pending, MAX_AUDIT_INCOMPATIBLE as i64 + 5);
1890        assert_eq!(audit.encodable, 0);
1891        assert_eq!(audit.incompatible.len(), MAX_AUDIT_INCOMPATIBLE);
1892        assert!(audit.truncated);
1893    }
1894
1895    #[test]
1896    fn previous_version_limit_defaults_and_ceiling_match_ts() {
1897        assert_eq!(resolve_previous_version_limit(None), Ok(50));
1898        assert_eq!(resolve_previous_version_limit(Some(7)), Ok(7));
1899        assert_eq!(resolve_previous_version_limit(Some(999)), Ok(200));
1900        assert!(resolve_previous_version_limit(Some(0)).is_err());
1901        assert!(resolve_previous_version_limit(Some(-1)).is_err());
1902    }
1903
1904    /// Removes every temp file the test created, so the container assertion
1905    /// "the path is gone" cannot pass because a later case reused the name.
1906    struct TempFiles {
1907        paths: Vec<PathBuf>,
1908    }
1909
1910    impl TempFiles {
1911        fn new(label: &str) -> Self {
1912            let path = std::env::temp_dir().join(format!(
1913                "syncular-prev-context-{label}-{}",
1914                uuid::Uuid::new_v4()
1915            ));
1916            let paths = vec![
1917                path.clone(),
1918                PathBuf::from(previous_version_container_path(
1919                    path.to_str().expect("utf-8 temp path"),
1920                )),
1921            ];
1922            Self { paths }
1923        }
1924
1925        fn replica(&self) -> &Path {
1926            &self.paths[0]
1927        }
1928    }
1929
1930    impl Drop for TempFiles {
1931        fn drop(&mut self) {
1932            for path in &self.paths {
1933                let _ = std::fs::remove_file(path);
1934                let _ = std::fs::remove_file(format!("{}-wal", path.display()));
1935                let _ = std::fs::remove_file(format!("{}-shm", path.display()));
1936            }
1937        }
1938    }
1939
1940    fn schema_json(version: i32) -> Value {
1941        json!({
1942            "version": version,
1943            "tables": [{
1944                "name": "things",
1945                "primaryKey": "id",
1946                "columns": [
1947                    { "name": "id", "type": "string", "nullable": false },
1948                    { "name": "project_id", "type": "string", "nullable": false },
1949                    { "name": "s", "type": "string", "nullable": true },
1950                    { "name": "i", "type": "integer", "nullable": true },
1951                    { "name": "f", "type": "float", "nullable": true },
1952                    { "name": "b", "type": "boolean", "nullable": true },
1953                    { "name": "j", "type": "json", "nullable": true },
1954                    { "name": "by", "type": "bytes", "nullable": true },
1955                    { "name": "cr", "type": "crdt", "nullable": true },
1956                    { "name": "br", "type": "blob_ref", "nullable": true },
1957                    { "name": "meta", "type": "string", "nullable": true }
1958                ],
1959                "scopes": []
1960            }, {
1961                "name": "notes",
1962                "primaryKey": "id",
1963                "columns": [
1964                    { "name": "id", "type": "string", "nullable": false },
1965                    { "name": "body", "type": "string", "nullable": true }
1966                ],
1967                "scopes": []
1968            }]
1969        })
1970    }
1971
1972    fn schema(version: i32) -> ClientSchema {
1973        parse_schema_json(&schema_json(version)).expect("valid test schema")
1974    }
1975
1976    /// The local DDL the client writes: bare column names, no declared type.
1977    fn seed_replica(path: &Path, rows: i64) {
1978        let replica = Connection::open(path).expect("open replica");
1979        replica
1980            .execute_batch(
1981                "CREATE TABLE _syncular_meta (key TEXT PRIMARY KEY, value TEXT NOT NULL);
1982                 CREATE TABLE things (id, project_id, s, i, f, b, j, by, cr, br, meta);
1983                 CREATE TABLE notes (id, body);",
1984            )
1985            .expect("create local tables");
1986        for index in 0..rows {
1987            replica
1988                .execute(
1989                    "INSERT INTO things(rowid, id, project_id, s, i, f, b, j, by, cr, br, meta)
1990                       VALUES (?1, ?2, 'p1', 'hello', 7, 1.5, 1, '{\"a\":1}',
1991                               x'010203', x'0908', 'blob:xyz', 'm1')",
1992                    rusqlite::params![index + 1, format!("r{}", index + 1)],
1993                )
1994                .expect("insert row");
1995        }
1996        replica
1997            .execute("INSERT INTO notes(id, body) VALUES ('n1', 'note')", [])
1998            .expect("insert note");
1999        // D1: the descriptor the bump's capture reads, exactly as the client
2000        // writes it — plus the §7.4.1 marker of the version that wrote it.
2001        set_local_schema_descriptor(&replica, &schema(1)).expect("descriptor");
2002        meta_set(&replica, LOCAL_SCHEMA_VERSION_KEY, "1");
2003    }
2004
2005    fn descriptor() -> LocalSchemaDescriptor {
2006        build_local_schema_descriptor(&schema(1))
2007    }
2008
2009    #[test]
2010    fn previous_version_descriptor_round_trips_and_rejects_unknown_shapes() {
2011        let conn = Connection::open_in_memory().expect("open in-memory");
2012        conn.execute_batch(
2013            "CREATE TABLE _syncular_meta (key TEXT PRIMARY KEY, value TEXT NOT NULL)",
2014        )
2015        .expect("create meta");
2016        let schema = schema(4);
2017        set_local_schema_descriptor(&conn, &schema).expect("descriptor");
2018
2019        let stored = meta_get(&conn, LOCAL_SCHEMA_DESCRIPTOR_KEY).expect("descriptor written");
2020        let parsed: Value = serde_json::from_str(&stored).expect("descriptor is JSON");
2021        assert_eq!(parsed.as_object().expect("object").len(), 3, "{stored}");
2022        assert_eq!(parsed["v"], json!(1));
2023        assert_eq!(parsed["version"], json!(4));
2024        let tables = parsed["tables"].as_array().expect("tables array");
2025        assert_eq!(tables.len(), 2);
2026        assert_eq!(tables[0]["name"], json!("things"));
2027        assert_eq!(tables[0]["primaryKey"], json!("id"));
2028        assert_eq!(
2029            tables[0]["columns"][1],
2030            json!({ "name": "project_id", "type": "string" })
2031        );
2032        assert_eq!(
2033            tables[0]["columns"][5],
2034            json!({ "name": "b", "type": "boolean" })
2035        );
2036        assert_eq!(
2037            tables[0]["columns"][7],
2038            json!({ "name": "by", "type": "bytes" })
2039        );
2040
2041        // Round trip through the strict decoder.
2042        assert_eq!(
2043            decode_local_schema_descriptor(&stored).expect("strict decode"),
2044            build_local_schema_descriptor(&schema)
2045        );
2046        assert_eq!(
2047            load_local_schema_descriptor(&conn).expect("loaded"),
2048            build_local_schema_descriptor(&schema)
2049        );
2050
2051        // Strict decode: an unknown shape is corruption, never a best guess.
2052        for raw in [
2053            r#"{"v":1,"version":4,"tables":[],"extra":1}"#,
2054            r#"{"v":2,"version":4,"tables":[]}"#,
2055            r#"{"v":1,"version":4}"#,
2056            r#"{"v":1,"version":-1,"tables":[]}"#,
2057            r#"{"v":1,"version":4,"tables":{}}"#,
2058            r#"{"v":1,"version":4,"tables":[{"name":"t","primaryKey":"id"}]}"#,
2059            r#"{"v":1,"version":4,"tables":[{"name":"t","primaryKey":"id","columns":[{"name":"a","type":"nope"}]}]}"#,
2060            r#"{"v":1,"version":4,"tables":[{"name":"t","primaryKey":"id","columns":[]}],"x":0}"#,
2061            "not json",
2062        ] {
2063            assert!(
2064                decode_local_schema_descriptor(raw).is_err(),
2065                "must reject {raw}"
2066            );
2067        }
2068
2069        // A corrupt stored value reads as absent (the reset then refuses).
2070        meta_set(&conn, LOCAL_SCHEMA_DESCRIPTOR_KEY, "{broken");
2071        assert_eq!(load_local_schema_descriptor(&conn), None);
2072    }
2073
2074    #[test]
2075    fn previous_version_capture_writes_sibling_container_with_readable_rows() {
2076        let temp = TempFiles::new("capture");
2077        let replica_path = temp.replica().to_str().expect("utf-8 path").to_owned();
2078        seed_replica(temp.replica(), 2);
2079        let replica = Connection::open(temp.replica()).expect("open replica");
2080
2081        let outcome = capture_previous_version_from_replica(
2082            &replica,
2083            &replica_path,
2084            1,
2085            2,
2086            &PreviousVersionContextConfig::default(),
2087            1_700_000_000_000,
2088        )
2089        .expect("capture");
2090        let CaptureOutcome::Captured(measurement) = outcome else {
2091            panic!("expected a capture, got {outcome:?}");
2092        };
2093        assert_eq!(measurement.tables, 2);
2094        assert_eq!(measurement.rows, 3);
2095        assert!(measurement.bytes > 0);
2096
2097        // The container is a SEPARATE file, and the replica cannot see it.
2098        let container_path = previous_version_container_path(&replica_path);
2099        assert!(Path::new(&container_path).exists());
2100        let visible: i64 = replica
2101            .query_row(
2102                "SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = ?1",
2103                rusqlite::params![CONTAINER_TABLE],
2104                |row| row.get(0),
2105            )
2106            .expect("count replica tables");
2107        assert_eq!(visible, 0);
2108
2109        let container = Connection::open(&container_path).expect("open container");
2110        let record = read_previous_version_container(&container)
2111            .expect("read record")
2112            .expect("record present");
2113        assert_eq!(record.previous_version, 1);
2114        assert_eq!(record.current_version, 2);
2115        assert_eq!(record.rows, 3);
2116        assert_eq!(record.bytes, measurement.bytes);
2117        assert_eq!(record.created_at_ms, 1_700_000_000_000);
2118        assert_eq!(record.tables.len(), 2);
2119        assert_eq!(record.tables[0].columns.len(), 11);
2120
2121        let table = record.table("things").expect("things in the record");
2122        let (rows, truncated) =
2123            read_previous_version_rows(&container, table, &[], 50).expect("read rows");
2124        assert!(!truncated);
2125        assert_eq!(rows.len(), 2);
2126        let row = &rows[0];
2127        assert_eq!(row["id"], json!("r1"));
2128        assert_eq!(row["s"], json!("hello"));
2129        assert_eq!(row["i"], json!(7));
2130        assert_eq!(row["f"], json!(1.5));
2131        assert_eq!(row["b"], json!(true));
2132        assert_eq!(row["j"], json!("{\"a\":1}"));
2133        assert_eq!(row["br"], json!("blob:xyz"));
2134        assert_eq!(row["by"], json!({ "$bytes": "010203" }));
2135        assert_eq!(row["cr"], json!({ "$bytes": "0908" }));
2136        assert!(!row.contains_key("_syncular_version"));
2137        assert!(!row.contains_key("_sync_version"));
2138
2139        // Row selection and the `limit + 1` truncation probe.
2140        let (selected, _) =
2141            read_previous_version_rows(&container, table, &["r2".to_owned()], 50).expect("select");
2142        assert_eq!(selected.len(), 1);
2143        assert_eq!(selected[0]["id"], json!("r2"));
2144        let (missing, _) =
2145            read_previous_version_rows(&container, table, &["absent".to_owned()], 50)
2146                .expect("select missing");
2147        assert!(missing.is_empty());
2148        let (limited, truncated) =
2149            read_previous_version_rows(&container, table, &[], 0).expect("limit 0");
2150        assert!(limited.is_empty());
2151        assert!(truncated);
2152    }
2153
2154    #[test]
2155    fn previous_version_each_budget_refuses_and_leaves_no_file() {
2156        let temp = TempFiles::new("budget");
2157        let replica_path = temp.replica().to_str().expect("utf-8 path").to_owned();
2158        seed_replica(temp.replica(), 2);
2159        let replica = Connection::open(temp.replica()).expect("open replica");
2160        let container_path = previous_version_container_path(&replica_path);
2161
2162        let cases = [
2163            (
2164                "maxTables",
2165                PreviousVersionContextConfig {
2166                    max_tables: 1,
2167                    ..Default::default()
2168                },
2169                0,
2170                false,
2171            ),
2172            (
2173                "maxRows",
2174                PreviousVersionContextConfig {
2175                    max_rows: 1,
2176                    ..Default::default()
2177                },
2178                2,
2179                false,
2180            ),
2181            (
2182                "maxBytes",
2183                PreviousVersionContextConfig {
2184                    max_bytes: 1,
2185                    ..Default::default()
2186                },
2187                3,
2188                true,
2189            ),
2190            (
2191                "maxRowBytes",
2192                PreviousVersionContextConfig {
2193                    max_row_bytes: 1,
2194                    ..Default::default()
2195                },
2196                3,
2197                false,
2198            ),
2199        ];
2200        for (label, config, expected_rows, bytes_measured) in cases {
2201            let outcome =
2202                capture_previous_version_from_replica(&replica, &replica_path, 1, 2, &config, 1)
2203                    .unwrap_or_else(|error| panic!("{label}: {error}"));
2204            let CaptureOutcome::Refused {
2205                reason,
2206                measurement,
2207            } = outcome
2208            else {
2209                panic!("{label}: expected a refusal, got {outcome:?}");
2210            };
2211            assert_eq!(
2212                reason,
2213                PreviousVersionRefusalReason::CaptureExceededBudget,
2214                "{label}"
2215            );
2216            assert_eq!(measurement.tables, 2, "{label}");
2217            assert_eq!(measurement.rows, expected_rows, "{label}");
2218            if bytes_measured {
2219                assert!(measurement.bytes > 1, "{label}");
2220            } else {
2221                assert_eq!(measurement.bytes, 0, "{label}");
2222            }
2223            // All-or-nothing: no partial copy, and no file at all.
2224            assert!(!Path::new(&container_path).exists(), "{label}");
2225            let refusal = meta_get(&replica, PREVIOUS_VERSION_CONTEXT_KEY)
2226                .unwrap_or_else(|| panic!("{label}: refusal recorded"));
2227            assert!(refusal.contains("capture-exceeded-budget"), "{refusal}");
2228        }
2229    }
2230
2231    #[test]
2232    fn previous_version_missing_or_stale_descriptor_refuses_without_a_file() {
2233        let temp = TempFiles::new("no-descriptor");
2234        let replica_path = temp.replica().to_str().expect("utf-8 path").to_owned();
2235        seed_replica(temp.replica(), 1);
2236        let replica = Connection::open(temp.replica()).expect("open replica");
2237        let container_path = previous_version_container_path(&replica_path);
2238
2239        // No descriptor at all: a database last opened by an unaware binary.
2240        meta_delete(&replica, LOCAL_SCHEMA_DESCRIPTOR_KEY);
2241        let outcome = capture_previous_version_from_replica(
2242            &replica,
2243            &replica_path,
2244            1,
2245            2,
2246            &PreviousVersionContextConfig::default(),
2247            1,
2248        )
2249        .expect("capture");
2250        assert_eq!(
2251            outcome,
2252            CaptureOutcome::Refused {
2253                reason: PreviousVersionRefusalReason::NoPreviousDescriptor,
2254                measurement: CaptureMeasurement::default(),
2255            }
2256        );
2257        assert!(!Path::new(&container_path).exists());
2258        let refusal = meta_get(&replica, PREVIOUS_VERSION_CONTEXT_KEY).expect("refusal");
2259        assert!(refusal.contains("no-previous-descriptor"), "{refusal}");
2260
2261        // A descriptor for a DIFFERENT version is treated exactly the same.
2262        meta_set(
2263            &replica,
2264            LOCAL_SCHEMA_DESCRIPTOR_KEY,
2265            &encode_local_schema_descriptor(&descriptor()),
2266        );
2267        let outcome = capture_previous_version_from_replica(
2268            &replica,
2269            &replica_path,
2270            3,
2271            4,
2272            &PreviousVersionContextConfig::default(),
2273            1,
2274        )
2275        .expect("capture");
2276        assert!(matches!(
2277            outcome,
2278            CaptureOutcome::Refused {
2279                reason: PreviousVersionRefusalReason::NoPreviousDescriptor,
2280                ..
2281            }
2282        ));
2283        assert!(!Path::new(&container_path).exists());
2284    }
2285
2286    #[test]
2287    fn previous_version_discard_removes_the_container_path() {
2288        let temp = TempFiles::new("discard");
2289        let replica_path = temp.replica().to_str().expect("utf-8 path").to_owned();
2290        seed_replica(temp.replica(), 1);
2291        let replica = Connection::open(temp.replica()).expect("open replica");
2292        let container_path = previous_version_container_path(&replica_path);
2293
2294        let outcome = capture_previous_version_from_replica(
2295            &replica,
2296            &replica_path,
2297            1,
2298            2,
2299            &PreviousVersionContextConfig::default(),
2300            1,
2301        )
2302        .expect("capture");
2303        assert!(matches!(outcome, CaptureOutcome::Captured(_)));
2304        assert!(Path::new(&container_path).exists());
2305        meta_set(&replica, PREVIOUS_VERSION_AUDIT_KEY, "{\"v\":1}");
2306
2307        let discarded = discard_previous_version(&replica, &replica_path).expect("discard");
2308        assert!(discarded.present);
2309        assert!(discarded.discarded);
2310        // The PATH is gone — not merely the tables inside it.
2311        assert!(!Path::new(&container_path).exists());
2312        assert!(!Path::new(&format!("{container_path}-wal")).exists());
2313        assert!(!Path::new(&format!("{container_path}-shm")).exists());
2314        assert_eq!(meta_get(&replica, PREVIOUS_VERSION_CONTEXT_KEY), None);
2315        assert_eq!(meta_get(&replica, PREVIOUS_VERSION_AUDIT_KEY), None);
2316
2317        // A no-op discard succeeds (the RFC 0006 key-loss contract needs it).
2318        let again = discard_previous_version(&replica, &replica_path).expect("discard again");
2319        assert!(!again.present);
2320        assert!(!again.discarded);
2321    }
2322
2323    #[test]
2324    fn previous_version_torn_container_never_blocks_sweep_discard_or_open() {
2325        let temp = TempFiles::new("torn");
2326        let replica_path = temp.replica().to_str().expect("utf-8 path").to_owned();
2327        let container_path = previous_version_container_path(&replica_path);
2328
2329        // FIX 1: garbage at the sibling path makes the connection open but
2330        // every statement fail with "file is not a database".
2331        std::fs::write(&container_path, b"not a sqlite database").expect("write garbage");
2332        let replica = Connection::open(temp.replica()).expect("open replica");
2333
2334        // The sweep must still remove it, and must not error.
2335        sweep_previous_version_container(&replica, &replica_path)
2336            .expect("a torn container must not fail the sweep");
2337        assert!(!Path::new(&container_path).exists());
2338
2339        // The discard must report it and still remove it.
2340        std::fs::write(&container_path, b"not a sqlite database").expect("write garbage again");
2341        let discarded = discard_previous_version(&replica, &replica_path)
2342            .expect("a torn container must not fail the discard");
2343        assert!(discarded.present);
2344        assert!(discarded.discarded);
2345        assert!(!Path::new(&container_path).exists());
2346
2347        // And a client opening over a torn container must still open, sweeping
2348        // it on the bump path.
2349        std::fs::write(&container_path, b"not a sqlite database")
2350            .expect("write garbage a third time");
2351        seed_replica(temp.replica(), 1);
2352        {
2353            SyncClient::open_path(
2354                "pvc".to_owned(),
2355                &schema_json(2),
2356                ClientLimits::default(),
2357                &replica_path,
2358            )
2359            .expect("a torn container must not block the client open");
2360        }
2361        assert!(!Path::new(&container_path).exists());
2362    }
2363
2364    #[test]
2365    fn previous_version_same_version_open_discards_a_container_it_did_not_bump_to() {
2366        // Reproduces the exact cross-file interleaving: the replica savepoint
2367        // opens (marker still 1), the sweep runs, the container's OWN
2368        // transaction commits {previousVersion: 1, currentVersion: 2}, and the
2369        // process dies before the replica savepoint releases. The next open is
2370        // at the OLD schema version, so it runs no reset and no sweep.
2371        let temp = TempFiles::new("same-version-orphan");
2372        let replica_path = temp.replica().to_str().expect("utf-8 path").to_owned();
2373        seed_replica(temp.replica(), 1);
2374        let replica = Connection::open(temp.replica()).expect("open replica");
2375        let container_path = previous_version_container_path(&replica_path);
2376
2377        let outcome = capture_previous_version_from_replica(
2378            &replica,
2379            &replica_path,
2380            1,
2381            2,
2382            &PreviousVersionContextConfig::default(),
2383            1,
2384        )
2385        .expect("capture");
2386        assert!(matches!(outcome, CaptureOutcome::Captured(_)));
2387        assert!(Path::new(&container_path).exists());
2388        // The replica never committed: the marker is still 1.
2389        assert_eq!(
2390            meta_get(&replica, LOCAL_SCHEMA_VERSION_KEY).as_deref(),
2391            Some("1")
2392        );
2393        drop(replica);
2394
2395        // Opening at the SAME version (1) must discard the stale container
2396        // before any read surface can see it, and open normally.
2397        {
2398            let client = SyncClient::open_path(
2399                "pvc".to_owned(),
2400                &schema_json(1),
2401                ClientLimits::default(),
2402                &replica_path,
2403            )
2404            .expect("the old-schema open must succeed");
2405            let rows = client
2406                .query("SELECT COUNT(*) AS c FROM things", &[])
2407                .expect("the client still serves ordinary queries");
2408            assert_eq!(rows.len(), 1);
2409        }
2410        // Read storage directly, never through a read API.
2411        assert!(!Path::new(&container_path).exists());
2412        let inspect = Connection::open(&replica_path).expect("inspect after open");
2413        assert_eq!(meta_get(&inspect, PREVIOUS_VERSION_CONTEXT_KEY), None);
2414        assert_eq!(meta_get(&inspect, PREVIOUS_VERSION_AUDIT_KEY), None);
2415        assert_eq!(
2416            inspect
2417                .query_row(
2418                    "SELECT COUNT(*) FROM sqlite_master WHERE name = ?1",
2419                    rusqlite::params![CONTAINER_TABLE],
2420                    |row| row.get::<_, i64>(0),
2421                )
2422                .expect("replica table count"),
2423            0
2424        );
2425
2426        // A container whose recorded currentVersion MATCHES the running schema
2427        // is left alone (it is the container this boot would serve).
2428        let temp = TempFiles::new("same-version-live");
2429        let replica_path = temp.replica().to_str().expect("utf-8 path").to_owned();
2430        seed_replica(temp.replica(), 1);
2431        let replica = Connection::open(temp.replica()).expect("open replica");
2432        capture_previous_version_from_replica(
2433            &replica,
2434            &replica_path,
2435            1,
2436            1,
2437            &PreviousVersionContextConfig::default(),
2438            1,
2439        )
2440        .expect("capture");
2441        drop(replica);
2442        let container_path = previous_version_container_path(&replica_path);
2443        assert!(Path::new(&container_path).exists());
2444        SyncClient::open_path(
2445            "pvc".to_owned(),
2446            &schema_json(1),
2447            ClientLimits::default(),
2448            &replica_path,
2449        )
2450        .expect("open v1 beside a matching container");
2451        assert!(Path::new(&container_path).exists());
2452    }
2453
2454    #[test]
2455    fn previous_version_client_writes_descriptor_and_captures_on_bump() {
2456        let temp = TempFiles::new("client");
2457        let replica_path = temp.replica().to_str().expect("utf-8 path").to_owned();
2458        let container_path = previous_version_container_path(&replica_path);
2459
2460        // Fresh install at v1: the descriptor is written beside the marker and
2461        // the feature (default off) creates no container.
2462        {
2463            let mut client = SyncClient::open_path(
2464                "pvc".to_owned(),
2465                &schema_json(1),
2466                ClientLimits::default(),
2467                &replica_path,
2468            )
2469            .expect("open v1");
2470            client
2471                .mutate(vec![Mutation::Upsert {
2472                    table: "things".to_owned(),
2473                    values: Map::from_iter([
2474                        ("id".to_owned(), json!("r1")),
2475                        ("project_id".to_owned(), json!("p1")),
2476                        ("s".to_owned(), json!("hello")),
2477                    ]),
2478                    base_version: None,
2479                }])
2480                .expect("seed a local row");
2481        }
2482        {
2483            let inspect = Connection::open(&replica_path).expect("inspect v1");
2484            let descriptor =
2485                load_local_schema_descriptor(&inspect).expect("descriptor after install");
2486            assert_eq!(descriptor.version, 1);
2487            assert_eq!(descriptor.tables.len(), 2);
2488            assert_eq!(
2489                descriptor.table("things").expect("things").columns.len(),
2490                11
2491            );
2492        }
2493        assert!(!Path::new(&container_path).exists());
2494
2495        // A same-version open backfills a descriptor an unaware binary never wrote.
2496        {
2497            let inspect = Connection::open(&replica_path).expect("inspect");
2498            meta_delete(&inspect, LOCAL_SCHEMA_DESCRIPTOR_KEY);
2499        }
2500        {
2501            let _client = SyncClient::open_path(
2502                "pvc".to_owned(),
2503                &schema_json(1),
2504                ClientLimits::default(),
2505                &replica_path,
2506            )
2507            .expect("reopen v1");
2508        }
2509        {
2510            let inspect = Connection::open(&replica_path).expect("inspect v1 again");
2511            assert_eq!(
2512                load_local_schema_descriptor(&inspect)
2513                    .expect("backfilled")
2514                    .version,
2515                1
2516            );
2517        }
2518
2519        // The bump captures the v1 row into the sibling file, and the replica
2520        // connection still cannot see the container table.
2521        {
2522            let limits = ClientLimits {
2523                previous_version_context: Some(PreviousVersionContextConfig {
2524                    enabled: true,
2525                    ..Default::default()
2526                }),
2527                ..Default::default()
2528            };
2529            SyncClient::open_path("pvc".to_owned(), &schema_json(2), limits, &replica_path)
2530                .expect("bump to v2");
2531        }
2532        assert!(Path::new(&container_path).exists());
2533        {
2534            let inspect = Connection::open(&replica_path).expect("inspect v2");
2535            assert_eq!(
2536                load_local_schema_descriptor(&inspect)
2537                    .expect("descriptor after bump")
2538                    .version,
2539                2
2540            );
2541            assert_eq!(
2542                inspect
2543                    .query_row(
2544                        "SELECT COUNT(*) FROM sqlite_master WHERE name = ?1",
2545                        rusqlite::params![CONTAINER_TABLE],
2546                        |row| row.get::<_, i64>(0),
2547                    )
2548                    .expect("replica table count"),
2549                0
2550            );
2551        }
2552        {
2553            let container = Connection::open(&container_path).expect("open container");
2554            let record = read_previous_version_container(&container)
2555                .expect("read record")
2556                .expect("captured record");
2557            assert_eq!(record.previous_version, 1);
2558            assert_eq!(record.current_version, 2);
2559            assert_eq!(record.rows, 1);
2560            let (rows, _) = read_previous_version_rows(
2561                &container,
2562                record.table("things").expect("things"),
2563                &[],
2564                50,
2565            )
2566            .expect("read rows");
2567            assert_eq!(rows.len(), 1);
2568            assert_eq!(rows[0]["s"], json!("hello"));
2569        }
2570
2571        // Discard removes the path and both metadata records.
2572        {
2573            let inspect = Connection::open(&replica_path).expect("inspect v2");
2574            let outcome = discard_previous_version(&inspect, &replica_path).expect("discard");
2575            assert!(outcome.present && outcome.discarded);
2576        }
2577        assert!(!Path::new(&container_path).exists());
2578    }
2579}