Skip to main content

powdb_storage/
catalog.rs

1use crate::btree::BTree;
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    pub fn expression_index_btree_mut(&mut self, table: &str, index_id: u64) -> Option<&mut BTree> {
2227        self.get_table_mut(table)?
2228            .expression_index_btree_mut(index_id)
2229    }
2230
2231    pub fn expression_index_lookup_all(
2232        &self,
2233        table: &str,
2234        index_id: u64,
2235        key: &Value,
2236    ) -> io::Result<Vec<RowId>> {
2237        let tree = self
2238            .by_name(table)?
2239            .expression_index_btree(index_id)
2240            .ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "expression index not found"))?;
2241        Ok(tree.lookup_all(key))
2242    }
2243
2244    pub fn expression_index_range_rids(
2245        &self,
2246        table: &str,
2247        index_id: u64,
2248        start: Option<&Value>,
2249        end: Option<&Value>,
2250    ) -> io::Result<Vec<RowId>> {
2251        let tree = self
2252            .by_name(table)?
2253            .expression_index_btree(index_id)
2254            .ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "expression index not found"))?;
2255        Ok(tree.raw_range_rids(start, end))
2256    }
2257
2258    pub fn expression_index_ordered_rids(
2259        &self,
2260        table: &str,
2261        index_id: u64,
2262    ) -> io::Result<Vec<RowId>> {
2263        let tree = self
2264            .by_name(table)?
2265            .expression_index_btree(index_id)
2266            .ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "expression index not found"))?;
2267        Ok(tree.ordered_rids_nulls_last())
2268    }
2269
2270    pub fn expression_index_ordered_rids_bounded(
2271        &self,
2272        table: &str,
2273        index_id: u64,
2274        direction: IndexOrderDirection,
2275        offset: usize,
2276        limit: usize,
2277    ) -> io::Result<Vec<RowId>> {
2278        let tree = self
2279            .by_name(table)?
2280            .expression_index_btree(index_id)
2281            .ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "expression index not found"))?;
2282        Ok(tree.bounded_ordered_rids_nulls_last(
2283            direction == IndexOrderDirection::Desc,
2284            offset,
2285            limit,
2286        ))
2287    }
2288
2289    pub fn drop_expression_index(&mut self, table: &str, index_id: u64) -> io::Result<()> {
2290        self.invalidate_structure();
2291        validate_table_name(table)?;
2292        let removed = self
2293            .by_name_mut(table)?
2294            .take_expression_index(index_id)
2295            .ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "expression index not found"))?;
2296        match self.persist_at_activation_boundary() {
2297            Ok(()) => {}
2298            Err(CatalogPersistError::BeforeActivation(error)) => {
2299                self.by_name_mut(table)?.restore_expression_index(removed);
2300                return Err(error);
2301            }
2302            Err(CatalogPersistError::AfterActivation(error)) => {
2303                warn!(
2304                    path = %self.data_dir.display(),
2305                    error = %error,
2306                    "expression index drop committed but catalog directory sync failed"
2307                );
2308            }
2309        }
2310        let index_path = self
2311            .data_dir
2312            .join(expression_index_file_name(table, index_id));
2313        if let Err(error) = fs::remove_file(&index_path) {
2314            if error.kind() != io::ErrorKind::NotFound {
2315                warn!(path = %index_path.display(), error = %error, "failed to remove dropped expression index file");
2316            }
2317        } else if let Err(error) = sync_directory(&self.data_dir) {
2318            warn!(path = %self.data_dir.display(), error = %error, "failed to sync expression index deletion");
2319        }
2320        Ok(())
2321    }
2322
2323    /// Persist expression-index identity and create its backup-compatible
2324    /// `.eidx` file. The catalog stays at v5 until every validation and file
2325    /// creation step succeeds; the v6 catalog rename is the activation point.
2326    pub fn create_expression_index_metadata(
2327        &mut self,
2328        table: &str,
2329        canonical_version: u16,
2330        canonical_text: impl Into<String>,
2331        json_path: StoredJsonPathV1,
2332        unique: bool,
2333    ) -> io::Result<u64> {
2334        self.invalidate_structure();
2335        validate_table_name(table)?;
2336        validate_column_name(&json_path.column)?;
2337        if canonical_version == 0 {
2338            return Err(io::Error::new(
2339                io::ErrorKind::InvalidInput,
2340                "expression canonical version must be non-zero",
2341            ));
2342        }
2343        let canonical_text = canonical_text.into();
2344        if canonical_text.is_empty() {
2345            return Err(io::Error::new(
2346                io::ErrorKind::InvalidInput,
2347                "expression canonical text must not be empty",
2348            ));
2349        }
2350        if canonical_version == 1 && canonical_text != json_path.canonical_text() {
2351            return Err(io::Error::new(
2352                io::ErrorKind::InvalidInput,
2353                "expression canonical text does not match its stored JSON path",
2354            ));
2355        }
2356        let table_ref = self.by_name(table)?;
2357        let root_index = table_ref
2358            .schema
2359            .column_index(&json_path.column)
2360            .ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "JSON root column not found"))?;
2361        if table_ref.schema.columns[root_index].type_id != TypeId::Json {
2362            return Err(io::Error::new(
2363                io::ErrorKind::InvalidInput,
2364                "expression index root column must have type json",
2365            ));
2366        }
2367        if table_ref.expression_index_metas().iter().any(|index| {
2368            index.canonical_version == canonical_version && index.canonical_text == canonical_text
2369        }) {
2370            return Err(io::Error::new(
2371                io::ErrorKind::AlreadyExists,
2372                "expression index already exists",
2373            ));
2374        }
2375
2376        let index_id = self.next_index_id;
2377        let next_index_id = index_id
2378            .checked_add(1)
2379            .ok_or_else(|| io::Error::other("expression index id space exhausted"))?;
2380        let index_path = self
2381            .data_dir
2382            .join(expression_index_file_name(table, index_id));
2383        if index_path.exists() {
2384            // The allocator proves this ID is not referenced by the active
2385            // catalog. A file here can therefore only be an orphan from a
2386            // crash after the index-file fsync but before catalog activation.
2387            fs::remove_file(&index_path)?;
2388            sync_directory(&self.data_dir)?;
2389        }
2390        let meta = ExpressionIndexMeta {
2391            index_id,
2392            unique,
2393            canonical_version,
2394            canonical_text,
2395            json_path,
2396        };
2397        self.by_name_mut(table)?
2398            .install_expression_index(meta, &index_path)?;
2399        if let Err(error) = sync_directory(&self.data_dir) {
2400            self.by_name_mut(table)?
2401                .remove_expression_index_by_id(index_id);
2402            let _ = fs::remove_file(&index_path);
2403            return Err(error);
2404        }
2405
2406        let previous_version = self.active_catalog_version;
2407        let previous_next_id = self.next_index_id;
2408        self.active_catalog_version = CATALOG_VERSION;
2409        self.next_index_id = next_index_id;
2410        match self.persist_at_activation_boundary() {
2411            Ok(()) => {}
2412            Err(CatalogPersistError::BeforeActivation(error)) => {
2413                self.by_name_mut(table)?
2414                    .remove_expression_index_by_id(index_id);
2415                self.active_catalog_version = previous_version;
2416                self.next_index_id = previous_next_id;
2417                let _ = fs::remove_file(&index_path);
2418                let _ = sync_directory(&self.data_dir);
2419                return Err(error);
2420            }
2421            Err(CatalogPersistError::AfterActivation(error)) => {
2422                warn!(
2423                    path = %self.data_dir.display(),
2424                    error = %error,
2425                    "expression index creation committed but catalog directory sync failed"
2426                );
2427            }
2428        }
2429        Ok(index_id)
2430    }
2431
2432    /// Whether `table.column` has a UNIQUE index. Returns `Some(true)` for
2433    /// a unique index, `Some(false)` for a non-unique index, and `None`
2434    /// when the column is not indexed or the table is unknown.
2435    pub fn is_index_unique(&self, table: &str, column: &str) -> Option<bool> {
2436        self.get_table(table)?.is_index_unique(column)
2437    }
2438
2439    /// Whether `table.column` has any index (unique or non-unique).
2440    pub fn has_index(&self, table: &str, column: &str) -> bool {
2441        self.get_table(table)
2442            .map(|t| t.has_index(column))
2443            .unwrap_or(false)
2444    }
2445
2446    pub fn index_lookup(&self, table: &str, column: &str, key: &Value) -> io::Result<Option<Row>> {
2447        Ok(self
2448            .by_name(table)?
2449            .index_lookup(column, key)
2450            .map(|(_, row)| row))
2451    }
2452
2453    pub fn list_tables(&self) -> Vec<&str> {
2454        // Phase 18: iterate the Vec directly — schema.table_name is
2455        // the source of truth, and Vec order is insertion order (more
2456        // deterministic than the old FxHashMap keys).
2457        self.tables
2458            .iter()
2459            .map(|t| t.schema.table_name.as_str())
2460            .collect()
2461    }
2462
2463    pub fn schema(&self, table: &str) -> Option<&Schema> {
2464        let slot = *self.name_to_slot.get(table)?;
2465        Some(&self.tables[slot].schema)
2466    }
2467
2468    /// Drop a table: remove from the catalog and delete its data files.
2469    /// Returns `Err` if the table doesn't exist.
2470    pub fn drop_table(&mut self, name: &str) -> io::Result<()> {
2471        self.invalidate_structure();
2472        validate_table_name(name)?;
2473        let slot = *self.name_to_slot.get(name).ok_or_else(|| {
2474            io::Error::new(io::ErrorKind::NotFound, format!("table '{name}' not found"))
2475        })?;
2476        if !self.wal.is_off() {
2477            let payload = encode_ddl_drop_table(name);
2478            self.wal.append(0, WalRecordType::DdlDropTable, &payload)?;
2479            self.wal.flush()?;
2480        }
2481        // Remove the data file.
2482        let table = &self.tables[slot];
2483        let heap_path = self
2484            .data_dir
2485            .join(format!("{}.heap", table.schema.table_name));
2486        if heap_path.exists() {
2487            fs::remove_file(&heap_path)?;
2488        }
2489        // Mission 3: remove only the .idx files that actually exist
2490        // (i.e. the columns the table currently has indexed). The pre-
2491        // Mission-3 code iterated every schema column blindly — harmless
2492        // but noisy. Now that we persist a real list of indexed columns,
2493        // we can be precise.
2494        for col_name in table.indexed_column_names() {
2495            let idx_path = self.data_dir.join(format!("{name}_{col_name}.idx"));
2496            if idx_path.exists() {
2497                let _ = fs::remove_file(&idx_path);
2498            }
2499        }
2500        let expression_index_ids = table.expression_index_ids();
2501        // Swap-remove from the Vec and fix up name_to_slot.
2502        self.name_to_slot.remove(name);
2503        let last = self.tables.len() - 1;
2504        if slot != last {
2505            let moved_name = self.tables[last].schema.table_name.clone();
2506            self.tables.swap(slot, last);
2507            self.name_to_slot.insert(moved_name, slot);
2508        }
2509        self.tables.pop();
2510        self.persist()?;
2511        for index_id in expression_index_ids {
2512            let idx_path = self
2513                .data_dir
2514                .join(expression_index_file_name(name, index_id));
2515            let _ = fs::remove_file(idx_path);
2516        }
2517        Ok(())
2518    }
2519
2520    /// Add a column to an existing table's schema and backfill all
2521    /// existing rows to match the new shape.
2522    ///
2523    /// Older versions of this method only mutated the in-memory schema
2524    /// and relied on a (false) claim that "the heap format already
2525    /// handles short rows gracefully". It doesn't: `decode_row` reads
2526    /// exactly `n_var + 1` variable-column offsets from the row bytes
2527    /// using the CURRENT schema. Any row encoded with the old schema's
2528    /// (smaller) offset table would walk off the end of its buffer and
2529    /// panic with "range end index X out of range for slice of length Y"
2530    /// — which is exactly what a bare `Type` scan triggered right after
2531    /// an ALTER ADD COLUMN.
2532    ///
2533    /// The fix: rewrite every existing row through
2534    /// `Table::rewrite_rows_for_schema_change` so the on-disk
2535    /// encoding matches the new schema layout. Existing rows get
2536    /// `Value::Empty` for the new column.
2537    ///
2538    /// If the new column is `required` we refuse to add it to a
2539    /// non-empty table — there is no default value to backfill with,
2540    /// and silently storing `Empty` in a required slot would just
2541    /// shift the invariant violation to the next query.
2542    pub fn alter_table_add_column(&mut self, table: &str, col: ColumnDef) -> io::Result<()> {
2543        self.invalidate_structure();
2544        let data_dir = self.data_dir.clone();
2545        {
2546            let tbl = self.by_name_mut(table)?;
2547            if tbl.schema.columns.iter().any(|c| c.name == col.name) {
2548                return Err(io::Error::new(
2549                    io::ErrorKind::AlreadyExists,
2550                    format!("column '{}' already exists in table '{table}'", col.name),
2551                ));
2552            }
2553        }
2554        let barrier_lsn = if !self.wal.is_off() {
2555            let payload = encode_ddl_alter_add_column(table, &col);
2556            self.wal.append(0, WalRecordType::DdlAddColumn, &payload)?;
2557            self.wal.flush()?;
2558            self.wal.last_appended_lsn()
2559        } else {
2560            0
2561        };
2562        let tbl = self.by_name_mut(table)?;
2563
2564        let old_schema = tbl.schema.clone();
2565
2566        // Peek at the heap to learn whether there are any existing
2567        // rows at all. An empty table is always safe to alter — no
2568        // rewrite needed, required columns are fine, etc.
2569        let has_rows = tbl.heap.scan().next().is_some();
2570
2571        if has_rows && col.required {
2572            return Err(io::Error::new(
2573                io::ErrorKind::InvalidInput,
2574                format!(
2575                    "cannot add required column '{}' to non-empty table '{table}': \
2576                     no default value to backfill existing rows with",
2577                    col.name
2578                ),
2579            ));
2580        }
2581
2582        // Commit the new column into the schema and refresh the
2583        // cached layout so the rewrite below encodes with the new
2584        // shape.
2585        tbl.schema.columns.push(col);
2586        tbl.refresh_layout();
2587
2588        if has_rows {
2589            // Build the "fill" template: all Empty, matching the new
2590            // schema width. `rewrite_rows_for_schema_change` will
2591            // overwrite old-column slots from each live row and leave
2592            // the new slot as Empty.
2593            let fill: Vec<Value> = vec![Value::Empty; tbl.schema.columns.len()];
2594            tbl.rewrite_rows_for_schema_change(&old_schema, &fill, &data_dir)?;
2595        }
2596        // P0 fix (v0.4.3): stamp every heap page with the DDL record's
2597        // LSN so any pre-DDL Insert/Update/Delete WAL record gets
2598        // skipped on replay. Without this barrier, a restart after
2599        // `alter add column` would replay pre-alter inserts (encoded in
2600        // the OLD layout) onto a heap that's already in the NEW layout,
2601        // producing a mixed-version heap that panics on the next
2602        // projection. Regression: see `restart_after_alter_add_column_then_index`.
2603        if barrier_lsn > 0 {
2604            tbl.heap.stamp_all_pages_min_lsn(barrier_lsn)?;
2605            tbl.heap.flush()?;
2606        }
2607
2608        self.persist()?;
2609        Ok(())
2610    }
2611
2612    /// Remove a column from an existing table's schema and rewrite
2613    /// every live row to match the new shape.
2614    ///
2615    /// Older versions of this method only mutated the in-memory schema
2616    /// and claimed that "reads simply won't decode the dropped column".
2617    /// That was wrong in several ways:
2618    ///
2619    ///   1. The null bitmap is indexed by column position. Dropping a
2620    ///      column shifts every later column's bit left, but old rows
2621    ///      still have bits in the original positions — so `is_null`
2622    ///      checks silently lie for every column after the dropped one.
2623    ///   2. The bitmap's byte width (`ceil(n_cols/8)`) can shrink when
2624    ///      `n_cols` crosses an 8-boundary, shifting every subsequent
2625    ///      byte of the row against the decoder's cursor.
2626    ///   3. Fixed-region size and the variable-offset-table width both
2627    ///      depend on the column set, so dropping any fixed or variable
2628    ///      column slides every following byte.
2629    ///
2630    /// The fix mirrors `alter_table_add_column`: snapshot the old
2631    /// schema, mutate to the new schema, then rewrite every row
2632    /// through `Table::rewrite_rows_for_schema_change`. Dropping a
2633    /// column from an empty table skips the rewrite.
2634    pub fn alter_table_drop_column(&mut self, table: &str, col_name: &str) -> io::Result<()> {
2635        self.invalidate_structure();
2636        let data_dir = self.data_dir.clone();
2637        {
2638            let tbl = self.by_name_mut(table)?;
2639            tbl.schema
2640                .columns
2641                .iter()
2642                .position(|c| c.name == col_name)
2643                .ok_or_else(|| {
2644                    io::Error::new(
2645                        io::ErrorKind::NotFound,
2646                        format!("column '{col_name}' not found in table '{table}'"),
2647                    )
2648                })?;
2649        }
2650        let removed_expression_index_ids = self
2651            .by_name_mut(table)?
2652            .remove_expression_indexes_for_root(col_name);
2653        let barrier_lsn = if !self.wal.is_off() {
2654            let payload = encode_ddl_alter_drop_column(table, col_name);
2655            self.wal.append(0, WalRecordType::DdlDropColumn, &payload)?;
2656            self.wal.flush()?;
2657            self.wal.last_appended_lsn()
2658        } else {
2659            0
2660        };
2661        let tbl = self.by_name_mut(table)?;
2662        let idx = tbl
2663            .schema
2664            .columns
2665            .iter()
2666            .position(|c| c.name == col_name)
2667            .ok_or_else(|| {
2668                io::Error::new(
2669                    io::ErrorKind::NotFound,
2670                    format!("column '{col_name}' not found in table '{table}'"),
2671                )
2672            })?;
2673
2674        // Snapshot for decoding old rows.
2675        let old_schema = tbl.schema.clone();
2676        let has_rows = tbl.heap.scan().next().is_some();
2677
2678        // Commit the schema change.
2679        tbl.schema.columns.remove(idx);
2680        for (i, col) in tbl.schema.columns.iter_mut().enumerate() {
2681            col.position = i as u16;
2682        }
2683        tbl.refresh_layout();
2684
2685        if has_rows {
2686            // Build a filler matching the new (smaller) shape. The
2687            // rewrite path overwrites each new-column slot from the
2688            // matching old-column value by name, so the filler only
2689            // matters for brand-new columns — drop has none, so
2690            // `Empty` is a safe placeholder that never gets read.
2691            let fill: Vec<Value> = vec![Value::Empty; tbl.schema.columns.len()];
2692            tbl.rewrite_rows_for_schema_change(&old_schema, &fill, &data_dir)?;
2693        }
2694        // P0 fix: see matching comment in alter_table_add_column.
2695        if barrier_lsn > 0 {
2696            tbl.heap.stamp_all_pages_min_lsn(barrier_lsn)?;
2697            tbl.heap.flush()?;
2698        }
2699
2700        self.persist()?;
2701        for index_id in removed_expression_index_ids {
2702            let idx_path = self
2703                .data_dir
2704                .join(expression_index_file_name(table, index_id));
2705            let _ = fs::remove_file(idx_path);
2706        }
2707        Ok(())
2708    }
2709}
2710
2711impl Drop for Catalog {
2712    fn drop(&mut self) {
2713        // A read-only snapshot handle never wrote anything and holds read-only
2714        // file descriptors; checkpointing would try to flush pages and truncate
2715        // the WAL, mutating a directory that must stay byte-identical.
2716        if self.read_only {
2717            return;
2718        }
2719        if self.active_tx_id.is_some() {
2720            if let Err(e) = self.abandon_active_transaction_for_drop() {
2721                warn!(error = %e, "catalog drop active transaction cleanup failed");
2722            }
2723            return;
2724        }
2725        // Mission 2: best-effort clean shutdown. `checkpoint` flushes
2726        // every heap and truncates the WAL, which is what
2727        // [`Catalog::open`] relies on to know that no replay is needed.
2728        //
2729        // We swallow errors here because Rust's `Drop` can't propagate
2730        // them and panicking during unwind is always a bigger problem
2731        // than a failed flush. The worst case on a failed drop-time
2732        // checkpoint is that the next open sees a non-empty WAL and
2733        // replays it (potentially producing duplicates — see the
2734        // [`Self::replay_wal`] caveat). That's strictly better than
2735        // losing committed writes.
2736        if let Err(e) = self.checkpoint() {
2737            warn!(error = %e, "catalog drop checkpoint failed");
2738        }
2739    }
2740}
2741
2742// ─── WAL payload codec ─────────────────────────────────────────────────────
2743//
2744// Per-record payload layout (little-endian):
2745//
2746//   table_name_len : u32
2747//   table_name     : utf-8 bytes
2748//   page_id        : u32   (for insert: 0, ignored on replay)
2749//   slot_index     : u16   (for insert: 0, ignored on replay)
2750//   row_len        : u32
2751//   row_bytes      : raw encoded row (length = row_len)
2752//
2753// Lives next to `Catalog` because this is the only code that produces or
2754// consumes these records — the `Wal` itself is payload-agnostic.
2755
2756fn encode_wal_payload(table: &str, rid: RowId, row_bytes: &[u8]) -> Vec<u8> {
2757    let name = table.as_bytes();
2758    let mut out = Vec::with_capacity(4 + name.len() + 4 + 2 + 4 + row_bytes.len());
2759    out.extend_from_slice(&(name.len() as u32).to_le_bytes());
2760    out.extend_from_slice(name);
2761    out.extend_from_slice(&rid.page_id.to_le_bytes());
2762    out.extend_from_slice(&rid.slot_index.to_le_bytes());
2763    out.extend_from_slice(&(row_bytes.len() as u32).to_le_bytes());
2764    out.extend_from_slice(row_bytes);
2765    out
2766}
2767
2768fn decode_wal_payload(data: &[u8]) -> Option<(String, RowId, Vec<u8>)> {
2769    let mut pos = 0usize;
2770    if data.len() < 4 {
2771        return None;
2772    }
2773    let name_len = u32::from_le_bytes(data[pos..pos + 4].try_into().ok()?) as usize;
2774    pos += 4;
2775    if pos + name_len > data.len() {
2776        return None;
2777    }
2778    let name = std::str::from_utf8(&data[pos..pos + name_len])
2779        .ok()?
2780        .to_string();
2781    pos += name_len;
2782    if pos + 4 + 2 + 4 > data.len() {
2783        return None;
2784    }
2785    let page_id = u32::from_le_bytes(data[pos..pos + 4].try_into().ok()?);
2786    pos += 4;
2787    let slot_index = u16::from_le_bytes(data[pos..pos + 2].try_into().ok()?);
2788    pos += 2;
2789    let row_len = u32::from_le_bytes(data[pos..pos + 4].try_into().ok()?) as usize;
2790    pos += 4;
2791    if pos + row_len > data.len() {
2792        return None;
2793    }
2794    let row_bytes = data[pos..pos + row_len].to_vec();
2795    Some((
2796        name,
2797        RowId {
2798            page_id,
2799            slot_index,
2800        },
2801        row_bytes,
2802    ))
2803}
2804
2805/// Write one out-of-line value's overflow chain to the heap (head-first,
2806/// singly linked) and log each chunk as a `WalRecordType::OverflowWrite`
2807/// record under `tx_id`, ordered BEFORE the row's Insert/Update record so the
2808/// stub the row carries always points at logged, replayable pages. Returns the
2809/// stub (u64 length, head page, whole-value CRC32). Enforces `MAX_VALUE_SIZE`.
2810fn write_overflow_chain_logged(
2811    heap: &mut HeapFile,
2812    wal: &mut Wal,
2813    table: &str,
2814    tx_id: u64,
2815    value: &[u8],
2816) -> io::Result<OverflowStub> {
2817    if value.len() > MAX_VALUE_SIZE {
2818        return Err(StorageError::ValueTooLarge {
2819            size: value.len(),
2820            max: MAX_VALUE_SIZE,
2821        }
2822        .into());
2823    }
2824    let n = value.len().div_ceil(OVERFLOW_PAYLOAD_CAP).max(1);
2825    let mut pages = Vec::with_capacity(n);
2826    for _ in 0..n {
2827        pages.push(heap.allocate_overflow_page()?);
2828    }
2829    for i in 0..n {
2830        let start = i * OVERFLOW_PAYLOAD_CAP;
2831        let end = (start + OVERFLOW_PAYLOAD_CAP).min(value.len());
2832        let chunk = &value[start..end];
2833        let next = if i + 1 < n {
2834            pages[i + 1]
2835        } else {
2836            OVERFLOW_CHAIN_END
2837        };
2838        let payload = encode_overflow_write_payload(table, pages[i], next, chunk);
2839        wal.append(tx_id, WalRecordType::OverflowWrite, &payload)?;
2840        let lsn = wal.last_appended_lsn();
2841        heap.write_overflow_page(pages[i], next, chunk, lsn)?;
2842    }
2843    Ok(OverflowStub::new(
2844        value.len() as u64,
2845        pages[0],
2846        crc32fast::hash(value),
2847    ))
2848}
2849
2850/// Spill-aware encode for the WAL path. If the row fits inline, returns its v1
2851/// bytes untouched. Otherwise writes each spilled value's chain (with WAL
2852/// logging under `tx_id`) and returns the v2 stub-row bytes to be inserted and
2853/// logged in the row's Insert/Update record.
2854fn encode_row_with_spill_logged(
2855    tbl: &mut Table,
2856    wal: &mut Wal,
2857    tx_id: u64,
2858    values: &Row,
2859) -> io::Result<Vec<u8>> {
2860    // Size the v1 encoding WITHOUT encoding it (a >64KB value would panic the
2861    // debug-mode v1 encoder). Only actually encode v1 when the row fits inline.
2862    let v1_len = crate::row::v1_encoded_len(tbl.row_layout(), values);
2863    let is_indexed = tbl.indexed_col_mask();
2864    let chosen = plan_spill(tbl.row_layout(), values, v1_len, &is_indexed);
2865    if chosen.is_empty() {
2866        let mut v1 = Vec::new();
2867        encode_row_into(&tbl.schema, values, &mut v1);
2868        return Ok(v1);
2869    }
2870    let table_name = tbl.schema.table_name.clone();
2871    let n_var = tbl.row_layout().n_var();
2872    let mut spilled: Vec<Option<OverflowStub>> = vec![None; n_var];
2873    for col_idx in chosen {
2874        let var_idx = tbl
2875            .row_layout()
2876            .var_index(col_idx)
2877            .expect("plan_spill only returns var columns");
2878        let bytes: Vec<u8> = match &values[col_idx] {
2879            Value::Str(s) => s.as_bytes().to_vec(),
2880            Value::Bytes(b) => b.to_vec(),
2881            Value::Json(b) => b.to_vec(),
2882            _ => continue,
2883        };
2884        let stub = write_overflow_chain_logged(&mut tbl.heap, wal, &table_name, tx_id, &bytes)?;
2885        spilled[var_idx] = Some(stub);
2886    }
2887    let mut out = Vec::new();
2888    encode_row_v2_into(&tbl.schema, tbl.row_layout(), values, &spilled, &mut out);
2889    Ok(out)
2890}
2891
2892/// `OverflowWrite` payload: `table_len u16 | table | page_id u32 |
2893/// next_page u32 | chunk_len u16 | chunk bytes`.
2894///
2895/// NOTE (deviation from design 3.5): the design lists the payload as
2896/// `page_id | next_page | chunk_len | chunk`, but overflow pages live in
2897/// per-table heap files with independent page-id spaces, so replay needs the
2898/// table identity to route the write. The table name is length-prefixed
2899/// exactly like [`encode_wal_payload`]. The chunk-level fields are unchanged.
2900fn encode_overflow_write_payload(
2901    table: &str,
2902    page_id: u32,
2903    next_page: u32,
2904    chunk: &[u8],
2905) -> Vec<u8> {
2906    let name = table.as_bytes();
2907    let mut out = Vec::with_capacity(2 + name.len() + 4 + 4 + 2 + chunk.len());
2908    out.extend_from_slice(&(name.len() as u16).to_le_bytes());
2909    out.extend_from_slice(name);
2910    out.extend_from_slice(&page_id.to_le_bytes());
2911    out.extend_from_slice(&next_page.to_le_bytes());
2912    out.extend_from_slice(&(chunk.len() as u16).to_le_bytes());
2913    out.extend_from_slice(chunk);
2914    out
2915}
2916
2917fn decode_overflow_write_payload(data: &[u8]) -> Option<(String, u32, u32, Vec<u8>)> {
2918    let mut pos = 0usize;
2919    if data.len() < 2 {
2920        return None;
2921    }
2922    let name_len = u16::from_le_bytes(data[pos..pos + 2].try_into().ok()?) as usize;
2923    pos += 2;
2924    if pos + name_len + 4 + 4 + 2 > data.len() {
2925        return None;
2926    }
2927    let name = std::str::from_utf8(&data[pos..pos + name_len])
2928        .ok()?
2929        .to_string();
2930    pos += name_len;
2931    let page_id = u32::from_le_bytes(data[pos..pos + 4].try_into().ok()?);
2932    pos += 4;
2933    let next_page = u32::from_le_bytes(data[pos..pos + 4].try_into().ok()?);
2934    pos += 4;
2935    let chunk_len = u16::from_le_bytes(data[pos..pos + 2].try_into().ok()?) as usize;
2936    pos += 2;
2937    if pos + chunk_len > data.len() {
2938        return None;
2939    }
2940    Some((
2941        name,
2942        page_id,
2943        next_page,
2944        data[pos..pos + chunk_len].to_vec(),
2945    ))
2946}
2947
2948/// `OverflowFree` payload: `table_len u16 | table | count u32 |
2949/// page_id u32 x count`. Table name added for the same routing reason as
2950/// [`encode_overflow_write_payload`].
2951fn encode_overflow_free_payload(table: &str, pages: &[u32]) -> Vec<u8> {
2952    let name = table.as_bytes();
2953    let mut out = Vec::with_capacity(2 + name.len() + 4 + pages.len() * 4);
2954    out.extend_from_slice(&(name.len() as u16).to_le_bytes());
2955    out.extend_from_slice(name);
2956    out.extend_from_slice(&(pages.len() as u32).to_le_bytes());
2957    for p in pages {
2958        out.extend_from_slice(&p.to_le_bytes());
2959    }
2960    out
2961}
2962
2963fn decode_overflow_free_payload(data: &[u8]) -> Option<(String, Vec<u32>)> {
2964    let mut pos = 0usize;
2965    if data.len() < 2 {
2966        return None;
2967    }
2968    let name_len = u16::from_le_bytes(data[pos..pos + 2].try_into().ok()?) as usize;
2969    pos += 2;
2970    if pos + name_len + 4 > data.len() {
2971        return None;
2972    }
2973    let name = std::str::from_utf8(&data[pos..pos + name_len])
2974        .ok()?
2975        .to_string();
2976    pos += name_len;
2977    let count = u32::from_le_bytes(data[pos..pos + 4].try_into().ok()?) as usize;
2978    pos += 4;
2979    if pos + count * 4 > data.len() {
2980        return None;
2981    }
2982    let mut pages = Vec::with_capacity(count);
2983    for _ in 0..count {
2984        pages.push(u32::from_le_bytes(data[pos..pos + 4].try_into().ok()?));
2985        pos += 4;
2986    }
2987    Some((name, pages))
2988}
2989
2990// ─── DDL WAL payload codecs ─────────────────────────────────────────────────
2991
2992fn encode_ddl_create_table(
2993    schema: &Schema,
2994    defaults: &[Option<Value>],
2995    auto_cols: &[bool],
2996) -> Vec<u8> {
2997    let name = schema.table_name.as_bytes();
2998    let mut out = Vec::new();
2999    out.extend_from_slice(&(name.len() as u32).to_le_bytes());
3000    out.extend_from_slice(name);
3001    out.extend_from_slice(&(schema.columns.len() as u16).to_le_bytes());
3002    for col in &schema.columns {
3003        let cn = col.name.as_bytes();
3004        out.extend_from_slice(&(cn.len() as u32).to_le_bytes());
3005        out.extend_from_slice(cn);
3006        out.push(col.type_id as u8);
3007        out.push(col.required as u8);
3008        out.extend_from_slice(&col.position.to_le_bytes());
3009    }
3010    // Trailing sections. Records written before each feature existed simply
3011    // lack the corresponding trailing bytes, so the decoder treats their
3012    // absence as "none" (length-detected, append-only).
3013    encode_defaults_section(&mut out, defaults);
3014    encode_auto_section(&mut out, auto_cols);
3015    out
3016}
3017
3018fn decode_ddl_create_table(data: &[u8]) -> Option<(Schema, Vec<Option<Value>>, Vec<bool>)> {
3019    let mut pos = 0usize;
3020    if data.len() < 4 {
3021        return None;
3022    }
3023    let name_len = u32::from_le_bytes(data[pos..pos + 4].try_into().ok()?) as usize;
3024    pos += 4;
3025    if pos + name_len > data.len() {
3026        return None;
3027    }
3028    let table_name = std::str::from_utf8(&data[pos..pos + name_len])
3029        .ok()?
3030        .to_string();
3031    pos += name_len;
3032    if pos + 2 > data.len() {
3033        return None;
3034    }
3035    let n_cols = u16::from_le_bytes(data[pos..pos + 2].try_into().ok()?) as usize;
3036    pos += 2;
3037    let mut columns = Vec::with_capacity(n_cols);
3038    for _ in 0..n_cols {
3039        if pos + 4 > data.len() {
3040            return None;
3041        }
3042        let cn_len = u32::from_le_bytes(data[pos..pos + 4].try_into().ok()?) as usize;
3043        pos += 4;
3044        if pos + cn_len + 4 > data.len() {
3045            return None;
3046        }
3047        let col_name = std::str::from_utf8(&data[pos..pos + cn_len])
3048            .ok()?
3049            .to_string();
3050        pos += cn_len;
3051        let type_id = TypeId::from_u8(data[pos])?;
3052        pos += 1;
3053        let required = data[pos] != 0;
3054        pos += 1;
3055        if pos + 2 > data.len() {
3056            return None;
3057        }
3058        let position = u16::from_le_bytes(data[pos..pos + 2].try_into().ok()?);
3059        pos += 2;
3060        columns.push(ColumnDef {
3061            name: col_name,
3062            type_id,
3063            required,
3064            position,
3065        });
3066    }
3067    // Trailing sections are present on records written after each feature
3068    // landed; older records end early, decoding to "none".
3069    let defaults = if pos < data.len() {
3070        decode_defaults_section(data, &mut pos, columns.len())?
3071    } else {
3072        Vec::new()
3073    };
3074    let auto_cols = if pos < data.len() {
3075        decode_auto_section(data, &mut pos, columns.len())?
3076    } else {
3077        Vec::new()
3078    };
3079    Some((
3080        Schema {
3081            table_name,
3082            columns,
3083        },
3084        defaults,
3085        auto_cols,
3086    ))
3087}
3088
3089fn encode_ddl_drop_table(table_name: &str) -> Vec<u8> {
3090    let name = table_name.as_bytes();
3091    let mut out = Vec::with_capacity(4 + name.len());
3092    out.extend_from_slice(&(name.len() as u32).to_le_bytes());
3093    out.extend_from_slice(name);
3094    out
3095}
3096
3097fn encode_ddl_alter_add_column(table_name: &str, col: &ColumnDef) -> Vec<u8> {
3098    let name = table_name.as_bytes();
3099    let cn = col.name.as_bytes();
3100    let mut out = Vec::with_capacity(4 + name.len() + 4 + cn.len() + 4);
3101    out.extend_from_slice(&(name.len() as u32).to_le_bytes());
3102    out.extend_from_slice(name);
3103    out.extend_from_slice(&(cn.len() as u32).to_le_bytes());
3104    out.extend_from_slice(cn);
3105    out.push(col.type_id as u8);
3106    out.push(col.required as u8);
3107    out.extend_from_slice(&col.position.to_le_bytes());
3108    out
3109}
3110
3111fn encode_ddl_alter_drop_column(table_name: &str, col_name: &str) -> Vec<u8> {
3112    let name = table_name.as_bytes();
3113    let cn = col_name.as_bytes();
3114    let mut out = Vec::with_capacity(4 + name.len() + 4 + cn.len());
3115    out.extend_from_slice(&(name.len() as u32).to_le_bytes());
3116    out.extend_from_slice(name);
3117    out.extend_from_slice(&(cn.len() as u32).to_le_bytes());
3118    out.extend_from_slice(cn);
3119    out
3120}
3121
3122fn decode_ddl_table_name(data: &[u8]) -> Option<(String, usize)> {
3123    if data.len() < 4 {
3124        return None;
3125    }
3126    let name_len = u32::from_le_bytes(data[0..4].try_into().ok()?) as usize;
3127    if 4 + name_len > data.len() {
3128        return None;
3129    }
3130    let name = std::str::from_utf8(&data[4..4 + name_len])
3131        .ok()?
3132        .to_string();
3133    Some((name, 4 + name_len))
3134}
3135
3136fn decode_ddl_alter_add_column(data: &[u8]) -> Option<(String, ColumnDef)> {
3137    let (table_name, mut pos) = decode_ddl_table_name(data)?;
3138    if pos + 4 > data.len() {
3139        return None;
3140    }
3141    let cn_len = u32::from_le_bytes(data[pos..pos + 4].try_into().ok()?) as usize;
3142    pos += 4;
3143    if pos + cn_len + 4 > data.len() {
3144        return None;
3145    }
3146    let col_name = std::str::from_utf8(&data[pos..pos + cn_len])
3147        .ok()?
3148        .to_string();
3149    pos += cn_len;
3150    let type_id = TypeId::from_u8(data[pos])?;
3151    pos += 1;
3152    let required = data[pos] != 0;
3153    pos += 1;
3154    if pos + 2 > data.len() {
3155        return None;
3156    }
3157    let position = u16::from_le_bytes(data[pos..pos + 2].try_into().ok()?);
3158    Some((
3159        table_name,
3160        ColumnDef {
3161            name: col_name,
3162            type_id,
3163            required,
3164            position,
3165        },
3166    ))
3167}
3168
3169fn decode_ddl_alter_drop_column(data: &[u8]) -> Option<(String, String)> {
3170    let (table_name, pos) = decode_ddl_table_name(data)?;
3171    if pos + 4 > data.len() {
3172        return None;
3173    }
3174    let cn_len = u32::from_le_bytes(data[pos..pos + 4].try_into().ok()?) as usize;
3175    if pos + 4 + cn_len > data.len() {
3176        return None;
3177    }
3178    let col_name = std::str::from_utf8(&data[pos + 4..pos + 4 + cn_len])
3179        .ok()?
3180        .to_string();
3181    Some((table_name, col_name))
3182}
3183
3184// ─── Catalog file format ────────────────────────────────────────────────────
3185//
3186// Layout (version 2):
3187//   magic     [4]      = "BCAT"
3188//   version   u16
3189//   n_tables  u32
3190//   for each table:
3191//     table_name_len  u32
3192//     table_name      utf8 bytes
3193//     n_columns       u16
3194//     for each column:
3195//       name_len      u32
3196//       name          utf8 bytes
3197//       type_id       u8
3198//       required      u8
3199//       position      u16
3200//     ── version 2 appends: ──
3201//     n_indexed_cols  u16
3202//     for each indexed column:
3203//       name_len      u32
3204//       name          utf8 bytes
3205//
3206// Version 1 files are accepted by the reader (same shape minus the
3207// trailing indexed-column block) and treated as having zero indexed
3208// columns. Writers always emit version 2 from Mission 3 onwards.
3209
3210/// Per-indexed-column metadata persisted in the catalog file.
3211pub(crate) struct IndexedColMeta {
3212    pub name: String,
3213    pub unique: bool,
3214}
3215
3216/// In-memory catalog entry pairing a schema with its indexed column list.
3217/// Produced by the reader; the writer takes the borrowed counterpart below.
3218pub(crate) struct CatalogEntry {
3219    pub schema: Schema,
3220    pub indexed_cols: Vec<IndexedColMeta>,
3221    pub expression_indexes: Vec<ExpressionIndexMeta>,
3222    /// Per-column defaults aligned to `schema.columns` by position. Empty when
3223    /// no column has a default (v1–v3 files always decode to empty).
3224    pub defaults: Vec<Option<Value>>,
3225    /// Which columns are `auto`, aligned to `schema.columns`. Empty when none
3226    /// (v1–v4 files always decode to empty).
3227    pub auto_cols: Vec<bool>,
3228}
3229
3230/// Borrowed view passed to the writer.
3231pub(crate) struct CatalogEntryRef<'a> {
3232    pub schema: &'a Schema,
3233    pub indexed_cols: Vec<IndexedColMeta>,
3234    pub expression_indexes: Vec<ExpressionIndexMeta>,
3235    pub defaults: &'a [Option<Value>],
3236    pub auto_cols: &'a [bool],
3237}
3238
3239// ─── Column-default codecs (shared by catalog.bin and the WAL DDL record) ────
3240
3241/// Encode a single scalar value: a `type_id` tag byte followed by a
3242/// type-specific, length-prefixed (for variable-width types) payload. Lossless
3243/// — used to persist literal column defaults.
3244fn encode_value_blob(out: &mut Vec<u8>, v: &Value) {
3245    out.push(v.type_id() as u8);
3246    match v {
3247        Value::Int(n) => out.extend_from_slice(&n.to_le_bytes()),
3248        Value::Float(f) => out.extend_from_slice(&f.to_bits().to_le_bytes()),
3249        Value::Bool(b) => out.push(*b as u8),
3250        Value::Str(s) => {
3251            out.extend_from_slice(&(s.len() as u32).to_le_bytes());
3252            out.extend_from_slice(s.as_bytes());
3253        }
3254        Value::DateTime(n) => out.extend_from_slice(&n.to_le_bytes()),
3255        Value::Uuid(u) => out.extend_from_slice(u),
3256        Value::Bytes(b) => {
3257            out.extend_from_slice(&(b.len() as u32).to_le_bytes());
3258            out.extend_from_slice(b);
3259        }
3260        Value::Json(b) => {
3261            out.extend_from_slice(&(b.len() as u32).to_le_bytes());
3262            out.extend_from_slice(b);
3263        }
3264        Value::Empty => {}
3265    }
3266}
3267
3268/// Inverse of [`encode_value_blob`]. Returns `None` on any malformed/truncated
3269/// input so a corrupt record fails closed rather than panicking.
3270fn decode_value_blob(data: &[u8], pos: &mut usize) -> Option<Value> {
3271    let tag = *data.get(*pos)?;
3272    *pos += 1;
3273    let type_id = TypeId::from_u8(tag)?;
3274    let take_fixed = |pos: &mut usize, n: usize| -> Option<Vec<u8>> {
3275        if *pos + n > data.len() {
3276            return None;
3277        }
3278        let slice = data[*pos..*pos + n].to_vec();
3279        *pos += n;
3280        Some(slice)
3281    };
3282    match type_id {
3283        TypeId::Empty => Some(Value::Empty),
3284        TypeId::Int => Some(Value::Int(i64::from_le_bytes(
3285            take_fixed(pos, 8)?.try_into().ok()?,
3286        ))),
3287        TypeId::Float => Some(Value::Float(f64::from_bits(u64::from_le_bytes(
3288            take_fixed(pos, 8)?.try_into().ok()?,
3289        )))),
3290        TypeId::Bool => Some(Value::Bool(take_fixed(pos, 1)?[0] != 0)),
3291        TypeId::DateTime => Some(Value::DateTime(i64::from_le_bytes(
3292            take_fixed(pos, 8)?.try_into().ok()?,
3293        ))),
3294        TypeId::Uuid => Some(Value::Uuid(take_fixed(pos, 16)?.try_into().ok()?)),
3295        TypeId::Str => {
3296            let len = u32::from_le_bytes(take_fixed(pos, 4)?.try_into().ok()?) as usize;
3297            Some(Value::Str(String::from_utf8(take_fixed(pos, len)?).ok()?))
3298        }
3299        TypeId::Bytes => {
3300            let len = u32::from_le_bytes(take_fixed(pos, 4)?.try_into().ok()?) as usize;
3301            Some(Value::Bytes(take_fixed(pos, len)?))
3302        }
3303        TypeId::Json => {
3304            let len = u32::from_le_bytes(take_fixed(pos, 4)?.try_into().ok()?) as usize;
3305            Some(Value::Json(take_fixed(pos, len)?.into()))
3306        }
3307    }
3308}
3309
3310/// Encode the per-table defaults as a sparse list: a `u16` count of columns
3311/// that have a default, then `(position: u16, value blob)` pairs. The common
3312/// "no defaults" case costs two bytes.
3313fn encode_defaults_section(out: &mut Vec<u8>, defaults: &[Option<Value>]) {
3314    let present: Vec<(u16, &Value)> = defaults
3315        .iter()
3316        .enumerate()
3317        .filter_map(|(i, d)| d.as_ref().map(|v| (i as u16, v)))
3318        .collect();
3319    out.extend_from_slice(&(present.len() as u16).to_le_bytes());
3320    for (pos, v) in present {
3321        out.extend_from_slice(&pos.to_le_bytes());
3322        encode_value_blob(out, v);
3323    }
3324}
3325
3326/// Inverse of [`encode_defaults_section`]. Builds a `Vec` of length `n_cols`
3327/// with `None` for columns without a default. Returns `None` on truncation.
3328fn decode_defaults_section(
3329    data: &[u8],
3330    pos: &mut usize,
3331    n_cols: usize,
3332) -> Option<Vec<Option<Value>>> {
3333    if *pos + 2 > data.len() {
3334        return None;
3335    }
3336    let count = u16::from_le_bytes(data[*pos..*pos + 2].try_into().ok()?) as usize;
3337    *pos += 2;
3338    let mut out = vec![None; n_cols];
3339    for _ in 0..count {
3340        if *pos + 2 > data.len() {
3341            return None;
3342        }
3343        let col = u16::from_le_bytes(data[*pos..*pos + 2].try_into().ok()?) as usize;
3344        *pos += 2;
3345        let value = decode_value_blob(data, pos)?;
3346        if col < n_cols {
3347            out[col] = Some(value);
3348        }
3349    }
3350    Some(out)
3351}
3352
3353/// Encode the per-table `auto` columns as a sparse list: a `u16` count of auto
3354/// columns, then their positions (`u16` each). "No auto columns" costs two
3355/// bytes.
3356fn encode_auto_section(out: &mut Vec<u8>, auto_cols: &[bool]) {
3357    let present: Vec<u16> = auto_cols
3358        .iter()
3359        .enumerate()
3360        .filter_map(|(i, &a)| if a { Some(i as u16) } else { None })
3361        .collect();
3362    out.extend_from_slice(&(present.len() as u16).to_le_bytes());
3363    for pos in present {
3364        out.extend_from_slice(&pos.to_le_bytes());
3365    }
3366}
3367
3368/// Inverse of [`encode_auto_section`]. Builds a `bool` vec of length `n_cols`.
3369/// Returns `None` on truncation.
3370fn decode_auto_section(data: &[u8], pos: &mut usize, n_cols: usize) -> Option<Vec<bool>> {
3371    if *pos + 2 > data.len() {
3372        return None;
3373    }
3374    let count = u16::from_le_bytes(data[*pos..*pos + 2].try_into().ok()?) as usize;
3375    *pos += 2;
3376    let mut out = vec![false; n_cols];
3377    for _ in 0..count {
3378        if *pos + 2 > data.len() {
3379            return None;
3380        }
3381        let col = u16::from_le_bytes(data[*pos..*pos + 2].try_into().ok()?) as usize;
3382        *pos += 2;
3383        if col < n_cols {
3384            out[col] = true;
3385        }
3386    }
3387    Some(out)
3388}
3389
3390fn push_catalog_string(out: &mut Vec<u8>, value: &str) -> io::Result<()> {
3391    let len = u32::try_from(value.len())
3392        .map_err(|_| io::Error::new(io::ErrorKind::InvalidInput, "catalog string is too large"))?;
3393    out.extend_from_slice(&len.to_le_bytes());
3394    out.extend_from_slice(value.as_bytes());
3395    Ok(())
3396}
3397
3398fn encode_expression_indexes(out: &mut Vec<u8>, indexes: &[ExpressionIndexMeta]) -> io::Result<()> {
3399    let count = u16::try_from(indexes.len()).map_err(|_| {
3400        io::Error::new(
3401            io::ErrorKind::InvalidInput,
3402            "too many expression indexes on one table",
3403        )
3404    })?;
3405    out.extend_from_slice(&count.to_le_bytes());
3406    for index in indexes {
3407        out.extend_from_slice(&index.index_id.to_le_bytes());
3408        out.push(u8::from(index.unique));
3409        out.extend_from_slice(&index.canonical_version.to_le_bytes());
3410        push_catalog_string(out, &index.canonical_text)?;
3411        push_catalog_string(out, &index.json_path.column)?;
3412        let segment_count = u16::try_from(index.json_path.segments.len()).map_err(|_| {
3413            io::Error::new(
3414                io::ErrorKind::InvalidInput,
3415                "JSON path has too many segments",
3416            )
3417        })?;
3418        out.extend_from_slice(&segment_count.to_le_bytes());
3419        for segment in &index.json_path.segments {
3420            match segment {
3421                StoredJsonPathSegmentV1::Key(key) => {
3422                    out.push(1);
3423                    push_catalog_string(out, key)?;
3424                }
3425                StoredJsonPathSegmentV1::Index(position) => {
3426                    out.push(2);
3427                    out.extend_from_slice(&position.to_le_bytes());
3428                }
3429            }
3430        }
3431    }
3432    Ok(())
3433}
3434
3435fn decode_expression_indexes(data: &[u8], pos: &mut usize) -> io::Result<Vec<ExpressionIndexMeta>> {
3436    let count = read_u16(data, pos)? as usize;
3437    let mut indexes = Vec::with_capacity(count);
3438    for _ in 0..count {
3439        let index_id = read_u64(data, pos)?;
3440        if index_id == 0 {
3441            return Err(io::Error::new(
3442                io::ErrorKind::InvalidData,
3443                "expression index id must be non-zero",
3444            ));
3445        }
3446        let unique = read_u8(data, pos)? != 0;
3447        let canonical_version = read_u16(data, pos)?;
3448        let canonical_len = read_u32(data, pos)? as usize;
3449        let canonical_text = read_string(data, pos, canonical_len)?;
3450        let column_len = read_u32(data, pos)? as usize;
3451        let column = read_string(data, pos, column_len)?;
3452        let segment_count = read_u16(data, pos)? as usize;
3453        let mut segments = Vec::with_capacity(segment_count);
3454        for _ in 0..segment_count {
3455            match read_u8(data, pos)? {
3456                1 => {
3457                    let len = read_u32(data, pos)? as usize;
3458                    segments.push(StoredJsonPathSegmentV1::Key(read_string(data, pos, len)?));
3459                }
3460                2 => segments.push(StoredJsonPathSegmentV1::Index(read_u32(data, pos)?)),
3461                tag => {
3462                    return Err(io::Error::new(
3463                        io::ErrorKind::InvalidData,
3464                        format!("unknown stored JSON path segment tag: {tag}"),
3465                    ));
3466                }
3467            }
3468        }
3469        indexes.push(ExpressionIndexMeta {
3470            index_id,
3471            unique,
3472            canonical_version,
3473            canonical_text,
3474            json_path: StoredJsonPathV1 { column, segments },
3475        });
3476    }
3477    Ok(indexes)
3478}
3479
3480fn write_catalog_file(
3481    path: &Path,
3482    version: u16,
3483    next_index_id: u64,
3484    entries: &[CatalogEntryRef<'_>],
3485) -> io::Result<()> {
3486    if !(1..=CATALOG_VERSION).contains(&version) {
3487        return Err(io::Error::new(
3488            io::ErrorKind::InvalidInput,
3489            format!("unsupported catalog write version: {version}"),
3490        ));
3491    }
3492    let mut buf: Vec<u8> = Vec::with_capacity(64);
3493    buf.extend_from_slice(CATALOG_MAGIC);
3494    buf.extend_from_slice(&version.to_le_bytes());
3495    buf.extend_from_slice(&(entries.len() as u32).to_le_bytes());
3496    if version >= 6 {
3497        buf.extend_from_slice(&next_index_id.to_le_bytes());
3498    }
3499
3500    for entry in entries {
3501        let schema = entry.schema;
3502        let name = schema.table_name.as_bytes();
3503        buf.extend_from_slice(&(name.len() as u32).to_le_bytes());
3504        buf.extend_from_slice(name);
3505        buf.extend_from_slice(&(schema.columns.len() as u16).to_le_bytes());
3506        for col in &schema.columns {
3507            let cn = col.name.as_bytes();
3508            buf.extend_from_slice(&(cn.len() as u32).to_le_bytes());
3509            buf.extend_from_slice(cn);
3510            buf.push(col.type_id as u8);
3511            buf.push(if col.required { 1 } else { 0 });
3512            buf.extend_from_slice(&col.position.to_le_bytes());
3513        }
3514        // Per-table indexed column list with uniqueness flags (version 3).
3515        buf.extend_from_slice(&(entry.indexed_cols.len() as u16).to_le_bytes());
3516        for meta in &entry.indexed_cols {
3517            let cn = meta.name.as_bytes();
3518            buf.extend_from_slice(&(cn.len() as u32).to_le_bytes());
3519            buf.extend_from_slice(cn);
3520            buf.push(if meta.unique { 1 } else { 0 });
3521        }
3522        // Per-table column defaults (version 4).
3523        encode_defaults_section(&mut buf, entry.defaults);
3524        // Per-table auto-increment columns (version 5).
3525        encode_auto_section(&mut buf, entry.auto_cols);
3526        if version >= 6 {
3527            encode_expression_indexes(&mut buf, &entry.expression_indexes)?;
3528        }
3529    }
3530
3531    // Append a CRC32 checksum of the entire payload so the reader can
3532    // detect corruption (the WAL and btree .idx files already do this;
3533    // catalog.bin was the one file missing a checksum).
3534    let crc = crc32fast::hash(&buf);
3535    buf.extend_from_slice(&crc.to_le_bytes());
3536
3537    let mut f = fs::OpenOptions::new()
3538        .create(true)
3539        .write(true)
3540        .truncate(true)
3541        .open(path)?;
3542    f.write_all(&buf)?;
3543    f.sync_data()?;
3544    Ok(())
3545}
3546
3547struct CatalogFile {
3548    version: u16,
3549    next_index_id: u64,
3550    entries: Vec<CatalogEntry>,
3551}
3552
3553fn read_catalog_file(path: &Path) -> io::Result<CatalogFile> {
3554    read_catalog_file_with_max_version(path, CATALOG_VERSION)
3555}
3556
3557/// Read the catalog format version currently persisted on disk for `data_dir`
3558/// without rehydrating tables. This is the database's *active* catalog version:
3559/// a database that has never activated an expression index stays at
3560/// [`LEGACY_CATALOG_VERSION`]. Sync producers use it to stamp published segments
3561/// with the active version rather than this binary's compile-time maximum.
3562pub fn read_active_catalog_version(data_dir: &Path) -> io::Result<u16> {
3563    let cat_path = data_dir.join(CATALOG_FILE);
3564    Ok(read_catalog_file(&cat_path)?.version)
3565}
3566
3567fn read_catalog_file_with_max_version(
3568    path: &Path,
3569    max_supported_version: u16,
3570) -> io::Result<CatalogFile> {
3571    let mut f = fs::File::open(path)?;
3572    let mut buf = Vec::new();
3573    f.read_to_end(&mut buf)?;
3574
3575    let mut pos = 0usize;
3576    // Minimum: 4 (magic) + 2 (version) + 4 (n_tables) + 4 (crc) = 14
3577    if buf.len() < 14 || &buf[0..4] != CATALOG_MAGIC {
3578        return Err(io::Error::new(
3579            io::ErrorKind::InvalidData,
3580            "bad catalog magic",
3581        ));
3582    }
3583
3584    // Verify the trailing CRC32 checksum.
3585    let payload = &buf[..buf.len() - 4];
3586    let stored_crc = u32::from_le_bytes(
3587        buf[buf.len() - 4..]
3588            .try_into()
3589            .map_err(|_| io::Error::new(io::ErrorKind::InvalidData, "truncated catalog CRC"))?,
3590    );
3591    let computed_crc = crc32fast::hash(payload);
3592    if stored_crc != computed_crc {
3593        return Err(io::Error::new(
3594            io::ErrorKind::InvalidData,
3595            format!(
3596                "catalog CRC32 mismatch: expected {stored_crc:#010x}, got {computed_crc:#010x}"
3597            ),
3598        ));
3599    }
3600    // Strip the CRC suffix so the parsing loop below doesn't walk into it.
3601    let buf = &buf[..buf.len() - 4];
3602    pos += 4;
3603    let version = u16::from_le_bytes(
3604        buf[pos..pos + 2]
3605            .try_into()
3606            .map_err(|_| io::Error::new(io::ErrorKind::InvalidData, "truncated catalog header"))?,
3607    );
3608    pos += 2;
3609    // Accept every version from 1 up to the current CATALOG_VERSION: the
3610    // field-reading staircase below fills in fields a newer version added
3611    // (indexed-col uniqueness at v3, defaults at v4, auto columns at v5) and
3612    // defaults them for older files, so any 1..=CATALOG_VERSION file loads.
3613    // A range check (not an enumerated list) is what makes this back-compat
3614    // hold automatically on the next bump — the previous `version != 1 &&
3615    // version != 2 && version != CATALOG_VERSION` form silently rejected the
3616    // intermediate v3/v4 files when the constant moved to 5, which would have
3617    // failed to open a v0.6.x database on upgrade (data loss).
3618    if version == 0 || version > max_supported_version {
3619        return Err(io::Error::new(
3620            io::ErrorKind::InvalidData,
3621            format!("unsupported catalog version: {version}"),
3622        ));
3623    }
3624    let n_tables = u32::from_le_bytes(
3625        buf[pos..pos + 4]
3626            .try_into()
3627            .map_err(|_| io::Error::new(io::ErrorKind::InvalidData, "truncated catalog header"))?,
3628    ) as usize;
3629    pos += 4;
3630    let next_index_id = if version >= 6 {
3631        let id = read_u64(buf, &mut pos)?;
3632        if id == 0 {
3633            return Err(io::Error::new(
3634                io::ErrorKind::InvalidData,
3635                "catalog next index id must be non-zero",
3636            ));
3637        }
3638        id
3639    } else {
3640        1
3641    };
3642
3643    // Don't size an allocation from an unvalidated count: a corrupt or hostile
3644    // catalog could claim billions of tables and make the `Vec::with_capacity`
3645    // below attempt a huge allocation (host abort — fatal in embedded mode). A
3646    // file of `buf.len()` bytes can describe at most that many tables (each
3647    // needs several header bytes), so a larger count is corrupt. Mirrors the
3648    // btree's node-count guard.
3649    if n_tables > buf.len() {
3650        return Err(io::Error::new(
3651            io::ErrorKind::InvalidData,
3652            format!("catalog file corrupt: implausible table count {n_tables}"),
3653        ));
3654    }
3655
3656    let mut entries = Vec::with_capacity(n_tables);
3657    for _ in 0..n_tables {
3658        let name_len = read_u32(buf, &mut pos)? as usize;
3659        let table_name = read_string(buf, &mut pos, name_len)?;
3660        let n_cols = read_u16(buf, &mut pos)? as usize;
3661
3662        let mut columns = Vec::with_capacity(n_cols);
3663        for _ in 0..n_cols {
3664            let cname_len = read_u32(buf, &mut pos)? as usize;
3665            let name = read_string(buf, &mut pos, cname_len)?;
3666            let type_id_raw = read_u8(buf, &mut pos)?;
3667            let type_id = type_id_from_u8(type_id_raw)?;
3668            let required = read_u8(buf, &mut pos)? != 0;
3669            let position = read_u16(buf, &mut pos)?;
3670            columns.push(ColumnDef {
3671                name,
3672                type_id,
3673                required,
3674                position,
3675            });
3676        }
3677
3678        // Version 3 appends indexed column list with uniqueness flag.
3679        // Version 2 has indexed column names without uniqueness (default
3680        // to non-unique). Version 1 has no index info at all.
3681        let indexed_cols: Vec<IndexedColMeta> = if version >= 3 {
3682            let n = read_u16(buf, &mut pos)? as usize;
3683            let mut v = Vec::with_capacity(n);
3684            for _ in 0..n {
3685                let l = read_u32(buf, &mut pos)? as usize;
3686                let name = read_string(buf, &mut pos, l)?;
3687                let unique = read_u8(buf, &mut pos)? != 0;
3688                v.push(IndexedColMeta { name, unique });
3689            }
3690            v
3691        } else if version >= 2 {
3692            let n = read_u16(buf, &mut pos)? as usize;
3693            let mut v = Vec::with_capacity(n);
3694            for _ in 0..n {
3695                let l = read_u32(buf, &mut pos)? as usize;
3696                let name = read_string(buf, &mut pos, l)?;
3697                v.push(IndexedColMeta {
3698                    name,
3699                    unique: false,
3700                });
3701            }
3702            v
3703        } else {
3704            Vec::new()
3705        };
3706
3707        // Version 4 appends a column-defaults section after the index list.
3708        let defaults = if version >= 4 {
3709            decode_defaults_section(buf, &mut pos, columns.len()).ok_or_else(|| {
3710                io::Error::new(io::ErrorKind::InvalidData, "truncated catalog defaults")
3711            })?
3712        } else {
3713            Vec::new()
3714        };
3715
3716        // Version 5 appends an auto-increment column section after that.
3717        let auto_cols = if version >= 5 {
3718            decode_auto_section(buf, &mut pos, columns.len()).ok_or_else(|| {
3719                io::Error::new(io::ErrorKind::InvalidData, "truncated catalog auto columns")
3720            })?
3721        } else {
3722            Vec::new()
3723        };
3724
3725        let expression_indexes = if version >= 6 {
3726            decode_expression_indexes(buf, &mut pos)?
3727        } else {
3728            Vec::new()
3729        };
3730
3731        entries.push(CatalogEntry {
3732            schema: Schema {
3733                table_name,
3734                columns,
3735            },
3736            indexed_cols,
3737            expression_indexes,
3738            defaults,
3739            auto_cols,
3740        });
3741    }
3742
3743    let mut seen_index_ids = FxHashMap::default();
3744    let mut max_index_id = 0;
3745    for entry in &entries {
3746        for index in &entry.expression_indexes {
3747            if index.canonical_version == 0 || index.canonical_text.is_empty() {
3748                return Err(io::Error::new(
3749                    io::ErrorKind::InvalidData,
3750                    "expression index has invalid canonical identity",
3751                ));
3752            }
3753            if index.canonical_version == 1
3754                && index.canonical_text != index.json_path.canonical_text()
3755            {
3756                return Err(io::Error::new(
3757                    io::ErrorKind::InvalidData,
3758                    "expression index canonical identity does not match its JSON path",
3759                ));
3760            }
3761            let Some(root) = entry
3762                .schema
3763                .columns
3764                .iter()
3765                .find(|column| column.name == index.json_path.column)
3766            else {
3767                return Err(io::Error::new(
3768                    io::ErrorKind::InvalidData,
3769                    "expression index JSON root is absent from its table",
3770                ));
3771            };
3772            if root.type_id != TypeId::Json {
3773                return Err(io::Error::new(
3774                    io::ErrorKind::InvalidData,
3775                    "expression index root column is not JSON",
3776                ));
3777            }
3778            if seen_index_ids.insert(index.index_id, ()).is_some() {
3779                return Err(io::Error::new(
3780                    io::ErrorKind::InvalidData,
3781                    "duplicate expression index id in catalog",
3782                ));
3783            }
3784            max_index_id = max_index_id.max(index.index_id);
3785        }
3786    }
3787    if next_index_id <= max_index_id {
3788        return Err(io::Error::new(
3789            io::ErrorKind::InvalidData,
3790            "catalog next index id does not exceed persisted index ids",
3791        ));
3792    }
3793    Ok(CatalogFile {
3794        version,
3795        next_index_id,
3796        entries,
3797    })
3798}
3799
3800fn read_u8(buf: &[u8], pos: &mut usize) -> io::Result<u8> {
3801    if *pos >= buf.len() {
3802        return Err(io::Error::new(
3803            io::ErrorKind::UnexpectedEof,
3804            "truncated catalog",
3805        ));
3806    }
3807    let v = buf[*pos];
3808    *pos += 1;
3809    Ok(v)
3810}
3811fn read_u16(buf: &[u8], pos: &mut usize) -> io::Result<u16> {
3812    if *pos + 2 > buf.len() {
3813        return Err(io::Error::new(
3814            io::ErrorKind::UnexpectedEof,
3815            "truncated catalog",
3816        ));
3817    }
3818    let v = u16::from_le_bytes(
3819        buf[*pos..*pos + 2]
3820            .try_into()
3821            .expect("bounds checked above"),
3822    );
3823    *pos += 2;
3824    Ok(v)
3825}
3826fn read_u32(buf: &[u8], pos: &mut usize) -> io::Result<u32> {
3827    if *pos + 4 > buf.len() {
3828        return Err(io::Error::new(
3829            io::ErrorKind::UnexpectedEof,
3830            "truncated catalog",
3831        ));
3832    }
3833    let v = u32::from_le_bytes(
3834        buf[*pos..*pos + 4]
3835            .try_into()
3836            .expect("bounds checked above"),
3837    );
3838    *pos += 4;
3839    Ok(v)
3840}
3841fn read_u64(buf: &[u8], pos: &mut usize) -> io::Result<u64> {
3842    if *pos + 8 > buf.len() {
3843        return Err(io::Error::new(
3844            io::ErrorKind::UnexpectedEof,
3845            "truncated catalog",
3846        ));
3847    }
3848    let value = u64::from_le_bytes(
3849        buf[*pos..*pos + 8]
3850            .try_into()
3851            .expect("bounds checked above"),
3852    );
3853    *pos += 8;
3854    Ok(value)
3855}
3856fn read_string(buf: &[u8], pos: &mut usize, len: usize) -> io::Result<String> {
3857    if *pos + len > buf.len() {
3858        return Err(io::Error::new(
3859            io::ErrorKind::UnexpectedEof,
3860            "truncated catalog string",
3861        ));
3862    }
3863    let s = std::str::from_utf8(&buf[*pos..*pos + len])
3864        .map_err(|_| io::Error::new(io::ErrorKind::InvalidData, "non-utf8 in catalog"))?
3865        .to_string();
3866    *pos += len;
3867    Ok(s)
3868}
3869fn type_id_from_u8(v: u8) -> io::Result<TypeId> {
3870    match v {
3871        0 => Ok(TypeId::Empty),
3872        1 => Ok(TypeId::Int),
3873        2 => Ok(TypeId::Float),
3874        3 => Ok(TypeId::Bool),
3875        4 => Ok(TypeId::Str),
3876        5 => Ok(TypeId::DateTime),
3877        6 => Ok(TypeId::Uuid),
3878        7 => Ok(TypeId::Bytes),
3879        8 => Ok(TypeId::Json),
3880        _ => Err(io::Error::new(
3881            io::ErrorKind::InvalidData,
3882            format!("unknown type id: {v}"),
3883        )),
3884    }
3885}
3886
3887#[cfg(test)]
3888mod tests {
3889    use super::*;
3890
3891    fn fail_next_catalog_persist_at(stage: u8) {
3892        CATALOG_PERSIST_FAILPOINT.with(|failpoint| failpoint.set(stage));
3893    }
3894
3895    fn temp_catalog(name: &str) -> Catalog {
3896        let dir = std::env::temp_dir().join(format!("powdb_cat_{name}_{}", std::process::id()));
3897        Catalog::create(&dir).unwrap()
3898    }
3899
3900    /// Recursively hash every file's path + bytes under `dir` so a test can
3901    /// assert a read-only open leaves the directory byte-identical. Lock
3902    /// artifacts are not created at the catalog layer (only the engine takes a
3903    /// lock), so nothing needs excluding here.
3904    fn hash_dir_tree(dir: &std::path::Path) -> String {
3905        let mut entries: Vec<std::path::PathBuf> = Vec::new();
3906        fn walk(dir: &std::path::Path, out: &mut Vec<std::path::PathBuf>) {
3907            let mut items: Vec<_> = fs::read_dir(dir).unwrap().flatten().collect();
3908            items.sort_by_key(std::fs::DirEntry::path);
3909            for item in items {
3910                let path = item.path();
3911                if path.is_dir() {
3912                    walk(&path, out);
3913                } else {
3914                    out.push(path);
3915                }
3916            }
3917        }
3918        walk(dir, &mut entries);
3919        let mut hasher = crc32fast::Hasher::new();
3920        for path in &entries {
3921            hasher.update(path.to_string_lossy().as_bytes());
3922            hasher.update(&fs::read(path).unwrap());
3923        }
3924        format!("{:08x}", hasher.finalize())
3925    }
3926
3927    fn seed_quiescent_dir(dir: &std::path::Path) {
3928        let mut catalog = Catalog::create(dir).unwrap();
3929        catalog
3930            .create_table(Schema {
3931                table_name: "User".into(),
3932                columns: vec![
3933                    ColumnDef {
3934                        name: "name".into(),
3935                        type_id: TypeId::Str,
3936                        required: true,
3937                        position: 0,
3938                    },
3939                    ColumnDef {
3940                        name: "age".into(),
3941                        type_id: TypeId::Int,
3942                        required: false,
3943                        position: 1,
3944                    },
3945                ],
3946            })
3947            .unwrap();
3948        catalog.create_index("User", "age").unwrap();
3949        catalog
3950            .insert("User", &vec![Value::Str("Ada".into()), Value::Int(36)])
3951            .unwrap();
3952        catalog
3953            .insert("User", &vec![Value::Str("Bo".into()), Value::Int(20)])
3954            .unwrap();
3955        // Clean drop checkpoints: flush heaps + truncate the WAL, leaving a
3956        // quiescent (WAL-clean) directory.
3957        drop(catalog);
3958    }
3959
3960    #[test]
3961    fn open_read_only_serves_reads_on_clean_dir() {
3962        let dir = tempfile::tempdir().unwrap();
3963        seed_quiescent_dir(dir.path());
3964
3965        let catalog = Catalog::open_read_only(dir.path()).unwrap();
3966        let rows: Vec<_> = catalog.scan("User").unwrap().collect();
3967        assert_eq!(rows.len(), 2);
3968        // Column-index read works read-only.
3969        let hit = catalog
3970            .index_lookup("User", "age", &Value::Int(36))
3971            .unwrap();
3972        assert_eq!(hit.unwrap()[0], Value::Str("Ada".into()));
3973    }
3974
3975    #[test]
3976    fn open_read_only_never_mutates_dir() {
3977        let dir = tempfile::tempdir().unwrap();
3978        seed_quiescent_dir(dir.path());
3979        let before = hash_dir_tree(dir.path());
3980
3981        {
3982            let catalog = Catalog::open_read_only(dir.path()).unwrap();
3983            let _ = catalog.scan("User").unwrap().count();
3984            let _ = catalog
3985                .index_lookup("User", "age", &Value::Int(20))
3986                .unwrap();
3987            // Drop the read-only catalog: must not checkpoint/truncate.
3988        }
3989        let after = hash_dir_tree(dir.path());
3990        assert_eq!(
3991            before, after,
3992            "read-only open + queries + drop must leave the directory byte-identical"
3993        );
3994    }
3995
3996    #[test]
3997    fn open_read_only_refuses_non_empty_wal() {
3998        let dir = tempfile::tempdir().unwrap();
3999        // Seed rows but DO NOT checkpoint: keep the WAL non-empty by forgetting
4000        // the catalog (a crash), so recovery would be required.
4001        {
4002            let mut catalog = Catalog::create(dir.path()).unwrap();
4003            catalog
4004                .create_table(Schema {
4005                    table_name: "T".into(),
4006                    columns: vec![ColumnDef {
4007                        name: "id".into(),
4008                        type_id: TypeId::Int,
4009                        required: true,
4010                        position: 0,
4011                    }],
4012                })
4013                .unwrap();
4014            catalog.insert("T", &vec![Value::Int(1)]).unwrap();
4015            catalog.sync_wal().unwrap();
4016            std::mem::forget(catalog); // leave the WAL non-empty, as a crash would
4017        }
4018        let err = match Catalog::open_read_only(dir.path()) {
4019            Ok(_) => panic!("read-only open must refuse a non-empty WAL"),
4020            Err(err) => err,
4021        };
4022        assert!(
4023            err.to_string().contains("WAL is not empty"),
4024            "expected a WAL-not-empty refusal naming the remedy, got: {err}"
4025        );
4026        assert!(err.to_string().contains("read-write engine"));
4027    }
4028
4029    #[test]
4030    fn open_read_only_expression_index_reads_work() {
4031        let dir = tempfile::tempdir().unwrap();
4032        {
4033            let mut catalog = Catalog::create(dir.path()).unwrap();
4034            catalog
4035                .create_table(Schema {
4036                    table_name: "Doc".into(),
4037                    columns: vec![ColumnDef {
4038                        name: "data".into(),
4039                        type_id: TypeId::Json,
4040                        required: false,
4041                        position: 0,
4042                    }],
4043                })
4044                .unwrap();
4045            let path =
4046                StoredJsonPathV1::new("data", vec![StoredJsonPathSegmentV1::Key("author".into())]);
4047            catalog
4048                .create_expression_index_metadata("Doc", 1, path.canonical_text(), path, false)
4049                .unwrap();
4050            drop(catalog);
4051        }
4052        // Opening read-only must load the expression index without writing.
4053        let before = hash_dir_tree(dir.path());
4054        let catalog = Catalog::open_read_only(dir.path()).unwrap();
4055        assert_eq!(catalog.scan("Doc").unwrap().count(), 0);
4056        drop(catalog);
4057        let after = hash_dir_tree(dir.path());
4058        assert_eq!(
4059            before, after,
4060            "read-only expression-index load must not write"
4061        );
4062    }
4063
4064    #[test]
4065    fn v5_reader_rejects_v6_catalog() {
4066        let dir = tempfile::tempdir().unwrap();
4067        let mut catalog = Catalog::create(dir.path()).unwrap();
4068        catalog
4069            .create_table(Schema {
4070                table_name: "Doc".into(),
4071                columns: vec![ColumnDef {
4072                    name: "data".into(),
4073                    type_id: TypeId::Json,
4074                    required: false,
4075                    position: 0,
4076                }],
4077            })
4078            .unwrap();
4079        let path =
4080            StoredJsonPathV1::new("data", vec![StoredJsonPathSegmentV1::Key("author".into())]);
4081        catalog
4082            .create_expression_index_metadata("Doc", 1, path.canonical_text(), path, false)
4083            .unwrap();
4084        let result = read_catalog_file_with_max_version(
4085            &dir.path().join(CATALOG_FILE),
4086            LEGACY_CATALOG_VERSION,
4087        );
4088        let error = match result {
4089            Ok(_) => panic!("a v5 reader must reject v6 before decoding its payload"),
4090            Err(error) => error,
4091        };
4092        assert!(error.to_string().contains("unsupported catalog version: 6"));
4093    }
4094
4095    #[test]
4096    fn expression_index_rolls_back_only_before_catalog_rename() {
4097        let before_dir = tempfile::tempdir().unwrap();
4098        let mut before = Catalog::create(before_dir.path()).unwrap();
4099        before
4100            .create_table(Schema {
4101                table_name: "Doc".into(),
4102                columns: vec![ColumnDef {
4103                    name: "data".into(),
4104                    type_id: TypeId::Json,
4105                    required: false,
4106                    position: 0,
4107                }],
4108            })
4109            .unwrap();
4110        let path =
4111            StoredJsonPathV1::new("data", vec![StoredJsonPathSegmentV1::Key("score".into())]);
4112
4113        fail_next_catalog_persist_at(1);
4114        let error = before
4115            .create_expression_index_metadata("Doc", 1, path.canonical_text(), path.clone(), false)
4116            .unwrap_err();
4117        assert!(error.to_string().contains("before rename"));
4118        assert_eq!(before.active_catalog_version(), LEGACY_CATALOG_VERSION);
4119        assert_eq!(before.next_index_id(), 1);
4120        assert!(before.expression_index_metadata("Doc").unwrap().is_empty());
4121        assert!(!before_dir
4122            .path()
4123            .join(expression_index_file_name("Doc", 1))
4124            .exists());
4125
4126        let before_index_id = before
4127            .create_expression_index_metadata("Doc", 1, path.canonical_text(), path.clone(), false)
4128            .unwrap();
4129        fail_next_catalog_persist_at(1);
4130        let error = before
4131            .drop_expression_index("Doc", before_index_id)
4132            .unwrap_err();
4133        assert!(error.to_string().contains("before rename"));
4134        assert!(before
4135            .expression_index_btree("Doc", before_index_id)
4136            .is_some());
4137        assert!(before_dir
4138            .path()
4139            .join(expression_index_file_name("Doc", 1))
4140            .exists());
4141        std::mem::forget(before);
4142        let before_reopened = Catalog::open(before_dir.path()).unwrap();
4143        assert!(before_reopened
4144            .expression_index_btree("Doc", before_index_id)
4145            .is_some());
4146
4147        let after_dir = tempfile::tempdir().unwrap();
4148        let mut after = Catalog::create(after_dir.path()).unwrap();
4149        after
4150            .create_table(Schema {
4151                table_name: "Doc".into(),
4152                columns: vec![ColumnDef {
4153                    name: "data".into(),
4154                    type_id: TypeId::Json,
4155                    required: false,
4156                    position: 0,
4157                }],
4158            })
4159            .unwrap();
4160        fail_next_catalog_persist_at(2);
4161        let index_id = after
4162            .create_expression_index_metadata("Doc", 1, path.canonical_text(), path.clone(), false)
4163            .unwrap();
4164        assert_eq!(index_id, 1);
4165        assert_eq!(after.active_catalog_version(), CATALOG_VERSION);
4166        assert_eq!(after.next_index_id(), 2);
4167        assert!(after.expression_index_btree("Doc", index_id).is_some());
4168        assert!(after_dir
4169            .path()
4170            .join(expression_index_file_name("Doc", 1))
4171            .exists());
4172        std::mem::forget(after);
4173
4174        let mut reopened = Catalog::open(after_dir.path()).unwrap();
4175        assert!(reopened.expression_index_btree("Doc", index_id).is_some());
4176        fail_next_catalog_persist_at(2);
4177        reopened.drop_expression_index("Doc", index_id).unwrap();
4178        assert!(reopened
4179            .expression_index_metadata("Doc")
4180            .unwrap()
4181            .is_empty());
4182        assert!(!after_dir
4183            .path()
4184            .join(expression_index_file_name("Doc", 1))
4185            .exists());
4186        std::mem::forget(reopened);
4187
4188        let final_open = Catalog::open(after_dir.path()).unwrap();
4189        assert!(final_open
4190            .expression_index_metadata("Doc")
4191            .unwrap()
4192            .is_empty());
4193    }
4194
4195    #[test]
4196    fn ordinary_catalog_persist_reports_post_rename_directory_sync_failure() {
4197        let dir = tempfile::tempdir().unwrap();
4198        let mut catalog = Catalog::create(dir.path()).unwrap();
4199        fail_next_catalog_persist_at(2);
4200        let error = catalog
4201            .create_table(Schema {
4202                table_name: "VisibleAfterRename".into(),
4203                columns: vec![ColumnDef {
4204                    name: "id".into(),
4205                    type_id: TypeId::Int,
4206                    required: true,
4207                    position: 0,
4208                }],
4209            })
4210            .unwrap_err();
4211        assert!(error.to_string().contains("after rename"));
4212        assert!(catalog.schema("VisibleAfterRename").is_some());
4213
4214        std::mem::forget(catalog);
4215        let reopened = Catalog::open(dir.path()).unwrap();
4216        assert!(reopened.schema("VisibleAfterRename").is_some());
4217    }
4218
4219    fn schema_two_cols() -> Schema {
4220        Schema {
4221            table_name: "T".into(),
4222            columns: vec![
4223                ColumnDef {
4224                    name: "id".into(),
4225                    type_id: TypeId::Int,
4226                    required: true,
4227                    position: 0,
4228                },
4229                ColumnDef {
4230                    name: "status".into(),
4231                    type_id: TypeId::Str,
4232                    required: false,
4233                    position: 1,
4234                },
4235            ],
4236        }
4237    }
4238
4239    #[test]
4240    fn replay_records_treats_reused_tx_ids_as_ordered_spans() {
4241        let mut cat = temp_catalog("reused_tx_ids");
4242        let schema = schema_two_cols();
4243        cat.create_table(schema.clone()).unwrap();
4244        cat.checkpoint().unwrap();
4245
4246        let mut committed_row = Vec::new();
4247        encode_row_into(
4248            &schema,
4249            &[Value::Int(1), Value::Str("committed".into())],
4250            &mut committed_row,
4251        );
4252        let mut incomplete_row = Vec::new();
4253        encode_row_into(
4254            &schema,
4255            &[Value::Int(2), Value::Str("incomplete".into())],
4256            &mut incomplete_row,
4257        );
4258
4259        let records = vec![
4260            WalRecord {
4261                tx_id: 1,
4262                record_type: WalRecordType::Begin,
4263                lsn: 1,
4264                data: Vec::new(),
4265            },
4266            WalRecord {
4267                tx_id: 1,
4268                record_type: WalRecordType::Insert,
4269                lsn: 2,
4270                data: encode_wal_payload(
4271                    "T",
4272                    RowId {
4273                        page_id: 1,
4274                        slot_index: 0,
4275                    },
4276                    &committed_row,
4277                ),
4278            },
4279            WalRecord {
4280                tx_id: 1,
4281                record_type: WalRecordType::Commit,
4282                lsn: 3,
4283                data: Vec::new(),
4284            },
4285            WalRecord {
4286                tx_id: 1,
4287                record_type: WalRecordType::Begin,
4288                lsn: 4,
4289                data: Vec::new(),
4290            },
4291            WalRecord {
4292                tx_id: 1,
4293                record_type: WalRecordType::Insert,
4294                lsn: 5,
4295                data: encode_wal_payload(
4296                    "T",
4297                    RowId {
4298                        page_id: 1,
4299                        slot_index: 1,
4300                    },
4301                    &incomplete_row,
4302                ),
4303            },
4304        ];
4305
4306        cat.apply_wal_records(&records).unwrap();
4307        let rows: Vec<_> = cat.scan("T").unwrap().collect();
4308        assert_eq!(rows.len(), 1);
4309        assert_eq!(rows[0].1[0], Value::Int(1));
4310        assert_eq!(rows[0].1[1], Value::Str("committed".into()));
4311    }
4312
4313    #[test]
4314    fn ddl_create_table_codec_roundtrips_defaults_and_auto() {
4315        let schema = schema_two_cols();
4316        let defaults = vec![None, Some(Value::Str("active".into()))];
4317        let auto_cols = vec![true, false];
4318        let encoded = encode_ddl_create_table(&schema, &defaults, &auto_cols);
4319        let (decoded_schema, decoded_defaults, decoded_auto) =
4320            decode_ddl_create_table(&encoded).unwrap();
4321        assert_eq!(decoded_schema.columns.len(), 2);
4322        assert_eq!(decoded_defaults, defaults);
4323        assert_eq!(decoded_auto, auto_cols);
4324    }
4325
4326    #[test]
4327    fn ddl_create_table_codec_back_compat_without_trailing_sections() {
4328        // Simulate a record written before column defaults / auto existed: the
4329        // old encoder stopped right after the columns, with no trailing
4330        // sections. The new decoder must read those as "none".
4331        let schema = schema_two_cols();
4332        let full = encode_ddl_create_table(&schema, &[], &[]);
4333        // Each empty trailing section is a u16 count of 0 (two bytes); chop
4334        // both off to mimic the pre-feature on-disk shape.
4335        let legacy = &full[..full.len() - 4];
4336        let (decoded_schema, decoded_defaults, decoded_auto) =
4337            decode_ddl_create_table(legacy).unwrap();
4338        assert_eq!(decoded_schema.columns.len(), 2);
4339        assert!(decoded_defaults.is_empty(), "no defaults section -> empty");
4340        assert!(decoded_auto.is_empty(), "no auto section -> empty");
4341    }
4342
4343    #[test]
4344    fn ddl_create_table_codec_back_compat_defaults_but_no_auto() {
4345        // A record from the column-defaults release (#129) has a defaults
4346        // section but no auto section; the auto-aware decoder must still read it.
4347        let schema = schema_two_cols();
4348        let defaults = vec![None, Some(Value::Str("active".into()))];
4349        let full = encode_ddl_create_table(&schema, &defaults, &[]);
4350        // Drop only the trailing auto section (its empty u16 count).
4351        let legacy = &full[..full.len() - 2];
4352        let (_schema, decoded_defaults, decoded_auto) = decode_ddl_create_table(legacy).unwrap();
4353        assert_eq!(decoded_defaults, defaults);
4354        assert!(decoded_auto.is_empty());
4355    }
4356
4357    #[test]
4358    fn read_catalog_file_accepts_intermediate_versions_3_and_4() {
4359        // Regression: the version gate accepted only {1, 2, CATALOG_VERSION}, so
4360        // a catalog written at version 3 (v0.6.x) or 4 (the column-defaults
4361        // release) was rejected with "unsupported catalog version" — the
4362        // database would fail to open on upgrade from those releases = data
4363        // loss. The field-reading staircase already handles v3/v4; only the gate
4364        // was stale. Build faithful v3/v4 catalog files by hand and confirm they
4365        // load (defaults/auto default to empty for the versions that lack them).
4366        use std::io::Write as _;
4367        fn write_legacy_catalog(path: &std::path::Path, version: u16) {
4368            let mut buf: Vec<u8> = Vec::new();
4369            buf.extend_from_slice(CATALOG_MAGIC);
4370            buf.extend_from_slice(&version.to_le_bytes());
4371            buf.extend_from_slice(&1u32.to_le_bytes()); // n_tables
4372                                                        // table "T"
4373            buf.extend_from_slice(&1u32.to_le_bytes());
4374            buf.extend_from_slice(b"T");
4375            buf.extend_from_slice(&2u16.to_le_bytes()); // n_cols
4376                                                        // col id: Int, required, pos 0
4377            buf.extend_from_slice(&2u32.to_le_bytes());
4378            buf.extend_from_slice(b"id");
4379            buf.push(TypeId::Int as u8);
4380            buf.push(1);
4381            buf.extend_from_slice(&0u16.to_le_bytes());
4382            // col status: Str, not required, pos 1
4383            buf.extend_from_slice(&6u32.to_le_bytes());
4384            buf.extend_from_slice(b"status");
4385            buf.push(TypeId::Str as u8);
4386            buf.push(0);
4387            buf.extend_from_slice(&1u16.to_le_bytes());
4388            // version >= 3: indexed-column section (count 0).
4389            buf.extend_from_slice(&0u16.to_le_bytes());
4390            // version >= 4: column-defaults section (none here). v3 omits it.
4391            if version >= 4 {
4392                encode_defaults_section(&mut buf, &[None, None]);
4393            }
4394            // v3/v4 never wrote the v5 auto section.
4395            let crc = crc32fast::hash(&buf);
4396            buf.extend_from_slice(&crc.to_le_bytes());
4397            let mut f = fs::File::create(path).unwrap();
4398            f.write_all(&buf).unwrap();
4399        }
4400
4401        for version in [3u16, 4u16] {
4402            let path = std::env::temp_dir().join(format!(
4403                "powdb_cat_v{version}_compat_{}.bin",
4404                std::process::id()
4405            ));
4406            write_legacy_catalog(&path, version);
4407            let catalog_file = read_catalog_file(&path)
4408                .unwrap_or_else(|e| panic!("version {version} catalog must load, got: {e}"));
4409            let entries = catalog_file.entries;
4410            assert_eq!(entries.len(), 1);
4411            assert_eq!(entries[0].schema.table_name, "T");
4412            assert_eq!(entries[0].schema.columns.len(), 2);
4413            assert!(
4414                entries[0].auto_cols.is_empty(),
4415                "v{version} has no auto cols"
4416            );
4417            fs::remove_file(&path).ok();
4418        }
4419    }
4420
4421    #[test]
4422    fn read_catalog_file_rejects_implausible_table_count() {
4423        // A corrupt/hostile catalog must not be trusted to size an allocation:
4424        // `Vec::with_capacity(n_tables)` on an unvalidated u32 would attempt a
4425        // huge allocation and abort the host. A file can describe at most as
4426        // many tables as it has bytes, so a count exceeding the payload length
4427        // is rejected with a clear error before any allocation. (We use a small
4428        // implausible count over a tiny buffer; a genuinely huge count would
4429        // abort the test runner pre-fix, but it hits the very same guard.)
4430        use std::io::Write as _;
4431        let mut buf: Vec<u8> = Vec::new();
4432        buf.extend_from_slice(CATALOG_MAGIC);
4433        buf.extend_from_slice(&CATALOG_VERSION.to_le_bytes());
4434        buf.extend_from_slice(&1000u32.to_le_bytes()); // claims 1000 tables…
4435        buf.extend_from_slice(&1u64.to_le_bytes()); // valid v6 next-index id
4436                                                    // …but no table data follows.
4437        let crc = crc32fast::hash(&buf);
4438        buf.extend_from_slice(&crc.to_le_bytes());
4439        let path =
4440            std::env::temp_dir().join(format!("powdb_cat_badcount_{}.bin", std::process::id()));
4441        fs::File::create(&path).unwrap().write_all(&buf).unwrap();
4442
4443        let msg = match read_catalog_file(&path) {
4444            Ok(_) => panic!("implausible table count must be rejected, got Ok"),
4445            Err(e) => e.to_string(),
4446        };
4447        assert!(
4448            msg.contains("implausible table count"),
4449            "expected an implausible-table-count error, got: {msg}"
4450        );
4451        fs::remove_file(&path).ok();
4452    }
4453
4454    #[test]
4455    fn data_dir_and_max_lsn_accessors() {
4456        let dir = std::env::temp_dir().join(format!("powdb_cat_maxlsn_{}", std::process::id()));
4457        let mut cat = Catalog::create(&dir).unwrap();
4458
4459        // data_dir() reflects the directory the catalog was created in.
4460        assert_eq!(cat.data_dir(), dir.as_path());
4461
4462        // A fresh catalog has stamped no page LSNs yet.
4463        assert_eq!(cat.max_lsn(), 0);
4464
4465        let schema = Schema {
4466            table_name: "users".into(),
4467            columns: vec![ColumnDef {
4468                name: "name".into(),
4469                type_id: TypeId::Str,
4470                required: true,
4471                position: 0,
4472            }],
4473        };
4474        cat.create_table(schema).unwrap();
4475
4476        cat.insert("users", &vec![Value::Str("Alice".into())])
4477            .unwrap();
4478        cat.sync_wal().unwrap();
4479
4480        // An inserted (and synced) row stamps a page LSN, raising the
4481        // durability high-water mark above zero.
4482        assert!(cat.max_lsn() > 0);
4483    }
4484
4485    #[test]
4486    fn test_create_table_and_insert() {
4487        let mut cat = temp_catalog("basic");
4488        let schema = Schema {
4489            table_name: "users".into(),
4490            columns: vec![
4491                ColumnDef {
4492                    name: "name".into(),
4493                    type_id: TypeId::Str,
4494                    required: true,
4495                    position: 0,
4496                },
4497                ColumnDef {
4498                    name: "age".into(),
4499                    type_id: TypeId::Int,
4500                    required: false,
4501                    position: 1,
4502                },
4503            ],
4504        };
4505        cat.create_table(schema).unwrap();
4506
4507        let row = vec![Value::Str("Alice".into()), Value::Int(30)];
4508        let rid = cat.insert("users", &row).unwrap();
4509
4510        let result = cat.get("users", rid).unwrap();
4511        assert_eq!(result[0], Value::Str("Alice".into()));
4512        assert_eq!(result[1], Value::Int(30));
4513    }
4514
4515    #[test]
4516    fn test_scan_table() {
4517        let mut cat = temp_catalog("scan");
4518        let schema = Schema {
4519            table_name: "items".into(),
4520            columns: vec![
4521                ColumnDef {
4522                    name: "name".into(),
4523                    type_id: TypeId::Str,
4524                    required: true,
4525                    position: 0,
4526                },
4527                ColumnDef {
4528                    name: "price".into(),
4529                    type_id: TypeId::Float,
4530                    required: true,
4531                    position: 1,
4532                },
4533            ],
4534        };
4535        cat.create_table(schema).unwrap();
4536
4537        for i in 0..50 {
4538            cat.insert(
4539                "items",
4540                &vec![
4541                    Value::Str(format!("item_{i}")),
4542                    Value::Float(i as f64 * 1.5),
4543                ],
4544            )
4545            .unwrap();
4546        }
4547
4548        let rows: Vec<_> = cat.scan("items").unwrap().collect();
4549        assert_eq!(rows.len(), 50);
4550    }
4551
4552    #[test]
4553    fn test_index_lookup() {
4554        let mut cat = temp_catalog("idx");
4555        let schema = Schema {
4556            table_name: "users".into(),
4557            columns: vec![
4558                ColumnDef {
4559                    name: "email".into(),
4560                    type_id: TypeId::Str,
4561                    required: true,
4562                    position: 0,
4563                },
4564                ColumnDef {
4565                    name: "name".into(),
4566                    type_id: TypeId::Str,
4567                    required: true,
4568                    position: 1,
4569                },
4570            ],
4571        };
4572        cat.create_table(schema).unwrap();
4573        cat.create_index("users", "email").unwrap();
4574
4575        cat.insert(
4576            "users",
4577            &vec![
4578                Value::Str("alice@example.com".into()),
4579                Value::Str("Alice".into()),
4580            ],
4581        )
4582        .unwrap();
4583        cat.insert(
4584            "users",
4585            &vec![
4586                Value::Str("bob@example.com".into()),
4587                Value::Str("Bob".into()),
4588            ],
4589        )
4590        .unwrap();
4591
4592        let result = cat
4593            .index_lookup("users", "email", &Value::Str("bob@example.com".into()))
4594            .unwrap();
4595        assert!(result.is_some());
4596        let row = result.unwrap();
4597        assert_eq!(row[1], Value::Str("Bob".into()));
4598    }
4599
4600    #[test]
4601    fn test_delete_row() {
4602        let mut cat = temp_catalog("delete");
4603        let schema = Schema {
4604            table_name: "t".into(),
4605            columns: vec![ColumnDef {
4606                name: "v".into(),
4607                type_id: TypeId::Int,
4608                required: true,
4609                position: 0,
4610            }],
4611        };
4612        cat.create_table(schema).unwrap();
4613        let r1 = cat.insert("t", &vec![Value::Int(1)]).unwrap();
4614        let r2 = cat.insert("t", &vec![Value::Int(2)]).unwrap();
4615        cat.delete("t", r1).unwrap();
4616        assert!(cat.get("t", r1).is_none());
4617        assert!(cat.get("t", r2).is_some());
4618    }
4619
4620    #[test]
4621    fn test_update_row() {
4622        let mut cat = temp_catalog("update");
4623        let schema = Schema {
4624            table_name: "t".into(),
4625            columns: vec![ColumnDef {
4626                name: "v".into(),
4627                type_id: TypeId::Int,
4628                required: true,
4629                position: 0,
4630            }],
4631        };
4632        cat.create_table(schema).unwrap();
4633        let rid = cat.insert("t", &vec![Value::Int(1)]).unwrap();
4634        let new_rid = cat.update("t", rid, &vec![Value::Int(99)]).unwrap();
4635        let row = cat.get("t", new_rid).unwrap();
4636        assert_eq!(row[0], Value::Int(99));
4637    }
4638
4639    #[test]
4640    fn test_persist_and_reopen() {
4641        let dir = std::env::temp_dir().join(format!("powdb_cat_persist_{}", std::process::id()));
4642        // Fresh dir
4643        let _ = std::fs::remove_dir_all(&dir);
4644
4645        {
4646            let mut cat = Catalog::create(&dir).unwrap();
4647            cat.create_table(Schema {
4648                table_name: "users".into(),
4649                columns: vec![
4650                    ColumnDef {
4651                        name: "name".into(),
4652                        type_id: TypeId::Str,
4653                        required: true,
4654                        position: 0,
4655                    },
4656                    ColumnDef {
4657                        name: "age".into(),
4658                        type_id: TypeId::Int,
4659                        required: false,
4660                        position: 1,
4661                    },
4662                ],
4663            })
4664            .unwrap();
4665            cat.insert("users", &vec![Value::Str("Alice".into()), Value::Int(30)])
4666                .unwrap();
4667            cat.insert("users", &vec![Value::Str("Bob".into()), Value::Int(25)])
4668                .unwrap();
4669        }
4670
4671        // Reopen — schema and rows should both still be there
4672        let cat = Catalog::open(&dir).unwrap();
4673        let schema = cat.schema("users").unwrap();
4674        assert_eq!(schema.columns.len(), 2);
4675        assert_eq!(schema.columns[0].name, "name");
4676        assert_eq!(schema.columns[0].type_id, TypeId::Str);
4677        assert_eq!(schema.columns[1].type_id, TypeId::Int);
4678
4679        let rows: Vec<_> = cat.scan("users").unwrap().collect();
4680        assert_eq!(rows.len(), 2);
4681
4682        std::fs::remove_dir_all(&dir).ok();
4683    }
4684
4685    #[test]
4686    fn test_open_missing_dir_errors() {
4687        let dir = std::env::temp_dir().join(format!("powdb_cat_missing_{}", std::process::id()));
4688        let _ = std::fs::remove_dir_all(&dir);
4689        std::fs::create_dir_all(&dir).unwrap();
4690        // No catalog.bin yet
4691        assert!(Catalog::open(&dir).is_err());
4692        std::fs::remove_dir_all(&dir).ok();
4693    }
4694
4695    #[test]
4696    fn test_list_tables() {
4697        let mut cat = temp_catalog("list");
4698        cat.create_table(Schema {
4699            table_name: "a".into(),
4700            columns: vec![ColumnDef {
4701                name: "x".into(),
4702                type_id: TypeId::Int,
4703                required: true,
4704                position: 0,
4705            }],
4706        })
4707        .unwrap();
4708        cat.create_table(Schema {
4709            table_name: "b".into(),
4710            columns: vec![ColumnDef {
4711                name: "y".into(),
4712                type_id: TypeId::Int,
4713                required: true,
4714                position: 0,
4715            }],
4716        })
4717        .unwrap();
4718        let mut tables = cat.list_tables();
4719        tables.sort();
4720        assert_eq!(tables, vec!["a", "b"]);
4721    }
4722
4723    #[test]
4724    fn test_path_traversal_table_name_rejected() {
4725        let mut cat = temp_catalog("path_trav");
4726        // Names with path separators must be rejected.
4727        let bad_names = vec![
4728            "../etc/passwd",
4729            "foo/bar",
4730            "table\0name",
4731            "",
4732            "123starts_with_digit",
4733            "has-dashes",
4734            "has spaces",
4735            "has.dots",
4736        ];
4737        for name in bad_names {
4738            let schema = Schema {
4739                table_name: name.into(),
4740                columns: vec![ColumnDef {
4741                    name: "x".into(),
4742                    type_id: TypeId::Int,
4743                    required: true,
4744                    position: 0,
4745                }],
4746            };
4747            let result = cat.create_table(schema);
4748            assert!(result.is_err(), "expected error for table name '{name}'");
4749            assert_eq!(result.unwrap_err().kind(), io::ErrorKind::InvalidInput);
4750        }
4751        // Valid names must still work.
4752        let good_names = vec!["users", "_private", "Table_123", "_"];
4753        for name in good_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            assert!(
4764                cat.create_table(schema).is_ok(),
4765                "expected ok for table name '{name}'"
4766            );
4767        }
4768    }
4769
4770    #[test]
4771    fn test_path_traversal_column_name_rejected() {
4772        let mut cat = temp_catalog("col_path_trav");
4773        let schema = Schema {
4774            table_name: "valid_table".into(),
4775            columns: vec![ColumnDef {
4776                name: "../bad".into(),
4777                type_id: TypeId::Int,
4778                required: true,
4779                position: 0,
4780            }],
4781        };
4782        let result = cat.create_table(schema);
4783        assert!(result.is_err());
4784        assert_eq!(result.unwrap_err().kind(), io::ErrorKind::InvalidInput);
4785    }
4786
4787    #[test]
4788    fn test_drop_table_validates_name() {
4789        let mut cat = temp_catalog("drop_trav");
4790        let result = cat.drop_table("../etc/passwd");
4791        assert!(result.is_err());
4792        // Should fail with InvalidInput (validation), not NotFound.
4793        assert_eq!(result.unwrap_err().kind(), io::ErrorKind::InvalidInput);
4794    }
4795}