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