Skip to main content

powdb_storage/
table.rs

1use crate::btree::BTree;
2use crate::catalog::{expression_index_file_name, ExpressionIndexMeta};
3use crate::error::StorageError;
4use crate::heap::HeapFile;
5use crate::page::{OVERFLOW_CHAIN_END, OVERFLOW_PAYLOAD_CAP};
6use crate::row::{
7    decode_column, decode_row, encode_row_into_with_layout, encode_row_v2_into,
8    patch_var_column_in_place, plan_spill, OverflowStub, RowLayout, MAX_VALUE_SIZE,
9};
10use crate::stored_json_path::StoredJsonPathSegmentV1;
11use crate::types::*;
12use std::io;
13use std::path::Path;
14
15/// Per-indexed-column metadata owning the BTree inline.
16///
17/// Mission C Phase 15 introduced this struct as a cache of `col_idx`,
18/// `col_name`, and `is_int` so the hot `Table::insert` path could skip
19/// the schema column-name linear scan. Mission C Phase 17 folds the
20/// BTree itself into this struct, retiring the parallel
21/// `FxHashMap<String, BTree>` that the hot write paths were otherwise
22/// forced to probe every single call. Everything the write paths need
23/// is now in a single tight `Vec<IndexedCol>` — no hash, no string
24/// compare, no out-of-line allocation.
25pub(crate) struct IndexedCol {
26    /// Schema column index of the indexed column.
27    pub col_idx: usize,
28    /// Column name — still needed to resolve name-based lookups from the
29    /// executor (`tbl.index("id")`, etc.). Cost is only paid on the
30    /// rarer name-keyed read paths.
31    pub col_name: String,
32    /// `true` when the column type is `TypeId::Int`. Lets `insert` /
33    /// `delete` take the `insert_int` / `delete_int` fast paths without
34    /// re-matching the schema every call.
35    pub is_int: bool,
36    /// `true` for primary key / explicitly unique indexes. `false` for
37    /// secondary indexes on non-unique columns. Non-unique indexes use
38    /// composite keys (column_value + RowId) so duplicate column values
39    /// don't overwrite each other.
40    pub unique: bool,
41    /// The B+ tree. Lives inline alongside the metadata so the hot
42    /// insert/delete/update loops can touch a single cache line per
43    /// index entry instead of chasing a separate HashMap probe.
44    pub btree: BTree,
45}
46
47pub(crate) struct ExpressionIndexedPath {
48    pub meta: ExpressionIndexMeta,
49    pub root_col_idx: usize,
50    pub btree: BTree,
51}
52
53/// A table combines a heap file, schema, and optional indexes.
54///
55/// Mission C Phase 17: indexes used to live in a `FxHashMap<String,
56/// BTree>` alongside a parallel `Vec<IndexedCol>` of metadata. Every row
57/// insert paid an FxHash of the index column name to look the btree back
58/// out of the map. This phase collapses both data structures into a
59/// single `Vec<IndexedCol>` where each entry owns its btree inline —
60/// the hot write path walks one small vec and calls straight through to
61/// `insert_int`.
62///
63/// Mission C Phase 2: holds `encode_scratch`, a reusable buffer for
64/// [`crate::row::encode_row_into`]. Bench loops that push thousands of
65/// rows through `insert`/`update` reuse the same allocation across calls,
66/// cutting the allocator traffic to ~zero after the first row.
67pub struct Table {
68    pub(crate) schema: Schema,
69    pub heap: HeapFile,
70    /// Reusable scratch buffer for row encoding. Cleared on every call.
71    encode_scratch: Vec<u8>,
72    /// Per-indexed-column metadata, each entry owning its BTree inline.
73    /// Public to the crate so the query executor's IndexScan fast paths
74    /// can reach in via the `index()` / `index_mut()` helpers instead
75    /// of probing a separate hash map.
76    pub(crate) indexed_cols: Vec<IndexedCol>,
77    pub(crate) expression_indexes: Vec<ExpressionIndexedPath>,
78    /// Mission C Phase 7: cached row layout so `delete` can decode only
79    /// the indexed columns out of the raw page bytes without running the
80    /// full per-row offset calculation every call.
81    row_layout: RowLayout,
82    /// Per-column literal defaults, aligned to `schema.columns` by position.
83    /// Empty means "no defaults" (the common case); otherwise `defaults[i]`
84    /// is the default for column `i`, or `None` if that column has none.
85    defaults: Vec<Option<Value>>,
86    /// Which columns are `auto` (auto-incrementing), aligned to
87    /// `schema.columns`. Empty means no auto columns (the common case).
88    auto_cols: Vec<bool>,
89    /// Next value to assign per auto column, aligned to `schema.columns`.
90    /// Lazily computed from the persisted rows on first insert after open
91    /// (so the sequence resumes above the highest existing id), guarded by
92    /// `auto_next_ready`.
93    auto_next: Vec<i64>,
94    auto_next_ready: bool,
95}
96
97/// The refusal for a duplicate key in a unique column index.
98///
99/// Raised as a typed [`StorageError`] inside the `io::Error` so callers can
100/// downcast it back to its variant (see `StorageError::kind_of_io_error`)
101/// instead of recovering the kind by matching this message. The rendered text
102/// is unchanged: it is on the server's egress allowlist and clients assert on
103/// it.
104fn unique_column_error(table: &str, column: &str) -> io::Error {
105    io::Error::new(
106        io::ErrorKind::InvalidInput,
107        StorageError::UniqueConstraintViolation {
108            table: table.to_string(),
109            column: column.to_string(),
110        },
111    )
112}
113
114/// The same refusal for a unique expression index. See
115/// [`unique_column_error`].
116fn expression_unique_error(table: &str, expression: &str) -> io::Error {
117    io::Error::new(
118        io::ErrorKind::InvalidInput,
119        StorageError::UniqueExpressionIndexViolation {
120            table: table.to_string(),
121            expression: expression.to_string(),
122        },
123    )
124}
125
126fn expression_key(meta: &ExpressionIndexMeta, root: &Value) -> io::Result<Value> {
127    let Value::Json(document) = root else {
128        if root.is_empty() {
129            return Ok(Value::Empty);
130        }
131        return Err(io::Error::new(
132            io::ErrorKind::InvalidData,
133            "expression index root value is not JSON",
134        ));
135    };
136    let mut node = document.as_ref();
137    for segment in &meta.json_path.segments {
138        let path_segment = match segment {
139            StoredJsonPathSegmentV1::Key(key) => crate::pj1::PathSeg::Key(key),
140            StoredJsonPathSegmentV1::Index(index) => crate::pj1::PathSeg::Index(*index),
141        };
142        let Some(next) = crate::pj1::pj1_get(node, &path_segment) else {
143            return Ok(Value::Empty);
144        };
145        node = next;
146    }
147    match crate::pj1::pj1_scalar(node).map_err(|error| {
148        io::Error::new(
149            io::ErrorKind::InvalidData,
150            format!("invalid PJ1 while extracting expression index key: {error}"),
151        )
152    })? {
153        crate::pj1::Pj1Scalar::Null => Ok(Value::Empty),
154        crate::pj1::Pj1Scalar::Bool(value) => Ok(Value::Bool(value)),
155        crate::pj1::Pj1Scalar::Int(value) => Ok(Value::Int(value)),
156        crate::pj1::Pj1Scalar::Float(value) => Ok(Value::Float(value)),
157        crate::pj1::Pj1Scalar::Str(value) => Ok(Value::Str(value.to_owned())),
158        crate::pj1::Pj1Scalar::NonScalar => Err(io::Error::new(
159            io::ErrorKind::InvalidInput,
160            format!(
161                "expression index key must be scalar: {}",
162                meta.canonical_text
163            ),
164        )),
165    }
166}
167
168impl Table {
169    pub fn create(schema: Schema, data_dir: &Path) -> io::Result<Self> {
170        let heap_path = data_dir.join(format!("{}.heap", schema.table_name));
171        let heap = HeapFile::create(&heap_path)?;
172        let row_layout = RowLayout::new(&schema);
173        Ok(Table {
174            schema,
175            heap,
176            encode_scratch: Vec::new(),
177            indexed_cols: Vec::new(),
178            expression_indexes: Vec::new(),
179            row_layout,
180            defaults: Vec::new(),
181            auto_cols: Vec::new(),
182            auto_next: Vec::new(),
183            auto_next_ready: false,
184        })
185    }
186
187    /// Reopen an existing table from disk. Caller supplies the schema (loaded
188    /// from the catalog file). No index columns are supplied, so no index is
189    /// rehydrated or rebuilt here; prefer `open_with_indexes` when the
190    /// catalog knows which columns are indexed.
191    pub fn open(schema: Schema, data_dir: &Path) -> io::Result<Self> {
192        Self::open_with_indexes(schema, data_dir, &[], &[])
193    }
194
195    /// Mission 3: reopen an existing table from disk, also rehydrating any
196    /// persisted b-tree indexes.
197    ///
198    /// For each name in `indexed_col_names`:
199    ///   - If the `{table}_{col}.idx` file exists at the current format,
200    ///     load it via `BTree::load` — O(file size) memcpy+decode, no heap
201    ///     scan.
202    ///   - If the file is a pre-v3 non-unique index (unescaped composite
203    ///     Str keys), it is never served: it is rebuilt from the heap in
204    ///     the escaped format and, on a writable open, saved as v3.
205    ///   - If the file is missing (e.g. first open after upgrading from
206    ///     pre-Mission-3 catalogs), fall back to the create-time rebuild
207    ///     path: scan the heap and insert every non-empty value. After the
208    ///     rebuild, `save` the freshly built tree so subsequent opens hit
209    ///     the fast path.
210    pub(crate) fn open_with_indexes(
211        schema: Schema,
212        data_dir: &Path,
213        indexed_col_metas: &[crate::catalog::IndexedColMeta],
214        expression_index_metas: &[ExpressionIndexMeta],
215    ) -> io::Result<Self> {
216        Self::open_with_indexes_inner(
217            schema,
218            data_dir,
219            indexed_col_metas,
220            expression_index_metas,
221            false,
222        )
223    }
224
225    /// Read-only reopen for snapshot serving: the heap and every persisted index
226    /// are opened without a writable handle. A missing index artifact is rebuilt
227    /// **in memory** but never saved (a read-only directory must not be mutated),
228    /// so index and expression-index reads work without touching disk.
229    pub(crate) fn open_with_indexes_read_only(
230        schema: Schema,
231        data_dir: &Path,
232        indexed_col_metas: &[crate::catalog::IndexedColMeta],
233        expression_index_metas: &[ExpressionIndexMeta],
234    ) -> io::Result<Self> {
235        Self::open_with_indexes_inner(
236            schema,
237            data_dir,
238            indexed_col_metas,
239            expression_index_metas,
240            true,
241        )
242    }
243
244    fn open_with_indexes_inner(
245        schema: Schema,
246        data_dir: &Path,
247        indexed_col_metas: &[crate::catalog::IndexedColMeta],
248        expression_index_metas: &[ExpressionIndexMeta],
249        read_only: bool,
250    ) -> io::Result<Self> {
251        let heap_path = data_dir.join(format!("{}.heap", schema.table_name));
252        let heap = if read_only {
253            HeapFile::open_read_only(&heap_path)?
254        } else {
255            HeapFile::open(&heap_path)?
256        };
257        let row_layout = RowLayout::new(&schema);
258        let mut table = Table {
259            schema,
260            heap,
261            encode_scratch: Vec::new(),
262            indexed_cols: Vec::new(),
263            expression_indexes: Vec::new(),
264            row_layout,
265            defaults: Vec::new(),
266            auto_cols: Vec::new(),
267            auto_next: Vec::new(),
268            auto_next_ready: false,
269        };
270
271        for meta in indexed_col_metas {
272            let col_name = &meta.name;
273            let unique = meta.unique;
274            let col_idx = match table.schema.column_index(col_name) {
275                Some(i) => i,
276                // Schema drift: the catalog lists an index on a column that
277                // no longer exists. Silently drop the index rather than
278                // failing the whole open — matches the `drop column`
279                // rewrite path, which already blows away indexes.
280                None => continue,
281            };
282            let is_int = table.schema.columns[col_idx].type_id == TypeId::Int;
283            let idx_path = data_dir.join(format!("{}_{}.idx", table.schema.table_name, col_name));
284
285            // Rebuild this column index from the heap. Reassemble via
286            // `table.scan()` so a spilled (v2) indexed column contributes its
287            // true key -- a v1-only `decode_row` would read it as `Empty` and
288            // build a btree missing that row's key (P2). Non-unique indexes are
289            // created at v3 (NUL-escaped composite Str keys); unique indexes
290            // stay v1.
291            let build_from_heap = |path: &std::path::Path| -> io::Result<BTree> {
292                let mut bt = if unique {
293                    BTree::create(path)?
294                } else {
295                    BTree::create_non_unique(path)?
296                };
297                for (rid, row) in table.scan() {
298                    if !row[col_idx].is_empty() {
299                        if unique {
300                            bt.insert(row[col_idx].clone(), rid);
301                        } else {
302                            bt.insert_non_unique(row[col_idx].clone(), rid);
303                        }
304                    }
305                }
306                // Read-only serving must not persist the rebuilt tree; it lives
307                // only for this handle's lifetime.
308                if !read_only {
309                    bt.save()?;
310                }
311                Ok(bt)
312            };
313
314            let btree = if idx_path.exists() {
315                let loaded = BTree::load(&idx_path)?;
316                if !unique && loaded.format_version() < crate::btree::BTREE_VERSION {
317                    // Migration: an old-format (v1/v2) non-unique column index
318                    // encodes composite Str keys without NUL escaping, so its
319                    // lookups can return wrong rows for embedded-NUL values.
320                    // Never serve it -- rebuild from the heap in the v3 escaped
321                    // format and save (the same machinery crash recovery uses).
322                    // A read-only open cannot persist the rebuild, so it pays
323                    // this scan on every open of an old-format directory; do
324                    // one writable open (or re-backup from an upgraded
325                    // primary) to persist v3.
326                    tracing::info!(
327                        index = %idx_path.display(),
328                        read_only,
329                        "rebuilding pre-v3 non-unique index in NUL-safe format"
330                    );
331                    build_from_heap(&idx_path)?
332                } else {
333                    // v3 non-unique (or any unique) index: serve as loaded.
334                    // Load counts distinct keys in Raw mode; a non-unique column
335                    // index stores composite keys, so switch it to count
336                    // distinct by value prefix.
337                    let mut loaded = loaded;
338                    if !unique {
339                        loaded.mark_composite();
340                    }
341                    loaded
342                }
343            } else {
344                build_from_heap(&idx_path)?
345            };
346
347            table.indexed_cols.push(IndexedCol {
348                col_idx,
349                col_name: col_name.clone(),
350                is_int,
351                unique,
352                btree,
353            });
354        }
355
356        for meta in expression_index_metas {
357            let root_col_idx = table
358                .schema
359                .column_index(&meta.json_path.column)
360                .ok_or_else(|| {
361                    io::Error::new(
362                        io::ErrorKind::InvalidData,
363                        "expression index root column is absent",
364                    )
365                })?;
366            let idx_path = data_dir.join(expression_index_file_name(
367                &table.schema.table_name,
368                meta.index_id,
369            ));
370            if idx_path.exists() {
371                let btree = BTree::load(&idx_path)?;
372                // Expression indexes need the v2 `empty_rids` side list. They
373                // store raw (never composite Str) keys, so the v3 NUL-escaping
374                // does not apply and pre-existing v2 files load unchanged.
375                if btree.format_version() < crate::btree::EXPRESSION_BTREE_VERSION {
376                    return Err(io::Error::new(
377                        io::ErrorKind::InvalidData,
378                        "expression index requires BIDX v2 (empty_rids side list)",
379                    ));
380                }
381                table.expression_indexes.push(ExpressionIndexedPath {
382                    meta: meta.clone(),
383                    root_col_idx,
384                    btree,
385                });
386            } else {
387                // Expression indexes were not released before the dedicated
388                // `.eidx` namespace. Rebuild any missing artifact from the
389                // heap instead of probing or deleting an ambiguous legacy
390                // `.idx` pathname that may belong to a live column index. In
391                // read-only mode the rebuilt tree is kept in memory only.
392                table.build_expression_index(meta.clone(), &idx_path, !read_only)?;
393            }
394        }
395
396        Ok(table)
397    }
398
399    /// Mission 3: catalog uses this to snapshot the list of columns that
400    /// currently have an index, so it can be persisted in `catalog.bin`.
401    pub(crate) fn indexed_column_names(&self) -> Vec<String> {
402        self.indexed_cols
403            .iter()
404            .map(|c| c.col_name.clone())
405            .collect()
406    }
407
408    /// Snapshot index metadata (name + uniqueness) for catalog persistence.
409    pub(crate) fn indexed_column_metas(&self) -> Vec<crate::catalog::IndexedColMeta> {
410        self.indexed_cols
411            .iter()
412            .map(|c| crate::catalog::IndexedColMeta {
413                name: c.col_name.clone(),
414                unique: c.unique,
415            })
416            .collect()
417    }
418
419    pub(crate) fn expression_index_metas(&self) -> Vec<ExpressionIndexMeta> {
420        self.expression_indexes
421            .iter()
422            .map(|index| index.meta.clone())
423            .collect()
424    }
425
426    pub(crate) fn expression_index_btree(&self, index_id: u64) -> Option<&BTree> {
427        self.expression_indexes
428            .iter()
429            .find(|index| index.meta.index_id == index_id)
430            .map(|index| &index.btree)
431    }
432
433    pub(crate) fn expression_index_btree_mut(&mut self, index_id: u64) -> Option<&mut BTree> {
434        self.expression_indexes
435            .iter_mut()
436            .find(|index| index.meta.index_id == index_id)
437            .map(|index| &mut index.btree)
438    }
439
440    /// Forget the plain b-tree index on `col_name`, if there is one.
441    ///
442    /// Returns `true` when an entry was actually removed, so the caller knows
443    /// whether there is a `{table}_{col}.idx` file left to delete. Called
444    /// unconditionally on a column drop rather than relying on the rewrite
445    /// path, because the rewrite only runs when the table has rows: an empty
446    /// table would otherwise keep an index entry naming a column that no longer
447    /// exists until the next reopen rebuilt the list from the schema.
448    pub(crate) fn remove_index_for_column(&mut self, col_name: &str) -> bool {
449        let previous_len = self.indexed_cols.len();
450        self.indexed_cols.retain(|c| c.col_name != col_name);
451        self.indexed_cols.len() != previous_len
452    }
453
454    pub(crate) fn remove_expression_indexes_for_root(&mut self, root: &str) -> Vec<u64> {
455        let mut removed = Vec::new();
456        self.expression_indexes.retain(|index| {
457            if index.meta.json_path.column == root {
458                removed.push(index.meta.index_id);
459                false
460            } else {
461                true
462            }
463        });
464        removed
465    }
466
467    pub(crate) fn remove_expression_index_by_id(&mut self, index_id: u64) -> bool {
468        let previous_len = self.expression_indexes.len();
469        self.expression_indexes
470            .retain(|index| index.meta.index_id != index_id);
471        self.expression_indexes.len() != previous_len
472    }
473
474    pub(crate) fn take_expression_index(&mut self, index_id: u64) -> Option<ExpressionIndexedPath> {
475        let position = self
476            .expression_indexes
477            .iter()
478            .position(|index| index.meta.index_id == index_id)?;
479        Some(self.expression_indexes.remove(position))
480    }
481
482    pub(crate) fn restore_expression_index(&mut self, index: ExpressionIndexedPath) {
483        self.expression_indexes.push(index);
484    }
485
486    pub(crate) fn expression_index_ids(&self) -> Vec<u64> {
487        self.expression_indexes
488            .iter()
489            .map(|index| index.meta.index_id)
490            .collect()
491    }
492
493    pub(crate) fn install_expression_index(
494        &mut self,
495        meta: ExpressionIndexMeta,
496        path: &Path,
497    ) -> io::Result<()> {
498        self.build_expression_index(meta, path, true)
499    }
500
501    /// Build an expression index from a heap scan. When `save` is false the tree
502    /// is registered in memory but never written to disk: the read-only
503    /// snapshot-serving path, which must not mutate the directory.
504    fn build_expression_index(
505        &mut self,
506        meta: ExpressionIndexMeta,
507        path: &Path,
508        save: bool,
509    ) -> io::Result<()> {
510        let root_col_idx = self
511            .schema
512            .column_index(&meta.json_path.column)
513            .ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "JSON root column not found"))?;
514        let mut btree = BTree::create_v2(path)?;
515        let rids = self.heap.scan().map(|(rid, _)| rid).collect::<Vec<_>>();
516        for rid in rids {
517            let root = self
518                .get_projected(rid, &[root_col_idx])?
519                .and_then(|mut values| values.pop())
520                .ok_or_else(|| {
521                    io::Error::new(
522                        io::ErrorKind::NotFound,
523                        "row disappeared while building expression index",
524                    )
525                })?;
526            let key = expression_key(&meta, &root)?;
527            if key.is_empty() {
528                btree.insert_empty(rid);
529            } else if meta.unique {
530                if btree.lookup(&key).is_some() {
531                    return Err(expression_unique_error(
532                        &self.schema.table_name,
533                        &meta.canonical_text,
534                    ));
535                }
536                btree.insert(key, rid);
537            } else {
538                btree.insert_duplicate(key, rid);
539            }
540        }
541        if save {
542            btree.save()?;
543        }
544        self.expression_indexes.push(ExpressionIndexedPath {
545            meta,
546            root_col_idx,
547            btree,
548        });
549        Ok(())
550    }
551
552    pub(crate) fn preflight_insert(&self, values: &Row) -> io::Result<Option<Vec<Value>>> {
553        for entry in &self.indexed_cols {
554            if !entry.unique {
555                continue;
556            }
557            let val = &values[entry.col_idx];
558            if !val.is_empty() && entry.btree.lookup(val).is_some() {
559                return Err(unique_column_error(
560                    &self.schema.table_name,
561                    &entry.col_name,
562                ));
563            }
564        }
565        // Keep the overwhelmingly common column-only table on the legacy
566        // insert shape. In particular, avoid constructing an expression-key
567        // result and entering a second maintenance loop for every row when no
568        // expression index exists.
569        if self.expression_indexes.is_empty() {
570            return Ok(None);
571        }
572        let keys = self.expression_keys(values)?;
573        for (index, key) in self.expression_indexes.iter().zip(&keys) {
574            if index.meta.unique && !key.is_empty() && index.btree.lookup(key).is_some() {
575                return Err(expression_unique_error(
576                    &self.schema.table_name,
577                    &index.meta.canonical_text,
578                ));
579            }
580        }
581        Ok(Some(keys))
582    }
583
584    pub(crate) fn preflight_update(&self, rid: RowId, values: &Row) -> io::Result<()> {
585        let old_row = if self.indexed_cols.iter().any(|index| index.unique) {
586            self.get(rid)
587        } else {
588            None
589        };
590        for entry in self.indexed_cols.iter().filter(|index| index.unique) {
591            let new_value = &values[entry.col_idx];
592            if new_value.is_empty()
593                || old_row
594                    .as_ref()
595                    .is_some_and(|old| old[entry.col_idx] == *new_value)
596            {
597                continue;
598            }
599            if entry
600                .btree
601                .lookup(new_value)
602                .is_some_and(|existing| existing != rid)
603            {
604                return Err(unique_column_error(
605                    &self.schema.table_name,
606                    &entry.col_name,
607                ));
608            }
609        }
610
611        let new_keys = self.expression_keys(values)?;
612        let old_keys = self.expression_keys_at(rid)?.ok_or_else(|| {
613            io::Error::new(io::ErrorKind::NotFound, "row not found for index update")
614        })?;
615        for ((index, old_key), new_key) in
616            self.expression_indexes.iter().zip(old_keys).zip(new_keys)
617        {
618            if !index.meta.unique || new_key.is_empty() || old_key == new_key {
619                continue;
620            }
621            if index
622                .btree
623                .lookup(&new_key)
624                .is_some_and(|existing| existing != rid)
625            {
626                return Err(expression_unique_error(
627                    &self.schema.table_name,
628                    &index.meta.canonical_text,
629                ));
630            }
631        }
632        Ok(())
633    }
634
635    fn expression_keys(&self, values: &Row) -> io::Result<Vec<Value>> {
636        self.expression_indexes
637            .iter()
638            .map(|index| expression_key(&index.meta, &values[index.root_col_idx]))
639            .collect()
640    }
641
642    fn expression_keys_at(&self, rid: RowId) -> io::Result<Option<Vec<Value>>> {
643        if self.expression_indexes.is_empty() {
644            return Ok(Some(Vec::new()));
645        }
646        let roots = self
647            .expression_indexes
648            .iter()
649            .map(|index| index.root_col_idx)
650            .collect::<Vec<_>>();
651        let Some(root_values) = self.get_projected(rid, &roots)? else {
652            return Ok(None);
653        };
654        self.expression_indexes
655            .iter()
656            .zip(root_values)
657            .map(|(index, root)| expression_key(&index.meta, &root))
658            .collect::<io::Result<Vec<_>>>()
659            .map(Some)
660    }
661
662    fn maintain_expression_indexes_on_insert(&mut self, keys: &[Value], rid: RowId) {
663        for (index, key) in self.expression_indexes.iter_mut().zip(keys) {
664            if key.is_empty() {
665                index.btree.insert_empty(rid);
666            } else if index.meta.unique {
667                index.btree.insert(key.clone(), rid);
668            } else {
669                index.btree.insert_duplicate(key.clone(), rid);
670            }
671        }
672    }
673
674    /// Install the per-column defaults (called at create time and on reopen
675    /// from the persisted catalog). Aligned to `schema.columns` by position.
676    pub(crate) fn set_defaults(&mut self, defaults: Vec<Option<Value>>) {
677        self.defaults = defaults;
678    }
679
680    /// Per-column defaults, aligned to `schema.columns` by position. Empty when
681    /// no column has a default.
682    pub(crate) fn defaults(&self) -> &[Option<Value>] {
683        &self.defaults
684    }
685
686    /// Install which columns are `auto` (called at create time and on reopen
687    /// from the persisted catalog), aligned to `schema.columns` by position.
688    pub(crate) fn set_auto_cols(&mut self, auto_cols: Vec<bool>) {
689        self.auto_cols = auto_cols;
690        // Force the counters to be recomputed from the current rows on next use.
691        self.auto_next_ready = false;
692    }
693
694    /// Whether this table has any `auto` column.
695    pub(crate) fn has_auto(&self) -> bool {
696        self.auto_cols.iter().any(|&a| a)
697    }
698
699    /// Which columns are `auto`, aligned to `schema.columns`. Empty when none.
700    pub(crate) fn auto_cols(&self) -> &[bool] {
701        &self.auto_cols
702    }
703
704    /// Fill any omitted (`Empty`) `auto` column in `values` from the per-table
705    /// sequence and advance it. An explicitly-provided value is left as-is but
706    /// still pushes the sequence past it, so later auto ids never collide with
707    /// an id the caller chose. No-op when the table has no auto columns.
708    pub(crate) fn assign_auto(&mut self, values: &mut [Value]) {
709        if !self.has_auto() {
710            return;
711        }
712        if !self.auto_next_ready {
713            self.init_auto_next();
714        }
715        for (i, &is_auto) in self.auto_cols.iter().enumerate() {
716            if !is_auto || i >= values.len() {
717                continue;
718            }
719            match values[i] {
720                Value::Empty => {
721                    values[i] = Value::Int(self.auto_next[i]);
722                    self.auto_next[i] += 1;
723                }
724                Value::Int(v) if v >= self.auto_next[i] => {
725                    self.auto_next[i] = v + 1;
726                }
727                _ => {}
728            }
729        }
730    }
731
732    /// Seed each auto column's next value to one past the highest value already
733    /// stored, so the sequence resumes correctly after a restart (committed
734    /// rows are already replayed from the WAL by the time inserts run). An
735    /// empty column starts at 1.
736    fn init_auto_next(&mut self) {
737        let n = self.schema.columns.len();
738        let mut next = vec![1i64; n];
739        let auto_idxs: Vec<usize> = self
740            .auto_cols
741            .iter()
742            .enumerate()
743            .filter_map(|(i, &a)| if a { Some(i) } else { None })
744            .collect();
745        if !auto_idxs.is_empty() {
746            for (_rid, row) in self.heap.scan() {
747                let decoded = crate::row::decode_row(&self.schema, &row);
748                for &i in &auto_idxs {
749                    if let Some(Value::Int(v)) = decoded.get(i) {
750                        if *v >= next[i] {
751                            next[i] = *v + 1;
752                        }
753                    }
754                }
755            }
756        }
757        self.auto_next = next;
758        self.auto_next_ready = true;
759    }
760
761    /// Recalculate the cached row layout from the current schema. Must be
762    /// called after any schema mutation (add/drop column).
763    pub(crate) fn refresh_layout(&mut self) {
764        self.row_layout = RowLayout::new(&self.schema);
765    }
766
767    /// Return the table schema without exposing structural mutation.
768    /// Schema changes are catalog-owned so prepared-query metadata is
769    /// invalidated whenever column layout or table identity changes.
770    pub fn schema(&self) -> &Schema {
771        &self.schema
772    }
773
774    /// Rewrite every live heap row to match a new schema shape.
775    ///
776    /// This is the backfill path for `ALTER TABLE ADD COLUMN`. Before
777    /// this existed, the catalog happily swapped the schema in memory
778    /// and left old rows on disk with the OLD variable-column offset
779    /// table layout. Any subsequent `decode_row` then panicked with
780    /// `range end index X out of range` because the decoder reads
781    /// `n_var + 1` offsets using the NEW schema.
782    ///
783    /// The caller passes in the pre-mutation schema so rows can be
784    /// decoded correctly; `self.schema` must already hold the NEW
785    /// schema when this is invoked. `fill_values` must have
786    /// `new_schema.columns.len()` entries and supplies the values for
787    /// columns that did not exist in the old schema (use
788    /// `Value::Empty` for optional adds).
789    ///
790    /// Rewrites every row via `HeapFile::update`, which may move the
791    /// row to a new page when the new encoding is larger. Any secondary
792    /// indexes are rebuilt from scratch at the end because their
793    /// `RowId` pointers can become stale during the rewrite.
794    ///
795    /// Not on any hot path — ALTER is a rare administrative op, so this
796    /// intentionally prefers simplicity (collect snapshot → rewrite →
797    /// rebuild indexes) over any of the fast-path tricks used by
798    /// insert/update/delete.
799    pub(crate) fn rewrite_rows_for_schema_change(
800        &mut self,
801        old_schema: &Schema,
802        fill_values: &[Value],
803        data_dir: &Path,
804    ) -> io::Result<()> {
805        debug_assert_eq!(fill_values.len(), self.schema.columns.len());
806
807        // Snapshot every live (rid, old_bytes) pair up front. We can't
808        // mutate `self.heap` while iterating it, and the rewrite grows
809        // every row (+2 bytes of offset table at minimum), so in-place
810        // updates are not guaranteed.
811        let snapshot: Vec<(RowId, Vec<u8>)> = self.heap.scan().collect();
812
813        // Map from old column index → new column index, or `None` if
814        // the old column was dropped by the schema change. The caller
815        // is expected to keep surviving columns in their original
816        // positions. We look up by name so ADD and DROP can share the
817        // same path: ADD has every old column present in the new
818        // schema; DROP has exactly one missing.
819        let old_to_new: Vec<Option<usize>> = old_schema
820            .columns
821            .iter()
822            .map(|c| self.schema.column_index(&c.name))
823            .collect();
824
825        // Phase 1 (immutable reads): reassemble each old row to its logical
826        // values, mapping columns into the new shape, and gather any overflow
827        // chain it referenced. A v2 (spilled) old row MUST be reassembled via
828        // `decode_row_v2` -- a v1-only `decode_row` reads its spilled columns as
829        // `Empty` and the ALTER would silently drop the out-of-line value (P1).
830        // We use the OLD schema's layout because `self.row_layout` already
831        // reflects the NEW schema.
832        let old_layout = RowLayout::new(old_schema);
833        let mut rewritten: Vec<(RowId, Vec<Value>, Vec<u32>)> = Vec::with_capacity(snapshot.len());
834        for (rid, old_bytes) in &snapshot {
835            let old_row = if crate::row::row_is_v2(old_bytes) {
836                crate::row::decode_row_v2(old_schema, &old_layout, old_bytes, |stub| {
837                    self.heap.read_overflow_value(stub).map_err(io::Error::from)
838                })?
839            } else {
840                decode_row(old_schema, old_bytes)
841            };
842            // Chain pages the old row referenced (empty for inline rows). Freed
843            // after the rewrite so the ALTER does not leak the old out-of-line
844            // values.
845            let mut old_pages: Vec<u32> = Vec::new();
846            if crate::row::row_is_v2(old_bytes) {
847                let mut heads: Vec<u32> = Vec::new();
848                crate::row::for_each_stub(old_schema, &old_layout, old_bytes, |_c, stub| {
849                    heads.push(stub.first_page);
850                });
851                for h in heads {
852                    old_pages.extend(self.heap.overflow_chain_pages(h)?);
853                }
854            }
855            // Start from the caller-supplied defaults for the new shape, then
856            // overwrite with whatever the old row had. Dropped columns are
857            // skipped (their value has nowhere to go in the new row).
858            let mut new_row: Vec<Value> = fill_values.to_vec();
859            for (old_idx, val) in old_row.into_iter().enumerate() {
860                if let Some(new_idx) = old_to_new[old_idx] {
861                    new_row[new_idx] = val;
862                }
863            }
864            rewritten.push((*rid, new_row, old_pages));
865        }
866
867        // Phase 2 (mutations): re-encode each row into the new shape and write
868        // it back. A row whose surviving values still exceed the inline cap
869        // re-spills through `encode_row_spilling` (writing fresh chains), so a
870        // large value survives ALTER; then the old chain is freed.
871        for (rid, new_row, old_pages) in rewritten {
872            if crate::row::v1_encoded_len(&self.row_layout, &new_row)
873                <= crate::page::MAX_ROW_DATA_SIZE
874            {
875                encode_row_into_with_layout(
876                    &self.schema,
877                    &self.row_layout,
878                    &new_row,
879                    &mut self.encode_scratch,
880                );
881                let encoded = std::mem::take(&mut self.encode_scratch);
882                self.heap.update(rid, &encoded)?;
883                self.encode_scratch = encoded;
884            } else {
885                let encoded = self.encode_row_spilling(&new_row)?;
886                self.heap.update(rid, &encoded)?;
887            }
888            // Free the old chain now that the row has been rewritten. Fresh
889            // chains for later rows may reuse these pages.
890            if !old_pages.is_empty() {
891                self.heap.release_overflow_pages(&old_pages);
892            }
893        }
894
895        // Rebuild every secondary index from the rewritten heap. The
896        // in-memory btree is the source of truth for reads, and its
897        // RowId pointers may now be stale after the heap rewrite.
898        if !self.indexed_cols.is_empty() {
899            // Preserve per-index metadata (col_idx, col_name, is_int)
900            // via fresh BTree instances. The old btrees are dropped
901            // when `indexed_cols` is reassigned.
902            //
903            // Resolve each index's column position from the *new* schema by
904            // name rather than trusting the `col_idx` it carried in. A drop
905            // makes both of the cached values wrong at once: the dropped
906            // column's own entry now names a column that is gone, and every
907            // index sitting after it has shifted one slot left. Reusing the old
908            // number indexed past the end of the rewritten row and aborted the
909            // process (`panic = "abort"`) before the catalog was persisted,
910            // while the DdlDropColumn WAL record was already durable — so every
911            // later open replayed it and aborted in the same place. Name
912            // resolution is what the expression-index arm below already does.
913            let existing: Vec<(usize, String, bool, bool)> = self
914                .indexed_cols
915                .iter()
916                .filter_map(|c| {
917                    let col_idx = self.schema.column_index(&c.col_name)?;
918                    Some((col_idx, c.col_name.clone(), c.is_int, c.unique))
919                })
920                .collect();
921
922            // Drain the old entries first so the borrow of
923            // `self.indexed_cols` is clear before we start scanning.
924            self.indexed_cols.clear();
925
926            // Snapshot the rewritten heap once. The rewrite above may have
927            // re-spilled a surviving large indexed value, so rows can be v2 and
928            // must be reassembled (`self.scan()`) -- a v1-only `decode_row`
929            // would read a spilled indexed column as `Empty` and build a btree
930            // missing that key (P2).
931            let rebuilt_rows: Vec<(RowId, Vec<Value>)> = self.scan().collect();
932            for (col_idx, col_name, is_int, unique) in existing {
933                // Mission 3: write the freshly rebuilt index back to its
934                // canonical `{table}_{col}.idx` file so a subsequent
935                // restart loads the up-to-date tree instead of the stale
936                // pre-rewrite version (whose RowIds may now point at
937                // moved rows).
938                let idx_path =
939                    data_dir.join(format!("{}_{}.idx", self.schema.table_name, col_name));
940                let mut btree = if unique {
941                    crate::btree::BTree::create(&idx_path)?
942                } else {
943                    // Match the other rebuild sites: non-unique trees start at
944                    // v3 so an all-empty column cannot persist a v1 file that
945                    // the next open would needlessly re-migrate.
946                    crate::btree::BTree::create_non_unique(&idx_path)?
947                };
948                for (rid, row) in &rebuilt_rows {
949                    let (rid, v) = (*rid, &row[col_idx]);
950                    if v.is_empty() {
951                        continue;
952                    }
953                    if unique {
954                        if is_int {
955                            if let Value::Int(i) = v {
956                                btree.insert_int(*i, rid);
957                                continue;
958                            }
959                        }
960                        btree.insert(v.clone(), rid);
961                    } else {
962                        btree.insert_non_unique(v.clone(), rid);
963                    }
964                }
965                btree.save()?;
966                self.indexed_cols.push(IndexedCol {
967                    col_idx,
968                    col_name,
969                    is_int,
970                    unique,
971                    btree,
972                });
973            }
974        }
975
976        if !self.expression_indexes.is_empty() {
977            for index in &mut self.expression_indexes {
978                index.root_col_idx = self
979                    .schema
980                    .column_index(&index.meta.json_path.column)
981                    .ok_or_else(|| {
982                        io::Error::new(
983                            io::ErrorKind::InvalidData,
984                            "expression index root disappeared during schema rewrite",
985                        )
986                    })?;
987            }
988            self.rebuild_indexes_from_heap()?;
989        }
990
991        Ok(())
992    }
993
994    /// Look up an index by column name. Returns `None` if no index on
995    /// this column. Used by the read-side executor paths (IndexScan,
996    /// Project(IndexScan), etc.) that still need name-based resolution;
997    /// the write-side hot paths iterate `indexed_cols` directly.
998    #[inline]
999    pub fn index(&self, col_name: &str) -> Option<&BTree> {
1000        self.indexed_cols
1001            .iter()
1002            .find(|c| c.col_name == col_name)
1003            .map(|c| &c.btree)
1004    }
1005
1006    /// Mutable counterpart to [`Self::index`].
1007    #[inline]
1008    pub fn index_mut(&mut self, col_name: &str) -> Option<&mut BTree> {
1009        self.indexed_cols
1010            .iter_mut()
1011            .find(|c| c.col_name == col_name)
1012            .map(|c| &mut c.btree)
1013    }
1014
1015    /// `true` if this table has an index on the named column.
1016    #[inline]
1017    pub fn has_index(&self, col_name: &str) -> bool {
1018        self.indexed_cols.iter().any(|c| c.col_name == col_name)
1019    }
1020
1021    /// `true` if this table has no secondary indexes at all.
1022    #[inline]
1023    pub fn indexes_is_empty(&self) -> bool {
1024        self.indexed_cols.is_empty() && self.expression_indexes.is_empty()
1025    }
1026
1027    /// Mission C Phase 15: the hot insert path used to do two wasted
1028    /// things per secondary index, on every row:
1029    ///   1. `for (col_name, btree) in &mut self.indexes` walked an
1030    ///      FxHashMap by iterator (cheap but not free), and
1031    ///   2. `self.schema.column_index(col_name)` walked `schema.columns`
1032    ///      doing an O(n_cols) strcmp linear search to translate the
1033    ///      column name back into its schema position.
1034    ///
1035    /// For the `insert_batch_1k` bench (1K rows, User table, one index on
1036    /// `id`) that came out to ~6 strcmps * 1000 rows = 6K wasted
1037    /// comparisons per iteration, plus the HashMap iter overhead. We now
1038    /// iterate the precomputed `indexed_cols` slice directly, which hands
1039    /// us `(col_idx, col_name, is_int)` per entry, and route int keys
1040    /// straight through `BTree::insert_int` to skip the generic
1041    /// `Value::Ord` dispatch on every binary-search comparison.
1042    pub fn insert(&mut self, values: &Row) -> io::Result<RowId> {
1043        let expression_keys = self.preflight_insert(values)?;
1044        // Common case: the row fits inline (v1) — encode straight into scratch,
1045        // byte-identical to pre-v0.11. Size it first WITHOUT encoding so a huge
1046        // value (which the debug v1 encoder would panic on) routes to spill.
1047        if crate::row::v1_encoded_len(&self.row_layout, values) <= crate::page::MAX_ROW_DATA_SIZE {
1048            encode_row_into_with_layout(
1049                &self.schema,
1050                &self.row_layout,
1051                values,
1052                &mut self.encode_scratch,
1053            );
1054            let rid = self.heap.insert(&self.encode_scratch)?;
1055            self.maintain_indexes_on_insert(values, rid);
1056            if let Some(expression_keys) = expression_keys.as_deref() {
1057                self.maintain_expression_indexes_on_insert(expression_keys, rid);
1058            }
1059            return Ok(rid);
1060        }
1061        // Otherwise spill the largest var values out of line and store a v2
1062        // stub row. This self-contained path (no WAL) backs the WAL-off and
1063        // direct-`Table` callers; the WAL path in `Catalog` writes the same
1064        // chains but logs each chunk for crash recovery.
1065        let encoded = self.encode_row_spilling(values)?;
1066        let rid = self.heap.insert(&encoded)?;
1067        self.maintain_indexes_on_insert(values, rid);
1068        if let Some(expression_keys) = expression_keys.as_deref() {
1069            self.maintain_expression_indexes_on_insert(expression_keys, rid);
1070        }
1071        Ok(rid)
1072    }
1073
1074    /// Mark-and-sweep this table's overflow pages (design 3.6). Reclaims every
1075    /// Overflow-typed page not referenced by a live row's stub — i.e. orphans
1076    /// left by crashed transactions or by delete/chain-replacing updates whose
1077    /// pages were never returned to the free list. Returns the reclaimed page
1078    /// ids; the caller (catalog) logs them as one `OverflowFree` record.
1079    ///
1080    /// Runs against an on-disk-consistent view (flushes dirty pages first) and
1081    /// reads only version words, bitmaps, and stubs — never a full decode.
1082    pub(crate) fn sweep_overflow(&mut self) -> io::Result<Vec<u32>> {
1083        self.heap.flush_all_dirty()?;
1084        // Mark: collect every referenced chain head from live v2 rows, then
1085        // walk each chain into the referenced set. Stubs are gathered first so
1086        // the row scan's `&heap` borrow is released before the chain walks.
1087        let schema = &self.schema;
1088        let layout = &self.row_layout;
1089        let mut heads: Vec<u32> = Vec::new();
1090        self.heap.for_each_row(|_rid, bytes| {
1091            if crate::row::row_is_v2(bytes) {
1092                crate::row::for_each_stub(schema, layout, bytes, |_col, stub| {
1093                    heads.push(stub.first_page);
1094                });
1095            }
1096        });
1097        let mut referenced: std::collections::HashSet<u32> = std::collections::HashSet::new();
1098        for head in heads {
1099            for pid in self.heap.overflow_chain_pages(head)? {
1100                referenced.insert(pid);
1101            }
1102        }
1103        // Sweep: reclaim unreferenced overflow pages below the watermark.
1104        self.heap.sweep_unreferenced_overflow(&referenced)
1105    }
1106
1107    /// Collect every overflow-chain page id referenced by the row at `rid`, or
1108    /// an empty vec when the table has never spilled, the row is gone, or the
1109    /// row is inline (v1). Used by the catalog to free a row's old chain when an
1110    /// update replaces/removes its spilled value or the row is deleted (design
1111    /// 3.6), so steady-state churn reclaims pages instead of leaking them.
1112    ///
1113    /// Does not touch the free list itself: the caller decides when it is safe
1114    /// to release (immediately for autocommit, at commit for an explicit tx).
1115    pub(crate) fn overflow_chain_pages_at(&self, rid: RowId) -> io::Result<Vec<u32>> {
1116        if !self.has_overflow_rows() {
1117            return Ok(Vec::new());
1118        }
1119        let data = match self.heap.get(rid) {
1120            Some(d) => d,
1121            None => return Ok(Vec::new()),
1122        };
1123        if !crate::row::row_is_v2(&data) {
1124            return Ok(Vec::new());
1125        }
1126        let mut heads: Vec<u32> = Vec::new();
1127        crate::row::for_each_stub(&self.schema, &self.row_layout, &data, |_col, stub| {
1128            heads.push(stub.first_page);
1129        });
1130        let mut pages = Vec::new();
1131        for head in heads {
1132            pages.extend(self.heap.overflow_chain_pages(head)?);
1133        }
1134        Ok(pages)
1135    }
1136
1137    /// Return a set of overflow-chain pages to this table's in-memory free list
1138    /// for reuse by the next spill. The catalog calls this once it is safe to
1139    /// reclaim (see [`Self::overflow_chain_pages_at`]).
1140    pub(crate) fn release_overflow_pages(&mut self, pages: &[u32]) {
1141        self.heap.release_overflow_pages(pages);
1142    }
1143
1144    /// Build the v2 stub-row encoding for `values`, writing each spilled
1145    /// value's overflow chain directly to the heap (no WAL — the WAL path
1146    /// lives in `Catalog`). Enforces `MAX_VALUE_SIZE` per value.
1147    fn encode_row_spilling(&mut self, values: &Row) -> io::Result<Vec<u8>> {
1148        let v1_len = crate::row::v1_encoded_len(&self.row_layout, values);
1149        let is_indexed = self.indexed_col_mask();
1150        let chosen = plan_spill(&self.row_layout, values, v1_len, &is_indexed);
1151        let n_var = self.row_layout.n_var();
1152        let mut spilled: Vec<Option<OverflowStub>> = vec![None; n_var];
1153        for col_idx in chosen {
1154            let var_idx = self
1155                .row_layout
1156                .var_index(col_idx)
1157                .expect("plan_spill only returns var columns");
1158            let bytes: Vec<u8> = match &values[col_idx] {
1159                Value::Str(s) => s.as_bytes().to_vec(),
1160                Value::Bytes(b) => b.to_vec(),
1161                Value::Json(b) => b.to_vec(),
1162                _ => continue,
1163            };
1164            if bytes.len() > MAX_VALUE_SIZE {
1165                return Err(StorageError::ValueTooLarge {
1166                    size: bytes.len(),
1167                    max: MAX_VALUE_SIZE,
1168                }
1169                .into());
1170            }
1171            spilled[var_idx] = Some(self.write_value_chain(&bytes)?);
1172        }
1173        let mut out = Vec::new();
1174        encode_row_v2_into(&self.schema, &self.row_layout, values, &spilled, &mut out);
1175        Ok(out)
1176    }
1177
1178    /// Allocate and write an overflow chain for `value` directly to the heap
1179    /// (LSN 0, no WAL). Head-first, singly linked. Returns the stub.
1180    fn write_value_chain(&mut self, value: &[u8]) -> io::Result<OverflowStub> {
1181        let n = value.len().div_ceil(OVERFLOW_PAYLOAD_CAP).max(1);
1182        let mut pages = Vec::with_capacity(n);
1183        for _ in 0..n {
1184            pages.push(self.heap.allocate_overflow_page()?);
1185        }
1186        for i in 0..n {
1187            let start = i * OVERFLOW_PAYLOAD_CAP;
1188            let end = (start + OVERFLOW_PAYLOAD_CAP).min(value.len());
1189            let next = if i + 1 < n {
1190                pages[i + 1]
1191            } else {
1192                OVERFLOW_CHAIN_END
1193            };
1194            self.heap
1195                .write_overflow_page(pages[i], next, &value[start..end], 0)?;
1196        }
1197        Ok(OverflowStub::new(
1198            value.len() as u64,
1199            pages[0],
1200            crc32fast::hash(value),
1201        ))
1202    }
1203
1204    /// Insert a row whose bytes were encoded by the caller (used by the
1205    /// overflow spill path, which builds a v2 stub row and writes the
1206    /// out-of-line chains before calling here). Index maintenance still uses
1207    /// the LOGICAL `values` — extraction happens on the full value before
1208    /// spill, so indexes never see a stub.
1209    pub(crate) fn insert_encoded(&mut self, values: &Row, encoded: &[u8]) -> io::Result<RowId> {
1210        let expression_keys = self.preflight_insert(values)?;
1211        let rid = self.heap.insert(encoded)?;
1212        self.maintain_indexes_on_insert(values, rid);
1213        if let Some(expression_keys) = expression_keys.as_deref() {
1214            self.maintain_expression_indexes_on_insert(expression_keys, rid);
1215        }
1216        Ok(rid)
1217    }
1218
1219    /// Insert the row's indexed columns into every b-tree from the logical
1220    /// values. Blocker B3: marks trees dirty in memory; the save is deferred
1221    /// to the next checkpoint.
1222    fn maintain_indexes_on_insert(&mut self, values: &Row, rid: RowId) {
1223        if self.indexed_cols.is_empty() {
1224            return;
1225        }
1226        for entry in &mut self.indexed_cols {
1227            let val = &values[entry.col_idx];
1228            if val.is_empty() {
1229                continue;
1230            }
1231            if entry.unique {
1232                if entry.is_int {
1233                    if let Value::Int(i) = val {
1234                        entry.btree.insert_int(*i, rid);
1235                        continue;
1236                    }
1237                }
1238                entry.btree.insert(val.clone(), rid);
1239            } else {
1240                entry.btree.insert_non_unique(val.clone(), rid);
1241            }
1242        }
1243    }
1244
1245    /// Blocker B3: flush every dirty btree index to disk. Wired into
1246    /// [`crate::catalog::Catalog::checkpoint`] and its `Drop` impl so
1247    /// we get one fsync + rename per dirty index per checkpoint, not
1248    /// one per inserted row. Clean trees (no mutations since last
1249    /// save) are free — `BTree::save_if_dirty` early-returns.
1250    pub(crate) fn save_dirty_indexes(&mut self) -> io::Result<()> {
1251        for entry in self.indexed_cols.iter_mut() {
1252            entry.btree.save_if_dirty()?;
1253        }
1254        for entry in self.expression_indexes.iter_mut() {
1255            entry.btree.save_if_dirty()?;
1256        }
1257        Ok(())
1258    }
1259
1260    /// Discard uncommitted, in-memory index mutations so they never reach
1261    /// disk. Called by ROLLBACK on the catalog it is about to drop: without
1262    /// this, the drop-time checkpoint's `save_dirty_indexes` would flush the
1263    /// rolled-back index writes to the `.idx` files, poisoning the unique
1264    /// index (see `Catalog::rollback_to_last_sync_inner`). Mirrors
1265    /// `Heap::discard_dirty` for the heap side.
1266    pub(crate) fn discard_dirty_indexes(&mut self) {
1267        for entry in self.indexed_cols.iter_mut() {
1268            entry.btree.discard_dirty();
1269        }
1270        for entry in self.expression_indexes.iter_mut() {
1271            entry.btree.discard_dirty();
1272        }
1273    }
1274
1275    /// Blocker B3: rebuild every secondary index from the heap.
1276    ///
1277    /// Used by the crash-recovery path in `Catalog::open`: after WAL
1278    /// replay lands rows back in the heap, the on-disk `.idx` files
1279    /// may lag (or lead) the heap because the prior session deferred
1280    /// btree saves until checkpoint. Replaying is cheap — we walk the
1281    /// heap once per index — and produces a tree that exactly matches
1282    /// the current heap state, which is the invariant subsequent
1283    /// inserts assume.
1284    ///
1285    /// After this call, every indexed tree is marked dirty so the
1286    /// next `Catalog::checkpoint` persists the recovered state.
1287    pub(crate) fn rebuild_indexes_from_heap(&mut self) -> io::Result<()> {
1288        if self.indexed_cols.is_empty() && self.expression_indexes.is_empty() {
1289            return Ok(());
1290        }
1291
1292        // Snapshot raw rows once so the reassembly below can take a second
1293        // shared borrow of the heap (read_overflow_value) without conflicting
1294        // with a live scan iterator. Rebuild is a cold recovery path, so the
1295        // owned snapshot is fine.
1296        let raw_rows: Vec<(RowId, Vec<u8>)> = self.heap.scan().collect();
1297        let schema = &self.schema;
1298        let layout = &self.row_layout;
1299        let heap = &self.heap;
1300        for entry in self.indexed_cols.iter_mut() {
1301            // Non-unique indexes rebuild at v3 (NUL-escaped composite Str keys);
1302            // unique indexes stay v1.
1303            let mut fresh = if entry.unique {
1304                BTree::create(entry.btree.file_path())?
1305            } else {
1306                BTree::create_non_unique(entry.btree.file_path())?
1307            };
1308            for (rid, raw) in &raw_rows {
1309                let (rid, raw) = (*rid, raw.as_slice());
1310                // v2 rows must be reassembled so a spilled indexed column
1311                // produces its true key, not Empty (P2: create-index /
1312                // rebuild-after-spill must not build a btree of missing keys).
1313                let owned;
1314                let v: &Value = if crate::row::row_is_v2(raw) {
1315                    match crate::row::decode_row_v2(schema, layout, raw, |stub| {
1316                        heap.read_overflow_value(stub).map_err(io::Error::from)
1317                    }) {
1318                        Ok(row) => {
1319                            owned = row[entry.col_idx].clone();
1320                            &owned
1321                        }
1322                        Err(_) => continue,
1323                    }
1324                } else {
1325                    owned = decode_column(schema, layout, raw, entry.col_idx);
1326                    &owned
1327                };
1328                if v.is_empty() {
1329                    continue;
1330                }
1331                if entry.unique {
1332                    if entry.is_int {
1333                        if let Value::Int(i) = v {
1334                            fresh.insert_int(*i, rid);
1335                            continue;
1336                        }
1337                    }
1338                    fresh.insert(v.clone(), rid);
1339                } else {
1340                    fresh.insert_non_unique(v.clone(), rid);
1341                }
1342            }
1343            // Force-mark dirty so the next checkpoint flushes the
1344            // freshly rebuilt tree, even if no further mutations
1345            // happen before shutdown.
1346            fresh.mark_dirty();
1347            entry.btree = fresh;
1348        }
1349        for entry in self.expression_indexes.iter_mut() {
1350            let mut fresh = BTree::create_v2(entry.btree.file_path())?;
1351            for (rid, raw) in &raw_rows {
1352                let row = if crate::row::row_is_v2(raw) {
1353                    crate::row::decode_row_v2(schema, layout, raw, |stub| {
1354                        heap.read_overflow_value(stub).map_err(io::Error::from)
1355                    })?
1356                } else {
1357                    decode_row(schema, raw)
1358                };
1359                let key = expression_key(&entry.meta, &row[entry.root_col_idx])?;
1360                if key.is_empty() {
1361                    fresh.insert_empty(*rid);
1362                } else if entry.meta.unique {
1363                    if fresh.lookup(&key).is_some() {
1364                        return Err(expression_unique_error(
1365                            &self.schema.table_name,
1366                            &entry.meta.canonical_text,
1367                        ));
1368                    }
1369                    fresh.insert(key, *rid);
1370                } else {
1371                    fresh.insert_duplicate(key, *rid);
1372                }
1373            }
1374            fresh.mark_dirty();
1375            entry.btree = fresh;
1376        }
1377        Ok(())
1378    }
1379
1380    pub fn get(&self, rid: RowId) -> Option<Row> {
1381        let data = self.heap.get(rid)?;
1382        if crate::row::row_is_v2(&data) {
1383            // v2 row: reassemble each spilled column from its overflow chain.
1384            return crate::row::decode_row_v2(&self.schema, &self.row_layout, &data, |stub| {
1385                self.heap.read_overflow_value(stub).map_err(io::Error::from)
1386            })
1387            .ok();
1388        }
1389        Some(decode_row(&self.schema, &data))
1390    }
1391
1392    /// Read only the requested logical columns from one row.
1393    ///
1394    /// Output order exactly follows `column_indices`, including duplicates.
1395    /// Inline values are decoded directly from the row body. For a v2 row,
1396    /// an overflow chain is fetched and verified only when its column was
1397    /// requested; an unselected spilled value is never touched.
1398    pub fn get_projected(
1399        &self,
1400        rid: RowId,
1401        column_indices: &[usize],
1402    ) -> io::Result<Option<Vec<Value>>> {
1403        for &column_index in column_indices {
1404            if column_index >= self.schema.columns.len() {
1405                return Err(io::Error::new(
1406                    io::ErrorKind::InvalidInput,
1407                    format!(
1408                        "projected column index {column_index} out of range for {} columns",
1409                        self.schema.columns.len()
1410                    ),
1411                ));
1412            }
1413        }
1414
1415        let Some(data) = self.heap.get(rid) else {
1416            return Ok(None);
1417        };
1418        let mut values: Vec<Value> = Vec::with_capacity(column_indices.len());
1419        for (request_position, &column_index) in column_indices.iter().enumerate() {
1420            if let Some(previous_position) = column_indices[..request_position]
1421                .iter()
1422                .position(|&previous| previous == column_index)
1423            {
1424                values.push(values[previous_position].clone());
1425                continue;
1426            }
1427
1428            let value = if let Some(stub) =
1429                crate::row::raw_stub(&self.schema, &self.row_layout, &data, column_index)
1430            {
1431                let bytes = self
1432                    .heap
1433                    .read_overflow_value(&stub)
1434                    .map_err(io::Error::from)?;
1435                match self.schema.columns[column_index].type_id {
1436                    TypeId::Str => Value::Str(String::from_utf8(bytes).map_err(|error| {
1437                        io::Error::new(
1438                            io::ErrorKind::InvalidData,
1439                            format!("invalid UTF-8 in projected string column: {error}"),
1440                        )
1441                    })?),
1442                    TypeId::Bytes => Value::Bytes(bytes),
1443                    TypeId::Json => Value::Json(bytes.into()),
1444                    _ => {
1445                        return Err(io::Error::new(
1446                            io::ErrorKind::InvalidData,
1447                            "fixed-width column has an overflow stub",
1448                        ));
1449                    }
1450                }
1451            } else {
1452                decode_column(&self.schema, &self.row_layout, &data, column_index)
1453            };
1454            values.push(value);
1455        }
1456        Ok(Some(values))
1457    }
1458
1459    /// Delete a row. Mission C Phase 7: if the table has indexes, we used to
1460    /// call `decode_row` here — allocating `Row` + every column's `Value`
1461    /// just to read the two or three columns that actually feed the index.
1462    /// Now we borrow the raw page bytes once and call `decode_column` for
1463    /// exactly the indexed columns, skipping the rest of the row entirely.
1464    ///
1465    /// Mission C Phase 11: the Phase 7 version still allocated a
1466    /// `Vec<(usize, Value)>` per row so the btree mutations could happen
1467    /// after the hot-page borrow closed. That's 3300 heap allocations per
1468    /// 100K-row `delete_by_filter` iteration — gone in Phase 11 via
1469    /// struct-field borrow splitting, so the btree lives alongside the
1470    /// page borrow inside the closure.
1471    pub fn delete(&mut self, rid: RowId) -> io::Result<()> {
1472        if self.indexed_cols.is_empty() && self.expression_indexes.is_empty() {
1473            return self.heap.delete(rid);
1474        }
1475        let expression_keys = self.expression_keys_at(rid)?.ok_or_else(|| {
1476            io::Error::new(io::ErrorKind::NotFound, "row not found for index deletion")
1477        })?;
1478
1479        // Split the borrow so `indexed_cols` (mutable — the btree lives
1480        // inside each entry now) can be captured by the closure alongside
1481        // `heap` (also mutable). Rust's disjoint-field borrowing lets
1482        // this compile without cloning anything.
1483        let Table {
1484            heap,
1485            schema,
1486            row_layout: layout,
1487            indexed_cols,
1488            ..
1489        } = self;
1490
1491        // A spilled indexed column holds only a stub inline, so its key must be
1492        // reassembled from the overflow chain — a v1-only `decode_column`
1493        // yields `Empty` and leaves the btree entry dangling (P2). Collect such
1494        // columns under the pinned borrow, reassemble after it closes. The v1
1495        // fast path is unchanged (no v2 row ⇒ `raw_stub` is always None ⇒ no
1496        // allocation, no deferral).
1497        let mut deferred: Vec<(usize, crate::row::OverflowStub)> = Vec::new();
1498        heap.with_row_bytes(rid, |data| {
1499            for (slot, entry) in indexed_cols.iter_mut().enumerate() {
1500                if let Some(stub) = crate::row::raw_stub(schema, layout, data, entry.col_idx) {
1501                    deferred.push((slot, stub));
1502                    continue;
1503                }
1504                let val = decode_column(schema, layout, data, entry.col_idx);
1505                if val.is_empty() {
1506                    continue;
1507                }
1508                if entry.unique {
1509                    // Unique index: key is the column value directly.
1510                    match &val {
1511                        Value::Int(i) => {
1512                            entry.btree.delete_int(*i);
1513                        }
1514                        _ => {
1515                            entry.btree.delete(&val);
1516                        }
1517                    }
1518                } else {
1519                    // Non-unique index: key is composite (col_val, rid).
1520                    entry.btree.delete_non_unique(&val, rid);
1521                }
1522            }
1523        })?;
1524
1525        // Reassemble + delete keys for any spilled indexed columns (rare:
1526        // `plan_spill` keeps indexed columns inline, so this only fires for
1527        // legacy rows or a column indexed AFTER its values spilled). The chain
1528        // pages are still intact here — `heap.delete` below only clears the
1529        // stub row's slot, never the overflow chain.
1530        for (slot, stub) in deferred {
1531            let bytes = heap.read_overflow_value(&stub).map_err(io::Error::from)?;
1532            let entry = &mut indexed_cols[slot];
1533            let val = match schema.columns[entry.col_idx].type_id {
1534                TypeId::Str => Value::Str(String::from_utf8_lossy(&bytes).into_owned()),
1535                TypeId::Bytes => Value::Bytes(bytes),
1536                TypeId::Json => Value::Json(bytes.into()),
1537                _ => continue,
1538            };
1539            if entry.unique {
1540                entry.btree.delete(&val);
1541            } else {
1542                entry.btree.delete_non_unique(&val, rid);
1543            }
1544        }
1545
1546        for (index, key) in self.expression_indexes.iter_mut().zip(&expression_keys) {
1547            if key.is_empty() {
1548                index.btree.delete_empty(rid);
1549            } else {
1550                index.btree.delete_pair(key, rid);
1551            }
1552        }
1553
1554        self.heap.delete(rid)?;
1555        // Blocker B3: btree mutations above marked the indexes dirty.
1556        // The actual persist happens at the next `Catalog::checkpoint`
1557        // (or `Drop`), batching many deletes into one fsync per index.
1558        Ok(())
1559    }
1560
1561    /// Mission C Phase 12: bulk delete a list of rids, batching the
1562    /// secondary-index maintenance.
1563    ///
1564    /// For a 100K-row `delete_by_filter` that removes ~20% of the rows,
1565    /// the per-row `Table::delete` path pays ~4ms of pure `Vec::remove`
1566    /// memmove inside the btree: every call shifts up to 4KB of leaf
1567    /// entries. This helper collects the indexed-column keys first,
1568    /// deletes the heap slots one by one (hot-page writes), then compacts
1569    /// each btree in a single pass via [`BTree::delete_many_int`].
1570    ///
1571    /// Restrictions / fall-through:
1572    /// - If the table has no indexes, this is equivalent to looping over
1573    ///   `heap.delete`.
1574    /// - If any indexed column is not `TypeId::Int`, this falls back to
1575    ///   the per-row `delete` path. The int-only constraint matches the
1576    ///   only btree batch primitive we have (`delete_many_int`) and
1577    ///   covers the overwhelmingly common case (primary keys,
1578    ///   `created_at`, foreign keys).
1579    ///
1580    /// Returns the number of rows removed.
1581    pub fn delete_many(&mut self, rids: &[RowId]) -> io::Result<u64> {
1582        if rids.is_empty() {
1583            return Ok(0);
1584        }
1585        if !self.expression_indexes.is_empty() {
1586            let mut count = 0;
1587            for &rid in rids {
1588                self.delete(rid)?;
1589                count += 1;
1590            }
1591            return Ok(count);
1592        }
1593        if self.indexed_cols.is_empty() {
1594            for &rid in rids {
1595                self.heap.delete(rid)?;
1596            }
1597            return Ok(rids.len() as u64);
1598        }
1599
1600        // All indexed cols must be int AND unique for the batch btree
1601        // path to apply. Non-unique indexes use composite keys, so the
1602        // `delete_many_int` primitive (which searches by raw i64) won't
1603        // find them.
1604        let all_int_unique = self.indexed_cols.iter().all(|c| c.is_int && c.unique);
1605        if !all_int_unique {
1606            // Mixed index types — defer to the generic per-row path.
1607            let mut count = 0u64;
1608            for &rid in rids {
1609                self.delete(rid)?;
1610                count += 1;
1611            }
1612            return Ok(count);
1613        }
1614
1615        // Split the borrow so the closure can capture `schema`/`layout`/
1616        // `indexed_cols` while `heap` is borrowed mutably by
1617        // `delete_with_hook`.
1618        let Table {
1619            heap,
1620            schema,
1621            row_layout: layout,
1622            indexed_cols,
1623            ..
1624        } = self;
1625
1626        let n_indexed = indexed_cols.len();
1627        let mut keys_per_index: Vec<Vec<i64>> = (0..n_indexed)
1628            .map(|_| Vec::with_capacity(rids.len()))
1629            .collect();
1630
1631        let mut count = 0u64;
1632        for &rid in rids {
1633            let found = heap.delete_with_hook(rid, |data| {
1634                for (slot_i, entry) in indexed_cols.iter().enumerate() {
1635                    if let Value::Int(i) = decode_column(schema, layout, data, entry.col_idx) {
1636                        keys_per_index[slot_i].push(i);
1637                    }
1638                }
1639            })?;
1640            if found {
1641                count += 1;
1642            }
1643        }
1644
1645        // Batch-compact each btree in a single leaf-chain walk. Mission C
1646        // Phase 17: btrees now live inline in indexed_cols, so this is a
1647        // direct `iter_mut()` over the same slice the hook above borrowed
1648        // immutably — no HashMap probe required.
1649        for (slot_i, entry) in indexed_cols.iter_mut().enumerate() {
1650            let keys = &mut keys_per_index[slot_i];
1651            keys.sort_unstable();
1652            entry.btree.delete_many_int(keys);
1653        }
1654
1655        // Blocker B3: indexes are now dirty in memory; `delete_many_int`
1656        // already flipped the dirty flag on each mutated btree above.
1657        // Checkpoint batches the persist.
1658
1659        Ok(count)
1660    }
1661
1662    /// Single-pass scan-and-delete driven by a raw-bytes predicate. Walks
1663    /// the heap once, marks matching rows deleted in place, and updates
1664    /// any int-keyed secondary indexes in a single batched
1665    /// `delete_many_int` per index at the end. Non-int secondary indexes
1666    /// fall back to per-key `btree.delete`, but still ride the same
1667    /// single heap pass.
1668    ///
1669    /// Mission C Phase 16: this is the Table-level hook for
1670    /// [`HeapFile::scan_delete_matching`]. See that method for the
1671    /// fusion rationale. The executor's `Delete` fast path routes
1672    /// `Filter(SeqScan)` / `SeqScan`-shaped delete plans here when the
1673    /// predicate compiles.
1674    pub fn scan_delete_matching<P>(&mut self, pred: P) -> io::Result<u64>
1675    where
1676        P: FnMut(&[u8]) -> bool,
1677    {
1678        self.scan_delete_matching_with_hook(pred, |_, _| {})
1679    }
1680
1681    /// Variant of [`Self::scan_delete_matching`] that lets the caller
1682    /// observe every matched row just before it's marked deleted. Used
1683    /// by [`crate::catalog::Catalog::scan_delete_matching_logged`] to
1684    /// emit one WAL `Delete` record per victim in the same single-pass
1685    /// scan — no second walk over the heap, no per-row `ensure_hot`
1686    /// round-trip.
1687    ///
1688    /// The user hook runs inside the heap's pinned hot-page borrow, so
1689    /// it must not call back into the catalog / table / heap. The WAL
1690    /// append path only writes into an in-memory buffer and is safe.
1691    pub fn scan_delete_matching_with_hook<P, H>(
1692        &mut self,
1693        mut pred: P,
1694        mut user_hook: H,
1695    ) -> io::Result<u64>
1696    where
1697        P: FnMut(&[u8]) -> bool,
1698        H: FnMut(RowId, &[u8]),
1699    {
1700        if !self.expression_indexes.is_empty() {
1701            let victims = self
1702                .heap
1703                .scan()
1704                .filter(|(_, bytes)| pred(bytes))
1705                .collect::<Vec<_>>();
1706            let count = victims.len() as u64;
1707            for (rid, bytes) in victims {
1708                user_hook(rid, &bytes);
1709                self.delete(rid)?;
1710            }
1711            return Ok(count);
1712        }
1713        if self.indexed_cols.is_empty() {
1714            return self.heap.scan_delete_matching(pred, |rid, bytes| {
1715                user_hook(rid, bytes);
1716            });
1717        }
1718
1719        // Split the borrow so the hook closure can capture schema /
1720        // layout / indexed_cols (immutably for reads) while `heap` is
1721        // mutably borrowed by `scan_delete_matching`. After the scan
1722        // completes, the closure is dropped, freeing the shared borrow
1723        // of `indexed_cols` so we can flip to `iter_mut()` for the
1724        // batch btree compaction.
1725        let Table {
1726            heap,
1727            schema,
1728            row_layout: layout,
1729            indexed_cols,
1730            ..
1731        } = self;
1732
1733        let n_indexed = indexed_cols.len();
1734        let all_int_unique = indexed_cols.iter().all(|c| c.is_int && c.unique);
1735
1736        if all_int_unique {
1737            let mut keys_per_index: Vec<Vec<i64>> =
1738                (0..n_indexed).map(|_| Vec::with_capacity(1024)).collect();
1739
1740            let count = heap.scan_delete_matching(pred, |rid, data| {
1741                for (slot_i, entry) in indexed_cols.iter().enumerate() {
1742                    if let Value::Int(i) = decode_column(schema, layout, data, entry.col_idx) {
1743                        keys_per_index[slot_i].push(i);
1744                    }
1745                }
1746                user_hook(rid, data);
1747            })?;
1748
1749            // Mission C Phase 17: btrees live inline in indexed_cols,
1750            // so this direct iter_mut replaces the old HashMap probe.
1751            for (slot_i, entry) in indexed_cols.iter_mut().enumerate() {
1752                let keys = &mut keys_per_index[slot_i];
1753                keys.sort_unstable();
1754                entry.btree.delete_many_int(keys);
1755            }
1756            // Blocker B3: dirty flags are already set by the
1757            // per-btree `delete_many_int` call above; checkpoint
1758            // handles the persist.
1759            return Ok(count);
1760        }
1761
1762        // Mixed / non-int / non-unique secondary indexes: single heap
1763        // pass, per-key btree deletes at the end. We collect (value, rid)
1764        // pairs so non-unique indexes can delete the correct composite key.
1765        let mut entries_per_index: Vec<Vec<(Value, RowId)>> =
1766            (0..n_indexed).map(|_| Vec::with_capacity(256)).collect();
1767        // Spilled indexed columns (v2 rows) can't be decoded inline — collect
1768        // their stubs and reassemble after the scan releases the heap borrow
1769        // (P2: a v1-only decode_column would yield Empty ⇒ dangling entry).
1770        let mut deferred: Vec<(usize, crate::row::OverflowStub, RowId)> = Vec::new();
1771
1772        let count = heap.scan_delete_matching(pred, |rid, data| {
1773            for (slot_i, entry) in indexed_cols.iter().enumerate() {
1774                if let Some(stub) = crate::row::raw_stub(schema, layout, data, entry.col_idx) {
1775                    deferred.push((slot_i, stub, rid));
1776                    continue;
1777                }
1778                let v = decode_column(schema, layout, data, entry.col_idx);
1779                if !v.is_empty() {
1780                    entries_per_index[slot_i].push((v, rid));
1781                }
1782            }
1783            user_hook(rid, data);
1784        })?;
1785
1786        // Reassemble spilled indexed keys now that the scan's heap borrow is
1787        // released (the deleted rows' overflow chains are still intact — the
1788        // scan clears slots only, not chains).
1789        for (slot_i, stub, rid) in deferred {
1790            let bytes = heap.read_overflow_value(&stub).map_err(io::Error::from)?;
1791            let v = match schema.columns[indexed_cols[slot_i].col_idx].type_id {
1792                TypeId::Str => Value::Str(String::from_utf8_lossy(&bytes).into_owned()),
1793                TypeId::Bytes => Value::Bytes(bytes),
1794                TypeId::Json => Value::Json(bytes.into()),
1795                _ => continue,
1796            };
1797            entries_per_index[slot_i].push((v, rid));
1798        }
1799
1800        for (slot_i, entry) in indexed_cols.iter_mut().enumerate() {
1801            for (v, rid) in &entries_per_index[slot_i] {
1802                if entry.unique {
1803                    entry.btree.delete(v);
1804                } else {
1805                    entry.btree.delete_non_unique(v, *rid);
1806                }
1807            }
1808        }
1809        // Blocker B3: btree dirty flags are set by `delete`; checkpoint
1810        // flushes later.
1811        Ok(count)
1812    }
1813
1814    /// Single-pass fused scan + in-place patch. Evaluates `pred` on raw
1815    /// row bytes and applies `try_mutate` to each match on the same hot
1816    /// page — no second pass. Returns `(patched_count, fallback_rids)`.
1817    ///
1818    /// The `hook` closure fires after each successful patch with the
1819    /// post-mutation bytes, used for WAL logging.
1820    ///
1821    /// Perf sprint: this is the update analogue of
1822    /// `scan_delete_matching_with_hook`. Eliminates the two-pass
1823    /// collect-then-patch pattern that doubled `ensure_hot` calls for
1824    /// `update_by_filter`.
1825    pub fn scan_patch_matching_with_hook<P, M, H>(
1826        &mut self,
1827        pred: P,
1828        try_mutate: M,
1829        hook: H,
1830    ) -> io::Result<(u64, Vec<RowId>)>
1831    where
1832        P: FnMut(&[u8]) -> bool,
1833        M: FnMut(&mut [u8]) -> Option<u16>,
1834        H: FnMut(RowId, &[u8]),
1835    {
1836        // No index maintenance needed — callers guarantee the patched
1837        // columns are not indexed (same constraint as the per-rid
1838        // `with_row_bytes_mut` / `patch_var_col_in_place` fast paths).
1839        self.heap.scan_patch_matching(pred, try_mutate, hook)
1840    }
1841
1842    /// Update a row in place when possible. Falls back to delete+insert only
1843    /// if the new encoding doesn't fit in the current slot.
1844    ///
1845    /// Mission D5: the previous implementation always did `delete + insert`,
1846    /// which:
1847    ///   1. read+wrote the page twice (once to clear the slot, once to fill it
1848    ///      again — usually on a different page),
1849    ///   2. did an O(N) scan over `pages_with_space` for every insert,
1850    ///   3. mutated every index even when the indexed column hadn't changed.
1851    ///
1852    /// On `update_by_filter` (50K matching rows, status-only update, no
1853    /// index on status) that turned ~1ms of work into 30 seconds — a
1854    /// catastrophic O(N²)-ish gap vs SQLite (6.7ms total). The fix is to
1855    /// (a) prefer `heap.update` which tries in-place first and (b) only
1856    /// touch indexes whose value actually changed.
1857    pub fn update(&mut self, rid: RowId, values: &Row) -> io::Result<RowId> {
1858        self.update_hinted(rid, values, None)
1859    }
1860
1861    /// Same as `update`, but the caller can supply the set of column
1862    /// indices that actually changed. If supplied, the old-row read is
1863    /// skipped entirely when none of the changed columns is indexed.
1864    ///
1865    /// Mission C Phase 2: `update_by_filter` hits this path ~50K times with
1866    /// a single-column assignment (status) on a table whose only index is
1867    /// on `id`. The old code called `self.get(rid)` unconditionally — a
1868    /// heap read + full decode every time — even though the result was
1869    /// always thrown away for non-indexed updates. Skipping that read is
1870    /// worth ~300ns/row, or ~15ms on a 50K-row update_by_filter.
1871    pub fn update_hinted(
1872        &mut self,
1873        rid: RowId,
1874        values: &Row,
1875        changed_col_indices: Option<&[usize]>,
1876    ) -> io::Result<RowId> {
1877        self.preflight_update(rid, values)?;
1878        // Size the new row first: if it exceeds the inline cap, an overflow
1879        // transition takes delete+insert of a v2 stub row (self-contained,
1880        // no WAL — the WAL path lives in `Catalog::update`). In-place patch
1881        // fast paths stay v1/inline-only (they never reach here for big rows).
1882        if crate::row::v1_encoded_len(&self.row_layout, values) > crate::page::MAX_ROW_DATA_SIZE {
1883            let encoded = self.encode_row_spilling(values)?;
1884            return self.apply_update(rid, values, &encoded, changed_col_indices);
1885        }
1886        encode_row_into_with_layout(
1887            &self.schema,
1888            &self.row_layout,
1889            values,
1890            &mut self.encode_scratch,
1891        );
1892        // Move the scratch out so `apply_update` can borrow `self` mutably for
1893        // the heap update without aliasing the scratch buffer.
1894        let encoded = std::mem::take(&mut self.encode_scratch);
1895        let result = self.apply_update(rid, values, &encoded, changed_col_indices);
1896        self.encode_scratch = encoded;
1897        result
1898    }
1899
1900    /// Update a row with caller-supplied pre-encoded bytes (used by the
1901    /// catalog WAL path, which builds the v2 stub row and logs the overflow
1902    /// chains itself). Index maintenance uses the logical `values`.
1903    pub(crate) fn update_encoded(
1904        &mut self,
1905        rid: RowId,
1906        values: &Row,
1907        encoded: &[u8],
1908        changed_col_indices: Option<&[usize]>,
1909    ) -> io::Result<RowId> {
1910        self.apply_update(rid, values, encoded, changed_col_indices)
1911    }
1912
1913    /// Shared core of the update paths: unique pre-check, heap update with the
1914    /// already-encoded bytes, and secondary-index maintenance from the logical
1915    /// `values`.
1916    fn apply_update(
1917        &mut self,
1918        rid: RowId,
1919        values: &Row,
1920        encoded: &[u8],
1921        changed_col_indices: Option<&[usize]>,
1922    ) -> io::Result<RowId> {
1923        self.preflight_update(rid, values)?;
1924        let old_expression_keys = self.expression_keys_at(rid)?.ok_or_else(|| {
1925            io::Error::new(io::ErrorKind::NotFound, "row not found for index update")
1926        })?;
1927        let new_expression_keys = self.expression_keys(values)?;
1928        let touches_index = if self.indexed_cols.is_empty() {
1929            false
1930        } else if let Some(changed) = changed_col_indices {
1931            self.indexed_cols
1932                .iter()
1933                .any(|c| changed.contains(&c.col_idx))
1934        } else {
1935            // No hint — fall back to the safe path that reads the old row.
1936            true
1937        };
1938
1939        let old_row = if touches_index { self.get(rid) } else { None };
1940
1941        let new_rid = self.heap.update(rid, encoded)?;
1942
1943        // P0 (overflow relocation): a spill/unspill (or any grow) update turns
1944        // into heap delete+insert and hands back a NEW rid. When no indexed
1945        // column changed, `touches_index` is false and the block below is
1946        // skipped -- but every index entry still points at the OLD rid, so a
1947        // relocated row vanishes from all keyed access (point lookup, filtered
1948        // update/delete) and only an unfiltered scan finds it. Repoint every
1949        // index from `rid` -> `new_rid` using the (unchanged) current values,
1950        // which for an unchanged column equal the old key. The `touches_index`
1951        // path already handles relocation via its `new_rid == rid` guards.
1952        if !touches_index && new_rid != rid && !self.indexed_cols.is_empty() {
1953            for entry in self.indexed_cols.iter_mut() {
1954                let val = &values[entry.col_idx];
1955                if val.is_empty() {
1956                    continue;
1957                }
1958                if entry.unique {
1959                    entry.btree.delete(val);
1960                    entry.btree.insert(val.clone(), new_rid);
1961                } else {
1962                    entry.btree.delete_non_unique(val, rid);
1963                    entry.btree.insert_non_unique(val.clone(), new_rid);
1964                }
1965            }
1966        }
1967
1968        if touches_index {
1969            // Mission C Phase 17: walk the Vec<IndexedCol> directly.
1970            // `col_idx` is already precomputed on each entry, so we
1971            // don't even re-probe schema.column_index here.
1972            for entry in self.indexed_cols.iter_mut() {
1973                let new_val = &values[entry.col_idx];
1974                let old_val_opt = old_row.as_ref().map(|r| &r[entry.col_idx]);
1975
1976                if entry.unique {
1977                    if let Some(old_val) = old_val_opt {
1978                        if old_val == new_val && new_rid == rid {
1979                            continue;
1980                        }
1981                        if !old_val.is_empty() {
1982                            entry.btree.delete(old_val);
1983                        }
1984                    }
1985                    if !new_val.is_empty() {
1986                        entry.btree.insert(new_val.clone(), new_rid);
1987                    }
1988                } else {
1989                    // Non-unique: delete old composite, insert new composite.
1990                    if let Some(old_val) = old_val_opt {
1991                        if old_val == new_val && new_rid == rid {
1992                            continue;
1993                        }
1994                        if !old_val.is_empty() {
1995                            entry.btree.delete_non_unique(old_val, rid);
1996                        }
1997                    }
1998                    if !new_val.is_empty() {
1999                        entry.btree.insert_non_unique(new_val.clone(), new_rid);
2000                    }
2001                }
2002            }
2003        }
2004        for ((index, old_key), new_key) in self
2005            .expression_indexes
2006            .iter_mut()
2007            .zip(old_expression_keys)
2008            .zip(new_expression_keys)
2009        {
2010            if old_key == new_key && rid == new_rid {
2011                continue;
2012            }
2013            if old_key.is_empty() {
2014                index.btree.delete_empty(rid);
2015            } else {
2016                index.btree.delete_pair(&old_key, rid);
2017            }
2018            if new_key.is_empty() {
2019                index.btree.insert_empty(new_rid);
2020            } else if index.meta.unique {
2021                index.btree.insert(new_key, new_rid);
2022            } else {
2023                index.btree.insert_duplicate(new_key, new_rid);
2024            }
2025        }
2026        // Blocker B3: any mutated btree is now dirty; checkpoint will
2027        // persist it. No per-row fsync on this hot path.
2028        Ok(new_rid)
2029    }
2030
2031    /// Patch a row's raw bytes in place. Caller guarantees the mutation
2032    /// does not change the row's total length and does not touch any
2033    /// indexed column — indexes are NOT updated by this path.
2034    ///
2035    /// Mission C Phase 4: see `HeapFile::with_row_bytes_mut`. This is the
2036    /// primitive that backs the executor's single-column fixed-width
2037    /// update fast path.
2038    #[inline]
2039    pub fn with_row_bytes_mut<F>(&mut self, rid: RowId, f: F) -> io::Result<bool>
2040    where
2041        F: FnOnce(&mut [u8]),
2042    {
2043        self.heap.with_row_bytes_mut(rid, f)
2044    }
2045
2046    /// Patch a single var-length column in place, shrinking the row when
2047    /// the new value is smaller than the old one. Returns `Ok(true)` on
2048    /// success, `Ok(false)` when the new value would grow the row or the
2049    /// slot is gone (caller should fall back to the full update path).
2050    ///
2051    /// The caller is responsible for ensuring no indexed column is
2052    /// touched by this patch — indexes are NOT maintained here.
2053    ///
2054    /// Mission C Phase 10: backs the executor's `update_by_filter` fast
2055    /// path for var-length single-column assignments.
2056    #[inline]
2057    pub fn patch_var_col_in_place(
2058        &mut self,
2059        rid: RowId,
2060        col_idx: usize,
2061        new_value: Option<&[u8]>,
2062    ) -> io::Result<bool> {
2063        if self.has_indexed_col(col_idx) {
2064            return Err(io::Error::new(
2065                io::ErrorKind::InvalidInput,
2066                "cannot byte-patch an indexed column",
2067            ));
2068        }
2069        let layout = &self.row_layout;
2070        self.heap.patch_row_shrink(rid, |bytes| {
2071            patch_var_column_in_place(bytes, layout, col_idx, new_value)
2072        })
2073    }
2074
2075    /// Cached row layout for this table. Used by the executor to plan
2076    /// the byte-patch fast paths without re-walking the schema.
2077    #[inline]
2078    pub fn row_layout(&self) -> &RowLayout {
2079        &self.row_layout
2080    }
2081
2082    /// Mission C Phase 15: does the given schema column index have an
2083    /// index attached? Used by the executor's update fast-path planner
2084    /// to decide whether a byte-patch update is safe (no index to
2085    /// maintain). Linear scan over `indexed_cols` — typically 1–3
2086    /// entries, so cheaper than a HashMap lookup by name.
2087    #[inline]
2088    pub fn has_indexed_col(&self, col_idx: usize) -> bool {
2089        self.indexed_cols.iter().any(|c| c.col_idx == col_idx)
2090            || self
2091                .expression_indexes
2092                .iter()
2093                .any(|index| index.root_col_idx == col_idx)
2094    }
2095
2096    /// A `is_indexed[col_idx]` mask over all schema columns, for
2097    /// [`crate::row::plan_spill`] so indexed columns are kept inline (see the
2098    /// P2 dangling-index-entry fix). Cheap: one bool vec, a handful of index
2099    /// entries walked.
2100    pub(crate) fn indexed_col_mask(&self) -> Vec<bool> {
2101        let mut mask = vec![false; self.schema.columns.len()];
2102        for entry in &self.indexed_cols {
2103            if entry.col_idx < mask.len() {
2104                mask[entry.col_idx] = true;
2105            }
2106        }
2107        mask
2108    }
2109
2110    /// Heap on-disk format version. `>= HEAP_FORMAT_VERSION_WITH_OVERFLOW` (3)
2111    /// means the table has used overflow pages at least once, so it may hold
2112    /// v2 (spilled) rows. The executor uses this to route such tables away from
2113    /// the v1-only raw-byte fast paths (which cannot correctly read or patch a
2114    /// v2 row) and onto the reassembling decode paths.
2115    #[inline]
2116    pub fn format_version(&self) -> u16 {
2117        self.heap.format_version()
2118    }
2119
2120    /// Whether this table may hold v2 (spilled) rows: true once its heap has
2121    /// ever written an overflow chain. The executor gates the v1-only raw-byte
2122    /// read/patch fast paths on this — a spilled table takes the reassembling
2123    /// decode paths instead (correct for values of any size, including the
2124    /// `>= 64KB` values that cannot be re-inlined into a u16 v1 row).
2125    #[inline]
2126    pub fn has_overflow_rows(&self) -> bool {
2127        self.heap.format_version() >= crate::heap::HEAP_FORMAT_VERSION_WITH_OVERFLOW
2128    }
2129
2130    pub fn scan(&self) -> impl Iterator<Item = (RowId, Row)> + '_ {
2131        self.heap.scan().map(|(rid, data)| {
2132            if crate::row::row_is_v2(&data) {
2133                let row =
2134                    crate::row::decode_row_v2(&self.schema, &self.row_layout, &data, |stub| {
2135                        self.heap.read_overflow_value(stub).map_err(io::Error::from)
2136                    })
2137                    // A corrupt chain during a scan degrades to Empty cells rather
2138                    // than aborting the whole scan; `get` surfaces the typed error.
2139                    .unwrap_or_else(|_| vec![Value::Empty; self.schema.columns.len()]);
2140                (rid, row)
2141            } else {
2142                (rid, decode_row(&self.schema, &data))
2143            }
2144        })
2145    }
2146
2147    /// Zero-copy scan that passes raw row bytes to the callback. v1/v0 rows
2148    /// are handed through untouched (zero copy). A v2 row is reassembled into
2149    /// an equivalent v1 (fully inline) row first — its spilled columns are
2150    /// fetched from the overflow chains — so every downstream consumer
2151    /// (`decode_row`, `decode_column`, compiled predicates) sees a v1 layout
2152    /// and needs no v2 awareness. Only the rare v2 rows pay the reassembly;
2153    /// v1 rows stay on the mmap zero-copy path. A row whose chain is corrupt
2154    /// is skipped (its typed error surfaces via `get`).
2155    pub fn for_each_row_raw<F>(&self, mut f: F)
2156    where
2157        F: FnMut(RowId, &[u8]),
2158    {
2159        let schema = &self.schema;
2160        let layout = &self.row_layout;
2161        let heap = &self.heap;
2162        heap.for_each_row(|rid, data| {
2163            if crate::row::row_is_v2(data) {
2164                if let Ok(v1) = crate::row::rehydrate_v2_to_v1(schema, layout, data, |stub| {
2165                    heap.read_overflow_value(stub).map_err(io::Error::from)
2166                }) {
2167                    f(rid, &v1);
2168                }
2169            } else {
2170                f(rid, data);
2171            }
2172        });
2173    }
2174
2175    /// Zero-copy scan with early termination. The callback returns
2176    /// `ControlFlow::Break(())` to stop. Used by `Limit` fast paths. v2 rows
2177    /// are reassembled to v1 first (see [`Self::for_each_row_raw`]).
2178    pub fn try_for_each_row_raw<F>(&self, mut f: F)
2179    where
2180        F: FnMut(RowId, &[u8]) -> std::ops::ControlFlow<()>,
2181    {
2182        use std::ops::ControlFlow;
2183        let schema = &self.schema;
2184        let layout = &self.row_layout;
2185        let heap = &self.heap;
2186        heap.try_for_each_row(|rid, data| {
2187            if crate::row::row_is_v2(data) {
2188                match crate::row::rehydrate_v2_to_v1(schema, layout, data, |stub| {
2189                    heap.read_overflow_value(stub).map_err(io::Error::from)
2190                }) {
2191                    Ok(v1) => f(rid, &v1),
2192                    Err(_) => ControlFlow::Continue(()),
2193                }
2194            } else {
2195                f(rid, data)
2196            }
2197        });
2198    }
2199
2200    pub fn index_lookup(&self, col_name: &str, key: &Value) -> Option<(RowId, Row)> {
2201        let entry = self.indexed_cols.iter().find(|c| c.col_name == col_name)?;
2202        if entry.unique {
2203            let rid = entry.btree.lookup(key)?;
2204            let row = self.get(rid)?;
2205            Some((rid, row))
2206        } else {
2207            // Non-unique: return the first match (for backwards compat).
2208            let rids = entry.btree.lookup_prefix(key);
2209            let rid = *rids.first()?;
2210            let row = self.get(rid)?;
2211            Some((rid, row))
2212        }
2213    }
2214
2215    /// Look up ALL matching rows for a column value. For unique indexes
2216    /// this returns 0 or 1 results. For non-unique indexes this returns
2217    /// all rows whose indexed column equals `key`.
2218    pub fn index_lookup_all(&self, col_name: &str, key: &Value) -> Vec<RowId> {
2219        let entry = match self.indexed_cols.iter().find(|c| c.col_name == col_name) {
2220            Some(e) => e,
2221            None => return Vec::new(),
2222        };
2223        if entry.unique {
2224            match entry.btree.lookup(key) {
2225                Some(rid) => vec![rid],
2226                None => Vec::new(),
2227            }
2228        } else {
2229            entry.btree.lookup_prefix(key)
2230        }
2231    }
2232
2233    /// Check if an index on the given column is unique.
2234    pub fn is_index_unique(&self, col_name: &str) -> Option<bool> {
2235        self.indexed_cols
2236            .iter()
2237            .find(|c| c.col_name == col_name)
2238            .map(|c| c.unique)
2239    }
2240
2241    /// Create a non-unique secondary index on a column. Duplicate column
2242    /// values are supported via composite keys (column_value, RowId).
2243    pub fn create_index(&mut self, col_name: &str, data_dir: &Path) -> io::Result<()> {
2244        self.create_index_with_unique(col_name, data_dir, false)
2245    }
2246
2247    /// Create an index on a column with an explicit uniqueness flag.
2248    /// `unique = true` creates a traditional unique index where duplicate
2249    /// key inserts overwrite (suitable for primary keys). `unique = false`
2250    /// creates a non-unique secondary index using composite keys.
2251    pub fn create_index_with_unique(
2252        &mut self,
2253        col_name: &str,
2254        data_dir: &Path,
2255        unique: bool,
2256    ) -> io::Result<()> {
2257        let col_idx = self
2258            .schema
2259            .column_index(col_name)
2260            .ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "column not found"))?;
2261
2262        // Mission C Phase 17: if this column already has an index,
2263        // no-op (matches the prior map.insert semantics of silently
2264        // replacing a duplicate, minus the wasted work).
2265        if self.indexed_cols.iter().any(|c| c.col_idx == col_idx) {
2266            return Ok(());
2267        }
2268
2269        let idx_path = data_dir.join(format!("{}_{}.idx", self.schema.table_name, col_name));
2270        // Non-unique indexes use v3 NUL-escaped composite Str keys even when the
2271        // table is empty; unique indexes stay v1.
2272        let mut btree = if unique {
2273            BTree::create(&idx_path)?
2274        } else {
2275            BTree::create_non_unique(&idx_path)?
2276        };
2277
2278        // Build index from existing data.
2279        for (rid, row) in self.scan() {
2280            if !row[col_idx].is_empty() {
2281                if unique {
2282                    btree.insert(row[col_idx].clone(), rid);
2283                } else {
2284                    btree.insert_non_unique(row[col_idx].clone(), rid);
2285                }
2286            }
2287        }
2288
2289        // Mission 3: persist the freshly-built index so it survives a
2290        // restart. `BTree::create` stashed the path inside the tree, so
2291        // `save()` writes to the right place. Subsequent inserts / updates
2292        // / deletes will re-save after each mutation (see `save_if_touched`).
2293        btree.save()?;
2294
2295        // Mission C Phase 17: store the btree inline alongside the
2296        // cached col_idx / col_name / is_int metadata — single tight
2297        // entry per index, walked directly by the hot write paths.
2298        let is_int = self.schema.columns[col_idx].type_id == TypeId::Int;
2299        self.indexed_cols.push(IndexedCol {
2300            col_idx,
2301            col_name: col_name.to_string(),
2302            is_int,
2303            unique,
2304            btree,
2305        });
2306        Ok(())
2307    }
2308}
2309
2310#[cfg(test)]
2311mod projected_tests {
2312    use super::*;
2313
2314    fn projected_table() -> (tempfile::TempDir, Table, RowId, Vec<Value>) {
2315        let dir = tempfile::tempdir().expect("tempdir");
2316        let schema = Schema {
2317            table_name: "Projected".into(),
2318            columns: vec![
2319                ColumnDef {
2320                    name: "id".into(),
2321                    type_id: TypeId::Int,
2322                    required: true,
2323                    position: 0,
2324                },
2325                ColumnDef {
2326                    name: "document".into(),
2327                    type_id: TypeId::Json,
2328                    required: true,
2329                    position: 1,
2330                },
2331                ColumnDef {
2332                    name: "payload".into(),
2333                    type_id: TypeId::Bytes,
2334                    required: true,
2335                    position: 2,
2336                },
2337            ],
2338        };
2339        let mut table = Table::create(schema, dir.path()).expect("create table");
2340        let json_text = format!(r#"{{"payload":"{}"}}"#, "j".repeat(9_000));
2341        let json = crate::pj1::parse_json_text(&json_text).expect("valid JSON");
2342        let row = vec![
2343            Value::Int(7),
2344            Value::Json(json.into_boxed_slice()),
2345            Value::Bytes(vec![0xA5; 9_000]),
2346        ];
2347        let rid = table.insert(&row).expect("insert spilled row");
2348        let raw = table.heap.get(rid).expect("raw row");
2349        assert!(crate::row::row_is_v2(&raw));
2350        assert!(crate::row::raw_stub(&table.schema, table.row_layout(), &raw, 1).is_some());
2351        assert!(crate::row::raw_stub(&table.schema, table.row_layout(), &raw, 2).is_some());
2352        (dir, table, rid, row)
2353    }
2354
2355    #[test]
2356    fn projected_read_preserves_order_duplicates_and_spilled_values() {
2357        let (_dir, table, rid, row) = projected_table();
2358        let projected = table
2359            .get_projected(rid, &[2, 0, 1, 2])
2360            .expect("projected read")
2361            .expect("row exists");
2362        assert_eq!(
2363            projected,
2364            vec![
2365                row[2].clone(),
2366                row[0].clone(),
2367                row[1].clone(),
2368                row[2].clone()
2369            ]
2370        );
2371        assert_eq!(
2372            table.get_projected(rid, &[]).expect("empty projection"),
2373            Some(Vec::new())
2374        );
2375        assert_eq!(
2376            table
2377                .get_projected(
2378                    RowId {
2379                        page_id: u32::MAX,
2380                        slot_index: u16::MAX,
2381                    },
2382                    &[0],
2383                )
2384                .expect("missing RID"),
2385            None
2386        );
2387        assert!(table.get_projected(rid, &[3]).is_err());
2388    }
2389
2390    #[test]
2391    fn projected_read_ignores_unselected_corrupt_spill() {
2392        let (_dir, mut table, rid, row) = projected_table();
2393        let raw = table.heap.get(rid).expect("raw row");
2394        let document_stub = crate::row::raw_stub(&table.schema, table.row_layout(), &raw, 1)
2395            .expect("document stub");
2396        table
2397            .heap
2398            .write_overflow_page(document_stub.first_page, OVERFLOW_CHAIN_END, b"corrupt", 0)
2399            .expect("corrupt selected chain deterministically");
2400
2401        let healthy_projection = table
2402            .get_projected(rid, &[0, 2])
2403            .expect("unselected corruption must be untouched")
2404            .expect("row exists");
2405        assert_eq!(healthy_projection, vec![row[0].clone(), row[2].clone()]);
2406
2407        let error = table
2408            .get_projected(rid, &[1])
2409            .expect_err("selected corrupt chain must fail");
2410        assert!(error.to_string().contains("overflow value length"));
2411    }
2412}
2413
2414#[cfg(test)]
2415mod nul_migration_tests {
2416    use super::*;
2417    use crate::btree::{BTREE_VERSION, LEGACY_BTREE_VERSION};
2418    use crate::catalog::IndexedColMeta;
2419
2420    fn schema() -> Schema {
2421        Schema {
2422            table_name: "T".into(),
2423            columns: vec![
2424                ColumnDef {
2425                    name: "id".into(),
2426                    type_id: TypeId::Int,
2427                    required: true,
2428                    position: 0,
2429                },
2430                ColumnDef {
2431                    name: "name".into(),
2432                    type_id: TypeId::Str,
2433                    required: false,
2434                    position: 1,
2435                },
2436            ],
2437        }
2438    }
2439
2440    fn row(id: i64, name: &str) -> Row {
2441        vec![Value::Int(id), Value::Str(name.into())]
2442    }
2443
2444    /// Write an OLD-format (v1, unescaped bare-0x00 terminator) non-unique
2445    /// composite index file matching the given (value, rid) pairs. This is the
2446    /// pre-v0.16 on-disk shape that the migration path must never serve.
2447    fn write_old_format_index(path: &std::path::Path, pairs: &[(&str, RowId)]) {
2448        let mut bt = BTree::create(path).unwrap(); // v1, Raw
2449        let str_tag = Value::Str(String::new()).type_id() as u8;
2450        for (val, rid) in pairs {
2451            let mut buf = Vec::new();
2452            buf.push(str_tag);
2453            buf.extend_from_slice(val.as_bytes());
2454            buf.push(0); // OLD single bare terminator (the buggy encoding)
2455            let rid_bits = ((rid.page_id as u64) << 16) | rid.slot_index as u64;
2456            buf.extend_from_slice(&rid_bits.to_be_bytes());
2457            bt.insert(Value::Bytes(buf), *rid);
2458        }
2459        // Never touched insert_non_unique, so it stays v1.
2460        assert_eq!(bt.format_version(), LEGACY_BTREE_VERSION);
2461        bt.save_to(path).unwrap();
2462    }
2463
2464    #[test]
2465    fn old_nonunique_index_rebuilt_to_v3_and_serves_correct_rows() {
2466        let dir = tempfile::tempdir().unwrap();
2467        let mut table = Table::create(schema(), dir.path()).unwrap();
2468        let r1 = table.insert(&row(1, "A")).unwrap();
2469        let r2 = table.insert(&row(2, "A\0")).unwrap();
2470        let r3 = table.insert(&row(3, "A")).unwrap();
2471        let r4 = table.insert(&row(4, "B")).unwrap();
2472        drop(table);
2473
2474        let idx_path = dir.path().join("T_name.idx");
2475        write_old_format_index(&idx_path, &[("A", r1), ("A\0", r2), ("A", r3), ("B", r4)]);
2476
2477        // The old file genuinely exhibits the bug it is meant to reproduce:
2478        // "A" over-matches the "A\0" row.
2479        {
2480            let mut old = BTree::load(&idx_path).unwrap();
2481            old.mark_composite();
2482            assert!(
2483                old.lookup_prefix(&Value::Str("A".into())).contains(&r2),
2484                "old-format file must exhibit the wrong-rows bug"
2485            );
2486        }
2487
2488        // Reopen with the catalog claiming a non-unique index on "name".
2489        let metas = vec![IndexedColMeta {
2490            name: "name".into(),
2491            unique: false,
2492        }];
2493        let reopened = Table::open_with_indexes(schema(), dir.path(), &metas, &[]).unwrap();
2494        let idx = reopened.index("name").expect("index present");
2495        assert_eq!(
2496            idx.format_version(),
2497            BTREE_VERSION,
2498            "old non-unique index rebuilt to v3 on open"
2499        );
2500
2501        let mut a = idx.lookup_prefix(&Value::Str("A".into()));
2502        a.sort_by_key(|r| (r.page_id, r.slot_index));
2503        let mut want = vec![r1, r3];
2504        want.sort_by_key(|r| (r.page_id, r.slot_index));
2505        assert_eq!(a, want, "\"A\" serves only its own rows after rebuild");
2506        assert_eq!(idx.lookup_prefix(&Value::Str("A\0".into())), vec![r2]);
2507        assert_eq!(idx.lookup_prefix(&Value::Str("B".into())), vec![r4]);
2508
2509        // The rebuild was persisted (writable open), so the on-disk file is v3.
2510        assert_eq!(
2511            BTree::load(&idx_path).unwrap().format_version(),
2512            BTREE_VERSION,
2513            "rebuilt v3 index saved back to disk"
2514        );
2515    }
2516
2517    #[test]
2518    fn old_unique_index_loads_without_rebuild() {
2519        let dir = tempfile::tempdir().unwrap();
2520        let mut table = Table::create(schema(), dir.path()).unwrap();
2521        table.insert(&row(1, "A")).unwrap();
2522        table.insert(&row(2, "B")).unwrap();
2523        // A real v1 unique index (unique indexes never used composite Str keys).
2524        table
2525            .create_index_with_unique("name", dir.path(), true)
2526            .unwrap();
2527        drop(table);
2528
2529        let idx_path = dir.path().join("T_name.idx");
2530        assert_eq!(
2531            BTree::load(&idx_path).unwrap().format_version(),
2532            LEGACY_BTREE_VERSION,
2533            "unique index is v1 on disk"
2534        );
2535
2536        let metas = vec![IndexedColMeta {
2537            name: "name".into(),
2538            unique: true,
2539        }];
2540        let reopened = Table::open_with_indexes(schema(), dir.path(), &metas, &[]).unwrap();
2541        let idx = reopened.index("name").expect("index present");
2542        assert_eq!(
2543            idx.format_version(),
2544            LEGACY_BTREE_VERSION,
2545            "unique index must NOT be rebuilt or version-bumped on open"
2546        );
2547        assert!(
2548            idx.lookup(&Value::Str("A".into())).is_some(),
2549            "unique index still serves its keys"
2550        );
2551        // File on disk still v1: untouched.
2552        assert_eq!(
2553            BTree::load(&idx_path).unwrap().format_version(),
2554            LEGACY_BTREE_VERSION,
2555            "unique index file unchanged on disk"
2556        );
2557    }
2558}