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