Skip to main content

powdb_storage/
catalog.rs

1use crate::row::encode_row_into;
2use crate::table::Table;
3use crate::types::*;
4use crate::wal::{Wal, WalRecord, WalRecordType, WalSyncMode};
5use rustc_hash::FxHashMap;
6use std::fs;
7use std::io::{self, Read, Write};
8use std::path::{Path, PathBuf};
9use tracing::{info, warn};
10
11/// Reject an encoded row that exceeds the single-page capacity BEFORE it is
12/// appended to the WAL. The heap performs the same check at its own insert/
13/// update boundary, but the update paths log to the WAL first — a logged
14/// record whose row the heap then rejects would poison the next replay.
15fn check_encoded_row_size(encoded: &[u8]) -> io::Result<()> {
16    if encoded.len() > crate::page::MAX_ROW_DATA_SIZE {
17        return Err(crate::error::StorageError::RowTooLarge {
18            size: encoded.len(),
19            max: crate::page::MAX_ROW_DATA_SIZE,
20        }
21        .into());
22    }
23    Ok(())
24}
25
26/// Validate that a name (table or column) is safe for use in file paths and
27/// follows the identifier convention: starts with a letter or underscore,
28/// followed by letters, digits, or underscores.
29fn validate_identifier(kind: &str, name: &str) -> io::Result<()> {
30    if name.is_empty() {
31        return Err(io::Error::new(
32            io::ErrorKind::InvalidInput,
33            format!("invalid {kind} name: must not be empty"),
34        ));
35    }
36    let mut chars = name.chars();
37    // Infallible: we returned early if `name.is_empty()` above.
38    let first = chars.next().expect("non-empty name");
39    if !first.is_ascii_alphabetic() && first != '_' {
40        return Err(io::Error::new(
41            io::ErrorKind::InvalidInput,
42            format!("invalid {kind} name '{name}': must start with a letter or underscore"),
43        ));
44    }
45    for ch in chars {
46        if !ch.is_ascii_alphanumeric() && ch != '_' {
47            return Err(io::Error::new(
48                io::ErrorKind::InvalidInput,
49                format!(
50                    "invalid {kind} name '{name}': must contain only letters, digits, and underscores"
51                ),
52            ));
53        }
54    }
55    Ok(())
56}
57
58/// Validate a table name for path safety.
59fn validate_table_name(name: &str) -> io::Result<()> {
60    validate_identifier("table", name)
61}
62
63/// Validate a column name for path safety.
64fn validate_column_name(name: &str) -> io::Result<()> {
65    validate_identifier("column", name)
66}
67
68/// On-disk catalog file: lists every table's schema so we can reopen them
69/// after a restart. Format is a small custom binary blob (no serde dep).
70///
71/// Mission 3: version 2 appends a per-table list of indexed column names
72/// after the column list, so indexes can be rehydrated on `Catalog::open`.
73/// Version 1 files still load cleanly — they're treated as having zero
74/// indexed columns, and the next `create_index` (or implicit rebuild on
75/// first open, depending on the caller) will populate the list.
76const CATALOG_FILE: &str = "catalog.bin";
77pub const CATALOG_LSN_FILE: &str = "catalog.lsn";
78const CATALOG_MAGIC: &[u8; 4] = b"BCAT";
79/// Version 4 appends a per-table column-defaults section after the indexed
80/// column list; version 5 appends an auto-increment column section after that.
81/// Older files load cleanly (no defaults / no auto columns).
82pub const CATALOG_VERSION: u16 = 5;
83
84/// Mission 2 (durability): the single shared WAL file lives under the catalog's
85/// data directory with this name. One WAL covers every table in the catalog.
86const WAL_FILE: &str = "wal.log";
87const SYNC_STATE_DIR: &str = ".powdb-sync";
88const SYNC_IDENTITY_FILE: &str = "identity.json";
89
90/// WAL batch size: flush auto-triggers after this many records, in addition
91/// to the explicit `wal.flush()` each top-level mutation does. Kept small so
92/// the tests see a predictable amount of buffering.
93const WAL_BATCH_SIZE: usize = 64;
94type WalArchiveCallback<'a> = &'a mut dyn FnMut(&Path, &[WalRecord]) -> io::Result<()>;
95
96fn read_durable_lsn(data_dir: &Path) -> io::Result<u64> {
97    let path = data_dir.join(CATALOG_LSN_FILE);
98    let bytes = match fs::read(path) {
99        Ok(bytes) => bytes,
100        Err(err) if err.kind() == io::ErrorKind::NotFound => return Ok(0),
101        Err(err) => return Err(err),
102    };
103    if bytes.len() != 8 {
104        return Err(io::Error::new(
105            io::ErrorKind::InvalidData,
106            "catalog LSN sidecar has invalid length",
107        ));
108    }
109    let mut buf = [0u8; 8];
110    buf.copy_from_slice(&bytes);
111    Ok(u64::from_le_bytes(buf))
112}
113
114fn write_durable_lsn(data_dir: &Path, lsn: u64) -> io::Result<()> {
115    let path = data_dir.join(CATALOG_LSN_FILE);
116    let tmp_path = data_dir.join(format!("{CATALOG_LSN_FILE}.tmp"));
117    let mut file = fs::File::create(&tmp_path)?;
118    file.write_all(&lsn.to_le_bytes())?;
119    file.sync_all()?;
120    drop(file);
121    fs::rename(&tmp_path, &path)?;
122    sync_directory(data_dir)?;
123    Ok(())
124}
125
126#[cfg(unix)]
127fn sync_directory(path: &Path) -> io::Result<()> {
128    fs::File::open(path)?.sync_all()
129}
130
131#[cfg(not(unix))]
132fn sync_directory(path: &Path) -> io::Result<()> {
133    let _ = path;
134    Ok(())
135}
136
137fn max_record_lsn(records: &[WalRecord]) -> Option<u64> {
138    records.iter().map(|record| record.lsn).max()
139}
140
141/// System catalog: registry of all tables.
142///
143/// Mission C Phase 18: tables live in a `Vec<Table>` addressed by a
144/// stable `slot` index, with a parallel `FxHashMap<String, usize>` for
145/// name-based resolution. Append-only (PowDB has no DROP TABLE yet), so
146/// slots are stable for the lifetime of the `Catalog` — callers like
147/// `PreparedQuery::insert_fast` cache a slot at prepare time and skip
148/// the name probe on every subsequent `execute_prepared_take`.
149///
150/// Earlier design (pre-Phase 18) held tables in a `FxHashMap<String, Table>`
151/// directly. That meant the `insert_batch_1k` hot path paid an
152/// `FxHash("User")` + bucket walk per row just to dispatch into the
153/// table — about 20-40ns out of a 233ns budget.
154pub struct Catalog {
155    /// All tables, in insertion order. Indexed by `slot: usize`. A table's
156    /// slot is assigned by `create_table`/`open` and never reused.
157    tables: Vec<Table>,
158    /// Name → slot index. Populated in sync with `tables` on every
159    /// `create_table` / `open`.
160    name_to_slot: FxHashMap<String, usize>,
161    data_dir: PathBuf,
162    /// Mission 2: shared write-ahead log owned by the catalog. Every
163    /// mutation (insert/update/delete) records its intent here BEFORE
164    /// touching the heap so a mid-write crash can be recovered from on the
165    /// next open. Flushed to disk at the end of every top-level op.
166    wal: Wal,
167    /// Monotonic transaction-id counter. Autocommit statements may allocate
168    /// multiple ids (one per row-level primitive), while explicit transactions
169    /// reuse one id for the whole BEGIN..COMMIT scope.
170    next_tx_id: u64,
171    /// Active explicit transaction id, if any. Owned by the connection/session
172    /// driving this catalog through `Engine`.
173    active_tx_id: Option<u64>,
174    /// Durable WAL byte offset captured at BEGIN. ROLLBACK truncates back to
175    /// this boundary so auto-flushed uncommitted records cannot replay later.
176    tx_start_len: Option<u64>,
177    /// Autocommit row-mutation tx ids appended since the previous group commit.
178    /// `commit_autocommit` writes commit markers for these ids before fsync.
179    pending_autocommit_tx_ids: Vec<u64>,
180    /// Has this catalog been cleanly checkpointed at least once since it
181    /// was opened? Used by `Drop` to decide whether to treat its own flush
182    /// as fatal (it isn't — we still try best-effort).
183    checkpointed: bool,
184    /// Catalog-level durable LSN. Heap page LSNs cover row mutations, but
185    /// DDL-only changes can advance the WAL without touching a data page.
186    durable_lsn: u64,
187}
188
189impl Catalog {
190    /// Create a brand-new catalog. Wipes any existing catalog file in this directory.
191    ///
192    /// # Examples
193    ///
194    /// ```
195    /// use powdb_storage::catalog::Catalog;
196    /// use powdb_storage::types::{Schema, ColumnDef, TypeId};
197    ///
198    /// let dir = tempfile::tempdir().unwrap();
199    /// let mut catalog = Catalog::create(dir.path()).unwrap();
200    ///
201    /// let schema = Schema {
202    ///     table_name: "User".to_string(),
203    ///     columns: vec![
204    ///         ColumnDef { name: "name".to_string(), type_id: TypeId::Str, required: true, position: 0 },
205    ///         ColumnDef { name: "age".to_string(), type_id: TypeId::Int, required: false, position: 1 },
206    ///     ],
207    /// };
208    /// catalog.create_table(schema).unwrap();
209    /// ```
210    pub fn create(data_dir: &Path) -> io::Result<Self> {
211        crate::create_data_dir_secure(data_dir)?;
212        let wal_path = data_dir.join(WAL_FILE);
213        let wal = Wal::create(&wal_path, WAL_BATCH_SIZE)?;
214        let cat = Catalog {
215            tables: Vec::new(),
216            name_to_slot: FxHashMap::default(),
217            data_dir: data_dir.to_path_buf(),
218            wal,
219            next_tx_id: 1,
220            active_tx_id: None,
221            tx_start_len: None,
222            pending_autocommit_tx_ids: Vec::new(),
223            checkpointed: false,
224            durable_lsn: 0,
225        };
226        cat.persist()?;
227        Ok(cat)
228    }
229
230    /// Open an existing catalog from disk, rehydrating every table. If no
231    /// catalog file is present this returns NotFound — callers can fall back
232    /// to `create` for a fresh data dir.
233    ///
234    /// Mission 2: after the per-table heap files are reopened, this replays
235    /// any records left in the WAL from a previous (crashed) session. The
236    /// WAL is then truncated once the replay lands cleanly on disk — that
237    /// re-establishes the "empty WAL = last shutdown was clean" invariant.
238    pub fn open(data_dir: &Path) -> io::Result<Self> {
239        Self::open_inner(data_dir, None)
240    }
241
242    /// Open an existing catalog and archive any replayed WAL records before
243    /// recovery truncates the WAL. This is for sync-aware callers that must
244    /// retain history needed by replicas.
245    ///
246    /// Replication boundary: this hook exists so `powdb-sync` can preserve WAL
247    /// history before storage recovery truncates it. Ordinary embedded/server
248    /// callers should use `open`; do not build application-level recovery flows
249    /// directly on this hook.
250    pub fn open_with_wal_archive<F>(data_dir: &Path, mut archive: F) -> io::Result<Self>
251    where
252        F: FnMut(&Path, &[WalRecord]) -> io::Result<()>,
253    {
254        let archive: WalArchiveCallback<'_> = &mut archive;
255        Self::open_inner(data_dir, Some(archive))
256    }
257
258    fn open_inner(data_dir: &Path, archive: Option<WalArchiveCallback<'_>>) -> io::Result<Self> {
259        let cat_path = data_dir.join(CATALOG_FILE);
260        if !cat_path.exists() {
261            return Err(io::Error::new(io::ErrorKind::NotFound, "no catalog file"));
262        }
263        let entries = read_catalog_file(&cat_path)?;
264        let durable_lsn = read_durable_lsn(data_dir)?;
265        let mut tables: Vec<Table> = Vec::with_capacity(entries.len());
266        let mut name_to_slot =
267            FxHashMap::with_capacity_and_hasher(entries.len(), Default::default());
268        for CatalogEntry {
269            schema,
270            indexed_cols,
271            defaults,
272            auto_cols,
273        } in entries
274        {
275            let name = schema.table_name.clone();
276            // Mission 3: rehydrate persisted indexes. `Table::open_with_indexes`
277            // tries to `BTree::load` each named index file; if a file is
278            // missing (e.g. first open after upgrade from catalog v1) it
279            // falls back to rebuilding from the heap scan and saving to
280            // disk so subsequent opens hit the fast path.
281            let mut table = Table::open_with_indexes(schema, data_dir, &indexed_cols)?;
282            table.set_defaults(defaults);
283            table.set_auto_cols(auto_cols);
284            name_to_slot.insert(name, tables.len());
285            tables.push(table);
286        }
287        let wal_path = data_dir.join(WAL_FILE);
288        let wal = Wal::open(&wal_path, WAL_BATCH_SIZE)?;
289        let mut cat = Catalog {
290            tables,
291            name_to_slot,
292            data_dir: data_dir.to_path_buf(),
293            wal,
294            next_tx_id: 1,
295            active_tx_id: None,
296            tx_start_len: None,
297            pending_autocommit_tx_ids: Vec::new(),
298            checkpointed: false,
299            durable_lsn,
300        };
301        cat.replay_wal(archive)?;
302        // Restore WAL LSN monotonicity across the restart. Heap pages carry
303        // LSNs stamped by replay (catalog.rs set_page_lsn) and by DDL
304        // rewrites (stamp_all_pages_min_lsn), but `Wal::open` reset the
305        // counter to 1. If the next write reused an LSN <= a stamped page
306        // LSN, the following crash's replay would skip it as already-applied
307        // — the data-loss bug behind the v0.4.x yanks. This runs on every
308        // open (including the empty-WAL clean-shutdown path, where pages may
309        // still carry LSNs from an earlier recovery). LSNs must be monotonic
310        // across restarts.
311        let max_page_lsn = cat
312            .tables
313            .iter()
314            .map(|t| t.heap.max_page_lsn())
315            .max()
316            .unwrap_or(0);
317        let max_known_lsn = max_page_lsn.max(cat.durable_lsn);
318        cat.wal.set_next_lsn_at_least(max_known_lsn + 1);
319        Ok(cat)
320    }
321
322    /// Replay every record currently buffered in the WAL file onto the open
323    /// tables. This is the recovery path: after a crash the heap files on
324    /// disk may be missing mutations that were logged to the WAL but never
325    /// written back to their pages. We re-apply every record unconditionally.
326    ///
327    /// **Idempotence:**
328    /// - `Delete`: idempotent — `HeapFile::delete` on an already-deleted or
329    ///   missing slot is a no-op.
330    /// - `Update`: idempotent — re-applies the same new row bytes to the
331    ///   same `RowId`, which either replaces the existing (already-updated)
332    ///   row with itself or lands the update for the first time.
333    /// - `Insert`: **NOT strictly idempotent**. `HeapFile::insert` allocates
334    ///   a fresh `RowId` on every call, so a row that was already flushed
335    ///   to disk will be re-inserted at a new location, producing a
336    ///   duplicate. See the mission report for the full caveat.
337    ///
338    /// The practical consequences are:
339    ///   1. On a "pure crash" (no heap pages ever flushed between open and
340    ///      crash), replay cleanly restores every logged row.
341    ///   2. On a crash where some heap pages were flushed by the hot-page
342    ///      eviction logic, replay may restore those rows a second time.
343    ///      A future mission can fix this with LSN-tagged pages.
344    ///
345    /// After a successful replay we truncate the WAL so the next shutdown
346    /// (crash or otherwise) replays only the NEW records.
347    fn replay_wal(&mut self, mut archive: Option<WalArchiveCallback<'_>>) -> io::Result<()> {
348        let records = self.wal.read_all()?;
349        if records.is_empty() {
350            return Ok(());
351        }
352        if archive.is_none() {
353            self.ensure_plain_wal_truncate_allowed(&records)?;
354        }
355        self.replay_records(&records)?;
356        if let Some(archive) = archive.as_mut() {
357            archive(&self.data_dir, &records)?;
358        }
359        self.wal.truncate()?;
360        Ok(())
361    }
362
363    /// Apply an LSN-preserving WAL record stream without appending it to the
364    /// local WAL. Sync callers must validate lineage and contiguity before
365    /// calling this method.
366    ///
367    /// Replication boundary: this is a storage adapter for `powdb-sync`, not a
368    /// general mutation API. Callers must reject unsupported record classes,
369    /// hold their own replica progress state, and pass only contiguous,
370    /// transaction-complete ranges or chunks.
371    pub fn apply_wal_records(&mut self, records: &[WalRecord]) -> io::Result<()> {
372        self.ensure_no_active_transaction_for_checkpoint()?;
373        self.ensure_no_pending_wal_records()?;
374        self.replay_records(records)
375    }
376
377    /// Sync callers use this before deciding an apply is a no-op. A replica with
378    /// local WAL history is divergent until a higher layer explicitly repairs it.
379    pub fn ensure_no_pending_wal_records(&self) -> io::Result<()> {
380        if self.wal.has_pending() || !self.wal.read_all()?.is_empty() {
381            return Err(io::Error::other(
382                "cannot apply replicated WAL records while local WAL records are pending",
383            ));
384        }
385        Ok(())
386    }
387
388    fn replay_records(&mut self, records: &[WalRecord]) -> io::Result<()> {
389        if records.is_empty() {
390            return Ok(());
391        }
392
393        info!(count = records.len(), "applying WAL records");
394
395        // Per-page LSN redo (ARIES-style). A record is already durable iff
396        // its *target page* carries an LSN >= the record's LSN. The previous
397        // implementation used a single per-table max LSN, which is unsafe:
398        // a low-LSN record on an unflushed page would be wrongly skipped
399        // because some other, flushed page of the same table advertised a
400        // higher LSN — silently dropping the record (one of the v0.4.x
401        // data-loss bugs). Every record now carries its real RowId (inserts
402        // included), so the target page is always known.
403        let has_boundaries = records.iter().any(|rec| {
404            matches!(
405                rec.record_type,
406                WalRecordType::Begin | WalRecordType::Commit | WalRecordType::Rollback
407            )
408        });
409        let mut committed_row_records = vec![true; records.len()];
410        if has_boundaries {
411            committed_row_records.fill(false);
412            let mut pending_tx_spans: Vec<(u64, Vec<usize>)> = Vec::new();
413            for (index, rec) in records.iter().enumerate() {
414                match rec.record_type {
415                    WalRecordType::Insert | WalRecordType::Update | WalRecordType::Delete
416                        if rec.tx_id == 0 =>
417                    {
418                        committed_row_records[index] = true;
419                    }
420                    WalRecordType::Insert | WalRecordType::Update | WalRecordType::Delete => {
421                        if let Some((_, rows)) = pending_tx_spans
422                            .iter_mut()
423                            .rev()
424                            .find(|(tx_id, _)| *tx_id == rec.tx_id)
425                        {
426                            rows.push(index);
427                        } else {
428                            pending_tx_spans.push((rec.tx_id, vec![index]));
429                        }
430                    }
431                    WalRecordType::Begin if rec.tx_id != 0 => {
432                        pending_tx_spans.push((rec.tx_id, Vec::new()));
433                    }
434                    WalRecordType::Commit if rec.tx_id != 0 => {
435                        if let Some(span_index) = pending_tx_spans
436                            .iter()
437                            .rposition(|(tx_id, _)| *tx_id == rec.tx_id)
438                        {
439                            let (_, rows) = pending_tx_spans.remove(span_index);
440                            for row_index in rows {
441                                committed_row_records[row_index] = true;
442                            }
443                        }
444                    }
445                    WalRecordType::Rollback if rec.tx_id != 0 => {
446                        if let Some(span_index) = pending_tx_spans
447                            .iter()
448                            .rposition(|(tx_id, _)| *tx_id == rec.tx_id)
449                        {
450                            pending_tx_spans.remove(span_index);
451                        }
452                    }
453                    _ => {}
454                }
455            }
456        }
457
458        let mut replayed_inserts = 0usize;
459        let mut replayed_updates = 0usize;
460        let mut replayed_deletes = 0usize;
461        let mut skipped = 0usize;
462        let mut skipped_uncommitted = 0usize;
463        let mut saw_ddl = false;
464        for (index, rec) in records.iter().enumerate() {
465            if has_boundaries
466                && !committed_row_records[index]
467                && matches!(
468                    rec.record_type,
469                    WalRecordType::Insert | WalRecordType::Update | WalRecordType::Delete
470                )
471            {
472                skipped_uncommitted += 1;
473                continue;
474            }
475            match rec.record_type {
476                WalRecordType::Insert => {
477                    if let Some((table_name, rid, row_bytes)) = decode_wal_payload(&rec.data) {
478                        if let Some(slot) = self.name_to_slot.get(&table_name).copied() {
479                            let tbl = &mut self.tables[slot];
480                            // Already persisted on its page? Skip — re-running
481                            // the insert would allocate a fresh slot and
482                            // duplicate the row.
483                            if rec.lsn > 0 && tbl.heap.page_lsn(rid.page_id) >= rec.lsn {
484                                skipped += 1;
485                                continue;
486                            }
487                            // Not yet durable: place the row at its exact
488                            // logged RowId so later Update/Delete records
489                            // (which carry that RowId) stay correctly
490                            // targeted. A plain re-`insert` would self-assign
491                            // a fresh slot whose position can diverge from the
492                            // original after a partial-flush crash.
493                            tbl.heap.insert_at(rid, &row_bytes)?;
494                            tbl.heap.set_page_lsn(rid.page_id, rec.lsn)?;
495                            replayed_inserts += 1;
496                        }
497                    }
498                }
499                WalRecordType::Update => {
500                    if let Some((table_name, rid, row_bytes)) = decode_wal_payload(&rec.data) {
501                        if let Some(slot) = self.name_to_slot.get(&table_name).copied() {
502                            let tbl = &mut self.tables[slot];
503                            if rec.lsn > 0 && tbl.heap.page_lsn(rid.page_id) >= rec.lsn {
504                                skipped += 1;
505                                continue;
506                            }
507                            let new_rid = tbl.heap.update(rid, &row_bytes)?;
508                            tbl.heap.set_page_lsn(new_rid.page_id, rec.lsn)?;
509                            replayed_updates += 1;
510                        }
511                    }
512                }
513                WalRecordType::Delete => {
514                    if let Some((table_name, rid, _)) = decode_wal_payload(&rec.data) {
515                        if let Some(slot) = self.name_to_slot.get(&table_name).copied() {
516                            let tbl = &mut self.tables[slot];
517                            if rec.lsn > 0 && tbl.heap.page_lsn(rid.page_id) >= rec.lsn {
518                                skipped += 1;
519                                continue;
520                            }
521                            let _ = tbl.heap.delete(rid);
522                            tbl.heap.set_page_lsn(rid.page_id, rec.lsn)?;
523                            replayed_deletes += 1;
524                        }
525                    }
526                }
527                WalRecordType::Begin | WalRecordType::Commit | WalRecordType::Rollback => {
528                    // Boundary records were consumed in the first pass.
529                }
530                WalRecordType::DdlCreateTable => {
531                    saw_ddl = true;
532                    if let Some((schema, defaults, auto_cols)) = decode_ddl_create_table(&rec.data)
533                    {
534                        if !self.name_to_slot.contains_key(&schema.table_name) {
535                            if let Ok(mut table) = Table::create(schema, &self.data_dir) {
536                                table.set_defaults(defaults);
537                                table.set_auto_cols(auto_cols);
538                                let slot = self.tables.len();
539                                let name = table.schema.table_name.clone();
540                                self.tables.push(table);
541                                self.name_to_slot.insert(name, slot);
542                            }
543                        }
544                    }
545                }
546                WalRecordType::DdlDropTable => {
547                    saw_ddl = true;
548                    if let Some((table_name, _)) = decode_ddl_table_name(&rec.data) {
549                        if let Some(&slot) = self.name_to_slot.get(&table_name) {
550                            let heap_path = self.data_dir.join(format!("{table_name}.heap"));
551                            if heap_path.exists() {
552                                let _ = fs::remove_file(&heap_path);
553                            }
554                            for col_name in self.tables[slot].indexed_column_names() {
555                                let idx_path =
556                                    self.data_dir.join(format!("{table_name}_{col_name}.idx"));
557                                if idx_path.exists() {
558                                    let _ = fs::remove_file(&idx_path);
559                                }
560                            }
561                            self.name_to_slot.remove(&table_name);
562                            let last = self.tables.len() - 1;
563                            if slot != last {
564                                let moved_name = self.tables[last].schema.table_name.clone();
565                                self.tables.swap(slot, last);
566                                self.name_to_slot.insert(moved_name, slot);
567                            }
568                            self.tables.pop();
569                        }
570                    }
571                }
572                WalRecordType::DdlAddColumn => {
573                    saw_ddl = true;
574                    if let Some((table_name, col)) = decode_ddl_alter_add_column(&rec.data) {
575                        if let Some(&slot) = self.name_to_slot.get(&table_name) {
576                            let tbl = &mut self.tables[slot];
577                            if !tbl.schema.columns.iter().any(|c| c.name == col.name) {
578                                let old_schema = tbl.schema.clone();
579                                let has_rows = tbl.heap.scan().next().is_some();
580                                tbl.schema.columns.push(col);
581                                tbl.refresh_layout();
582                                if has_rows {
583                                    let fill = vec![Value::Empty; tbl.schema.columns.len()];
584                                    let data_dir = self.data_dir.clone();
585                                    let _ = tbl.rewrite_rows_for_schema_change(
586                                        &old_schema,
587                                        &fill,
588                                        &data_dir,
589                                    );
590                                }
591                            }
592                            // Stamp every page with the DDL's LSN so a
593                            // subsequent restart's per-page check skips the
594                            // pre-DDL Insert/Update/Delete records — they
595                            // have already been folded into the new layout
596                            // by the rewrite above. See
597                            // `stamp_all_pages_min_lsn` doc.
598                            if rec.lsn > 0 {
599                                let _ = tbl.heap.stamp_all_pages_min_lsn(rec.lsn);
600                            }
601                        }
602                    }
603                }
604                WalRecordType::DdlDropColumn => {
605                    saw_ddl = true;
606                    if let Some((table_name, col_name)) = decode_ddl_alter_drop_column(&rec.data) {
607                        if let Some(&slot) = self.name_to_slot.get(&table_name) {
608                            let tbl = &mut self.tables[slot];
609                            if let Some(idx) =
610                                tbl.schema.columns.iter().position(|c| c.name == col_name)
611                            {
612                                let old_schema = tbl.schema.clone();
613                                let has_rows = tbl.heap.scan().next().is_some();
614                                tbl.schema.columns.remove(idx);
615                                for (i, c) in tbl.schema.columns.iter_mut().enumerate() {
616                                    c.position = i as u16;
617                                }
618                                tbl.refresh_layout();
619                                if has_rows {
620                                    let fill = vec![Value::Empty; tbl.schema.columns.len()];
621                                    let data_dir = self.data_dir.clone();
622                                    let _ = tbl.rewrite_rows_for_schema_change(
623                                        &old_schema,
624                                        &fill,
625                                        &data_dir,
626                                    );
627                                }
628                            }
629                            if rec.lsn > 0 {
630                                let _ = tbl.heap.stamp_all_pages_min_lsn(rec.lsn);
631                            }
632                        }
633                    }
634                }
635            }
636        }
637        info!(
638            inserts = replayed_inserts,
639            updates = replayed_updates,
640            deletes = replayed_deletes,
641            skipped = skipped,
642            skipped_uncommitted = skipped_uncommitted,
643            "WAL record apply complete (commit-boundary + LSN idempotent)"
644        );
645        if saw_ddl {
646            self.persist()?;
647        }
648        // Persist the replayed changes to disk before truncating the WAL,
649        // otherwise a crash between here and the next checkpoint would lose
650        // the replayed records. `flush_all_dirty` on every heap moves every
651        // dirty page through the normal write path.
652        //
653        // Blocker B3: under the deferred-index-save model, the on-disk
654        // `.idx` files may lag the heap because the pre-crash session
655        // never got to its next `checkpoint`. Replay restored the
656        // heap rows above, but the btrees that loaded from those
657        // possibly-stale `.idx` files don't know about them. Rebuild
658        // every secondary index from the post-replay heap so the
659        // trees exactly match disk. The rebuild is O(heap) per
660        // indexed column, which is fine on a crash-recovery path.
661        for tbl in &mut self.tables {
662            tbl.heap.flush_all_dirty()?;
663            tbl.heap.flush()?;
664            tbl.rebuild_indexes_from_heap()?;
665            // Flush the rebuilt indexes now so a crash between here
666            // and the next mutation still leaves `.idx` files matching
667            // the heap. Without this, a second crash before any
668            // insert could leave us back where we started.
669            tbl.save_dirty_indexes()?;
670        }
671        if let Some(max_lsn) = max_record_lsn(records) {
672            self.record_durable_lsn_at_least(max_lsn)?;
673            self.wal.set_next_lsn_at_least(max_lsn.saturating_add(1));
674        }
675        Ok(())
676    }
677
678    /// Flush every dirty heap page and truncate the WAL. This is the
679    /// "clean shutdown" point — after this returns, the on-disk heap files
680    /// are fully consistent and the WAL is empty, so the next `open` will
681    /// skip replay entirely.
682    ///
683    /// Safe to call multiple times. Safe to call on a catalog that has
684    /// performed zero mutations since the last checkpoint (in which case
685    /// the flushes are no-ops and the truncate is a bounded syscall).
686    pub fn checkpoint(&mut self) -> io::Result<()> {
687        self.ensure_no_active_transaction_for_checkpoint()?;
688        self.ensure_plain_checkpoint_allowed_before_flush()?;
689        self.flush_checkpoint_state()?;
690        self.wal.flush()?;
691        self.record_durable_lsn_at_least(self.wal.last_appended_lsn())?;
692        self.wal.truncate()?;
693        self.checkpointed = true;
694        Ok(())
695    }
696
697    /// Flush every dirty heap page, archive retained WAL records, then
698    /// truncate the WAL. Sync-aware callers use this to make archive-before-
699    /// truncate explicit without making storage depend on the sync crate.
700    ///
701    /// Replication boundary: this hook is for retained-history publication.
702    /// It should stay behind sync-aware lifecycle helpers rather than becoming
703    /// an ordinary checkpoint surface for application code.
704    pub fn checkpoint_with_wal_archive<F>(&mut self, mut archive: F) -> io::Result<()>
705    where
706        F: FnMut(&Path, &[WalRecord]) -> io::Result<()>,
707    {
708        self.ensure_no_active_transaction_for_checkpoint()?;
709        self.commit_autocommit()?;
710        self.flush_checkpoint_state()?;
711        self.wal.flush()?;
712        let records = self.wal.read_all()?;
713        let archive: WalArchiveCallback<'_> = &mut archive;
714        archive(&self.data_dir, &records)?;
715        if let Some(max_lsn) = max_record_lsn(&records) {
716            self.record_durable_lsn_at_least(max_lsn)?;
717        } else {
718            self.record_durable_lsn_at_least(self.wal.last_appended_lsn())?;
719        }
720        self.wal.truncate()?;
721        self.checkpointed = true;
722        Ok(())
723    }
724
725    fn ensure_no_active_transaction_for_checkpoint(&self) -> io::Result<()> {
726        if self.active_tx_id.is_some() {
727            return Err(io::Error::other(
728                "cannot checkpoint while an explicit transaction is active",
729            ));
730        }
731        Ok(())
732    }
733
734    fn flush_checkpoint_state(&mut self) -> io::Result<()> {
735        for tbl in &mut self.tables {
736            tbl.heap.flush_all_dirty()?;
737            tbl.heap.flush()?;
738            // Blocker B3: the hot insert/update/delete paths no longer
739            // fsync index files per row — they only mark the in-memory
740            // btree dirty. Checkpoint is where those deferred saves
741            // actually hit disk. Clean (non-dirty) indexes are free.
742            tbl.save_dirty_indexes()?;
743        }
744        Ok(())
745    }
746
747    fn ensure_plain_checkpoint_allowed_before_flush(&self) -> io::Result<()> {
748        if !self.sync_identity_file_exists() {
749            return Ok(());
750        }
751        if self.wal.has_pending() {
752            return Err(io::Error::other(
753                "sync identity exists but checkpoint/recovery was called without a WAL archive hook; refusing to truncate retained history",
754            ));
755        }
756        let records = self.wal.read_all()?;
757        self.ensure_plain_wal_truncate_allowed(&records)
758    }
759
760    fn ensure_plain_wal_truncate_allowed(&self, records: &[WalRecord]) -> io::Result<()> {
761        if records.is_empty() {
762            return Ok(());
763        }
764        if self.sync_identity_file_exists() {
765            return Err(io::Error::other(
766                "sync identity exists but checkpoint/recovery was called without a WAL archive hook; refusing to truncate retained history",
767            ));
768        }
769        Ok(())
770    }
771
772    fn sync_identity_file_exists(&self) -> bool {
773        self.data_dir
774            .join(SYNC_STATE_DIR)
775            .join(SYNC_IDENTITY_FILE)
776            .exists()
777    }
778
779    fn record_durable_lsn_at_least(&mut self, lsn: u64) -> io::Result<()> {
780        if lsn <= self.durable_lsn {
781            return Ok(());
782        }
783        self.durable_lsn = lsn;
784        write_durable_lsn(&self.data_dir, lsn)
785    }
786
787    /// Allocate or return the transaction id for the current mutation.
788    #[inline]
789    fn next_tx(&mut self) -> u64 {
790        if let Some(id) = self.active_tx_id {
791            return id;
792        }
793        let id = self.next_tx_id;
794        self.next_tx_id = self.next_tx_id.wrapping_add(1);
795        id
796    }
797
798    /// Begin a connection/session-scoped explicit transaction.
799    pub fn begin_transaction(&mut self) -> io::Result<()> {
800        if self.active_tx_id.is_some() {
801            return Err(io::Error::new(
802                io::ErrorKind::InvalidInput,
803                "explicit transaction is already active",
804            ));
805        }
806        let start_len = self.wal.synced_len()?;
807        let id = self.next_tx_id;
808        self.next_tx_id = self.next_tx_id.wrapping_add(1);
809        self.active_tx_id = Some(id);
810        self.tx_start_len = Some(start_len);
811        self.pending_autocommit_tx_ids.clear();
812        if !self.wal.is_off() {
813            self.wal.append(id, WalRecordType::Begin, &[])?;
814            self.wal.flush()?;
815        }
816        Ok(())
817    }
818
819    /// Commit the active explicit transaction by appending a durable boundary
820    /// marker after its row records.
821    pub fn commit_transaction(&mut self) -> io::Result<()> {
822        if let Some(id) = self.active_tx_id.take() {
823            if !self.wal.is_off() {
824                self.wal.append(id, WalRecordType::Commit, &[])?;
825                self.wal.flush()?;
826            }
827        }
828        self.tx_start_len = None;
829        Ok(())
830    }
831
832    /// Commit any autocommit row mutations accumulated by the current
833    /// statement. Pure reads/DDL have no pending tx ids and fall through to a
834    /// cheap WAL flush/no-op.
835    pub fn commit_autocommit(&mut self) -> io::Result<()> {
836        if !self.wal.is_off() && !self.pending_autocommit_tx_ids.is_empty() {
837            self.pending_autocommit_tx_ids.sort_unstable();
838            self.pending_autocommit_tx_ids.dedup();
839            for id in self.pending_autocommit_tx_ids.drain(..) {
840                self.wal.append(id, WalRecordType::Commit, &[])?;
841            }
842        }
843        self.wal.flush()
844    }
845
846    /// Append a mutation record to the WAL buffer. **Does not flush.**
847    ///
848    /// Mission B (post-review): per-row `wal.flush()` was a ~1ms fsync on
849    /// every mutation, turning `update_by_filter` into a ~19s workload.
850    /// The flush is now deferred to [`Self::sync_wal`], which the executor
851    /// calls exactly once at the end of every mutating statement. This
852    /// gives us statement-level group commit: N-row updates pay one fsync,
853    /// not N.
854    ///
855    /// Durability contract: any path that observes `Ok(...)` back from
856    /// the executor must have called `sync_wal` before returning that
857    /// Ok. Replay is still correct because WAL records are appended in
858    /// order and only records that reached `fdatasync`ed bytes are
859    /// replayed.
860    fn wal_log(
861        &mut self,
862        tx_id: u64,
863        record_type: WalRecordType,
864        table: &str,
865        rid: RowId,
866        row_bytes: &[u8],
867    ) -> io::Result<()> {
868        // Mission B (post-review, second pass): when the WAL is in Off
869        // mode the `append` call below is a no-op, so building the
870        // payload first wastes a `Vec` allocation + ~3 extends per
871        // mutation. The catalog hot paths check `wal.is_off()` before
872        // calling here, but this guard is the belt-and-braces version
873        // for any internal caller that doesn't.
874        if self.wal.is_off() {
875            return Ok(());
876        }
877        let payload = encode_wal_payload(table, rid, row_bytes);
878        self.wal.append(tx_id, record_type, &payload)?;
879        if self.active_tx_id.is_none() {
880            self.pending_autocommit_tx_ids.push(tx_id);
881        }
882        Ok(())
883    }
884
885    /// Flush any buffered WAL records to disk. Called by the executor
886    /// at the end of every mutating statement so the group-commit
887    /// window is exactly one statement.
888    ///
889    /// See [`Self::wal_log`] for the durability contract.
890    #[inline]
891    pub fn sync_wal(&mut self) -> io::Result<()> {
892        self.wal.flush()
893    }
894
895    /// Set the WAL sync mode. Production code should leave this at the
896    /// default ([`WalSyncMode::Full`]). Benchmarks set it to
897    /// [`WalSyncMode::Off`] to compare apples-to-apples against
898    /// `:memory:` SQLite (which has zero fsync cost).
899    ///
900    /// **Never** call this with `Off` in production — a machine crash
901    /// can lose any record written since the last `sync_wal` returned.
902    pub fn set_wal_sync_mode(&mut self, mode: WalSyncMode) {
903        self.wal.set_sync_mode(mode);
904    }
905
906    /// Discard in-memory mutations made since the last `sync_wal()` and
907    /// restore the catalog to its on-disk state. Used by ROLLBACK to
908    /// undo an in-progress transaction's changes.
909    ///
910    /// This re-opens the catalog from the checkpoint file and replays
911    /// only the durable (already flushed) WAL records. Any WAL records
912    /// that were appended but not yet flushed are lost.
913    ///
914    /// **Critical**: before replacing `*self` we must discard every
915    /// dirty in-memory page across all heaps. Otherwise the old
916    /// `Catalog`'s `Drop` impl calls `checkpoint()` which flushes those
917    /// dirty pages to disk — and the freshly-opened replacement catalog
918    /// would then read the flushed (uncommitted) rows back, defeating
919    /// the entire rollback.
920    pub fn rollback_to_last_sync(&mut self) -> io::Result<()> {
921        self.rollback_to_last_sync_inner(None)
922    }
923
924    /// Roll back the active transaction, then reopen/replay any remaining WAL
925    /// through an archive hook before recovery truncates it. Sync-aware callers
926    /// use this when committed pre-transaction records must remain available to
927    /// replicas after rollback.
928    pub fn rollback_to_last_sync_with_wal_archive<F>(&mut self, mut archive: F) -> io::Result<()>
929    where
930        F: FnMut(&Path, &[WalRecord]) -> io::Result<()>,
931    {
932        let archive: WalArchiveCallback<'_> = &mut archive;
933        self.rollback_to_last_sync_inner(Some(archive))
934    }
935
936    fn rollback_to_last_sync_inner(
937        &mut self,
938        mut archive: Option<WalArchiveCallback<'_>>,
939    ) -> io::Result<()> {
940        let start_len = self.tx_start_len.unwrap_or(0);
941        let prearchived = if let Some(archive) = archive.as_mut() {
942            let records = self.wal.read_through_len(start_len)?;
943            if !records.is_empty() {
944                archive(&self.data_dir, &records)?;
945            }
946            true
947        } else {
948            false
949        };
950
951        let start_len = self.tx_start_len.take().unwrap_or(0);
952        if let Some(id) = self.active_tx_id.take() {
953            if !self.wal.is_off() {
954                let _ = self.wal.append(id, WalRecordType::Rollback, &[]);
955            }
956        }
957        self.wal.discard_and_truncate_to(start_len)?;
958
959        // Step 1: throw away every uncommitted in-memory write so the
960        // upcoming Drop of `*self` has nothing dirty to flush.
961        for tbl in &mut self.tables {
962            tbl.heap.discard_dirty();
963        }
964        // Step 2: discard WAL records appended since the last explicit
965        // sync point. Large pending records can spill through BufWriter and
966        // become file-visible before `sync_wal()`; truncating to the last
967        // synced boundary prevents `open()` below from replaying rolled-back
968        // transaction records.
969        self.wal.discard_pending()?;
970        // Step 3: re-open the catalog from disk. The heap files on disk
971        // still reflect the last checkpoint (pre-transaction state)
972        // because we never flushed the transaction's dirty pages.
973        let data_dir = self.data_dir.clone();
974        let sync_mode = self.wal.sync_mode();
975        let restored = if prearchived {
976            let mut already_archived = |_dir: &Path, _records: &[WalRecord]| Ok(());
977            let archive: WalArchiveCallback<'_> = &mut already_archived;
978            Self::open_inner(&data_dir, Some(archive))?
979        } else {
980            Self::open_inner(&data_dir, archive)?
981        };
982        *self = restored;
983        self.wal.set_sync_mode(sync_mode);
984        Ok(())
985    }
986
987    fn abandon_active_transaction_for_drop(&mut self) -> io::Result<()> {
988        for tbl in &mut self.tables {
989            tbl.heap.discard_dirty();
990        }
991        self.pending_autocommit_tx_ids.clear();
992        let truncate_result = match self.tx_start_len.take() {
993            Some(start_len) => self.wal.discard_and_truncate_to(start_len),
994            None => self.wal.discard_pending(),
995        };
996        self.active_tx_id = None;
997        truncate_result
998    }
999
1000    /// Returns a reference to the data directory.
1001    pub fn data_dir(&self) -> &Path {
1002        &self.data_dir
1003    }
1004
1005    /// Highest page LSN across all tables (0 if nothing has been written).
1006    /// This is the durability high-water mark — the LSN a backup taken now
1007    /// corresponds to, and the value `Catalog::open` uses to restore
1008    /// `next_lsn` after a reopen/restore.
1009    pub fn max_lsn(&self) -> u64 {
1010        let max_page_lsn = self
1011            .tables
1012            .iter()
1013            .map(|t| t.heap.max_page_lsn())
1014            .max()
1015            .unwrap_or(0);
1016        max_page_lsn
1017            .max(self.durable_lsn)
1018            .max(self.wal.last_appended_lsn())
1019    }
1020
1021    pub fn create_table(&mut self, schema: Schema) -> io::Result<()> {
1022        self.create_table_full(schema, Vec::new(), Vec::new())
1023    }
1024
1025    /// Create a table whose columns carry literal defaults. `defaults` is
1026    /// aligned to `schema.columns` by position (and may be shorter / empty for
1027    /// columns without a default).
1028    pub fn create_table_with_defaults(
1029        &mut self,
1030        schema: Schema,
1031        defaults: Vec<Option<Value>>,
1032    ) -> io::Result<()> {
1033        self.create_table_full(schema, defaults, Vec::new())
1034    }
1035
1036    /// Create a table with per-column literal defaults and auto-increment
1037    /// flags. Both vecs are aligned to `schema.columns` by position (and may be
1038    /// empty). Defaults and auto flags are WAL-logged and persisted in the
1039    /// catalog so they survive a restart.
1040    pub fn create_table_full(
1041        &mut self,
1042        schema: Schema,
1043        defaults: Vec<Option<Value>>,
1044        auto_cols: Vec<bool>,
1045    ) -> io::Result<()> {
1046        validate_table_name(&schema.table_name)?;
1047        for col in &schema.columns {
1048            validate_column_name(&col.name)?;
1049        }
1050        let name = schema.table_name.clone();
1051        if self.name_to_slot.contains_key(&name) {
1052            return Err(io::Error::new(
1053                io::ErrorKind::AlreadyExists,
1054                format!("table '{name}' already exists"),
1055            ));
1056        }
1057        if !self.wal.is_off() {
1058            let payload = encode_ddl_create_table(&schema, &defaults, &auto_cols);
1059            self.wal
1060                .append(0, WalRecordType::DdlCreateTable, &payload)?;
1061            self.wal.flush()?;
1062        }
1063        let mut table = Table::create(schema, &self.data_dir)?;
1064        table.set_defaults(defaults);
1065        table.set_auto_cols(auto_cols);
1066        let slot = self.tables.len();
1067        self.tables.push(table);
1068        self.name_to_slot.insert(name, slot);
1069        self.persist()?;
1070        Ok(())
1071    }
1072
1073    /// Per-column literal defaults for a table, aligned to its columns by
1074    /// position. `None` when the table is unknown; an empty slice when no
1075    /// column has a default.
1076    pub fn column_defaults(&self, table: &str) -> Option<&[Option<Value>]> {
1077        let slot = *self.name_to_slot.get(table)?;
1078        Some(self.tables[slot].defaults())
1079    }
1080
1081    /// Which columns of a table are `auto`, aligned to its columns by position.
1082    /// `None` when the table is unknown; an empty slice when none are auto.
1083    pub fn auto_columns(&self, table: &str) -> Option<&[bool]> {
1084        let slot = *self.name_to_slot.get(table)?;
1085        Some(self.tables[slot].auto_cols())
1086    }
1087
1088    /// Fill any omitted (`Empty`) auto column in `values` from the table's
1089    /// sequence and advance it. No-op when the table is unknown or has no auto
1090    /// columns.
1091    pub fn assign_auto_columns(&mut self, table: &str, values: &mut [Value]) {
1092        if let Some(&slot) = self.name_to_slot.get(table) {
1093            self.tables[slot].assign_auto(values);
1094        }
1095    }
1096
1097    /// Write the current set of schemas to disk atomically (write-then-rename).
1098    ///
1099    /// Mission 3: also writes the per-table list of indexed column names so
1100    /// `Catalog::open` can rehydrate b-tree indexes on restart.
1101    fn persist(&self) -> io::Result<()> {
1102        let cat_path = self.data_dir.join(CATALOG_FILE);
1103        let tmp_path = self.data_dir.join(format!("{CATALOG_FILE}.tmp"));
1104        let entries: Vec<CatalogEntryRef<'_>> = self
1105            .tables
1106            .iter()
1107            .map(|t| CatalogEntryRef {
1108                schema: &t.schema,
1109                indexed_cols: t.indexed_column_metas(),
1110                defaults: t.defaults(),
1111                auto_cols: t.auto_cols(),
1112            })
1113            .collect();
1114        write_catalog_file(&tmp_path, &entries)?;
1115        fs::rename(&tmp_path, &cat_path)?;
1116        Ok(())
1117    }
1118
1119    /// Resolve a table name to its stable slot index. Prepared-query
1120    /// fast paths cache this once and skip the hash probe on every
1121    /// subsequent execution. Slots never shift once assigned.
1122    #[inline]
1123    pub fn table_slot(&self, name: &str) -> Option<usize> {
1124        self.name_to_slot.get(name).copied()
1125    }
1126
1127    /// O(1) slot-indexed table access. Panics on an out-of-range slot
1128    /// — callers must have obtained the slot via `table_slot()`.
1129    #[inline]
1130    pub fn table_by_slot(&self, slot: usize) -> &Table {
1131        &self.tables[slot]
1132    }
1133
1134    /// Mutable counterpart to [`Self::table_by_slot`].
1135    #[inline]
1136    pub fn table_by_slot_mut(&mut self, slot: usize) -> &mut Table {
1137        &mut self.tables[slot]
1138    }
1139
1140    pub fn get_table(&self, name: &str) -> Option<&Table> {
1141        let slot = *self.name_to_slot.get(name)?;
1142        Some(&self.tables[slot])
1143    }
1144
1145    pub fn get_table_mut(&mut self, name: &str) -> Option<&mut Table> {
1146        let slot = *self.name_to_slot.get(name)?;
1147        Some(&mut self.tables[slot])
1148    }
1149
1150    /// Private helper: resolve a table name to `&Table`, or return an
1151    /// `io::Error` with the same "table '<name>' not found" message the
1152    /// older `get_mut().ok_or_else(...)` callers produced. Phase 18
1153    /// consolidates ~14 copies of that idiom into this one place.
1154    #[inline]
1155    fn by_name(&self, table: &str) -> io::Result<&Table> {
1156        let slot = *self.name_to_slot.get(table).ok_or_else(|| {
1157            io::Error::new(
1158                io::ErrorKind::NotFound,
1159                format!("table '{table}' not found"),
1160            )
1161        })?;
1162        Ok(&self.tables[slot])
1163    }
1164
1165    /// Mutable counterpart to [`Self::by_name`].
1166    #[inline]
1167    fn by_name_mut(&mut self, table: &str) -> io::Result<&mut Table> {
1168        let slot = *self.name_to_slot.get(table).ok_or_else(|| {
1169            io::Error::new(
1170                io::ErrorKind::NotFound,
1171                format!("table '{table}' not found"),
1172            )
1173        })?;
1174        Ok(&mut self.tables[slot])
1175    }
1176
1177    pub fn insert(&mut self, table: &str, values: &Row) -> io::Result<RowId> {
1178        // Mission 2: encode the row into a scratch buffer first so we can
1179        // log it to the WAL before touching the heap. We re-encode inside
1180        // `Table::insert`, which keeps the insert hot path untouched — the
1181        // WAL encode here is additive.
1182        //
1183        // Mission B (post-review, second pass): in `WalSyncMode::Off` the
1184        // entire WAL pipeline is a no-op, so skip the per-row
1185        // `encode_row_into` allocation and `wal_log` call entirely.
1186        if self.wal.is_off() {
1187            return self.by_name_mut(table)?.insert(values);
1188        }
1189        let tbl = self.by_name_mut(table)?;
1190        let mut wal_bytes: Vec<u8> = Vec::new();
1191        encode_row_into(&tbl.schema, values, &mut wal_bytes);
1192        // Insert into the heap FIRST so we can log the record with the real
1193        // RowId. Replay needs the true (page, slot) to (a) know which page
1194        // an Insert targets — for the per-page LSN durability check in
1195        // `replay_wal` — and (b) reproduce the exact slot assignment, which
1196        // makes Insert replay idempotent (no duplicate rows after a
1197        // partial-flush crash, the v0.4.x data-loss bug).
1198        //
1199        // Ordering note: mutating the heap before appending+fsyncing the WAL
1200        // is safe. A crash between the heap insert and the WAL append leaves
1201        // the row only in an un-fsynced hot page (not durable) and the
1202        // statement has not returned `Ok` (the executor's end-of-statement
1203        // `sync_wal` hasn't run), so the write was never acknowledged.
1204        // Durability is still gated on the WAL fsync at statement end.
1205        let new_rid = tbl.insert(values)?;
1206        let tx_id = self.next_tx();
1207        self.wal_log(tx_id, WalRecordType::Insert, table, new_rid, &wal_bytes)?;
1208        // Stamp the landing page with this record's LSN so a future replay
1209        // recognises the row as already persisted (per-page idempotency).
1210        // The page is hot (just inserted into), so this is an in-memory
1211        // header write — no extra I/O on the insert hot path.
1212        let lsn = self.wal.last_appended_lsn();
1213        if lsn > 0 {
1214            self.by_name_mut(table)?
1215                .heap
1216                .set_page_lsn(new_rid.page_id, lsn)?;
1217        }
1218        Ok(new_rid)
1219    }
1220
1221    /// WAL-logged insert addressed by table slot index instead of name.
1222    /// Backs the executor's prepared-insert fast path, which resolves the
1223    /// slot at prepare time to skip the name→slot hash probe. Behaves exactly
1224    /// like [`Self::insert`] (logs the record with the real RowId, stamps the
1225    /// landing page's LSN) — the prepared path previously called the raw
1226    /// `Table::insert` and bypassed the WAL entirely, silently losing every
1227    /// prepared insert on a crash.
1228    pub fn insert_by_slot(&mut self, slot: usize, values: &Row) -> io::Result<RowId> {
1229        if self.wal.is_off() {
1230            return self.tables[slot].insert(values);
1231        }
1232        let tx_id = self.next_tx();
1233        let autocommit = self.active_tx_id.is_none();
1234        let Catalog { tables, wal, .. } = self;
1235        let tbl = &mut tables[slot];
1236        let mut wal_bytes: Vec<u8> = Vec::new();
1237        encode_row_into(&tbl.schema, values, &mut wal_bytes);
1238        // Insert first so the WAL record carries the real RowId (see
1239        // `insert` for the ordering/durability argument).
1240        let new_rid = tbl.insert(values)?;
1241        let payload = encode_wal_payload(&tbl.schema.table_name, new_rid, &wal_bytes);
1242        wal.append(tx_id, WalRecordType::Insert, &payload)?;
1243        if autocommit {
1244            self.pending_autocommit_tx_ids.push(tx_id);
1245        }
1246        let lsn = wal.last_appended_lsn();
1247        if lsn > 0 {
1248            tbl.heap.set_page_lsn(new_rid.page_id, lsn)?;
1249        }
1250        Ok(new_rid)
1251    }
1252
1253    pub fn get(&self, table: &str, rid: RowId) -> Option<Row> {
1254        self.get_table(table)?.get(rid)
1255    }
1256
1257    pub fn delete(&mut self, table: &str, rid: RowId) -> io::Result<()> {
1258        // Mission B (post-review, second pass): WAL Off → no payload
1259        // construction.
1260        if self.wal.is_off() {
1261            return self.by_name_mut(table)?.delete(rid);
1262        }
1263        let tx_id = self.next_tx();
1264        // Delete records carry only the rid — no row payload.
1265        self.wal_log(tx_id, WalRecordType::Delete, table, rid, &[])?;
1266        self.by_name_mut(table)?.delete(rid)
1267    }
1268
1269    /// Mission C Phase 12: bulk delete a list of rids, batching btree
1270    /// maintenance. See [`Table::delete_many`] for the full explanation
1271    /// and fall-through rules. Returns the number of rows removed.
1272    pub fn delete_many(&mut self, table: &str, rids: &[RowId]) -> io::Result<u64> {
1273        // Mission 2: log every rid as an individual Delete record. The
1274        // WAL flush is deferred to the executor's statement-end
1275        // `sync_wal` — see [`Self::wal_log`] for the group-commit rules.
1276        //
1277        // Mission B (post-review, second pass): in Off mode skip the
1278        // entire per-row payload loop — `wal.append` would no-op every
1279        // call but the `encode_wal_payload` Vec alloc would still run.
1280        if self.wal.is_off() {
1281            return self.by_name_mut(table)?.delete_many(rids);
1282        }
1283        let tx_id = self.next_tx();
1284        for &rid in rids {
1285            let payload = encode_wal_payload(table, rid, &[]);
1286            self.wal.append(tx_id, WalRecordType::Delete, &payload)?;
1287        }
1288        if self.active_tx_id.is_none() && !rids.is_empty() {
1289            self.pending_autocommit_tx_ids.push(tx_id);
1290        }
1291        self.by_name_mut(table)?.delete_many(rids)
1292    }
1293
1294    /// Mission C Phase 16: single-pass scan-and-delete driven by a
1295    /// raw-bytes predicate. See [`Table::scan_delete_matching`] and
1296    /// [`HeapFile::scan_delete_matching`] for the fusion rationale.
1297    ///
1298    /// Mission B2: prefer [`Self::scan_delete_matching_logged`] from any
1299    /// caller that needs crash durability. This variant writes no WAL
1300    /// records, so a crash between the scan and the next checkpoint
1301    /// would lose the deletes. Kept here for internal paths (e.g.
1302    /// `drop_table`) where the whole heap is about to be removed anyway.
1303    pub fn scan_delete_matching<P>(&mut self, table: &str, pred: P) -> io::Result<u64>
1304    where
1305        P: FnMut(&[u8]) -> bool,
1306    {
1307        self.by_name_mut(table)?.scan_delete_matching(pred)
1308    }
1309
1310    /// Mission B2: WAL-logged variant of [`Self::scan_delete_matching`].
1311    /// Every matched row emits one `WalRecordType::Delete` record in the
1312    /// same single-pass scan (via the table's `_with_hook` variant), so
1313    /// crash recovery sees every deletion. Used by the executor's
1314    /// `Delete(Filter(SeqScan))` and bare `Delete(SeqScan)` fast paths.
1315    ///
1316    /// Performance cost vs the non-logged primitive is one per-row WAL
1317    /// append into the in-memory buffer plus one `fsync` at the end —
1318    /// the heap scan itself still runs as a single pass with one
1319    /// `ensure_hot` per page.
1320    pub fn scan_delete_matching_logged<P>(&mut self, table: &str, pred: P) -> io::Result<u64>
1321    where
1322        P: FnMut(&[u8]) -> bool,
1323    {
1324        // Mission B (post-review, second pass): in Off mode the per-row
1325        // hook would build a Vec, do five extends, and then `append`
1326        // would no-op. Skip the WAL hook entirely and route through
1327        // the no-WAL primitive — same single-pass scan, zero per-row
1328        // payload work.
1329        if self.wal.is_off() {
1330            return self.by_name_mut(table)?.scan_delete_matching(pred);
1331        }
1332        // Resolve slot up front so we can split the borrow — the user
1333        // hook closes over `&mut self.wal`, which can't coexist with a
1334        // `by_name_mut` borrow of `self.tables`.
1335        let slot = *self.name_to_slot.get(table).ok_or_else(|| {
1336            io::Error::new(
1337                io::ErrorKind::NotFound,
1338                format!("table '{table}' not found"),
1339            )
1340        })?;
1341        let tx_id = self.next_tx();
1342        let autocommit = self.active_tx_id.is_none();
1343        // Split-borrow the catalog fields so the hook can write into
1344        // `wal` while the scan pins `tables[slot]` mutably.
1345        let Catalog { tables, wal, .. } = self;
1346        let tbl = &mut tables[slot];
1347        // Pre-encode the table-name prefix of every WAL payload once —
1348        // it doesn't vary row-to-row, and the per-row rid+row bytes are
1349        // the only things we append inside the hook.
1350        let name_bytes = table.as_bytes();
1351        let count = tbl.scan_delete_matching_with_hook(pred, |rid, row_bytes| {
1352            let mut payload: Vec<u8> =
1353                Vec::with_capacity(4 + name_bytes.len() + 10 + row_bytes.len());
1354            payload.extend_from_slice(&(name_bytes.len() as u32).to_le_bytes());
1355            payload.extend_from_slice(name_bytes);
1356            payload.extend_from_slice(&rid.page_id.to_le_bytes());
1357            payload.extend_from_slice(&rid.slot_index.to_le_bytes());
1358            // Delete records carry no row payload on replay, but we
1359            // match the `encode_wal_payload` layout so `decode_wal_payload`
1360            // (which is type-agnostic) parses them cleanly.
1361            payload.extend_from_slice(&0u32.to_le_bytes());
1362            // Best-effort append — if it errors we have no way to
1363            // propagate from inside the hook; we swallow it here and
1364            // the outer scan's `io::Result` will still succeed. In
1365            // practice the `BufWriter`-backed `Wal::append` only errors
1366            // on allocation failure or a disk-full fsync, both of
1367            // which would fail the outer flush below as well.
1368            let _ = wal.append(tx_id, WalRecordType::Delete, &payload);
1369        })?;
1370        if autocommit && count > 0 {
1371            self.pending_autocommit_tx_ids.push(tx_id);
1372        }
1373        // Flush is deferred to the executor's statement-end `sync_wal`.
1374        Ok(count)
1375    }
1376
1377    /// Single-pass fused scan + in-place patch with WAL logging.
1378    /// Evaluates `pred` on raw row bytes and applies `try_mutate` to each
1379    /// match on the same hot page — no second pass. Returns
1380    /// `(patched_count, fallback_rids)`.
1381    ///
1382    /// Perf sprint: update analogue of `scan_delete_matching_logged`.
1383    /// Eliminates the two-pass collect-then-patch pattern.
1384    pub fn scan_patch_matching_logged<P, M>(
1385        &mut self,
1386        table: &str,
1387        pred: P,
1388        try_mutate: M,
1389    ) -> io::Result<(u64, Vec<RowId>)>
1390    where
1391        P: FnMut(&[u8]) -> bool,
1392        M: FnMut(&mut [u8]) -> Option<u16>,
1393    {
1394        if self.wal.is_off() {
1395            return self.by_name_mut(table)?.scan_patch_matching_with_hook(
1396                pred,
1397                try_mutate,
1398                |_, _| {},
1399            );
1400        }
1401        let slot = *self.name_to_slot.get(table).ok_or_else(|| {
1402            io::Error::new(
1403                io::ErrorKind::NotFound,
1404                format!("table '{table}' not found"),
1405            )
1406        })?;
1407        let tx_id = self.next_tx();
1408        let autocommit = self.active_tx_id.is_none();
1409        let Catalog { tables, wal, .. } = self;
1410        let tbl = &mut tables[slot];
1411        let name_bytes = table.as_bytes();
1412        let result = tbl.scan_patch_matching_with_hook(pred, try_mutate, |rid, row_bytes| {
1413            let mut payload: Vec<u8> =
1414                Vec::with_capacity(4 + name_bytes.len() + 10 + row_bytes.len());
1415            payload.extend_from_slice(&(name_bytes.len() as u32).to_le_bytes());
1416            payload.extend_from_slice(name_bytes);
1417            payload.extend_from_slice(&rid.page_id.to_le_bytes());
1418            payload.extend_from_slice(&rid.slot_index.to_le_bytes());
1419            payload.extend_from_slice(&(row_bytes.len() as u32).to_le_bytes());
1420            payload.extend_from_slice(row_bytes);
1421            let _ = wal.append(tx_id, WalRecordType::Update, &payload);
1422        })?;
1423        if autocommit && result.0 > 0 {
1424            self.pending_autocommit_tx_ids.push(tx_id);
1425        }
1426        Ok(result)
1427    }
1428
1429    pub fn update(&mut self, table: &str, rid: RowId, values: &Row) -> io::Result<RowId> {
1430        // Mission B (post-review, second pass): WAL Off → no payload
1431        // construction.
1432        if self.wal.is_off() {
1433            return self.by_name_mut(table)?.update(rid, values);
1434        }
1435        let tbl = self.by_name_mut(table)?;
1436        let mut wal_bytes: Vec<u8> = Vec::new();
1437        encode_row_into(&tbl.schema, values, &mut wal_bytes);
1438        // Reject oversized rows BEFORE appending the WAL record: a logged
1439        // Update that the heap then rejects would poison the next replay.
1440        check_encoded_row_size(&wal_bytes)?;
1441        let tx_id = self.next_tx();
1442        self.wal_log(tx_id, WalRecordType::Update, table, rid, &wal_bytes)?;
1443        self.by_name_mut(table)?.update(rid, values)
1444    }
1445
1446    /// Mission C Phase 2: update with a hint about which columns actually
1447    /// changed. Lets [`Table::update_hinted`] skip the old-row read when
1448    /// the hint shows no indexed column is in the changed set.
1449    pub fn update_hinted(
1450        &mut self,
1451        table: &str,
1452        rid: RowId,
1453        values: &Row,
1454        changed_col_indices: Option<&[usize]>,
1455    ) -> io::Result<RowId> {
1456        // Mission B (post-review, second pass): WAL Off → no payload
1457        // construction. The `update_by_filter` powql bench drives this
1458        // path tens of thousands of times per iteration.
1459        if self.wal.is_off() {
1460            return self
1461                .by_name_mut(table)?
1462                .update_hinted(rid, values, changed_col_indices);
1463        }
1464        let tbl = self.by_name_mut(table)?;
1465        let mut wal_bytes: Vec<u8> = Vec::new();
1466        encode_row_into(&tbl.schema, values, &mut wal_bytes);
1467        // Same pre-WAL size gate as [`Self::update`].
1468        check_encoded_row_size(&wal_bytes)?;
1469        let tx_id = self.next_tx();
1470        self.wal_log(tx_id, WalRecordType::Update, table, rid, &wal_bytes)?;
1471        self.by_name_mut(table)?
1472            .update_hinted(rid, values, changed_col_indices)
1473    }
1474
1475    /// Mission C Phase 4: fast-path update that patches a row's raw bytes
1476    /// in place, skipping decode/encode. Caller guarantees the mutation
1477    /// preserves the row length and touches no indexed column. Returns
1478    /// `Ok(true)` if the patch landed, `Ok(false)` if the row is gone.
1479    ///
1480    /// Mission B2: this primitive does NOT log to the WAL. Executor
1481    /// callers must route through [`Self::update_row_bytes_logged`] (or
1482    /// [`Self::update_row_bytes_logged_by_slot`]) so crash recovery
1483    /// sees the patched bytes. This raw form is retained for replay
1484    /// itself and any future callers that can tolerate the non-durable
1485    /// contract.
1486    #[inline]
1487    pub fn with_row_bytes_mut<F>(&mut self, table: &str, rid: RowId, f: F) -> io::Result<bool>
1488    where
1489        F: FnOnce(&mut [u8]),
1490    {
1491        self.by_name_mut(table)?.with_row_bytes_mut(rid, f)
1492    }
1493
1494    /// Mission B2: WAL-logged variant of [`Self::with_row_bytes_mut`].
1495    /// Applies `f` to the live row bytes on the hot page, then reads
1496    /// the mutated bytes back and emits a `WalRecordType::Update`
1497    /// record so replay will re-apply the same patch after a crash.
1498    ///
1499    /// Ordering: the hot-page mutation happens first (in-memory only,
1500    /// no disk I/O), then the WAL record is appended and flushed. A
1501    /// crash after the mutation but before the WAL flush loses the
1502    /// update, but the caller never saw success in that case, so the
1503    /// contract holds: any `Ok(true)` return is durable.
1504    ///
1505    /// No hot-page eviction can happen between steps because this
1506    /// method holds the catalog's `&mut self` exclusively.
1507    #[inline]
1508    pub fn update_row_bytes_logged<F>(&mut self, table: &str, rid: RowId, f: F) -> io::Result<bool>
1509    where
1510        F: FnOnce(&mut [u8]),
1511    {
1512        let slot = *self.name_to_slot.get(table).ok_or_else(|| {
1513            io::Error::new(
1514                io::ErrorKind::NotFound,
1515                format!("table '{table}' not found"),
1516            )
1517        })?;
1518        self.update_row_bytes_logged_by_slot(slot, rid, f)
1519    }
1520
1521    /// Slot-indexed counterpart to [`Self::update_row_bytes_logged`].
1522    /// Used by prepared-query fast paths that already cached the table
1523    /// slot at prepare time and want to skip the name->slot probe on
1524    /// every execution.
1525    #[inline]
1526    pub fn update_row_bytes_logged_by_slot<F>(
1527        &mut self,
1528        slot: usize,
1529        rid: RowId,
1530        f: F,
1531    ) -> io::Result<bool>
1532    where
1533        F: FnOnce(&mut [u8]),
1534    {
1535        // Step 1: apply the mutation on the hot page. Failure here
1536        // (slot gone) short-circuits with Ok(false) — no WAL record.
1537        let tbl = &mut self.tables[slot];
1538        let ok = tbl.with_row_bytes_mut(rid, f)?;
1539        if !ok {
1540            return Ok(false);
1541        }
1542        // Mission B (post-review, second pass): in Off mode the per-row
1543        // get + clone + table-name clone + wal_log call are all wasted
1544        // — `wal.append` would no-op. Skip the snapshot path entirely.
1545        if self.wal.is_off() {
1546            return Ok(true);
1547        }
1548        // Step 2: snapshot the now-mutated bytes. `HeapFile::get`
1549        // observes the pinned hot page, so it returns the fresh row.
1550        let new_bytes = match tbl.heap.get(rid) {
1551            Some(b) => b,
1552            // Shouldn't happen — we just patched it — but be defensive.
1553            None => return Ok(false),
1554        };
1555        // Step 3: log + flush. Clone the table name out of the schema
1556        // so we can drop the `&mut tbl` borrow before touching `self.wal`.
1557        let table_name = tbl.schema.table_name.clone();
1558        let tx_id = self.next_tx();
1559        self.wal_log(tx_id, WalRecordType::Update, &table_name, rid, &new_bytes)?;
1560        Ok(true)
1561    }
1562
1563    /// Mission C Phase 10: var-column in-place update fast path. Patches
1564    /// a single variable-length column's bytes directly into the row's
1565    /// slot, shrinking the row if the new value is smaller. Returns
1566    /// `Ok(false)` if the new value would grow the row (caller must fall
1567    /// back to the full encode path) or the row is gone.
1568    ///
1569    /// Caller guarantees no indexed column is touched — indexes are NOT
1570    /// maintained by this primitive.
1571    ///
1572    /// Mission B2: not WAL-logged. Executor callers should use
1573    /// [`Self::patch_var_col_logged`] instead.
1574    #[inline]
1575    pub fn patch_var_col_in_place(
1576        &mut self,
1577        table: &str,
1578        rid: RowId,
1579        col_idx: usize,
1580        new_value: Option<&[u8]>,
1581    ) -> io::Result<bool> {
1582        self.by_name_mut(table)?
1583            .patch_var_col_in_place(rid, col_idx, new_value)
1584    }
1585
1586    /// Mission B2: WAL-logged variant of [`Self::patch_var_col_in_place`].
1587    /// Runs the in-place shrink on the hot page, then reads the mutated
1588    /// row bytes back and logs a `WalRecordType::Update` record. On a
1589    /// `false` return (grow-case bail) nothing is logged — the caller's
1590    /// fall-through to `update_hinted` handles the WAL itself.
1591    pub fn patch_var_col_logged(
1592        &mut self,
1593        table: &str,
1594        rid: RowId,
1595        col_idx: usize,
1596        new_value: Option<&[u8]>,
1597    ) -> io::Result<bool> {
1598        let slot = *self.name_to_slot.get(table).ok_or_else(|| {
1599            io::Error::new(
1600                io::ErrorKind::NotFound,
1601                format!("table '{table}' not found"),
1602            )
1603        })?;
1604        let tbl = &mut self.tables[slot];
1605        let ok = tbl.patch_var_col_in_place(rid, col_idx, new_value)?;
1606        if !ok {
1607            return Ok(false);
1608        }
1609        // Mission B (post-review, second pass): WAL Off → skip the
1610        // snapshot + clone + log entirely.
1611        if self.wal.is_off() {
1612            return Ok(true);
1613        }
1614        let new_bytes = match tbl.heap.get(rid) {
1615            Some(b) => b,
1616            None => return Ok(false),
1617        };
1618        let table_name = tbl.schema.table_name.clone();
1619        let tx_id = self.next_tx();
1620        self.wal_log(tx_id, WalRecordType::Update, &table_name, rid, &new_bytes)?;
1621        Ok(true)
1622    }
1623
1624    pub fn scan(&self, table: &str) -> io::Result<impl Iterator<Item = (RowId, Row)> + '_> {
1625        Ok(self.by_name(table)?.scan())
1626    }
1627
1628    /// Zero-copy scan: passes raw row bytes to the callback without any
1629    /// per-row allocation. Used by the executor's fast paths.
1630    pub fn for_each_row_raw<F>(&self, table: &str, f: F) -> io::Result<()>
1631    where
1632        F: FnMut(RowId, &[u8]),
1633    {
1634        self.by_name(table)?.for_each_row_raw(f);
1635        Ok(())
1636    }
1637
1638    /// Zero-copy scan with early termination. The callback returns
1639    /// `ControlFlow::Break(())` to stop. Used by `Limit` fast paths so a
1640    /// `limit 100` query doesn't pay decode/predicate cost for every row
1641    /// in the table after the limit is reached.
1642    pub fn try_for_each_row_raw<F>(&self, table: &str, f: F) -> io::Result<()>
1643    where
1644        F: FnMut(RowId, &[u8]) -> std::ops::ControlFlow<()>,
1645    {
1646        self.by_name(table)?.try_for_each_row_raw(f);
1647        Ok(())
1648    }
1649
1650    pub fn create_index(&mut self, table: &str, column: &str) -> io::Result<()> {
1651        self.create_index_unique(table, column, false)
1652    }
1653
1654    /// Create an index with an explicit uniqueness flag. `unique = true`
1655    /// for primary-key-like columns where duplicate values should
1656    /// overwrite. `unique = false` for secondary indexes that allow
1657    /// duplicate column values (the default via `create_index`).
1658    pub fn create_index_unique(
1659        &mut self,
1660        table: &str,
1661        column: &str,
1662        unique: bool,
1663    ) -> io::Result<()> {
1664        let data_dir = self.data_dir.clone();
1665        self.by_name_mut(table)?
1666            .create_index_with_unique(column, &data_dir, unique)?;
1667        // Mission 3: persist the updated catalog so the indexed column
1668        // list survives a restart. `Table::create_index` already saved
1669        // the btree file itself.
1670        self.persist()
1671    }
1672
1673    /// Whether `table.column` has a UNIQUE index. Returns `Some(true)` for
1674    /// a unique index, `Some(false)` for a non-unique index, and `None`
1675    /// when the column is not indexed or the table is unknown.
1676    pub fn is_index_unique(&self, table: &str, column: &str) -> Option<bool> {
1677        self.get_table(table)?.is_index_unique(column)
1678    }
1679
1680    /// Whether `table.column` has any index (unique or non-unique).
1681    pub fn has_index(&self, table: &str, column: &str) -> bool {
1682        self.get_table(table)
1683            .map(|t| t.has_index(column))
1684            .unwrap_or(false)
1685    }
1686
1687    pub fn index_lookup(&self, table: &str, column: &str, key: &Value) -> io::Result<Option<Row>> {
1688        Ok(self
1689            .by_name(table)?
1690            .index_lookup(column, key)
1691            .map(|(_, row)| row))
1692    }
1693
1694    pub fn list_tables(&self) -> Vec<&str> {
1695        // Phase 18: iterate the Vec directly — schema.table_name is
1696        // the source of truth, and Vec order is insertion order (more
1697        // deterministic than the old FxHashMap keys).
1698        self.tables
1699            .iter()
1700            .map(|t| t.schema.table_name.as_str())
1701            .collect()
1702    }
1703
1704    pub fn schema(&self, table: &str) -> Option<&Schema> {
1705        let slot = *self.name_to_slot.get(table)?;
1706        Some(&self.tables[slot].schema)
1707    }
1708
1709    /// Drop a table: remove from the catalog and delete its data files.
1710    /// Returns `Err` if the table doesn't exist.
1711    pub fn drop_table(&mut self, name: &str) -> io::Result<()> {
1712        validate_table_name(name)?;
1713        let slot = *self.name_to_slot.get(name).ok_or_else(|| {
1714            io::Error::new(io::ErrorKind::NotFound, format!("table '{name}' not found"))
1715        })?;
1716        if !self.wal.is_off() {
1717            let payload = encode_ddl_drop_table(name);
1718            self.wal.append(0, WalRecordType::DdlDropTable, &payload)?;
1719            self.wal.flush()?;
1720        }
1721        // Remove the data file.
1722        let table = &self.tables[slot];
1723        let heap_path = self
1724            .data_dir
1725            .join(format!("{}.heap", table.schema.table_name));
1726        if heap_path.exists() {
1727            fs::remove_file(&heap_path)?;
1728        }
1729        // Mission 3: remove only the .idx files that actually exist
1730        // (i.e. the columns the table currently has indexed). The pre-
1731        // Mission-3 code iterated every schema column blindly — harmless
1732        // but noisy. Now that we persist a real list of indexed columns,
1733        // we can be precise.
1734        for col_name in table.indexed_column_names() {
1735            let idx_path = self.data_dir.join(format!("{name}_{col_name}.idx"));
1736            if idx_path.exists() {
1737                let _ = fs::remove_file(&idx_path);
1738            }
1739        }
1740        // Swap-remove from the Vec and fix up name_to_slot.
1741        self.name_to_slot.remove(name);
1742        let last = self.tables.len() - 1;
1743        if slot != last {
1744            let moved_name = self.tables[last].schema.table_name.clone();
1745            self.tables.swap(slot, last);
1746            self.name_to_slot.insert(moved_name, slot);
1747        }
1748        self.tables.pop();
1749        self.persist()?;
1750        Ok(())
1751    }
1752
1753    /// Add a column to an existing table's schema and backfill all
1754    /// existing rows to match the new shape.
1755    ///
1756    /// Older versions of this method only mutated the in-memory schema
1757    /// and relied on a (false) claim that "the heap format already
1758    /// handles short rows gracefully". It doesn't: `decode_row` reads
1759    /// exactly `n_var + 1` variable-column offsets from the row bytes
1760    /// using the CURRENT schema. Any row encoded with the old schema's
1761    /// (smaller) offset table would walk off the end of its buffer and
1762    /// panic with "range end index X out of range for slice of length Y"
1763    /// — which is exactly what a bare `Type` scan triggered right after
1764    /// an ALTER ADD COLUMN.
1765    ///
1766    /// The fix: rewrite every existing row through
1767    /// [`Table::rewrite_rows_for_schema_change`] so the on-disk
1768    /// encoding matches the new schema layout. Existing rows get
1769    /// `Value::Empty` for the new column.
1770    ///
1771    /// If the new column is `required` we refuse to add it to a
1772    /// non-empty table — there is no default value to backfill with,
1773    /// and silently storing `Empty` in a required slot would just
1774    /// shift the invariant violation to the next query.
1775    pub fn alter_table_add_column(&mut self, table: &str, col: ColumnDef) -> io::Result<()> {
1776        let data_dir = self.data_dir.clone();
1777        {
1778            let tbl = self.by_name_mut(table)?;
1779            if tbl.schema.columns.iter().any(|c| c.name == col.name) {
1780                return Err(io::Error::new(
1781                    io::ErrorKind::AlreadyExists,
1782                    format!("column '{}' already exists in table '{table}'", col.name),
1783                ));
1784            }
1785        }
1786        let barrier_lsn = if !self.wal.is_off() {
1787            let payload = encode_ddl_alter_add_column(table, &col);
1788            self.wal.append(0, WalRecordType::DdlAddColumn, &payload)?;
1789            self.wal.flush()?;
1790            self.wal.last_appended_lsn()
1791        } else {
1792            0
1793        };
1794        let tbl = self.by_name_mut(table)?;
1795
1796        let old_schema = tbl.schema.clone();
1797
1798        // Peek at the heap to learn whether there are any existing
1799        // rows at all. An empty table is always safe to alter — no
1800        // rewrite needed, required columns are fine, etc.
1801        let has_rows = tbl.heap.scan().next().is_some();
1802
1803        if has_rows && col.required {
1804            return Err(io::Error::new(
1805                io::ErrorKind::InvalidInput,
1806                format!(
1807                    "cannot add required column '{}' to non-empty table '{table}': \
1808                     no default value to backfill existing rows with",
1809                    col.name
1810                ),
1811            ));
1812        }
1813
1814        // Commit the new column into the schema and refresh the
1815        // cached layout so the rewrite below encodes with the new
1816        // shape.
1817        tbl.schema.columns.push(col);
1818        tbl.refresh_layout();
1819
1820        if has_rows {
1821            // Build the "fill" template: all Empty, matching the new
1822            // schema width. `rewrite_rows_for_schema_change` will
1823            // overwrite old-column slots from each live row and leave
1824            // the new slot as Empty.
1825            let fill: Vec<Value> = vec![Value::Empty; tbl.schema.columns.len()];
1826            tbl.rewrite_rows_for_schema_change(&old_schema, &fill, &data_dir)?;
1827        }
1828        // P0 fix (v0.4.3): stamp every heap page with the DDL record's
1829        // LSN so any pre-DDL Insert/Update/Delete WAL record gets
1830        // skipped on replay. Without this barrier, a restart after
1831        // `alter add column` would replay pre-alter inserts (encoded in
1832        // the OLD layout) onto a heap that's already in the NEW layout,
1833        // producing a mixed-version heap that panics on the next
1834        // projection. Regression: see `restart_after_alter_add_column_then_index`.
1835        if barrier_lsn > 0 {
1836            tbl.heap.stamp_all_pages_min_lsn(barrier_lsn)?;
1837            tbl.heap.flush()?;
1838        }
1839
1840        self.persist()?;
1841        Ok(())
1842    }
1843
1844    /// Remove a column from an existing table's schema and rewrite
1845    /// every live row to match the new shape.
1846    ///
1847    /// Older versions of this method only mutated the in-memory schema
1848    /// and claimed that "reads simply won't decode the dropped column".
1849    /// That was wrong in several ways:
1850    ///
1851    ///   1. The null bitmap is indexed by column position. Dropping a
1852    ///      column shifts every later column's bit left, but old rows
1853    ///      still have bits in the original positions — so `is_null`
1854    ///      checks silently lie for every column after the dropped one.
1855    ///   2. The bitmap's byte width (`ceil(n_cols/8)`) can shrink when
1856    ///      `n_cols` crosses an 8-boundary, shifting every subsequent
1857    ///      byte of the row against the decoder's cursor.
1858    ///   3. Fixed-region size and the variable-offset-table width both
1859    ///      depend on the column set, so dropping any fixed or variable
1860    ///      column slides every following byte.
1861    ///
1862    /// The fix mirrors `alter_table_add_column`: snapshot the old
1863    /// schema, mutate to the new schema, then rewrite every row
1864    /// through [`Table::rewrite_rows_for_schema_change`]. Dropping a
1865    /// column from an empty table skips the rewrite.
1866    pub fn alter_table_drop_column(&mut self, table: &str, col_name: &str) -> io::Result<()> {
1867        let data_dir = self.data_dir.clone();
1868        {
1869            let tbl = self.by_name_mut(table)?;
1870            tbl.schema
1871                .columns
1872                .iter()
1873                .position(|c| c.name == col_name)
1874                .ok_or_else(|| {
1875                    io::Error::new(
1876                        io::ErrorKind::NotFound,
1877                        format!("column '{col_name}' not found in table '{table}'"),
1878                    )
1879                })?;
1880        }
1881        let barrier_lsn = if !self.wal.is_off() {
1882            let payload = encode_ddl_alter_drop_column(table, col_name);
1883            self.wal.append(0, WalRecordType::DdlDropColumn, &payload)?;
1884            self.wal.flush()?;
1885            self.wal.last_appended_lsn()
1886        } else {
1887            0
1888        };
1889        let tbl = self.by_name_mut(table)?;
1890        let idx = tbl
1891            .schema
1892            .columns
1893            .iter()
1894            .position(|c| c.name == col_name)
1895            .ok_or_else(|| {
1896                io::Error::new(
1897                    io::ErrorKind::NotFound,
1898                    format!("column '{col_name}' not found in table '{table}'"),
1899                )
1900            })?;
1901
1902        // Snapshot for decoding old rows.
1903        let old_schema = tbl.schema.clone();
1904        let has_rows = tbl.heap.scan().next().is_some();
1905
1906        // Commit the schema change.
1907        tbl.schema.columns.remove(idx);
1908        for (i, col) in tbl.schema.columns.iter_mut().enumerate() {
1909            col.position = i as u16;
1910        }
1911        tbl.refresh_layout();
1912
1913        if has_rows {
1914            // Build a filler matching the new (smaller) shape. The
1915            // rewrite path overwrites each new-column slot from the
1916            // matching old-column value by name, so the filler only
1917            // matters for brand-new columns — drop has none, so
1918            // `Empty` is a safe placeholder that never gets read.
1919            let fill: Vec<Value> = vec![Value::Empty; tbl.schema.columns.len()];
1920            tbl.rewrite_rows_for_schema_change(&old_schema, &fill, &data_dir)?;
1921        }
1922        // P0 fix: see matching comment in alter_table_add_column.
1923        if barrier_lsn > 0 {
1924            tbl.heap.stamp_all_pages_min_lsn(barrier_lsn)?;
1925            tbl.heap.flush()?;
1926        }
1927
1928        self.persist()?;
1929        Ok(())
1930    }
1931}
1932
1933impl Drop for Catalog {
1934    fn drop(&mut self) {
1935        if self.active_tx_id.is_some() {
1936            if let Err(e) = self.abandon_active_transaction_for_drop() {
1937                warn!(error = %e, "catalog drop active transaction cleanup failed");
1938            }
1939            return;
1940        }
1941        // Mission 2: best-effort clean shutdown. `checkpoint` flushes
1942        // every heap and truncates the WAL, which is what
1943        // [`Catalog::open`] relies on to know that no replay is needed.
1944        //
1945        // We swallow errors here because Rust's `Drop` can't propagate
1946        // them and panicking during unwind is always a bigger problem
1947        // than a failed flush. The worst case on a failed drop-time
1948        // checkpoint is that the next open sees a non-empty WAL and
1949        // replays it (potentially producing duplicates — see the
1950        // [`Self::replay_wal`] caveat). That's strictly better than
1951        // losing committed writes.
1952        if let Err(e) = self.checkpoint() {
1953            warn!(error = %e, "catalog drop checkpoint failed");
1954        }
1955    }
1956}
1957
1958// ─── WAL payload codec ─────────────────────────────────────────────────────
1959//
1960// Per-record payload layout (little-endian):
1961//
1962//   table_name_len : u32
1963//   table_name     : utf-8 bytes
1964//   page_id        : u32   (for insert: 0, ignored on replay)
1965//   slot_index     : u16   (for insert: 0, ignored on replay)
1966//   row_len        : u32
1967//   row_bytes      : raw encoded row (length = row_len)
1968//
1969// Lives next to `Catalog` because this is the only code that produces or
1970// consumes these records — the `Wal` itself is payload-agnostic.
1971
1972fn encode_wal_payload(table: &str, rid: RowId, row_bytes: &[u8]) -> Vec<u8> {
1973    let name = table.as_bytes();
1974    let mut out = Vec::with_capacity(4 + name.len() + 4 + 2 + 4 + row_bytes.len());
1975    out.extend_from_slice(&(name.len() as u32).to_le_bytes());
1976    out.extend_from_slice(name);
1977    out.extend_from_slice(&rid.page_id.to_le_bytes());
1978    out.extend_from_slice(&rid.slot_index.to_le_bytes());
1979    out.extend_from_slice(&(row_bytes.len() as u32).to_le_bytes());
1980    out.extend_from_slice(row_bytes);
1981    out
1982}
1983
1984fn decode_wal_payload(data: &[u8]) -> Option<(String, RowId, Vec<u8>)> {
1985    let mut pos = 0usize;
1986    if data.len() < 4 {
1987        return None;
1988    }
1989    let name_len = u32::from_le_bytes(data[pos..pos + 4].try_into().ok()?) as usize;
1990    pos += 4;
1991    if pos + name_len > data.len() {
1992        return None;
1993    }
1994    let name = std::str::from_utf8(&data[pos..pos + name_len])
1995        .ok()?
1996        .to_string();
1997    pos += name_len;
1998    if pos + 4 + 2 + 4 > data.len() {
1999        return None;
2000    }
2001    let page_id = u32::from_le_bytes(data[pos..pos + 4].try_into().ok()?);
2002    pos += 4;
2003    let slot_index = u16::from_le_bytes(data[pos..pos + 2].try_into().ok()?);
2004    pos += 2;
2005    let row_len = u32::from_le_bytes(data[pos..pos + 4].try_into().ok()?) as usize;
2006    pos += 4;
2007    if pos + row_len > data.len() {
2008        return None;
2009    }
2010    let row_bytes = data[pos..pos + row_len].to_vec();
2011    Some((
2012        name,
2013        RowId {
2014            page_id,
2015            slot_index,
2016        },
2017        row_bytes,
2018    ))
2019}
2020
2021// ─── DDL WAL payload codecs ─────────────────────────────────────────────────
2022
2023fn encode_ddl_create_table(
2024    schema: &Schema,
2025    defaults: &[Option<Value>],
2026    auto_cols: &[bool],
2027) -> Vec<u8> {
2028    let name = schema.table_name.as_bytes();
2029    let mut out = Vec::new();
2030    out.extend_from_slice(&(name.len() as u32).to_le_bytes());
2031    out.extend_from_slice(name);
2032    out.extend_from_slice(&(schema.columns.len() as u16).to_le_bytes());
2033    for col in &schema.columns {
2034        let cn = col.name.as_bytes();
2035        out.extend_from_slice(&(cn.len() as u32).to_le_bytes());
2036        out.extend_from_slice(cn);
2037        out.push(col.type_id as u8);
2038        out.push(col.required as u8);
2039        out.extend_from_slice(&col.position.to_le_bytes());
2040    }
2041    // Trailing sections. Records written before each feature existed simply
2042    // lack the corresponding trailing bytes, so the decoder treats their
2043    // absence as "none" (length-detected, append-only).
2044    encode_defaults_section(&mut out, defaults);
2045    encode_auto_section(&mut out, auto_cols);
2046    out
2047}
2048
2049fn decode_ddl_create_table(data: &[u8]) -> Option<(Schema, Vec<Option<Value>>, Vec<bool>)> {
2050    let mut pos = 0usize;
2051    if data.len() < 4 {
2052        return None;
2053    }
2054    let name_len = u32::from_le_bytes(data[pos..pos + 4].try_into().ok()?) as usize;
2055    pos += 4;
2056    if pos + name_len > data.len() {
2057        return None;
2058    }
2059    let table_name = std::str::from_utf8(&data[pos..pos + name_len])
2060        .ok()?
2061        .to_string();
2062    pos += name_len;
2063    if pos + 2 > data.len() {
2064        return None;
2065    }
2066    let n_cols = u16::from_le_bytes(data[pos..pos + 2].try_into().ok()?) as usize;
2067    pos += 2;
2068    let mut columns = Vec::with_capacity(n_cols);
2069    for _ in 0..n_cols {
2070        if pos + 4 > data.len() {
2071            return None;
2072        }
2073        let cn_len = u32::from_le_bytes(data[pos..pos + 4].try_into().ok()?) as usize;
2074        pos += 4;
2075        if pos + cn_len + 4 > data.len() {
2076            return None;
2077        }
2078        let col_name = std::str::from_utf8(&data[pos..pos + cn_len])
2079            .ok()?
2080            .to_string();
2081        pos += cn_len;
2082        let type_id = TypeId::from_u8(data[pos])?;
2083        pos += 1;
2084        let required = data[pos] != 0;
2085        pos += 1;
2086        if pos + 2 > data.len() {
2087            return None;
2088        }
2089        let position = u16::from_le_bytes(data[pos..pos + 2].try_into().ok()?);
2090        pos += 2;
2091        columns.push(ColumnDef {
2092            name: col_name,
2093            type_id,
2094            required,
2095            position,
2096        });
2097    }
2098    // Trailing sections are present on records written after each feature
2099    // landed; older records end early, decoding to "none".
2100    let defaults = if pos < data.len() {
2101        decode_defaults_section(data, &mut pos, columns.len())?
2102    } else {
2103        Vec::new()
2104    };
2105    let auto_cols = if pos < data.len() {
2106        decode_auto_section(data, &mut pos, columns.len())?
2107    } else {
2108        Vec::new()
2109    };
2110    Some((
2111        Schema {
2112            table_name,
2113            columns,
2114        },
2115        defaults,
2116        auto_cols,
2117    ))
2118}
2119
2120fn encode_ddl_drop_table(table_name: &str) -> Vec<u8> {
2121    let name = table_name.as_bytes();
2122    let mut out = Vec::with_capacity(4 + name.len());
2123    out.extend_from_slice(&(name.len() as u32).to_le_bytes());
2124    out.extend_from_slice(name);
2125    out
2126}
2127
2128fn encode_ddl_alter_add_column(table_name: &str, col: &ColumnDef) -> Vec<u8> {
2129    let name = table_name.as_bytes();
2130    let cn = col.name.as_bytes();
2131    let mut out = Vec::with_capacity(4 + name.len() + 4 + cn.len() + 4);
2132    out.extend_from_slice(&(name.len() as u32).to_le_bytes());
2133    out.extend_from_slice(name);
2134    out.extend_from_slice(&(cn.len() as u32).to_le_bytes());
2135    out.extend_from_slice(cn);
2136    out.push(col.type_id as u8);
2137    out.push(col.required as u8);
2138    out.extend_from_slice(&col.position.to_le_bytes());
2139    out
2140}
2141
2142fn encode_ddl_alter_drop_column(table_name: &str, col_name: &str) -> Vec<u8> {
2143    let name = table_name.as_bytes();
2144    let cn = col_name.as_bytes();
2145    let mut out = Vec::with_capacity(4 + name.len() + 4 + cn.len());
2146    out.extend_from_slice(&(name.len() as u32).to_le_bytes());
2147    out.extend_from_slice(name);
2148    out.extend_from_slice(&(cn.len() as u32).to_le_bytes());
2149    out.extend_from_slice(cn);
2150    out
2151}
2152
2153fn decode_ddl_table_name(data: &[u8]) -> Option<(String, usize)> {
2154    if data.len() < 4 {
2155        return None;
2156    }
2157    let name_len = u32::from_le_bytes(data[0..4].try_into().ok()?) as usize;
2158    if 4 + name_len > data.len() {
2159        return None;
2160    }
2161    let name = std::str::from_utf8(&data[4..4 + name_len])
2162        .ok()?
2163        .to_string();
2164    Some((name, 4 + name_len))
2165}
2166
2167fn decode_ddl_alter_add_column(data: &[u8]) -> Option<(String, ColumnDef)> {
2168    let (table_name, mut pos) = decode_ddl_table_name(data)?;
2169    if pos + 4 > data.len() {
2170        return None;
2171    }
2172    let cn_len = u32::from_le_bytes(data[pos..pos + 4].try_into().ok()?) as usize;
2173    pos += 4;
2174    if pos + cn_len + 4 > data.len() {
2175        return None;
2176    }
2177    let col_name = std::str::from_utf8(&data[pos..pos + cn_len])
2178        .ok()?
2179        .to_string();
2180    pos += cn_len;
2181    let type_id = TypeId::from_u8(data[pos])?;
2182    pos += 1;
2183    let required = data[pos] != 0;
2184    pos += 1;
2185    if pos + 2 > data.len() {
2186        return None;
2187    }
2188    let position = u16::from_le_bytes(data[pos..pos + 2].try_into().ok()?);
2189    Some((
2190        table_name,
2191        ColumnDef {
2192            name: col_name,
2193            type_id,
2194            required,
2195            position,
2196        },
2197    ))
2198}
2199
2200fn decode_ddl_alter_drop_column(data: &[u8]) -> Option<(String, String)> {
2201    let (table_name, pos) = decode_ddl_table_name(data)?;
2202    if pos + 4 > data.len() {
2203        return None;
2204    }
2205    let cn_len = u32::from_le_bytes(data[pos..pos + 4].try_into().ok()?) as usize;
2206    if pos + 4 + cn_len > data.len() {
2207        return None;
2208    }
2209    let col_name = std::str::from_utf8(&data[pos + 4..pos + 4 + cn_len])
2210        .ok()?
2211        .to_string();
2212    Some((table_name, col_name))
2213}
2214
2215// ─── Catalog file format ────────────────────────────────────────────────────
2216//
2217// Layout (version 2):
2218//   magic     [4]      = "BCAT"
2219//   version   u16
2220//   n_tables  u32
2221//   for each table:
2222//     table_name_len  u32
2223//     table_name      utf8 bytes
2224//     n_columns       u16
2225//     for each column:
2226//       name_len      u32
2227//       name          utf8 bytes
2228//       type_id       u8
2229//       required      u8
2230//       position      u16
2231//     ── version 2 appends: ──
2232//     n_indexed_cols  u16
2233//     for each indexed column:
2234//       name_len      u32
2235//       name          utf8 bytes
2236//
2237// Version 1 files are accepted by the reader (same shape minus the
2238// trailing indexed-column block) and treated as having zero indexed
2239// columns. Writers always emit version 2 from Mission 3 onwards.
2240
2241/// Per-indexed-column metadata persisted in the catalog file.
2242pub(crate) struct IndexedColMeta {
2243    pub name: String,
2244    pub unique: bool,
2245}
2246
2247/// In-memory catalog entry pairing a schema with its indexed column list.
2248/// Produced by the reader; the writer takes the borrowed counterpart below.
2249pub(crate) struct CatalogEntry {
2250    pub schema: Schema,
2251    pub indexed_cols: Vec<IndexedColMeta>,
2252    /// Per-column defaults aligned to `schema.columns` by position. Empty when
2253    /// no column has a default (v1–v3 files always decode to empty).
2254    pub defaults: Vec<Option<Value>>,
2255    /// Which columns are `auto`, aligned to `schema.columns`. Empty when none
2256    /// (v1–v4 files always decode to empty).
2257    pub auto_cols: Vec<bool>,
2258}
2259
2260/// Borrowed view passed to the writer.
2261pub(crate) struct CatalogEntryRef<'a> {
2262    pub schema: &'a Schema,
2263    pub indexed_cols: Vec<IndexedColMeta>,
2264    pub defaults: &'a [Option<Value>],
2265    pub auto_cols: &'a [bool],
2266}
2267
2268// ─── Column-default codecs (shared by catalog.bin and the WAL DDL record) ────
2269
2270/// Encode a single scalar value: a `type_id` tag byte followed by a
2271/// type-specific, length-prefixed (for variable-width types) payload. Lossless
2272/// — used to persist literal column defaults.
2273fn encode_value_blob(out: &mut Vec<u8>, v: &Value) {
2274    out.push(v.type_id() as u8);
2275    match v {
2276        Value::Int(n) => out.extend_from_slice(&n.to_le_bytes()),
2277        Value::Float(f) => out.extend_from_slice(&f.to_bits().to_le_bytes()),
2278        Value::Bool(b) => out.push(*b as u8),
2279        Value::Str(s) => {
2280            out.extend_from_slice(&(s.len() as u32).to_le_bytes());
2281            out.extend_from_slice(s.as_bytes());
2282        }
2283        Value::DateTime(n) => out.extend_from_slice(&n.to_le_bytes()),
2284        Value::Uuid(u) => out.extend_from_slice(u),
2285        Value::Bytes(b) => {
2286            out.extend_from_slice(&(b.len() as u32).to_le_bytes());
2287            out.extend_from_slice(b);
2288        }
2289        Value::Empty => {}
2290    }
2291}
2292
2293/// Inverse of [`encode_value_blob`]. Returns `None` on any malformed/truncated
2294/// input so a corrupt record fails closed rather than panicking.
2295fn decode_value_blob(data: &[u8], pos: &mut usize) -> Option<Value> {
2296    let tag = *data.get(*pos)?;
2297    *pos += 1;
2298    let type_id = TypeId::from_u8(tag)?;
2299    let take_fixed = |pos: &mut usize, n: usize| -> Option<Vec<u8>> {
2300        if *pos + n > data.len() {
2301            return None;
2302        }
2303        let slice = data[*pos..*pos + n].to_vec();
2304        *pos += n;
2305        Some(slice)
2306    };
2307    match type_id {
2308        TypeId::Empty => Some(Value::Empty),
2309        TypeId::Int => Some(Value::Int(i64::from_le_bytes(
2310            take_fixed(pos, 8)?.try_into().ok()?,
2311        ))),
2312        TypeId::Float => Some(Value::Float(f64::from_bits(u64::from_le_bytes(
2313            take_fixed(pos, 8)?.try_into().ok()?,
2314        )))),
2315        TypeId::Bool => Some(Value::Bool(take_fixed(pos, 1)?[0] != 0)),
2316        TypeId::DateTime => Some(Value::DateTime(i64::from_le_bytes(
2317            take_fixed(pos, 8)?.try_into().ok()?,
2318        ))),
2319        TypeId::Uuid => Some(Value::Uuid(take_fixed(pos, 16)?.try_into().ok()?)),
2320        TypeId::Str => {
2321            let len = u32::from_le_bytes(take_fixed(pos, 4)?.try_into().ok()?) as usize;
2322            Some(Value::Str(String::from_utf8(take_fixed(pos, len)?).ok()?))
2323        }
2324        TypeId::Bytes => {
2325            let len = u32::from_le_bytes(take_fixed(pos, 4)?.try_into().ok()?) as usize;
2326            Some(Value::Bytes(take_fixed(pos, len)?))
2327        }
2328    }
2329}
2330
2331/// Encode the per-table defaults as a sparse list: a `u16` count of columns
2332/// that have a default, then `(position: u16, value blob)` pairs. The common
2333/// "no defaults" case costs two bytes.
2334fn encode_defaults_section(out: &mut Vec<u8>, defaults: &[Option<Value>]) {
2335    let present: Vec<(u16, &Value)> = defaults
2336        .iter()
2337        .enumerate()
2338        .filter_map(|(i, d)| d.as_ref().map(|v| (i as u16, v)))
2339        .collect();
2340    out.extend_from_slice(&(present.len() as u16).to_le_bytes());
2341    for (pos, v) in present {
2342        out.extend_from_slice(&pos.to_le_bytes());
2343        encode_value_blob(out, v);
2344    }
2345}
2346
2347/// Inverse of [`encode_defaults_section`]. Builds a `Vec` of length `n_cols`
2348/// with `None` for columns without a default. Returns `None` on truncation.
2349fn decode_defaults_section(
2350    data: &[u8],
2351    pos: &mut usize,
2352    n_cols: usize,
2353) -> Option<Vec<Option<Value>>> {
2354    if *pos + 2 > data.len() {
2355        return None;
2356    }
2357    let count = u16::from_le_bytes(data[*pos..*pos + 2].try_into().ok()?) as usize;
2358    *pos += 2;
2359    let mut out = vec![None; n_cols];
2360    for _ in 0..count {
2361        if *pos + 2 > data.len() {
2362            return None;
2363        }
2364        let col = u16::from_le_bytes(data[*pos..*pos + 2].try_into().ok()?) as usize;
2365        *pos += 2;
2366        let value = decode_value_blob(data, pos)?;
2367        if col < n_cols {
2368            out[col] = Some(value);
2369        }
2370    }
2371    Some(out)
2372}
2373
2374/// Encode the per-table `auto` columns as a sparse list: a `u16` count of auto
2375/// columns, then their positions (`u16` each). "No auto columns" costs two
2376/// bytes.
2377fn encode_auto_section(out: &mut Vec<u8>, auto_cols: &[bool]) {
2378    let present: Vec<u16> = auto_cols
2379        .iter()
2380        .enumerate()
2381        .filter_map(|(i, &a)| if a { Some(i as u16) } else { None })
2382        .collect();
2383    out.extend_from_slice(&(present.len() as u16).to_le_bytes());
2384    for pos in present {
2385        out.extend_from_slice(&pos.to_le_bytes());
2386    }
2387}
2388
2389/// Inverse of [`encode_auto_section`]. Builds a `bool` vec of length `n_cols`.
2390/// Returns `None` on truncation.
2391fn decode_auto_section(data: &[u8], pos: &mut usize, n_cols: usize) -> Option<Vec<bool>> {
2392    if *pos + 2 > data.len() {
2393        return None;
2394    }
2395    let count = u16::from_le_bytes(data[*pos..*pos + 2].try_into().ok()?) as usize;
2396    *pos += 2;
2397    let mut out = vec![false; n_cols];
2398    for _ in 0..count {
2399        if *pos + 2 > data.len() {
2400            return None;
2401        }
2402        let col = u16::from_le_bytes(data[*pos..*pos + 2].try_into().ok()?) as usize;
2403        *pos += 2;
2404        if col < n_cols {
2405            out[col] = true;
2406        }
2407    }
2408    Some(out)
2409}
2410
2411fn write_catalog_file(path: &Path, entries: &[CatalogEntryRef<'_>]) -> io::Result<()> {
2412    let mut buf: Vec<u8> = Vec::with_capacity(64);
2413    buf.extend_from_slice(CATALOG_MAGIC);
2414    buf.extend_from_slice(&CATALOG_VERSION.to_le_bytes());
2415    buf.extend_from_slice(&(entries.len() as u32).to_le_bytes());
2416
2417    for entry in entries {
2418        let schema = entry.schema;
2419        let name = schema.table_name.as_bytes();
2420        buf.extend_from_slice(&(name.len() as u32).to_le_bytes());
2421        buf.extend_from_slice(name);
2422        buf.extend_from_slice(&(schema.columns.len() as u16).to_le_bytes());
2423        for col in &schema.columns {
2424            let cn = col.name.as_bytes();
2425            buf.extend_from_slice(&(cn.len() as u32).to_le_bytes());
2426            buf.extend_from_slice(cn);
2427            buf.push(col.type_id as u8);
2428            buf.push(if col.required { 1 } else { 0 });
2429            buf.extend_from_slice(&col.position.to_le_bytes());
2430        }
2431        // Per-table indexed column list with uniqueness flags (version 3).
2432        buf.extend_from_slice(&(entry.indexed_cols.len() as u16).to_le_bytes());
2433        for meta in &entry.indexed_cols {
2434            let cn = meta.name.as_bytes();
2435            buf.extend_from_slice(&(cn.len() as u32).to_le_bytes());
2436            buf.extend_from_slice(cn);
2437            buf.push(if meta.unique { 1 } else { 0 });
2438        }
2439        // Per-table column defaults (version 4).
2440        encode_defaults_section(&mut buf, entry.defaults);
2441        // Per-table auto-increment columns (version 5).
2442        encode_auto_section(&mut buf, entry.auto_cols);
2443    }
2444
2445    // Append a CRC32 checksum of the entire payload so the reader can
2446    // detect corruption (the WAL and btree .idx files already do this;
2447    // catalog.bin was the one file missing a checksum).
2448    let crc = crc32fast::hash(&buf);
2449    buf.extend_from_slice(&crc.to_le_bytes());
2450
2451    let mut f = fs::OpenOptions::new()
2452        .create(true)
2453        .write(true)
2454        .truncate(true)
2455        .open(path)?;
2456    f.write_all(&buf)?;
2457    f.sync_data()?;
2458    Ok(())
2459}
2460
2461fn read_catalog_file(path: &Path) -> io::Result<Vec<CatalogEntry>> {
2462    let mut f = fs::File::open(path)?;
2463    let mut buf = Vec::new();
2464    f.read_to_end(&mut buf)?;
2465
2466    let mut pos = 0usize;
2467    // Minimum: 4 (magic) + 2 (version) + 4 (n_tables) + 4 (crc) = 14
2468    if buf.len() < 14 || &buf[0..4] != CATALOG_MAGIC {
2469        return Err(io::Error::new(
2470            io::ErrorKind::InvalidData,
2471            "bad catalog magic",
2472        ));
2473    }
2474
2475    // Verify the trailing CRC32 checksum.
2476    let payload = &buf[..buf.len() - 4];
2477    let stored_crc = u32::from_le_bytes(
2478        buf[buf.len() - 4..]
2479            .try_into()
2480            .map_err(|_| io::Error::new(io::ErrorKind::InvalidData, "truncated catalog CRC"))?,
2481    );
2482    let computed_crc = crc32fast::hash(payload);
2483    if stored_crc != computed_crc {
2484        return Err(io::Error::new(
2485            io::ErrorKind::InvalidData,
2486            format!(
2487                "catalog CRC32 mismatch: expected {stored_crc:#010x}, got {computed_crc:#010x}"
2488            ),
2489        ));
2490    }
2491    // Strip the CRC suffix so the parsing loop below doesn't walk into it.
2492    let buf = &buf[..buf.len() - 4];
2493    pos += 4;
2494    let version = u16::from_le_bytes(
2495        buf[pos..pos + 2]
2496            .try_into()
2497            .map_err(|_| io::Error::new(io::ErrorKind::InvalidData, "truncated catalog header"))?,
2498    );
2499    pos += 2;
2500    // Accept every version from 1 up to the current CATALOG_VERSION: the
2501    // field-reading staircase below fills in fields a newer version added
2502    // (indexed-col uniqueness at v3, defaults at v4, auto columns at v5) and
2503    // defaults them for older files, so any 1..=CATALOG_VERSION file loads.
2504    // A range check (not an enumerated list) is what makes this back-compat
2505    // hold automatically on the next bump — the previous `version != 1 &&
2506    // version != 2 && version != CATALOG_VERSION` form silently rejected the
2507    // intermediate v3/v4 files when the constant moved to 5, which would have
2508    // failed to open a v0.6.x database on upgrade (data loss).
2509    if version == 0 || version > CATALOG_VERSION {
2510        return Err(io::Error::new(
2511            io::ErrorKind::InvalidData,
2512            format!("unsupported catalog version: {version}"),
2513        ));
2514    }
2515    let n_tables = u32::from_le_bytes(
2516        buf[pos..pos + 4]
2517            .try_into()
2518            .map_err(|_| io::Error::new(io::ErrorKind::InvalidData, "truncated catalog header"))?,
2519    ) as usize;
2520    pos += 4;
2521
2522    // Don't size an allocation from an unvalidated count: a corrupt or hostile
2523    // catalog could claim billions of tables and make the `Vec::with_capacity`
2524    // below attempt a huge allocation (host abort — fatal in embedded mode). A
2525    // file of `buf.len()` bytes can describe at most that many tables (each
2526    // needs several header bytes), so a larger count is corrupt. Mirrors the
2527    // btree's node-count guard.
2528    if n_tables > buf.len() {
2529        return Err(io::Error::new(
2530            io::ErrorKind::InvalidData,
2531            format!("catalog file corrupt: implausible table count {n_tables}"),
2532        ));
2533    }
2534
2535    let mut entries = Vec::with_capacity(n_tables);
2536    for _ in 0..n_tables {
2537        let name_len = read_u32(buf, &mut pos)? as usize;
2538        let table_name = read_string(buf, &mut pos, name_len)?;
2539        let n_cols = read_u16(buf, &mut pos)? as usize;
2540
2541        let mut columns = Vec::with_capacity(n_cols);
2542        for _ in 0..n_cols {
2543            let cname_len = read_u32(buf, &mut pos)? as usize;
2544            let name = read_string(buf, &mut pos, cname_len)?;
2545            let type_id_raw = read_u8(buf, &mut pos)?;
2546            let type_id = type_id_from_u8(type_id_raw)?;
2547            let required = read_u8(buf, &mut pos)? != 0;
2548            let position = read_u16(buf, &mut pos)?;
2549            columns.push(ColumnDef {
2550                name,
2551                type_id,
2552                required,
2553                position,
2554            });
2555        }
2556
2557        // Version 3 appends indexed column list with uniqueness flag.
2558        // Version 2 has indexed column names without uniqueness (default
2559        // to non-unique). Version 1 has no index info at all.
2560        let indexed_cols: Vec<IndexedColMeta> = if version >= 3 {
2561            let n = read_u16(buf, &mut pos)? as usize;
2562            let mut v = Vec::with_capacity(n);
2563            for _ in 0..n {
2564                let l = read_u32(buf, &mut pos)? as usize;
2565                let name = read_string(buf, &mut pos, l)?;
2566                let unique = read_u8(buf, &mut pos)? != 0;
2567                v.push(IndexedColMeta { name, unique });
2568            }
2569            v
2570        } else if version >= 2 {
2571            let n = read_u16(buf, &mut pos)? as usize;
2572            let mut v = Vec::with_capacity(n);
2573            for _ in 0..n {
2574                let l = read_u32(buf, &mut pos)? as usize;
2575                let name = read_string(buf, &mut pos, l)?;
2576                v.push(IndexedColMeta {
2577                    name,
2578                    unique: false,
2579                });
2580            }
2581            v
2582        } else {
2583            Vec::new()
2584        };
2585
2586        // Version 4 appends a column-defaults section after the index list.
2587        let defaults = if version >= 4 {
2588            decode_defaults_section(buf, &mut pos, columns.len()).ok_or_else(|| {
2589                io::Error::new(io::ErrorKind::InvalidData, "truncated catalog defaults")
2590            })?
2591        } else {
2592            Vec::new()
2593        };
2594
2595        // Version 5 appends an auto-increment column section after that.
2596        let auto_cols = if version >= 5 {
2597            decode_auto_section(buf, &mut pos, columns.len()).ok_or_else(|| {
2598                io::Error::new(io::ErrorKind::InvalidData, "truncated catalog auto columns")
2599            })?
2600        } else {
2601            Vec::new()
2602        };
2603
2604        entries.push(CatalogEntry {
2605            schema: Schema {
2606                table_name,
2607                columns,
2608            },
2609            indexed_cols,
2610            defaults,
2611            auto_cols,
2612        });
2613    }
2614
2615    Ok(entries)
2616}
2617
2618fn read_u8(buf: &[u8], pos: &mut usize) -> io::Result<u8> {
2619    if *pos >= buf.len() {
2620        return Err(io::Error::new(
2621            io::ErrorKind::UnexpectedEof,
2622            "truncated catalog",
2623        ));
2624    }
2625    let v = buf[*pos];
2626    *pos += 1;
2627    Ok(v)
2628}
2629fn read_u16(buf: &[u8], pos: &mut usize) -> io::Result<u16> {
2630    if *pos + 2 > buf.len() {
2631        return Err(io::Error::new(
2632            io::ErrorKind::UnexpectedEof,
2633            "truncated catalog",
2634        ));
2635    }
2636    let v = u16::from_le_bytes(
2637        buf[*pos..*pos + 2]
2638            .try_into()
2639            .expect("bounds checked above"),
2640    );
2641    *pos += 2;
2642    Ok(v)
2643}
2644fn read_u32(buf: &[u8], pos: &mut usize) -> io::Result<u32> {
2645    if *pos + 4 > buf.len() {
2646        return Err(io::Error::new(
2647            io::ErrorKind::UnexpectedEof,
2648            "truncated catalog",
2649        ));
2650    }
2651    let v = u32::from_le_bytes(
2652        buf[*pos..*pos + 4]
2653            .try_into()
2654            .expect("bounds checked above"),
2655    );
2656    *pos += 4;
2657    Ok(v)
2658}
2659fn read_string(buf: &[u8], pos: &mut usize, len: usize) -> io::Result<String> {
2660    if *pos + len > buf.len() {
2661        return Err(io::Error::new(
2662            io::ErrorKind::UnexpectedEof,
2663            "truncated catalog string",
2664        ));
2665    }
2666    let s = std::str::from_utf8(&buf[*pos..*pos + len])
2667        .map_err(|_| io::Error::new(io::ErrorKind::InvalidData, "non-utf8 in catalog"))?
2668        .to_string();
2669    *pos += len;
2670    Ok(s)
2671}
2672fn type_id_from_u8(v: u8) -> io::Result<TypeId> {
2673    match v {
2674        0 => Ok(TypeId::Empty),
2675        1 => Ok(TypeId::Int),
2676        2 => Ok(TypeId::Float),
2677        3 => Ok(TypeId::Bool),
2678        4 => Ok(TypeId::Str),
2679        5 => Ok(TypeId::DateTime),
2680        6 => Ok(TypeId::Uuid),
2681        7 => Ok(TypeId::Bytes),
2682        _ => Err(io::Error::new(
2683            io::ErrorKind::InvalidData,
2684            format!("unknown type id: {v}"),
2685        )),
2686    }
2687}
2688
2689#[cfg(test)]
2690mod tests {
2691    use super::*;
2692    fn temp_catalog(name: &str) -> Catalog {
2693        let dir = std::env::temp_dir().join(format!("powdb_cat_{name}_{}", std::process::id()));
2694        Catalog::create(&dir).unwrap()
2695    }
2696
2697    fn schema_two_cols() -> Schema {
2698        Schema {
2699            table_name: "T".into(),
2700            columns: vec![
2701                ColumnDef {
2702                    name: "id".into(),
2703                    type_id: TypeId::Int,
2704                    required: true,
2705                    position: 0,
2706                },
2707                ColumnDef {
2708                    name: "status".into(),
2709                    type_id: TypeId::Str,
2710                    required: false,
2711                    position: 1,
2712                },
2713            ],
2714        }
2715    }
2716
2717    #[test]
2718    fn replay_records_treats_reused_tx_ids_as_ordered_spans() {
2719        let mut cat = temp_catalog("reused_tx_ids");
2720        let schema = schema_two_cols();
2721        cat.create_table(schema.clone()).unwrap();
2722        cat.checkpoint().unwrap();
2723
2724        let mut committed_row = Vec::new();
2725        encode_row_into(
2726            &schema,
2727            &[Value::Int(1), Value::Str("committed".into())],
2728            &mut committed_row,
2729        );
2730        let mut incomplete_row = Vec::new();
2731        encode_row_into(
2732            &schema,
2733            &[Value::Int(2), Value::Str("incomplete".into())],
2734            &mut incomplete_row,
2735        );
2736
2737        let records = vec![
2738            WalRecord {
2739                tx_id: 1,
2740                record_type: WalRecordType::Begin,
2741                lsn: 1,
2742                data: Vec::new(),
2743            },
2744            WalRecord {
2745                tx_id: 1,
2746                record_type: WalRecordType::Insert,
2747                lsn: 2,
2748                data: encode_wal_payload(
2749                    "T",
2750                    RowId {
2751                        page_id: 1,
2752                        slot_index: 0,
2753                    },
2754                    &committed_row,
2755                ),
2756            },
2757            WalRecord {
2758                tx_id: 1,
2759                record_type: WalRecordType::Commit,
2760                lsn: 3,
2761                data: Vec::new(),
2762            },
2763            WalRecord {
2764                tx_id: 1,
2765                record_type: WalRecordType::Begin,
2766                lsn: 4,
2767                data: Vec::new(),
2768            },
2769            WalRecord {
2770                tx_id: 1,
2771                record_type: WalRecordType::Insert,
2772                lsn: 5,
2773                data: encode_wal_payload(
2774                    "T",
2775                    RowId {
2776                        page_id: 1,
2777                        slot_index: 1,
2778                    },
2779                    &incomplete_row,
2780                ),
2781            },
2782        ];
2783
2784        cat.apply_wal_records(&records).unwrap();
2785        let rows: Vec<_> = cat.scan("T").unwrap().collect();
2786        assert_eq!(rows.len(), 1);
2787        assert_eq!(rows[0].1[0], Value::Int(1));
2788        assert_eq!(rows[0].1[1], Value::Str("committed".into()));
2789    }
2790
2791    #[test]
2792    fn ddl_create_table_codec_roundtrips_defaults_and_auto() {
2793        let schema = schema_two_cols();
2794        let defaults = vec![None, Some(Value::Str("active".into()))];
2795        let auto_cols = vec![true, false];
2796        let encoded = encode_ddl_create_table(&schema, &defaults, &auto_cols);
2797        let (decoded_schema, decoded_defaults, decoded_auto) =
2798            decode_ddl_create_table(&encoded).unwrap();
2799        assert_eq!(decoded_schema.columns.len(), 2);
2800        assert_eq!(decoded_defaults, defaults);
2801        assert_eq!(decoded_auto, auto_cols);
2802    }
2803
2804    #[test]
2805    fn ddl_create_table_codec_back_compat_without_trailing_sections() {
2806        // Simulate a record written before column defaults / auto existed: the
2807        // old encoder stopped right after the columns, with no trailing
2808        // sections. The new decoder must read those as "none".
2809        let schema = schema_two_cols();
2810        let full = encode_ddl_create_table(&schema, &[], &[]);
2811        // Each empty trailing section is a u16 count of 0 (two bytes); chop
2812        // both off to mimic the pre-feature on-disk shape.
2813        let legacy = &full[..full.len() - 4];
2814        let (decoded_schema, decoded_defaults, decoded_auto) =
2815            decode_ddl_create_table(legacy).unwrap();
2816        assert_eq!(decoded_schema.columns.len(), 2);
2817        assert!(decoded_defaults.is_empty(), "no defaults section -> empty");
2818        assert!(decoded_auto.is_empty(), "no auto section -> empty");
2819    }
2820
2821    #[test]
2822    fn ddl_create_table_codec_back_compat_defaults_but_no_auto() {
2823        // A record from the column-defaults release (#129) has a defaults
2824        // section but no auto section; the auto-aware decoder must still read it.
2825        let schema = schema_two_cols();
2826        let defaults = vec![None, Some(Value::Str("active".into()))];
2827        let full = encode_ddl_create_table(&schema, &defaults, &[]);
2828        // Drop only the trailing auto section (its empty u16 count).
2829        let legacy = &full[..full.len() - 2];
2830        let (_schema, decoded_defaults, decoded_auto) = decode_ddl_create_table(legacy).unwrap();
2831        assert_eq!(decoded_defaults, defaults);
2832        assert!(decoded_auto.is_empty());
2833    }
2834
2835    #[test]
2836    fn read_catalog_file_accepts_intermediate_versions_3_and_4() {
2837        // Regression: the version gate accepted only {1, 2, CATALOG_VERSION}, so
2838        // a catalog written at version 3 (v0.6.x) or 4 (the column-defaults
2839        // release) was rejected with "unsupported catalog version" — the
2840        // database would fail to open on upgrade from those releases = data
2841        // loss. The field-reading staircase already handles v3/v4; only the gate
2842        // was stale. Build faithful v3/v4 catalog files by hand and confirm they
2843        // load (defaults/auto default to empty for the versions that lack them).
2844        use std::io::Write as _;
2845        fn write_legacy_catalog(path: &std::path::Path, version: u16) {
2846            let mut buf: Vec<u8> = Vec::new();
2847            buf.extend_from_slice(CATALOG_MAGIC);
2848            buf.extend_from_slice(&version.to_le_bytes());
2849            buf.extend_from_slice(&1u32.to_le_bytes()); // n_tables
2850                                                        // table "T"
2851            buf.extend_from_slice(&1u32.to_le_bytes());
2852            buf.extend_from_slice(b"T");
2853            buf.extend_from_slice(&2u16.to_le_bytes()); // n_cols
2854                                                        // col id: Int, required, pos 0
2855            buf.extend_from_slice(&2u32.to_le_bytes());
2856            buf.extend_from_slice(b"id");
2857            buf.push(TypeId::Int as u8);
2858            buf.push(1);
2859            buf.extend_from_slice(&0u16.to_le_bytes());
2860            // col status: Str, not required, pos 1
2861            buf.extend_from_slice(&6u32.to_le_bytes());
2862            buf.extend_from_slice(b"status");
2863            buf.push(TypeId::Str as u8);
2864            buf.push(0);
2865            buf.extend_from_slice(&1u16.to_le_bytes());
2866            // version >= 3: indexed-column section (count 0).
2867            buf.extend_from_slice(&0u16.to_le_bytes());
2868            // version >= 4: column-defaults section (none here). v3 omits it.
2869            if version >= 4 {
2870                encode_defaults_section(&mut buf, &[None, None]);
2871            }
2872            // v3/v4 never wrote the v5 auto section.
2873            let crc = crc32fast::hash(&buf);
2874            buf.extend_from_slice(&crc.to_le_bytes());
2875            let mut f = fs::File::create(path).unwrap();
2876            f.write_all(&buf).unwrap();
2877        }
2878
2879        for version in [3u16, 4u16] {
2880            let path = std::env::temp_dir().join(format!(
2881                "powdb_cat_v{version}_compat_{}.bin",
2882                std::process::id()
2883            ));
2884            write_legacy_catalog(&path, version);
2885            let entries = read_catalog_file(&path)
2886                .unwrap_or_else(|e| panic!("version {version} catalog must load, got: {e}"));
2887            assert_eq!(entries.len(), 1);
2888            assert_eq!(entries[0].schema.table_name, "T");
2889            assert_eq!(entries[0].schema.columns.len(), 2);
2890            assert!(
2891                entries[0].auto_cols.is_empty(),
2892                "v{version} has no auto cols"
2893            );
2894            fs::remove_file(&path).ok();
2895        }
2896    }
2897
2898    #[test]
2899    fn read_catalog_file_rejects_implausible_table_count() {
2900        // A corrupt/hostile catalog must not be trusted to size an allocation:
2901        // `Vec::with_capacity(n_tables)` on an unvalidated u32 would attempt a
2902        // huge allocation and abort the host. A file can describe at most as
2903        // many tables as it has bytes, so a count exceeding the payload length
2904        // is rejected with a clear error before any allocation. (We use a small
2905        // implausible count over a tiny buffer; a genuinely huge count would
2906        // abort the test runner pre-fix, but it hits the very same guard.)
2907        use std::io::Write as _;
2908        let mut buf: Vec<u8> = Vec::new();
2909        buf.extend_from_slice(CATALOG_MAGIC);
2910        buf.extend_from_slice(&CATALOG_VERSION.to_le_bytes());
2911        buf.extend_from_slice(&1000u32.to_le_bytes()); // claims 1000 tables…
2912                                                       // …but no table data follows (payload is only 10 bytes).
2913        let crc = crc32fast::hash(&buf);
2914        buf.extend_from_slice(&crc.to_le_bytes());
2915        let path =
2916            std::env::temp_dir().join(format!("powdb_cat_badcount_{}.bin", std::process::id()));
2917        fs::File::create(&path).unwrap().write_all(&buf).unwrap();
2918
2919        let msg = match read_catalog_file(&path) {
2920            Ok(_) => panic!("implausible table count must be rejected, got Ok"),
2921            Err(e) => e.to_string(),
2922        };
2923        assert!(
2924            msg.contains("implausible table count"),
2925            "expected an implausible-table-count error, got: {msg}"
2926        );
2927        fs::remove_file(&path).ok();
2928    }
2929
2930    #[test]
2931    fn data_dir_and_max_lsn_accessors() {
2932        let dir = std::env::temp_dir().join(format!("powdb_cat_maxlsn_{}", std::process::id()));
2933        let mut cat = Catalog::create(&dir).unwrap();
2934
2935        // data_dir() reflects the directory the catalog was created in.
2936        assert_eq!(cat.data_dir(), dir.as_path());
2937
2938        // A fresh catalog has stamped no page LSNs yet.
2939        assert_eq!(cat.max_lsn(), 0);
2940
2941        let schema = Schema {
2942            table_name: "users".into(),
2943            columns: vec![ColumnDef {
2944                name: "name".into(),
2945                type_id: TypeId::Str,
2946                required: true,
2947                position: 0,
2948            }],
2949        };
2950        cat.create_table(schema).unwrap();
2951
2952        cat.insert("users", &vec![Value::Str("Alice".into())])
2953            .unwrap();
2954        cat.sync_wal().unwrap();
2955
2956        // An inserted (and synced) row stamps a page LSN, raising the
2957        // durability high-water mark above zero.
2958        assert!(cat.max_lsn() > 0);
2959    }
2960
2961    #[test]
2962    fn test_create_table_and_insert() {
2963        let mut cat = temp_catalog("basic");
2964        let schema = Schema {
2965            table_name: "users".into(),
2966            columns: vec![
2967                ColumnDef {
2968                    name: "name".into(),
2969                    type_id: TypeId::Str,
2970                    required: true,
2971                    position: 0,
2972                },
2973                ColumnDef {
2974                    name: "age".into(),
2975                    type_id: TypeId::Int,
2976                    required: false,
2977                    position: 1,
2978                },
2979            ],
2980        };
2981        cat.create_table(schema).unwrap();
2982
2983        let row = vec![Value::Str("Alice".into()), Value::Int(30)];
2984        let rid = cat.insert("users", &row).unwrap();
2985
2986        let result = cat.get("users", rid).unwrap();
2987        assert_eq!(result[0], Value::Str("Alice".into()));
2988        assert_eq!(result[1], Value::Int(30));
2989    }
2990
2991    #[test]
2992    fn test_scan_table() {
2993        let mut cat = temp_catalog("scan");
2994        let schema = Schema {
2995            table_name: "items".into(),
2996            columns: vec![
2997                ColumnDef {
2998                    name: "name".into(),
2999                    type_id: TypeId::Str,
3000                    required: true,
3001                    position: 0,
3002                },
3003                ColumnDef {
3004                    name: "price".into(),
3005                    type_id: TypeId::Float,
3006                    required: true,
3007                    position: 1,
3008                },
3009            ],
3010        };
3011        cat.create_table(schema).unwrap();
3012
3013        for i in 0..50 {
3014            cat.insert(
3015                "items",
3016                &vec![
3017                    Value::Str(format!("item_{i}")),
3018                    Value::Float(i as f64 * 1.5),
3019                ],
3020            )
3021            .unwrap();
3022        }
3023
3024        let rows: Vec<_> = cat.scan("items").unwrap().collect();
3025        assert_eq!(rows.len(), 50);
3026    }
3027
3028    #[test]
3029    fn test_index_lookup() {
3030        let mut cat = temp_catalog("idx");
3031        let schema = Schema {
3032            table_name: "users".into(),
3033            columns: vec![
3034                ColumnDef {
3035                    name: "email".into(),
3036                    type_id: TypeId::Str,
3037                    required: true,
3038                    position: 0,
3039                },
3040                ColumnDef {
3041                    name: "name".into(),
3042                    type_id: TypeId::Str,
3043                    required: true,
3044                    position: 1,
3045                },
3046            ],
3047        };
3048        cat.create_table(schema).unwrap();
3049        cat.create_index("users", "email").unwrap();
3050
3051        cat.insert(
3052            "users",
3053            &vec![
3054                Value::Str("alice@example.com".into()),
3055                Value::Str("Alice".into()),
3056            ],
3057        )
3058        .unwrap();
3059        cat.insert(
3060            "users",
3061            &vec![
3062                Value::Str("bob@example.com".into()),
3063                Value::Str("Bob".into()),
3064            ],
3065        )
3066        .unwrap();
3067
3068        let result = cat
3069            .index_lookup("users", "email", &Value::Str("bob@example.com".into()))
3070            .unwrap();
3071        assert!(result.is_some());
3072        let row = result.unwrap();
3073        assert_eq!(row[1], Value::Str("Bob".into()));
3074    }
3075
3076    #[test]
3077    fn test_delete_row() {
3078        let mut cat = temp_catalog("delete");
3079        let schema = Schema {
3080            table_name: "t".into(),
3081            columns: vec![ColumnDef {
3082                name: "v".into(),
3083                type_id: TypeId::Int,
3084                required: true,
3085                position: 0,
3086            }],
3087        };
3088        cat.create_table(schema).unwrap();
3089        let r1 = cat.insert("t", &vec![Value::Int(1)]).unwrap();
3090        let r2 = cat.insert("t", &vec![Value::Int(2)]).unwrap();
3091        cat.delete("t", r1).unwrap();
3092        assert!(cat.get("t", r1).is_none());
3093        assert!(cat.get("t", r2).is_some());
3094    }
3095
3096    #[test]
3097    fn test_update_row() {
3098        let mut cat = temp_catalog("update");
3099        let schema = Schema {
3100            table_name: "t".into(),
3101            columns: vec![ColumnDef {
3102                name: "v".into(),
3103                type_id: TypeId::Int,
3104                required: true,
3105                position: 0,
3106            }],
3107        };
3108        cat.create_table(schema).unwrap();
3109        let rid = cat.insert("t", &vec![Value::Int(1)]).unwrap();
3110        let new_rid = cat.update("t", rid, &vec![Value::Int(99)]).unwrap();
3111        let row = cat.get("t", new_rid).unwrap();
3112        assert_eq!(row[0], Value::Int(99));
3113    }
3114
3115    #[test]
3116    fn test_persist_and_reopen() {
3117        let dir = std::env::temp_dir().join(format!("powdb_cat_persist_{}", std::process::id()));
3118        // Fresh dir
3119        let _ = std::fs::remove_dir_all(&dir);
3120
3121        {
3122            let mut cat = Catalog::create(&dir).unwrap();
3123            cat.create_table(Schema {
3124                table_name: "users".into(),
3125                columns: vec![
3126                    ColumnDef {
3127                        name: "name".into(),
3128                        type_id: TypeId::Str,
3129                        required: true,
3130                        position: 0,
3131                    },
3132                    ColumnDef {
3133                        name: "age".into(),
3134                        type_id: TypeId::Int,
3135                        required: false,
3136                        position: 1,
3137                    },
3138                ],
3139            })
3140            .unwrap();
3141            cat.insert("users", &vec![Value::Str("Alice".into()), Value::Int(30)])
3142                .unwrap();
3143            cat.insert("users", &vec![Value::Str("Bob".into()), Value::Int(25)])
3144                .unwrap();
3145        }
3146
3147        // Reopen — schema and rows should both still be there
3148        let cat = Catalog::open(&dir).unwrap();
3149        let schema = cat.schema("users").unwrap();
3150        assert_eq!(schema.columns.len(), 2);
3151        assert_eq!(schema.columns[0].name, "name");
3152        assert_eq!(schema.columns[0].type_id, TypeId::Str);
3153        assert_eq!(schema.columns[1].type_id, TypeId::Int);
3154
3155        let rows: Vec<_> = cat.scan("users").unwrap().collect();
3156        assert_eq!(rows.len(), 2);
3157
3158        std::fs::remove_dir_all(&dir).ok();
3159    }
3160
3161    #[test]
3162    fn test_open_missing_dir_errors() {
3163        let dir = std::env::temp_dir().join(format!("powdb_cat_missing_{}", std::process::id()));
3164        let _ = std::fs::remove_dir_all(&dir);
3165        std::fs::create_dir_all(&dir).unwrap();
3166        // No catalog.bin yet
3167        assert!(Catalog::open(&dir).is_err());
3168        std::fs::remove_dir_all(&dir).ok();
3169    }
3170
3171    #[test]
3172    fn test_list_tables() {
3173        let mut cat = temp_catalog("list");
3174        cat.create_table(Schema {
3175            table_name: "a".into(),
3176            columns: vec![ColumnDef {
3177                name: "x".into(),
3178                type_id: TypeId::Int,
3179                required: true,
3180                position: 0,
3181            }],
3182        })
3183        .unwrap();
3184        cat.create_table(Schema {
3185            table_name: "b".into(),
3186            columns: vec![ColumnDef {
3187                name: "y".into(),
3188                type_id: TypeId::Int,
3189                required: true,
3190                position: 0,
3191            }],
3192        })
3193        .unwrap();
3194        let mut tables = cat.list_tables();
3195        tables.sort();
3196        assert_eq!(tables, vec!["a", "b"]);
3197    }
3198
3199    #[test]
3200    fn test_path_traversal_table_name_rejected() {
3201        let mut cat = temp_catalog("path_trav");
3202        // Names with path separators must be rejected.
3203        let bad_names = vec![
3204            "../etc/passwd",
3205            "foo/bar",
3206            "table\0name",
3207            "",
3208            "123starts_with_digit",
3209            "has-dashes",
3210            "has spaces",
3211            "has.dots",
3212        ];
3213        for name in bad_names {
3214            let schema = Schema {
3215                table_name: name.into(),
3216                columns: vec![ColumnDef {
3217                    name: "x".into(),
3218                    type_id: TypeId::Int,
3219                    required: true,
3220                    position: 0,
3221                }],
3222            };
3223            let result = cat.create_table(schema);
3224            assert!(result.is_err(), "expected error for table name '{name}'");
3225            assert_eq!(result.unwrap_err().kind(), io::ErrorKind::InvalidInput);
3226        }
3227        // Valid names must still work.
3228        let good_names = vec!["users", "_private", "Table_123", "_"];
3229        for name in good_names {
3230            let schema = Schema {
3231                table_name: name.into(),
3232                columns: vec![ColumnDef {
3233                    name: "x".into(),
3234                    type_id: TypeId::Int,
3235                    required: true,
3236                    position: 0,
3237                }],
3238            };
3239            assert!(
3240                cat.create_table(schema).is_ok(),
3241                "expected ok for table name '{name}'"
3242            );
3243        }
3244    }
3245
3246    #[test]
3247    fn test_path_traversal_column_name_rejected() {
3248        let mut cat = temp_catalog("col_path_trav");
3249        let schema = Schema {
3250            table_name: "valid_table".into(),
3251            columns: vec![ColumnDef {
3252                name: "../bad".into(),
3253                type_id: TypeId::Int,
3254                required: true,
3255                position: 0,
3256            }],
3257        };
3258        let result = cat.create_table(schema);
3259        assert!(result.is_err());
3260        assert_eq!(result.unwrap_err().kind(), io::ErrorKind::InvalidInput);
3261    }
3262
3263    #[test]
3264    fn test_drop_table_validates_name() {
3265        let mut cat = temp_catalog("drop_trav");
3266        let result = cat.drop_table("../etc/passwd");
3267        assert!(result.is_err());
3268        // Should fail with InvalidInput (validation), not NotFound.
3269        assert_eq!(result.unwrap_err().kind(), io::ErrorKind::InvalidInput);
3270    }
3271}