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