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, WalDurabilityTicket, 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    /// Defer Full-mode commit fsyncs (WAL group commit). While enabled, the
907    /// commit paths register the WAL generation they need durable instead of
908    /// fsyncing inline; the pending claim is retrieved with
909    /// [`Self::take_wal_durability_ticket`] and the caller must wait on it
910    /// before acknowledging the statement. This lets the fsync leave the
911    /// engine's exclusive-lock hold so overlapping committers can share one
912    /// fsync. `Normal`/`Off` modes are unaffected.
913    pub fn set_wal_sync_deferred(&mut self, defer: bool) {
914        self.wal.set_defer_sync(defer);
915    }
916
917    /// Take the durability claim registered by deferred commit flushes since
918    /// the last take, if any. See [`Self::set_wal_sync_deferred`].
919    pub fn take_wal_durability_ticket(&mut self) -> Option<WalDurabilityTicket> {
920        self.wal.take_durability_ticket()
921    }
922
923    /// Number of fsyncs issued against the WAL (test/metrics hook).
924    pub fn wal_fsync_count(&self) -> u64 {
925        self.wal.fsync_count()
926    }
927
928    /// Discard in-memory mutations made since the last `sync_wal()` and
929    /// restore the catalog to its on-disk state. Used by ROLLBACK to
930    /// undo an in-progress transaction's changes.
931    ///
932    /// This re-opens the catalog from the checkpoint file and replays
933    /// only the durable (already flushed) WAL records. Any WAL records
934    /// that were appended but not yet flushed are lost.
935    ///
936    /// **Critical**: before replacing `*self` we must discard every
937    /// dirty in-memory page across all heaps. Otherwise the old
938    /// `Catalog`'s `Drop` impl calls `checkpoint()` which flushes those
939    /// dirty pages to disk — and the freshly-opened replacement catalog
940    /// would then read the flushed (uncommitted) rows back, defeating
941    /// the entire rollback.
942    pub fn rollback_to_last_sync(&mut self) -> io::Result<()> {
943        self.rollback_to_last_sync_inner(None)
944    }
945
946    /// Roll back the active transaction, then reopen/replay any remaining WAL
947    /// through an archive hook before recovery truncates it. Sync-aware callers
948    /// use this when committed pre-transaction records must remain available to
949    /// replicas after rollback.
950    pub fn rollback_to_last_sync_with_wal_archive<F>(&mut self, mut archive: F) -> io::Result<()>
951    where
952        F: FnMut(&Path, &[WalRecord]) -> io::Result<()>,
953    {
954        let archive: WalArchiveCallback<'_> = &mut archive;
955        self.rollback_to_last_sync_inner(Some(archive))
956    }
957
958    fn rollback_to_last_sync_inner(
959        &mut self,
960        mut archive: Option<WalArchiveCallback<'_>>,
961    ) -> io::Result<()> {
962        let start_len = self.tx_start_len.unwrap_or(0);
963        let prearchived = if let Some(archive) = archive.as_mut() {
964            let records = self.wal.read_through_len(start_len)?;
965            if !records.is_empty() {
966                archive(&self.data_dir, &records)?;
967            }
968            true
969        } else {
970            false
971        };
972
973        let start_len = self.tx_start_len.take().unwrap_or(0);
974        if let Some(id) = self.active_tx_id.take() {
975            if !self.wal.is_off() {
976                let _ = self.wal.append(id, WalRecordType::Rollback, &[]);
977            }
978        }
979        self.wal.discard_and_truncate_to(start_len)?;
980
981        // Step 1: throw away every uncommitted in-memory write so the
982        // upcoming Drop of `*self` has nothing dirty to flush.
983        for tbl in &mut self.tables {
984            tbl.heap.discard_dirty();
985        }
986        // Step 2: discard WAL records appended since the last explicit
987        // sync point. Large pending records can spill through BufWriter and
988        // become file-visible before `sync_wal()`; truncating to the last
989        // synced boundary prevents `open()` below from replaying rolled-back
990        // transaction records.
991        self.wal.discard_pending()?;
992        // Step 3: re-open the catalog from disk. The heap files on disk
993        // still reflect the last checkpoint (pre-transaction state)
994        // because we never flushed the transaction's dirty pages.
995        let data_dir = self.data_dir.clone();
996        let sync_mode = self.wal.sync_mode();
997        let restored = if prearchived {
998            let mut already_archived = |_dir: &Path, _records: &[WalRecord]| Ok(());
999            let archive: WalArchiveCallback<'_> = &mut already_archived;
1000            Self::open_inner(&data_dir, Some(archive))?
1001        } else {
1002            Self::open_inner(&data_dir, archive)?
1003        };
1004        *self = restored;
1005        self.wal.set_sync_mode(sync_mode);
1006        Ok(())
1007    }
1008
1009    fn abandon_active_transaction_for_drop(&mut self) -> io::Result<()> {
1010        for tbl in &mut self.tables {
1011            tbl.heap.discard_dirty();
1012        }
1013        self.pending_autocommit_tx_ids.clear();
1014        let truncate_result = match self.tx_start_len.take() {
1015            Some(start_len) => self.wal.discard_and_truncate_to(start_len),
1016            None => self.wal.discard_pending(),
1017        };
1018        self.active_tx_id = None;
1019        truncate_result
1020    }
1021
1022    /// Returns a reference to the data directory.
1023    pub fn data_dir(&self) -> &Path {
1024        &self.data_dir
1025    }
1026
1027    /// Highest page LSN across all tables (0 if nothing has been written).
1028    /// This is the durability high-water mark — the LSN a backup taken now
1029    /// corresponds to, and the value `Catalog::open` uses to restore
1030    /// `next_lsn` after a reopen/restore.
1031    pub fn max_lsn(&self) -> u64 {
1032        let max_page_lsn = self
1033            .tables
1034            .iter()
1035            .map(|t| t.heap.max_page_lsn())
1036            .max()
1037            .unwrap_or(0);
1038        max_page_lsn
1039            .max(self.durable_lsn)
1040            .max(self.wal.last_appended_lsn())
1041    }
1042
1043    pub fn create_table(&mut self, schema: Schema) -> io::Result<()> {
1044        self.create_table_full(schema, Vec::new(), Vec::new())
1045    }
1046
1047    /// Create a table whose columns carry literal defaults. `defaults` is
1048    /// aligned to `schema.columns` by position (and may be shorter / empty for
1049    /// columns without a default).
1050    pub fn create_table_with_defaults(
1051        &mut self,
1052        schema: Schema,
1053        defaults: Vec<Option<Value>>,
1054    ) -> io::Result<()> {
1055        self.create_table_full(schema, defaults, Vec::new())
1056    }
1057
1058    /// Create a table with per-column literal defaults and auto-increment
1059    /// flags. Both vecs are aligned to `schema.columns` by position (and may be
1060    /// empty). Defaults and auto flags are WAL-logged and persisted in the
1061    /// catalog so they survive a restart.
1062    pub fn create_table_full(
1063        &mut self,
1064        schema: Schema,
1065        defaults: Vec<Option<Value>>,
1066        auto_cols: Vec<bool>,
1067    ) -> io::Result<()> {
1068        validate_table_name(&schema.table_name)?;
1069        for col in &schema.columns {
1070            validate_column_name(&col.name)?;
1071        }
1072        let name = schema.table_name.clone();
1073        if self.name_to_slot.contains_key(&name) {
1074            return Err(io::Error::new(
1075                io::ErrorKind::AlreadyExists,
1076                format!("table '{name}' already exists"),
1077            ));
1078        }
1079        if !self.wal.is_off() {
1080            let payload = encode_ddl_create_table(&schema, &defaults, &auto_cols);
1081            self.wal
1082                .append(0, WalRecordType::DdlCreateTable, &payload)?;
1083            self.wal.flush()?;
1084        }
1085        let mut table = Table::create(schema, &self.data_dir)?;
1086        table.set_defaults(defaults);
1087        table.set_auto_cols(auto_cols);
1088        let slot = self.tables.len();
1089        self.tables.push(table);
1090        self.name_to_slot.insert(name, slot);
1091        self.persist()?;
1092        Ok(())
1093    }
1094
1095    /// Per-column literal defaults for a table, aligned to its columns by
1096    /// position. `None` when the table is unknown; an empty slice when no
1097    /// column has a default.
1098    pub fn column_defaults(&self, table: &str) -> Option<&[Option<Value>]> {
1099        let slot = *self.name_to_slot.get(table)?;
1100        Some(self.tables[slot].defaults())
1101    }
1102
1103    /// Which columns of a table are `auto`, aligned to its columns by position.
1104    /// `None` when the table is unknown; an empty slice when none are auto.
1105    pub fn auto_columns(&self, table: &str) -> Option<&[bool]> {
1106        let slot = *self.name_to_slot.get(table)?;
1107        Some(self.tables[slot].auto_cols())
1108    }
1109
1110    /// Fill any omitted (`Empty`) auto column in `values` from the table's
1111    /// sequence and advance it. No-op when the table is unknown or has no auto
1112    /// columns.
1113    pub fn assign_auto_columns(&mut self, table: &str, values: &mut [Value]) {
1114        if let Some(&slot) = self.name_to_slot.get(table) {
1115            self.tables[slot].assign_auto(values);
1116        }
1117    }
1118
1119    /// Write the current set of schemas to disk atomically (write-then-rename).
1120    ///
1121    /// Mission 3: also writes the per-table list of indexed column names so
1122    /// `Catalog::open` can rehydrate b-tree indexes on restart.
1123    fn persist(&self) -> io::Result<()> {
1124        let cat_path = self.data_dir.join(CATALOG_FILE);
1125        let tmp_path = self.data_dir.join(format!("{CATALOG_FILE}.tmp"));
1126        let entries: Vec<CatalogEntryRef<'_>> = self
1127            .tables
1128            .iter()
1129            .map(|t| CatalogEntryRef {
1130                schema: &t.schema,
1131                indexed_cols: t.indexed_column_metas(),
1132                defaults: t.defaults(),
1133                auto_cols: t.auto_cols(),
1134            })
1135            .collect();
1136        write_catalog_file(&tmp_path, &entries)?;
1137        fs::rename(&tmp_path, &cat_path)?;
1138        Ok(())
1139    }
1140
1141    /// Resolve a table name to its stable slot index. Prepared-query
1142    /// fast paths cache this once and skip the hash probe on every
1143    /// subsequent execution. Slots never shift once assigned.
1144    #[inline]
1145    pub fn table_slot(&self, name: &str) -> Option<usize> {
1146        self.name_to_slot.get(name).copied()
1147    }
1148
1149    /// O(1) slot-indexed table access. Panics on an out-of-range slot
1150    /// — callers must have obtained the slot via `table_slot()`.
1151    #[inline]
1152    pub fn table_by_slot(&self, slot: usize) -> &Table {
1153        &self.tables[slot]
1154    }
1155
1156    /// Mutable counterpart to [`Self::table_by_slot`].
1157    #[inline]
1158    pub fn table_by_slot_mut(&mut self, slot: usize) -> &mut Table {
1159        &mut self.tables[slot]
1160    }
1161
1162    pub fn get_table(&self, name: &str) -> Option<&Table> {
1163        let slot = *self.name_to_slot.get(name)?;
1164        Some(&self.tables[slot])
1165    }
1166
1167    pub fn get_table_mut(&mut self, name: &str) -> Option<&mut Table> {
1168        let slot = *self.name_to_slot.get(name)?;
1169        Some(&mut self.tables[slot])
1170    }
1171
1172    /// Private helper: resolve a table name to `&Table`, or return an
1173    /// `io::Error` with the same "table '<name>' not found" message the
1174    /// older `get_mut().ok_or_else(...)` callers produced. Phase 18
1175    /// consolidates ~14 copies of that idiom into this one place.
1176    #[inline]
1177    fn by_name(&self, table: &str) -> io::Result<&Table> {
1178        let slot = *self.name_to_slot.get(table).ok_or_else(|| {
1179            io::Error::new(
1180                io::ErrorKind::NotFound,
1181                format!("table '{table}' not found"),
1182            )
1183        })?;
1184        Ok(&self.tables[slot])
1185    }
1186
1187    /// Mutable counterpart to [`Self::by_name`].
1188    #[inline]
1189    fn by_name_mut(&mut self, table: &str) -> io::Result<&mut Table> {
1190        let slot = *self.name_to_slot.get(table).ok_or_else(|| {
1191            io::Error::new(
1192                io::ErrorKind::NotFound,
1193                format!("table '{table}' not found"),
1194            )
1195        })?;
1196        Ok(&mut self.tables[slot])
1197    }
1198
1199    pub fn insert(&mut self, table: &str, values: &Row) -> io::Result<RowId> {
1200        // Mission 2: encode the row into a scratch buffer first so we can
1201        // log it to the WAL before touching the heap. We re-encode inside
1202        // `Table::insert`, which keeps the insert hot path untouched — the
1203        // WAL encode here is additive.
1204        //
1205        // Mission B (post-review, second pass): in `WalSyncMode::Off` the
1206        // entire WAL pipeline is a no-op, so skip the per-row
1207        // `encode_row_into` allocation and `wal_log` call entirely.
1208        if self.wal.is_off() {
1209            return self.by_name_mut(table)?.insert(values);
1210        }
1211        let tbl = self.by_name_mut(table)?;
1212        let mut wal_bytes: Vec<u8> = Vec::new();
1213        encode_row_into(&tbl.schema, values, &mut wal_bytes);
1214        // Insert into the heap FIRST so we can log the record with the real
1215        // RowId. Replay needs the true (page, slot) to (a) know which page
1216        // an Insert targets — for the per-page LSN durability check in
1217        // `replay_wal` — and (b) reproduce the exact slot assignment, which
1218        // makes Insert replay idempotent (no duplicate rows after a
1219        // partial-flush crash, the v0.4.x data-loss bug).
1220        //
1221        // Ordering note: mutating the heap before appending+fsyncing the WAL
1222        // is safe. A crash between the heap insert and the WAL append leaves
1223        // the row only in an un-fsynced hot page (not durable) and the
1224        // statement has not returned `Ok` (the executor's end-of-statement
1225        // `sync_wal` hasn't run), so the write was never acknowledged.
1226        // Durability is still gated on the WAL fsync at statement end.
1227        let new_rid = tbl.insert(values)?;
1228        let tx_id = self.next_tx();
1229        self.wal_log(tx_id, WalRecordType::Insert, table, new_rid, &wal_bytes)?;
1230        // Stamp the landing page with this record's LSN so a future replay
1231        // recognises the row as already persisted (per-page idempotency).
1232        // The page is hot (just inserted into), so this is an in-memory
1233        // header write — no extra I/O on the insert hot path.
1234        let lsn = self.wal.last_appended_lsn();
1235        if lsn > 0 {
1236            self.by_name_mut(table)?
1237                .heap
1238                .set_page_lsn(new_rid.page_id, lsn)?;
1239        }
1240        Ok(new_rid)
1241    }
1242
1243    /// WAL-logged insert addressed by table slot index instead of name.
1244    /// Backs the executor's prepared-insert fast path, which resolves the
1245    /// slot at prepare time to skip the name→slot hash probe. Behaves exactly
1246    /// like [`Self::insert`] (logs the record with the real RowId, stamps the
1247    /// landing page's LSN) — the prepared path previously called the raw
1248    /// `Table::insert` and bypassed the WAL entirely, silently losing every
1249    /// prepared insert on a crash.
1250    pub fn insert_by_slot(&mut self, slot: usize, values: &Row) -> io::Result<RowId> {
1251        if self.wal.is_off() {
1252            return self.tables[slot].insert(values);
1253        }
1254        let tx_id = self.next_tx();
1255        let autocommit = self.active_tx_id.is_none();
1256        let Catalog { tables, wal, .. } = self;
1257        let tbl = &mut tables[slot];
1258        let mut wal_bytes: Vec<u8> = Vec::new();
1259        encode_row_into(&tbl.schema, values, &mut wal_bytes);
1260        // Insert first so the WAL record carries the real RowId (see
1261        // `insert` for the ordering/durability argument).
1262        let new_rid = tbl.insert(values)?;
1263        let payload = encode_wal_payload(&tbl.schema.table_name, new_rid, &wal_bytes);
1264        wal.append(tx_id, WalRecordType::Insert, &payload)?;
1265        if autocommit {
1266            self.pending_autocommit_tx_ids.push(tx_id);
1267        }
1268        let lsn = wal.last_appended_lsn();
1269        if lsn > 0 {
1270            tbl.heap.set_page_lsn(new_rid.page_id, lsn)?;
1271        }
1272        Ok(new_rid)
1273    }
1274
1275    pub fn get(&self, table: &str, rid: RowId) -> Option<Row> {
1276        self.get_table(table)?.get(rid)
1277    }
1278
1279    pub fn delete(&mut self, table: &str, rid: RowId) -> io::Result<()> {
1280        // Mission B (post-review, second pass): WAL Off → no payload
1281        // construction.
1282        if self.wal.is_off() {
1283            return self.by_name_mut(table)?.delete(rid);
1284        }
1285        let tx_id = self.next_tx();
1286        // Delete records carry only the rid — no row payload.
1287        self.wal_log(tx_id, WalRecordType::Delete, table, rid, &[])?;
1288        self.by_name_mut(table)?.delete(rid)
1289    }
1290
1291    /// Mission C Phase 12: bulk delete a list of rids, batching btree
1292    /// maintenance. See [`Table::delete_many`] for the full explanation
1293    /// and fall-through rules. Returns the number of rows removed.
1294    pub fn delete_many(&mut self, table: &str, rids: &[RowId]) -> io::Result<u64> {
1295        // Mission 2: log every rid as an individual Delete record. The
1296        // WAL flush is deferred to the executor's statement-end
1297        // `sync_wal` — see [`Self::wal_log`] for the group-commit rules.
1298        //
1299        // Mission B (post-review, second pass): in Off mode skip the
1300        // entire per-row payload loop — `wal.append` would no-op every
1301        // call but the `encode_wal_payload` Vec alloc would still run.
1302        if self.wal.is_off() {
1303            return self.by_name_mut(table)?.delete_many(rids);
1304        }
1305        let tx_id = self.next_tx();
1306        for &rid in rids {
1307            let payload = encode_wal_payload(table, rid, &[]);
1308            self.wal.append(tx_id, WalRecordType::Delete, &payload)?;
1309        }
1310        if self.active_tx_id.is_none() && !rids.is_empty() {
1311            self.pending_autocommit_tx_ids.push(tx_id);
1312        }
1313        self.by_name_mut(table)?.delete_many(rids)
1314    }
1315
1316    /// Single-pass scan-and-delete driven by a raw-bytes predicate. See
1317    /// [`Table::scan_delete_matching`] and `HeapFile::scan_delete_matching`
1318    /// for the fusion rationale.
1319    ///
1320    /// Prefer [`Self::scan_delete_matching_logged`] from any
1321    /// caller that needs crash durability. This variant writes no WAL
1322    /// records, so a crash between the scan and the next checkpoint
1323    /// would lose the deletes. Kept here for internal paths (e.g.
1324    /// `drop_table`) where the whole heap is about to be removed anyway.
1325    pub fn scan_delete_matching<P>(&mut self, table: &str, pred: P) -> io::Result<u64>
1326    where
1327        P: FnMut(&[u8]) -> bool,
1328    {
1329        self.by_name_mut(table)?.scan_delete_matching(pred)
1330    }
1331
1332    /// WAL-logged variant of [`Self::scan_delete_matching`].
1333    /// Every matched row emits one `WalRecordType::Delete` record in the
1334    /// same single-pass scan (via the table's `_with_hook` variant), so
1335    /// crash recovery sees every deletion. Used by the executor's
1336    /// `Delete(Filter(SeqScan))` and bare `Delete(SeqScan)` fast paths.
1337    ///
1338    /// Performance cost vs the non-logged primitive is one per-row WAL
1339    /// append into the in-memory buffer plus one `fsync` at the end —
1340    /// the heap scan itself still runs as a single pass with one
1341    /// `ensure_hot` per page.
1342    pub fn scan_delete_matching_logged<P>(&mut self, table: &str, pred: P) -> io::Result<u64>
1343    where
1344        P: FnMut(&[u8]) -> bool,
1345    {
1346        // Mission B (post-review, second pass): in Off mode the per-row
1347        // hook would build a Vec, do five extends, and then `append`
1348        // would no-op. Skip the WAL hook entirely and route through
1349        // the no-WAL primitive — same single-pass scan, zero per-row
1350        // payload work.
1351        if self.wal.is_off() {
1352            return self.by_name_mut(table)?.scan_delete_matching(pred);
1353        }
1354        // Resolve slot up front so we can split the borrow — the user
1355        // hook closes over `&mut self.wal`, which can't coexist with a
1356        // `by_name_mut` borrow of `self.tables`.
1357        let slot = *self.name_to_slot.get(table).ok_or_else(|| {
1358            io::Error::new(
1359                io::ErrorKind::NotFound,
1360                format!("table '{table}' not found"),
1361            )
1362        })?;
1363        let tx_id = self.next_tx();
1364        let autocommit = self.active_tx_id.is_none();
1365        // Split-borrow the catalog fields so the hook can write into
1366        // `wal` while the scan pins `tables[slot]` mutably.
1367        let Catalog { tables, wal, .. } = self;
1368        let tbl = &mut tables[slot];
1369        // Pre-encode the table-name prefix of every WAL payload once —
1370        // it doesn't vary row-to-row, and the per-row rid+row bytes are
1371        // the only things we append inside the hook.
1372        let name_bytes = table.as_bytes();
1373        let count = tbl.scan_delete_matching_with_hook(pred, |rid, row_bytes| {
1374            let mut payload: Vec<u8> =
1375                Vec::with_capacity(4 + name_bytes.len() + 10 + row_bytes.len());
1376            payload.extend_from_slice(&(name_bytes.len() as u32).to_le_bytes());
1377            payload.extend_from_slice(name_bytes);
1378            payload.extend_from_slice(&rid.page_id.to_le_bytes());
1379            payload.extend_from_slice(&rid.slot_index.to_le_bytes());
1380            // Delete records carry no row payload on replay, but we
1381            // match the `encode_wal_payload` layout so `decode_wal_payload`
1382            // (which is type-agnostic) parses them cleanly.
1383            payload.extend_from_slice(&0u32.to_le_bytes());
1384            // Best-effort append — if it errors we have no way to
1385            // propagate from inside the hook; we swallow it here and
1386            // the outer scan's `io::Result` will still succeed. In
1387            // practice the `BufWriter`-backed `Wal::append` only errors
1388            // on allocation failure or a disk-full fsync, both of
1389            // which would fail the outer flush below as well.
1390            let _ = wal.append(tx_id, WalRecordType::Delete, &payload);
1391        })?;
1392        if autocommit && count > 0 {
1393            self.pending_autocommit_tx_ids.push(tx_id);
1394        }
1395        // Flush is deferred to the executor's statement-end `sync_wal`.
1396        Ok(count)
1397    }
1398
1399    /// Single-pass fused scan + in-place patch with WAL logging.
1400    /// Evaluates `pred` on raw row bytes and applies `try_mutate` to each
1401    /// match on the same hot page — no second pass. Returns
1402    /// `(patched_count, fallback_rids)`.
1403    ///
1404    /// Perf sprint: update analogue of `scan_delete_matching_logged`.
1405    /// Eliminates the two-pass collect-then-patch pattern.
1406    pub fn scan_patch_matching_logged<P, M>(
1407        &mut self,
1408        table: &str,
1409        pred: P,
1410        try_mutate: M,
1411    ) -> io::Result<(u64, Vec<RowId>)>
1412    where
1413        P: FnMut(&[u8]) -> bool,
1414        M: FnMut(&mut [u8]) -> Option<u16>,
1415    {
1416        if self.wal.is_off() {
1417            return self.by_name_mut(table)?.scan_patch_matching_with_hook(
1418                pred,
1419                try_mutate,
1420                |_, _| {},
1421            );
1422        }
1423        let slot = *self.name_to_slot.get(table).ok_or_else(|| {
1424            io::Error::new(
1425                io::ErrorKind::NotFound,
1426                format!("table '{table}' not found"),
1427            )
1428        })?;
1429        let tx_id = self.next_tx();
1430        let autocommit = self.active_tx_id.is_none();
1431        let Catalog { tables, wal, .. } = self;
1432        let tbl = &mut tables[slot];
1433        let name_bytes = table.as_bytes();
1434        let result = tbl.scan_patch_matching_with_hook(pred, try_mutate, |rid, row_bytes| {
1435            let mut payload: Vec<u8> =
1436                Vec::with_capacity(4 + name_bytes.len() + 10 + row_bytes.len());
1437            payload.extend_from_slice(&(name_bytes.len() as u32).to_le_bytes());
1438            payload.extend_from_slice(name_bytes);
1439            payload.extend_from_slice(&rid.page_id.to_le_bytes());
1440            payload.extend_from_slice(&rid.slot_index.to_le_bytes());
1441            payload.extend_from_slice(&(row_bytes.len() as u32).to_le_bytes());
1442            payload.extend_from_slice(row_bytes);
1443            let _ = wal.append(tx_id, WalRecordType::Update, &payload);
1444        })?;
1445        if autocommit && result.0 > 0 {
1446            self.pending_autocommit_tx_ids.push(tx_id);
1447        }
1448        Ok(result)
1449    }
1450
1451    pub fn update(&mut self, table: &str, rid: RowId, values: &Row) -> io::Result<RowId> {
1452        // Mission B (post-review, second pass): WAL Off → no payload
1453        // construction.
1454        if self.wal.is_off() {
1455            return self.by_name_mut(table)?.update(rid, values);
1456        }
1457        let tbl = self.by_name_mut(table)?;
1458        let mut wal_bytes: Vec<u8> = Vec::new();
1459        encode_row_into(&tbl.schema, values, &mut wal_bytes);
1460        // Reject oversized rows BEFORE appending the WAL record: a logged
1461        // Update that the heap then rejects would poison the next replay.
1462        check_encoded_row_size(&wal_bytes)?;
1463        let tx_id = self.next_tx();
1464        self.wal_log(tx_id, WalRecordType::Update, table, rid, &wal_bytes)?;
1465        self.by_name_mut(table)?.update(rid, values)
1466    }
1467
1468    /// Mission C Phase 2: update with a hint about which columns actually
1469    /// changed. Lets [`Table::update_hinted`] skip the old-row read when
1470    /// the hint shows no indexed column is in the changed set.
1471    pub fn update_hinted(
1472        &mut self,
1473        table: &str,
1474        rid: RowId,
1475        values: &Row,
1476        changed_col_indices: Option<&[usize]>,
1477    ) -> io::Result<RowId> {
1478        // Mission B (post-review, second pass): WAL Off → no payload
1479        // construction. The `update_by_filter` powql bench drives this
1480        // path tens of thousands of times per iteration.
1481        if self.wal.is_off() {
1482            return self
1483                .by_name_mut(table)?
1484                .update_hinted(rid, values, changed_col_indices);
1485        }
1486        let tbl = self.by_name_mut(table)?;
1487        let mut wal_bytes: Vec<u8> = Vec::new();
1488        encode_row_into(&tbl.schema, values, &mut wal_bytes);
1489        // Same pre-WAL size gate as [`Self::update`].
1490        check_encoded_row_size(&wal_bytes)?;
1491        let tx_id = self.next_tx();
1492        self.wal_log(tx_id, WalRecordType::Update, table, rid, &wal_bytes)?;
1493        self.by_name_mut(table)?
1494            .update_hinted(rid, values, changed_col_indices)
1495    }
1496
1497    /// Mission C Phase 4: fast-path update that patches a row's raw bytes
1498    /// in place, skipping decode/encode. Caller guarantees the mutation
1499    /// preserves the row length and touches no indexed column. Returns
1500    /// `Ok(true)` if the patch landed, `Ok(false)` if the row is gone.
1501    ///
1502    /// This primitive does NOT log to the WAL. Executor
1503    /// callers must route through [`Self::update_row_bytes_logged`] (or
1504    /// [`Self::update_row_bytes_logged_by_slot`]) so crash recovery
1505    /// sees the patched bytes. This raw form is retained for replay
1506    /// itself and any future callers that can tolerate the non-durable
1507    /// contract.
1508    #[inline]
1509    pub fn with_row_bytes_mut<F>(&mut self, table: &str, rid: RowId, f: F) -> io::Result<bool>
1510    where
1511        F: FnOnce(&mut [u8]),
1512    {
1513        self.by_name_mut(table)?.with_row_bytes_mut(rid, f)
1514    }
1515
1516    /// WAL-logged variant of [`Self::with_row_bytes_mut`].
1517    /// Applies `f` to the live row bytes on the hot page, then reads
1518    /// the mutated bytes back and emits a `WalRecordType::Update`
1519    /// record so replay will re-apply the same patch after a crash.
1520    ///
1521    /// Ordering: the hot-page mutation happens first (in-memory only,
1522    /// no disk I/O), then the WAL record is appended and flushed. A
1523    /// crash after the mutation but before the WAL flush loses the
1524    /// update, but the caller never saw success in that case, so the
1525    /// contract holds: any `Ok(true)` return is durable.
1526    ///
1527    /// No hot-page eviction can happen between steps because this
1528    /// method holds the catalog's `&mut self` exclusively.
1529    #[inline]
1530    pub fn update_row_bytes_logged<F>(&mut self, table: &str, rid: RowId, f: F) -> io::Result<bool>
1531    where
1532        F: FnOnce(&mut [u8]),
1533    {
1534        let slot = *self.name_to_slot.get(table).ok_or_else(|| {
1535            io::Error::new(
1536                io::ErrorKind::NotFound,
1537                format!("table '{table}' not found"),
1538            )
1539        })?;
1540        self.update_row_bytes_logged_by_slot(slot, rid, f)
1541    }
1542
1543    /// Slot-indexed counterpart to [`Self::update_row_bytes_logged`].
1544    /// Used by prepared-query fast paths that already cached the table
1545    /// slot at prepare time and want to skip the name->slot probe on
1546    /// every execution.
1547    #[inline]
1548    pub fn update_row_bytes_logged_by_slot<F>(
1549        &mut self,
1550        slot: usize,
1551        rid: RowId,
1552        f: F,
1553    ) -> io::Result<bool>
1554    where
1555        F: FnOnce(&mut [u8]),
1556    {
1557        // Step 1: apply the mutation on the hot page. Failure here
1558        // (slot gone) short-circuits with Ok(false) — no WAL record.
1559        let tbl = &mut self.tables[slot];
1560        let ok = tbl.with_row_bytes_mut(rid, f)?;
1561        if !ok {
1562            return Ok(false);
1563        }
1564        // Mission B (post-review, second pass): in Off mode the per-row
1565        // get + clone + table-name clone + wal_log call are all wasted
1566        // — `wal.append` would no-op. Skip the snapshot path entirely.
1567        if self.wal.is_off() {
1568            return Ok(true);
1569        }
1570        // Step 2: snapshot the now-mutated bytes. `HeapFile::get`
1571        // observes the pinned hot page, so it returns the fresh row.
1572        let new_bytes = match tbl.heap.get(rid) {
1573            Some(b) => b,
1574            // Shouldn't happen — we just patched it — but be defensive.
1575            None => return Ok(false),
1576        };
1577        // Step 3: log + flush. Clone the table name out of the schema
1578        // so we can drop the `&mut tbl` borrow before touching `self.wal`.
1579        let table_name = tbl.schema.table_name.clone();
1580        let tx_id = self.next_tx();
1581        self.wal_log(tx_id, WalRecordType::Update, &table_name, rid, &new_bytes)?;
1582        Ok(true)
1583    }
1584
1585    /// Mission C Phase 10: var-column in-place update fast path. Patches
1586    /// a single variable-length column's bytes directly into the row's
1587    /// slot, shrinking the row if the new value is smaller. Returns
1588    /// `Ok(false)` if the new value would grow the row (caller must fall
1589    /// back to the full encode path) or the row is gone.
1590    ///
1591    /// Caller guarantees no indexed column is touched — indexes are NOT
1592    /// maintained by this primitive.
1593    ///
1594    /// Not WAL-logged. Executor callers should use
1595    /// [`Self::patch_var_col_logged`] instead.
1596    #[inline]
1597    pub fn patch_var_col_in_place(
1598        &mut self,
1599        table: &str,
1600        rid: RowId,
1601        col_idx: usize,
1602        new_value: Option<&[u8]>,
1603    ) -> io::Result<bool> {
1604        self.by_name_mut(table)?
1605            .patch_var_col_in_place(rid, col_idx, new_value)
1606    }
1607
1608    /// WAL-logged variant of [`Self::patch_var_col_in_place`].
1609    /// Runs the in-place shrink on the hot page, then reads the mutated
1610    /// row bytes back and logs a `WalRecordType::Update` record. On a
1611    /// `false` return (grow-case bail) nothing is logged — the caller's
1612    /// fall-through to `update_hinted` handles the WAL itself.
1613    pub fn patch_var_col_logged(
1614        &mut self,
1615        table: &str,
1616        rid: RowId,
1617        col_idx: usize,
1618        new_value: Option<&[u8]>,
1619    ) -> io::Result<bool> {
1620        let slot = *self.name_to_slot.get(table).ok_or_else(|| {
1621            io::Error::new(
1622                io::ErrorKind::NotFound,
1623                format!("table '{table}' not found"),
1624            )
1625        })?;
1626        let tbl = &mut self.tables[slot];
1627        let ok = tbl.patch_var_col_in_place(rid, col_idx, new_value)?;
1628        if !ok {
1629            return Ok(false);
1630        }
1631        // Mission B (post-review, second pass): WAL Off → skip the
1632        // snapshot + clone + log entirely.
1633        if self.wal.is_off() {
1634            return Ok(true);
1635        }
1636        let new_bytes = match tbl.heap.get(rid) {
1637            Some(b) => b,
1638            None => return Ok(false),
1639        };
1640        let table_name = tbl.schema.table_name.clone();
1641        let tx_id = self.next_tx();
1642        self.wal_log(tx_id, WalRecordType::Update, &table_name, rid, &new_bytes)?;
1643        Ok(true)
1644    }
1645
1646    pub fn scan(&self, table: &str) -> io::Result<impl Iterator<Item = (RowId, Row)> + '_> {
1647        Ok(self.by_name(table)?.scan())
1648    }
1649
1650    /// Zero-copy scan: passes raw row bytes to the callback without any
1651    /// per-row allocation. Used by the executor's fast paths.
1652    pub fn for_each_row_raw<F>(&self, table: &str, f: F) -> io::Result<()>
1653    where
1654        F: FnMut(RowId, &[u8]),
1655    {
1656        self.by_name(table)?.for_each_row_raw(f);
1657        Ok(())
1658    }
1659
1660    /// Zero-copy scan with early termination. The callback returns
1661    /// `ControlFlow::Break(())` to stop. Used by `Limit` fast paths so a
1662    /// `limit 100` query doesn't pay decode/predicate cost for every row
1663    /// in the table after the limit is reached.
1664    pub fn try_for_each_row_raw<F>(&self, table: &str, f: F) -> io::Result<()>
1665    where
1666        F: FnMut(RowId, &[u8]) -> std::ops::ControlFlow<()>,
1667    {
1668        self.by_name(table)?.try_for_each_row_raw(f);
1669        Ok(())
1670    }
1671
1672    pub fn create_index(&mut self, table: &str, column: &str) -> io::Result<()> {
1673        self.create_index_unique(table, column, false)
1674    }
1675
1676    /// Create an index with an explicit uniqueness flag. `unique = true`
1677    /// for primary-key-like columns where duplicate values should
1678    /// overwrite. `unique = false` for secondary indexes that allow
1679    /// duplicate column values (the default via `create_index`).
1680    pub fn create_index_unique(
1681        &mut self,
1682        table: &str,
1683        column: &str,
1684        unique: bool,
1685    ) -> io::Result<()> {
1686        let data_dir = self.data_dir.clone();
1687        self.by_name_mut(table)?
1688            .create_index_with_unique(column, &data_dir, unique)?;
1689        // Mission 3: persist the updated catalog so the indexed column
1690        // list survives a restart. `Table::create_index` already saved
1691        // the btree file itself.
1692        self.persist()
1693    }
1694
1695    /// Whether `table.column` has a UNIQUE index. Returns `Some(true)` for
1696    /// a unique index, `Some(false)` for a non-unique index, and `None`
1697    /// when the column is not indexed or the table is unknown.
1698    pub fn is_index_unique(&self, table: &str, column: &str) -> Option<bool> {
1699        self.get_table(table)?.is_index_unique(column)
1700    }
1701
1702    /// Whether `table.column` has any index (unique or non-unique).
1703    pub fn has_index(&self, table: &str, column: &str) -> bool {
1704        self.get_table(table)
1705            .map(|t| t.has_index(column))
1706            .unwrap_or(false)
1707    }
1708
1709    pub fn index_lookup(&self, table: &str, column: &str, key: &Value) -> io::Result<Option<Row>> {
1710        Ok(self
1711            .by_name(table)?
1712            .index_lookup(column, key)
1713            .map(|(_, row)| row))
1714    }
1715
1716    pub fn list_tables(&self) -> Vec<&str> {
1717        // Phase 18: iterate the Vec directly — schema.table_name is
1718        // the source of truth, and Vec order is insertion order (more
1719        // deterministic than the old FxHashMap keys).
1720        self.tables
1721            .iter()
1722            .map(|t| t.schema.table_name.as_str())
1723            .collect()
1724    }
1725
1726    pub fn schema(&self, table: &str) -> Option<&Schema> {
1727        let slot = *self.name_to_slot.get(table)?;
1728        Some(&self.tables[slot].schema)
1729    }
1730
1731    /// Drop a table: remove from the catalog and delete its data files.
1732    /// Returns `Err` if the table doesn't exist.
1733    pub fn drop_table(&mut self, name: &str) -> io::Result<()> {
1734        validate_table_name(name)?;
1735        let slot = *self.name_to_slot.get(name).ok_or_else(|| {
1736            io::Error::new(io::ErrorKind::NotFound, format!("table '{name}' not found"))
1737        })?;
1738        if !self.wal.is_off() {
1739            let payload = encode_ddl_drop_table(name);
1740            self.wal.append(0, WalRecordType::DdlDropTable, &payload)?;
1741            self.wal.flush()?;
1742        }
1743        // Remove the data file.
1744        let table = &self.tables[slot];
1745        let heap_path = self
1746            .data_dir
1747            .join(format!("{}.heap", table.schema.table_name));
1748        if heap_path.exists() {
1749            fs::remove_file(&heap_path)?;
1750        }
1751        // Mission 3: remove only the .idx files that actually exist
1752        // (i.e. the columns the table currently has indexed). The pre-
1753        // Mission-3 code iterated every schema column blindly — harmless
1754        // but noisy. Now that we persist a real list of indexed columns,
1755        // we can be precise.
1756        for col_name in table.indexed_column_names() {
1757            let idx_path = self.data_dir.join(format!("{name}_{col_name}.idx"));
1758            if idx_path.exists() {
1759                let _ = fs::remove_file(&idx_path);
1760            }
1761        }
1762        // Swap-remove from the Vec and fix up name_to_slot.
1763        self.name_to_slot.remove(name);
1764        let last = self.tables.len() - 1;
1765        if slot != last {
1766            let moved_name = self.tables[last].schema.table_name.clone();
1767            self.tables.swap(slot, last);
1768            self.name_to_slot.insert(moved_name, slot);
1769        }
1770        self.tables.pop();
1771        self.persist()?;
1772        Ok(())
1773    }
1774
1775    /// Add a column to an existing table's schema and backfill all
1776    /// existing rows to match the new shape.
1777    ///
1778    /// Older versions of this method only mutated the in-memory schema
1779    /// and relied on a (false) claim that "the heap format already
1780    /// handles short rows gracefully". It doesn't: `decode_row` reads
1781    /// exactly `n_var + 1` variable-column offsets from the row bytes
1782    /// using the CURRENT schema. Any row encoded with the old schema's
1783    /// (smaller) offset table would walk off the end of its buffer and
1784    /// panic with "range end index X out of range for slice of length Y"
1785    /// — which is exactly what a bare `Type` scan triggered right after
1786    /// an ALTER ADD COLUMN.
1787    ///
1788    /// The fix: rewrite every existing row through
1789    /// `Table::rewrite_rows_for_schema_change` so the on-disk
1790    /// encoding matches the new schema layout. Existing rows get
1791    /// `Value::Empty` for the new column.
1792    ///
1793    /// If the new column is `required` we refuse to add it to a
1794    /// non-empty table — there is no default value to backfill with,
1795    /// and silently storing `Empty` in a required slot would just
1796    /// shift the invariant violation to the next query.
1797    pub fn alter_table_add_column(&mut self, table: &str, col: ColumnDef) -> io::Result<()> {
1798        let data_dir = self.data_dir.clone();
1799        {
1800            let tbl = self.by_name_mut(table)?;
1801            if tbl.schema.columns.iter().any(|c| c.name == col.name) {
1802                return Err(io::Error::new(
1803                    io::ErrorKind::AlreadyExists,
1804                    format!("column '{}' already exists in table '{table}'", col.name),
1805                ));
1806            }
1807        }
1808        let barrier_lsn = if !self.wal.is_off() {
1809            let payload = encode_ddl_alter_add_column(table, &col);
1810            self.wal.append(0, WalRecordType::DdlAddColumn, &payload)?;
1811            self.wal.flush()?;
1812            self.wal.last_appended_lsn()
1813        } else {
1814            0
1815        };
1816        let tbl = self.by_name_mut(table)?;
1817
1818        let old_schema = tbl.schema.clone();
1819
1820        // Peek at the heap to learn whether there are any existing
1821        // rows at all. An empty table is always safe to alter — no
1822        // rewrite needed, required columns are fine, etc.
1823        let has_rows = tbl.heap.scan().next().is_some();
1824
1825        if has_rows && col.required {
1826            return Err(io::Error::new(
1827                io::ErrorKind::InvalidInput,
1828                format!(
1829                    "cannot add required column '{}' to non-empty table '{table}': \
1830                     no default value to backfill existing rows with",
1831                    col.name
1832                ),
1833            ));
1834        }
1835
1836        // Commit the new column into the schema and refresh the
1837        // cached layout so the rewrite below encodes with the new
1838        // shape.
1839        tbl.schema.columns.push(col);
1840        tbl.refresh_layout();
1841
1842        if has_rows {
1843            // Build the "fill" template: all Empty, matching the new
1844            // schema width. `rewrite_rows_for_schema_change` will
1845            // overwrite old-column slots from each live row and leave
1846            // the new slot as Empty.
1847            let fill: Vec<Value> = vec![Value::Empty; tbl.schema.columns.len()];
1848            tbl.rewrite_rows_for_schema_change(&old_schema, &fill, &data_dir)?;
1849        }
1850        // P0 fix (v0.4.3): stamp every heap page with the DDL record's
1851        // LSN so any pre-DDL Insert/Update/Delete WAL record gets
1852        // skipped on replay. Without this barrier, a restart after
1853        // `alter add column` would replay pre-alter inserts (encoded in
1854        // the OLD layout) onto a heap that's already in the NEW layout,
1855        // producing a mixed-version heap that panics on the next
1856        // projection. Regression: see `restart_after_alter_add_column_then_index`.
1857        if barrier_lsn > 0 {
1858            tbl.heap.stamp_all_pages_min_lsn(barrier_lsn)?;
1859            tbl.heap.flush()?;
1860        }
1861
1862        self.persist()?;
1863        Ok(())
1864    }
1865
1866    /// Remove a column from an existing table's schema and rewrite
1867    /// every live row to match the new shape.
1868    ///
1869    /// Older versions of this method only mutated the in-memory schema
1870    /// and claimed that "reads simply won't decode the dropped column".
1871    /// That was wrong in several ways:
1872    ///
1873    ///   1. The null bitmap is indexed by column position. Dropping a
1874    ///      column shifts every later column's bit left, but old rows
1875    ///      still have bits in the original positions — so `is_null`
1876    ///      checks silently lie for every column after the dropped one.
1877    ///   2. The bitmap's byte width (`ceil(n_cols/8)`) can shrink when
1878    ///      `n_cols` crosses an 8-boundary, shifting every subsequent
1879    ///      byte of the row against the decoder's cursor.
1880    ///   3. Fixed-region size and the variable-offset-table width both
1881    ///      depend on the column set, so dropping any fixed or variable
1882    ///      column slides every following byte.
1883    ///
1884    /// The fix mirrors `alter_table_add_column`: snapshot the old
1885    /// schema, mutate to the new schema, then rewrite every row
1886    /// through `Table::rewrite_rows_for_schema_change`. Dropping a
1887    /// column from an empty table skips the rewrite.
1888    pub fn alter_table_drop_column(&mut self, table: &str, col_name: &str) -> io::Result<()> {
1889        let data_dir = self.data_dir.clone();
1890        {
1891            let tbl = self.by_name_mut(table)?;
1892            tbl.schema
1893                .columns
1894                .iter()
1895                .position(|c| c.name == col_name)
1896                .ok_or_else(|| {
1897                    io::Error::new(
1898                        io::ErrorKind::NotFound,
1899                        format!("column '{col_name}' not found in table '{table}'"),
1900                    )
1901                })?;
1902        }
1903        let barrier_lsn = if !self.wal.is_off() {
1904            let payload = encode_ddl_alter_drop_column(table, col_name);
1905            self.wal.append(0, WalRecordType::DdlDropColumn, &payload)?;
1906            self.wal.flush()?;
1907            self.wal.last_appended_lsn()
1908        } else {
1909            0
1910        };
1911        let tbl = self.by_name_mut(table)?;
1912        let idx = tbl
1913            .schema
1914            .columns
1915            .iter()
1916            .position(|c| c.name == col_name)
1917            .ok_or_else(|| {
1918                io::Error::new(
1919                    io::ErrorKind::NotFound,
1920                    format!("column '{col_name}' not found in table '{table}'"),
1921                )
1922            })?;
1923
1924        // Snapshot for decoding old rows.
1925        let old_schema = tbl.schema.clone();
1926        let has_rows = tbl.heap.scan().next().is_some();
1927
1928        // Commit the schema change.
1929        tbl.schema.columns.remove(idx);
1930        for (i, col) in tbl.schema.columns.iter_mut().enumerate() {
1931            col.position = i as u16;
1932        }
1933        tbl.refresh_layout();
1934
1935        if has_rows {
1936            // Build a filler matching the new (smaller) shape. The
1937            // rewrite path overwrites each new-column slot from the
1938            // matching old-column value by name, so the filler only
1939            // matters for brand-new columns — drop has none, so
1940            // `Empty` is a safe placeholder that never gets read.
1941            let fill: Vec<Value> = vec![Value::Empty; tbl.schema.columns.len()];
1942            tbl.rewrite_rows_for_schema_change(&old_schema, &fill, &data_dir)?;
1943        }
1944        // P0 fix: see matching comment in alter_table_add_column.
1945        if barrier_lsn > 0 {
1946            tbl.heap.stamp_all_pages_min_lsn(barrier_lsn)?;
1947            tbl.heap.flush()?;
1948        }
1949
1950        self.persist()?;
1951        Ok(())
1952    }
1953}
1954
1955impl Drop for Catalog {
1956    fn drop(&mut self) {
1957        if self.active_tx_id.is_some() {
1958            if let Err(e) = self.abandon_active_transaction_for_drop() {
1959                warn!(error = %e, "catalog drop active transaction cleanup failed");
1960            }
1961            return;
1962        }
1963        // Mission 2: best-effort clean shutdown. `checkpoint` flushes
1964        // every heap and truncates the WAL, which is what
1965        // [`Catalog::open`] relies on to know that no replay is needed.
1966        //
1967        // We swallow errors here because Rust's `Drop` can't propagate
1968        // them and panicking during unwind is always a bigger problem
1969        // than a failed flush. The worst case on a failed drop-time
1970        // checkpoint is that the next open sees a non-empty WAL and
1971        // replays it (potentially producing duplicates — see the
1972        // [`Self::replay_wal`] caveat). That's strictly better than
1973        // losing committed writes.
1974        if let Err(e) = self.checkpoint() {
1975            warn!(error = %e, "catalog drop checkpoint failed");
1976        }
1977    }
1978}
1979
1980// ─── WAL payload codec ─────────────────────────────────────────────────────
1981//
1982// Per-record payload layout (little-endian):
1983//
1984//   table_name_len : u32
1985//   table_name     : utf-8 bytes
1986//   page_id        : u32   (for insert: 0, ignored on replay)
1987//   slot_index     : u16   (for insert: 0, ignored on replay)
1988//   row_len        : u32
1989//   row_bytes      : raw encoded row (length = row_len)
1990//
1991// Lives next to `Catalog` because this is the only code that produces or
1992// consumes these records — the `Wal` itself is payload-agnostic.
1993
1994fn encode_wal_payload(table: &str, rid: RowId, row_bytes: &[u8]) -> Vec<u8> {
1995    let name = table.as_bytes();
1996    let mut out = Vec::with_capacity(4 + name.len() + 4 + 2 + 4 + row_bytes.len());
1997    out.extend_from_slice(&(name.len() as u32).to_le_bytes());
1998    out.extend_from_slice(name);
1999    out.extend_from_slice(&rid.page_id.to_le_bytes());
2000    out.extend_from_slice(&rid.slot_index.to_le_bytes());
2001    out.extend_from_slice(&(row_bytes.len() as u32).to_le_bytes());
2002    out.extend_from_slice(row_bytes);
2003    out
2004}
2005
2006fn decode_wal_payload(data: &[u8]) -> Option<(String, RowId, Vec<u8>)> {
2007    let mut pos = 0usize;
2008    if data.len() < 4 {
2009        return None;
2010    }
2011    let name_len = u32::from_le_bytes(data[pos..pos + 4].try_into().ok()?) as usize;
2012    pos += 4;
2013    if pos + name_len > data.len() {
2014        return None;
2015    }
2016    let name = std::str::from_utf8(&data[pos..pos + name_len])
2017        .ok()?
2018        .to_string();
2019    pos += name_len;
2020    if pos + 4 + 2 + 4 > data.len() {
2021        return None;
2022    }
2023    let page_id = u32::from_le_bytes(data[pos..pos + 4].try_into().ok()?);
2024    pos += 4;
2025    let slot_index = u16::from_le_bytes(data[pos..pos + 2].try_into().ok()?);
2026    pos += 2;
2027    let row_len = u32::from_le_bytes(data[pos..pos + 4].try_into().ok()?) as usize;
2028    pos += 4;
2029    if pos + row_len > data.len() {
2030        return None;
2031    }
2032    let row_bytes = data[pos..pos + row_len].to_vec();
2033    Some((
2034        name,
2035        RowId {
2036            page_id,
2037            slot_index,
2038        },
2039        row_bytes,
2040    ))
2041}
2042
2043// ─── DDL WAL payload codecs ─────────────────────────────────────────────────
2044
2045fn encode_ddl_create_table(
2046    schema: &Schema,
2047    defaults: &[Option<Value>],
2048    auto_cols: &[bool],
2049) -> Vec<u8> {
2050    let name = schema.table_name.as_bytes();
2051    let mut out = Vec::new();
2052    out.extend_from_slice(&(name.len() as u32).to_le_bytes());
2053    out.extend_from_slice(name);
2054    out.extend_from_slice(&(schema.columns.len() as u16).to_le_bytes());
2055    for col in &schema.columns {
2056        let cn = col.name.as_bytes();
2057        out.extend_from_slice(&(cn.len() as u32).to_le_bytes());
2058        out.extend_from_slice(cn);
2059        out.push(col.type_id as u8);
2060        out.push(col.required as u8);
2061        out.extend_from_slice(&col.position.to_le_bytes());
2062    }
2063    // Trailing sections. Records written before each feature existed simply
2064    // lack the corresponding trailing bytes, so the decoder treats their
2065    // absence as "none" (length-detected, append-only).
2066    encode_defaults_section(&mut out, defaults);
2067    encode_auto_section(&mut out, auto_cols);
2068    out
2069}
2070
2071fn decode_ddl_create_table(data: &[u8]) -> Option<(Schema, Vec<Option<Value>>, Vec<bool>)> {
2072    let mut pos = 0usize;
2073    if data.len() < 4 {
2074        return None;
2075    }
2076    let name_len = u32::from_le_bytes(data[pos..pos + 4].try_into().ok()?) as usize;
2077    pos += 4;
2078    if pos + name_len > data.len() {
2079        return None;
2080    }
2081    let table_name = std::str::from_utf8(&data[pos..pos + name_len])
2082        .ok()?
2083        .to_string();
2084    pos += name_len;
2085    if pos + 2 > data.len() {
2086        return None;
2087    }
2088    let n_cols = u16::from_le_bytes(data[pos..pos + 2].try_into().ok()?) as usize;
2089    pos += 2;
2090    let mut columns = Vec::with_capacity(n_cols);
2091    for _ in 0..n_cols {
2092        if pos + 4 > data.len() {
2093            return None;
2094        }
2095        let cn_len = u32::from_le_bytes(data[pos..pos + 4].try_into().ok()?) as usize;
2096        pos += 4;
2097        if pos + cn_len + 4 > data.len() {
2098            return None;
2099        }
2100        let col_name = std::str::from_utf8(&data[pos..pos + cn_len])
2101            .ok()?
2102            .to_string();
2103        pos += cn_len;
2104        let type_id = TypeId::from_u8(data[pos])?;
2105        pos += 1;
2106        let required = data[pos] != 0;
2107        pos += 1;
2108        if pos + 2 > data.len() {
2109            return None;
2110        }
2111        let position = u16::from_le_bytes(data[pos..pos + 2].try_into().ok()?);
2112        pos += 2;
2113        columns.push(ColumnDef {
2114            name: col_name,
2115            type_id,
2116            required,
2117            position,
2118        });
2119    }
2120    // Trailing sections are present on records written after each feature
2121    // landed; older records end early, decoding to "none".
2122    let defaults = if pos < data.len() {
2123        decode_defaults_section(data, &mut pos, columns.len())?
2124    } else {
2125        Vec::new()
2126    };
2127    let auto_cols = if pos < data.len() {
2128        decode_auto_section(data, &mut pos, columns.len())?
2129    } else {
2130        Vec::new()
2131    };
2132    Some((
2133        Schema {
2134            table_name,
2135            columns,
2136        },
2137        defaults,
2138        auto_cols,
2139    ))
2140}
2141
2142fn encode_ddl_drop_table(table_name: &str) -> Vec<u8> {
2143    let name = table_name.as_bytes();
2144    let mut out = Vec::with_capacity(4 + name.len());
2145    out.extend_from_slice(&(name.len() as u32).to_le_bytes());
2146    out.extend_from_slice(name);
2147    out
2148}
2149
2150fn encode_ddl_alter_add_column(table_name: &str, col: &ColumnDef) -> Vec<u8> {
2151    let name = table_name.as_bytes();
2152    let cn = col.name.as_bytes();
2153    let mut out = Vec::with_capacity(4 + name.len() + 4 + cn.len() + 4);
2154    out.extend_from_slice(&(name.len() as u32).to_le_bytes());
2155    out.extend_from_slice(name);
2156    out.extend_from_slice(&(cn.len() as u32).to_le_bytes());
2157    out.extend_from_slice(cn);
2158    out.push(col.type_id as u8);
2159    out.push(col.required as u8);
2160    out.extend_from_slice(&col.position.to_le_bytes());
2161    out
2162}
2163
2164fn encode_ddl_alter_drop_column(table_name: &str, col_name: &str) -> Vec<u8> {
2165    let name = table_name.as_bytes();
2166    let cn = col_name.as_bytes();
2167    let mut out = Vec::with_capacity(4 + name.len() + 4 + cn.len());
2168    out.extend_from_slice(&(name.len() as u32).to_le_bytes());
2169    out.extend_from_slice(name);
2170    out.extend_from_slice(&(cn.len() as u32).to_le_bytes());
2171    out.extend_from_slice(cn);
2172    out
2173}
2174
2175fn decode_ddl_table_name(data: &[u8]) -> Option<(String, usize)> {
2176    if data.len() < 4 {
2177        return None;
2178    }
2179    let name_len = u32::from_le_bytes(data[0..4].try_into().ok()?) as usize;
2180    if 4 + name_len > data.len() {
2181        return None;
2182    }
2183    let name = std::str::from_utf8(&data[4..4 + name_len])
2184        .ok()?
2185        .to_string();
2186    Some((name, 4 + name_len))
2187}
2188
2189fn decode_ddl_alter_add_column(data: &[u8]) -> Option<(String, ColumnDef)> {
2190    let (table_name, mut pos) = decode_ddl_table_name(data)?;
2191    if pos + 4 > data.len() {
2192        return None;
2193    }
2194    let cn_len = u32::from_le_bytes(data[pos..pos + 4].try_into().ok()?) as usize;
2195    pos += 4;
2196    if pos + cn_len + 4 > data.len() {
2197        return None;
2198    }
2199    let col_name = std::str::from_utf8(&data[pos..pos + cn_len])
2200        .ok()?
2201        .to_string();
2202    pos += cn_len;
2203    let type_id = TypeId::from_u8(data[pos])?;
2204    pos += 1;
2205    let required = data[pos] != 0;
2206    pos += 1;
2207    if pos + 2 > data.len() {
2208        return None;
2209    }
2210    let position = u16::from_le_bytes(data[pos..pos + 2].try_into().ok()?);
2211    Some((
2212        table_name,
2213        ColumnDef {
2214            name: col_name,
2215            type_id,
2216            required,
2217            position,
2218        },
2219    ))
2220}
2221
2222fn decode_ddl_alter_drop_column(data: &[u8]) -> Option<(String, String)> {
2223    let (table_name, pos) = decode_ddl_table_name(data)?;
2224    if pos + 4 > data.len() {
2225        return None;
2226    }
2227    let cn_len = u32::from_le_bytes(data[pos..pos + 4].try_into().ok()?) as usize;
2228    if pos + 4 + cn_len > data.len() {
2229        return None;
2230    }
2231    let col_name = std::str::from_utf8(&data[pos + 4..pos + 4 + cn_len])
2232        .ok()?
2233        .to_string();
2234    Some((table_name, col_name))
2235}
2236
2237// ─── Catalog file format ────────────────────────────────────────────────────
2238//
2239// Layout (version 2):
2240//   magic     [4]      = "BCAT"
2241//   version   u16
2242//   n_tables  u32
2243//   for each table:
2244//     table_name_len  u32
2245//     table_name      utf8 bytes
2246//     n_columns       u16
2247//     for each column:
2248//       name_len      u32
2249//       name          utf8 bytes
2250//       type_id       u8
2251//       required      u8
2252//       position      u16
2253//     ── version 2 appends: ──
2254//     n_indexed_cols  u16
2255//     for each indexed column:
2256//       name_len      u32
2257//       name          utf8 bytes
2258//
2259// Version 1 files are accepted by the reader (same shape minus the
2260// trailing indexed-column block) and treated as having zero indexed
2261// columns. Writers always emit version 2 from Mission 3 onwards.
2262
2263/// Per-indexed-column metadata persisted in the catalog file.
2264pub(crate) struct IndexedColMeta {
2265    pub name: String,
2266    pub unique: bool,
2267}
2268
2269/// In-memory catalog entry pairing a schema with its indexed column list.
2270/// Produced by the reader; the writer takes the borrowed counterpart below.
2271pub(crate) struct CatalogEntry {
2272    pub schema: Schema,
2273    pub indexed_cols: Vec<IndexedColMeta>,
2274    /// Per-column defaults aligned to `schema.columns` by position. Empty when
2275    /// no column has a default (v1–v3 files always decode to empty).
2276    pub defaults: Vec<Option<Value>>,
2277    /// Which columns are `auto`, aligned to `schema.columns`. Empty when none
2278    /// (v1–v4 files always decode to empty).
2279    pub auto_cols: Vec<bool>,
2280}
2281
2282/// Borrowed view passed to the writer.
2283pub(crate) struct CatalogEntryRef<'a> {
2284    pub schema: &'a Schema,
2285    pub indexed_cols: Vec<IndexedColMeta>,
2286    pub defaults: &'a [Option<Value>],
2287    pub auto_cols: &'a [bool],
2288}
2289
2290// ─── Column-default codecs (shared by catalog.bin and the WAL DDL record) ────
2291
2292/// Encode a single scalar value: a `type_id` tag byte followed by a
2293/// type-specific, length-prefixed (for variable-width types) payload. Lossless
2294/// — used to persist literal column defaults.
2295fn encode_value_blob(out: &mut Vec<u8>, v: &Value) {
2296    out.push(v.type_id() as u8);
2297    match v {
2298        Value::Int(n) => out.extend_from_slice(&n.to_le_bytes()),
2299        Value::Float(f) => out.extend_from_slice(&f.to_bits().to_le_bytes()),
2300        Value::Bool(b) => out.push(*b as u8),
2301        Value::Str(s) => {
2302            out.extend_from_slice(&(s.len() as u32).to_le_bytes());
2303            out.extend_from_slice(s.as_bytes());
2304        }
2305        Value::DateTime(n) => out.extend_from_slice(&n.to_le_bytes()),
2306        Value::Uuid(u) => out.extend_from_slice(u),
2307        Value::Bytes(b) => {
2308            out.extend_from_slice(&(b.len() as u32).to_le_bytes());
2309            out.extend_from_slice(b);
2310        }
2311        Value::Empty => {}
2312    }
2313}
2314
2315/// Inverse of [`encode_value_blob`]. Returns `None` on any malformed/truncated
2316/// input so a corrupt record fails closed rather than panicking.
2317fn decode_value_blob(data: &[u8], pos: &mut usize) -> Option<Value> {
2318    let tag = *data.get(*pos)?;
2319    *pos += 1;
2320    let type_id = TypeId::from_u8(tag)?;
2321    let take_fixed = |pos: &mut usize, n: usize| -> Option<Vec<u8>> {
2322        if *pos + n > data.len() {
2323            return None;
2324        }
2325        let slice = data[*pos..*pos + n].to_vec();
2326        *pos += n;
2327        Some(slice)
2328    };
2329    match type_id {
2330        TypeId::Empty => Some(Value::Empty),
2331        TypeId::Int => Some(Value::Int(i64::from_le_bytes(
2332            take_fixed(pos, 8)?.try_into().ok()?,
2333        ))),
2334        TypeId::Float => Some(Value::Float(f64::from_bits(u64::from_le_bytes(
2335            take_fixed(pos, 8)?.try_into().ok()?,
2336        )))),
2337        TypeId::Bool => Some(Value::Bool(take_fixed(pos, 1)?[0] != 0)),
2338        TypeId::DateTime => Some(Value::DateTime(i64::from_le_bytes(
2339            take_fixed(pos, 8)?.try_into().ok()?,
2340        ))),
2341        TypeId::Uuid => Some(Value::Uuid(take_fixed(pos, 16)?.try_into().ok()?)),
2342        TypeId::Str => {
2343            let len = u32::from_le_bytes(take_fixed(pos, 4)?.try_into().ok()?) as usize;
2344            Some(Value::Str(String::from_utf8(take_fixed(pos, len)?).ok()?))
2345        }
2346        TypeId::Bytes => {
2347            let len = u32::from_le_bytes(take_fixed(pos, 4)?.try_into().ok()?) as usize;
2348            Some(Value::Bytes(take_fixed(pos, len)?))
2349        }
2350    }
2351}
2352
2353/// Encode the per-table defaults as a sparse list: a `u16` count of columns
2354/// that have a default, then `(position: u16, value blob)` pairs. The common
2355/// "no defaults" case costs two bytes.
2356fn encode_defaults_section(out: &mut Vec<u8>, defaults: &[Option<Value>]) {
2357    let present: Vec<(u16, &Value)> = defaults
2358        .iter()
2359        .enumerate()
2360        .filter_map(|(i, d)| d.as_ref().map(|v| (i as u16, v)))
2361        .collect();
2362    out.extend_from_slice(&(present.len() as u16).to_le_bytes());
2363    for (pos, v) in present {
2364        out.extend_from_slice(&pos.to_le_bytes());
2365        encode_value_blob(out, v);
2366    }
2367}
2368
2369/// Inverse of [`encode_defaults_section`]. Builds a `Vec` of length `n_cols`
2370/// with `None` for columns without a default. Returns `None` on truncation.
2371fn decode_defaults_section(
2372    data: &[u8],
2373    pos: &mut usize,
2374    n_cols: usize,
2375) -> Option<Vec<Option<Value>>> {
2376    if *pos + 2 > data.len() {
2377        return None;
2378    }
2379    let count = u16::from_le_bytes(data[*pos..*pos + 2].try_into().ok()?) as usize;
2380    *pos += 2;
2381    let mut out = vec![None; n_cols];
2382    for _ in 0..count {
2383        if *pos + 2 > data.len() {
2384            return None;
2385        }
2386        let col = u16::from_le_bytes(data[*pos..*pos + 2].try_into().ok()?) as usize;
2387        *pos += 2;
2388        let value = decode_value_blob(data, pos)?;
2389        if col < n_cols {
2390            out[col] = Some(value);
2391        }
2392    }
2393    Some(out)
2394}
2395
2396/// Encode the per-table `auto` columns as a sparse list: a `u16` count of auto
2397/// columns, then their positions (`u16` each). "No auto columns" costs two
2398/// bytes.
2399fn encode_auto_section(out: &mut Vec<u8>, auto_cols: &[bool]) {
2400    let present: Vec<u16> = auto_cols
2401        .iter()
2402        .enumerate()
2403        .filter_map(|(i, &a)| if a { Some(i as u16) } else { None })
2404        .collect();
2405    out.extend_from_slice(&(present.len() as u16).to_le_bytes());
2406    for pos in present {
2407        out.extend_from_slice(&pos.to_le_bytes());
2408    }
2409}
2410
2411/// Inverse of [`encode_auto_section`]. Builds a `bool` vec of length `n_cols`.
2412/// Returns `None` on truncation.
2413fn decode_auto_section(data: &[u8], pos: &mut usize, n_cols: usize) -> Option<Vec<bool>> {
2414    if *pos + 2 > data.len() {
2415        return None;
2416    }
2417    let count = u16::from_le_bytes(data[*pos..*pos + 2].try_into().ok()?) as usize;
2418    *pos += 2;
2419    let mut out = vec![false; n_cols];
2420    for _ in 0..count {
2421        if *pos + 2 > data.len() {
2422            return None;
2423        }
2424        let col = u16::from_le_bytes(data[*pos..*pos + 2].try_into().ok()?) as usize;
2425        *pos += 2;
2426        if col < n_cols {
2427            out[col] = true;
2428        }
2429    }
2430    Some(out)
2431}
2432
2433fn write_catalog_file(path: &Path, entries: &[CatalogEntryRef<'_>]) -> io::Result<()> {
2434    let mut buf: Vec<u8> = Vec::with_capacity(64);
2435    buf.extend_from_slice(CATALOG_MAGIC);
2436    buf.extend_from_slice(&CATALOG_VERSION.to_le_bytes());
2437    buf.extend_from_slice(&(entries.len() as u32).to_le_bytes());
2438
2439    for entry in entries {
2440        let schema = entry.schema;
2441        let name = schema.table_name.as_bytes();
2442        buf.extend_from_slice(&(name.len() as u32).to_le_bytes());
2443        buf.extend_from_slice(name);
2444        buf.extend_from_slice(&(schema.columns.len() as u16).to_le_bytes());
2445        for col in &schema.columns {
2446            let cn = col.name.as_bytes();
2447            buf.extend_from_slice(&(cn.len() as u32).to_le_bytes());
2448            buf.extend_from_slice(cn);
2449            buf.push(col.type_id as u8);
2450            buf.push(if col.required { 1 } else { 0 });
2451            buf.extend_from_slice(&col.position.to_le_bytes());
2452        }
2453        // Per-table indexed column list with uniqueness flags (version 3).
2454        buf.extend_from_slice(&(entry.indexed_cols.len() as u16).to_le_bytes());
2455        for meta in &entry.indexed_cols {
2456            let cn = meta.name.as_bytes();
2457            buf.extend_from_slice(&(cn.len() as u32).to_le_bytes());
2458            buf.extend_from_slice(cn);
2459            buf.push(if meta.unique { 1 } else { 0 });
2460        }
2461        // Per-table column defaults (version 4).
2462        encode_defaults_section(&mut buf, entry.defaults);
2463        // Per-table auto-increment columns (version 5).
2464        encode_auto_section(&mut buf, entry.auto_cols);
2465    }
2466
2467    // Append a CRC32 checksum of the entire payload so the reader can
2468    // detect corruption (the WAL and btree .idx files already do this;
2469    // catalog.bin was the one file missing a checksum).
2470    let crc = crc32fast::hash(&buf);
2471    buf.extend_from_slice(&crc.to_le_bytes());
2472
2473    let mut f = fs::OpenOptions::new()
2474        .create(true)
2475        .write(true)
2476        .truncate(true)
2477        .open(path)?;
2478    f.write_all(&buf)?;
2479    f.sync_data()?;
2480    Ok(())
2481}
2482
2483fn read_catalog_file(path: &Path) -> io::Result<Vec<CatalogEntry>> {
2484    let mut f = fs::File::open(path)?;
2485    let mut buf = Vec::new();
2486    f.read_to_end(&mut buf)?;
2487
2488    let mut pos = 0usize;
2489    // Minimum: 4 (magic) + 2 (version) + 4 (n_tables) + 4 (crc) = 14
2490    if buf.len() < 14 || &buf[0..4] != CATALOG_MAGIC {
2491        return Err(io::Error::new(
2492            io::ErrorKind::InvalidData,
2493            "bad catalog magic",
2494        ));
2495    }
2496
2497    // Verify the trailing CRC32 checksum.
2498    let payload = &buf[..buf.len() - 4];
2499    let stored_crc = u32::from_le_bytes(
2500        buf[buf.len() - 4..]
2501            .try_into()
2502            .map_err(|_| io::Error::new(io::ErrorKind::InvalidData, "truncated catalog CRC"))?,
2503    );
2504    let computed_crc = crc32fast::hash(payload);
2505    if stored_crc != computed_crc {
2506        return Err(io::Error::new(
2507            io::ErrorKind::InvalidData,
2508            format!(
2509                "catalog CRC32 mismatch: expected {stored_crc:#010x}, got {computed_crc:#010x}"
2510            ),
2511        ));
2512    }
2513    // Strip the CRC suffix so the parsing loop below doesn't walk into it.
2514    let buf = &buf[..buf.len() - 4];
2515    pos += 4;
2516    let version = u16::from_le_bytes(
2517        buf[pos..pos + 2]
2518            .try_into()
2519            .map_err(|_| io::Error::new(io::ErrorKind::InvalidData, "truncated catalog header"))?,
2520    );
2521    pos += 2;
2522    // Accept every version from 1 up to the current CATALOG_VERSION: the
2523    // field-reading staircase below fills in fields a newer version added
2524    // (indexed-col uniqueness at v3, defaults at v4, auto columns at v5) and
2525    // defaults them for older files, so any 1..=CATALOG_VERSION file loads.
2526    // A range check (not an enumerated list) is what makes this back-compat
2527    // hold automatically on the next bump — the previous `version != 1 &&
2528    // version != 2 && version != CATALOG_VERSION` form silently rejected the
2529    // intermediate v3/v4 files when the constant moved to 5, which would have
2530    // failed to open a v0.6.x database on upgrade (data loss).
2531    if version == 0 || version > CATALOG_VERSION {
2532        return Err(io::Error::new(
2533            io::ErrorKind::InvalidData,
2534            format!("unsupported catalog version: {version}"),
2535        ));
2536    }
2537    let n_tables = u32::from_le_bytes(
2538        buf[pos..pos + 4]
2539            .try_into()
2540            .map_err(|_| io::Error::new(io::ErrorKind::InvalidData, "truncated catalog header"))?,
2541    ) as usize;
2542    pos += 4;
2543
2544    // Don't size an allocation from an unvalidated count: a corrupt or hostile
2545    // catalog could claim billions of tables and make the `Vec::with_capacity`
2546    // below attempt a huge allocation (host abort — fatal in embedded mode). A
2547    // file of `buf.len()` bytes can describe at most that many tables (each
2548    // needs several header bytes), so a larger count is corrupt. Mirrors the
2549    // btree's node-count guard.
2550    if n_tables > buf.len() {
2551        return Err(io::Error::new(
2552            io::ErrorKind::InvalidData,
2553            format!("catalog file corrupt: implausible table count {n_tables}"),
2554        ));
2555    }
2556
2557    let mut entries = Vec::with_capacity(n_tables);
2558    for _ in 0..n_tables {
2559        let name_len = read_u32(buf, &mut pos)? as usize;
2560        let table_name = read_string(buf, &mut pos, name_len)?;
2561        let n_cols = read_u16(buf, &mut pos)? as usize;
2562
2563        let mut columns = Vec::with_capacity(n_cols);
2564        for _ in 0..n_cols {
2565            let cname_len = read_u32(buf, &mut pos)? as usize;
2566            let name = read_string(buf, &mut pos, cname_len)?;
2567            let type_id_raw = read_u8(buf, &mut pos)?;
2568            let type_id = type_id_from_u8(type_id_raw)?;
2569            let required = read_u8(buf, &mut pos)? != 0;
2570            let position = read_u16(buf, &mut pos)?;
2571            columns.push(ColumnDef {
2572                name,
2573                type_id,
2574                required,
2575                position,
2576            });
2577        }
2578
2579        // Version 3 appends indexed column list with uniqueness flag.
2580        // Version 2 has indexed column names without uniqueness (default
2581        // to non-unique). Version 1 has no index info at all.
2582        let indexed_cols: Vec<IndexedColMeta> = if version >= 3 {
2583            let n = read_u16(buf, &mut pos)? as usize;
2584            let mut v = Vec::with_capacity(n);
2585            for _ in 0..n {
2586                let l = read_u32(buf, &mut pos)? as usize;
2587                let name = read_string(buf, &mut pos, l)?;
2588                let unique = read_u8(buf, &mut pos)? != 0;
2589                v.push(IndexedColMeta { name, unique });
2590            }
2591            v
2592        } else if version >= 2 {
2593            let n = read_u16(buf, &mut pos)? as usize;
2594            let mut v = Vec::with_capacity(n);
2595            for _ in 0..n {
2596                let l = read_u32(buf, &mut pos)? as usize;
2597                let name = read_string(buf, &mut pos, l)?;
2598                v.push(IndexedColMeta {
2599                    name,
2600                    unique: false,
2601                });
2602            }
2603            v
2604        } else {
2605            Vec::new()
2606        };
2607
2608        // Version 4 appends a column-defaults section after the index list.
2609        let defaults = if version >= 4 {
2610            decode_defaults_section(buf, &mut pos, columns.len()).ok_or_else(|| {
2611                io::Error::new(io::ErrorKind::InvalidData, "truncated catalog defaults")
2612            })?
2613        } else {
2614            Vec::new()
2615        };
2616
2617        // Version 5 appends an auto-increment column section after that.
2618        let auto_cols = if version >= 5 {
2619            decode_auto_section(buf, &mut pos, columns.len()).ok_or_else(|| {
2620                io::Error::new(io::ErrorKind::InvalidData, "truncated catalog auto columns")
2621            })?
2622        } else {
2623            Vec::new()
2624        };
2625
2626        entries.push(CatalogEntry {
2627            schema: Schema {
2628                table_name,
2629                columns,
2630            },
2631            indexed_cols,
2632            defaults,
2633            auto_cols,
2634        });
2635    }
2636
2637    Ok(entries)
2638}
2639
2640fn read_u8(buf: &[u8], pos: &mut usize) -> io::Result<u8> {
2641    if *pos >= buf.len() {
2642        return Err(io::Error::new(
2643            io::ErrorKind::UnexpectedEof,
2644            "truncated catalog",
2645        ));
2646    }
2647    let v = buf[*pos];
2648    *pos += 1;
2649    Ok(v)
2650}
2651fn read_u16(buf: &[u8], pos: &mut usize) -> io::Result<u16> {
2652    if *pos + 2 > buf.len() {
2653        return Err(io::Error::new(
2654            io::ErrorKind::UnexpectedEof,
2655            "truncated catalog",
2656        ));
2657    }
2658    let v = u16::from_le_bytes(
2659        buf[*pos..*pos + 2]
2660            .try_into()
2661            .expect("bounds checked above"),
2662    );
2663    *pos += 2;
2664    Ok(v)
2665}
2666fn read_u32(buf: &[u8], pos: &mut usize) -> io::Result<u32> {
2667    if *pos + 4 > buf.len() {
2668        return Err(io::Error::new(
2669            io::ErrorKind::UnexpectedEof,
2670            "truncated catalog",
2671        ));
2672    }
2673    let v = u32::from_le_bytes(
2674        buf[*pos..*pos + 4]
2675            .try_into()
2676            .expect("bounds checked above"),
2677    );
2678    *pos += 4;
2679    Ok(v)
2680}
2681fn read_string(buf: &[u8], pos: &mut usize, len: usize) -> io::Result<String> {
2682    if *pos + len > buf.len() {
2683        return Err(io::Error::new(
2684            io::ErrorKind::UnexpectedEof,
2685            "truncated catalog string",
2686        ));
2687    }
2688    let s = std::str::from_utf8(&buf[*pos..*pos + len])
2689        .map_err(|_| io::Error::new(io::ErrorKind::InvalidData, "non-utf8 in catalog"))?
2690        .to_string();
2691    *pos += len;
2692    Ok(s)
2693}
2694fn type_id_from_u8(v: u8) -> io::Result<TypeId> {
2695    match v {
2696        0 => Ok(TypeId::Empty),
2697        1 => Ok(TypeId::Int),
2698        2 => Ok(TypeId::Float),
2699        3 => Ok(TypeId::Bool),
2700        4 => Ok(TypeId::Str),
2701        5 => Ok(TypeId::DateTime),
2702        6 => Ok(TypeId::Uuid),
2703        7 => Ok(TypeId::Bytes),
2704        _ => Err(io::Error::new(
2705            io::ErrorKind::InvalidData,
2706            format!("unknown type id: {v}"),
2707        )),
2708    }
2709}
2710
2711#[cfg(test)]
2712mod tests {
2713    use super::*;
2714    fn temp_catalog(name: &str) -> Catalog {
2715        let dir = std::env::temp_dir().join(format!("powdb_cat_{name}_{}", std::process::id()));
2716        Catalog::create(&dir).unwrap()
2717    }
2718
2719    fn schema_two_cols() -> Schema {
2720        Schema {
2721            table_name: "T".into(),
2722            columns: vec![
2723                ColumnDef {
2724                    name: "id".into(),
2725                    type_id: TypeId::Int,
2726                    required: true,
2727                    position: 0,
2728                },
2729                ColumnDef {
2730                    name: "status".into(),
2731                    type_id: TypeId::Str,
2732                    required: false,
2733                    position: 1,
2734                },
2735            ],
2736        }
2737    }
2738
2739    #[test]
2740    fn replay_records_treats_reused_tx_ids_as_ordered_spans() {
2741        let mut cat = temp_catalog("reused_tx_ids");
2742        let schema = schema_two_cols();
2743        cat.create_table(schema.clone()).unwrap();
2744        cat.checkpoint().unwrap();
2745
2746        let mut committed_row = Vec::new();
2747        encode_row_into(
2748            &schema,
2749            &[Value::Int(1), Value::Str("committed".into())],
2750            &mut committed_row,
2751        );
2752        let mut incomplete_row = Vec::new();
2753        encode_row_into(
2754            &schema,
2755            &[Value::Int(2), Value::Str("incomplete".into())],
2756            &mut incomplete_row,
2757        );
2758
2759        let records = vec![
2760            WalRecord {
2761                tx_id: 1,
2762                record_type: WalRecordType::Begin,
2763                lsn: 1,
2764                data: Vec::new(),
2765            },
2766            WalRecord {
2767                tx_id: 1,
2768                record_type: WalRecordType::Insert,
2769                lsn: 2,
2770                data: encode_wal_payload(
2771                    "T",
2772                    RowId {
2773                        page_id: 1,
2774                        slot_index: 0,
2775                    },
2776                    &committed_row,
2777                ),
2778            },
2779            WalRecord {
2780                tx_id: 1,
2781                record_type: WalRecordType::Commit,
2782                lsn: 3,
2783                data: Vec::new(),
2784            },
2785            WalRecord {
2786                tx_id: 1,
2787                record_type: WalRecordType::Begin,
2788                lsn: 4,
2789                data: Vec::new(),
2790            },
2791            WalRecord {
2792                tx_id: 1,
2793                record_type: WalRecordType::Insert,
2794                lsn: 5,
2795                data: encode_wal_payload(
2796                    "T",
2797                    RowId {
2798                        page_id: 1,
2799                        slot_index: 1,
2800                    },
2801                    &incomplete_row,
2802                ),
2803            },
2804        ];
2805
2806        cat.apply_wal_records(&records).unwrap();
2807        let rows: Vec<_> = cat.scan("T").unwrap().collect();
2808        assert_eq!(rows.len(), 1);
2809        assert_eq!(rows[0].1[0], Value::Int(1));
2810        assert_eq!(rows[0].1[1], Value::Str("committed".into()));
2811    }
2812
2813    #[test]
2814    fn ddl_create_table_codec_roundtrips_defaults_and_auto() {
2815        let schema = schema_two_cols();
2816        let defaults = vec![None, Some(Value::Str("active".into()))];
2817        let auto_cols = vec![true, false];
2818        let encoded = encode_ddl_create_table(&schema, &defaults, &auto_cols);
2819        let (decoded_schema, decoded_defaults, decoded_auto) =
2820            decode_ddl_create_table(&encoded).unwrap();
2821        assert_eq!(decoded_schema.columns.len(), 2);
2822        assert_eq!(decoded_defaults, defaults);
2823        assert_eq!(decoded_auto, auto_cols);
2824    }
2825
2826    #[test]
2827    fn ddl_create_table_codec_back_compat_without_trailing_sections() {
2828        // Simulate a record written before column defaults / auto existed: the
2829        // old encoder stopped right after the columns, with no trailing
2830        // sections. The new decoder must read those as "none".
2831        let schema = schema_two_cols();
2832        let full = encode_ddl_create_table(&schema, &[], &[]);
2833        // Each empty trailing section is a u16 count of 0 (two bytes); chop
2834        // both off to mimic the pre-feature on-disk shape.
2835        let legacy = &full[..full.len() - 4];
2836        let (decoded_schema, decoded_defaults, decoded_auto) =
2837            decode_ddl_create_table(legacy).unwrap();
2838        assert_eq!(decoded_schema.columns.len(), 2);
2839        assert!(decoded_defaults.is_empty(), "no defaults section -> empty");
2840        assert!(decoded_auto.is_empty(), "no auto section -> empty");
2841    }
2842
2843    #[test]
2844    fn ddl_create_table_codec_back_compat_defaults_but_no_auto() {
2845        // A record from the column-defaults release (#129) has a defaults
2846        // section but no auto section; the auto-aware decoder must still read it.
2847        let schema = schema_two_cols();
2848        let defaults = vec![None, Some(Value::Str("active".into()))];
2849        let full = encode_ddl_create_table(&schema, &defaults, &[]);
2850        // Drop only the trailing auto section (its empty u16 count).
2851        let legacy = &full[..full.len() - 2];
2852        let (_schema, decoded_defaults, decoded_auto) = decode_ddl_create_table(legacy).unwrap();
2853        assert_eq!(decoded_defaults, defaults);
2854        assert!(decoded_auto.is_empty());
2855    }
2856
2857    #[test]
2858    fn read_catalog_file_accepts_intermediate_versions_3_and_4() {
2859        // Regression: the version gate accepted only {1, 2, CATALOG_VERSION}, so
2860        // a catalog written at version 3 (v0.6.x) or 4 (the column-defaults
2861        // release) was rejected with "unsupported catalog version" — the
2862        // database would fail to open on upgrade from those releases = data
2863        // loss. The field-reading staircase already handles v3/v4; only the gate
2864        // was stale. Build faithful v3/v4 catalog files by hand and confirm they
2865        // load (defaults/auto default to empty for the versions that lack them).
2866        use std::io::Write as _;
2867        fn write_legacy_catalog(path: &std::path::Path, version: u16) {
2868            let mut buf: Vec<u8> = Vec::new();
2869            buf.extend_from_slice(CATALOG_MAGIC);
2870            buf.extend_from_slice(&version.to_le_bytes());
2871            buf.extend_from_slice(&1u32.to_le_bytes()); // n_tables
2872                                                        // table "T"
2873            buf.extend_from_slice(&1u32.to_le_bytes());
2874            buf.extend_from_slice(b"T");
2875            buf.extend_from_slice(&2u16.to_le_bytes()); // n_cols
2876                                                        // col id: Int, required, pos 0
2877            buf.extend_from_slice(&2u32.to_le_bytes());
2878            buf.extend_from_slice(b"id");
2879            buf.push(TypeId::Int as u8);
2880            buf.push(1);
2881            buf.extend_from_slice(&0u16.to_le_bytes());
2882            // col status: Str, not required, pos 1
2883            buf.extend_from_slice(&6u32.to_le_bytes());
2884            buf.extend_from_slice(b"status");
2885            buf.push(TypeId::Str as u8);
2886            buf.push(0);
2887            buf.extend_from_slice(&1u16.to_le_bytes());
2888            // version >= 3: indexed-column section (count 0).
2889            buf.extend_from_slice(&0u16.to_le_bytes());
2890            // version >= 4: column-defaults section (none here). v3 omits it.
2891            if version >= 4 {
2892                encode_defaults_section(&mut buf, &[None, None]);
2893            }
2894            // v3/v4 never wrote the v5 auto section.
2895            let crc = crc32fast::hash(&buf);
2896            buf.extend_from_slice(&crc.to_le_bytes());
2897            let mut f = fs::File::create(path).unwrap();
2898            f.write_all(&buf).unwrap();
2899        }
2900
2901        for version in [3u16, 4u16] {
2902            let path = std::env::temp_dir().join(format!(
2903                "powdb_cat_v{version}_compat_{}.bin",
2904                std::process::id()
2905            ));
2906            write_legacy_catalog(&path, version);
2907            let entries = read_catalog_file(&path)
2908                .unwrap_or_else(|e| panic!("version {version} catalog must load, got: {e}"));
2909            assert_eq!(entries.len(), 1);
2910            assert_eq!(entries[0].schema.table_name, "T");
2911            assert_eq!(entries[0].schema.columns.len(), 2);
2912            assert!(
2913                entries[0].auto_cols.is_empty(),
2914                "v{version} has no auto cols"
2915            );
2916            fs::remove_file(&path).ok();
2917        }
2918    }
2919
2920    #[test]
2921    fn read_catalog_file_rejects_implausible_table_count() {
2922        // A corrupt/hostile catalog must not be trusted to size an allocation:
2923        // `Vec::with_capacity(n_tables)` on an unvalidated u32 would attempt a
2924        // huge allocation and abort the host. A file can describe at most as
2925        // many tables as it has bytes, so a count exceeding the payload length
2926        // is rejected with a clear error before any allocation. (We use a small
2927        // implausible count over a tiny buffer; a genuinely huge count would
2928        // abort the test runner pre-fix, but it hits the very same guard.)
2929        use std::io::Write as _;
2930        let mut buf: Vec<u8> = Vec::new();
2931        buf.extend_from_slice(CATALOG_MAGIC);
2932        buf.extend_from_slice(&CATALOG_VERSION.to_le_bytes());
2933        buf.extend_from_slice(&1000u32.to_le_bytes()); // claims 1000 tables…
2934                                                       // …but no table data follows (payload is only 10 bytes).
2935        let crc = crc32fast::hash(&buf);
2936        buf.extend_from_slice(&crc.to_le_bytes());
2937        let path =
2938            std::env::temp_dir().join(format!("powdb_cat_badcount_{}.bin", std::process::id()));
2939        fs::File::create(&path).unwrap().write_all(&buf).unwrap();
2940
2941        let msg = match read_catalog_file(&path) {
2942            Ok(_) => panic!("implausible table count must be rejected, got Ok"),
2943            Err(e) => e.to_string(),
2944        };
2945        assert!(
2946            msg.contains("implausible table count"),
2947            "expected an implausible-table-count error, got: {msg}"
2948        );
2949        fs::remove_file(&path).ok();
2950    }
2951
2952    #[test]
2953    fn data_dir_and_max_lsn_accessors() {
2954        let dir = std::env::temp_dir().join(format!("powdb_cat_maxlsn_{}", std::process::id()));
2955        let mut cat = Catalog::create(&dir).unwrap();
2956
2957        // data_dir() reflects the directory the catalog was created in.
2958        assert_eq!(cat.data_dir(), dir.as_path());
2959
2960        // A fresh catalog has stamped no page LSNs yet.
2961        assert_eq!(cat.max_lsn(), 0);
2962
2963        let schema = Schema {
2964            table_name: "users".into(),
2965            columns: vec![ColumnDef {
2966                name: "name".into(),
2967                type_id: TypeId::Str,
2968                required: true,
2969                position: 0,
2970            }],
2971        };
2972        cat.create_table(schema).unwrap();
2973
2974        cat.insert("users", &vec![Value::Str("Alice".into())])
2975            .unwrap();
2976        cat.sync_wal().unwrap();
2977
2978        // An inserted (and synced) row stamps a page LSN, raising the
2979        // durability high-water mark above zero.
2980        assert!(cat.max_lsn() > 0);
2981    }
2982
2983    #[test]
2984    fn test_create_table_and_insert() {
2985        let mut cat = temp_catalog("basic");
2986        let schema = Schema {
2987            table_name: "users".into(),
2988            columns: vec![
2989                ColumnDef {
2990                    name: "name".into(),
2991                    type_id: TypeId::Str,
2992                    required: true,
2993                    position: 0,
2994                },
2995                ColumnDef {
2996                    name: "age".into(),
2997                    type_id: TypeId::Int,
2998                    required: false,
2999                    position: 1,
3000                },
3001            ],
3002        };
3003        cat.create_table(schema).unwrap();
3004
3005        let row = vec![Value::Str("Alice".into()), Value::Int(30)];
3006        let rid = cat.insert("users", &row).unwrap();
3007
3008        let result = cat.get("users", rid).unwrap();
3009        assert_eq!(result[0], Value::Str("Alice".into()));
3010        assert_eq!(result[1], Value::Int(30));
3011    }
3012
3013    #[test]
3014    fn test_scan_table() {
3015        let mut cat = temp_catalog("scan");
3016        let schema = Schema {
3017            table_name: "items".into(),
3018            columns: vec![
3019                ColumnDef {
3020                    name: "name".into(),
3021                    type_id: TypeId::Str,
3022                    required: true,
3023                    position: 0,
3024                },
3025                ColumnDef {
3026                    name: "price".into(),
3027                    type_id: TypeId::Float,
3028                    required: true,
3029                    position: 1,
3030                },
3031            ],
3032        };
3033        cat.create_table(schema).unwrap();
3034
3035        for i in 0..50 {
3036            cat.insert(
3037                "items",
3038                &vec![
3039                    Value::Str(format!("item_{i}")),
3040                    Value::Float(i as f64 * 1.5),
3041                ],
3042            )
3043            .unwrap();
3044        }
3045
3046        let rows: Vec<_> = cat.scan("items").unwrap().collect();
3047        assert_eq!(rows.len(), 50);
3048    }
3049
3050    #[test]
3051    fn test_index_lookup() {
3052        let mut cat = temp_catalog("idx");
3053        let schema = Schema {
3054            table_name: "users".into(),
3055            columns: vec![
3056                ColumnDef {
3057                    name: "email".into(),
3058                    type_id: TypeId::Str,
3059                    required: true,
3060                    position: 0,
3061                },
3062                ColumnDef {
3063                    name: "name".into(),
3064                    type_id: TypeId::Str,
3065                    required: true,
3066                    position: 1,
3067                },
3068            ],
3069        };
3070        cat.create_table(schema).unwrap();
3071        cat.create_index("users", "email").unwrap();
3072
3073        cat.insert(
3074            "users",
3075            &vec![
3076                Value::Str("alice@example.com".into()),
3077                Value::Str("Alice".into()),
3078            ],
3079        )
3080        .unwrap();
3081        cat.insert(
3082            "users",
3083            &vec![
3084                Value::Str("bob@example.com".into()),
3085                Value::Str("Bob".into()),
3086            ],
3087        )
3088        .unwrap();
3089
3090        let result = cat
3091            .index_lookup("users", "email", &Value::Str("bob@example.com".into()))
3092            .unwrap();
3093        assert!(result.is_some());
3094        let row = result.unwrap();
3095        assert_eq!(row[1], Value::Str("Bob".into()));
3096    }
3097
3098    #[test]
3099    fn test_delete_row() {
3100        let mut cat = temp_catalog("delete");
3101        let schema = Schema {
3102            table_name: "t".into(),
3103            columns: vec![ColumnDef {
3104                name: "v".into(),
3105                type_id: TypeId::Int,
3106                required: true,
3107                position: 0,
3108            }],
3109        };
3110        cat.create_table(schema).unwrap();
3111        let r1 = cat.insert("t", &vec![Value::Int(1)]).unwrap();
3112        let r2 = cat.insert("t", &vec![Value::Int(2)]).unwrap();
3113        cat.delete("t", r1).unwrap();
3114        assert!(cat.get("t", r1).is_none());
3115        assert!(cat.get("t", r2).is_some());
3116    }
3117
3118    #[test]
3119    fn test_update_row() {
3120        let mut cat = temp_catalog("update");
3121        let schema = Schema {
3122            table_name: "t".into(),
3123            columns: vec![ColumnDef {
3124                name: "v".into(),
3125                type_id: TypeId::Int,
3126                required: true,
3127                position: 0,
3128            }],
3129        };
3130        cat.create_table(schema).unwrap();
3131        let rid = cat.insert("t", &vec![Value::Int(1)]).unwrap();
3132        let new_rid = cat.update("t", rid, &vec![Value::Int(99)]).unwrap();
3133        let row = cat.get("t", new_rid).unwrap();
3134        assert_eq!(row[0], Value::Int(99));
3135    }
3136
3137    #[test]
3138    fn test_persist_and_reopen() {
3139        let dir = std::env::temp_dir().join(format!("powdb_cat_persist_{}", std::process::id()));
3140        // Fresh dir
3141        let _ = std::fs::remove_dir_all(&dir);
3142
3143        {
3144            let mut cat = Catalog::create(&dir).unwrap();
3145            cat.create_table(Schema {
3146                table_name: "users".into(),
3147                columns: vec![
3148                    ColumnDef {
3149                        name: "name".into(),
3150                        type_id: TypeId::Str,
3151                        required: true,
3152                        position: 0,
3153                    },
3154                    ColumnDef {
3155                        name: "age".into(),
3156                        type_id: TypeId::Int,
3157                        required: false,
3158                        position: 1,
3159                    },
3160                ],
3161            })
3162            .unwrap();
3163            cat.insert("users", &vec![Value::Str("Alice".into()), Value::Int(30)])
3164                .unwrap();
3165            cat.insert("users", &vec![Value::Str("Bob".into()), Value::Int(25)])
3166                .unwrap();
3167        }
3168
3169        // Reopen — schema and rows should both still be there
3170        let cat = Catalog::open(&dir).unwrap();
3171        let schema = cat.schema("users").unwrap();
3172        assert_eq!(schema.columns.len(), 2);
3173        assert_eq!(schema.columns[0].name, "name");
3174        assert_eq!(schema.columns[0].type_id, TypeId::Str);
3175        assert_eq!(schema.columns[1].type_id, TypeId::Int);
3176
3177        let rows: Vec<_> = cat.scan("users").unwrap().collect();
3178        assert_eq!(rows.len(), 2);
3179
3180        std::fs::remove_dir_all(&dir).ok();
3181    }
3182
3183    #[test]
3184    fn test_open_missing_dir_errors() {
3185        let dir = std::env::temp_dir().join(format!("powdb_cat_missing_{}", std::process::id()));
3186        let _ = std::fs::remove_dir_all(&dir);
3187        std::fs::create_dir_all(&dir).unwrap();
3188        // No catalog.bin yet
3189        assert!(Catalog::open(&dir).is_err());
3190        std::fs::remove_dir_all(&dir).ok();
3191    }
3192
3193    #[test]
3194    fn test_list_tables() {
3195        let mut cat = temp_catalog("list");
3196        cat.create_table(Schema {
3197            table_name: "a".into(),
3198            columns: vec![ColumnDef {
3199                name: "x".into(),
3200                type_id: TypeId::Int,
3201                required: true,
3202                position: 0,
3203            }],
3204        })
3205        .unwrap();
3206        cat.create_table(Schema {
3207            table_name: "b".into(),
3208            columns: vec![ColumnDef {
3209                name: "y".into(),
3210                type_id: TypeId::Int,
3211                required: true,
3212                position: 0,
3213            }],
3214        })
3215        .unwrap();
3216        let mut tables = cat.list_tables();
3217        tables.sort();
3218        assert_eq!(tables, vec!["a", "b"]);
3219    }
3220
3221    #[test]
3222    fn test_path_traversal_table_name_rejected() {
3223        let mut cat = temp_catalog("path_trav");
3224        // Names with path separators must be rejected.
3225        let bad_names = vec![
3226            "../etc/passwd",
3227            "foo/bar",
3228            "table\0name",
3229            "",
3230            "123starts_with_digit",
3231            "has-dashes",
3232            "has spaces",
3233            "has.dots",
3234        ];
3235        for name in bad_names {
3236            let schema = Schema {
3237                table_name: name.into(),
3238                columns: vec![ColumnDef {
3239                    name: "x".into(),
3240                    type_id: TypeId::Int,
3241                    required: true,
3242                    position: 0,
3243                }],
3244            };
3245            let result = cat.create_table(schema);
3246            assert!(result.is_err(), "expected error for table name '{name}'");
3247            assert_eq!(result.unwrap_err().kind(), io::ErrorKind::InvalidInput);
3248        }
3249        // Valid names must still work.
3250        let good_names = vec!["users", "_private", "Table_123", "_"];
3251        for name in good_names {
3252            let schema = Schema {
3253                table_name: name.into(),
3254                columns: vec![ColumnDef {
3255                    name: "x".into(),
3256                    type_id: TypeId::Int,
3257                    required: true,
3258                    position: 0,
3259                }],
3260            };
3261            assert!(
3262                cat.create_table(schema).is_ok(),
3263                "expected ok for table name '{name}'"
3264            );
3265        }
3266    }
3267
3268    #[test]
3269    fn test_path_traversal_column_name_rejected() {
3270        let mut cat = temp_catalog("col_path_trav");
3271        let schema = Schema {
3272            table_name: "valid_table".into(),
3273            columns: vec![ColumnDef {
3274                name: "../bad".into(),
3275                type_id: TypeId::Int,
3276                required: true,
3277                position: 0,
3278            }],
3279        };
3280        let result = cat.create_table(schema);
3281        assert!(result.is_err());
3282        assert_eq!(result.unwrap_err().kind(), io::ErrorKind::InvalidInput);
3283    }
3284
3285    #[test]
3286    fn test_drop_table_validates_name() {
3287        let mut cat = temp_catalog("drop_trav");
3288        let result = cat.drop_table("../etc/passwd");
3289        assert!(result.is_err());
3290        // Should fail with InvalidInput (validation), not NotFound.
3291        assert_eq!(result.unwrap_err().kind(), io::ErrorKind::InvalidInput);
3292    }
3293}