Skip to main content

powdb_storage/
catalog.rs

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