Skip to main content

powdb_storage/
catalog.rs

1use crate::btree::{BTree, IndexStats};
2use crate::error::StorageError;
3use crate::heap::HeapFile;
4use crate::page::{OVERFLOW_CHAIN_END, OVERFLOW_PAYLOAD_CAP};
5use crate::row::{encode_row_into, encode_row_v2_into, plan_spill, OverflowStub, MAX_VALUE_SIZE};
6use crate::stored_json_path::{StoredJsonPathSegmentV1, StoredJsonPathV1};
7use crate::table::Table;
8use crate::types::*;
9use crate::wal::{Wal, WalDurabilityTicket, WalRecord, WalRecordType, WalSyncMode};
10use rustc_hash::FxHashMap;
11use std::fs;
12use std::io::{self, Read, Write};
13use std::path::{Path, PathBuf};
14use std::sync::atomic::{AtomicU64, Ordering};
15use tracing::{info, warn};
16
17static NEXT_STRUCTURE_GENERATION: AtomicU64 = AtomicU64::new(1);
18
19fn next_structure_generation() -> u64 {
20    NEXT_STRUCTURE_GENERATION.fetch_add(1, Ordering::Relaxed)
21}
22
23/// Reject an encoded row that exceeds the single-page capacity BEFORE it is
24/// appended to the WAL. The heap performs the same check at its own insert/
25/// update boundary, but the update paths log to the WAL first — a logged
26/// record whose row the heap then rejects would poison the next replay.
27fn check_encoded_row_size(encoded: &[u8]) -> io::Result<()> {
28    if encoded.len() > crate::page::MAX_ROW_DATA_SIZE {
29        return Err(crate::error::StorageError::RowTooLarge {
30            size: encoded.len(),
31            max: crate::page::MAX_ROW_DATA_SIZE,
32        }
33        .into());
34    }
35    Ok(())
36}
37
38/// Validate that a name (table or column) is safe for use in file paths and
39/// follows the identifier convention: starts with a letter or underscore,
40/// followed by letters, digits, or underscores.
41fn validate_identifier(kind: &str, name: &str) -> io::Result<()> {
42    if name.is_empty() {
43        return Err(io::Error::new(
44            io::ErrorKind::InvalidInput,
45            format!("invalid {kind} name: must not be empty"),
46        ));
47    }
48    let mut chars = name.chars();
49    // Infallible: we returned early if `name.is_empty()` above.
50    let first = chars.next().expect("non-empty name");
51    if !first.is_ascii_alphabetic() && first != '_' {
52        return Err(io::Error::new(
53            io::ErrorKind::InvalidInput,
54            format!("invalid {kind} name '{name}': must start with a letter or underscore"),
55        ));
56    }
57    for ch in chars {
58        if !ch.is_ascii_alphanumeric() && ch != '_' {
59            return Err(io::Error::new(
60                io::ErrorKind::InvalidInput,
61                format!(
62                    "invalid {kind} name '{name}': must contain only letters, digits, and underscores"
63                ),
64            ));
65        }
66    }
67    Ok(())
68}
69
70/// Validate a table name for path safety.
71fn validate_table_name(name: &str) -> io::Result<()> {
72    validate_identifier("table", name)
73}
74
75/// Validate a column name for path safety.
76fn validate_column_name(name: &str) -> io::Result<()> {
77    validate_identifier("column", name)
78}
79
80/// On-disk catalog file: lists every table's schema so we can reopen them
81/// after a restart. Format is a small custom binary blob (no serde dep).
82///
83/// Mission 3: version 2 appends a per-table list of indexed column names
84/// after the column list, so indexes can be rehydrated on `Catalog::open`.
85/// Version 1 files still load cleanly — they're treated as having zero
86/// indexed columns, and the next `create_index` (or implicit rebuild on
87/// first open, depending on the caller) will populate the list.
88const CATALOG_FILE: &str = "catalog.bin";
89pub const CATALOG_LSN_FILE: &str = "catalog.lsn";
90const CATALOG_MAGIC: &[u8; 4] = b"BCAT";
91/// Version 4 appends a per-table column-defaults section after the indexed
92/// column list; version 5 appends an auto-increment column section after that.
93/// Older files load cleanly (no defaults / no auto columns).
94pub const LEGACY_CATALOG_VERSION: u16 = 5;
95pub const CATALOG_VERSION: u16 = 6;
96
97/// Persisted metadata for a JSON-path expression index. Expression index files
98/// are addressed only by `index_id`; canonical expression text never reaches a
99/// filesystem path.
100#[derive(Debug, Clone, PartialEq, Eq)]
101pub struct ExpressionIndexMeta {
102    pub index_id: u64,
103    pub unique: bool,
104    pub canonical_version: u16,
105    pub canonical_text: String,
106    pub json_path: StoredJsonPathV1,
107}
108
109#[derive(Debug, Clone, PartialEq, Eq)]
110pub enum IndexKeySource {
111    Column {
112        column: String,
113    },
114    Expression {
115        index_id: u64,
116        canonical_version: u16,
117        canonical_text: String,
118        json_path: StoredJsonPathV1,
119    },
120}
121
122#[derive(Debug, Clone, PartialEq, Eq)]
123pub struct IndexMetadata {
124    pub unique: bool,
125    pub source: IndexKeySource,
126}
127
128#[derive(Debug, Clone, Copy, PartialEq, Eq)]
129pub enum IndexOrderDirection {
130    Asc,
131    Desc,
132}
133
134/// Expression-index artifacts live in a filename namespace disjoint from
135/// legacy column indexes. Column indexes always end in `.idx`; expression
136/// indexes always end in `.eidx`. Keeping the extension distinct prevents a
137/// table/column underscore decomposition from ever aliasing an expression ID.
138pub fn expression_index_file_name(table: &str, index_id: u64) -> String {
139    format!("{table}_{index_id}.eidx")
140}
141
142/// Mission 2 (durability): the single shared WAL file lives under the catalog's
143/// data directory with this name. One WAL covers every table in the catalog.
144const WAL_FILE: &str = "wal.log";
145const SYNC_STATE_DIR: &str = ".powdb-sync";
146const SYNC_IDENTITY_FILE: &str = "identity.json";
147
148/// WAL batch size: flush auto-triggers after this many records, in addition
149/// to the explicit `wal.flush()` each top-level mutation does. Kept small so
150/// the tests see a predictable amount of buffering.
151const WAL_BATCH_SIZE: usize = 64;
152type WalArchiveCallback<'a> = &'a mut dyn FnMut(&Path, &[WalRecord]) -> io::Result<()>;
153
154fn read_durable_lsn(data_dir: &Path) -> io::Result<u64> {
155    let path = data_dir.join(CATALOG_LSN_FILE);
156    let bytes = match fs::read(path) {
157        Ok(bytes) => bytes,
158        Err(err) if err.kind() == io::ErrorKind::NotFound => return Ok(0),
159        Err(err) => return Err(err),
160    };
161    if bytes.len() != 8 {
162        return Err(io::Error::new(
163            io::ErrorKind::InvalidData,
164            "catalog LSN sidecar has invalid length",
165        ));
166    }
167    let mut buf = [0u8; 8];
168    buf.copy_from_slice(&bytes);
169    Ok(u64::from_le_bytes(buf))
170}
171
172fn write_durable_lsn(data_dir: &Path, lsn: u64) -> io::Result<()> {
173    let path = data_dir.join(CATALOG_LSN_FILE);
174    let tmp_path = data_dir.join(format!("{CATALOG_LSN_FILE}.tmp"));
175    let mut file = fs::File::create(&tmp_path)?;
176    file.write_all(&lsn.to_le_bytes())?;
177    file.sync_all()?;
178    drop(file);
179    fs::rename(&tmp_path, &path)?;
180    sync_directory(data_dir)?;
181    Ok(())
182}
183
184#[cfg(unix)]
185fn sync_directory(path: &Path) -> io::Result<()> {
186    fs::File::open(path)?.sync_all()
187}
188
189#[cfg(not(unix))]
190fn sync_directory(path: &Path) -> io::Result<()> {
191    let _ = path;
192    Ok(())
193}
194
195#[cfg(test)]
196thread_local! {
197    static CATALOG_PERSIST_FAILPOINT: std::cell::Cell<u8> = const { std::cell::Cell::new(0) };
198}
199
200#[cfg(test)]
201fn take_catalog_persist_failpoint(stage: u8) -> bool {
202    CATALOG_PERSIST_FAILPOINT.with(|failpoint| {
203        if failpoint.get() == stage {
204            failpoint.set(0);
205            true
206        } else {
207            false
208        }
209    })
210}
211
212enum CatalogPersistError {
213    BeforeActivation(io::Error),
214    AfterActivation(io::Error),
215}
216
217impl CatalogPersistError {
218    fn into_io_error(self) -> io::Error {
219        match self {
220            Self::BeforeActivation(error) | Self::AfterActivation(error) => error,
221        }
222    }
223}
224
225fn max_record_lsn(records: &[WalRecord]) -> Option<u64> {
226    records.iter().map(|record| record.lsn).max()
227}
228
229/// System catalog: registry of all tables.
230///
231/// Mission C Phase 18: tables live in a `Vec<Table>` addressed by a `slot`
232/// index, with a parallel `FxHashMap<String, usize>` for name-based resolution.
233/// DROP TABLE can move slots, so prepared fast paths pair a cached slot with the
234/// O(1) structural generation below and fall back when any DDL invalidates it.
235///
236/// Earlier design (pre-Phase 18) held tables in a `FxHashMap<String, Table>`
237/// directly. That meant the `insert_batch_1k` hot path paid an
238/// `FxHash("User")` + bucket walk per row just to dispatch into the
239/// table — about 20-40ns out of a 233ns budget.
240pub struct Catalog {
241    /// All tables, in insertion order. Indexed by `slot: usize`.
242    tables: Vec<Table>,
243    /// Name → slot index. Populated in sync with `tables` on every
244    /// `create_table` / `open`.
245    name_to_slot: FxHashMap<String, usize>,
246    data_dir: PathBuf,
247    /// Mission 2: shared write-ahead log owned by the catalog. Every
248    /// mutation (insert/update/delete) records its intent here BEFORE
249    /// touching the heap so a mid-write crash can be recovered from on the
250    /// next open. Flushed to disk at the end of every top-level op.
251    wal: Wal,
252    /// Monotonic transaction-id counter. Autocommit statements may allocate
253    /// multiple ids (one per row-level primitive), while explicit transactions
254    /// reuse one id for the whole BEGIN..COMMIT scope.
255    next_tx_id: u64,
256    /// Active explicit transaction id, if any. Owned by the connection/session
257    /// driving this catalog through `Engine`.
258    active_tx_id: Option<u64>,
259    /// Durable WAL byte offset captured at BEGIN. ROLLBACK truncates back to
260    /// this boundary so auto-flushed uncommitted records cannot replay later.
261    tx_start_len: Option<u64>,
262    /// Autocommit row-mutation tx ids appended since the previous group commit.
263    /// `commit_autocommit` writes commit markers for these ids before fsync.
264    pending_autocommit_tx_ids: Vec<u64>,
265    /// Has this catalog been cleanly checkpointed at least once since it
266    /// was opened? Used by `Drop` to decide whether to treat its own flush
267    /// as fatal (it isn't — we still try best-effort).
268    checkpointed: bool,
269    /// Catalog-level durable LSN. Heap page LSNs cover row mutations, but
270    /// DDL-only changes can advance the WAL without touching a data page.
271    durable_lsn: u64,
272    /// Overflow-chain pages to return to their table's free list once the
273    /// current EXPLICIT transaction commits (design 3.6 pending-free list).
274    /// Populated only while `active_tx_id.is_some()`: a chain-replacing update
275    /// or a delete inside a transaction cannot free its old chain immediately,
276    /// because ROLLBACK resurrects the old row and its stub must still address a
277    /// live chain. Autocommit mutations free immediately (no rollback window).
278    /// Drained by `commit_transaction`; discarded (via reopen) by ROLLBACK.
279    /// Entries are `(table_slot, chain_pages)`.
280    pending_free_overflow: Vec<(usize, Vec<u32>)>,
281    /// Catalog format currently active on disk. v6 activates lazily on the
282    /// first successful expression-index metadata creation.
283    active_catalog_version: u16,
284    /// Global, durable, monotonically increasing expression-index identity.
285    next_index_id: u64,
286    /// Process-local catalog structure identity. Any table/schema/default/
287    /// auto/index DDL replaces this token, invalidating cached prepared
288    /// metadata in O(1). Opening a replacement Catalog (including rollback)
289    /// also receives a fresh token.
290    structure_generation: u64,
291    /// True when opened via [`Catalog::open_read_only`] for snapshot serving.
292    /// The heap/index/WAL files are read-only handles, no LSN stamping or
293    /// overflow sweep ran at open, and [`Drop`] skips the checkpoint (which would
294    /// otherwise flush pages and truncate the WAL, mutating the directory).
295    read_only: bool,
296}
297
298impl Catalog {
299    /// Create a brand-new catalog. Wipes any existing catalog file in this directory.
300    ///
301    /// # Examples
302    ///
303    /// ```
304    /// use powdb_storage::catalog::Catalog;
305    /// use powdb_storage::types::{Schema, ColumnDef, TypeId};
306    ///
307    /// let dir = tempfile::tempdir().unwrap();
308    /// let mut catalog = Catalog::create(dir.path()).unwrap();
309    ///
310    /// let schema = Schema {
311    ///     table_name: "User".to_string(),
312    ///     columns: vec![
313    ///         ColumnDef { name: "name".to_string(), type_id: TypeId::Str, required: true, position: 0 },
314    ///         ColumnDef { name: "age".to_string(), type_id: TypeId::Int, required: false, position: 1 },
315    ///     ],
316    /// };
317    /// catalog.create_table(schema).unwrap();
318    /// ```
319    pub fn create(data_dir: &Path) -> io::Result<Self> {
320        crate::create_data_dir_secure(data_dir)?;
321        let wal_path = data_dir.join(WAL_FILE);
322        let wal = Wal::create(&wal_path, WAL_BATCH_SIZE)?;
323        let cat = Catalog {
324            tables: Vec::new(),
325            name_to_slot: FxHashMap::default(),
326            data_dir: data_dir.to_path_buf(),
327            wal,
328            next_tx_id: 1,
329            active_tx_id: None,
330            tx_start_len: None,
331            pending_autocommit_tx_ids: Vec::new(),
332            pending_free_overflow: Vec::new(),
333            checkpointed: false,
334            durable_lsn: 0,
335            active_catalog_version: LEGACY_CATALOG_VERSION,
336            next_index_id: 1,
337            structure_generation: next_structure_generation(),
338            read_only: false,
339        };
340        cat.persist()?;
341        Ok(cat)
342    }
343
344    /// Open an existing catalog from disk, rehydrating every table. If no
345    /// catalog file is present this returns NotFound — callers can fall back
346    /// to `create` for a fresh data dir.
347    ///
348    /// Mission 2: after the per-table heap files are reopened, this replays
349    /// any records left in the WAL from a previous (crashed) session. The
350    /// WAL is then truncated once the replay lands cleanly on disk — that
351    /// re-establishes the "empty WAL = last shutdown was clean" invariant.
352    pub fn open(data_dir: &Path) -> io::Result<Self> {
353        Self::open_inner(data_dir, None)
354    }
355
356    /// Open an existing catalog and archive any replayed WAL records before
357    /// recovery truncates the WAL. This is for sync-aware callers that must
358    /// retain history needed by replicas.
359    ///
360    /// Replication boundary: this hook exists so `powdb-sync` can preserve WAL
361    /// history before storage recovery truncates it. Ordinary embedded/server
362    /// callers should use `open`; do not build application-level recovery flows
363    /// directly on this hook.
364    pub fn open_with_wal_archive<F>(data_dir: &Path, mut archive: F) -> io::Result<Self>
365    where
366        F: FnMut(&Path, &[WalRecord]) -> io::Result<()>,
367    {
368        let archive: WalArchiveCallback<'_> = &mut archive;
369        Self::open_inner(data_dir, Some(archive))
370    }
371
372    fn open_inner(data_dir: &Path, archive: Option<WalArchiveCallback<'_>>) -> io::Result<Self> {
373        let cat_path = data_dir.join(CATALOG_FILE);
374        if !cat_path.exists() {
375            return Err(io::Error::new(io::ErrorKind::NotFound, "no catalog file"));
376        }
377        let catalog_file = read_catalog_file(&cat_path)?;
378        let active_catalog_version = catalog_file.version;
379        let next_index_id = catalog_file.next_index_id;
380        let entries = catalog_file.entries;
381        let durable_lsn = read_durable_lsn(data_dir)?;
382        let mut tables: Vec<Table> = Vec::with_capacity(entries.len());
383        let mut name_to_slot =
384            FxHashMap::with_capacity_and_hasher(entries.len(), Default::default());
385        for CatalogEntry {
386            schema,
387            indexed_cols,
388            expression_indexes: expression_metas,
389            defaults,
390            auto_cols,
391        } in entries
392        {
393            let name = schema.table_name.clone();
394            // Mission 3: rehydrate persisted indexes. `Table::open_with_indexes`
395            // tries to `BTree::load` each named index file; if a file is
396            // missing (e.g. first open after upgrade from catalog v1) it
397            // falls back to rebuilding from the heap scan and saving to
398            // disk so subsequent opens hit the fast path.
399            let mut table =
400                Table::open_with_indexes(schema, data_dir, &indexed_cols, &expression_metas)?;
401            table.set_defaults(defaults);
402            table.set_auto_cols(auto_cols);
403            name_to_slot.insert(name.clone(), tables.len());
404            tables.push(table);
405        }
406        let wal_path = data_dir.join(WAL_FILE);
407        let wal = Wal::open(&wal_path, WAL_BATCH_SIZE)?;
408        let mut cat = Catalog {
409            tables,
410            name_to_slot,
411            data_dir: data_dir.to_path_buf(),
412            wal,
413            next_tx_id: 1,
414            active_tx_id: None,
415            tx_start_len: None,
416            pending_autocommit_tx_ids: Vec::new(),
417            pending_free_overflow: Vec::new(),
418            checkpointed: false,
419            durable_lsn,
420            active_catalog_version,
421            next_index_id,
422            structure_generation: next_structure_generation(),
423            read_only: false,
424        };
425        cat.replay_wal(archive)?;
426        // Restore WAL LSN monotonicity across the restart. Heap pages carry
427        // LSNs stamped by replay (catalog.rs set_page_lsn) and by DDL
428        // rewrites (stamp_all_pages_min_lsn), but `Wal::open` reset the
429        // counter to 1. If the next write reused an LSN <= a stamped page
430        // LSN, the following crash's replay would skip it as already-applied
431        // — the data-loss bug behind the v0.4.x yanks. This runs on every
432        // open (including the empty-WAL clean-shutdown path, where pages may
433        // still carry LSNs from an earlier recovery). LSNs must be monotonic
434        // across restarts.
435        let max_page_lsn = cat
436            .tables
437            .iter()
438            .map(|t| t.heap.max_page_lsn())
439            .max()
440            .unwrap_or(0);
441        let max_known_lsn = max_page_lsn.max(cat.durable_lsn);
442        cat.wal.set_next_lsn_at_least(max_known_lsn + 1);
443        // Auto-sweep overflow orphans after recovery: a crash is exactly when
444        // a chain page can end up flushed but referenced by no committed row
445        // (its Insert was uncommitted, or its Delete committed). Reclaim them
446        // now (design 3.6). Best-effort — a sweep failure must not block open.
447        if let Err(e) = cat.sweep_all() {
448            warn!(error = %e, "post-recovery overflow sweep failed (non-fatal)");
449        }
450        Ok(cat)
451    }
452
453    /// Open a catalog **read-only** for snapshot serving (tier 1 of the replica
454    /// story). This is for a *quiescent* directory: a restored backup or a
455    /// checkpointed replica, both guaranteed WAL-clean.
456    ///
457    /// Unlike [`Catalog::open`], this path:
458    /// - opens every heap and index file read-only (no writable descriptor);
459    /// - never calls `set_permissions` (it validates the directory instead);
460    /// - **refuses** a non-empty WAL rather than replaying and truncating it ,
461    ///   an unclean directory must be recovered by a read-write engine first;
462    /// - stamps no page LSNs, sweeps no overflow orphans, and truncates nothing.
463    ///
464    /// The result never mutates the directory, so N read-only processes can serve
465    /// the same snapshot concurrently.
466    pub fn open_read_only(data_dir: &Path) -> io::Result<Self> {
467        crate::validate_data_dir_read_only(data_dir)?;
468        let cat_path = data_dir.join(CATALOG_FILE);
469        if !cat_path.exists() {
470            return Err(io::Error::new(io::ErrorKind::NotFound, "no catalog file"));
471        }
472
473        // Refuse a non-empty WAL: the directory has un-checkpointed mutations
474        // that only a read-write open may safely replay. Naming the remedy keeps
475        // the operator from guessing.
476        let wal_path = data_dir.join(WAL_FILE);
477        if crate::wal::wal_has_committed_records(&wal_path)? {
478            return Err(io::Error::new(
479                io::ErrorKind::InvalidData,
480                "cannot open read-only: the WAL is not empty (the directory has \
481                 un-checkpointed writes). Open the directory once with a read-write \
482                 engine to recover, or restore from a backup, then serve it read-only",
483            ));
484        }
485
486        let catalog_file = read_catalog_file(&cat_path)?;
487        let active_catalog_version = catalog_file.version;
488        let next_index_id = catalog_file.next_index_id;
489        let entries = catalog_file.entries;
490        let durable_lsn = read_durable_lsn(data_dir)?;
491        let mut tables: Vec<Table> = Vec::with_capacity(entries.len());
492        let mut name_to_slot =
493            FxHashMap::with_capacity_and_hasher(entries.len(), Default::default());
494        for CatalogEntry {
495            schema,
496            indexed_cols,
497            expression_indexes: expression_metas,
498            defaults,
499            auto_cols,
500        } in entries
501        {
502            let name = schema.table_name.clone();
503            let mut table = Table::open_with_indexes_read_only(
504                schema,
505                data_dir,
506                &indexed_cols,
507                &expression_metas,
508            )?;
509            table.set_defaults(defaults);
510            table.set_auto_cols(auto_cols);
511            name_to_slot.insert(name.clone(), tables.len());
512            tables.push(table);
513        }
514        let wal = Wal::open_read_only(&wal_path, WAL_BATCH_SIZE)?;
515        Ok(Catalog {
516            tables,
517            name_to_slot,
518            data_dir: data_dir.to_path_buf(),
519            wal,
520            next_tx_id: 1,
521            active_tx_id: None,
522            tx_start_len: None,
523            pending_autocommit_tx_ids: Vec::new(),
524            pending_free_overflow: Vec::new(),
525            checkpointed: false,
526            durable_lsn,
527            active_catalog_version,
528            next_index_id,
529            structure_generation: next_structure_generation(),
530            read_only: true,
531        })
532    }
533
534    /// Replay every record currently buffered in the WAL file onto the open
535    /// tables. This is the recovery path: after a crash the heap files on
536    /// disk may be missing mutations that were logged to the WAL but never
537    /// written back to their pages. We re-apply every record unconditionally.
538    ///
539    /// **Idempotence:**
540    /// - `Delete`: idempotent — `HeapFile::delete` on an already-deleted or
541    ///   missing slot is a no-op.
542    /// - `Update`: idempotent — re-applies the same new row bytes to the
543    ///   same `RowId`, which either replaces the existing (already-updated)
544    ///   row with itself or lands the update for the first time.
545    /// - `Insert`: **NOT strictly idempotent**. `HeapFile::insert` allocates
546    ///   a fresh `RowId` on every call, so a row that was already flushed
547    ///   to disk will be re-inserted at a new location, producing a
548    ///   duplicate. See the mission report for the full caveat.
549    ///
550    /// The practical consequences are:
551    ///   1. On a "pure crash" (no heap pages ever flushed between open and
552    ///      crash), replay cleanly restores every logged row.
553    ///   2. On a crash where some heap pages were flushed by the hot-page
554    ///      eviction logic, replay may restore those rows a second time.
555    ///      A future mission can fix this with LSN-tagged pages.
556    ///
557    /// After a successful replay we truncate the WAL so the next shutdown
558    /// (crash or otherwise) replays only the NEW records.
559    fn replay_wal(&mut self, mut archive: Option<WalArchiveCallback<'_>>) -> io::Result<()> {
560        let records = self.wal.read_all()?;
561        if records.is_empty() {
562            return Ok(());
563        }
564        if archive.is_none() {
565            self.ensure_plain_wal_truncate_allowed(&records)?;
566        }
567        self.replay_records(&records)?;
568        if let Some(archive) = archive.as_mut() {
569            archive(&self.data_dir, &records)?;
570        }
571        self.wal.truncate()?;
572        Ok(())
573    }
574
575    /// Apply an LSN-preserving WAL record stream without appending it to the
576    /// local WAL. Sync callers must validate lineage and contiguity before
577    /// calling this method.
578    ///
579    /// Replication boundary: this is a storage adapter for `powdb-sync`, not a
580    /// general mutation API. Callers must reject unsupported record classes,
581    /// hold their own replica progress state, and pass only contiguous,
582    /// transaction-complete ranges or chunks.
583    pub fn apply_wal_records(&mut self, records: &[WalRecord]) -> io::Result<()> {
584        self.ensure_no_active_transaction_for_checkpoint()?;
585        self.ensure_no_pending_wal_records()?;
586        self.replay_records(records)
587    }
588
589    /// Sync callers use this before deciding an apply is a no-op. A replica with
590    /// local WAL history is divergent until a higher layer explicitly repairs it.
591    pub fn ensure_no_pending_wal_records(&self) -> io::Result<()> {
592        if self.wal.has_pending() || !self.wal.read_all()?.is_empty() {
593            return Err(io::Error::other(
594                "cannot apply replicated WAL records while local WAL records are pending",
595            ));
596        }
597        Ok(())
598    }
599
600    fn replay_records(&mut self, records: &[WalRecord]) -> io::Result<()> {
601        if records.is_empty() {
602            return Ok(());
603        }
604
605        info!(count = records.len(), "applying WAL records");
606
607        // Per-page LSN redo (ARIES-style). A record is already durable iff
608        // its *target page* carries an LSN >= the record's LSN. The previous
609        // implementation used a single per-table max LSN, which is unsafe:
610        // a low-LSN record on an unflushed page would be wrongly skipped
611        // because some other, flushed page of the same table advertised a
612        // higher LSN — silently dropping the record (one of the v0.4.x
613        // data-loss bugs). Every record now carries its real RowId (inserts
614        // included), so the target page is always known.
615        let has_boundaries = records.iter().any(|rec| {
616            matches!(
617                rec.record_type,
618                WalRecordType::Begin | WalRecordType::Commit | WalRecordType::Rollback
619            )
620        });
621        let mut committed_row_records = vec![true; records.len()];
622        if has_boundaries {
623            committed_row_records.fill(false);
624            let mut pending_tx_spans: Vec<(u64, Vec<usize>)> = Vec::new();
625            for (index, rec) in records.iter().enumerate() {
626                match rec.record_type {
627                    WalRecordType::Insert
628                    | WalRecordType::Update
629                    | WalRecordType::Delete
630                    | WalRecordType::OverflowWrite
631                    | WalRecordType::OverflowFree
632                        if rec.tx_id == 0 =>
633                    {
634                        committed_row_records[index] = true;
635                    }
636                    WalRecordType::Insert
637                    | WalRecordType::Update
638                    | WalRecordType::Delete
639                    | WalRecordType::OverflowWrite
640                    | WalRecordType::OverflowFree => {
641                        if let Some((_, rows)) = pending_tx_spans
642                            .iter_mut()
643                            .rev()
644                            .find(|(tx_id, _)| *tx_id == rec.tx_id)
645                        {
646                            rows.push(index);
647                        } else {
648                            pending_tx_spans.push((rec.tx_id, vec![index]));
649                        }
650                    }
651                    WalRecordType::Begin if rec.tx_id != 0 => {
652                        pending_tx_spans.push((rec.tx_id, Vec::new()));
653                    }
654                    WalRecordType::Commit if rec.tx_id != 0 => {
655                        if let Some(span_index) = pending_tx_spans
656                            .iter()
657                            .rposition(|(tx_id, _)| *tx_id == rec.tx_id)
658                        {
659                            let (_, rows) = pending_tx_spans.remove(span_index);
660                            for row_index in rows {
661                                committed_row_records[row_index] = true;
662                            }
663                        }
664                    }
665                    WalRecordType::Rollback if rec.tx_id != 0 => {
666                        if let Some(span_index) = pending_tx_spans
667                            .iter()
668                            .rposition(|(tx_id, _)| *tx_id == rec.tx_id)
669                        {
670                            pending_tx_spans.remove(span_index);
671                        }
672                    }
673                    _ => {}
674                }
675            }
676        }
677
678        let mut replayed_inserts = 0usize;
679        let mut replayed_updates = 0usize;
680        let mut replayed_deletes = 0usize;
681        let mut skipped = 0usize;
682        let mut skipped_uncommitted = 0usize;
683        let mut saw_ddl = false;
684        for (index, rec) in records.iter().enumerate() {
685            if has_boundaries
686                && !committed_row_records[index]
687                && matches!(
688                    rec.record_type,
689                    WalRecordType::Insert
690                        | WalRecordType::Update
691                        | WalRecordType::Delete
692                        | WalRecordType::OverflowWrite
693                        | WalRecordType::OverflowFree
694                )
695            {
696                skipped_uncommitted += 1;
697                continue;
698            }
699            match rec.record_type {
700                WalRecordType::Insert => {
701                    if let Some((table_name, rid, row_bytes)) = decode_wal_payload(&rec.data) {
702                        if let Some(slot) = self.name_to_slot.get(&table_name).copied() {
703                            let tbl = &mut self.tables[slot];
704                            // Already persisted on its page? Skip — re-running
705                            // the insert would allocate a fresh slot and
706                            // duplicate the row.
707                            if rec.lsn > 0 && tbl.heap.page_lsn(rid.page_id) >= rec.lsn {
708                                skipped += 1;
709                                continue;
710                            }
711                            // Not yet durable: place the row at its exact
712                            // logged RowId so later Update/Delete records
713                            // (which carry that RowId) stay correctly
714                            // targeted. A plain re-`insert` would self-assign
715                            // a fresh slot whose position can diverge from the
716                            // original after a partial-flush crash.
717                            tbl.heap.insert_at(rid, &row_bytes)?;
718                            tbl.heap.set_page_lsn(rid.page_id, rec.lsn)?;
719                            replayed_inserts += 1;
720                        }
721                    }
722                }
723                WalRecordType::Update => {
724                    if let Some((table_name, rid, row_bytes)) = decode_wal_payload(&rec.data) {
725                        if let Some(slot) = self.name_to_slot.get(&table_name).copied() {
726                            let tbl = &mut self.tables[slot];
727                            if rec.lsn > 0 && tbl.heap.page_lsn(rid.page_id) >= rec.lsn {
728                                skipped += 1;
729                                continue;
730                            }
731                            let new_rid = tbl.heap.update(rid, &row_bytes)?;
732                            tbl.heap.set_page_lsn(new_rid.page_id, rec.lsn)?;
733                            replayed_updates += 1;
734                        }
735                    }
736                }
737                WalRecordType::Delete => {
738                    if let Some((table_name, rid, _)) = decode_wal_payload(&rec.data) {
739                        if let Some(slot) = self.name_to_slot.get(&table_name).copied() {
740                            let tbl = &mut self.tables[slot];
741                            if rec.lsn > 0 && tbl.heap.page_lsn(rid.page_id) >= rec.lsn {
742                                skipped += 1;
743                                continue;
744                            }
745                            let _ = tbl.heap.delete(rid);
746                            tbl.heap.set_page_lsn(rid.page_id, rec.lsn)?;
747                            replayed_deletes += 1;
748                        }
749                    }
750                }
751                WalRecordType::OverflowWrite => {
752                    // Physical redo of one chain chunk. Applied by page id
753                    // under the per-page LSN skip, so double replay is a
754                    // no-op. Ordered before its Insert/Update in the log, so
755                    // the stub the row carries always points at live pages.
756                    if let Some((table_name, page_id, next_page, chunk)) =
757                        decode_overflow_write_payload(&rec.data)
758                    {
759                        if let Some(slot) = self.name_to_slot.get(&table_name).copied() {
760                            let tbl = &mut self.tables[slot];
761                            if rec.lsn > 0 && tbl.heap.overflow_page_lsn(page_id) >= rec.lsn {
762                                skipped += 1;
763                                continue;
764                            }
765                            tbl.heap
766                                .write_overflow_page(page_id, next_page, &chunk, rec.lsn)?;
767                        }
768                    }
769                }
770                WalRecordType::OverflowFree => {
771                    // Return a freed chain's pages to the in-memory free list.
772                    // Only reached for committed records (uncommitted frees
773                    // are skipped above), so a live row can never lose its
774                    // chain to a rolled-back free.
775                    if let Some((table_name, pages)) = decode_overflow_free_payload(&rec.data) {
776                        if let Some(slot) = self.name_to_slot.get(&table_name).copied() {
777                            self.tables[slot].heap.release_overflow_pages(&pages);
778                        }
779                    }
780                }
781                WalRecordType::Begin | WalRecordType::Commit | WalRecordType::Rollback => {
782                    // Boundary records were consumed in the first pass.
783                }
784                WalRecordType::DdlCreateTable => {
785                    saw_ddl = true;
786                    if let Some((schema, defaults, auto_cols)) = decode_ddl_create_table(&rec.data)
787                    {
788                        if !self.name_to_slot.contains_key(&schema.table_name) {
789                            if let Ok(mut table) = Table::create(schema, &self.data_dir) {
790                                table.set_defaults(defaults);
791                                table.set_auto_cols(auto_cols);
792                                let slot = self.tables.len();
793                                let name = table.schema.table_name.clone();
794                                self.tables.push(table);
795                                self.name_to_slot.insert(name, slot);
796                            }
797                        }
798                    }
799                }
800                WalRecordType::DdlDropTable => {
801                    saw_ddl = true;
802                    if let Some((table_name, _)) = decode_ddl_table_name(&rec.data) {
803                        if let Some(&slot) = self.name_to_slot.get(&table_name) {
804                            let heap_path = self.data_dir.join(format!("{table_name}.heap"));
805                            if heap_path.exists() {
806                                let _ = fs::remove_file(&heap_path);
807                            }
808                            for col_name in self.tables[slot].indexed_column_names() {
809                                let idx_path =
810                                    self.data_dir.join(format!("{table_name}_{col_name}.idx"));
811                                if idx_path.exists() {
812                                    let _ = fs::remove_file(&idx_path);
813                                }
814                            }
815                            for index_id in self.tables[slot].expression_index_ids() {
816                                let idx_path = self
817                                    .data_dir
818                                    .join(expression_index_file_name(&table_name, index_id));
819                                let _ = fs::remove_file(idx_path);
820                            }
821                            self.name_to_slot.remove(&table_name);
822                            let last = self.tables.len() - 1;
823                            if slot != last {
824                                let moved_name = self.tables[last].schema.table_name.clone();
825                                self.tables.swap(slot, last);
826                                self.name_to_slot.insert(moved_name, slot);
827                            }
828                            self.tables.pop();
829                        }
830                    }
831                }
832                WalRecordType::DdlAddColumn => {
833                    saw_ddl = true;
834                    if let Some((table_name, col)) = decode_ddl_alter_add_column(&rec.data) {
835                        if let Some(&slot) = self.name_to_slot.get(&table_name) {
836                            let tbl = &mut self.tables[slot];
837                            if !tbl.schema.columns.iter().any(|c| c.name == col.name) {
838                                let old_schema = tbl.schema.clone();
839                                let has_rows = tbl.heap.scan().next().is_some();
840                                tbl.schema.columns.push(col);
841                                tbl.refresh_layout();
842                                if has_rows {
843                                    let fill = vec![Value::Empty; tbl.schema.columns.len()];
844                                    let data_dir = self.data_dir.clone();
845                                    let _ = tbl.rewrite_rows_for_schema_change(
846                                        &old_schema,
847                                        &fill,
848                                        &data_dir,
849                                    );
850                                }
851                            }
852                            // Stamp every page with the DDL's LSN so a
853                            // subsequent restart's per-page check skips the
854                            // pre-DDL Insert/Update/Delete records — they
855                            // have already been folded into the new layout
856                            // by the rewrite above. See
857                            // `stamp_all_pages_min_lsn` doc.
858                            if rec.lsn > 0 {
859                                let _ = tbl.heap.stamp_all_pages_min_lsn(rec.lsn);
860                            }
861                        }
862                    }
863                }
864                WalRecordType::DdlDropColumn => {
865                    saw_ddl = true;
866                    if let Some((table_name, col_name)) = decode_ddl_alter_drop_column(&rec.data) {
867                        if let Some(&slot) = self.name_to_slot.get(&table_name) {
868                            {
869                                let tbl = &mut self.tables[slot];
870                                if let Some(idx) =
871                                    tbl.schema.columns.iter().position(|c| c.name == col_name)
872                                {
873                                    let old_schema = tbl.schema.clone();
874                                    let has_rows = tbl.heap.scan().next().is_some();
875                                    tbl.schema.columns.remove(idx);
876                                    for (i, c) in tbl.schema.columns.iter_mut().enumerate() {
877                                        c.position = i as u16;
878                                    }
879                                    tbl.refresh_layout();
880                                    if has_rows {
881                                        let fill = vec![Value::Empty; tbl.schema.columns.len()];
882                                        let data_dir = self.data_dir.clone();
883                                        let _ = tbl.rewrite_rows_for_schema_change(
884                                            &old_schema,
885                                            &fill,
886                                            &data_dir,
887                                        );
888                                    }
889                                }
890                                if rec.lsn > 0 {
891                                    let _ = tbl.heap.stamp_all_pages_min_lsn(rec.lsn);
892                                }
893                            }
894
895                            let removed_ids =
896                                self.tables[slot].remove_expression_indexes_for_root(&col_name);
897                            for index_id in removed_ids {
898                                let idx_path = self
899                                    .data_dir
900                                    .join(expression_index_file_name(&table_name, index_id));
901                                let _ = fs::remove_file(idx_path);
902                            }
903                        }
904                    }
905                }
906            }
907        }
908        info!(
909            inserts = replayed_inserts,
910            updates = replayed_updates,
911            deletes = replayed_deletes,
912            skipped = skipped,
913            skipped_uncommitted = skipped_uncommitted,
914            "WAL record apply complete (commit-boundary + LSN idempotent)"
915        );
916        if saw_ddl {
917            self.persist()?;
918        }
919        // Persist the replayed changes to disk before truncating the WAL,
920        // otherwise a crash between here and the next checkpoint would lose
921        // the replayed records. `flush_all_dirty` on every heap moves every
922        // dirty page through the normal write path.
923        //
924        // Blocker B3: under the deferred-index-save model, the on-disk
925        // `.idx` files may lag the heap because the pre-crash session
926        // never got to its next `checkpoint`. Replay restored the
927        // heap rows above, but the btrees that loaded from those
928        // possibly-stale `.idx` files don't know about them. Rebuild
929        // every secondary index from the post-replay heap so the
930        // trees exactly match disk. The rebuild is O(heap) per
931        // indexed column, which is fine on a crash-recovery path.
932        for tbl in &mut self.tables {
933            tbl.heap.flush_all_dirty()?;
934            tbl.heap.flush()?;
935            tbl.rebuild_indexes_from_heap()?;
936            // Flush the rebuilt indexes now so a crash between here
937            // and the next mutation still leaves `.idx` files matching
938            // the heap. Without this, a second crash before any
939            // insert could leave us back where we started.
940            tbl.save_dirty_indexes()?;
941        }
942        if let Some(max_lsn) = max_record_lsn(records) {
943            self.record_durable_lsn_at_least(max_lsn)?;
944            self.wal.set_next_lsn_at_least(max_lsn.saturating_add(1));
945        }
946        Ok(())
947    }
948
949    /// Flush every dirty heap page and truncate the WAL. This is the
950    /// "clean shutdown" point — after this returns, the on-disk heap files
951    /// are fully consistent and the WAL is empty, so the next `open` will
952    /// skip replay entirely.
953    ///
954    /// Safe to call multiple times. Safe to call on a catalog that has
955    /// performed zero mutations since the last checkpoint (in which case
956    /// the flushes are no-ops and the truncate is a bounded syscall).
957    pub fn checkpoint(&mut self) -> io::Result<()> {
958        self.ensure_no_active_transaction_for_checkpoint()?;
959        self.ensure_plain_checkpoint_allowed_before_flush()?;
960        self.flush_checkpoint_state()?;
961        self.wal.flush()?;
962        self.record_durable_lsn_at_least(self.wal.last_appended_lsn())?;
963        self.wal.truncate()?;
964        self.checkpointed = true;
965        Ok(())
966    }
967
968    /// Flush every dirty heap page, archive retained WAL records, then
969    /// truncate the WAL. Sync-aware callers use this to make archive-before-
970    /// truncate explicit without making storage depend on the sync crate.
971    ///
972    /// Replication boundary: this hook is for retained-history publication.
973    /// It should stay behind sync-aware lifecycle helpers rather than becoming
974    /// an ordinary checkpoint surface for application code.
975    pub fn checkpoint_with_wal_archive<F>(&mut self, mut archive: F) -> io::Result<()>
976    where
977        F: FnMut(&Path, &[WalRecord]) -> io::Result<()>,
978    {
979        self.ensure_no_active_transaction_for_checkpoint()?;
980        self.commit_autocommit()?;
981        self.flush_checkpoint_state()?;
982        self.wal.flush()?;
983        let records = self.wal.read_all()?;
984        let archive: WalArchiveCallback<'_> = &mut archive;
985        archive(&self.data_dir, &records)?;
986        if let Some(max_lsn) = max_record_lsn(&records) {
987            self.record_durable_lsn_at_least(max_lsn)?;
988        } else {
989            self.record_durable_lsn_at_least(self.wal.last_appended_lsn())?;
990        }
991        self.wal.truncate()?;
992        self.checkpointed = true;
993        Ok(())
994    }
995
996    fn ensure_no_active_transaction_for_checkpoint(&self) -> io::Result<()> {
997        if self.active_tx_id.is_some() {
998            return Err(io::Error::other(
999                "cannot checkpoint while an explicit transaction is active",
1000            ));
1001        }
1002        Ok(())
1003    }
1004
1005    fn flush_checkpoint_state(&mut self) -> io::Result<()> {
1006        for tbl in &mut self.tables {
1007            tbl.heap.flush_all_dirty()?;
1008            tbl.heap.flush()?;
1009            // Blocker B3: the hot insert/update/delete paths no longer
1010            // fsync index files per row — they only mark the in-memory
1011            // btree dirty. Checkpoint is where those deferred saves
1012            // actually hit disk. Clean (non-dirty) indexes are free.
1013            tbl.save_dirty_indexes()?;
1014        }
1015        Ok(())
1016    }
1017
1018    fn ensure_plain_checkpoint_allowed_before_flush(&self) -> io::Result<()> {
1019        if !self.sync_identity_file_exists() {
1020            return Ok(());
1021        }
1022        if self.wal.has_pending() {
1023            return Err(io::Error::other(
1024                "sync identity exists but checkpoint/recovery was called without a WAL archive hook; refusing to truncate retained history",
1025            ));
1026        }
1027        let records = self.wal.read_all()?;
1028        self.ensure_plain_wal_truncate_allowed(&records)
1029    }
1030
1031    fn ensure_plain_wal_truncate_allowed(&self, records: &[WalRecord]) -> io::Result<()> {
1032        if records.is_empty() {
1033            return Ok(());
1034        }
1035        if self.sync_identity_file_exists() {
1036            return Err(io::Error::other(
1037                "sync identity exists but checkpoint/recovery was called without a WAL archive hook; refusing to truncate retained history",
1038            ));
1039        }
1040        Ok(())
1041    }
1042
1043    fn sync_identity_file_exists(&self) -> bool {
1044        self.data_dir
1045            .join(SYNC_STATE_DIR)
1046            .join(SYNC_IDENTITY_FILE)
1047            .exists()
1048    }
1049
1050    fn record_durable_lsn_at_least(&mut self, lsn: u64) -> io::Result<()> {
1051        if lsn <= self.durable_lsn {
1052            return Ok(());
1053        }
1054        self.durable_lsn = lsn;
1055        write_durable_lsn(&self.data_dir, lsn)
1056    }
1057
1058    /// Allocate or return the transaction id for the current mutation.
1059    #[inline]
1060    /// Free (or defer freeing) the overflow-chain pages a mutation just
1061    /// orphaned. In autocommit there is no rollback window, so the pages return
1062    /// to the table's free list immediately and the next spill reuses them
1063    /// (bounding steady-state churn). Inside an explicit transaction the free is
1064    /// held on `pending_free_overflow` until COMMIT: a ROLLBACK reopens the
1065    /// catalog from disk, discarding this list, so the resurrected old row still
1066    /// points at a live chain. Reuse is crash-safe without an `OverflowFree`
1067    /// record because a later spill that overwrites a reused page logs its own
1068    /// per-page `OverflowWrite` (LSN-idempotent), and post-recovery `sweep`
1069    /// reclaims anything the in-memory list lost.
1070    fn free_overflow_chain(&mut self, slot: usize, pages: Vec<u32>) {
1071        if pages.is_empty() {
1072            return;
1073        }
1074        if self.active_tx_id.is_some() {
1075            self.pending_free_overflow.push((slot, pages));
1076        } else {
1077            self.tables[slot].release_overflow_pages(&pages);
1078        }
1079    }
1080
1081    fn next_tx(&mut self) -> u64 {
1082        if let Some(id) = self.active_tx_id {
1083            return id;
1084        }
1085        let id = self.next_tx_id;
1086        self.next_tx_id = self.next_tx_id.wrapping_add(1);
1087        id
1088    }
1089
1090    /// Begin a connection/session-scoped explicit transaction.
1091    pub fn begin_transaction(&mut self) -> io::Result<()> {
1092        if self.active_tx_id.is_some() {
1093            return Err(io::Error::new(
1094                io::ErrorKind::InvalidInput,
1095                "explicit transaction is already active",
1096            ));
1097        }
1098        let start_len = self.wal.synced_len()?;
1099        let id = self.next_tx_id;
1100        self.next_tx_id = self.next_tx_id.wrapping_add(1);
1101        self.active_tx_id = Some(id);
1102        self.tx_start_len = Some(start_len);
1103        self.pending_autocommit_tx_ids.clear();
1104        if !self.wal.is_off() {
1105            self.wal.append(id, WalRecordType::Begin, &[])?;
1106            self.wal.flush()?;
1107        }
1108        Ok(())
1109    }
1110
1111    /// Commit the active explicit transaction by appending a durable boundary
1112    /// marker after its row records.
1113    pub fn commit_transaction(&mut self) -> io::Result<()> {
1114        if let Some(id) = self.active_tx_id.take() {
1115            if !self.wal.is_off() {
1116                self.wal.append(id, WalRecordType::Commit, &[])?;
1117                self.wal.flush()?;
1118            }
1119        }
1120        self.tx_start_len = None;
1121        // The transaction committed: its rows are durable and can no longer be
1122        // resurrected by ROLLBACK, so the old chains they replaced/removed are
1123        // safe to reclaim. (Populated only while a tx was active.)
1124        for (slot, pages) in std::mem::take(&mut self.pending_free_overflow) {
1125            self.tables[slot].release_overflow_pages(&pages);
1126        }
1127        Ok(())
1128    }
1129
1130    /// Commit any autocommit row mutations accumulated by the current
1131    /// statement. Pure reads/DDL have no pending tx ids and fall through to a
1132    /// cheap WAL flush/no-op.
1133    pub fn commit_autocommit(&mut self) -> io::Result<()> {
1134        if !self.wal.is_off() && !self.pending_autocommit_tx_ids.is_empty() {
1135            self.pending_autocommit_tx_ids.sort_unstable();
1136            self.pending_autocommit_tx_ids.dedup();
1137            for id in self.pending_autocommit_tx_ids.drain(..) {
1138                self.wal.append(id, WalRecordType::Commit, &[])?;
1139            }
1140        }
1141        self.wal.flush()
1142    }
1143
1144    /// Append a mutation record to the WAL buffer. **Does not flush.**
1145    ///
1146    /// Mission B (post-review): per-row `wal.flush()` was a ~1ms fsync on
1147    /// every mutation, turning `update_by_filter` into a ~19s workload.
1148    /// The flush is now deferred to [`Self::sync_wal`], which the executor
1149    /// calls exactly once at the end of every mutating statement. This
1150    /// gives us statement-level group commit: N-row updates pay one fsync,
1151    /// not N.
1152    ///
1153    /// Durability contract: any path that observes `Ok(...)` back from
1154    /// the executor must have called `sync_wal` before returning that
1155    /// Ok. Replay is still correct because WAL records are appended in
1156    /// order and only records that reached `fdatasync`ed bytes are
1157    /// replayed.
1158    fn wal_log(
1159        &mut self,
1160        tx_id: u64,
1161        record_type: WalRecordType,
1162        table: &str,
1163        rid: RowId,
1164        row_bytes: &[u8],
1165    ) -> io::Result<()> {
1166        // Mission B (post-review, second pass): when the WAL is in Off
1167        // mode the `append` call below is a no-op, so building the
1168        // payload first wastes a `Vec` allocation + ~3 extends per
1169        // mutation. The catalog hot paths check `wal.is_off()` before
1170        // calling here, but this guard is the belt-and-braces version
1171        // for any internal caller that doesn't.
1172        if self.wal.is_off() {
1173            return Ok(());
1174        }
1175        let payload = encode_wal_payload(table, rid, row_bytes);
1176        self.wal.append(tx_id, record_type, &payload)?;
1177        if self.active_tx_id.is_none() {
1178            self.pending_autocommit_tx_ids.push(tx_id);
1179        }
1180        Ok(())
1181    }
1182
1183    /// Flush any buffered WAL records to disk. Called by the executor
1184    /// at the end of every mutating statement so the group-commit
1185    /// window is exactly one statement.
1186    ///
1187    /// See `Self::wal_log` for the durability contract.
1188    #[inline]
1189    pub fn sync_wal(&mut self) -> io::Result<()> {
1190        self.wal.flush()
1191    }
1192
1193    /// Set the WAL sync mode. Production code should leave this at the
1194    /// default ([`WalSyncMode::Full`]). Benchmarks set it to
1195    /// [`WalSyncMode::Off`] to compare apples-to-apples against
1196    /// `:memory:` SQLite (which has zero fsync cost).
1197    ///
1198    /// **Never** call this with `Off` in production — a machine crash
1199    /// can lose any record written since the last `sync_wal` returned.
1200    pub fn set_wal_sync_mode(&mut self, mode: WalSyncMode) {
1201        self.wal.set_sync_mode(mode);
1202    }
1203
1204    /// Defer Full-mode commit fsyncs (WAL group commit). While enabled, the
1205    /// commit paths register the WAL generation they need durable instead of
1206    /// fsyncing inline; the pending claim is retrieved with
1207    /// [`Self::take_wal_durability_ticket`] and the caller must wait on it
1208    /// before acknowledging the statement. This lets the fsync leave the
1209    /// engine's exclusive-lock hold so overlapping committers can share one
1210    /// fsync. `Normal`/`Off` modes are unaffected.
1211    pub fn set_wal_sync_deferred(&mut self, defer: bool) {
1212        self.wal.set_defer_sync(defer);
1213    }
1214
1215    /// Take the durability claim registered by deferred commit flushes since
1216    /// the last take, if any. See [`Self::set_wal_sync_deferred`].
1217    pub fn take_wal_durability_ticket(&mut self) -> Option<WalDurabilityTicket> {
1218        self.wal.take_durability_ticket()
1219    }
1220
1221    /// Number of fsyncs issued against the WAL (test/metrics hook).
1222    pub fn wal_fsync_count(&self) -> u64 {
1223        self.wal.fsync_count()
1224    }
1225
1226    /// Discard in-memory mutations made since the last `sync_wal()` and
1227    /// restore the catalog to its on-disk state. Used by ROLLBACK to
1228    /// undo an in-progress transaction's changes.
1229    ///
1230    /// This re-opens the catalog from the checkpoint file and replays
1231    /// only the durable (already flushed) WAL records. Any WAL records
1232    /// that were appended but not yet flushed are lost.
1233    ///
1234    /// **Critical**: before replacing `*self` we must discard every
1235    /// dirty in-memory page across all heaps. Otherwise the old
1236    /// `Catalog`'s `Drop` impl calls `checkpoint()` which flushes those
1237    /// dirty pages to disk — and the freshly-opened replacement catalog
1238    /// would then read the flushed (uncommitted) rows back, defeating
1239    /// the entire rollback.
1240    pub fn rollback_to_last_sync(&mut self) -> io::Result<()> {
1241        self.rollback_to_last_sync_inner(None)
1242    }
1243
1244    /// Roll back the active transaction, then reopen/replay any remaining WAL
1245    /// through an archive hook before recovery truncates it. Sync-aware callers
1246    /// use this when committed pre-transaction records must remain available to
1247    /// replicas after rollback.
1248    pub fn rollback_to_last_sync_with_wal_archive<F>(&mut self, mut archive: F) -> io::Result<()>
1249    where
1250        F: FnMut(&Path, &[WalRecord]) -> io::Result<()>,
1251    {
1252        let archive: WalArchiveCallback<'_> = &mut archive;
1253        self.rollback_to_last_sync_inner(Some(archive))
1254    }
1255
1256    fn rollback_to_last_sync_inner(
1257        &mut self,
1258        mut archive: Option<WalArchiveCallback<'_>>,
1259    ) -> io::Result<()> {
1260        let start_len = self.tx_start_len.unwrap_or(0);
1261        let prearchived = if let Some(archive) = archive.as_mut() {
1262            let records = self.wal.read_through_len(start_len)?;
1263            if !records.is_empty() {
1264                archive(&self.data_dir, &records)?;
1265            }
1266            true
1267        } else {
1268            false
1269        };
1270
1271        let start_len = self.tx_start_len.take().unwrap_or(0);
1272        if let Some(id) = self.active_tx_id.take() {
1273            if !self.wal.is_off() {
1274                let _ = self.wal.append(id, WalRecordType::Rollback, &[]);
1275            }
1276        }
1277        self.wal.discard_and_truncate_to(start_len)?;
1278
1279        // Step 1: throw away every uncommitted in-memory write so the
1280        // upcoming Drop of `*self` has nothing dirty to flush. This covers
1281        // both the heap pages AND the btree index mutations: the Drop below
1282        // runs `checkpoint()` (active_tx_id was already taken above), whose
1283        // `save_dirty_indexes` would otherwise flush the rolled-back index
1284        // writes to the `.idx` files — poisoning the unique index. The
1285        // freshly-opened replacement catalog reloads clean trees from the
1286        // untouched on-disk `.idx`, so discarding the dirty flags here is
1287        // what actually reverts the transaction's index writes.
1288        for tbl in &mut self.tables {
1289            tbl.heap.discard_dirty();
1290            tbl.discard_dirty_indexes();
1291        }
1292        // Step 2: discard WAL records appended since the last explicit
1293        // sync point. Large pending records can spill through BufWriter and
1294        // become file-visible before `sync_wal()`; truncating to the last
1295        // synced boundary prevents `open()` below from replaying rolled-back
1296        // transaction records.
1297        self.wal.discard_pending()?;
1298        // Step 3: re-open the catalog from disk. The heap files on disk
1299        // still reflect the last checkpoint (pre-transaction state)
1300        // because we never flushed the transaction's dirty pages.
1301        let data_dir = self.data_dir.clone();
1302        let sync_mode = self.wal.sync_mode();
1303        let mut restored = if prearchived {
1304            let mut already_archived = |_dir: &Path, _records: &[WalRecord]| Ok(());
1305            let archive: WalArchiveCallback<'_> = &mut already_archived;
1306            Self::open_inner(&data_dir, Some(archive))?
1307        } else {
1308            Self::open_inner(&data_dir, archive)?
1309        };
1310        // Row-only rollback reopens the catalog to discard dirty heap/index
1311        // state, but it does not change prepared-query metadata. Preserve the
1312        // O(1) token in that common case so existing PreparedQuery handles keep
1313        // their fast path. Any schema/default/auto/index difference retains the
1314        // fresh token assigned by open_inner and invalidates cached metadata.
1315        if self.has_same_prepared_structure(&restored) {
1316            restored.structure_generation = self.structure_generation;
1317        }
1318        *self = restored;
1319        self.wal.set_sync_mode(sync_mode);
1320        Ok(())
1321    }
1322
1323    fn abandon_active_transaction_for_drop(&mut self) -> io::Result<()> {
1324        for tbl in &mut self.tables {
1325            tbl.heap.discard_dirty();
1326        }
1327        self.pending_autocommit_tx_ids.clear();
1328        let truncate_result = match self.tx_start_len.take() {
1329            Some(start_len) => self.wal.discard_and_truncate_to(start_len),
1330            None => self.wal.discard_pending(),
1331        };
1332        self.active_tx_id = None;
1333        truncate_result
1334    }
1335
1336    /// Returns a reference to the data directory.
1337    pub fn data_dir(&self) -> &Path {
1338        &self.data_dir
1339    }
1340
1341    /// Highest page LSN across all tables (0 if nothing has been written).
1342    /// This is the durability high-water mark — the LSN a backup taken now
1343    /// corresponds to, and the value `Catalog::open` uses to restore
1344    /// `next_lsn` after a reopen/restore.
1345    pub fn max_lsn(&self) -> u64 {
1346        let max_page_lsn = self
1347            .tables
1348            .iter()
1349            .map(|t| t.heap.max_page_lsn())
1350            .max()
1351            .unwrap_or(0);
1352        max_page_lsn
1353            .max(self.durable_lsn)
1354            .max(self.wal.last_appended_lsn())
1355    }
1356
1357    pub fn create_table(&mut self, schema: Schema) -> io::Result<()> {
1358        self.create_table_full(schema, Vec::new(), Vec::new())
1359    }
1360
1361    /// Create a table whose columns carry literal defaults. `defaults` is
1362    /// aligned to `schema.columns` by position (and may be shorter / empty for
1363    /// columns without a default).
1364    pub fn create_table_with_defaults(
1365        &mut self,
1366        schema: Schema,
1367        defaults: Vec<Option<Value>>,
1368    ) -> io::Result<()> {
1369        self.create_table_full(schema, defaults, Vec::new())
1370    }
1371
1372    /// Create a table with per-column literal defaults and auto-increment
1373    /// flags. Both vecs are aligned to `schema.columns` by position (and may be
1374    /// empty). Defaults and auto flags are WAL-logged and persisted in the
1375    /// catalog so they survive a restart.
1376    pub fn create_table_full(
1377        &mut self,
1378        schema: Schema,
1379        defaults: Vec<Option<Value>>,
1380        auto_cols: Vec<bool>,
1381    ) -> io::Result<()> {
1382        self.invalidate_structure();
1383        validate_table_name(&schema.table_name)?;
1384        for col in &schema.columns {
1385            validate_column_name(&col.name)?;
1386        }
1387        let name = schema.table_name.clone();
1388        if self.name_to_slot.contains_key(&name) {
1389            return Err(io::Error::new(
1390                io::ErrorKind::AlreadyExists,
1391                format!("table '{name}' already exists"),
1392            ));
1393        }
1394        if !self.wal.is_off() {
1395            let payload = encode_ddl_create_table(&schema, &defaults, &auto_cols);
1396            self.wal
1397                .append(0, WalRecordType::DdlCreateTable, &payload)?;
1398            self.wal.flush()?;
1399        }
1400        let mut table = Table::create(schema, &self.data_dir)?;
1401        table.set_defaults(defaults);
1402        table.set_auto_cols(auto_cols);
1403        let slot = self.tables.len();
1404        self.tables.push(table);
1405        self.name_to_slot.insert(name, slot);
1406        self.persist()?;
1407        Ok(())
1408    }
1409
1410    /// Per-column literal defaults for a table, aligned to its columns by
1411    /// position. `None` when the table is unknown; an empty slice when no
1412    /// column has a default.
1413    pub fn column_defaults(&self, table: &str) -> Option<&[Option<Value>]> {
1414        let slot = *self.name_to_slot.get(table)?;
1415        Some(self.tables[slot].defaults())
1416    }
1417
1418    /// Which columns of a table are `auto`, aligned to its columns by position.
1419    /// `None` when the table is unknown; an empty slice when none are auto.
1420    pub fn auto_columns(&self, table: &str) -> Option<&[bool]> {
1421        let slot = *self.name_to_slot.get(table)?;
1422        Some(self.tables[slot].auto_cols())
1423    }
1424
1425    /// Fill any omitted (`Empty`) auto column in `values` from the table's
1426    /// sequence and advance it. No-op when the table is unknown or has no auto
1427    /// columns.
1428    pub fn assign_auto_columns(&mut self, table: &str, values: &mut [Value]) {
1429        if let Some(&slot) = self.name_to_slot.get(table) {
1430            self.tables[slot].assign_auto(values);
1431        }
1432    }
1433
1434    /// Write the current set of schemas to disk atomically (write-then-rename).
1435    ///
1436    /// Mission 3: also writes the per-table list of indexed column names so
1437    /// `Catalog::open` can rehydrate b-tree indexes on restart.
1438    fn persist_at_activation_boundary(&self) -> Result<(), CatalogPersistError> {
1439        let cat_path = self.data_dir.join(CATALOG_FILE);
1440        let tmp_path = self.data_dir.join(format!("{CATALOG_FILE}.tmp"));
1441        let entries: Vec<CatalogEntryRef<'_>> = self
1442            .tables
1443            .iter()
1444            .map(|t| CatalogEntryRef {
1445                schema: &t.schema,
1446                indexed_cols: t.indexed_column_metas(),
1447                expression_indexes: t.expression_index_metas(),
1448                defaults: t.defaults(),
1449                auto_cols: t.auto_cols(),
1450            })
1451            .collect();
1452        write_catalog_file(
1453            &tmp_path,
1454            self.active_catalog_version,
1455            self.next_index_id,
1456            &entries,
1457        )
1458        .map_err(CatalogPersistError::BeforeActivation)?;
1459        #[cfg(test)]
1460        if take_catalog_persist_failpoint(1) {
1461            return Err(CatalogPersistError::BeforeActivation(io::Error::other(
1462                "injected catalog failure before rename",
1463            )));
1464        }
1465        fs::rename(&tmp_path, &cat_path).map_err(CatalogPersistError::BeforeActivation)?;
1466        #[cfg(test)]
1467        let directory_sync = if take_catalog_persist_failpoint(2) {
1468            Err(io::Error::other(
1469                "injected catalog directory sync failure after rename",
1470            ))
1471        } else {
1472            sync_directory(&self.data_dir)
1473        };
1474        #[cfg(not(test))]
1475        let directory_sync = sync_directory(&self.data_dir);
1476        directory_sync.map_err(CatalogPersistError::AfterActivation)
1477    }
1478
1479    fn persist(&self) -> io::Result<()> {
1480        self.persist_at_activation_boundary()
1481            .map_err(CatalogPersistError::into_io_error)
1482    }
1483
1484    /// Resolve a table name to its current slot index. DROP TABLE uses
1485    /// swap-remove, so prepared-query fast paths pair this value with
1486    /// [`Self::structure_generation`] before every slot-indexed access.
1487    #[inline]
1488    pub fn table_slot(&self, name: &str) -> Option<usize> {
1489        self.name_to_slot.get(name).copied()
1490    }
1491
1492    /// O(1) prepared-metadata validity token. It is process-local by design:
1493    /// prepared queries do not cross process boundaries, and a reopened or
1494    /// rollback-replaced Catalog must invalidate every cached slot/offset.
1495    #[inline]
1496    pub fn structure_generation(&self) -> u64 {
1497        self.structure_generation
1498    }
1499
1500    #[inline]
1501    fn invalidate_structure(&mut self) {
1502        self.structure_generation = next_structure_generation();
1503    }
1504
1505    fn has_same_prepared_structure(&self, other: &Self) -> bool {
1506        self.tables.len() == other.tables.len()
1507            && self.tables.iter().zip(&other.tables).all(|(left, right)| {
1508                let left_schema = &left.schema;
1509                let right_schema = &right.schema;
1510                left_schema.table_name == right_schema.table_name
1511                    && left_schema.columns.len() == right_schema.columns.len()
1512                    && left_schema.columns.iter().zip(&right_schema.columns).all(
1513                        |(left_col, right_col)| {
1514                            left_col.name == right_col.name
1515                                && left_col.type_id == right_col.type_id
1516                                && left_col.required == right_col.required
1517                                && left_col.position == right_col.position
1518                        },
1519                    )
1520                    && left.defaults() == right.defaults()
1521                    && left.auto_cols() == right.auto_cols()
1522                    && {
1523                        let left_indexes = left.indexed_column_metas();
1524                        let right_indexes = right.indexed_column_metas();
1525                        left_indexes.len() == right_indexes.len()
1526                            && left_indexes.iter().zip(&right_indexes).all(
1527                                |(left_index, right_index)| {
1528                                    left_index.name == right_index.name
1529                                        && left_index.unique == right_index.unique
1530                                },
1531                            )
1532                    }
1533                    && left.expression_index_metas() == right.expression_index_metas()
1534            })
1535    }
1536
1537    /// O(1) slot-indexed table access. Panics on an out-of-range slot
1538    /// — callers must have obtained the slot via `table_slot()`.
1539    #[inline]
1540    pub fn table_by_slot(&self, slot: usize) -> &Table {
1541        &self.tables[slot]
1542    }
1543
1544    /// Mutable counterpart to [`Self::table_by_slot`].
1545    #[inline]
1546    pub fn table_by_slot_mut(&mut self, slot: usize) -> &mut Table {
1547        &mut self.tables[slot]
1548    }
1549
1550    pub fn get_table(&self, name: &str) -> Option<&Table> {
1551        let slot = *self.name_to_slot.get(name)?;
1552        Some(&self.tables[slot])
1553    }
1554
1555    pub fn get_table_mut(&mut self, name: &str) -> Option<&mut Table> {
1556        let slot = *self.name_to_slot.get(name)?;
1557        Some(&mut self.tables[slot])
1558    }
1559
1560    /// Whether `table` may hold v2 (spilled) rows (see
1561    /// [`Table::has_overflow_rows`]). Unknown table ⇒ false. The executor gates
1562    /// its v1-only raw-byte fast paths on this.
1563    #[inline]
1564    pub fn table_has_overflow(&self, table: &str) -> bool {
1565        self.get_table(table)
1566            .map(|t| t.has_overflow_rows())
1567            .unwrap_or(false)
1568    }
1569
1570    /// Private helper: resolve a table name to `&Table`, or return an
1571    /// `io::Error` with the same "table '<name>' not found" message the
1572    /// older `get_mut().ok_or_else(...)` callers produced. Phase 18
1573    /// consolidates ~14 copies of that idiom into this one place.
1574    #[inline]
1575    fn by_name(&self, table: &str) -> io::Result<&Table> {
1576        let slot = *self.name_to_slot.get(table).ok_or_else(|| {
1577            io::Error::new(
1578                io::ErrorKind::NotFound,
1579                format!("table '{table}' not found"),
1580            )
1581        })?;
1582        Ok(&self.tables[slot])
1583    }
1584
1585    /// Mutable counterpart to [`Self::by_name`].
1586    #[inline]
1587    fn by_name_mut(&mut self, table: &str) -> io::Result<&mut Table> {
1588        let slot = self.slot_of(table)?;
1589        Ok(&mut self.tables[slot])
1590    }
1591
1592    /// Mark-and-sweep one table's overflow pages, returning the number of pages
1593    /// reclaimed (design 3.6, door D12). Reclaimed pages are logged as a single
1594    /// `OverflowFree` record so the reclamation is crash-safe, then returned to
1595    /// the free list for reuse. Intended to run under the table write lock.
1596    pub fn sweep(&mut self, table: &str) -> io::Result<usize> {
1597        let slot = self.slot_of(table)?;
1598        let reclaimed = self.tables[slot].sweep_overflow()?;
1599        if !reclaimed.is_empty() && !self.wal.is_off() {
1600            let payload = encode_overflow_free_payload(table, &reclaimed);
1601            self.wal.append(0, WalRecordType::OverflowFree, &payload)?;
1602            self.wal.flush()?;
1603        }
1604        Ok(reclaimed.len())
1605    }
1606
1607    /// Sweep overflow pages across every table. Returns the total reclaimed.
1608    pub fn sweep_all(&mut self) -> io::Result<usize> {
1609        let names: Vec<String> = self
1610            .tables
1611            .iter()
1612            .map(|t| t.schema.table_name.clone())
1613            .collect();
1614        let mut total = 0;
1615        for name in names {
1616            total += self.sweep(&name)?;
1617        }
1618        Ok(total)
1619    }
1620
1621    fn slot_of(&self, table: &str) -> io::Result<usize> {
1622        self.name_to_slot.get(table).copied().ok_or_else(|| {
1623            io::Error::new(
1624                io::ErrorKind::NotFound,
1625                format!("table '{table}' not found"),
1626            )
1627        })
1628    }
1629
1630    pub fn insert(&mut self, table: &str, values: &Row) -> io::Result<RowId> {
1631        // Mission 2: encode the row into a scratch buffer first so we can
1632        // log it to the WAL before touching the heap. We re-encode inside
1633        // `Table::insert`, which keeps the insert hot path untouched — the
1634        // WAL encode here is additive.
1635        //
1636        // Mission B (post-review, second pass): in `WalSyncMode::Off` the
1637        // entire WAL pipeline is a no-op, so skip the per-row
1638        // `encode_row_into` allocation and `wal_log` call entirely.
1639        if self.wal.is_off() {
1640            return self.by_name_mut(table)?.insert(values);
1641        }
1642        let slot = self.slot_of(table)?;
1643        let _ = self.tables[slot].preflight_insert(values)?;
1644        // Allocate the tx id up front: any overflow chains for a spilled row
1645        // must be logged under the SAME tx (and before the Insert record) so
1646        // an uncommitted big row's chain writes are skipped on replay.
1647        let tx_id = self.next_tx();
1648        let row_bytes = {
1649            let Catalog { tables, wal, .. } = self;
1650            encode_row_with_spill_logged(&mut tables[slot], wal, tx_id, values)?
1651        };
1652        // Insert the (v1 or v2) row bytes into the heap FIRST so the Insert
1653        // record carries the real RowId. Index maintenance uses the logical
1654        // `values`, so a spilled column is indexed by its full value, never
1655        // the stub. See the v0.4.x idempotency rationale in the git history.
1656        let new_rid = self.tables[slot].insert_encoded(values, &row_bytes)?;
1657        self.wal_log(tx_id, WalRecordType::Insert, table, new_rid, &row_bytes)?;
1658        let lsn = self.wal.last_appended_lsn();
1659        if lsn > 0 {
1660            self.tables[slot].heap.set_page_lsn(new_rid.page_id, lsn)?;
1661        }
1662        Ok(new_rid)
1663    }
1664
1665    /// WAL-logged insert addressed by table slot index instead of name.
1666    /// Backs the executor's prepared-insert fast path, which resolves the
1667    /// slot at prepare time to skip the name→slot hash probe. Behaves exactly
1668    /// like [`Self::insert`] (logs the record with the real RowId, stamps the
1669    /// landing page's LSN) — the prepared path previously called the raw
1670    /// `Table::insert` and bypassed the WAL entirely, silently losing every
1671    /// prepared insert on a crash.
1672    pub fn insert_by_slot(&mut self, slot: usize, values: &Row) -> io::Result<RowId> {
1673        if self.wal.is_off() {
1674            return self.tables[slot].insert(values);
1675        }
1676        let _ = self.tables[slot].preflight_insert(values)?;
1677        let tx_id = self.next_tx();
1678        let autocommit = self.active_tx_id.is_none();
1679        let Catalog { tables, wal, .. } = self;
1680        let tbl = &mut tables[slot];
1681        // Spill-aware encode (logs any overflow chains under `tx_id`, before
1682        // the Insert record). Returns v1 bytes for rows that fit inline.
1683        let row_bytes = encode_row_with_spill_logged(tbl, wal, tx_id, values)?;
1684        // Insert first so the WAL record carries the real RowId (see
1685        // `insert` for the ordering/durability argument).
1686        let new_rid = tbl.insert_encoded(values, &row_bytes)?;
1687        let payload = encode_wal_payload(&tbl.schema.table_name, new_rid, &row_bytes);
1688        wal.append(tx_id, WalRecordType::Insert, &payload)?;
1689        if autocommit {
1690            self.pending_autocommit_tx_ids.push(tx_id);
1691        }
1692        let lsn = wal.last_appended_lsn();
1693        if lsn > 0 {
1694            tbl.heap.set_page_lsn(new_rid.page_id, lsn)?;
1695        }
1696        Ok(new_rid)
1697    }
1698
1699    pub fn get(&self, table: &str, rid: RowId) -> Option<Row> {
1700        self.get_table(table)?.get(rid)
1701    }
1702
1703    pub fn get_projected(
1704        &self,
1705        table: &str,
1706        rid: RowId,
1707        column_indices: &[usize],
1708    ) -> io::Result<Option<Vec<Value>>> {
1709        self.by_name(table)?.get_projected(rid, column_indices)
1710    }
1711
1712    pub fn delete(&mut self, table: &str, rid: RowId) -> io::Result<()> {
1713        let slot = self.slot_of(table)?;
1714        // Capture the deleted row's overflow chain BEFORE the heap slot is
1715        // cleared, so it can be freed once safe (design 3.6). Empty for
1716        // inline-only tables (cheap `has_overflow_rows` check).
1717        let old_pages = self.tables[slot].overflow_chain_pages_at(rid)?;
1718        // Mission B (post-review, second pass): WAL Off → no payload
1719        // construction.
1720        if self.wal.is_off() {
1721            self.tables[slot].delete(rid)?;
1722            self.free_overflow_chain(slot, old_pages);
1723            return Ok(());
1724        }
1725        let tx_id = self.next_tx();
1726        // Delete records carry only the rid — no row payload.
1727        self.wal_log(tx_id, WalRecordType::Delete, table, rid, &[])?;
1728        self.tables[slot].delete(rid)?;
1729        self.free_overflow_chain(slot, old_pages);
1730        Ok(())
1731    }
1732
1733    /// Mission C Phase 12: bulk delete a list of rids, batching btree
1734    /// maintenance. See [`Table::delete_many`] for the full explanation
1735    /// and fall-through rules. Returns the number of rows removed.
1736    pub fn delete_many(&mut self, table: &str, rids: &[RowId]) -> io::Result<u64> {
1737        // Mission 2: log every rid as an individual Delete record. The
1738        // WAL flush is deferred to the executor's statement-end
1739        // `sync_wal` — see [`Self::wal_log`] for the group-commit rules.
1740        //
1741        // Mission B (post-review, second pass): in Off mode skip the
1742        // entire per-row payload loop — `wal.append` would no-op every
1743        // call but the `encode_wal_payload` Vec alloc would still run.
1744        let slot = self.slot_of(table)?;
1745        // Gather every deleted row's overflow chain up front (empty and cheap
1746        // for inline-only tables) so the pages can be freed once safe.
1747        let old_pages = self.collect_overflow_pages(slot, rids)?;
1748        if self.wal.is_off() {
1749            let count = self.tables[slot].delete_many(rids)?;
1750            self.free_overflow_chain(slot, old_pages);
1751            return Ok(count);
1752        }
1753        let tx_id = self.next_tx();
1754        for &rid in rids {
1755            let payload = encode_wal_payload(table, rid, &[]);
1756            self.wal.append(tx_id, WalRecordType::Delete, &payload)?;
1757        }
1758        if self.active_tx_id.is_none() && !rids.is_empty() {
1759            self.pending_autocommit_tx_ids.push(tx_id);
1760        }
1761        let count = self.tables[slot].delete_many(rids)?;
1762        self.free_overflow_chain(slot, old_pages);
1763        Ok(count)
1764    }
1765
1766    /// Collect all overflow-chain pages referenced by `rids` in one table.
1767    /// Returns empty for inline-only tables without touching any row.
1768    fn collect_overflow_pages(&self, slot: usize, rids: &[RowId]) -> io::Result<Vec<u32>> {
1769        if !self.tables[slot].has_overflow_rows() {
1770            return Ok(Vec::new());
1771        }
1772        let mut pages = Vec::new();
1773        for &rid in rids {
1774            pages.extend(self.tables[slot].overflow_chain_pages_at(rid)?);
1775        }
1776        Ok(pages)
1777    }
1778
1779    /// Single-pass scan-and-delete driven by a raw-bytes predicate. See
1780    /// [`Table::scan_delete_matching`] and `HeapFile::scan_delete_matching`
1781    /// for the fusion rationale.
1782    ///
1783    /// Prefer [`Self::scan_delete_matching_logged`] from any
1784    /// caller that needs crash durability. This variant writes no WAL
1785    /// records, so a crash between the scan and the next checkpoint
1786    /// would lose the deletes. Kept here for internal paths (e.g.
1787    /// `drop_table`) where the whole heap is about to be removed anyway.
1788    pub fn scan_delete_matching<P>(&mut self, table: &str, pred: P) -> io::Result<u64>
1789    where
1790        P: FnMut(&[u8]) -> bool,
1791    {
1792        self.by_name_mut(table)?.scan_delete_matching(pred)
1793    }
1794
1795    /// WAL-logged variant of [`Self::scan_delete_matching`].
1796    /// Every matched row emits one `WalRecordType::Delete` record in the
1797    /// same single-pass scan (via the table's `_with_hook` variant), so
1798    /// crash recovery sees every deletion. Used by the executor's
1799    /// `Delete(Filter(SeqScan))` and bare `Delete(SeqScan)` fast paths.
1800    ///
1801    /// Performance cost vs the non-logged primitive is one per-row WAL
1802    /// append into the in-memory buffer plus one `fsync` at the end —
1803    /// the heap scan itself still runs as a single pass with one
1804    /// `ensure_hot` per page.
1805    pub fn scan_delete_matching_logged<P>(&mut self, table: &str, pred: P) -> io::Result<u64>
1806    where
1807        P: FnMut(&[u8]) -> bool,
1808    {
1809        // Mission B (post-review, second pass): in Off mode the per-row
1810        // hook would build a Vec, do five extends, and then `append`
1811        // would no-op. Skip the WAL hook entirely and route through
1812        // the no-WAL primitive — same single-pass scan, zero per-row
1813        // payload work.
1814        if self.wal.is_off() {
1815            return self.by_name_mut(table)?.scan_delete_matching(pred);
1816        }
1817        // Resolve slot up front so we can split the borrow — the user
1818        // hook closes over `&mut self.wal`, which can't coexist with a
1819        // `by_name_mut` borrow of `self.tables`.
1820        let slot = *self.name_to_slot.get(table).ok_or_else(|| {
1821            io::Error::new(
1822                io::ErrorKind::NotFound,
1823                format!("table '{table}' not found"),
1824            )
1825        })?;
1826        let tx_id = self.next_tx();
1827        let autocommit = self.active_tx_id.is_none();
1828        // Split-borrow the catalog fields so the hook can write into
1829        // `wal` while the scan pins `tables[slot]` mutably.
1830        let Catalog { tables, wal, .. } = self;
1831        let tbl = &mut tables[slot];
1832        // Pre-encode the table-name prefix of every WAL payload once —
1833        // it doesn't vary row-to-row, and the per-row rid+row bytes are
1834        // the only things we append inside the hook.
1835        let name_bytes = table.as_bytes();
1836        let count = tbl.scan_delete_matching_with_hook(pred, |rid, row_bytes| {
1837            let mut payload: Vec<u8> =
1838                Vec::with_capacity(4 + name_bytes.len() + 10 + row_bytes.len());
1839            payload.extend_from_slice(&(name_bytes.len() as u32).to_le_bytes());
1840            payload.extend_from_slice(name_bytes);
1841            payload.extend_from_slice(&rid.page_id.to_le_bytes());
1842            payload.extend_from_slice(&rid.slot_index.to_le_bytes());
1843            // Delete records carry no row payload on replay, but we
1844            // match the `encode_wal_payload` layout so `decode_wal_payload`
1845            // (which is type-agnostic) parses them cleanly.
1846            payload.extend_from_slice(&0u32.to_le_bytes());
1847            // Best-effort append — if it errors we have no way to
1848            // propagate from inside the hook; we swallow it here and
1849            // the outer scan's `io::Result` will still succeed. In
1850            // practice the `BufWriter`-backed `Wal::append` only errors
1851            // on allocation failure or a disk-full fsync, both of
1852            // which would fail the outer flush below as well.
1853            let _ = wal.append(tx_id, WalRecordType::Delete, &payload);
1854        })?;
1855        if autocommit && count > 0 {
1856            self.pending_autocommit_tx_ids.push(tx_id);
1857        }
1858        // Flush is deferred to the executor's statement-end `sync_wal`.
1859        Ok(count)
1860    }
1861
1862    /// Single-pass fused scan + in-place patch with WAL logging.
1863    /// Evaluates `pred` on raw row bytes and applies `try_mutate` to each
1864    /// match on the same hot page — no second pass. Returns
1865    /// `(patched_count, fallback_rids)`.
1866    ///
1867    /// Perf sprint: update analogue of `scan_delete_matching_logged`.
1868    /// Eliminates the two-pass collect-then-patch pattern.
1869    pub fn scan_patch_matching_logged<P, M>(
1870        &mut self,
1871        table: &str,
1872        pred: P,
1873        try_mutate: M,
1874    ) -> io::Result<(u64, Vec<RowId>)>
1875    where
1876        P: FnMut(&[u8]) -> bool,
1877        M: FnMut(&mut [u8]) -> Option<u16>,
1878    {
1879        if self.wal.is_off() {
1880            return self.by_name_mut(table)?.scan_patch_matching_with_hook(
1881                pred,
1882                try_mutate,
1883                |_, _| {},
1884            );
1885        }
1886        let slot = *self.name_to_slot.get(table).ok_or_else(|| {
1887            io::Error::new(
1888                io::ErrorKind::NotFound,
1889                format!("table '{table}' not found"),
1890            )
1891        })?;
1892        let tx_id = self.next_tx();
1893        let autocommit = self.active_tx_id.is_none();
1894        let Catalog { tables, wal, .. } = self;
1895        let tbl = &mut tables[slot];
1896        let name_bytes = table.as_bytes();
1897        let result = tbl.scan_patch_matching_with_hook(pred, try_mutate, |rid, row_bytes| {
1898            let mut payload: Vec<u8> =
1899                Vec::with_capacity(4 + name_bytes.len() + 10 + row_bytes.len());
1900            payload.extend_from_slice(&(name_bytes.len() as u32).to_le_bytes());
1901            payload.extend_from_slice(name_bytes);
1902            payload.extend_from_slice(&rid.page_id.to_le_bytes());
1903            payload.extend_from_slice(&rid.slot_index.to_le_bytes());
1904            payload.extend_from_slice(&(row_bytes.len() as u32).to_le_bytes());
1905            payload.extend_from_slice(row_bytes);
1906            let _ = wal.append(tx_id, WalRecordType::Update, &payload);
1907        })?;
1908        if autocommit && result.0 > 0 {
1909            self.pending_autocommit_tx_ids.push(tx_id);
1910        }
1911        Ok(result)
1912    }
1913
1914    pub fn update(&mut self, table: &str, rid: RowId, values: &Row) -> io::Result<RowId> {
1915        // Mission B (post-review, second pass): WAL Off → no payload
1916        // construction.
1917        if self.wal.is_off() {
1918            let slot = self.slot_of(table)?;
1919            let old_pages = self.tables[slot].overflow_chain_pages_at(rid)?;
1920            let new_rid = self.tables[slot].update(rid, values)?;
1921            self.free_overflow_chain(slot, old_pages);
1922            return Ok(new_rid);
1923        }
1924        let slot = self.slot_of(table)?;
1925        self.tables[slot].preflight_update(rid, values)?;
1926        let tx_id = self.next_tx();
1927        // Capture the old row's overflow chain (empty for inline-only tables)
1928        // BEFORE the update replaces it, so it can be freed once safe (design
1929        // 3.6). A chain-replacing update always orphans the old chain.
1930        let old_pages = self.tables[slot].overflow_chain_pages_at(rid)?;
1931        // Spill-aware encode: logs any overflow chains under `tx_id` (before
1932        // the Update record) and returns the v1/v2 row bytes. An overflow
1933        // transition relocates the row via heap delete+insert inside
1934        // `update_encoded`; the old row's chain (if any) is left for `sweep`.
1935        let row_bytes = {
1936            let Catalog { tables, wal, .. } = self;
1937            encode_row_with_spill_logged(&mut tables[slot], wal, tx_id, values)?
1938        };
1939        // Reject oversized rows BEFORE appending the Update record: a logged
1940        // Update the heap then rejects would poison the next replay. (A v2
1941        // stub row is always small; only a non-spilled v1 row can trip this.)
1942        check_encoded_row_size(&row_bytes)?;
1943        self.wal_log(tx_id, WalRecordType::Update, table, rid, &row_bytes)?;
1944        let new_rid = self.tables[slot].update_encoded(rid, values, &row_bytes, None)?;
1945        self.free_overflow_chain(slot, old_pages);
1946        Ok(new_rid)
1947    }
1948
1949    /// Mission C Phase 2: update with a hint about which columns actually
1950    /// changed. Lets [`Table::update_hinted`] skip the old-row read when
1951    /// the hint shows no indexed column is in the changed set.
1952    pub fn update_hinted(
1953        &mut self,
1954        table: &str,
1955        rid: RowId,
1956        values: &Row,
1957        changed_col_indices: Option<&[usize]>,
1958    ) -> io::Result<RowId> {
1959        // Mission B (post-review, second pass): WAL Off → no payload
1960        // construction. The `update_by_filter` powql bench drives this
1961        // path tens of thousands of times per iteration.
1962        if self.wal.is_off() {
1963            let slot = self.slot_of(table)?;
1964            let old_pages = self.tables[slot].overflow_chain_pages_at(rid)?;
1965            let new_rid = self.tables[slot].update_hinted(rid, values, changed_col_indices)?;
1966            self.free_overflow_chain(slot, old_pages);
1967            return Ok(new_rid);
1968        }
1969        let slot = self.slot_of(table)?;
1970        self.tables[slot].preflight_update(rid, values)?;
1971        let tx_id = self.next_tx();
1972        let old_pages = self.tables[slot].overflow_chain_pages_at(rid)?;
1973        let row_bytes = {
1974            let Catalog { tables, wal, .. } = self;
1975            encode_row_with_spill_logged(&mut tables[slot], wal, tx_id, values)?
1976        };
1977        // Same pre-WAL size gate as [`Self::update`].
1978        check_encoded_row_size(&row_bytes)?;
1979        self.wal_log(tx_id, WalRecordType::Update, table, rid, &row_bytes)?;
1980        let new_rid =
1981            self.tables[slot].update_encoded(rid, values, &row_bytes, changed_col_indices)?;
1982        self.free_overflow_chain(slot, old_pages);
1983        Ok(new_rid)
1984    }
1985
1986    /// Mission C Phase 4: fast-path update that patches a row's raw bytes
1987    /// in place, skipping decode/encode. Caller guarantees the mutation
1988    /// preserves the row length and touches no indexed column. Returns
1989    /// `Ok(true)` if the patch landed, `Ok(false)` if the row is gone.
1990    ///
1991    /// This primitive does NOT log to the WAL. Executor
1992    /// callers must route through [`Self::update_row_bytes_logged`] (or
1993    /// [`Self::update_row_bytes_logged_by_slot`]) so crash recovery
1994    /// sees the patched bytes. This raw form is retained for replay
1995    /// itself and any future callers that can tolerate the non-durable
1996    /// contract.
1997    #[inline]
1998    pub fn with_row_bytes_mut<F>(&mut self, table: &str, rid: RowId, f: F) -> io::Result<bool>
1999    where
2000        F: FnOnce(&mut [u8]),
2001    {
2002        self.by_name_mut(table)?.with_row_bytes_mut(rid, f)
2003    }
2004
2005    /// WAL-logged variant of [`Self::with_row_bytes_mut`].
2006    /// Applies `f` to the live row bytes on the hot page, then reads
2007    /// the mutated bytes back and emits a `WalRecordType::Update`
2008    /// record so replay will re-apply the same patch after a crash.
2009    ///
2010    /// Ordering: the hot-page mutation happens first (in-memory only,
2011    /// no disk I/O), then the WAL record is appended and flushed. A
2012    /// crash after the mutation but before the WAL flush loses the
2013    /// update, but the caller never saw success in that case, so the
2014    /// contract holds: any `Ok(true)` return is durable.
2015    ///
2016    /// No hot-page eviction can happen between steps because this
2017    /// method holds the catalog's `&mut self` exclusively.
2018    #[inline]
2019    pub fn update_row_bytes_logged<F>(&mut self, table: &str, rid: RowId, f: F) -> io::Result<bool>
2020    where
2021        F: FnOnce(&mut [u8]),
2022    {
2023        let slot = *self.name_to_slot.get(table).ok_or_else(|| {
2024            io::Error::new(
2025                io::ErrorKind::NotFound,
2026                format!("table '{table}' not found"),
2027            )
2028        })?;
2029        self.update_row_bytes_logged_by_slot(slot, rid, f)
2030    }
2031
2032    /// Slot-indexed counterpart to [`Self::update_row_bytes_logged`].
2033    /// Used by prepared-query fast paths that already cached the table
2034    /// slot at prepare time and want to skip the name->slot probe on
2035    /// every execution.
2036    #[inline]
2037    pub fn update_row_bytes_logged_by_slot<F>(
2038        &mut self,
2039        slot: usize,
2040        rid: RowId,
2041        f: F,
2042    ) -> io::Result<bool>
2043    where
2044        F: FnOnce(&mut [u8]),
2045    {
2046        // Step 1: apply the mutation on the hot page. Failure here
2047        // (slot gone) short-circuits with Ok(false) — no WAL record.
2048        let tbl = &mut self.tables[slot];
2049        let ok = tbl.with_row_bytes_mut(rid, f)?;
2050        if !ok {
2051            return Ok(false);
2052        }
2053        // Mission B (post-review, second pass): in Off mode the per-row
2054        // get + clone + table-name clone + wal_log call are all wasted
2055        // — `wal.append` would no-op. Skip the snapshot path entirely.
2056        if self.wal.is_off() {
2057            return Ok(true);
2058        }
2059        // Step 2: snapshot the now-mutated bytes. `HeapFile::get`
2060        // observes the pinned hot page, so it returns the fresh row.
2061        let new_bytes = match tbl.heap.get(rid) {
2062            Some(b) => b,
2063            // Shouldn't happen — we just patched it — but be defensive.
2064            None => return Ok(false),
2065        };
2066        // Step 3: log + flush. Clone the table name out of the schema
2067        // so we can drop the `&mut tbl` borrow before touching `self.wal`.
2068        let table_name = tbl.schema.table_name.clone();
2069        let tx_id = self.next_tx();
2070        self.wal_log(tx_id, WalRecordType::Update, &table_name, rid, &new_bytes)?;
2071        Ok(true)
2072    }
2073
2074    /// Mission C Phase 10: var-column in-place update fast path. Patches
2075    /// a single variable-length column's bytes directly into the row's
2076    /// slot, shrinking the row if the new value is smaller. Returns
2077    /// `Ok(false)` if the new value would grow the row (caller must fall
2078    /// back to the full encode path) or the row is gone.
2079    ///
2080    /// Caller guarantees no indexed column is touched — indexes are NOT
2081    /// maintained by this primitive.
2082    ///
2083    /// Not WAL-logged. Executor callers should use
2084    /// [`Self::patch_var_col_logged`] instead.
2085    #[inline]
2086    pub fn patch_var_col_in_place(
2087        &mut self,
2088        table: &str,
2089        rid: RowId,
2090        col_idx: usize,
2091        new_value: Option<&[u8]>,
2092    ) -> io::Result<bool> {
2093        self.by_name_mut(table)?
2094            .patch_var_col_in_place(rid, col_idx, new_value)
2095    }
2096
2097    /// WAL-logged variant of [`Self::patch_var_col_in_place`].
2098    /// Runs the in-place shrink on the hot page, then reads the mutated
2099    /// row bytes back and logs a `WalRecordType::Update` record. On a
2100    /// `false` return (grow-case bail) nothing is logged — the caller's
2101    /// fall-through to `update_hinted` handles the WAL itself.
2102    pub fn patch_var_col_logged(
2103        &mut self,
2104        table: &str,
2105        rid: RowId,
2106        col_idx: usize,
2107        new_value: Option<&[u8]>,
2108    ) -> io::Result<bool> {
2109        let slot = *self.name_to_slot.get(table).ok_or_else(|| {
2110            io::Error::new(
2111                io::ErrorKind::NotFound,
2112                format!("table '{table}' not found"),
2113            )
2114        })?;
2115        let tbl = &mut self.tables[slot];
2116        let ok = tbl.patch_var_col_in_place(rid, col_idx, new_value)?;
2117        if !ok {
2118            return Ok(false);
2119        }
2120        // Mission B (post-review, second pass): WAL Off → skip the
2121        // snapshot + clone + log entirely.
2122        if self.wal.is_off() {
2123            return Ok(true);
2124        }
2125        let new_bytes = match tbl.heap.get(rid) {
2126            Some(b) => b,
2127            None => return Ok(false),
2128        };
2129        let table_name = tbl.schema.table_name.clone();
2130        let tx_id = self.next_tx();
2131        self.wal_log(tx_id, WalRecordType::Update, &table_name, rid, &new_bytes)?;
2132        Ok(true)
2133    }
2134
2135    pub fn scan(&self, table: &str) -> io::Result<impl Iterator<Item = (RowId, Row)> + '_> {
2136        Ok(self.by_name(table)?.scan())
2137    }
2138
2139    /// Zero-copy scan: passes raw row bytes to the callback without any
2140    /// per-row allocation. Used by the executor's fast paths.
2141    pub fn for_each_row_raw<F>(&self, table: &str, f: F) -> io::Result<()>
2142    where
2143        F: FnMut(RowId, &[u8]),
2144    {
2145        self.by_name(table)?.for_each_row_raw(f);
2146        Ok(())
2147    }
2148
2149    /// Zero-copy scan with early termination. The callback returns
2150    /// `ControlFlow::Break(())` to stop. Used by `Limit` fast paths so a
2151    /// `limit 100` query doesn't pay decode/predicate cost for every row
2152    /// in the table after the limit is reached.
2153    pub fn try_for_each_row_raw<F>(&self, table: &str, f: F) -> io::Result<()>
2154    where
2155        F: FnMut(RowId, &[u8]) -> std::ops::ControlFlow<()>,
2156    {
2157        self.by_name(table)?.try_for_each_row_raw(f);
2158        Ok(())
2159    }
2160
2161    pub fn create_index(&mut self, table: &str, column: &str) -> io::Result<()> {
2162        self.create_index_unique(table, column, false)
2163    }
2164
2165    /// Create an index with an explicit uniqueness flag. `unique = true`
2166    /// for primary-key-like columns where duplicate values should
2167    /// overwrite. `unique = false` for secondary indexes that allow
2168    /// duplicate column values (the default via `create_index`).
2169    pub fn create_index_unique(
2170        &mut self,
2171        table: &str,
2172        column: &str,
2173        unique: bool,
2174    ) -> io::Result<()> {
2175        self.invalidate_structure();
2176        let data_dir = self.data_dir.clone();
2177        self.by_name_mut(table)?
2178            .create_index_with_unique(column, &data_dir, unique)?;
2179        // Mission 3: persist the updated catalog so the indexed column
2180        // list survives a restart. `Table::create_index` already saved
2181        // the btree file itself.
2182        self.persist()
2183    }
2184
2185    pub fn active_catalog_version(&self) -> u16 {
2186        self.active_catalog_version
2187    }
2188
2189    pub fn next_index_id(&self) -> u64 {
2190        self.next_index_id
2191    }
2192
2193    /// Return both legacy column-index and v6 expression-index identities.
2194    pub fn index_metadata(&self, table: &str) -> Option<Vec<IndexMetadata>> {
2195        let table_ref = self.get_table(table)?;
2196        let mut metadata = table_ref
2197            .indexed_column_metas()
2198            .into_iter()
2199            .map(|index| IndexMetadata {
2200                unique: index.unique,
2201                source: IndexKeySource::Column { column: index.name },
2202            })
2203            .collect::<Vec<_>>();
2204        metadata.extend(table_ref.expression_index_metas().into_iter().map(|index| {
2205            IndexMetadata {
2206                unique: index.unique,
2207                source: IndexKeySource::Expression {
2208                    index_id: index.index_id,
2209                    canonical_version: index.canonical_version,
2210                    canonical_text: index.canonical_text,
2211                    json_path: index.json_path,
2212                },
2213            }
2214        }));
2215        Some(metadata)
2216    }
2217
2218    pub fn expression_index_metadata(&self, table: &str) -> Option<Vec<ExpressionIndexMeta>> {
2219        Some(self.get_table(table)?.expression_index_metas())
2220    }
2221
2222    pub fn expression_index_btree(&self, table: &str, index_id: u64) -> Option<&BTree> {
2223        self.get_table(table)?.expression_index_btree(index_id)
2224    }
2225
2226    /// Per-index statistics for a column index. O(1) read of the loaded tree's
2227    /// in-memory counters; `None` when the table or column index is absent. Used
2228    /// by the conjunction index chooser during plan lowering.
2229    pub fn index_stats(&self, table: &str, column: &str) -> Option<IndexStats> {
2230        Some(self.get_table(table)?.index(column)?.stats())
2231    }
2232
2233    /// Per-index statistics for an expression index by id. O(1).
2234    pub fn expression_index_stats(&self, table: &str, index_id: u64) -> Option<IndexStats> {
2235        Some(
2236            self.get_table(table)?
2237                .expression_index_btree(index_id)?
2238                .stats(),
2239        )
2240    }
2241
2242    pub fn expression_index_btree_mut(&mut self, table: &str, index_id: u64) -> Option<&mut BTree> {
2243        self.get_table_mut(table)?
2244            .expression_index_btree_mut(index_id)
2245    }
2246
2247    pub fn expression_index_lookup_all(
2248        &self,
2249        table: &str,
2250        index_id: u64,
2251        key: &Value,
2252    ) -> io::Result<Vec<RowId>> {
2253        let tree = self
2254            .by_name(table)?
2255            .expression_index_btree(index_id)
2256            .ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "expression index not found"))?;
2257        Ok(tree.lookup_all(key))
2258    }
2259
2260    pub fn expression_index_range_rids(
2261        &self,
2262        table: &str,
2263        index_id: u64,
2264        start: Option<&Value>,
2265        end: Option<&Value>,
2266    ) -> io::Result<Vec<RowId>> {
2267        let tree = self
2268            .by_name(table)?
2269            .expression_index_btree(index_id)
2270            .ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "expression index not found"))?;
2271        Ok(tree.raw_range_rids(start, end))
2272    }
2273
2274    pub fn expression_index_ordered_rids(
2275        &self,
2276        table: &str,
2277        index_id: u64,
2278    ) -> io::Result<Vec<RowId>> {
2279        let tree = self
2280            .by_name(table)?
2281            .expression_index_btree(index_id)
2282            .ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "expression index not found"))?;
2283        Ok(tree.ordered_rids_nulls_last())
2284    }
2285
2286    pub fn expression_index_ordered_rids_bounded(
2287        &self,
2288        table: &str,
2289        index_id: u64,
2290        direction: IndexOrderDirection,
2291        offset: usize,
2292        limit: usize,
2293    ) -> io::Result<Vec<RowId>> {
2294        let tree = self
2295            .by_name(table)?
2296            .expression_index_btree(index_id)
2297            .ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "expression index not found"))?;
2298        Ok(tree.bounded_ordered_rids_nulls_last(
2299            direction == IndexOrderDirection::Desc,
2300            offset,
2301            limit,
2302        ))
2303    }
2304
2305    pub fn drop_expression_index(&mut self, table: &str, index_id: u64) -> io::Result<()> {
2306        self.invalidate_structure();
2307        validate_table_name(table)?;
2308        let removed = self
2309            .by_name_mut(table)?
2310            .take_expression_index(index_id)
2311            .ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "expression index not found"))?;
2312        match self.persist_at_activation_boundary() {
2313            Ok(()) => {}
2314            Err(CatalogPersistError::BeforeActivation(error)) => {
2315                self.by_name_mut(table)?.restore_expression_index(removed);
2316                return Err(error);
2317            }
2318            Err(CatalogPersistError::AfterActivation(error)) => {
2319                warn!(
2320                    path = %self.data_dir.display(),
2321                    error = %error,
2322                    "expression index drop committed but catalog directory sync failed"
2323                );
2324            }
2325        }
2326        let index_path = self
2327            .data_dir
2328            .join(expression_index_file_name(table, index_id));
2329        if let Err(error) = fs::remove_file(&index_path) {
2330            if error.kind() != io::ErrorKind::NotFound {
2331                warn!(path = %index_path.display(), error = %error, "failed to remove dropped expression index file");
2332            }
2333        } else if let Err(error) = sync_directory(&self.data_dir) {
2334            warn!(path = %self.data_dir.display(), error = %error, "failed to sync expression index deletion");
2335        }
2336        Ok(())
2337    }
2338
2339    /// Persist expression-index identity and create its backup-compatible
2340    /// `.eidx` file. The catalog stays at v5 until every validation and file
2341    /// creation step succeeds; the v6 catalog rename is the activation point.
2342    pub fn create_expression_index_metadata(
2343        &mut self,
2344        table: &str,
2345        canonical_version: u16,
2346        canonical_text: impl Into<String>,
2347        json_path: StoredJsonPathV1,
2348        unique: bool,
2349    ) -> io::Result<u64> {
2350        self.invalidate_structure();
2351        validate_table_name(table)?;
2352        validate_column_name(&json_path.column)?;
2353        if canonical_version == 0 {
2354            return Err(io::Error::new(
2355                io::ErrorKind::InvalidInput,
2356                "expression canonical version must be non-zero",
2357            ));
2358        }
2359        let canonical_text = canonical_text.into();
2360        if canonical_text.is_empty() {
2361            return Err(io::Error::new(
2362                io::ErrorKind::InvalidInput,
2363                "expression canonical text must not be empty",
2364            ));
2365        }
2366        if canonical_version == 1 && canonical_text != json_path.canonical_text() {
2367            return Err(io::Error::new(
2368                io::ErrorKind::InvalidInput,
2369                "expression canonical text does not match its stored JSON path",
2370            ));
2371        }
2372        let table_ref = self.by_name(table)?;
2373        let root_index = table_ref
2374            .schema
2375            .column_index(&json_path.column)
2376            .ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "JSON root column not found"))?;
2377        if table_ref.schema.columns[root_index].type_id != TypeId::Json {
2378            return Err(io::Error::new(
2379                io::ErrorKind::InvalidInput,
2380                "expression index root column must have type json",
2381            ));
2382        }
2383        if table_ref.expression_index_metas().iter().any(|index| {
2384            index.canonical_version == canonical_version && index.canonical_text == canonical_text
2385        }) {
2386            return Err(io::Error::new(
2387                io::ErrorKind::AlreadyExists,
2388                "expression index already exists",
2389            ));
2390        }
2391
2392        let index_id = self.next_index_id;
2393        let next_index_id = index_id
2394            .checked_add(1)
2395            .ok_or_else(|| io::Error::other("expression index id space exhausted"))?;
2396        let index_path = self
2397            .data_dir
2398            .join(expression_index_file_name(table, index_id));
2399        if index_path.exists() {
2400            // The allocator proves this ID is not referenced by the active
2401            // catalog. A file here can therefore only be an orphan from a
2402            // crash after the index-file fsync but before catalog activation.
2403            fs::remove_file(&index_path)?;
2404            sync_directory(&self.data_dir)?;
2405        }
2406        let meta = ExpressionIndexMeta {
2407            index_id,
2408            unique,
2409            canonical_version,
2410            canonical_text,
2411            json_path,
2412        };
2413        self.by_name_mut(table)?
2414            .install_expression_index(meta, &index_path)?;
2415        if let Err(error) = sync_directory(&self.data_dir) {
2416            self.by_name_mut(table)?
2417                .remove_expression_index_by_id(index_id);
2418            let _ = fs::remove_file(&index_path);
2419            return Err(error);
2420        }
2421
2422        let previous_version = self.active_catalog_version;
2423        let previous_next_id = self.next_index_id;
2424        self.active_catalog_version = CATALOG_VERSION;
2425        self.next_index_id = next_index_id;
2426        match self.persist_at_activation_boundary() {
2427            Ok(()) => {}
2428            Err(CatalogPersistError::BeforeActivation(error)) => {
2429                self.by_name_mut(table)?
2430                    .remove_expression_index_by_id(index_id);
2431                self.active_catalog_version = previous_version;
2432                self.next_index_id = previous_next_id;
2433                let _ = fs::remove_file(&index_path);
2434                let _ = sync_directory(&self.data_dir);
2435                return Err(error);
2436            }
2437            Err(CatalogPersistError::AfterActivation(error)) => {
2438                warn!(
2439                    path = %self.data_dir.display(),
2440                    error = %error,
2441                    "expression index creation committed but catalog directory sync failed"
2442                );
2443            }
2444        }
2445        Ok(index_id)
2446    }
2447
2448    /// Whether `table.column` has a UNIQUE index. Returns `Some(true)` for
2449    /// a unique index, `Some(false)` for a non-unique index, and `None`
2450    /// when the column is not indexed or the table is unknown.
2451    pub fn is_index_unique(&self, table: &str, column: &str) -> Option<bool> {
2452        self.get_table(table)?.is_index_unique(column)
2453    }
2454
2455    /// Whether `table.column` has any index (unique or non-unique).
2456    pub fn has_index(&self, table: &str, column: &str) -> bool {
2457        self.get_table(table)
2458            .map(|t| t.has_index(column))
2459            .unwrap_or(false)
2460    }
2461
2462    pub fn index_lookup(&self, table: &str, column: &str, key: &Value) -> io::Result<Option<Row>> {
2463        Ok(self
2464            .by_name(table)?
2465            .index_lookup(column, key)
2466            .map(|(_, row)| row))
2467    }
2468
2469    pub fn list_tables(&self) -> Vec<&str> {
2470        // Phase 18: iterate the Vec directly — schema.table_name is
2471        // the source of truth, and Vec order is insertion order (more
2472        // deterministic than the old FxHashMap keys).
2473        self.tables
2474            .iter()
2475            .map(|t| t.schema.table_name.as_str())
2476            .collect()
2477    }
2478
2479    pub fn schema(&self, table: &str) -> Option<&Schema> {
2480        let slot = *self.name_to_slot.get(table)?;
2481        Some(&self.tables[slot].schema)
2482    }
2483
2484    /// Drop a table: remove from the catalog and delete its data files.
2485    /// Returns `Err` if the table doesn't exist.
2486    pub fn drop_table(&mut self, name: &str) -> io::Result<()> {
2487        self.invalidate_structure();
2488        validate_table_name(name)?;
2489        let slot = *self.name_to_slot.get(name).ok_or_else(|| {
2490            io::Error::new(io::ErrorKind::NotFound, format!("table '{name}' not found"))
2491        })?;
2492        if !self.wal.is_off() {
2493            let payload = encode_ddl_drop_table(name);
2494            self.wal.append(0, WalRecordType::DdlDropTable, &payload)?;
2495            self.wal.flush()?;
2496        }
2497        // Remove the data file.
2498        let table = &self.tables[slot];
2499        let heap_path = self
2500            .data_dir
2501            .join(format!("{}.heap", table.schema.table_name));
2502        if heap_path.exists() {
2503            fs::remove_file(&heap_path)?;
2504        }
2505        // Mission 3: remove only the .idx files that actually exist
2506        // (i.e. the columns the table currently has indexed). The pre-
2507        // Mission-3 code iterated every schema column blindly — harmless
2508        // but noisy. Now that we persist a real list of indexed columns,
2509        // we can be precise.
2510        for col_name in table.indexed_column_names() {
2511            let idx_path = self.data_dir.join(format!("{name}_{col_name}.idx"));
2512            if idx_path.exists() {
2513                let _ = fs::remove_file(&idx_path);
2514            }
2515        }
2516        let expression_index_ids = table.expression_index_ids();
2517        // Swap-remove from the Vec and fix up name_to_slot.
2518        self.name_to_slot.remove(name);
2519        let last = self.tables.len() - 1;
2520        if slot != last {
2521            let moved_name = self.tables[last].schema.table_name.clone();
2522            self.tables.swap(slot, last);
2523            self.name_to_slot.insert(moved_name, slot);
2524        }
2525        self.tables.pop();
2526        self.persist()?;
2527        for index_id in expression_index_ids {
2528            let idx_path = self
2529                .data_dir
2530                .join(expression_index_file_name(name, index_id));
2531            let _ = fs::remove_file(idx_path);
2532        }
2533        Ok(())
2534    }
2535
2536    /// Add a column to an existing table's schema and backfill all
2537    /// existing rows to match the new shape.
2538    ///
2539    /// Older versions of this method only mutated the in-memory schema
2540    /// and relied on a (false) claim that "the heap format already
2541    /// handles short rows gracefully". It doesn't: `decode_row` reads
2542    /// exactly `n_var + 1` variable-column offsets from the row bytes
2543    /// using the CURRENT schema. Any row encoded with the old schema's
2544    /// (smaller) offset table would walk off the end of its buffer and
2545    /// panic with "range end index X out of range for slice of length Y"
2546    /// — which is exactly what a bare `Type` scan triggered right after
2547    /// an ALTER ADD COLUMN.
2548    ///
2549    /// The fix: rewrite every existing row through
2550    /// `Table::rewrite_rows_for_schema_change` so the on-disk
2551    /// encoding matches the new schema layout. Existing rows get
2552    /// `Value::Empty` for the new column.
2553    ///
2554    /// If the new column is `required` we refuse to add it to a
2555    /// non-empty table — there is no default value to backfill with,
2556    /// and silently storing `Empty` in a required slot would just
2557    /// shift the invariant violation to the next query.
2558    pub fn alter_table_add_column(&mut self, table: &str, col: ColumnDef) -> io::Result<()> {
2559        self.invalidate_structure();
2560        let data_dir = self.data_dir.clone();
2561        {
2562            let tbl = self.by_name_mut(table)?;
2563            if tbl.schema.columns.iter().any(|c| c.name == col.name) {
2564                return Err(io::Error::new(
2565                    io::ErrorKind::AlreadyExists,
2566                    format!("column '{}' already exists in table '{table}'", col.name),
2567                ));
2568            }
2569        }
2570        let barrier_lsn = if !self.wal.is_off() {
2571            let payload = encode_ddl_alter_add_column(table, &col);
2572            self.wal.append(0, WalRecordType::DdlAddColumn, &payload)?;
2573            self.wal.flush()?;
2574            self.wal.last_appended_lsn()
2575        } else {
2576            0
2577        };
2578        let tbl = self.by_name_mut(table)?;
2579
2580        let old_schema = tbl.schema.clone();
2581
2582        // Peek at the heap to learn whether there are any existing
2583        // rows at all. An empty table is always safe to alter — no
2584        // rewrite needed, required columns are fine, etc.
2585        let has_rows = tbl.heap.scan().next().is_some();
2586
2587        if has_rows && col.required {
2588            return Err(io::Error::new(
2589                io::ErrorKind::InvalidInput,
2590                format!(
2591                    "cannot add required column '{}' to non-empty table '{table}': \
2592                     no default value to backfill existing rows with",
2593                    col.name
2594                ),
2595            ));
2596        }
2597
2598        // Commit the new column into the schema and refresh the
2599        // cached layout so the rewrite below encodes with the new
2600        // shape.
2601        tbl.schema.columns.push(col);
2602        tbl.refresh_layout();
2603
2604        if has_rows {
2605            // Build the "fill" template: all Empty, matching the new
2606            // schema width. `rewrite_rows_for_schema_change` will
2607            // overwrite old-column slots from each live row and leave
2608            // the new slot as Empty.
2609            let fill: Vec<Value> = vec![Value::Empty; tbl.schema.columns.len()];
2610            tbl.rewrite_rows_for_schema_change(&old_schema, &fill, &data_dir)?;
2611        }
2612        // P0 fix (v0.4.3): stamp every heap page with the DDL record's
2613        // LSN so any pre-DDL Insert/Update/Delete WAL record gets
2614        // skipped on replay. Without this barrier, a restart after
2615        // `alter add column` would replay pre-alter inserts (encoded in
2616        // the OLD layout) onto a heap that's already in the NEW layout,
2617        // producing a mixed-version heap that panics on the next
2618        // projection. Regression: see `restart_after_alter_add_column_then_index`.
2619        if barrier_lsn > 0 {
2620            tbl.heap.stamp_all_pages_min_lsn(barrier_lsn)?;
2621            tbl.heap.flush()?;
2622        }
2623
2624        self.persist()?;
2625        Ok(())
2626    }
2627
2628    /// Remove a column from an existing table's schema and rewrite
2629    /// every live row to match the new shape.
2630    ///
2631    /// Older versions of this method only mutated the in-memory schema
2632    /// and claimed that "reads simply won't decode the dropped column".
2633    /// That was wrong in several ways:
2634    ///
2635    ///   1. The null bitmap is indexed by column position. Dropping a
2636    ///      column shifts every later column's bit left, but old rows
2637    ///      still have bits in the original positions — so `is_null`
2638    ///      checks silently lie for every column after the dropped one.
2639    ///   2. The bitmap's byte width (`ceil(n_cols/8)`) can shrink when
2640    ///      `n_cols` crosses an 8-boundary, shifting every subsequent
2641    ///      byte of the row against the decoder's cursor.
2642    ///   3. Fixed-region size and the variable-offset-table width both
2643    ///      depend on the column set, so dropping any fixed or variable
2644    ///      column slides every following byte.
2645    ///
2646    /// The fix mirrors `alter_table_add_column`: snapshot the old
2647    /// schema, mutate to the new schema, then rewrite every row
2648    /// through `Table::rewrite_rows_for_schema_change`. Dropping a
2649    /// column from an empty table skips the rewrite.
2650    pub fn alter_table_drop_column(&mut self, table: &str, col_name: &str) -> io::Result<()> {
2651        self.invalidate_structure();
2652        let data_dir = self.data_dir.clone();
2653        {
2654            let tbl = self.by_name_mut(table)?;
2655            tbl.schema
2656                .columns
2657                .iter()
2658                .position(|c| c.name == col_name)
2659                .ok_or_else(|| {
2660                    io::Error::new(
2661                        io::ErrorKind::NotFound,
2662                        format!("column '{col_name}' not found in table '{table}'"),
2663                    )
2664                })?;
2665        }
2666        let removed_expression_index_ids = self
2667            .by_name_mut(table)?
2668            .remove_expression_indexes_for_root(col_name);
2669        let barrier_lsn = if !self.wal.is_off() {
2670            let payload = encode_ddl_alter_drop_column(table, col_name);
2671            self.wal.append(0, WalRecordType::DdlDropColumn, &payload)?;
2672            self.wal.flush()?;
2673            self.wal.last_appended_lsn()
2674        } else {
2675            0
2676        };
2677        let tbl = self.by_name_mut(table)?;
2678        let idx = tbl
2679            .schema
2680            .columns
2681            .iter()
2682            .position(|c| c.name == col_name)
2683            .ok_or_else(|| {
2684                io::Error::new(
2685                    io::ErrorKind::NotFound,
2686                    format!("column '{col_name}' not found in table '{table}'"),
2687                )
2688            })?;
2689
2690        // Snapshot for decoding old rows.
2691        let old_schema = tbl.schema.clone();
2692        let has_rows = tbl.heap.scan().next().is_some();
2693
2694        // Commit the schema change.
2695        tbl.schema.columns.remove(idx);
2696        for (i, col) in tbl.schema.columns.iter_mut().enumerate() {
2697            col.position = i as u16;
2698        }
2699        tbl.refresh_layout();
2700
2701        if has_rows {
2702            // Build a filler matching the new (smaller) shape. The
2703            // rewrite path overwrites each new-column slot from the
2704            // matching old-column value by name, so the filler only
2705            // matters for brand-new columns — drop has none, so
2706            // `Empty` is a safe placeholder that never gets read.
2707            let fill: Vec<Value> = vec![Value::Empty; tbl.schema.columns.len()];
2708            tbl.rewrite_rows_for_schema_change(&old_schema, &fill, &data_dir)?;
2709        }
2710        // P0 fix: see matching comment in alter_table_add_column.
2711        if barrier_lsn > 0 {
2712            tbl.heap.stamp_all_pages_min_lsn(barrier_lsn)?;
2713            tbl.heap.flush()?;
2714        }
2715
2716        self.persist()?;
2717        for index_id in removed_expression_index_ids {
2718            let idx_path = self
2719                .data_dir
2720                .join(expression_index_file_name(table, index_id));
2721            let _ = fs::remove_file(idx_path);
2722        }
2723        Ok(())
2724    }
2725}
2726
2727impl Drop for Catalog {
2728    fn drop(&mut self) {
2729        // A read-only snapshot handle never wrote anything and holds read-only
2730        // file descriptors; checkpointing would try to flush pages and truncate
2731        // the WAL, mutating a directory that must stay byte-identical.
2732        if self.read_only {
2733            return;
2734        }
2735        if self.active_tx_id.is_some() {
2736            if let Err(e) = self.abandon_active_transaction_for_drop() {
2737                warn!(error = %e, "catalog drop active transaction cleanup failed");
2738            }
2739            return;
2740        }
2741        // Mission 2: best-effort clean shutdown. `checkpoint` flushes
2742        // every heap and truncates the WAL, which is what
2743        // [`Catalog::open`] relies on to know that no replay is needed.
2744        //
2745        // We swallow errors here because Rust's `Drop` can't propagate
2746        // them and panicking during unwind is always a bigger problem
2747        // than a failed flush. The worst case on a failed drop-time
2748        // checkpoint is that the next open sees a non-empty WAL and
2749        // replays it (potentially producing duplicates — see the
2750        // [`Self::replay_wal`] caveat). That's strictly better than
2751        // losing committed writes.
2752        if let Err(e) = self.checkpoint() {
2753            warn!(error = %e, "catalog drop checkpoint failed");
2754        }
2755    }
2756}
2757
2758// ─── WAL payload codec ─────────────────────────────────────────────────────
2759//
2760// Per-record payload layout (little-endian):
2761//
2762//   table_name_len : u32
2763//   table_name     : utf-8 bytes
2764//   page_id        : u32   (for insert: 0, ignored on replay)
2765//   slot_index     : u16   (for insert: 0, ignored on replay)
2766//   row_len        : u32
2767//   row_bytes      : raw encoded row (length = row_len)
2768//
2769// Lives next to `Catalog` because this is the only code that produces or
2770// consumes these records — the `Wal` itself is payload-agnostic.
2771
2772fn encode_wal_payload(table: &str, rid: RowId, row_bytes: &[u8]) -> Vec<u8> {
2773    let name = table.as_bytes();
2774    let mut out = Vec::with_capacity(4 + name.len() + 4 + 2 + 4 + row_bytes.len());
2775    out.extend_from_slice(&(name.len() as u32).to_le_bytes());
2776    out.extend_from_slice(name);
2777    out.extend_from_slice(&rid.page_id.to_le_bytes());
2778    out.extend_from_slice(&rid.slot_index.to_le_bytes());
2779    out.extend_from_slice(&(row_bytes.len() as u32).to_le_bytes());
2780    out.extend_from_slice(row_bytes);
2781    out
2782}
2783
2784fn decode_wal_payload(data: &[u8]) -> Option<(String, RowId, Vec<u8>)> {
2785    let mut pos = 0usize;
2786    if data.len() < 4 {
2787        return None;
2788    }
2789    let name_len = u32::from_le_bytes(data[pos..pos + 4].try_into().ok()?) as usize;
2790    pos += 4;
2791    if pos + name_len > data.len() {
2792        return None;
2793    }
2794    let name = std::str::from_utf8(&data[pos..pos + name_len])
2795        .ok()?
2796        .to_string();
2797    pos += name_len;
2798    if pos + 4 + 2 + 4 > data.len() {
2799        return None;
2800    }
2801    let page_id = u32::from_le_bytes(data[pos..pos + 4].try_into().ok()?);
2802    pos += 4;
2803    let slot_index = u16::from_le_bytes(data[pos..pos + 2].try_into().ok()?);
2804    pos += 2;
2805    let row_len = u32::from_le_bytes(data[pos..pos + 4].try_into().ok()?) as usize;
2806    pos += 4;
2807    if pos + row_len > data.len() {
2808        return None;
2809    }
2810    let row_bytes = data[pos..pos + row_len].to_vec();
2811    Some((
2812        name,
2813        RowId {
2814            page_id,
2815            slot_index,
2816        },
2817        row_bytes,
2818    ))
2819}
2820
2821/// Write one out-of-line value's overflow chain to the heap (head-first,
2822/// singly linked) and log each chunk as a `WalRecordType::OverflowWrite`
2823/// record under `tx_id`, ordered BEFORE the row's Insert/Update record so the
2824/// stub the row carries always points at logged, replayable pages. Returns the
2825/// stub (u64 length, head page, whole-value CRC32). Enforces `MAX_VALUE_SIZE`.
2826fn write_overflow_chain_logged(
2827    heap: &mut HeapFile,
2828    wal: &mut Wal,
2829    table: &str,
2830    tx_id: u64,
2831    value: &[u8],
2832) -> io::Result<OverflowStub> {
2833    if value.len() > MAX_VALUE_SIZE {
2834        return Err(StorageError::ValueTooLarge {
2835            size: value.len(),
2836            max: MAX_VALUE_SIZE,
2837        }
2838        .into());
2839    }
2840    let n = value.len().div_ceil(OVERFLOW_PAYLOAD_CAP).max(1);
2841    let mut pages = Vec::with_capacity(n);
2842    for _ in 0..n {
2843        pages.push(heap.allocate_overflow_page()?);
2844    }
2845    for i in 0..n {
2846        let start = i * OVERFLOW_PAYLOAD_CAP;
2847        let end = (start + OVERFLOW_PAYLOAD_CAP).min(value.len());
2848        let chunk = &value[start..end];
2849        let next = if i + 1 < n {
2850            pages[i + 1]
2851        } else {
2852            OVERFLOW_CHAIN_END
2853        };
2854        let payload = encode_overflow_write_payload(table, pages[i], next, chunk);
2855        wal.append(tx_id, WalRecordType::OverflowWrite, &payload)?;
2856        let lsn = wal.last_appended_lsn();
2857        heap.write_overflow_page(pages[i], next, chunk, lsn)?;
2858    }
2859    Ok(OverflowStub::new(
2860        value.len() as u64,
2861        pages[0],
2862        crc32fast::hash(value),
2863    ))
2864}
2865
2866/// Spill-aware encode for the WAL path. If the row fits inline, returns its v1
2867/// bytes untouched. Otherwise writes each spilled value's chain (with WAL
2868/// logging under `tx_id`) and returns the v2 stub-row bytes to be inserted and
2869/// logged in the row's Insert/Update record.
2870fn encode_row_with_spill_logged(
2871    tbl: &mut Table,
2872    wal: &mut Wal,
2873    tx_id: u64,
2874    values: &Row,
2875) -> io::Result<Vec<u8>> {
2876    // Size the v1 encoding WITHOUT encoding it (a >64KB value would panic the
2877    // debug-mode v1 encoder). Only actually encode v1 when the row fits inline.
2878    let v1_len = crate::row::v1_encoded_len(tbl.row_layout(), values);
2879    let is_indexed = tbl.indexed_col_mask();
2880    let chosen = plan_spill(tbl.row_layout(), values, v1_len, &is_indexed);
2881    if chosen.is_empty() {
2882        let mut v1 = Vec::new();
2883        encode_row_into(&tbl.schema, values, &mut v1);
2884        return Ok(v1);
2885    }
2886    let table_name = tbl.schema.table_name.clone();
2887    let n_var = tbl.row_layout().n_var();
2888    let mut spilled: Vec<Option<OverflowStub>> = vec![None; n_var];
2889    for col_idx in chosen {
2890        let var_idx = tbl
2891            .row_layout()
2892            .var_index(col_idx)
2893            .expect("plan_spill only returns var columns");
2894        let bytes: Vec<u8> = match &values[col_idx] {
2895            Value::Str(s) => s.as_bytes().to_vec(),
2896            Value::Bytes(b) => b.to_vec(),
2897            Value::Json(b) => b.to_vec(),
2898            _ => continue,
2899        };
2900        let stub = write_overflow_chain_logged(&mut tbl.heap, wal, &table_name, tx_id, &bytes)?;
2901        spilled[var_idx] = Some(stub);
2902    }
2903    let mut out = Vec::new();
2904    encode_row_v2_into(&tbl.schema, tbl.row_layout(), values, &spilled, &mut out);
2905    Ok(out)
2906}
2907
2908/// `OverflowWrite` payload: `table_len u16 | table | page_id u32 |
2909/// next_page u32 | chunk_len u16 | chunk bytes`.
2910///
2911/// NOTE (deviation from design 3.5): the design lists the payload as
2912/// `page_id | next_page | chunk_len | chunk`, but overflow pages live in
2913/// per-table heap files with independent page-id spaces, so replay needs the
2914/// table identity to route the write. The table name is length-prefixed
2915/// exactly like [`encode_wal_payload`]. The chunk-level fields are unchanged.
2916fn encode_overflow_write_payload(
2917    table: &str,
2918    page_id: u32,
2919    next_page: u32,
2920    chunk: &[u8],
2921) -> Vec<u8> {
2922    let name = table.as_bytes();
2923    let mut out = Vec::with_capacity(2 + name.len() + 4 + 4 + 2 + chunk.len());
2924    out.extend_from_slice(&(name.len() as u16).to_le_bytes());
2925    out.extend_from_slice(name);
2926    out.extend_from_slice(&page_id.to_le_bytes());
2927    out.extend_from_slice(&next_page.to_le_bytes());
2928    out.extend_from_slice(&(chunk.len() as u16).to_le_bytes());
2929    out.extend_from_slice(chunk);
2930    out
2931}
2932
2933fn decode_overflow_write_payload(data: &[u8]) -> Option<(String, u32, u32, Vec<u8>)> {
2934    let mut pos = 0usize;
2935    if data.len() < 2 {
2936        return None;
2937    }
2938    let name_len = u16::from_le_bytes(data[pos..pos + 2].try_into().ok()?) as usize;
2939    pos += 2;
2940    if pos + name_len + 4 + 4 + 2 > data.len() {
2941        return None;
2942    }
2943    let name = std::str::from_utf8(&data[pos..pos + name_len])
2944        .ok()?
2945        .to_string();
2946    pos += name_len;
2947    let page_id = u32::from_le_bytes(data[pos..pos + 4].try_into().ok()?);
2948    pos += 4;
2949    let next_page = u32::from_le_bytes(data[pos..pos + 4].try_into().ok()?);
2950    pos += 4;
2951    let chunk_len = u16::from_le_bytes(data[pos..pos + 2].try_into().ok()?) as usize;
2952    pos += 2;
2953    if pos + chunk_len > data.len() {
2954        return None;
2955    }
2956    Some((
2957        name,
2958        page_id,
2959        next_page,
2960        data[pos..pos + chunk_len].to_vec(),
2961    ))
2962}
2963
2964/// `OverflowFree` payload: `table_len u16 | table | count u32 |
2965/// page_id u32 x count`. Table name added for the same routing reason as
2966/// [`encode_overflow_write_payload`].
2967fn encode_overflow_free_payload(table: &str, pages: &[u32]) -> Vec<u8> {
2968    let name = table.as_bytes();
2969    let mut out = Vec::with_capacity(2 + name.len() + 4 + pages.len() * 4);
2970    out.extend_from_slice(&(name.len() as u16).to_le_bytes());
2971    out.extend_from_slice(name);
2972    out.extend_from_slice(&(pages.len() as u32).to_le_bytes());
2973    for p in pages {
2974        out.extend_from_slice(&p.to_le_bytes());
2975    }
2976    out
2977}
2978
2979fn decode_overflow_free_payload(data: &[u8]) -> Option<(String, Vec<u32>)> {
2980    let mut pos = 0usize;
2981    if data.len() < 2 {
2982        return None;
2983    }
2984    let name_len = u16::from_le_bytes(data[pos..pos + 2].try_into().ok()?) as usize;
2985    pos += 2;
2986    if pos + name_len + 4 > data.len() {
2987        return None;
2988    }
2989    let name = std::str::from_utf8(&data[pos..pos + name_len])
2990        .ok()?
2991        .to_string();
2992    pos += name_len;
2993    let count = u32::from_le_bytes(data[pos..pos + 4].try_into().ok()?) as usize;
2994    pos += 4;
2995    if pos + count * 4 > data.len() {
2996        return None;
2997    }
2998    let mut pages = Vec::with_capacity(count);
2999    for _ in 0..count {
3000        pages.push(u32::from_le_bytes(data[pos..pos + 4].try_into().ok()?));
3001        pos += 4;
3002    }
3003    Some((name, pages))
3004}
3005
3006// ─── DDL WAL payload codecs ─────────────────────────────────────────────────
3007
3008fn encode_ddl_create_table(
3009    schema: &Schema,
3010    defaults: &[Option<Value>],
3011    auto_cols: &[bool],
3012) -> Vec<u8> {
3013    let name = schema.table_name.as_bytes();
3014    let mut out = Vec::new();
3015    out.extend_from_slice(&(name.len() as u32).to_le_bytes());
3016    out.extend_from_slice(name);
3017    out.extend_from_slice(&(schema.columns.len() as u16).to_le_bytes());
3018    for col in &schema.columns {
3019        let cn = col.name.as_bytes();
3020        out.extend_from_slice(&(cn.len() as u32).to_le_bytes());
3021        out.extend_from_slice(cn);
3022        out.push(col.type_id as u8);
3023        out.push(col.required as u8);
3024        out.extend_from_slice(&col.position.to_le_bytes());
3025    }
3026    // Trailing sections. Records written before each feature existed simply
3027    // lack the corresponding trailing bytes, so the decoder treats their
3028    // absence as "none" (length-detected, append-only).
3029    encode_defaults_section(&mut out, defaults);
3030    encode_auto_section(&mut out, auto_cols);
3031    out
3032}
3033
3034fn decode_ddl_create_table(data: &[u8]) -> Option<(Schema, Vec<Option<Value>>, Vec<bool>)> {
3035    let mut pos = 0usize;
3036    if data.len() < 4 {
3037        return None;
3038    }
3039    let name_len = u32::from_le_bytes(data[pos..pos + 4].try_into().ok()?) as usize;
3040    pos += 4;
3041    if pos + name_len > data.len() {
3042        return None;
3043    }
3044    let table_name = std::str::from_utf8(&data[pos..pos + name_len])
3045        .ok()?
3046        .to_string();
3047    pos += name_len;
3048    if pos + 2 > data.len() {
3049        return None;
3050    }
3051    let n_cols = u16::from_le_bytes(data[pos..pos + 2].try_into().ok()?) as usize;
3052    pos += 2;
3053    let mut columns = Vec::with_capacity(n_cols);
3054    for _ in 0..n_cols {
3055        if pos + 4 > data.len() {
3056            return None;
3057        }
3058        let cn_len = u32::from_le_bytes(data[pos..pos + 4].try_into().ok()?) as usize;
3059        pos += 4;
3060        if pos + cn_len + 4 > data.len() {
3061            return None;
3062        }
3063        let col_name = std::str::from_utf8(&data[pos..pos + cn_len])
3064            .ok()?
3065            .to_string();
3066        pos += cn_len;
3067        let type_id = TypeId::from_u8(data[pos])?;
3068        pos += 1;
3069        let required = data[pos] != 0;
3070        pos += 1;
3071        if pos + 2 > data.len() {
3072            return None;
3073        }
3074        let position = u16::from_le_bytes(data[pos..pos + 2].try_into().ok()?);
3075        pos += 2;
3076        columns.push(ColumnDef {
3077            name: col_name,
3078            type_id,
3079            required,
3080            position,
3081        });
3082    }
3083    // Trailing sections are present on records written after each feature
3084    // landed; older records end early, decoding to "none".
3085    let defaults = if pos < data.len() {
3086        decode_defaults_section(data, &mut pos, columns.len())?
3087    } else {
3088        Vec::new()
3089    };
3090    let auto_cols = if pos < data.len() {
3091        decode_auto_section(data, &mut pos, columns.len())?
3092    } else {
3093        Vec::new()
3094    };
3095    Some((
3096        Schema {
3097            table_name,
3098            columns,
3099        },
3100        defaults,
3101        auto_cols,
3102    ))
3103}
3104
3105fn encode_ddl_drop_table(table_name: &str) -> Vec<u8> {
3106    let name = table_name.as_bytes();
3107    let mut out = Vec::with_capacity(4 + name.len());
3108    out.extend_from_slice(&(name.len() as u32).to_le_bytes());
3109    out.extend_from_slice(name);
3110    out
3111}
3112
3113fn encode_ddl_alter_add_column(table_name: &str, col: &ColumnDef) -> Vec<u8> {
3114    let name = table_name.as_bytes();
3115    let cn = col.name.as_bytes();
3116    let mut out = Vec::with_capacity(4 + name.len() + 4 + cn.len() + 4);
3117    out.extend_from_slice(&(name.len() as u32).to_le_bytes());
3118    out.extend_from_slice(name);
3119    out.extend_from_slice(&(cn.len() as u32).to_le_bytes());
3120    out.extend_from_slice(cn);
3121    out.push(col.type_id as u8);
3122    out.push(col.required as u8);
3123    out.extend_from_slice(&col.position.to_le_bytes());
3124    out
3125}
3126
3127fn encode_ddl_alter_drop_column(table_name: &str, col_name: &str) -> Vec<u8> {
3128    let name = table_name.as_bytes();
3129    let cn = col_name.as_bytes();
3130    let mut out = Vec::with_capacity(4 + name.len() + 4 + cn.len());
3131    out.extend_from_slice(&(name.len() as u32).to_le_bytes());
3132    out.extend_from_slice(name);
3133    out.extend_from_slice(&(cn.len() as u32).to_le_bytes());
3134    out.extend_from_slice(cn);
3135    out
3136}
3137
3138fn decode_ddl_table_name(data: &[u8]) -> Option<(String, usize)> {
3139    if data.len() < 4 {
3140        return None;
3141    }
3142    let name_len = u32::from_le_bytes(data[0..4].try_into().ok()?) as usize;
3143    if 4 + name_len > data.len() {
3144        return None;
3145    }
3146    let name = std::str::from_utf8(&data[4..4 + name_len])
3147        .ok()?
3148        .to_string();
3149    Some((name, 4 + name_len))
3150}
3151
3152fn decode_ddl_alter_add_column(data: &[u8]) -> Option<(String, ColumnDef)> {
3153    let (table_name, mut pos) = decode_ddl_table_name(data)?;
3154    if pos + 4 > data.len() {
3155        return None;
3156    }
3157    let cn_len = u32::from_le_bytes(data[pos..pos + 4].try_into().ok()?) as usize;
3158    pos += 4;
3159    if pos + cn_len + 4 > data.len() {
3160        return None;
3161    }
3162    let col_name = std::str::from_utf8(&data[pos..pos + cn_len])
3163        .ok()?
3164        .to_string();
3165    pos += cn_len;
3166    let type_id = TypeId::from_u8(data[pos])?;
3167    pos += 1;
3168    let required = data[pos] != 0;
3169    pos += 1;
3170    if pos + 2 > data.len() {
3171        return None;
3172    }
3173    let position = u16::from_le_bytes(data[pos..pos + 2].try_into().ok()?);
3174    Some((
3175        table_name,
3176        ColumnDef {
3177            name: col_name,
3178            type_id,
3179            required,
3180            position,
3181        },
3182    ))
3183}
3184
3185fn decode_ddl_alter_drop_column(data: &[u8]) -> Option<(String, String)> {
3186    let (table_name, pos) = decode_ddl_table_name(data)?;
3187    if pos + 4 > data.len() {
3188        return None;
3189    }
3190    let cn_len = u32::from_le_bytes(data[pos..pos + 4].try_into().ok()?) as usize;
3191    if pos + 4 + cn_len > data.len() {
3192        return None;
3193    }
3194    let col_name = std::str::from_utf8(&data[pos + 4..pos + 4 + cn_len])
3195        .ok()?
3196        .to_string();
3197    Some((table_name, col_name))
3198}
3199
3200// ─── Catalog file format ────────────────────────────────────────────────────
3201//
3202// Layout (version 2):
3203//   magic     [4]      = "BCAT"
3204//   version   u16
3205//   n_tables  u32
3206//   for each table:
3207//     table_name_len  u32
3208//     table_name      utf8 bytes
3209//     n_columns       u16
3210//     for each column:
3211//       name_len      u32
3212//       name          utf8 bytes
3213//       type_id       u8
3214//       required      u8
3215//       position      u16
3216//     ── version 2 appends: ──
3217//     n_indexed_cols  u16
3218//     for each indexed column:
3219//       name_len      u32
3220//       name          utf8 bytes
3221//
3222// Version 1 files are accepted by the reader (same shape minus the
3223// trailing indexed-column block) and treated as having zero indexed
3224// columns. Writers always emit version 2 from Mission 3 onwards.
3225
3226/// Per-indexed-column metadata persisted in the catalog file.
3227pub(crate) struct IndexedColMeta {
3228    pub name: String,
3229    pub unique: bool,
3230}
3231
3232/// In-memory catalog entry pairing a schema with its indexed column list.
3233/// Produced by the reader; the writer takes the borrowed counterpart below.
3234pub(crate) struct CatalogEntry {
3235    pub schema: Schema,
3236    pub indexed_cols: Vec<IndexedColMeta>,
3237    pub expression_indexes: Vec<ExpressionIndexMeta>,
3238    /// Per-column defaults aligned to `schema.columns` by position. Empty when
3239    /// no column has a default (v1–v3 files always decode to empty).
3240    pub defaults: Vec<Option<Value>>,
3241    /// Which columns are `auto`, aligned to `schema.columns`. Empty when none
3242    /// (v1–v4 files always decode to empty).
3243    pub auto_cols: Vec<bool>,
3244}
3245
3246/// Borrowed view passed to the writer.
3247pub(crate) struct CatalogEntryRef<'a> {
3248    pub schema: &'a Schema,
3249    pub indexed_cols: Vec<IndexedColMeta>,
3250    pub expression_indexes: Vec<ExpressionIndexMeta>,
3251    pub defaults: &'a [Option<Value>],
3252    pub auto_cols: &'a [bool],
3253}
3254
3255// ─── Column-default codecs (shared by catalog.bin and the WAL DDL record) ────
3256
3257/// Encode a single scalar value: a `type_id` tag byte followed by a
3258/// type-specific, length-prefixed (for variable-width types) payload. Lossless
3259/// — used to persist literal column defaults.
3260fn encode_value_blob(out: &mut Vec<u8>, v: &Value) {
3261    out.push(v.type_id() as u8);
3262    match v {
3263        Value::Int(n) => out.extend_from_slice(&n.to_le_bytes()),
3264        Value::Float(f) => out.extend_from_slice(&f.to_bits().to_le_bytes()),
3265        Value::Bool(b) => out.push(*b as u8),
3266        Value::Str(s) => {
3267            out.extend_from_slice(&(s.len() as u32).to_le_bytes());
3268            out.extend_from_slice(s.as_bytes());
3269        }
3270        Value::DateTime(n) => out.extend_from_slice(&n.to_le_bytes()),
3271        Value::Uuid(u) => out.extend_from_slice(u),
3272        Value::Bytes(b) => {
3273            out.extend_from_slice(&(b.len() as u32).to_le_bytes());
3274            out.extend_from_slice(b);
3275        }
3276        Value::Json(b) => {
3277            out.extend_from_slice(&(b.len() as u32).to_le_bytes());
3278            out.extend_from_slice(b);
3279        }
3280        Value::Empty => {}
3281    }
3282}
3283
3284/// Inverse of [`encode_value_blob`]. Returns `None` on any malformed/truncated
3285/// input so a corrupt record fails closed rather than panicking.
3286fn decode_value_blob(data: &[u8], pos: &mut usize) -> Option<Value> {
3287    let tag = *data.get(*pos)?;
3288    *pos += 1;
3289    let type_id = TypeId::from_u8(tag)?;
3290    let take_fixed = |pos: &mut usize, n: usize| -> Option<Vec<u8>> {
3291        if *pos + n > data.len() {
3292            return None;
3293        }
3294        let slice = data[*pos..*pos + n].to_vec();
3295        *pos += n;
3296        Some(slice)
3297    };
3298    match type_id {
3299        TypeId::Empty => Some(Value::Empty),
3300        TypeId::Int => Some(Value::Int(i64::from_le_bytes(
3301            take_fixed(pos, 8)?.try_into().ok()?,
3302        ))),
3303        TypeId::Float => Some(Value::Float(f64::from_bits(u64::from_le_bytes(
3304            take_fixed(pos, 8)?.try_into().ok()?,
3305        )))),
3306        TypeId::Bool => Some(Value::Bool(take_fixed(pos, 1)?[0] != 0)),
3307        TypeId::DateTime => Some(Value::DateTime(i64::from_le_bytes(
3308            take_fixed(pos, 8)?.try_into().ok()?,
3309        ))),
3310        TypeId::Uuid => Some(Value::Uuid(take_fixed(pos, 16)?.try_into().ok()?)),
3311        TypeId::Str => {
3312            let len = u32::from_le_bytes(take_fixed(pos, 4)?.try_into().ok()?) as usize;
3313            Some(Value::Str(String::from_utf8(take_fixed(pos, len)?).ok()?))
3314        }
3315        TypeId::Bytes => {
3316            let len = u32::from_le_bytes(take_fixed(pos, 4)?.try_into().ok()?) as usize;
3317            Some(Value::Bytes(take_fixed(pos, len)?))
3318        }
3319        TypeId::Json => {
3320            let len = u32::from_le_bytes(take_fixed(pos, 4)?.try_into().ok()?) as usize;
3321            Some(Value::Json(take_fixed(pos, len)?.into()))
3322        }
3323    }
3324}
3325
3326/// Encode the per-table defaults as a sparse list: a `u16` count of columns
3327/// that have a default, then `(position: u16, value blob)` pairs. The common
3328/// "no defaults" case costs two bytes.
3329fn encode_defaults_section(out: &mut Vec<u8>, defaults: &[Option<Value>]) {
3330    let present: Vec<(u16, &Value)> = defaults
3331        .iter()
3332        .enumerate()
3333        .filter_map(|(i, d)| d.as_ref().map(|v| (i as u16, v)))
3334        .collect();
3335    out.extend_from_slice(&(present.len() as u16).to_le_bytes());
3336    for (pos, v) in present {
3337        out.extend_from_slice(&pos.to_le_bytes());
3338        encode_value_blob(out, v);
3339    }
3340}
3341
3342/// Inverse of [`encode_defaults_section`]. Builds a `Vec` of length `n_cols`
3343/// with `None` for columns without a default. Returns `None` on truncation.
3344fn decode_defaults_section(
3345    data: &[u8],
3346    pos: &mut usize,
3347    n_cols: usize,
3348) -> Option<Vec<Option<Value>>> {
3349    if *pos + 2 > data.len() {
3350        return None;
3351    }
3352    let count = u16::from_le_bytes(data[*pos..*pos + 2].try_into().ok()?) as usize;
3353    *pos += 2;
3354    let mut out = vec![None; n_cols];
3355    for _ in 0..count {
3356        if *pos + 2 > data.len() {
3357            return None;
3358        }
3359        let col = u16::from_le_bytes(data[*pos..*pos + 2].try_into().ok()?) as usize;
3360        *pos += 2;
3361        let value = decode_value_blob(data, pos)?;
3362        if col < n_cols {
3363            out[col] = Some(value);
3364        }
3365    }
3366    Some(out)
3367}
3368
3369/// Encode the per-table `auto` columns as a sparse list: a `u16` count of auto
3370/// columns, then their positions (`u16` each). "No auto columns" costs two
3371/// bytes.
3372fn encode_auto_section(out: &mut Vec<u8>, auto_cols: &[bool]) {
3373    let present: Vec<u16> = auto_cols
3374        .iter()
3375        .enumerate()
3376        .filter_map(|(i, &a)| if a { Some(i as u16) } else { None })
3377        .collect();
3378    out.extend_from_slice(&(present.len() as u16).to_le_bytes());
3379    for pos in present {
3380        out.extend_from_slice(&pos.to_le_bytes());
3381    }
3382}
3383
3384/// Inverse of [`encode_auto_section`]. Builds a `bool` vec of length `n_cols`.
3385/// Returns `None` on truncation.
3386fn decode_auto_section(data: &[u8], pos: &mut usize, n_cols: usize) -> Option<Vec<bool>> {
3387    if *pos + 2 > data.len() {
3388        return None;
3389    }
3390    let count = u16::from_le_bytes(data[*pos..*pos + 2].try_into().ok()?) as usize;
3391    *pos += 2;
3392    let mut out = vec![false; n_cols];
3393    for _ in 0..count {
3394        if *pos + 2 > data.len() {
3395            return None;
3396        }
3397        let col = u16::from_le_bytes(data[*pos..*pos + 2].try_into().ok()?) as usize;
3398        *pos += 2;
3399        if col < n_cols {
3400            out[col] = true;
3401        }
3402    }
3403    Some(out)
3404}
3405
3406fn push_catalog_string(out: &mut Vec<u8>, value: &str) -> io::Result<()> {
3407    let len = u32::try_from(value.len())
3408        .map_err(|_| io::Error::new(io::ErrorKind::InvalidInput, "catalog string is too large"))?;
3409    out.extend_from_slice(&len.to_le_bytes());
3410    out.extend_from_slice(value.as_bytes());
3411    Ok(())
3412}
3413
3414fn encode_expression_indexes(out: &mut Vec<u8>, indexes: &[ExpressionIndexMeta]) -> io::Result<()> {
3415    let count = u16::try_from(indexes.len()).map_err(|_| {
3416        io::Error::new(
3417            io::ErrorKind::InvalidInput,
3418            "too many expression indexes on one table",
3419        )
3420    })?;
3421    out.extend_from_slice(&count.to_le_bytes());
3422    for index in indexes {
3423        out.extend_from_slice(&index.index_id.to_le_bytes());
3424        out.push(u8::from(index.unique));
3425        out.extend_from_slice(&index.canonical_version.to_le_bytes());
3426        push_catalog_string(out, &index.canonical_text)?;
3427        push_catalog_string(out, &index.json_path.column)?;
3428        let segment_count = u16::try_from(index.json_path.segments.len()).map_err(|_| {
3429            io::Error::new(
3430                io::ErrorKind::InvalidInput,
3431                "JSON path has too many segments",
3432            )
3433        })?;
3434        out.extend_from_slice(&segment_count.to_le_bytes());
3435        for segment in &index.json_path.segments {
3436            match segment {
3437                StoredJsonPathSegmentV1::Key(key) => {
3438                    out.push(1);
3439                    push_catalog_string(out, key)?;
3440                }
3441                StoredJsonPathSegmentV1::Index(position) => {
3442                    out.push(2);
3443                    out.extend_from_slice(&position.to_le_bytes());
3444                }
3445            }
3446        }
3447    }
3448    Ok(())
3449}
3450
3451fn decode_expression_indexes(data: &[u8], pos: &mut usize) -> io::Result<Vec<ExpressionIndexMeta>> {
3452    let count = read_u16(data, pos)? as usize;
3453    let mut indexes = Vec::with_capacity(count);
3454    for _ in 0..count {
3455        let index_id = read_u64(data, pos)?;
3456        if index_id == 0 {
3457            return Err(io::Error::new(
3458                io::ErrorKind::InvalidData,
3459                "expression index id must be non-zero",
3460            ));
3461        }
3462        let unique = read_u8(data, pos)? != 0;
3463        let canonical_version = read_u16(data, pos)?;
3464        let canonical_len = read_u32(data, pos)? as usize;
3465        let canonical_text = read_string(data, pos, canonical_len)?;
3466        let column_len = read_u32(data, pos)? as usize;
3467        let column = read_string(data, pos, column_len)?;
3468        let segment_count = read_u16(data, pos)? as usize;
3469        let mut segments = Vec::with_capacity(segment_count);
3470        for _ in 0..segment_count {
3471            match read_u8(data, pos)? {
3472                1 => {
3473                    let len = read_u32(data, pos)? as usize;
3474                    segments.push(StoredJsonPathSegmentV1::Key(read_string(data, pos, len)?));
3475                }
3476                2 => segments.push(StoredJsonPathSegmentV1::Index(read_u32(data, pos)?)),
3477                tag => {
3478                    return Err(io::Error::new(
3479                        io::ErrorKind::InvalidData,
3480                        format!("unknown stored JSON path segment tag: {tag}"),
3481                    ));
3482                }
3483            }
3484        }
3485        indexes.push(ExpressionIndexMeta {
3486            index_id,
3487            unique,
3488            canonical_version,
3489            canonical_text,
3490            json_path: StoredJsonPathV1 { column, segments },
3491        });
3492    }
3493    Ok(indexes)
3494}
3495
3496fn write_catalog_file(
3497    path: &Path,
3498    version: u16,
3499    next_index_id: u64,
3500    entries: &[CatalogEntryRef<'_>],
3501) -> io::Result<()> {
3502    if !(1..=CATALOG_VERSION).contains(&version) {
3503        return Err(io::Error::new(
3504            io::ErrorKind::InvalidInput,
3505            format!("unsupported catalog write version: {version}"),
3506        ));
3507    }
3508    let mut buf: Vec<u8> = Vec::with_capacity(64);
3509    buf.extend_from_slice(CATALOG_MAGIC);
3510    buf.extend_from_slice(&version.to_le_bytes());
3511    buf.extend_from_slice(&(entries.len() as u32).to_le_bytes());
3512    if version >= 6 {
3513        buf.extend_from_slice(&next_index_id.to_le_bytes());
3514    }
3515
3516    for entry in entries {
3517        let schema = entry.schema;
3518        let name = schema.table_name.as_bytes();
3519        buf.extend_from_slice(&(name.len() as u32).to_le_bytes());
3520        buf.extend_from_slice(name);
3521        buf.extend_from_slice(&(schema.columns.len() as u16).to_le_bytes());
3522        for col in &schema.columns {
3523            let cn = col.name.as_bytes();
3524            buf.extend_from_slice(&(cn.len() as u32).to_le_bytes());
3525            buf.extend_from_slice(cn);
3526            buf.push(col.type_id as u8);
3527            buf.push(if col.required { 1 } else { 0 });
3528            buf.extend_from_slice(&col.position.to_le_bytes());
3529        }
3530        // Per-table indexed column list with uniqueness flags (version 3).
3531        buf.extend_from_slice(&(entry.indexed_cols.len() as u16).to_le_bytes());
3532        for meta in &entry.indexed_cols {
3533            let cn = meta.name.as_bytes();
3534            buf.extend_from_slice(&(cn.len() as u32).to_le_bytes());
3535            buf.extend_from_slice(cn);
3536            buf.push(if meta.unique { 1 } else { 0 });
3537        }
3538        // Per-table column defaults (version 4).
3539        encode_defaults_section(&mut buf, entry.defaults);
3540        // Per-table auto-increment columns (version 5).
3541        encode_auto_section(&mut buf, entry.auto_cols);
3542        if version >= 6 {
3543            encode_expression_indexes(&mut buf, &entry.expression_indexes)?;
3544        }
3545    }
3546
3547    // Append a CRC32 checksum of the entire payload so the reader can
3548    // detect corruption (the WAL and btree .idx files already do this;
3549    // catalog.bin was the one file missing a checksum).
3550    let crc = crc32fast::hash(&buf);
3551    buf.extend_from_slice(&crc.to_le_bytes());
3552
3553    let mut f = fs::OpenOptions::new()
3554        .create(true)
3555        .write(true)
3556        .truncate(true)
3557        .open(path)?;
3558    f.write_all(&buf)?;
3559    f.sync_data()?;
3560    Ok(())
3561}
3562
3563struct CatalogFile {
3564    version: u16,
3565    next_index_id: u64,
3566    entries: Vec<CatalogEntry>,
3567}
3568
3569fn read_catalog_file(path: &Path) -> io::Result<CatalogFile> {
3570    read_catalog_file_with_max_version(path, CATALOG_VERSION)
3571}
3572
3573/// Read the catalog format version currently persisted on disk for `data_dir`
3574/// without rehydrating tables. This is the database's *active* catalog version:
3575/// a database that has never activated an expression index stays at
3576/// [`LEGACY_CATALOG_VERSION`]. Sync producers use it to stamp published segments
3577/// with the active version rather than this binary's compile-time maximum.
3578pub fn read_active_catalog_version(data_dir: &Path) -> io::Result<u16> {
3579    let cat_path = data_dir.join(CATALOG_FILE);
3580    Ok(read_catalog_file(&cat_path)?.version)
3581}
3582
3583fn read_catalog_file_with_max_version(
3584    path: &Path,
3585    max_supported_version: u16,
3586) -> io::Result<CatalogFile> {
3587    let mut f = fs::File::open(path)?;
3588    let mut buf = Vec::new();
3589    f.read_to_end(&mut buf)?;
3590
3591    let mut pos = 0usize;
3592    // Minimum: 4 (magic) + 2 (version) + 4 (n_tables) + 4 (crc) = 14
3593    if buf.len() < 14 || &buf[0..4] != CATALOG_MAGIC {
3594        return Err(io::Error::new(
3595            io::ErrorKind::InvalidData,
3596            "bad catalog magic",
3597        ));
3598    }
3599
3600    // Verify the trailing CRC32 checksum.
3601    let payload = &buf[..buf.len() - 4];
3602    let stored_crc = u32::from_le_bytes(
3603        buf[buf.len() - 4..]
3604            .try_into()
3605            .map_err(|_| io::Error::new(io::ErrorKind::InvalidData, "truncated catalog CRC"))?,
3606    );
3607    let computed_crc = crc32fast::hash(payload);
3608    if stored_crc != computed_crc {
3609        return Err(io::Error::new(
3610            io::ErrorKind::InvalidData,
3611            format!(
3612                "catalog CRC32 mismatch: expected {stored_crc:#010x}, got {computed_crc:#010x}"
3613            ),
3614        ));
3615    }
3616    // Strip the CRC suffix so the parsing loop below doesn't walk into it.
3617    let buf = &buf[..buf.len() - 4];
3618    pos += 4;
3619    let version = u16::from_le_bytes(
3620        buf[pos..pos + 2]
3621            .try_into()
3622            .map_err(|_| io::Error::new(io::ErrorKind::InvalidData, "truncated catalog header"))?,
3623    );
3624    pos += 2;
3625    // Accept every version from 1 up to the current CATALOG_VERSION: the
3626    // field-reading staircase below fills in fields a newer version added
3627    // (indexed-col uniqueness at v3, defaults at v4, auto columns at v5) and
3628    // defaults them for older files, so any 1..=CATALOG_VERSION file loads.
3629    // A range check (not an enumerated list) is what makes this back-compat
3630    // hold automatically on the next bump — the previous `version != 1 &&
3631    // version != 2 && version != CATALOG_VERSION` form silently rejected the
3632    // intermediate v3/v4 files when the constant moved to 5, which would have
3633    // failed to open a v0.6.x database on upgrade (data loss).
3634    if version == 0 || version > max_supported_version {
3635        return Err(io::Error::new(
3636            io::ErrorKind::InvalidData,
3637            format!("unsupported catalog version: {version}"),
3638        ));
3639    }
3640    let n_tables = u32::from_le_bytes(
3641        buf[pos..pos + 4]
3642            .try_into()
3643            .map_err(|_| io::Error::new(io::ErrorKind::InvalidData, "truncated catalog header"))?,
3644    ) as usize;
3645    pos += 4;
3646    let next_index_id = if version >= 6 {
3647        let id = read_u64(buf, &mut pos)?;
3648        if id == 0 {
3649            return Err(io::Error::new(
3650                io::ErrorKind::InvalidData,
3651                "catalog next index id must be non-zero",
3652            ));
3653        }
3654        id
3655    } else {
3656        1
3657    };
3658
3659    // Don't size an allocation from an unvalidated count: a corrupt or hostile
3660    // catalog could claim billions of tables and make the `Vec::with_capacity`
3661    // below attempt a huge allocation (host abort — fatal in embedded mode). A
3662    // file of `buf.len()` bytes can describe at most that many tables (each
3663    // needs several header bytes), so a larger count is corrupt. Mirrors the
3664    // btree's node-count guard.
3665    if n_tables > buf.len() {
3666        return Err(io::Error::new(
3667            io::ErrorKind::InvalidData,
3668            format!("catalog file corrupt: implausible table count {n_tables}"),
3669        ));
3670    }
3671
3672    let mut entries = Vec::with_capacity(n_tables);
3673    for _ in 0..n_tables {
3674        let name_len = read_u32(buf, &mut pos)? as usize;
3675        let table_name = read_string(buf, &mut pos, name_len)?;
3676        let n_cols = read_u16(buf, &mut pos)? as usize;
3677
3678        let mut columns = Vec::with_capacity(n_cols);
3679        for _ in 0..n_cols {
3680            let cname_len = read_u32(buf, &mut pos)? as usize;
3681            let name = read_string(buf, &mut pos, cname_len)?;
3682            let type_id_raw = read_u8(buf, &mut pos)?;
3683            let type_id = type_id_from_u8(type_id_raw)?;
3684            let required = read_u8(buf, &mut pos)? != 0;
3685            let position = read_u16(buf, &mut pos)?;
3686            columns.push(ColumnDef {
3687                name,
3688                type_id,
3689                required,
3690                position,
3691            });
3692        }
3693
3694        // Version 3 appends indexed column list with uniqueness flag.
3695        // Version 2 has indexed column names without uniqueness (default
3696        // to non-unique). Version 1 has no index info at all.
3697        let indexed_cols: Vec<IndexedColMeta> = if version >= 3 {
3698            let n = read_u16(buf, &mut pos)? as usize;
3699            let mut v = Vec::with_capacity(n);
3700            for _ in 0..n {
3701                let l = read_u32(buf, &mut pos)? as usize;
3702                let name = read_string(buf, &mut pos, l)?;
3703                let unique = read_u8(buf, &mut pos)? != 0;
3704                v.push(IndexedColMeta { name, unique });
3705            }
3706            v
3707        } else if version >= 2 {
3708            let n = read_u16(buf, &mut pos)? as usize;
3709            let mut v = Vec::with_capacity(n);
3710            for _ in 0..n {
3711                let l = read_u32(buf, &mut pos)? as usize;
3712                let name = read_string(buf, &mut pos, l)?;
3713                v.push(IndexedColMeta {
3714                    name,
3715                    unique: false,
3716                });
3717            }
3718            v
3719        } else {
3720            Vec::new()
3721        };
3722
3723        // Version 4 appends a column-defaults section after the index list.
3724        let defaults = if version >= 4 {
3725            decode_defaults_section(buf, &mut pos, columns.len()).ok_or_else(|| {
3726                io::Error::new(io::ErrorKind::InvalidData, "truncated catalog defaults")
3727            })?
3728        } else {
3729            Vec::new()
3730        };
3731
3732        // Version 5 appends an auto-increment column section after that.
3733        let auto_cols = if version >= 5 {
3734            decode_auto_section(buf, &mut pos, columns.len()).ok_or_else(|| {
3735                io::Error::new(io::ErrorKind::InvalidData, "truncated catalog auto columns")
3736            })?
3737        } else {
3738            Vec::new()
3739        };
3740
3741        let expression_indexes = if version >= 6 {
3742            decode_expression_indexes(buf, &mut pos)?
3743        } else {
3744            Vec::new()
3745        };
3746
3747        entries.push(CatalogEntry {
3748            schema: Schema {
3749                table_name,
3750                columns,
3751            },
3752            indexed_cols,
3753            expression_indexes,
3754            defaults,
3755            auto_cols,
3756        });
3757    }
3758
3759    let mut seen_index_ids = FxHashMap::default();
3760    let mut max_index_id = 0;
3761    for entry in &entries {
3762        for index in &entry.expression_indexes {
3763            if index.canonical_version == 0 || index.canonical_text.is_empty() {
3764                return Err(io::Error::new(
3765                    io::ErrorKind::InvalidData,
3766                    "expression index has invalid canonical identity",
3767                ));
3768            }
3769            if index.canonical_version == 1
3770                && index.canonical_text != index.json_path.canonical_text()
3771            {
3772                return Err(io::Error::new(
3773                    io::ErrorKind::InvalidData,
3774                    "expression index canonical identity does not match its JSON path",
3775                ));
3776            }
3777            let Some(root) = entry
3778                .schema
3779                .columns
3780                .iter()
3781                .find(|column| column.name == index.json_path.column)
3782            else {
3783                return Err(io::Error::new(
3784                    io::ErrorKind::InvalidData,
3785                    "expression index JSON root is absent from its table",
3786                ));
3787            };
3788            if root.type_id != TypeId::Json {
3789                return Err(io::Error::new(
3790                    io::ErrorKind::InvalidData,
3791                    "expression index root column is not JSON",
3792                ));
3793            }
3794            if seen_index_ids.insert(index.index_id, ()).is_some() {
3795                return Err(io::Error::new(
3796                    io::ErrorKind::InvalidData,
3797                    "duplicate expression index id in catalog",
3798                ));
3799            }
3800            max_index_id = max_index_id.max(index.index_id);
3801        }
3802    }
3803    if next_index_id <= max_index_id {
3804        return Err(io::Error::new(
3805            io::ErrorKind::InvalidData,
3806            "catalog next index id does not exceed persisted index ids",
3807        ));
3808    }
3809    Ok(CatalogFile {
3810        version,
3811        next_index_id,
3812        entries,
3813    })
3814}
3815
3816fn read_u8(buf: &[u8], pos: &mut usize) -> io::Result<u8> {
3817    if *pos >= buf.len() {
3818        return Err(io::Error::new(
3819            io::ErrorKind::UnexpectedEof,
3820            "truncated catalog",
3821        ));
3822    }
3823    let v = buf[*pos];
3824    *pos += 1;
3825    Ok(v)
3826}
3827fn read_u16(buf: &[u8], pos: &mut usize) -> io::Result<u16> {
3828    if *pos + 2 > buf.len() {
3829        return Err(io::Error::new(
3830            io::ErrorKind::UnexpectedEof,
3831            "truncated catalog",
3832        ));
3833    }
3834    let v = u16::from_le_bytes(
3835        buf[*pos..*pos + 2]
3836            .try_into()
3837            .expect("bounds checked above"),
3838    );
3839    *pos += 2;
3840    Ok(v)
3841}
3842fn read_u32(buf: &[u8], pos: &mut usize) -> io::Result<u32> {
3843    if *pos + 4 > buf.len() {
3844        return Err(io::Error::new(
3845            io::ErrorKind::UnexpectedEof,
3846            "truncated catalog",
3847        ));
3848    }
3849    let v = u32::from_le_bytes(
3850        buf[*pos..*pos + 4]
3851            .try_into()
3852            .expect("bounds checked above"),
3853    );
3854    *pos += 4;
3855    Ok(v)
3856}
3857fn read_u64(buf: &[u8], pos: &mut usize) -> io::Result<u64> {
3858    if *pos + 8 > buf.len() {
3859        return Err(io::Error::new(
3860            io::ErrorKind::UnexpectedEof,
3861            "truncated catalog",
3862        ));
3863    }
3864    let value = u64::from_le_bytes(
3865        buf[*pos..*pos + 8]
3866            .try_into()
3867            .expect("bounds checked above"),
3868    );
3869    *pos += 8;
3870    Ok(value)
3871}
3872fn read_string(buf: &[u8], pos: &mut usize, len: usize) -> io::Result<String> {
3873    if *pos + len > buf.len() {
3874        return Err(io::Error::new(
3875            io::ErrorKind::UnexpectedEof,
3876            "truncated catalog string",
3877        ));
3878    }
3879    let s = std::str::from_utf8(&buf[*pos..*pos + len])
3880        .map_err(|_| io::Error::new(io::ErrorKind::InvalidData, "non-utf8 in catalog"))?
3881        .to_string();
3882    *pos += len;
3883    Ok(s)
3884}
3885fn type_id_from_u8(v: u8) -> io::Result<TypeId> {
3886    match v {
3887        0 => Ok(TypeId::Empty),
3888        1 => Ok(TypeId::Int),
3889        2 => Ok(TypeId::Float),
3890        3 => Ok(TypeId::Bool),
3891        4 => Ok(TypeId::Str),
3892        5 => Ok(TypeId::DateTime),
3893        6 => Ok(TypeId::Uuid),
3894        7 => Ok(TypeId::Bytes),
3895        8 => Ok(TypeId::Json),
3896        _ => Err(io::Error::new(
3897            io::ErrorKind::InvalidData,
3898            format!("unknown type id: {v}"),
3899        )),
3900    }
3901}
3902
3903#[cfg(test)]
3904mod tests {
3905    use super::*;
3906
3907    fn fail_next_catalog_persist_at(stage: u8) {
3908        CATALOG_PERSIST_FAILPOINT.with(|failpoint| failpoint.set(stage));
3909    }
3910
3911    fn temp_catalog(name: &str) -> Catalog {
3912        let dir = std::env::temp_dir().join(format!("powdb_cat_{name}_{}", std::process::id()));
3913        Catalog::create(&dir).unwrap()
3914    }
3915
3916    /// Recursively hash every file's path + bytes under `dir` so a test can
3917    /// assert a read-only open leaves the directory byte-identical. Lock
3918    /// artifacts are not created at the catalog layer (only the engine takes a
3919    /// lock), so nothing needs excluding here.
3920    fn hash_dir_tree(dir: &std::path::Path) -> String {
3921        let mut entries: Vec<std::path::PathBuf> = Vec::new();
3922        fn walk(dir: &std::path::Path, out: &mut Vec<std::path::PathBuf>) {
3923            let mut items: Vec<_> = fs::read_dir(dir).unwrap().flatten().collect();
3924            items.sort_by_key(std::fs::DirEntry::path);
3925            for item in items {
3926                let path = item.path();
3927                if path.is_dir() {
3928                    walk(&path, out);
3929                } else {
3930                    out.push(path);
3931                }
3932            }
3933        }
3934        walk(dir, &mut entries);
3935        let mut hasher = crc32fast::Hasher::new();
3936        for path in &entries {
3937            hasher.update(path.to_string_lossy().as_bytes());
3938            hasher.update(&fs::read(path).unwrap());
3939        }
3940        format!("{:08x}", hasher.finalize())
3941    }
3942
3943    fn seed_quiescent_dir(dir: &std::path::Path) {
3944        let mut catalog = Catalog::create(dir).unwrap();
3945        catalog
3946            .create_table(Schema {
3947                table_name: "User".into(),
3948                columns: vec![
3949                    ColumnDef {
3950                        name: "name".into(),
3951                        type_id: TypeId::Str,
3952                        required: true,
3953                        position: 0,
3954                    },
3955                    ColumnDef {
3956                        name: "age".into(),
3957                        type_id: TypeId::Int,
3958                        required: false,
3959                        position: 1,
3960                    },
3961                ],
3962            })
3963            .unwrap();
3964        catalog.create_index("User", "age").unwrap();
3965        catalog
3966            .insert("User", &vec![Value::Str("Ada".into()), Value::Int(36)])
3967            .unwrap();
3968        catalog
3969            .insert("User", &vec![Value::Str("Bo".into()), Value::Int(20)])
3970            .unwrap();
3971        // Clean drop checkpoints: flush heaps + truncate the WAL, leaving a
3972        // quiescent (WAL-clean) directory.
3973        drop(catalog);
3974    }
3975
3976    #[test]
3977    fn open_read_only_serves_reads_on_clean_dir() {
3978        let dir = tempfile::tempdir().unwrap();
3979        seed_quiescent_dir(dir.path());
3980
3981        let catalog = Catalog::open_read_only(dir.path()).unwrap();
3982        let rows: Vec<_> = catalog.scan("User").unwrap().collect();
3983        assert_eq!(rows.len(), 2);
3984        // Column-index read works read-only.
3985        let hit = catalog
3986            .index_lookup("User", "age", &Value::Int(36))
3987            .unwrap();
3988        assert_eq!(hit.unwrap()[0], Value::Str("Ada".into()));
3989    }
3990
3991    #[test]
3992    fn open_read_only_never_mutates_dir() {
3993        let dir = tempfile::tempdir().unwrap();
3994        seed_quiescent_dir(dir.path());
3995        let before = hash_dir_tree(dir.path());
3996
3997        {
3998            let catalog = Catalog::open_read_only(dir.path()).unwrap();
3999            let _ = catalog.scan("User").unwrap().count();
4000            let _ = catalog
4001                .index_lookup("User", "age", &Value::Int(20))
4002                .unwrap();
4003            // Drop the read-only catalog: must not checkpoint/truncate.
4004        }
4005        let after = hash_dir_tree(dir.path());
4006        assert_eq!(
4007            before, after,
4008            "read-only open + queries + drop must leave the directory byte-identical"
4009        );
4010    }
4011
4012    #[test]
4013    fn open_read_only_refuses_non_empty_wal() {
4014        let dir = tempfile::tempdir().unwrap();
4015        // Seed rows but DO NOT checkpoint: keep the WAL non-empty by forgetting
4016        // the catalog (a crash), so recovery would be required.
4017        {
4018            let mut catalog = Catalog::create(dir.path()).unwrap();
4019            catalog
4020                .create_table(Schema {
4021                    table_name: "T".into(),
4022                    columns: vec![ColumnDef {
4023                        name: "id".into(),
4024                        type_id: TypeId::Int,
4025                        required: true,
4026                        position: 0,
4027                    }],
4028                })
4029                .unwrap();
4030            catalog.insert("T", &vec![Value::Int(1)]).unwrap();
4031            catalog.sync_wal().unwrap();
4032            std::mem::forget(catalog); // leave the WAL non-empty, as a crash would
4033        }
4034        let err = match Catalog::open_read_only(dir.path()) {
4035            Ok(_) => panic!("read-only open must refuse a non-empty WAL"),
4036            Err(err) => err,
4037        };
4038        assert!(
4039            err.to_string().contains("WAL is not empty"),
4040            "expected a WAL-not-empty refusal naming the remedy, got: {err}"
4041        );
4042        assert!(err.to_string().contains("read-write engine"));
4043    }
4044
4045    #[test]
4046    fn open_read_only_expression_index_reads_work() {
4047        let dir = tempfile::tempdir().unwrap();
4048        {
4049            let mut catalog = Catalog::create(dir.path()).unwrap();
4050            catalog
4051                .create_table(Schema {
4052                    table_name: "Doc".into(),
4053                    columns: vec![ColumnDef {
4054                        name: "data".into(),
4055                        type_id: TypeId::Json,
4056                        required: false,
4057                        position: 0,
4058                    }],
4059                })
4060                .unwrap();
4061            let path =
4062                StoredJsonPathV1::new("data", vec![StoredJsonPathSegmentV1::Key("author".into())]);
4063            catalog
4064                .create_expression_index_metadata("Doc", 1, path.canonical_text(), path, false)
4065                .unwrap();
4066            drop(catalog);
4067        }
4068        // Opening read-only must load the expression index without writing.
4069        let before = hash_dir_tree(dir.path());
4070        let catalog = Catalog::open_read_only(dir.path()).unwrap();
4071        assert_eq!(catalog.scan("Doc").unwrap().count(), 0);
4072        drop(catalog);
4073        let after = hash_dir_tree(dir.path());
4074        assert_eq!(
4075            before, after,
4076            "read-only expression-index load must not write"
4077        );
4078    }
4079
4080    #[test]
4081    fn v5_reader_rejects_v6_catalog() {
4082        let dir = tempfile::tempdir().unwrap();
4083        let mut catalog = Catalog::create(dir.path()).unwrap();
4084        catalog
4085            .create_table(Schema {
4086                table_name: "Doc".into(),
4087                columns: vec![ColumnDef {
4088                    name: "data".into(),
4089                    type_id: TypeId::Json,
4090                    required: false,
4091                    position: 0,
4092                }],
4093            })
4094            .unwrap();
4095        let path =
4096            StoredJsonPathV1::new("data", vec![StoredJsonPathSegmentV1::Key("author".into())]);
4097        catalog
4098            .create_expression_index_metadata("Doc", 1, path.canonical_text(), path, false)
4099            .unwrap();
4100        let result = read_catalog_file_with_max_version(
4101            &dir.path().join(CATALOG_FILE),
4102            LEGACY_CATALOG_VERSION,
4103        );
4104        let error = match result {
4105            Ok(_) => panic!("a v5 reader must reject v6 before decoding its payload"),
4106            Err(error) => error,
4107        };
4108        assert!(error.to_string().contains("unsupported catalog version: 6"));
4109    }
4110
4111    #[test]
4112    fn expression_index_rolls_back_only_before_catalog_rename() {
4113        let before_dir = tempfile::tempdir().unwrap();
4114        let mut before = Catalog::create(before_dir.path()).unwrap();
4115        before
4116            .create_table(Schema {
4117                table_name: "Doc".into(),
4118                columns: vec![ColumnDef {
4119                    name: "data".into(),
4120                    type_id: TypeId::Json,
4121                    required: false,
4122                    position: 0,
4123                }],
4124            })
4125            .unwrap();
4126        let path =
4127            StoredJsonPathV1::new("data", vec![StoredJsonPathSegmentV1::Key("score".into())]);
4128
4129        fail_next_catalog_persist_at(1);
4130        let error = before
4131            .create_expression_index_metadata("Doc", 1, path.canonical_text(), path.clone(), false)
4132            .unwrap_err();
4133        assert!(error.to_string().contains("before rename"));
4134        assert_eq!(before.active_catalog_version(), LEGACY_CATALOG_VERSION);
4135        assert_eq!(before.next_index_id(), 1);
4136        assert!(before.expression_index_metadata("Doc").unwrap().is_empty());
4137        assert!(!before_dir
4138            .path()
4139            .join(expression_index_file_name("Doc", 1))
4140            .exists());
4141
4142        let before_index_id = before
4143            .create_expression_index_metadata("Doc", 1, path.canonical_text(), path.clone(), false)
4144            .unwrap();
4145        fail_next_catalog_persist_at(1);
4146        let error = before
4147            .drop_expression_index("Doc", before_index_id)
4148            .unwrap_err();
4149        assert!(error.to_string().contains("before rename"));
4150        assert!(before
4151            .expression_index_btree("Doc", before_index_id)
4152            .is_some());
4153        assert!(before_dir
4154            .path()
4155            .join(expression_index_file_name("Doc", 1))
4156            .exists());
4157        std::mem::forget(before);
4158        let before_reopened = Catalog::open(before_dir.path()).unwrap();
4159        assert!(before_reopened
4160            .expression_index_btree("Doc", before_index_id)
4161            .is_some());
4162
4163        let after_dir = tempfile::tempdir().unwrap();
4164        let mut after = Catalog::create(after_dir.path()).unwrap();
4165        after
4166            .create_table(Schema {
4167                table_name: "Doc".into(),
4168                columns: vec![ColumnDef {
4169                    name: "data".into(),
4170                    type_id: TypeId::Json,
4171                    required: false,
4172                    position: 0,
4173                }],
4174            })
4175            .unwrap();
4176        fail_next_catalog_persist_at(2);
4177        let index_id = after
4178            .create_expression_index_metadata("Doc", 1, path.canonical_text(), path.clone(), false)
4179            .unwrap();
4180        assert_eq!(index_id, 1);
4181        assert_eq!(after.active_catalog_version(), CATALOG_VERSION);
4182        assert_eq!(after.next_index_id(), 2);
4183        assert!(after.expression_index_btree("Doc", index_id).is_some());
4184        assert!(after_dir
4185            .path()
4186            .join(expression_index_file_name("Doc", 1))
4187            .exists());
4188        std::mem::forget(after);
4189
4190        let mut reopened = Catalog::open(after_dir.path()).unwrap();
4191        assert!(reopened.expression_index_btree("Doc", index_id).is_some());
4192        fail_next_catalog_persist_at(2);
4193        reopened.drop_expression_index("Doc", index_id).unwrap();
4194        assert!(reopened
4195            .expression_index_metadata("Doc")
4196            .unwrap()
4197            .is_empty());
4198        assert!(!after_dir
4199            .path()
4200            .join(expression_index_file_name("Doc", 1))
4201            .exists());
4202        std::mem::forget(reopened);
4203
4204        let final_open = Catalog::open(after_dir.path()).unwrap();
4205        assert!(final_open
4206            .expression_index_metadata("Doc")
4207            .unwrap()
4208            .is_empty());
4209    }
4210
4211    #[test]
4212    fn ordinary_catalog_persist_reports_post_rename_directory_sync_failure() {
4213        let dir = tempfile::tempdir().unwrap();
4214        let mut catalog = Catalog::create(dir.path()).unwrap();
4215        fail_next_catalog_persist_at(2);
4216        let error = catalog
4217            .create_table(Schema {
4218                table_name: "VisibleAfterRename".into(),
4219                columns: vec![ColumnDef {
4220                    name: "id".into(),
4221                    type_id: TypeId::Int,
4222                    required: true,
4223                    position: 0,
4224                }],
4225            })
4226            .unwrap_err();
4227        assert!(error.to_string().contains("after rename"));
4228        assert!(catalog.schema("VisibleAfterRename").is_some());
4229
4230        std::mem::forget(catalog);
4231        let reopened = Catalog::open(dir.path()).unwrap();
4232        assert!(reopened.schema("VisibleAfterRename").is_some());
4233    }
4234
4235    fn schema_two_cols() -> Schema {
4236        Schema {
4237            table_name: "T".into(),
4238            columns: vec![
4239                ColumnDef {
4240                    name: "id".into(),
4241                    type_id: TypeId::Int,
4242                    required: true,
4243                    position: 0,
4244                },
4245                ColumnDef {
4246                    name: "status".into(),
4247                    type_id: TypeId::Str,
4248                    required: false,
4249                    position: 1,
4250                },
4251            ],
4252        }
4253    }
4254
4255    #[test]
4256    fn replay_records_treats_reused_tx_ids_as_ordered_spans() {
4257        let mut cat = temp_catalog("reused_tx_ids");
4258        let schema = schema_two_cols();
4259        cat.create_table(schema.clone()).unwrap();
4260        cat.checkpoint().unwrap();
4261
4262        let mut committed_row = Vec::new();
4263        encode_row_into(
4264            &schema,
4265            &[Value::Int(1), Value::Str("committed".into())],
4266            &mut committed_row,
4267        );
4268        let mut incomplete_row = Vec::new();
4269        encode_row_into(
4270            &schema,
4271            &[Value::Int(2), Value::Str("incomplete".into())],
4272            &mut incomplete_row,
4273        );
4274
4275        let records = vec![
4276            WalRecord {
4277                tx_id: 1,
4278                record_type: WalRecordType::Begin,
4279                lsn: 1,
4280                data: Vec::new(),
4281            },
4282            WalRecord {
4283                tx_id: 1,
4284                record_type: WalRecordType::Insert,
4285                lsn: 2,
4286                data: encode_wal_payload(
4287                    "T",
4288                    RowId {
4289                        page_id: 1,
4290                        slot_index: 0,
4291                    },
4292                    &committed_row,
4293                ),
4294            },
4295            WalRecord {
4296                tx_id: 1,
4297                record_type: WalRecordType::Commit,
4298                lsn: 3,
4299                data: Vec::new(),
4300            },
4301            WalRecord {
4302                tx_id: 1,
4303                record_type: WalRecordType::Begin,
4304                lsn: 4,
4305                data: Vec::new(),
4306            },
4307            WalRecord {
4308                tx_id: 1,
4309                record_type: WalRecordType::Insert,
4310                lsn: 5,
4311                data: encode_wal_payload(
4312                    "T",
4313                    RowId {
4314                        page_id: 1,
4315                        slot_index: 1,
4316                    },
4317                    &incomplete_row,
4318                ),
4319            },
4320        ];
4321
4322        cat.apply_wal_records(&records).unwrap();
4323        let rows: Vec<_> = cat.scan("T").unwrap().collect();
4324        assert_eq!(rows.len(), 1);
4325        assert_eq!(rows[0].1[0], Value::Int(1));
4326        assert_eq!(rows[0].1[1], Value::Str("committed".into()));
4327    }
4328
4329    #[test]
4330    fn ddl_create_table_codec_roundtrips_defaults_and_auto() {
4331        let schema = schema_two_cols();
4332        let defaults = vec![None, Some(Value::Str("active".into()))];
4333        let auto_cols = vec![true, false];
4334        let encoded = encode_ddl_create_table(&schema, &defaults, &auto_cols);
4335        let (decoded_schema, decoded_defaults, decoded_auto) =
4336            decode_ddl_create_table(&encoded).unwrap();
4337        assert_eq!(decoded_schema.columns.len(), 2);
4338        assert_eq!(decoded_defaults, defaults);
4339        assert_eq!(decoded_auto, auto_cols);
4340    }
4341
4342    #[test]
4343    fn ddl_create_table_codec_back_compat_without_trailing_sections() {
4344        // Simulate a record written before column defaults / auto existed: the
4345        // old encoder stopped right after the columns, with no trailing
4346        // sections. The new decoder must read those as "none".
4347        let schema = schema_two_cols();
4348        let full = encode_ddl_create_table(&schema, &[], &[]);
4349        // Each empty trailing section is a u16 count of 0 (two bytes); chop
4350        // both off to mimic the pre-feature on-disk shape.
4351        let legacy = &full[..full.len() - 4];
4352        let (decoded_schema, decoded_defaults, decoded_auto) =
4353            decode_ddl_create_table(legacy).unwrap();
4354        assert_eq!(decoded_schema.columns.len(), 2);
4355        assert!(decoded_defaults.is_empty(), "no defaults section -> empty");
4356        assert!(decoded_auto.is_empty(), "no auto section -> empty");
4357    }
4358
4359    #[test]
4360    fn ddl_create_table_codec_back_compat_defaults_but_no_auto() {
4361        // A record from the column-defaults release (#129) has a defaults
4362        // section but no auto section; the auto-aware decoder must still read it.
4363        let schema = schema_two_cols();
4364        let defaults = vec![None, Some(Value::Str("active".into()))];
4365        let full = encode_ddl_create_table(&schema, &defaults, &[]);
4366        // Drop only the trailing auto section (its empty u16 count).
4367        let legacy = &full[..full.len() - 2];
4368        let (_schema, decoded_defaults, decoded_auto) = decode_ddl_create_table(legacy).unwrap();
4369        assert_eq!(decoded_defaults, defaults);
4370        assert!(decoded_auto.is_empty());
4371    }
4372
4373    #[test]
4374    fn read_catalog_file_accepts_intermediate_versions_3_and_4() {
4375        // Regression: the version gate accepted only {1, 2, CATALOG_VERSION}, so
4376        // a catalog written at version 3 (v0.6.x) or 4 (the column-defaults
4377        // release) was rejected with "unsupported catalog version" — the
4378        // database would fail to open on upgrade from those releases = data
4379        // loss. The field-reading staircase already handles v3/v4; only the gate
4380        // was stale. Build faithful v3/v4 catalog files by hand and confirm they
4381        // load (defaults/auto default to empty for the versions that lack them).
4382        use std::io::Write as _;
4383        fn write_legacy_catalog(path: &std::path::Path, version: u16) {
4384            let mut buf: Vec<u8> = Vec::new();
4385            buf.extend_from_slice(CATALOG_MAGIC);
4386            buf.extend_from_slice(&version.to_le_bytes());
4387            buf.extend_from_slice(&1u32.to_le_bytes()); // n_tables
4388                                                        // table "T"
4389            buf.extend_from_slice(&1u32.to_le_bytes());
4390            buf.extend_from_slice(b"T");
4391            buf.extend_from_slice(&2u16.to_le_bytes()); // n_cols
4392                                                        // col id: Int, required, pos 0
4393            buf.extend_from_slice(&2u32.to_le_bytes());
4394            buf.extend_from_slice(b"id");
4395            buf.push(TypeId::Int as u8);
4396            buf.push(1);
4397            buf.extend_from_slice(&0u16.to_le_bytes());
4398            // col status: Str, not required, pos 1
4399            buf.extend_from_slice(&6u32.to_le_bytes());
4400            buf.extend_from_slice(b"status");
4401            buf.push(TypeId::Str as u8);
4402            buf.push(0);
4403            buf.extend_from_slice(&1u16.to_le_bytes());
4404            // version >= 3: indexed-column section (count 0).
4405            buf.extend_from_slice(&0u16.to_le_bytes());
4406            // version >= 4: column-defaults section (none here). v3 omits it.
4407            if version >= 4 {
4408                encode_defaults_section(&mut buf, &[None, None]);
4409            }
4410            // v3/v4 never wrote the v5 auto section.
4411            let crc = crc32fast::hash(&buf);
4412            buf.extend_from_slice(&crc.to_le_bytes());
4413            let mut f = fs::File::create(path).unwrap();
4414            f.write_all(&buf).unwrap();
4415        }
4416
4417        for version in [3u16, 4u16] {
4418            let path = std::env::temp_dir().join(format!(
4419                "powdb_cat_v{version}_compat_{}.bin",
4420                std::process::id()
4421            ));
4422            write_legacy_catalog(&path, version);
4423            let catalog_file = read_catalog_file(&path)
4424                .unwrap_or_else(|e| panic!("version {version} catalog must load, got: {e}"));
4425            let entries = catalog_file.entries;
4426            assert_eq!(entries.len(), 1);
4427            assert_eq!(entries[0].schema.table_name, "T");
4428            assert_eq!(entries[0].schema.columns.len(), 2);
4429            assert!(
4430                entries[0].auto_cols.is_empty(),
4431                "v{version} has no auto cols"
4432            );
4433            fs::remove_file(&path).ok();
4434        }
4435    }
4436
4437    #[test]
4438    fn read_catalog_file_rejects_implausible_table_count() {
4439        // A corrupt/hostile catalog must not be trusted to size an allocation:
4440        // `Vec::with_capacity(n_tables)` on an unvalidated u32 would attempt a
4441        // huge allocation and abort the host. A file can describe at most as
4442        // many tables as it has bytes, so a count exceeding the payload length
4443        // is rejected with a clear error before any allocation. (We use a small
4444        // implausible count over a tiny buffer; a genuinely huge count would
4445        // abort the test runner pre-fix, but it hits the very same guard.)
4446        use std::io::Write as _;
4447        let mut buf: Vec<u8> = Vec::new();
4448        buf.extend_from_slice(CATALOG_MAGIC);
4449        buf.extend_from_slice(&CATALOG_VERSION.to_le_bytes());
4450        buf.extend_from_slice(&1000u32.to_le_bytes()); // claims 1000 tables…
4451        buf.extend_from_slice(&1u64.to_le_bytes()); // valid v6 next-index id
4452                                                    // …but no table data follows.
4453        let crc = crc32fast::hash(&buf);
4454        buf.extend_from_slice(&crc.to_le_bytes());
4455        let path =
4456            std::env::temp_dir().join(format!("powdb_cat_badcount_{}.bin", std::process::id()));
4457        fs::File::create(&path).unwrap().write_all(&buf).unwrap();
4458
4459        let msg = match read_catalog_file(&path) {
4460            Ok(_) => panic!("implausible table count must be rejected, got Ok"),
4461            Err(e) => e.to_string(),
4462        };
4463        assert!(
4464            msg.contains("implausible table count"),
4465            "expected an implausible-table-count error, got: {msg}"
4466        );
4467        fs::remove_file(&path).ok();
4468    }
4469
4470    #[test]
4471    fn data_dir_and_max_lsn_accessors() {
4472        let dir = std::env::temp_dir().join(format!("powdb_cat_maxlsn_{}", std::process::id()));
4473        let mut cat = Catalog::create(&dir).unwrap();
4474
4475        // data_dir() reflects the directory the catalog was created in.
4476        assert_eq!(cat.data_dir(), dir.as_path());
4477
4478        // A fresh catalog has stamped no page LSNs yet.
4479        assert_eq!(cat.max_lsn(), 0);
4480
4481        let schema = Schema {
4482            table_name: "users".into(),
4483            columns: vec![ColumnDef {
4484                name: "name".into(),
4485                type_id: TypeId::Str,
4486                required: true,
4487                position: 0,
4488            }],
4489        };
4490        cat.create_table(schema).unwrap();
4491
4492        cat.insert("users", &vec![Value::Str("Alice".into())])
4493            .unwrap();
4494        cat.sync_wal().unwrap();
4495
4496        // An inserted (and synced) row stamps a page LSN, raising the
4497        // durability high-water mark above zero.
4498        assert!(cat.max_lsn() > 0);
4499    }
4500
4501    #[test]
4502    fn test_create_table_and_insert() {
4503        let mut cat = temp_catalog("basic");
4504        let schema = Schema {
4505            table_name: "users".into(),
4506            columns: vec![
4507                ColumnDef {
4508                    name: "name".into(),
4509                    type_id: TypeId::Str,
4510                    required: true,
4511                    position: 0,
4512                },
4513                ColumnDef {
4514                    name: "age".into(),
4515                    type_id: TypeId::Int,
4516                    required: false,
4517                    position: 1,
4518                },
4519            ],
4520        };
4521        cat.create_table(schema).unwrap();
4522
4523        let row = vec![Value::Str("Alice".into()), Value::Int(30)];
4524        let rid = cat.insert("users", &row).unwrap();
4525
4526        let result = cat.get("users", rid).unwrap();
4527        assert_eq!(result[0], Value::Str("Alice".into()));
4528        assert_eq!(result[1], Value::Int(30));
4529    }
4530
4531    #[test]
4532    fn test_scan_table() {
4533        let mut cat = temp_catalog("scan");
4534        let schema = Schema {
4535            table_name: "items".into(),
4536            columns: vec![
4537                ColumnDef {
4538                    name: "name".into(),
4539                    type_id: TypeId::Str,
4540                    required: true,
4541                    position: 0,
4542                },
4543                ColumnDef {
4544                    name: "price".into(),
4545                    type_id: TypeId::Float,
4546                    required: true,
4547                    position: 1,
4548                },
4549            ],
4550        };
4551        cat.create_table(schema).unwrap();
4552
4553        for i in 0..50 {
4554            cat.insert(
4555                "items",
4556                &vec![
4557                    Value::Str(format!("item_{i}")),
4558                    Value::Float(i as f64 * 1.5),
4559                ],
4560            )
4561            .unwrap();
4562        }
4563
4564        let rows: Vec<_> = cat.scan("items").unwrap().collect();
4565        assert_eq!(rows.len(), 50);
4566    }
4567
4568    #[test]
4569    fn test_index_lookup() {
4570        let mut cat = temp_catalog("idx");
4571        let schema = Schema {
4572            table_name: "users".into(),
4573            columns: vec![
4574                ColumnDef {
4575                    name: "email".into(),
4576                    type_id: TypeId::Str,
4577                    required: true,
4578                    position: 0,
4579                },
4580                ColumnDef {
4581                    name: "name".into(),
4582                    type_id: TypeId::Str,
4583                    required: true,
4584                    position: 1,
4585                },
4586            ],
4587        };
4588        cat.create_table(schema).unwrap();
4589        cat.create_index("users", "email").unwrap();
4590
4591        cat.insert(
4592            "users",
4593            &vec![
4594                Value::Str("alice@example.com".into()),
4595                Value::Str("Alice".into()),
4596            ],
4597        )
4598        .unwrap();
4599        cat.insert(
4600            "users",
4601            &vec![
4602                Value::Str("bob@example.com".into()),
4603                Value::Str("Bob".into()),
4604            ],
4605        )
4606        .unwrap();
4607
4608        let result = cat
4609            .index_lookup("users", "email", &Value::Str("bob@example.com".into()))
4610            .unwrap();
4611        assert!(result.is_some());
4612        let row = result.unwrap();
4613        assert_eq!(row[1], Value::Str("Bob".into()));
4614    }
4615
4616    #[test]
4617    fn test_delete_row() {
4618        let mut cat = temp_catalog("delete");
4619        let schema = Schema {
4620            table_name: "t".into(),
4621            columns: vec![ColumnDef {
4622                name: "v".into(),
4623                type_id: TypeId::Int,
4624                required: true,
4625                position: 0,
4626            }],
4627        };
4628        cat.create_table(schema).unwrap();
4629        let r1 = cat.insert("t", &vec![Value::Int(1)]).unwrap();
4630        let r2 = cat.insert("t", &vec![Value::Int(2)]).unwrap();
4631        cat.delete("t", r1).unwrap();
4632        assert!(cat.get("t", r1).is_none());
4633        assert!(cat.get("t", r2).is_some());
4634    }
4635
4636    #[test]
4637    fn test_update_row() {
4638        let mut cat = temp_catalog("update");
4639        let schema = Schema {
4640            table_name: "t".into(),
4641            columns: vec![ColumnDef {
4642                name: "v".into(),
4643                type_id: TypeId::Int,
4644                required: true,
4645                position: 0,
4646            }],
4647        };
4648        cat.create_table(schema).unwrap();
4649        let rid = cat.insert("t", &vec![Value::Int(1)]).unwrap();
4650        let new_rid = cat.update("t", rid, &vec![Value::Int(99)]).unwrap();
4651        let row = cat.get("t", new_rid).unwrap();
4652        assert_eq!(row[0], Value::Int(99));
4653    }
4654
4655    #[test]
4656    fn test_persist_and_reopen() {
4657        let dir = std::env::temp_dir().join(format!("powdb_cat_persist_{}", std::process::id()));
4658        // Fresh dir
4659        let _ = std::fs::remove_dir_all(&dir);
4660
4661        {
4662            let mut cat = Catalog::create(&dir).unwrap();
4663            cat.create_table(Schema {
4664                table_name: "users".into(),
4665                columns: vec![
4666                    ColumnDef {
4667                        name: "name".into(),
4668                        type_id: TypeId::Str,
4669                        required: true,
4670                        position: 0,
4671                    },
4672                    ColumnDef {
4673                        name: "age".into(),
4674                        type_id: TypeId::Int,
4675                        required: false,
4676                        position: 1,
4677                    },
4678                ],
4679            })
4680            .unwrap();
4681            cat.insert("users", &vec![Value::Str("Alice".into()), Value::Int(30)])
4682                .unwrap();
4683            cat.insert("users", &vec![Value::Str("Bob".into()), Value::Int(25)])
4684                .unwrap();
4685        }
4686
4687        // Reopen — schema and rows should both still be there
4688        let cat = Catalog::open(&dir).unwrap();
4689        let schema = cat.schema("users").unwrap();
4690        assert_eq!(schema.columns.len(), 2);
4691        assert_eq!(schema.columns[0].name, "name");
4692        assert_eq!(schema.columns[0].type_id, TypeId::Str);
4693        assert_eq!(schema.columns[1].type_id, TypeId::Int);
4694
4695        let rows: Vec<_> = cat.scan("users").unwrap().collect();
4696        assert_eq!(rows.len(), 2);
4697
4698        std::fs::remove_dir_all(&dir).ok();
4699    }
4700
4701    #[test]
4702    fn test_open_missing_dir_errors() {
4703        let dir = std::env::temp_dir().join(format!("powdb_cat_missing_{}", std::process::id()));
4704        let _ = std::fs::remove_dir_all(&dir);
4705        std::fs::create_dir_all(&dir).unwrap();
4706        // No catalog.bin yet
4707        assert!(Catalog::open(&dir).is_err());
4708        std::fs::remove_dir_all(&dir).ok();
4709    }
4710
4711    #[test]
4712    fn test_list_tables() {
4713        let mut cat = temp_catalog("list");
4714        cat.create_table(Schema {
4715            table_name: "a".into(),
4716            columns: vec![ColumnDef {
4717                name: "x".into(),
4718                type_id: TypeId::Int,
4719                required: true,
4720                position: 0,
4721            }],
4722        })
4723        .unwrap();
4724        cat.create_table(Schema {
4725            table_name: "b".into(),
4726            columns: vec![ColumnDef {
4727                name: "y".into(),
4728                type_id: TypeId::Int,
4729                required: true,
4730                position: 0,
4731            }],
4732        })
4733        .unwrap();
4734        let mut tables = cat.list_tables();
4735        tables.sort();
4736        assert_eq!(tables, vec!["a", "b"]);
4737    }
4738
4739    #[test]
4740    fn test_path_traversal_table_name_rejected() {
4741        let mut cat = temp_catalog("path_trav");
4742        // Names with path separators must be rejected.
4743        let bad_names = vec![
4744            "../etc/passwd",
4745            "foo/bar",
4746            "table\0name",
4747            "",
4748            "123starts_with_digit",
4749            "has-dashes",
4750            "has spaces",
4751            "has.dots",
4752        ];
4753        for name in bad_names {
4754            let schema = Schema {
4755                table_name: name.into(),
4756                columns: vec![ColumnDef {
4757                    name: "x".into(),
4758                    type_id: TypeId::Int,
4759                    required: true,
4760                    position: 0,
4761                }],
4762            };
4763            let result = cat.create_table(schema);
4764            assert!(result.is_err(), "expected error for table name '{name}'");
4765            assert_eq!(result.unwrap_err().kind(), io::ErrorKind::InvalidInput);
4766        }
4767        // Valid names must still work.
4768        let good_names = vec!["users", "_private", "Table_123", "_"];
4769        for name in good_names {
4770            let schema = Schema {
4771                table_name: name.into(),
4772                columns: vec![ColumnDef {
4773                    name: "x".into(),
4774                    type_id: TypeId::Int,
4775                    required: true,
4776                    position: 0,
4777                }],
4778            };
4779            assert!(
4780                cat.create_table(schema).is_ok(),
4781                "expected ok for table name '{name}'"
4782            );
4783        }
4784    }
4785
4786    #[test]
4787    fn test_path_traversal_column_name_rejected() {
4788        let mut cat = temp_catalog("col_path_trav");
4789        let schema = Schema {
4790            table_name: "valid_table".into(),
4791            columns: vec![ColumnDef {
4792                name: "../bad".into(),
4793                type_id: TypeId::Int,
4794                required: true,
4795                position: 0,
4796            }],
4797        };
4798        let result = cat.create_table(schema);
4799        assert!(result.is_err());
4800        assert_eq!(result.unwrap_err().kind(), io::ErrorKind::InvalidInput);
4801    }
4802
4803    #[test]
4804    fn test_drop_table_validates_name() {
4805        let mut cat = temp_catalog("drop_trav");
4806        let result = cat.drop_table("../etc/passwd");
4807        assert!(result.is_err());
4808        // Should fail with InvalidInput (validation), not NotFound.
4809        assert_eq!(result.unwrap_err().kind(), io::ErrorKind::InvalidInput);
4810    }
4811}