Skip to main content

spg_storage/
lib.rs

1//! In-memory storage primitives.
2//!
3//! v0.3 is intentionally simple: a flat catalog of tables, each holding rows
4//! as `Vec<Value>` (positional, matching the table's `TableSchema`). No MVCC,
5//! no on-disk format — those land in later milestones.
6#![no_std]
7// v3.3.2 NEON path for l2_distance_sq (aarch64 only). Scoped allow:
8// `unsafe_code = "deny"` at workspace level stays in force for every
9// other crate.
10#![cfg_attr(target_arch = "aarch64", allow(unsafe_code))]
11
12extern crate alloc;
13
14pub mod bloom;
15mod codec;
16pub mod fts_simple;
17pub mod halfvec;
18pub mod jsonb_gin;
19mod nsw;
20pub mod persistent;
21pub mod persistent_btree;
22pub mod quantize;
23pub mod row_locator;
24pub mod segment;
25mod table;
26pub mod trgm;
27
28pub use self::bloom::{BloomError, BloomFilter};
29// v7.31 monster tier-3 cut 3 — on-disk codec moved to `codec`; the
30// public dense-row surface keeps its `spg_storage::*` paths, and the
31// low-level write/read primitives stay crate-visible for the
32// `Catalog::serialize`/`deserialize` methods that remain in this file.
33pub(crate) use self::codec::*;
34pub use self::codec::{decode_row_body_dense, encode_row_body_dense, row_body_encoded_len};
35// v7.31 monster tier-3 cut 2 — HNSW algorithms moved to `nsw`; the
36// public vector-search surface keeps its `spg_storage::*` paths via
37// these re-exports, and `nsw_insert_at` stays crate-visible for the
38// `Table` insert paths in the `table` module.
39pub(crate) use self::nsw::nsw_insert_at;
40pub use self::nsw::{NswMetric, cosine_dot_norms_f32, inner_product_f32, nsw_index_on, nsw_query};
41pub use self::row_locator::{RowLocator, RowLocatorError};
42pub use self::segment::{
43    BRIN_SIDECAR_MAGIC, BrinSummary, OwnedSegment, SEGMENT_COMPRESS_ALGO_LZSS,
44    SEGMENT_COMPRESS_ALGO_NONE, SEGMENT_MAGIC, SEGMENT_MAGIC_V2, SEGMENT_PAGE_BYTES, SegmentError,
45    SegmentMeta, SegmentReader, derive_brin_summaries, encode_segment, wrap_v2_envelope,
46    wrap_v2_envelope_with_brin,
47};
48
49use alloc::borrow::Cow;
50use alloc::boxed::Box;
51use alloc::collections::{BTreeMap, BTreeSet};
52use alloc::format;
53use alloc::string::{String, ToString};
54use alloc::sync::Arc;
55use alloc::vec::Vec;
56use core::fmt;
57
58use self::persistent::PersistentVec;
59use self::persistent_btree::PersistentBTreeMap;
60
61/// In-cell encoding for `DataType::Vector`. Mirrors
62/// `spg_sql::ast::VecEncoding` — kept here so storage stays
63/// dep-free of `spg-sql`. The engine bridges between the two
64/// at DDL-execution time.
65///
66/// `F32` is the pre-v6 default: each cell holds a raw `Vec<f32>`.
67/// `Sq8` (v6.0.1) stores `Sq8Vector { min, max, bytes: Vec<u8> }`
68/// per cell; 4× compression vs `F32` with recall@10 ≥ 0.95 on
69/// natural embeddings (Gaussian / unit-sphere corpora).
70/// `F16` (v6.0.3, DDL keyword `HALF`) stores each element as
71/// IEEE-754 binary16; 2× compression and bit-exact dequantise.
72#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
73pub enum VecEncoding {
74    #[default]
75    F32,
76    Sq8,
77    F16,
78}
79
80impl fmt::Display for VecEncoding {
81    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
82        match self {
83            Self::F32 => f.write_str("F32"),
84            Self::Sq8 => f.write_str("SQ8"),
85            Self::F16 => f.write_str("HALF"),
86        }
87    }
88}
89
90/// Runtime type tags. `Vector { dim, encoding }` / `Varchar(max)` /
91/// `Char(size)` are parameterised; the parameter travels with both
92/// the column schema and the on-wire serialised representation.
93#[derive(Debug, Clone, Copy, PartialEq, Eq)]
94pub enum DataType {
95    /// 16-bit signed. Backed by `Value::SmallInt(i16)`; arithmetic that
96    /// would overflow surfaces as a type error at INSERT time.
97    SmallInt,
98    Int,    // 32-bit signed
99    BigInt, // 64-bit signed
100    Float,  // f64 (PG double precision)
101    Text,
102    /// `VARCHAR(n)` — same byte representation as `Text`, but INSERT
103    /// rejects values longer than `n` Unicode characters.
104    Varchar(u32),
105    /// `CHAR(n)` — same representation as `Text`, but INSERT right-pads
106    /// with U+0020 to exactly `n` Unicode characters (or rejects when
107    /// the input is already longer).
108    Char(u32),
109    Bool,
110    /// pgvector-style fixed-dimension vector. `encoding` selects
111    /// the in-cell representation (`F32` = pre-v6 raw f32 buffer;
112    /// `Sq8` = v6.0.1 8-bit scalar-quantised). The DDL grammar
113    /// surfaces encoding via the optional `USING <encoding>`
114    /// clause: `VECTOR(128) USING SQ8`.
115    Vector {
116        dim: u32,
117        encoding: VecEncoding,
118    },
119    /// `NUMERIC(precision, scale)` — exact fixed-point decimal stored as
120    /// a scaled `i128`. `precision` caps total decimal digits, `scale`
121    /// fixes digits after the decimal point. v1.12 supports up to
122    /// precision 38 (the i128-safe ceiling). `NUMERIC` and `NUMERIC(p)`
123    /// surface as `Numeric { precision: p, scale: 0 }`.
124    Numeric {
125        precision: u8,
126        scale: u8,
127    },
128    /// `DATE` — calendar date with day precision, stored as `i32` days
129    /// since the Unix epoch (1970-01-01).
130    Date,
131    /// `TIMESTAMP` (a.k.a. `MySQL` `DATETIME`) — instant with microsecond
132    /// precision, stored as `i64` microseconds since the Unix epoch.
133    Timestamp,
134    /// v7.9.2 `TIMESTAMPTZ` — bit-identical to `Timestamp` on disk
135    /// (i64 microseconds, UTC by convention). Carried as a distinct
136    /// type tag so the PG-wire layer can advertise OID 1184 (PG's
137    /// `timestamp with time zone`) and `sqlx`/`pgx`/JDBC clients
138    /// decode into their TZ-aware datetime types. The internal
139    /// semantics are unchanged: SPG never stored per-row offsets,
140    /// and neither did PG — `TIMESTAMPTZ` in PG is also UTC i64.
141    Timestamptz,
142    /// `INTERVAL` — calendar-aware span (months + microseconds). v2.11
143    /// supports INTERVAL only as a runtime intermediate (literals,
144    /// arithmetic results); on-disk encoding is rejected so this branch
145    /// can't appear in a `ColumnSchema`.
146    Interval,
147    /// v4.9: `JSON` — text-backed JSON document. We don't parse
148    /// the content (no path operators or jsonb functions yet) —
149    /// the column accepts any TEXT-compatible value and round-trips
150    /// it verbatim. PG OID 114 on the wire.
151    Json,
152    /// v7.9.0: `JSONB` — semantically identical to `Json` on
153    /// the storage side (same `Value::Json` cells, same
154    /// row codec), but advertised as PG OID 3802 on the wire
155    /// so `sqlx`-style clients that bind `jsonb` columns
156    /// decode correctly. mailrs migration blocker #3.
157    Jsonb,
158    /// v7.10.4: `BYTES` / `BYTEA` — variable-length raw binary.
159    /// Backed by `Value::Bytes(Vec<u8>)`. PG wire OID 17. Literal
160    /// forms accepted by parser/engine: PG hex form `'\xDEADBEEF'`
161    /// (case-insensitive hex pairs) and escape form
162    /// `'foo\\000bar'` (the latter decoded at coercion time when
163    /// the target column is BYTEA — TEXT columns leave the
164    /// backslash sequence verbatim).
165    Bytes,
166    /// v7.10.9: `TEXT[]` — single-dimension TEXT array. Elements
167    /// may be NULL (PG semantics). PG wire OID 1009. Literal
168    /// forms: `ARRAY['a', 'b', NULL]` and the PG external form
169    /// `'{a,b,NULL}'::TEXT[]`. Engine implements `= ANY(arr)`,
170    /// `<> ALL(arr)`, and 1-based indexing `arr[i]`. Catalog
171    /// FILE_VERSION 18+; older snapshots reject this DataType
172    /// (forward-only by design — TEXT[] columns aren't readable
173    /// on a pre-v7.10 binary).
174    TextArray,
175    /// v7.11.12: `INT[]` — single-dimension i32 array. PG wire
176    /// OID 1007 (_int4). Same `ARRAY[...]` / `'{1,2,3}'::INT[]`
177    /// literal surface as TEXT[]. Catalog FILE_VERSION 19+.
178    IntArray,
179    /// v7.11.12: `BIGINT[]` — single-dimension i64 array. PG
180    /// wire OID 1016 (_int8). Catalog FILE_VERSION 19+.
181    BigIntArray,
182    /// v7.37.5 β-P4 — `INTERVAL[]` — single-dimension array of
183    /// `IntervalSpan { months, days, micros }`. PG wire OID 1187
184    /// (`_interval`). Catalog tag 35 + per-cell body
185    /// `[u16 count][per elem: u8 null + (if non-null) 16-byte
186    /// interval body in LE PG-byte-equal field order]`.
187    /// FILE_VERSION 48+.
188    IntervalArray,
189    /// v7.37.5 γ — full PG array-of-scalar family. Catalog tags
190    /// 36..48; wire OIDs from PG `pg_type.dat`. Per-element body
191    /// uses the scalar's existing `write_value_body` shape.
192    /// FILE_VERSION 48+ (same window as β; no separate bump).
193    BoolArray, // PG `_bool`        OID 1000, tag 36
194    SmallIntArray,    // PG `_int2`        OID 1005, tag 37
195    FloatArray,       // PG `_float8`      OID 1022, tag 38
196    NumericArray,     // PG `_numeric`     OID 1231, tag 39
197    DateArray,        // PG `_date`        OID 1182, tag 40
198    TimestampArray,   // PG `_timestamp`   OID 1115, tag 41
199    TimestamptzArray, // PG `_timestamptz` OID 1185, tag 42
200    UuidArray,        // PG `_uuid`        OID 2951, tag 43
201    JsonArray,        // PG `_json`        OID 199,  tag 44
202    JsonbArray,       // PG `_jsonb`       OID 3807, tag 45
203    BytesArray,       // PG `_bytea`       OID 1001, tag 46
204    VarcharArray,     // PG `_varchar`     OID 1015, tag 47
205    CharArray,        // PG `_bpchar`      OID 1014, tag 48
206    /// v7.37.5 δ — PG 14+ multirange types. A multirange is an
207    /// ordered collection of non-overlapping ranges of the same
208    /// element kind (e.g. `int4multirange(int4range(1,5),
209    /// int4range(10,15))` → `{[1,5),[10,15)}`). The same DataType
210    /// variant covers all six builtin multiranges; `RangeKind`
211    /// pins the element type so encode/decode/display can route
212    /// off one switch (parallel to `Range(RangeKind)`).
213    /// Wire OIDs: int4multirange=4451, int8multirange=4537,
214    /// nummultirange=4536, tsmultirange=4533, tstzmultirange=4534,
215    /// datemultirange=4535. Catalog tag 49 + 1-byte RangeKind on
216    /// the dense type-tag side. FILE_VERSION 48+ (same window as
217    /// β/γ, no separate bump).
218    Multirange(RangeKind),
219    /// v7.37.5 ε — PG geometry scalar family. Mirrors PG's seven
220    /// builtin geometric types one-for-one. Body shapes (LE):
221    ///   Point   = 16 B fixed (f64 x + f64 y)            OID 600
222    ///   Lseg    = 32 B fixed (Point p1 + Point p2)      OID 601
223    ///   Path    = varlena ([u8 closed][u32 n][Point*n]) OID 602
224    ///   Box     = 32 B fixed (Point ur + Point ll)      OID 603
225    ///   Polygon = varlena ([u32 n][Point*n])            OID 604
226    ///   Line    = 24 B fixed (f64 a + f64 b + f64 c)    OID 628
227    ///   Circle  = 24 B fixed (Point center + f64 r)     OID 718
228    /// Catalog tags 50..56. FILE_VERSION 48+ (same window as β/γ/δ;
229    /// no separate bump). Geometric operators (`<->` / `@>` / `&&`
230    /// / `<<` / `>>` / `~=`) are a planner-integration follow-up,
231    /// parallel to the Range operator defer in e2e_pg_range.rs.
232    Point,
233    Lseg,
234    Path,
235    PgBox,
236    Polygon,
237    Line,
238    Circle,
239    /// v7.37.5 ζ-A — PG network address family. Body shapes (LE):
240    ///   Inet     = 18 B fixed (u8 family + u8 bits + 16 B addr)  OID 869
241    ///   Cidr     = 18 B fixed (same shape as Inet; CIDR rejects
242    ///                          host bits at parse / coerce)       OID 650
243    ///   Macaddr  = 6 B fixed                                      OID 829
244    ///   Macaddr8 = 8 B fixed (EUI-64)                             OID 774
245    /// Catalog tags 57-60. FILE_VERSION 48+. `family = 4` is IPv4
246    /// (uses the first 4 bytes of the 16-B addr slot, rest 0);
247    /// `family = 6` is IPv6 (full 16 B).
248    Inet,
249    Cidr,
250    Macaddr,
251    Macaddr8,
252    /// v7.37.5 ζ-A — PG bit string. Body = `[u32 nbits][ceil(nbits/8) bytes]`,
253    /// big-endian within each byte (matches PG binary).
254    ///   Bit         OID 1560 (fixed-length, but SPG carries the
255    ///                         length per cell — column declaration
256    ///                         `BIT(n)` constrains at coerce time)
257    ///   BitVarying  OID 1562 (variable-length, declared as `VARBIT`)
258    /// Catalog tags 61-62.
259    Bit,
260    BitVarying,
261    /// v7.37.5 ζ-A — PG `xml`. Body identical to TEXT (storage is
262    /// the verbatim XML string; no parse-time validation). Only
263    /// the wire OID (142) differs. Catalog tag 63.
264    Xml,
265    /// v7.37.5 ζ-A — PG `"char"` (the internal single-byte type,
266    /// distinct from `CHAR(n)` / `BPCHAR`). Body = 1 byte raw.
267    /// OID 18. Catalog tag 64.
268    Char1,
269    /// v7.37.5 ζ-A — `MONEY[]`. Body = `[u16 count][per elem: u8 null
270    /// + (non-null) i64 LE cents]`. OID 791. Catalog tag 65.
271    MoneyArray,
272    /// v7.12.0: PG `tsvector` — ordered, deduplicated set of
273    /// `(lexeme, positions, weight)` tuples. PG wire OID 3614.
274    /// Catalog FILE_VERSION 20+. Storage shape is row-codec
275    /// tag 22; the schema-agnostic `write_value` path emits tag
276    /// 18. Literal: `'foo:1 bar:2,3'::tsvector` (PG external
277    /// form). G-CRIT-3 entry — v7.12.0 only ships the type +
278    /// codec; matching `@@` lands in v7.12.2.
279    TsVector,
280    /// v7.12.0: PG `tsquery` — parse tree of lexemes joined by
281    /// `&` `|` `!` and phrase operators. PG wire OID 3615.
282    /// Catalog FILE_VERSION 20+.
283    TsQuery,
284    /// v7.17.0: PG `uuid` — 128-bit identifier stored as
285    /// `Value::Uuid([u8; 16])`. PG wire OID 2950. Canonical
286    /// text form is lowercase 8-4-4-4-12 hyphenated; input
287    /// also accepts uppercase, unhyphenated, and brace-wrapped
288    /// forms (`{xxxx…}`). Catalog FILE_VERSION 36+; tag 24 on
289    /// the dense type-tag side, tag 20 on the schema-agnostic
290    /// value side. The drop-in PG/MySQL surface for Django /
291    /// Rails / Hibernate "id UUID PRIMARY KEY DEFAULT
292    /// gen_random_uuid()" default-PK pattern.
293    Uuid,
294    /// v7.17.0 Phase 3.P0-32: PG `time` (without time zone) — i64
295    /// microseconds since 00:00:00. PG wire OID 1083. Display:
296    /// canonical zero-padded `HH:MM:SS` when fractional is zero,
297    /// `HH:MM:SS.ffffff` otherwise. Catalog FILE_VERSION 37+;
298    /// tag 25 on the dense type-tag side, tag 21 on the schema-
299    /// agnostic value side. The wall-clock-of-day half of PG's
300    /// date/time triplet (date / time / timestamp).
301    Time,
302    /// v7.17.0 Phase 3.P0-33: MySQL `YEAR` — u16 in range
303    /// 1901..=2155 plus the special zero-year sentinel 0. No
304    /// dedicated PG OID (advertised as INT4 / OID 23 on the wire
305    /// — psql renders integers, MySQL CLI renders 4-digit
306    /// zero-padded text). Display always 4 digits: `0000` for the
307    /// zero-year, `1985` / `2007` / etc otherwise. Catalog
308    /// FILE_VERSION 38+; tag 26 on the dense type-tag side, tag
309    /// 22 on the schema-agnostic value side.
310    Year,
311    /// v7.17.0 Phase 3.P0-34: PG `time with time zone` (TIMETZ) —
312    /// i64 microseconds since 00:00:00 in the local wall clock
313    /// PLUS i32 offset-from-UTC in seconds. PG wire OID 1266.
314    /// Display: `HH:MM:SS[.ffffff]±HH[:MM]` (PG `timetz_out`).
315    /// Range: offset in ±50400 seconds (±14 hours). Catalog
316    /// FILE_VERSION 39+; tag 27 on the dense type-tag side, tag
317    /// 23 on the schema-agnostic value side.
318    TimeTz,
319    /// v7.17.0 Phase 3.P0-35: PG `money` — i64 cents (locale-
320    /// independent storage). PG wire OID 790. Display: en_US
321    /// locale (`$N,NNN.CC`, negative → `-$1.23`). Input accepts
322    /// `$N.NN`, `$N,NNN.NN`, bare integer (treated as major
323    /// units), optional leading `-`. Range: full i64. Catalog
324    /// FILE_VERSION 40+; tag 28 on the dense type-tag side, tag
325    /// 24 on the schema-agnostic value side.
326    Money,
327    /// v7.17.0 Phase 3.P0-38: PG range type. The same DataType
328    /// variant covers all six builtin ranges (int4range,
329    /// int8range, numrange, tsrange, tstzrange, daterange) —
330    /// `RangeKind` pins the element type so encode / decode /
331    /// display can route off one switch. Catalog FILE_VERSION
332    /// 43+; tag 29 + a 1-byte RangeKind on the dense type-tag
333    /// side, tag 25 on the schema-agnostic value side.
334    Range(RangeKind),
335    /// v7.17.0 Phase 3.P0-39: PG `hstore` extension type — flat
336    /// `text => text` map with NULL value support. Catalog
337    /// FILE_VERSION 44+; tag 30 on the dense type-tag side, tag
338    /// 26 on the schema-agnostic value side. The contrib OID is
339    /// installation-dependent in real PG; SPG advertises it via
340    /// dynamic lookup, falling back to TEXT (OID 25) on the wire
341    /// when the installed `hstore` extension hasn't claimed an
342    /// OID yet.
343    Hstore,
344    /// v7.17.0 Phase 3.P0-40: PG `int[][]` — 2-dimensional INT
345    /// matrix. Storage: row-major Vec<Vec<Option<i32>>>. All
346    /// rows must share the same column count. Wire OID 1007
347    /// (same as INT[]; the dimension count travels in the data
348    /// header, not the OID). Catalog FILE_VERSION 45+; tag 31
349    /// on the dense type-tag side, tag 27 on the schema-agnostic
350    /// value side.
351    IntArray2D,
352    /// v7.17.0 Phase 3.P0-40: PG `bigint[][]` — 2-dimensional
353    /// BIGINT matrix. Storage / OID / tags mirror IntArray2D.
354    /// Tag 32 dense, tag 28 schema-agnostic.
355    BigIntArray2D,
356    /// v7.17.0 Phase 3.P0-40: PG `text[][]` — 2-dimensional TEXT
357    /// matrix. Storage: row-major Vec<Vec<Option<String>>>.
358    /// Tag 33 dense, tag 29 schema-agnostic.
359    TextArray2D,
360}
361
362/// v7.17.0 Phase 3.P0-38 — pins the element type of a range value
363/// or column. Wire OIDs: Int4=3904, Int8=3926, Num=3906,
364/// Ts=3908, TsTz=3910, Date=3912.
365#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
366pub enum RangeKind {
367    Int4,
368    Int8,
369    Num,
370    Ts,
371    TsTz,
372    Date,
373}
374
375impl RangeKind {
376    pub const fn tag(self) -> u8 {
377        match self {
378            Self::Int4 => 0,
379            Self::Int8 => 1,
380            Self::Num => 2,
381            Self::Ts => 3,
382            Self::TsTz => 4,
383            Self::Date => 5,
384        }
385    }
386    pub const fn from_tag(t: u8) -> Option<Self> {
387        Some(match t {
388            0 => Self::Int4,
389            1 => Self::Int8,
390            2 => Self::Num,
391            3 => Self::Ts,
392            4 => Self::TsTz,
393            5 => Self::Date,
394            _ => return None,
395        })
396    }
397    pub const fn keyword(self) -> &'static str {
398        match self {
399            Self::Int4 => "INT4RANGE",
400            Self::Int8 => "INT8RANGE",
401            Self::Num => "NUMRANGE",
402            Self::Ts => "TSRANGE",
403            Self::TsTz => "TSTZRANGE",
404            Self::Date => "DATERANGE",
405        }
406    }
407}
408
409impl fmt::Display for DataType {
410    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
411        match self {
412            Self::SmallInt => f.write_str("SMALLINT"),
413            Self::Int => f.write_str("INT"),
414            Self::BigInt => f.write_str("BIGINT"),
415            Self::Float => f.write_str("FLOAT"),
416            Self::Text => f.write_str("TEXT"),
417            Self::Varchar(n) => write!(f, "VARCHAR({n})"),
418            Self::Char(n) => write!(f, "CHAR({n})"),
419            Self::Bool => f.write_str("BOOL"),
420            Self::Vector { dim, encoding } => match encoding {
421                VecEncoding::F32 => write!(f, "VECTOR({dim})"),
422                VecEncoding::Sq8 => write!(f, "VECTOR({dim}) USING SQ8"),
423                VecEncoding::F16 => write!(f, "VECTOR({dim}) USING HALF"),
424            },
425            Self::Numeric { precision, scale } => {
426                if *scale == 0 {
427                    write!(f, "NUMERIC({precision})")
428                } else {
429                    write!(f, "NUMERIC({precision}, {scale})")
430                }
431            }
432            Self::Date => f.write_str("DATE"),
433            Self::Timestamp => f.write_str("TIMESTAMP"),
434            Self::Timestamptz => f.write_str("TIMESTAMPTZ"),
435            Self::Interval => f.write_str("INTERVAL"),
436            Self::Json => f.write_str("JSON"),
437            Self::Jsonb => f.write_str("JSONB"),
438            Self::Bytes => f.write_str("BYTEA"),
439            Self::TextArray => f.write_str("TEXT[]"),
440            Self::IntArray => f.write_str("INT[]"),
441            Self::BigIntArray => f.write_str("BIGINT[]"),
442            Self::IntervalArray => f.write_str("INTERVAL[]"),
443            Self::BoolArray => f.write_str("BOOL[]"),
444            Self::SmallIntArray => f.write_str("SMALLINT[]"),
445            Self::FloatArray => f.write_str("FLOAT[]"),
446            Self::NumericArray => f.write_str("NUMERIC[]"),
447            Self::DateArray => f.write_str("DATE[]"),
448            Self::TimestampArray => f.write_str("TIMESTAMP[]"),
449            Self::TimestamptzArray => f.write_str("TIMESTAMPTZ[]"),
450            Self::UuidArray => f.write_str("UUID[]"),
451            Self::JsonArray => f.write_str("JSON[]"),
452            Self::JsonbArray => f.write_str("JSONB[]"),
453            Self::BytesArray => f.write_str("BYTEA[]"),
454            Self::VarcharArray => f.write_str("VARCHAR[]"),
455            Self::CharArray => f.write_str("CHAR[]"),
456            Self::Multirange(k) => f.write_str(match k {
457                RangeKind::Int4 => "INT4MULTIRANGE",
458                RangeKind::Int8 => "INT8MULTIRANGE",
459                RangeKind::Num => "NUMMULTIRANGE",
460                RangeKind::Ts => "TSMULTIRANGE",
461                RangeKind::TsTz => "TSTZMULTIRANGE",
462                RangeKind::Date => "DATEMULTIRANGE",
463            }),
464            Self::Point => f.write_str("POINT"),
465            Self::Lseg => f.write_str("LSEG"),
466            Self::Path => f.write_str("PATH"),
467            Self::PgBox => f.write_str("BOX"),
468            Self::Polygon => f.write_str("POLYGON"),
469            Self::Line => f.write_str("LINE"),
470            Self::Circle => f.write_str("CIRCLE"),
471            Self::Inet => f.write_str("INET"),
472            Self::Cidr => f.write_str("CIDR"),
473            Self::Macaddr => f.write_str("MACADDR"),
474            Self::Macaddr8 => f.write_str("MACADDR8"),
475            Self::Bit => f.write_str("BIT"),
476            Self::BitVarying => f.write_str("VARBIT"),
477            Self::Xml => f.write_str("XML"),
478            Self::Char1 => f.write_str("\"char\""),
479            Self::MoneyArray => f.write_str("MONEY[]"),
480            Self::TsVector => f.write_str("TSVECTOR"),
481            Self::TsQuery => f.write_str("TSQUERY"),
482            Self::Uuid => f.write_str("UUID"),
483            Self::Time => f.write_str("TIME"),
484            Self::Year => f.write_str("YEAR"),
485            Self::TimeTz => f.write_str("TIMETZ"),
486            Self::Money => f.write_str("MONEY"),
487            Self::Range(k) => f.write_str(k.keyword()),
488            Self::Hstore => f.write_str("HSTORE"),
489            Self::IntArray2D => f.write_str("INT[][]"),
490            Self::BigIntArray2D => f.write_str("BIGINT[][]"),
491            Self::TextArray2D => f.write_str("TEXT[][]"),
492        }
493    }
494}
495
496/// v7.12.0 — one entry in a `Value::TsVector`. The lexeme is the
497/// (already-tokenised + stemmed in v7.12.1+) word; `positions` is
498/// a strictly-ascending list of 1-based positions; `weight` is the
499/// PG weight letter (A=3, B=2, C=1, D=0) — v7.12.0 defaults every
500/// lexeme to D, the v7.12.2 ranking path consumes the weight.
501#[derive(Debug, Clone, PartialEq, Eq)]
502pub struct TsLexeme {
503    pub word: String,
504    pub positions: Vec<u16>,
505    pub weight: u8,
506}
507
508/// v7.12.0 — parse tree for a PG `tsquery`. v7.12.0 ships the
509/// type + codec only; the `to_tsquery` / `plainto_tsquery` lexer
510/// lands in v7.12.1 and the `@@` evaluator in v7.12.2.
511#[derive(Debug, Clone, PartialEq, Eq)]
512pub enum TsQueryAst {
513    /// Single lexeme term. The `weight_mask` is the PG-style
514    /// bitmask of accepted weights (`A=1<<3`, `B=1<<2`, `C=1<<1`,
515    /// `D=1<<0`); `0` = any weight. v7.12.0 always sets it to 0.
516    Term {
517        word: String,
518        weight_mask: u8,
519    },
520    And(Box<TsQueryAst>, Box<TsQueryAst>),
521    Or(Box<TsQueryAst>, Box<TsQueryAst>),
522    Not(Box<TsQueryAst>),
523    /// `phrase <distance> phrase`. v7.12.0 only persists this; the
524    /// match semantics arrive in v7.12.2 alongside `@@`.
525    Phrase {
526        left: Box<TsQueryAst>,
527        right: Box<TsQueryAst>,
528        distance: u16,
529    },
530}
531
532/// A row-cell value, including SQL `NULL`. `Float` uses `f64`; NaN compares
533/// non-equal to itself (PG behaviour) — `PartialEq` is derived so callers
534/// must opt into NaN-aware comparison if they need stronger guarantees.
535///
536/// v7.37.42-arena Phase 1: parameterised on `'arena` so heap-bearing
537/// variants (Text/Json/Xml/Bytes/Vector/BitString.bytes) can borrow from
538/// a per-query bump arena (`Cow::Borrowed(&'arena ...)`). Persistent /
539/// catalog Values use `Value<'static>` (alias `ValueOwned`) with
540/// `Cow::Owned(...)`. Phase 1 keeps Range/Multirange recursive `Box<Value>`
541/// at `'static` (owned) — arena migration deferred to a later phase.
542/// Array-of-Option<String> variants (TextArray etc.) also stay owned in
543/// Phase 1; their nested shape is awkward for the simple Cow lift and the
544/// SCALARSQ hot path doesn't touch them.
545#[derive(Debug, Clone, PartialEq)]
546#[non_exhaustive]
547pub enum Value<'arena> {
548    SmallInt(i16),
549    Int(i32),
550    BigInt(i64),
551    Float(f64),
552    Text(Cow<'arena, str>),
553    Bool(bool),
554    Vector(Cow<'arena, [f32]>),
555    /// v6.0.1: 8-bit scalar-quantised vector cell. Lives in
556    /// columns declared `VECTOR(N) USING SQ8`. Layout per cell:
557    /// `Sq8Vector { min: f32, max: f32, bytes: Vec<u8> }` —
558    /// 4× compression vs `Vector(Vec<f32>)`. The wire layer
559    /// dequantises to `f32` on SELECT; INSERT path quantises
560    /// incoming `Vector(Vec<f32>)` cells into this variant.
561    Sq8Vector(crate::quantize::Sq8Vector),
562    /// v6.0.3: IEEE-754 binary16 vector cell. Lives in columns
563    /// declared `VECTOR(N) USING HALF`. Stores raw u16 LE bits
564    /// (2× compression vs `Vector(Vec<f32>)`). Wire / display
565    /// paths dequantise to f32 bit-exactly; INSERT path converts
566    /// incoming f32 vectors at the engine boundary.
567    HalfVector(crate::halfvec::HalfVector),
568    /// Exact fixed-point decimal. `scaled` holds the value as
569    /// `actual * 10^scale` so the storage type is always integral —
570    /// arithmetic never falls back to floating-point.
571    Numeric {
572        scaled: i128,
573        scale: u8,
574    },
575    /// Days since the Unix epoch (1970-01-01). Negative for earlier dates.
576    Date(i32),
577    /// Microseconds since the Unix epoch (1970-01-01T00:00:00Z).
578    Timestamp(i64),
579    /// Calendar span: `months` + `days` + `micros`. Three fields are
580    /// required for PG byte-equal: `'1 day'` ≠ `'24 hours'` (DST,
581    /// month-boundary, and the on-wire `pg_type` `interval` are all
582    /// `i64 micros + i32 days + i32 months`). v7.37.5 β widened from
583    /// `{months, micros}`; column storage lands in the same window.
584    Interval {
585        months: i32,
586        days: i32,
587        micros: i64,
588    },
589    /// v4.9 `JSON` — raw JSON text. No structural validation
590    /// happens at the storage layer; whatever the parser hands us
591    /// round-trips verbatim. Equality is byte-wise.
592    Json(Cow<'arena, str>),
593    /// v7.10.4 `BYTEA` — raw binary blob. Equality is byte-wise.
594    /// Layout matches `Text`'s length-prefixed shape (`[u32 LE
595    /// len][bytes]`) under tag 18; the engine accepts PG hex
596    /// literals (`'\xDEADBEEF'`) and escape literals at the
597    /// coercion boundary.
598    Bytes(Cow<'arena, [u8]>),
599    /// v7.10.9 `TEXT[]` — single-dimension TEXT array with
600    /// optional NULL elements. Equality is element-wise. PG's
601    /// NULL-element comparison semantics: NULL ≠ NULL inside
602    /// arrays under `=`, so `[NULL] != [NULL]` (the engine
603    /// honours this).
604    TextArray(Vec<Option<String>>),
605    /// v7.11.12 `INT[]` — single-dimension i32 array with optional
606    /// NULL elements. Codec mirrors TextArray with i32 LE per
607    /// element instead of length-prefixed UTF-8.
608    IntArray(Vec<Option<i32>>),
609    /// v7.11.12 `BIGINT[]` — single-dimension i64 array with optional
610    /// NULL elements.
611    BigIntArray(Vec<Option<i64>>),
612    /// v7.37.5 β-P4 `INTERVAL[]` — single-dimension array of
613    /// `IntervalSpan { months, days, micros }` with optional NULL
614    /// elements. PG external form quotes each non-NULL element
615    /// (`{"1 day","24:00:00",NULL}`) because interval text contains
616    /// spaces and colons. Storage codec follows the BigIntArray
617    /// shape with a 16-byte per-element body.
618    IntervalArray(Vec<Option<IntervalSpan>>),
619    /// v7.37.5 γ — single-dimension arrays of the remaining PG
620    /// scalar types. Each carries `Vec<Option<T>>` with the
621    /// scalar's natural Rust shape; element NULLs are first-class
622    /// (per PG: `{1,NULL,3}` is a 3-element array, not a 2-element
623    /// one). Codec follows the IntervalArray shape — `[u16 count]
624    /// [per elem: u8 null + (non-null) scalar body]`.
625    BoolArray(Vec<Option<bool>>),
626    SmallIntArray(Vec<Option<i16>>),
627    FloatArray(Vec<Option<f64>>),
628    /// PG `NUMERIC[]` — `(scaled: i128, scale: u8)` per element.
629    NumericArray(Vec<Option<(i128, u8)>>),
630    DateArray(Vec<Option<i32>>),
631    TimestampArray(Vec<Option<i64>>),
632    TimestamptzArray(Vec<Option<i64>>),
633    UuidArray(Vec<Option<[u8; 16]>>),
634    JsonArray(Vec<Option<String>>),
635    JsonbArray(Vec<Option<String>>),
636    BytesArray(Vec<Option<Vec<u8>>>),
637    VarcharArray(Vec<Option<String>>),
638    CharArray(Vec<Option<String>>),
639    /// v7.37.5 δ — PG 14+ multirange. `ranges` is a Vec of
640    /// non-overlapping bounds spans of the shared `kind`. PG's
641    /// canonical text form is `{[a,b),[c,d),...}` (comma-separated
642    /// ranges in braces; `{}` for the empty multirange). SPG's
643    /// constructor enforces no overlap/coalescing — for now the
644    /// engine trusts the caller (mirrors PG's `_construct_array`
645    /// pattern). Catalog tag 49 + 1-byte RangeKind on the dense
646    /// type-tag side; schema-less path is unreachable (multirange
647    /// is column-typed only).
648    Multirange {
649        kind: RangeKind,
650        ranges: Vec<RangeSpan>,
651    },
652    /// v7.37.5 ε — PG geometry scalars. Per-type Vec/struct shape;
653    /// codec body shape is described on the matching DataType
654    /// variant. PG canonical text forms:
655    ///   Point   `(x,y)`
656    ///   Lseg    `[(x1,y1),(x2,y2)]`
657    ///   Path    open `[(x,y),(x,y),...]` / closed `((x,y),(x,y),...)`
658    ///   Box     `(ux,uy),(lx,ly)` (PG normalises to upper-right + lower-left)
659    ///   Polygon `((x,y),(x,y),...)` (implicit closed)
660    ///   Line    `{a,b,c}` (Ax + By + C = 0)
661    ///   Circle  `<(x,y),r>`
662    Point(Point2D),
663    Lseg(Point2D, Point2D),
664    /// `closed = true` is `((p,p,...))`; `false` is `[(p,p,...)]`.
665    Path {
666        points: Vec<Point2D>,
667        closed: bool,
668    },
669    /// PG `box` — stored as `(upper_right, lower_left)` (PG's
670    /// normalised order). The engine accepts both endpoint
671    /// orderings at parse time and normalises here.
672    PgBox(Point2D, Point2D),
673    Polygon(Vec<Point2D>),
674    Line {
675        a: f64,
676        b: f64,
677        c: f64,
678    },
679    Circle {
680        center: Point2D,
681        radius: f64,
682    },
683    /// v7.37.5 ζ-A — PG `inet`. `family = 4` (IPv4) or `6` (IPv6).
684    /// `bits` is the netmask bit count (0..=32 for IPv4, 0..=128
685    /// for IPv6). `addr` is right-padded with zeros when family=4
686    /// (first 4 bytes are the address).
687    Inet {
688        family: u8,
689        bits: u8,
690        addr: [u8; 16],
691    },
692    /// v7.37.5 ζ-A — PG `cidr`. Same shape as Inet; CIDR's
693    /// invariant (host bits zero) is enforced at parse / coerce.
694    Cidr {
695        family: u8,
696        bits: u8,
697        addr: [u8; 16],
698    },
699    /// v7.37.5 ζ-A — PG `macaddr`. 6 bytes (XX:XX:XX:XX:XX:XX).
700    Macaddr([u8; 6]),
701    /// v7.37.5 ζ-A — PG `macaddr8`. 8 bytes (EUI-64).
702    Macaddr8([u8; 8]),
703    /// v7.37.5 ζ-A — PG `bit` / `bit varying`. `nbits` is the
704    /// actual bit count; `bytes` is the packed representation
705    /// (big-endian within each byte; final byte right-padded
706    /// with 0s if `nbits % 8 != 0`).
707    BitString {
708        nbits: u32,
709        bytes: Cow<'arena, [u8]>,
710    },
711    /// v7.37.5 ζ-A — PG `xml`. Stored verbatim as a string; no
712    /// parse-time validation (matches the SPG JSON convention).
713    Xml(Cow<'arena, str>),
714    /// v7.37.5 ζ-A — PG `"char"` (internal single-byte type,
715    /// distinct from CHAR(n)).
716    Char1(u8),
717    /// v7.37.5 ζ-A — PG `money[]`.
718    MoneyArray(Vec<Option<i64>>),
719    /// v7.12.0 `tsvector` — sorted-by-word, deduped lexeme set with
720    /// positions + weights. The engine enforces sort/dedup on
721    /// construction; consumers can rely on `lexemes.windows(2)`
722    /// being strictly ascending by `word`.
723    TsVector(Vec<TsLexeme>),
724    /// v7.12.0 `tsquery` — boolean / phrase parse tree over
725    /// lexemes. Engine builds via `to_tsquery` family.
726    TsQuery(TsQueryAst),
727    /// v7.17.0 `uuid` — 128-bit identifier. Stored as 16 bytes
728    /// (big-endian / network-byte order, same as RFC 4122).
729    /// Display normalises to canonical lowercase 8-4-4-4-12
730    /// hyphenated form. Equality is byte-wise.
731    Uuid([u8; 16]),
732    /// v7.17.0 Phase 3.P0-32 — PG `time` (without time zone) —
733    /// i64 microseconds since 00:00:00. Range 0..86_400_000_000.
734    /// Display: `HH:MM:SS` zero-padded, with optional `.ffffff`
735    /// suffix when fractional is non-zero.
736    Time(i64),
737    /// v7.17.0 Phase 3.P0-33 — MySQL `YEAR` — u16 in range
738    /// 1901..=2155 plus the special zero-year sentinel 0.
739    /// Display always 4 digits zero-padded (`0000` for the
740    /// sentinel; `1985`/`2007` otherwise).
741    Year(u16),
742    /// v7.17.0 Phase 3.P0-34 — PG `time with time zone` — i64
743    /// microseconds since 00:00:00 in the LOCAL wall clock PLUS
744    /// an i32 offset-from-UTC in seconds. PG preserves the
745    /// offset on output, so the wall-clock value is NOT shifted
746    /// to UTC at storage time. Offset range: ±50400 seconds
747    /// (±14 hours).
748    TimeTz {
749        us: i64,
750        offset_secs: i32,
751    },
752    /// v7.17.0 Phase 3.P0-35 — PG `money` — i64 cents
753    /// (locale-independent storage; the en_US locale renders on
754    /// display via `$N,NNN.CC`).
755    Money(i64),
756    /// v7.17.0 Phase 3.P0-39 — PG `hstore` value: flat
757    /// `text => text` map with NULL value support. Insertion
758    /// order preserved on input; duplicate keys take last-write-
759    /// wins at parse time.
760    Hstore(Vec<(String, Option<String>)>),
761    /// v7.17.0 Phase 3.P0-40 — 2D INT matrix (row-major).
762    IntArray2D(Vec<Vec<Option<i32>>>),
763    /// v7.17.0 Phase 3.P0-40 — 2D BIGINT matrix (row-major).
764    BigIntArray2D(Vec<Vec<Option<i64>>>),
765    /// v7.17.0 Phase 3.P0-40 — 2D TEXT matrix (row-major).
766    TextArray2D(Vec<Vec<Option<String>>>),
767    /// v7.17.0 Phase 3.P0-38 — PG range value. One shape covers
768    /// all six builtin range types; `kind` pins the element type
769    /// (must match the column's `DataType::Range(kind)`).
770    /// `lower` / `upper` are `None` for the unbounded sides;
771    /// `lower_inc` / `upper_inc` mirror the canonical PG
772    /// `[` / `(` / `]` / `)` bracket inclusivity. `empty=true`
773    /// supersedes all other fields (the empty range has no
774    /// bounds).
775    Range {
776        kind: RangeKind,
777        // v7.37.42-arena Phase 1: Range bounds stay owned ('static).
778        // Recursive arena lifetimes are awkward to migrate at this
779        // phase and the SCALARSQ hot path doesn't construct ranges.
780        lower: Option<alloc::boxed::Box<Value<'static>>>,
781        upper: Option<alloc::boxed::Box<Value<'static>>>,
782        lower_inc: bool,
783        upper_inc: bool,
784        empty: bool,
785    },
786    Null,
787}
788
789/// Owned `Value` — heap-bearing variants are `Cow::Owned`. Used everywhere
790/// a Value must outlive a query-scoped arena (catalog defaults, persistent
791/// storage, public APIs).
792pub type ValueOwned = Value<'static>;
793
794/// v7.37.5 ε — PG `point` building block. Shared by every other
795/// geometric type (lseg / path / box / polygon / circle all
796/// reduce to compositions of `Point2D`). Packed `{x: f64, y: f64}`,
797/// 16 B, on-disk LE field order matches the PG binary point
798/// format byte-for-byte (so a future binary BIND path lands
799/// without rearrangement).
800#[derive(Debug, Clone, Copy, PartialEq)]
801pub struct Point2D {
802    pub x: f64,
803    pub y: f64,
804}
805
806/// v7.37.5 δ — single-range bounds without the kind tag. Used as
807/// the element type of `Value::Multirange { kind, ranges }` so a
808/// multirange carries one shared `RangeKind` plus N bounds-only
809/// spans (saves 1 byte/elem vs duplicating the kind). The five
810/// other fields mirror `Value::Range` exactly.
811#[derive(Debug, Clone, PartialEq)]
812pub struct RangeSpan {
813    // v7.37.42-arena Phase 1: stays owned ('static) — same rationale as
814    // Range bounds above.
815    pub lower: Option<alloc::boxed::Box<Value<'static>>>,
816    pub upper: Option<alloc::boxed::Box<Value<'static>>>,
817    pub lower_inc: bool,
818    pub upper_inc: bool,
819    pub empty: bool,
820}
821
822/// v7.37.5 β-P4 — element type for `Value::IntervalArray`. Mirrors
823/// the `{months, days, micros}` shape of scalar `Value::Interval`,
824/// broken out as a named struct so `IntervalArray`'s element type
825/// is concrete (24 bytes, packed) instead of an enum-boxed Value.
826/// All three dimensions are independent — `IntervalSpan { days: 1,
827/// .. }` is distinct from `IntervalSpan { micros: 86_400_000_000,
828/// .. }` per PG byte-equal.
829#[derive(Debug, Clone, Copy, PartialEq, Eq)]
830pub struct IntervalSpan {
831    pub months: i32,
832    pub days: i32,
833    pub micros: i64,
834}
835
836impl<'arena> Value<'arena> {
837    /// Type tag, or `None` for `NULL` (unknown at value level).
838    pub fn data_type(&self) -> Option<DataType> {
839        match self {
840            Self::SmallInt(_) => Some(DataType::SmallInt),
841            Self::Int(_) => Some(DataType::Int),
842            Self::BigInt(_) => Some(DataType::BigInt),
843            Self::Float(_) => Some(DataType::Float),
844            // `Text` covers both unbounded TEXT and bounded VARCHAR/CHAR
845            // — the constraint lives on the column schema, not the value.
846            Self::Text(_) => Some(DataType::Text),
847            Self::Bool(_) => Some(DataType::Bool),
848            Self::Vector(v) => Some(DataType::Vector {
849                dim: u32::try_from(v.len()).expect("vector dim ≤ u32"),
850                encoding: VecEncoding::F32,
851            }),
852            Self::Sq8Vector(q) => Some(DataType::Vector {
853                dim: u32::try_from(q.bytes.len()).expect("vector dim ≤ u32"),
854                encoding: VecEncoding::Sq8,
855            }),
856            Self::HalfVector(h) => Some(DataType::Vector {
857                dim: u32::try_from(h.dim()).expect("vector dim ≤ u32"),
858                encoding: VecEncoding::F16,
859            }),
860            // `Value::Numeric` doesn't carry its precision (the column
861            // schema does); we surface precision=0 as "unknown" and let
862            // the engine reconcile against the column type at coercion
863            // time.
864            Self::Numeric { scale, .. } => Some(DataType::Numeric {
865                precision: 0,
866                scale: *scale,
867            }),
868            Self::Date(_) => Some(DataType::Date),
869            Self::Timestamp(_) => Some(DataType::Timestamp),
870            Self::Interval { .. } => Some(DataType::Interval),
871            Self::Json(_) => Some(DataType::Json),
872            Self::Bytes(_) => Some(DataType::Bytes),
873            Self::TextArray(_) => Some(DataType::TextArray),
874            Self::IntArray(_) => Some(DataType::IntArray),
875            Self::BigIntArray(_) => Some(DataType::BigIntArray),
876            Self::IntervalArray(_) => Some(DataType::IntervalArray),
877            Self::BoolArray(_) => Some(DataType::BoolArray),
878            Self::SmallIntArray(_) => Some(DataType::SmallIntArray),
879            Self::FloatArray(_) => Some(DataType::FloatArray),
880            Self::NumericArray(_) => Some(DataType::NumericArray),
881            Self::DateArray(_) => Some(DataType::DateArray),
882            Self::TimestampArray(_) => Some(DataType::TimestampArray),
883            Self::TimestamptzArray(_) => Some(DataType::TimestamptzArray),
884            Self::UuidArray(_) => Some(DataType::UuidArray),
885            Self::JsonArray(_) => Some(DataType::JsonArray),
886            Self::JsonbArray(_) => Some(DataType::JsonbArray),
887            Self::BytesArray(_) => Some(DataType::BytesArray),
888            Self::VarcharArray(_) => Some(DataType::VarcharArray),
889            Self::CharArray(_) => Some(DataType::CharArray),
890            Self::Multirange { kind, .. } => Some(DataType::Multirange(*kind)),
891            Self::Point(_) => Some(DataType::Point),
892            Self::Lseg(_, _) => Some(DataType::Lseg),
893            Self::Path { .. } => Some(DataType::Path),
894            Self::PgBox(_, _) => Some(DataType::PgBox),
895            Self::Polygon(_) => Some(DataType::Polygon),
896            Self::Line { .. } => Some(DataType::Line),
897            Self::Circle { .. } => Some(DataType::Circle),
898            Self::Inet { .. } => Some(DataType::Inet),
899            Self::Cidr { .. } => Some(DataType::Cidr),
900            Self::Macaddr(_) => Some(DataType::Macaddr),
901            Self::Macaddr8(_) => Some(DataType::Macaddr8),
902            // BitString could be either Bit or BitVarying; column
903            // schema decides. Default to BitVarying when called
904            // schema-less (rare; storage path is always
905            // schema-aware so this only matters for diagnostics).
906            Self::BitString { .. } => Some(DataType::BitVarying),
907            Self::Xml(_) => Some(DataType::Xml),
908            Self::Char1(_) => Some(DataType::Char1),
909            Self::MoneyArray(_) => Some(DataType::MoneyArray),
910            Self::TsVector(_) => Some(DataType::TsVector),
911            Self::TsQuery(_) => Some(DataType::TsQuery),
912            Self::Uuid(_) => Some(DataType::Uuid),
913            Self::Time(_) => Some(DataType::Time),
914            Self::Year(_) => Some(DataType::Year),
915            Self::TimeTz { .. } => Some(DataType::TimeTz),
916            Self::Money(_) => Some(DataType::Money),
917            Self::Range { kind, .. } => Some(DataType::Range(*kind)),
918            Self::Hstore(_) => Some(DataType::Hstore),
919            Self::IntArray2D(_) => Some(DataType::IntArray2D),
920            Self::BigIntArray2D(_) => Some(DataType::BigIntArray2D),
921            Self::TextArray2D(_) => Some(DataType::TextArray2D),
922            Self::Null => None,
923        }
924    }
925
926    pub const fn is_null(&self) -> bool {
927        matches!(self, Self::Null)
928    }
929
930    /// v7.37.42-arena Phase 1: lift any `Value<'arena>` (possibly
931    /// borrowing from a bump arena) into a fully-owned `Value<'static>`.
932    /// Used at boundaries that must outlive the per-query arena
933    /// (catalog write, public QueryResult emit, sqlx materialise).
934    ///
935    /// For the recursive Range/Multirange variants — bounds are already
936    /// `Box<Value<'static>>` per Phase 1 design, so we just rebuild the
937    /// outer enum at `'static`.
938    pub fn into_owned(self) -> Value<'static> {
939        match self {
940            Value::SmallInt(n) => Value::SmallInt(n),
941            Value::Int(n) => Value::Int(n),
942            Value::BigInt(n) => Value::BigInt(n),
943            Value::Float(f) => Value::Float(f),
944            Value::Text(s) => Value::Text(Cow::Owned(s.into_owned())),
945            Value::Bool(b) => Value::Bool(b),
946            Value::Vector(v) => Value::Vector(Cow::Owned(v.into_owned())),
947            Value::Sq8Vector(q) => Value::Sq8Vector(q),
948            Value::HalfVector(h) => Value::HalfVector(h),
949            Value::Numeric { scaled, scale } => Value::Numeric { scaled, scale },
950            Value::Date(d) => Value::Date(d),
951            Value::Timestamp(t) => Value::Timestamp(t),
952            Value::Interval {
953                months,
954                days,
955                micros,
956            } => Value::Interval {
957                months,
958                days,
959                micros,
960            },
961            Value::Json(s) => Value::Json(Cow::Owned(s.into_owned())),
962            Value::Bytes(b) => Value::Bytes(Cow::Owned(b.into_owned())),
963            Value::TextArray(v) => Value::TextArray(v),
964            Value::IntArray(v) => Value::IntArray(v),
965            Value::BigIntArray(v) => Value::BigIntArray(v),
966            Value::IntervalArray(v) => Value::IntervalArray(v),
967            Value::BoolArray(v) => Value::BoolArray(v),
968            Value::SmallIntArray(v) => Value::SmallIntArray(v),
969            Value::FloatArray(v) => Value::FloatArray(v),
970            Value::NumericArray(v) => Value::NumericArray(v),
971            Value::DateArray(v) => Value::DateArray(v),
972            Value::TimestampArray(v) => Value::TimestampArray(v),
973            Value::TimestamptzArray(v) => Value::TimestamptzArray(v),
974            Value::UuidArray(v) => Value::UuidArray(v),
975            Value::JsonArray(v) => Value::JsonArray(v),
976            Value::JsonbArray(v) => Value::JsonbArray(v),
977            Value::BytesArray(v) => Value::BytesArray(v),
978            Value::VarcharArray(v) => Value::VarcharArray(v),
979            Value::CharArray(v) => Value::CharArray(v),
980            Value::Multirange { kind, ranges } => Value::Multirange { kind, ranges },
981            Value::Point(p) => Value::Point(p),
982            Value::Lseg(a, b) => Value::Lseg(a, b),
983            Value::Path { points, closed } => Value::Path { points, closed },
984            Value::PgBox(a, b) => Value::PgBox(a, b),
985            Value::Polygon(p) => Value::Polygon(p),
986            Value::Line { a, b, c } => Value::Line { a, b, c },
987            Value::Circle { center, radius } => Value::Circle { center, radius },
988            Value::Inet { family, bits, addr } => Value::Inet { family, bits, addr },
989            Value::Cidr { family, bits, addr } => Value::Cidr { family, bits, addr },
990            Value::Macaddr(m) => Value::Macaddr(m),
991            Value::Macaddr8(m) => Value::Macaddr8(m),
992            Value::BitString { nbits, bytes } => Value::BitString {
993                nbits,
994                bytes: Cow::Owned(bytes.into_owned()),
995            },
996            Value::Xml(s) => Value::Xml(Cow::Owned(s.into_owned())),
997            Value::Char1(c) => Value::Char1(c),
998            Value::MoneyArray(v) => Value::MoneyArray(v),
999            Value::TsVector(v) => Value::TsVector(v),
1000            Value::TsQuery(q) => Value::TsQuery(q),
1001            Value::Uuid(u) => Value::Uuid(u),
1002            Value::Time(t) => Value::Time(t),
1003            Value::Year(y) => Value::Year(y),
1004            Value::TimeTz { us, offset_secs } => Value::TimeTz { us, offset_secs },
1005            Value::Money(m) => Value::Money(m),
1006            Value::Range {
1007                kind,
1008                lower,
1009                upper,
1010                lower_inc,
1011                upper_inc,
1012                empty,
1013            } => Value::Range {
1014                kind,
1015                lower,
1016                upper,
1017                lower_inc,
1018                upper_inc,
1019                empty,
1020            },
1021            Value::Hstore(h) => Value::Hstore(h),
1022            Value::IntArray2D(a) => Value::IntArray2D(a),
1023            Value::BigIntArray2D(a) => Value::BigIntArray2D(a),
1024            Value::TextArray2D(a) => Value::TextArray2D(a),
1025            Value::Null => Value::Null,
1026        }
1027    }
1028
1029    /// v7.37.42-arena Phase 4 — copy heap payloads into the supplied
1030    /// bump arena, yielding a `Value<'a>` whose Cow-variant payloads
1031    /// are arena-borrowed (or stay as small owned scalars for the
1032    /// `Copy`-able variants).
1033    ///
1034    /// Used at the catalog ↔ ephemeral boundary: a `ColumnSchema.default`
1035    /// is `Value<'static>` but INSERT-time eval may want it stamped into
1036    /// the per-statement arena alongside other arena-built scalars.
1037    ///
1038    /// Allocates only into the supplied arena; the input `&self` keeps
1039    /// its own storage. For `Copy`-able / nested-owned variants the
1040    /// implementation falls back to `clone()` (the nested heap blocks
1041    /// stay on the global allocator, which is fine — the boundary
1042    /// requirement is just "no aliasing of caller-owned strings").
1043    pub fn clone_into<'a>(&self, arena: &'a bumpalo::Bump) -> Value<'a> {
1044        match self {
1045            Value::Text(s) => Value::Text(Cow::Borrowed(arena.alloc_str(s))),
1046            Value::Json(s) => Value::Json(Cow::Borrowed(arena.alloc_str(s))),
1047            Value::Xml(s) => Value::Xml(Cow::Borrowed(arena.alloc_str(s))),
1048            Value::Bytes(b) => {
1049                let slot = arena.alloc_slice_copy::<u8>(b);
1050                Value::Bytes(Cow::Borrowed(slot))
1051            }
1052            Value::Vector(v) => {
1053                let slot = arena.alloc_slice_copy::<f32>(v);
1054                Value::Vector(Cow::Borrowed(slot))
1055            }
1056            Value::BitString { nbits, bytes } => {
1057                let slot = arena.alloc_slice_copy::<u8>(bytes);
1058                Value::BitString {
1059                    nbits: *nbits,
1060                    bytes: Cow::Borrowed(slot),
1061                }
1062            }
1063            // Copy-able scalars + variants whose nested heap blocks are
1064            // `'static` regardless of `'arena` (TextArray, JsonArray,
1065            // Hstore, TsVector, Range bounds, …). Clone the heap block
1066            // via the standard `into_owned()` path then lift the
1067            // resulting `Value<'static>` to `Value<'a>` via the Cow
1068            // variance — `'static` covers any lifetime.
1069            other => other.clone().into_owned(),
1070        }
1071    }
1072}
1073
1074impl Value<'static> {
1075    /// v7.37.42-arena Phase 1 — owned-Text constructor. The variant now
1076    /// holds `Cow<'arena, str>`, so the previous `Value::Text(String)`
1077    /// shape no longer compiles directly. This helper preserves the
1078    /// historical ergonomics: `Value::text("foo")` or
1079    /// `Value::text(String::from("foo"))`.
1080    pub fn text<S: Into<String>>(s: S) -> Self {
1081        Value::Text(Cow::Owned(s.into()))
1082    }
1083
1084    /// v7.37.42-arena Phase 1 — owned-Json constructor (mirrors `text`).
1085    pub fn json<S: Into<String>>(s: S) -> Self {
1086        Value::Json(Cow::Owned(s.into()))
1087    }
1088
1089    /// v7.37.42-arena Phase 1 — owned-Xml constructor.
1090    pub fn xml<S: Into<String>>(s: S) -> Self {
1091        Value::Xml(Cow::Owned(s.into()))
1092    }
1093
1094    /// v7.37.42-arena Phase 1 — owned-Bytes constructor.
1095    pub fn bytes<B: Into<Vec<u8>>>(b: B) -> Self {
1096        Value::Bytes(Cow::Owned(b.into()))
1097    }
1098
1099    /// v7.37.42-arena Phase 1 — owned-Vector constructor.
1100    pub fn vector<V: Into<Vec<f32>>>(v: V) -> Self {
1101        Value::Vector(Cow::Owned(v.into()))
1102    }
1103
1104    /// v7.37.42-arena Phase 1 — owned-BitString constructor.
1105    pub fn bit_string<B: Into<Vec<u8>>>(nbits: u32, bytes: B) -> Self {
1106        Value::BitString {
1107            nbits,
1108            bytes: Cow::Owned(bytes.into()),
1109        }
1110    }
1111}
1112
1113/// One table row — values are positional and must match
1114/// `TableSchema.columns` in length and (modulo NULL) in `DataType`.
1115///
1116/// v7.37.42-arena Phase 1: parameterised on `'arena` so per-query rows
1117/// can borrow from a bump arena. The owned shape (`Row<'static>`, alias
1118/// `RowOwned`) is what catalog storage, public APIs, and tests use.
1119#[derive(Debug, Clone, PartialEq)]
1120pub struct Row<'arena> {
1121    pub values: Vec<Value<'arena>>,
1122}
1123
1124/// Owned `Row` — values are `Value<'static>`. Used everywhere a row must
1125/// outlive a query-scoped arena.
1126pub type RowOwned = Row<'static>;
1127
1128impl<'arena> Row<'arena> {
1129    pub const fn new(values: Vec<Value<'arena>>) -> Self {
1130        Self { values }
1131    }
1132
1133    pub fn len(&self) -> usize {
1134        self.values.len()
1135    }
1136
1137    pub fn is_empty(&self) -> bool {
1138        self.values.is_empty()
1139    }
1140}
1141
1142impl<'arena> Row<'arena> {
1143    /// v7.37.42-arena Phase 4 — copy every cell into the supplied bump
1144    /// arena, yielding a `Row<'a>` whose Cow-payloads are arena-borrowed.
1145    /// Boundary helper for catalog defaults → DML eval handoff and
1146    /// arena-local row scratch.
1147    pub fn clone_into<'a>(&self, arena: &'a bumpalo::Bump) -> Row<'a> {
1148        Row {
1149            values: self.values.iter().map(|v| v.clone_into(arena)).collect(),
1150        }
1151    }
1152
1153    /// v7.37.42-arena Phase 4 — lift this `Row<'arena>` to a fully-owned
1154    /// `Row<'static>` for catalog write / WAL serialisation. Equivalent
1155    /// to `Row::from_arena(self)` but consumes by value at any lifetime
1156    /// (callers can write `row.into_owned()` mirroring `Value::into_owned`).
1157    pub fn into_owned(self) -> Row<'static> {
1158        Row {
1159            values: self.values.into_iter().map(Value::into_owned).collect(),
1160        }
1161    }
1162}
1163
1164impl Row<'static> {
1165    /// v7.37.42-arena Phase 1 — lift any `Row<'arena>` (possibly arena-
1166    /// borrowed) into a fully-owned `Row<'static>`. Mirrors
1167    /// `Value::into_owned`.
1168    pub fn from_arena(row: Row<'_>) -> Self {
1169        Self {
1170            values: row.values.into_iter().map(Value::into_owned).collect(),
1171        }
1172    }
1173}
1174
1175#[derive(Debug, Clone, PartialEq)]
1176pub struct ColumnSchema {
1177    pub name: String,
1178    pub ty: DataType,
1179    pub nullable: bool,
1180    /// Optional `DEFAULT` value, frozen at CREATE TABLE time. `None`
1181    /// means "no default" (so omitted columns become NULL, or error
1182    /// out when the column is NOT NULL). Literal defaults take this
1183    /// path.
1184    ///
1185    /// v7.37.42-arena Phase 1: explicitly `Value<'static>` — catalog
1186    /// defaults must outlive any per-query arena.
1187    pub default: Option<Value<'static>>,
1188    /// v7.9.21 — for DEFAULT expressions that need INSERT-time
1189    /// evaluation (e.g. `DEFAULT now()`, `DEFAULT CURRENT_TIMESTAMP`),
1190    /// the Display form of the expression. The engine re-parses
1191    /// it on each INSERT default-fill, evaluates against an empty
1192    /// row context, and coerces to the column type. mailrs G4.
1193    /// Persisted in catalog FILE_VERSION 15+; older catalogs
1194    /// deserialise with None.
1195    pub runtime_default: Option<String>,
1196    /// MySQL-style `AUTO_INCREMENT`. When set, an INSERT that leaves
1197    /// this column unbound (or sets it to NULL) gets the next integer
1198    /// computed from the column's current max + 1.
1199    pub auto_increment: bool,
1200    /// v7.17.0 Phase 1.4 — when the column is bound to a user-
1201    /// defined ENUM type (the parser saw an unknown type ident
1202    /// and the engine resolved it against `catalog.enum_types`),
1203    /// this carries the enum name so INSERT/UPDATE can validate
1204    /// the cell value against the enum's labels. `ty` is
1205    /// `DataType::Text` in that case. Persisted in catalog
1206    /// FILE_VERSION 29+; older catalogs deserialise with None.
1207    pub user_enum_type: Option<String>,
1208    /// v7.17.0 Phase 1.5 — when the column is bound to a user-
1209    /// defined DOMAIN (the parser saw an unknown type ident and
1210    /// the engine resolved it against `catalog.domain_types`),
1211    /// this carries the domain name. `ty` is the domain's base
1212    /// type; INSERT/UPDATE re-evaluates the domain's CHECK list
1213    /// + NOT NULL against the cell value. Persisted in catalog
1214    /// FILE_VERSION 30+; older catalogs deserialise with None.
1215    pub user_domain_type: Option<String>,
1216    /// v7.17.0 Phase 2.1 — MySQL `ON UPDATE CURRENT_TIMESTAMP`
1217    /// column attribute. When `Some(expr_src)`, an UPDATE that
1218    /// does NOT bind this column overrides the new value with
1219    /// the engine-evaluated expression (always `now()` in
1220    /// v7.17.0). Stored as Display-form source so storage
1221    /// stays free of spg-sql; the engine re-parses at UPDATE
1222    /// time. Persisted in catalog FILE_VERSION 32+; older
1223    /// catalogs deserialise with None — preserves the existing
1224    /// "silent ignore" behaviour for snapshots written before
1225    /// the upgrade.
1226    pub on_update_runtime: Option<String>,
1227    /// v7.17.0 Phase 2.5 — text collation. Pre-2.5 SPG accepted
1228    /// `COLLATE <name>` clauses but discarded the name, so a
1229    /// column declared `COLLATE "case_insensitive"` (or any
1230    /// MySQL `_ci` collation) still compared byte-wise — a
1231    /// Tier-S silent failure where `WHERE name = 'foo'` never
1232    /// matched stored `'Foo'`. This carries the parser-derived
1233    /// classification so the engine's WHERE evaluator can route
1234    /// text equality through a case-aware compare. `Binary` (the
1235    /// default) preserves the prior byte-wise behaviour. Only
1236    /// CaseInsensitive lands in the catalog appendix — Binary
1237    /// columns stay implicit, keeping snapshots compact.
1238    /// Persisted in catalog FILE_VERSION 34+; older catalogs
1239    /// deserialise every column as `Binary`.
1240    pub collation: Collation,
1241    /// v7.17.0 Phase 4.4 — MySQL `UNSIGNED` modifier flag. Drives
1242    /// engine-side INSERT / UPDATE range enforcement (rejects
1243    /// negative values on UNSIGNED int columns). Pre-4.4 the
1244    /// parser consumed and discarded the keyword silently, so
1245    /// every UNSIGNED column quietly accepted negatives — a
1246    /// Tier-A correctness drift. Sparse: only UNSIGNED columns
1247    /// land in the catalog appendix; the default `false` keeps
1248    /// snapshots compact for the common signed-int path.
1249    /// Persisted in catalog FILE_VERSION 35+; older catalogs
1250    /// deserialise every column as `is_unsigned = false`.
1251    pub is_unsigned: bool,
1252    /// v7.17.0 Phase 3.P0-36 — MySQL inline `ENUM('a','b','c')`
1253    /// value list. Distinct from `user_enum_type` (which points
1254    /// to a separately CREATE TYPE'd PG enum); this carries the
1255    /// column-local list MySQL DDL declares inline. When `Some`,
1256    /// `ty` is `DataType::Text` and INSERT/UPDATE validates the
1257    /// cell value against this list. Variant ORDER is preserved
1258    /// (MySQL uses it for `ORDER BY col`). Sparse: only ENUM
1259    /// columns land in the catalog appendix.
1260    /// Persisted in catalog FILE_VERSION 41+; older catalogs
1261    /// deserialise with None — preserves silent-drop behaviour
1262    /// for snapshots written before P0-36.
1263    pub inline_enum_variants: Option<Vec<String>>,
1264    /// v7.17.0 Phase 3.P0-37 — MySQL inline `SET('a','b','c')`
1265    /// variant list. Storage is TEXT (canonical comma-joined in
1266    /// definition order, de-duplicated). INSERT/UPDATE validates
1267    /// every comma-separated token against this list. Sparse:
1268    /// only SET columns land in the catalog appendix.
1269    /// Persisted in catalog FILE_VERSION 42+; older catalogs
1270    /// deserialise with None.
1271    pub inline_set_variants: Option<Vec<String>>,
1272    /// v7.37.7(sentori Epic 3 P1)— `GENERATED ALWAYS AS (<expr>)
1273    /// STORED` computed-column source. When `Some`, INSERT / UPDATE
1274    /// recompute the cell against the candidate row(re-parse the
1275    /// stored Display form and evaluate)and overwrite any
1276    /// user-supplied value, matching PG's stored-generated-column
1277    /// semantics. `None` (the default) preserves the regular
1278    /// "column value is whatever the caller passed" path.
1279    /// Persisted in catalog FILE_VERSION 50+; older catalogs
1280    /// deserialise with None.
1281    pub generated_stored_expr: Option<String>,
1282}
1283
1284/// v7.17.0 Phase 2.5 — column-level text collation. Drives the
1285/// engine's WHERE / GROUP BY equality routing for `Value::Text`.
1286/// Only two variants are modelled in v7.17:
1287///   * `Binary`  — byte-wise comparison (the SPG default;
1288///                 matches PG `COLLATE "C"` / `pg_catalog.default`
1289///                 and MySQL `*_bin`).
1290///   * `CaseInsensitive` — ASCII case-folded comparison
1291///                 (matches PG `COLLATE "case_insensitive"` and
1292///                 MySQL `*_ci` collations). Non-ASCII bytes
1293///                 still compare byte-wise; full ICU folding is
1294///                 out of v7.17 scope.
1295/// New variants append at the end — older catalogs read missing
1296/// columns as `Binary`.
1297#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1298pub enum Collation {
1299    Binary,
1300    CaseInsensitive,
1301}
1302
1303#[allow(clippy::derivable_impls)]
1304impl Default for Collation {
1305    fn default() -> Self {
1306        Self::Binary
1307    }
1308}
1309
1310impl Collation {
1311    /// Wire tag persisted in the FILE_VERSION 34+ catalog appendix.
1312    /// Stable: future variants append above the recognised range
1313    /// and unknown tags read back as `Binary` for forward-compat
1314    /// on rollback.
1315    pub const TAG_BINARY: u8 = 0;
1316    pub const TAG_CASE_INSENSITIVE: u8 = 1;
1317}
1318
1319#[derive(Debug, Clone, PartialEq)]
1320pub struct TableSchema {
1321    pub name: String,
1322    pub columns: Vec<ColumnSchema>,
1323    /// v6.7.2 — per-table hot-tier byte budget override. `None`
1324    /// falls through to the global `SPG_HOT_TIER_BYTES` setting;
1325    /// `Some(n)` overrides it for this specific table. Set via
1326    /// `ALTER TABLE t SET hot_tier_bytes = X`. Persisted in
1327    /// catalog FILE_VERSION 11+.
1328    pub hot_tier_bytes: Option<u64>,
1329    /// v7.6.1 — FOREIGN KEY constraints declared on this table.
1330    /// Engine maintains this in lock-step with `spg-sql`'s parser
1331    /// AST; the storage layer carries the on-disk shape so a
1332    /// catalog snapshot round-trips without external mapping.
1333    /// Persisted in catalog FILE_VERSION 13+. Older catalogs
1334    /// deserialise with an empty vec.
1335    pub foreign_keys: Vec<ForeignKeyConstraint>,
1336    /// v7.9.19 — composite UNIQUE / PRIMARY KEY constraints
1337    /// declared at the table level. Each entry's leading column
1338    /// has a BTree index (created via the constraint), and INSERT
1339    /// path enforces the full-tuple uniqueness via a scan keyed
1340    /// by the leading column. Persisted in catalog FILE_VERSION
1341    /// 15+. Older catalogs (≤ 14) deserialise with an empty vec.
1342    pub uniqueness_constraints: Vec<UniquenessConstraint>,
1343    /// v7.13.0 — `CHECK (<expr>)` predicates declared on this
1344    /// table. Both column-level inline `CHECK (…)` and
1345    /// table-level `CHECK (…)` fold into this list. Each entry
1346    /// is the AST Expr's `Display` form, re-parsed on every
1347    /// INSERT/UPDATE and evaluated against the candidate row.
1348    /// A false / NULL result rejects the mutation (PG semantics).
1349    /// Persisted in catalog FILE_VERSION 23+. Older catalogs
1350    /// deserialise with an empty vec.
1351    pub checks: Vec<String>,
1352    /// v7.37.6-B — declarative partition role(sentori Epic 2 P0).
1353    /// `None` = 普通表(后向兼容,< v49 catalog 默认 None)。
1354    /// `Some(Parent { … })` = `CREATE TABLE p (...) PARTITION BY RANGE (key_col)` 父表 —
1355    /// 父表自己 `rows` 永远空,INSERT 在引擎层路由到命中的 child。
1356    /// `Some(Range { … })` = `CREATE TABLE c PARTITION OF p FOR VALUES FROM (a) TO (b)` 范围子表。
1357    /// `Some(Default { … })` = `CREATE TABLE c PARTITION OF p DEFAULT` 兜底子表。
1358    /// 持久化于 FILE_VERSION 49+。
1359    pub partition_role: Option<PartitionRole>,
1360}
1361
1362/// v7.37.6-B — partition 三态(parent / range child / default child)。
1363#[derive(Debug, Clone, PartialEq, Eq)]
1364pub enum PartitionRole {
1365    Parent {
1366        kind: PartitionKind,
1367        /// 父表 columns 中 key 列的下标(单列 v7.37.6-B,
1368        /// `Vec` 为将来扩多列预留)。
1369        key_column_positions: Vec<usize>,
1370        /// `CREATE INDEX ON parent (…)` 的 Display-form 源串。
1371        /// child 创建时再 parse + 在 child 上 execute,这样 future
1372        /// child 也自动继承父表索引。fan-out 实施在引擎层。
1373        index_template_sources: Vec<String>,
1374    },
1375    Range {
1376        parent_name: String,
1377        /// 半开区间下界(`>=`,SQL `FROM (lower)`).
1378        lower: PartitionBound,
1379        /// 半开区间上界(`<`,SQL `TO (upper)`).
1380        upper: PartitionBound,
1381    },
1382    Default {
1383        parent_name: String,
1384    },
1385}
1386
1387/// v7.37.6-B — 分区策略(v7.37.6-B 只 Range;留 enum 给将来 List/Hash)。
1388#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1389pub enum PartitionKind {
1390    Range,
1391}
1392
1393/// v7.37.6-B — partition 边界 literal。v7.37.6-B 锁 TIMESTAMPTZ
1394/// (i64 microseconds since epoch — 与 `Value::Timestamptz` 同存储);
1395/// `MinValue` / `MaxValue` 对应 SQL `MINVALUE` / `MAXVALUE`(sentori
1396/// 不依赖,但 zero cost 留口)。后续 phase 扩 DateInt / Int8 等。
1397#[derive(Debug, Clone, PartialEq, Eq)]
1398pub enum PartitionBound {
1399    MinValue,
1400    MaxValue,
1401    TimestampTz(i64),
1402}
1403
1404/// v7.9.19 — composite UNIQUE / PRIMARY KEY constraint persisted
1405/// on the table schema. The leading column always has a BTree
1406/// index (created at CREATE TABLE time); INSERT enforcement
1407/// scans that index for collisions on the full column tuple.
1408#[derive(Debug, Clone, PartialEq, Eq)]
1409pub struct UniquenessConstraint {
1410    /// `true` when this constraint was declared as `PRIMARY KEY`
1411    /// (vs `UNIQUE`). Semantically PK implies NOT NULL on all
1412    /// referenced columns; the engine enforces that at CREATE
1413    /// TABLE time.
1414    pub is_primary_key: bool,
1415    /// Column positions on the parent table. ≥ 1 element. For
1416    /// single-column UNIQUE this is exactly one position; the
1417    /// BTree index alone enforces it.
1418    pub columns: Vec<usize>,
1419    /// v7.13.0 — `UNIQUE NULLS NOT DISTINCT` modifier
1420    /// (mailrs round-5 G10; PG 15+ surface). When `true`, two
1421    /// rows whose constrained columns are all NULL collide on
1422    /// the constraint. Default (`false`) is the SQL-standard
1423    /// `NULLS DISTINCT` behaviour where any NULL passes.
1424    /// Persisted in catalog FILE_VERSION 23+.
1425    pub nulls_not_distinct: bool,
1426}
1427
1428/// v7.6.1 — Storage-layer mirror of `spg_sql::ast::ForeignKeyConstraint`.
1429/// The engine's CREATE TABLE path translates between the two; keeping
1430/// them separate preserves the no-deps boundary between
1431/// `spg-storage` and `spg-sql`.
1432#[derive(Debug, Clone, PartialEq, Eq)]
1433pub struct ForeignKeyConstraint {
1434    /// Optional user-supplied constraint name (`CONSTRAINT <name>`
1435    /// prefix). Used by `ALTER TABLE DROP CONSTRAINT <name>` in
1436    /// v7.6.8; ignored by enforcement.
1437    pub name: Option<String>,
1438    /// Positions of local columns in this table's column list.
1439    /// Same arity as `parent_columns`.
1440    pub local_columns: Vec<usize>,
1441    /// Referenced parent table name.
1442    pub parent_table: String,
1443    /// Positions of parent columns in the parent's column list.
1444    /// Engine resolves these at CREATE TABLE time (after the parent
1445    /// schema is known) so enforcement paths can skip the name
1446    /// lookup on every row.
1447    pub parent_columns: Vec<usize>,
1448    /// Referential action when a parent row is deleted.
1449    pub on_delete: FkAction,
1450    /// Referential action when a parent row's referenced columns
1451    /// are updated.
1452    pub on_update: FkAction,
1453}
1454
1455/// v7.6.1 — referential action tag. Mirrors `spg_sql::ast::FkAction`.
1456#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1457pub enum FkAction {
1458    Restrict,
1459    Cascade,
1460    SetNull,
1461    SetDefault,
1462    NoAction,
1463}
1464
1465impl FkAction {
1466    /// On-disk tag byte (v13 catalog appendix).
1467    pub const fn tag(self) -> u8 {
1468        match self {
1469            Self::Restrict => 0,
1470            Self::Cascade => 1,
1471            Self::SetNull => 2,
1472            Self::SetDefault => 3,
1473            Self::NoAction => 4,
1474        }
1475    }
1476    pub const fn from_tag(b: u8) -> Option<Self> {
1477        Some(match b {
1478            0 => Self::Restrict,
1479            1 => Self::Cascade,
1480            2 => Self::SetNull,
1481            3 => Self::SetDefault,
1482            4 => Self::NoAction,
1483            _ => return None,
1484        })
1485    }
1486}
1487
1488impl TableSchema {
1489    pub fn column_position(&self, name: &str) -> Option<usize> {
1490        self.columns.iter().position(|c| c.name == name)
1491    }
1492}
1493
1494/// Key type accepted by secondary indices. Float / NULL / Vector values
1495/// can't participate in a B-tree index — `f64` is only `PartialOrd`, NULL
1496/// has SQL-three-valued semantics, and Vector belongs to the (future) HNSW
1497/// path. Index lookups on those columns fall back to full scan.
1498#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
1499pub enum IndexKey {
1500    Int(i64),
1501    Text(String),
1502    Bool(bool),
1503    /// v7.17.0 — `Value::Uuid` index key. Comparison is byte-wise
1504    /// (RFC 4122 byte order) so PRIMARY KEY UUID lookups land on
1505    /// the same fast-path as Int / Text.
1506    Uuid([u8; 16]),
1507}
1508
1509impl IndexKey {
1510    /// v7.37.43 (INSUBQ B-4) — inline-friendly BigInt fast path.
1511    /// `try_count_star_pk_in_subquery_fast` (and any other hot loop
1512    /// probing an integer PK) already holds an `i64`; this builds the
1513    /// `IndexKey` without going through the generic `from_value`
1514    /// dispatch tree.
1515    #[inline]
1516    pub fn from_i64(n: i64) -> Self {
1517        Self::Int(n)
1518    }
1519
1520    pub fn from_value(v: &Value<'_>) -> Option<Self> {
1521        match v {
1522            // v7.37.43 (INSUBQ B-4) — BigInt hits first (the dominant
1523            // INSUBQ shape probes PK as BigInt). Tiny micro-win.
1524            Value::BigInt(n) => Some(Self::Int(*n)),
1525            Value::SmallInt(n) => Some(Self::Int(i64::from(*n))),
1526            Value::Int(n) => Some(Self::Int(i64::from(*n))),
1527            Value::Text(s) => Some(Self::Text(s.clone().into_owned())),
1528            Value::Bool(b) => Some(Self::Bool(*b)),
1529            // Date/Timestamp use their integer storage repr as the
1530            // index key — same order semantics, same comparison.
1531            Value::Date(d) => Some(Self::Int(i64::from(*d))),
1532            Value::Timestamp(t) => Some(Self::Int(*t)),
1533            // v7.17.0: UUID indexable via byte-wise ordering. Lookup
1534            // on `id = '...'::uuid` resolves through the secondary
1535            // index rather than full-scan.
1536            Value::Uuid(b) => Some(Self::Uuid(*b)),
1537            // v7.17.0 Phase 3.P0-32: TIME indexable via i64 — same
1538            // order semantics as Date/Timestamp.
1539            Value::Time(us) => Some(Self::Int(*us)),
1540            // v7.17.0 Phase 3.P0-33: YEAR indexable as i64 — u16
1541            // widens losslessly and gives the natural calendar
1542            // ordering.
1543            Value::Year(y) => Some(Self::Int(i64::from(*y))),
1544            // v7.17.0 Phase 3.P0-34: TIMETZ indexable by its
1545            // UTC-equivalent microseconds (local wall - offset).
1546            // Without normalising, two values for the same
1547            // physical instant in different zones would sort
1548            // wrong. Matches PG's TIMETZ index behaviour.
1549            Value::TimeTz { us, offset_secs } => {
1550                Some(Self::Int(us - i64::from(*offset_secs) * 1_000_000))
1551            }
1552            // v7.17.0 Phase 3.P0-35: MONEY indexable as i64 cents
1553            // (no scaling needed — natural numeric ordering).
1554            Value::Money(c) => Some(Self::Int(*c)),
1555            // v7.17.0 Phase 3.P0-38: ranges are NOT indexable in
1556            // v7.17.0 — they'd need a custom comparator (PG uses
1557            // SP-GiST for this). Skip.
1558            Value::Range { .. } => None,
1559            // v7.17.0 Phase 3.P0-39: hstore is NOT indexable in
1560            // v7.17.0 — map columns need GIN with bespoke ops.
1561            Value::Hstore(_) => None,
1562            // v7.17.0 Phase 3.P0-40: 2D arrays aren't indexable.
1563            Value::IntArray2D(_) | Value::BigIntArray2D(_) | Value::TextArray2D(_) => None,
1564            // v7.37.5 β-P4: INTERVAL[] isn't indexable (PG uses
1565            // GIN/intarray for array-contains queries; SPG plans
1566            // that as a separate axis under v7.37.8 GIN-on-jsonb).
1567            Value::IntervalArray(_) => None,
1568            // v7.37.5 γ — none of the array-of-scalar family is
1569            // B-tree indexable. Same reason as IntervalArray: PG
1570            // serves array-contains / array-overlap queries via
1571            // GIN, and SPG's GIN axis lands in v7.37.8.
1572            Value::BoolArray(_)
1573            | Value::SmallIntArray(_)
1574            | Value::FloatArray(_)
1575            | Value::NumericArray(_)
1576            | Value::DateArray(_)
1577            | Value::TimestampArray(_)
1578            | Value::TimestamptzArray(_)
1579            | Value::UuidArray(_)
1580            | Value::JsonArray(_)
1581            | Value::JsonbArray(_)
1582            | Value::BytesArray(_)
1583            | Value::VarcharArray(_)
1584            | Value::CharArray(_)
1585            // v7.37.5 δ — multirange not indexable (PG uses GiST/
1586            // SP-GiST + a custom operator class; SPG plans the same
1587            // axis under v7.37.8 with ranges).
1588            | Value::Multirange { .. }
1589            // v7.37.5 ε — geometric scalars not B-tree indexable
1590            // (PG uses GiST/SP-GiST for these too; SPG plans the
1591            // same axis under v7.37.8).
1592            | Value::Point(_)
1593            | Value::Lseg(_, _)
1594            | Value::Path { .. }
1595            | Value::PgBox(_, _)
1596            | Value::Polygon(_)
1597            | Value::Line { .. }
1598            | Value::Circle { .. }
1599            // v7.37.5 ζ-A — network / bit / xml / "char" / money[].
1600            // INET / CIDR / MACADDR / MACADDR8 could be B-tree
1601            // indexable (PG does this), but the byte-wise compare
1602            // family-blind would mis-order IPv4 vs IPv6; left as
1603            // a follow-up under v7.37.8 GIN window.
1604            | Value::Inet { .. }
1605            | Value::Cidr { .. }
1606            | Value::Macaddr(_)
1607            | Value::Macaddr8(_)
1608            | Value::BitString { .. }
1609            | Value::Xml(_)
1610            | Value::Char1(_)
1611            | Value::MoneyArray(_) => None,
1612            // Numeric isn't (yet) indexable — exact-decimal index keys
1613            // would need a stable scale-normalised representation.
1614            // Interval isn't index-eligible either (and can't reach this
1615            // path through column storage anyway).
1616            Value::Null
1617            | Value::Float(_)
1618            | Value::Vector(_)
1619            | Value::Sq8Vector(_)
1620            | Value::HalfVector(_)
1621            | Value::Numeric { .. }
1622            | Value::Interval { .. }
1623            | Value::Json(_)
1624            | Value::Bytes(_)
1625            | Value::TextArray(_)
1626            | Value::IntArray(_)
1627            | Value::BigIntArray(_)
1628            | Value::TsVector(_)
1629            | Value::TsQuery(_) => None,
1630        }
1631    }
1632}
1633
1634/// A single-column secondary index. v2.0 carries either a B-tree map
1635/// (the default — used for equality / range lookups on scalar columns)
1636/// or a navigable-small-world graph (used for kNN over vector
1637/// columns).
1638#[derive(Debug, Clone)]
1639pub struct Index {
1640    pub name: String,
1641    pub column_position: usize,
1642    pub kind: IndexKind,
1643    /// v6.8.0 — column positions of `INCLUDE (col1, col2, …)`
1644    /// non-key columns. Carries the planner's "this query is
1645    /// covered by the index" signal; lookup paths still resolve
1646    /// via the `RowLocator` to fetch the row body, but EXPLAIN
1647    /// surfaces the covered-scan annotation so operators can
1648    /// confirm the planner sees the coverage.
1649    ///
1650    /// Empty `Vec` = no `INCLUDE` clause (the legacy shape). v12
1651    /// catalog snapshots deserialise with an empty vec.
1652    pub included_columns: Vec<usize>,
1653    /// v6.8.1 — partial-index predicate stored as its canonical
1654    /// Display form (the engine re-parses it on the maintenance
1655    /// path). `None` = unconditional index (the legacy shape).
1656    /// Persisted as `[u8 has_pred][u16 LE len][bytes]` on the
1657    /// catalog snapshot (FILE_VERSION 12, appended after
1658    /// `included_columns`).
1659    pub partial_predicate: Option<String>,
1660    /// v6.8.2 — expression-index key, stored as the expression's
1661    /// canonical Display form. `None` = bare column-reference
1662    /// index (the legacy shape). Persisted alongside
1663    /// `partial_predicate` on the v12 catalog snapshot.
1664    pub expression: Option<String>,
1665    /// v7.9.29 — `CREATE UNIQUE INDEX …`. When true the engine
1666    /// rejects INSERTs whose key already appears in this index
1667    /// (combined with `partial_predicate` when present — only
1668    /// rows matching the predicate enter the uniqueness check).
1669    /// Catalog FILE_VERSION 16+; older snapshots deserialise
1670    /// with `false`. mailrs K1.
1671    pub is_unique: bool,
1672    /// v7.9.29 — extra (non-leading) column positions for
1673    /// multi-column indexes (`CREATE INDEX … (a, b, c)`). The
1674    /// planner today still only uses the leading
1675    /// `column_position` for index seeks, but UNIQUE INDEX
1676    /// enforcement walks the full tuple so partial-unique
1677    /// invariants like CalDAV `(calendar_id, uid,
1678    /// recurrence_id)` are enforced correctly. Catalog
1679    /// FILE_VERSION 16+; older snapshots deserialise empty.
1680    pub extra_column_positions: Vec<usize>,
1681}
1682
1683/// Default neighbor degree (M) for the NSW graph. Picked at construction
1684/// time and persisted with the index.
1685pub const NSW_DEFAULT_M: usize = 16;
1686
1687/// v5.2.2: outcome of a successful [`Catalog::freeze_oldest_to_cold`]
1688/// call. The catalog state has already been mutated by the time this
1689/// is returned (hot rows dropped + segment registered + Cold locators
1690/// flipped). The caller's only remaining concern is `segment_bytes` —
1691/// persist them to disk under `<db>.spg/segments/seg_<id>.spg` so a
1692/// future restart can reload via the v5.1 `SPG_PRELOAD_COLD_SEGMENT`
1693/// path. (v5.3's manifest will subsume this manual step.)
1694#[derive(Debug, Clone)]
1695pub struct FreezeReport {
1696    /// Id allocated by [`Catalog::load_segment_bytes`] for the new
1697    /// cold-tier segment. Stable across the call's success path.
1698    pub segment_id: u32,
1699    /// Number of rows that moved hot → cold. Equals the `max_rows`
1700    /// the caller asked for (the API is strict on the count).
1701    pub frozen_rows: usize,
1702    /// Hot-tier bytes reclaimed by the freeze — the
1703    /// [`Table::hot_bytes`] delta before vs after. Useful to feed
1704    /// back into the freezer's budget check on the next tick.
1705    pub bytes_freed: u64,
1706    /// Encoded segment bytes, byte-identical to what
1707    /// [`encode_segment`] produced. The catalog already owns a
1708    /// copy inside `cold_segments`; this hand-off lets the caller
1709    /// persist them without re-encoding.
1710    pub segment_bytes: Vec<u8>,
1711}
1712
1713/// v6.7.4 — read-only output of [`Catalog::prepare_freeze_slice`].
1714/// Carries every row body + key in a contiguous hot-row range,
1715/// already encoded and sorted by PK so the coordinator's merge
1716/// step is a k-way merge over already-sorted streams.
1717///
1718/// `Vec<FreezeSlice>` from N independent workers feeds
1719/// [`Catalog::commit_freeze_slices`], which concats + encodes the
1720/// merged segment + atomically swaps the catalog state.
1721#[derive(Debug, Clone)]
1722pub struct FreezeSlice {
1723    /// Hot-row index range this slice covered (half-open, in the
1724    /// table's `rows: PersistentVec` ordering at call time). The
1725    /// commit step uses this to compute the union range that
1726    /// gets passed to [`Table::delete_rows`].
1727    pub row_range: core::ops::Range<usize>,
1728    /// `(pk_u64, encoded_row_body, IndexKey)` triples, sorted
1729    /// ascending by `pk_u64`. Per-slice sort happens inside
1730    /// `prepare_freeze_slice`; the coordinator does only a
1731    /// k-way merge to reach the global PK ordering
1732    /// [`encode_segment`] requires.
1733    pub rows: Vec<(u64, Vec<u8>, IndexKey)>,
1734}
1735
1736/// v6.7.3 — outcome of a [`Catalog::compact_cold_segments`] call.
1737/// The catalog state has already been mutated when this is returned:
1738/// the merged segment is loaded into `cold_segments`, the source
1739/// segment slots are tombstoned (`None`), and every BTree-index
1740/// `RowLocator::Cold` that previously pointed at a source now
1741/// points at the merged segment. The caller's remaining job is to
1742/// persist `merged_segment_bytes` under
1743/// `<db>.spg/segments/seg_<merged_segment_id>.spg` and update the
1744/// in-memory `segment_id → path` map (remove the source ids, add
1745/// the merged id) so the next CHECKPOINT writes a manifest that
1746/// no longer lists the retired sources.
1747///
1748/// On a no-op (fewer than 2 candidate segments under the threshold),
1749/// `merged_segment_id` is `None` and `sources` is empty; the
1750/// catalog was not mutated.
1751#[derive(Debug, Clone)]
1752pub struct CompactReport {
1753    /// Source segment ids that were merged + tombstoned.
1754    pub sources: Vec<u32>,
1755    /// Id allocated for the merged segment. `None` on no-op.
1756    pub merged_segment_id: Option<u32>,
1757    /// Encoded merged-segment bytes (empty on no-op).
1758    pub merged_segment_bytes: Vec<u8>,
1759    /// Number of rows that landed in the merged segment.
1760    pub merged_rows: usize,
1761    /// `Σ source.num_rows − merged_rows`. Rows present in source
1762    /// segment payloads but unreferenced by any live BTree
1763    /// `Cold` locator — DELETE'd-but-still-frozen rows that
1764    /// compaction GC'd during the merge.
1765    pub deleted_rows_pruned: usize,
1766    /// `Σ source.bytes() − merged.bytes()`. Estimate of on-disk
1767    /// space the merge will reclaim once the source segment files
1768    /// are GC'd. Saturating subtract — never negative.
1769    pub bytes_reclaimed_estimate: u64,
1770}
1771
1772#[derive(Debug, Clone)]
1773pub enum IndexKind {
1774    /// v4.40: structural-sharing B-tree over `IndexKey`. Replaces the v0.8
1775    /// `BTreeMap<IndexKey, Vec<usize>>` — `Index::clone` is now an `Arc`
1776    /// bump regardless of index size, so `Catalog::clone` inside the
1777    /// v4.34 auto-commit wrap stays O(1) even for tables with secondary
1778    /// indices (the case that bottlenecked v4.39 at 1M rows in the
1779    /// sweep).
1780    ///
1781    /// v5.1: value type widened from `Vec<usize>` to `Vec<RowLocator>` so
1782    /// a single key can point to a mix of hot-tier rows (`RowLocator::Hot`,
1783    /// equivalent to the pre-v5 `usize` row index) and cold-tier rows
1784    /// (`RowLocator::Cold { segment_id, page_offset }`) once the v5.2
1785    /// freezer starts producing them. Pre-v5.2 only `Hot` entries appear
1786    /// — the on-disk encoding stays at `FILE_VERSION` 8 (raw u64 row index)
1787    /// because every locator round-trips through `RowLocator::from_legacy_v8_u64`
1788    /// without information loss. `FILE_VERSION` 9 with tagged encoding lands
1789    /// alongside the first freezer commit (v5.1 step 2b / v5.2).
1790    BTree(PersistentBTreeMap<IndexKey, Vec<RowLocator>>),
1791    /// Navigable-small-world graph for vector kNN search.
1792    Nsw(NswGraph),
1793    /// v6.7.1 — BRIN (Block Range INdex). Pure metadata: BRIN
1794    /// indexes carry NO in-memory key→locator map. The (min,
1795    /// max) summaries live in each cold-tier segment's v2
1796    /// envelope sidecar; the BRIN entry in `Table.indices` only
1797    /// records THAT a BRIN index exists on this column so the
1798    /// segment encoder + planner can opt into the summary path.
1799    Brin {
1800        /// The cell type at `column_position` at CREATE INDEX time.
1801        /// Used by the planner to type-check WHERE-clause range
1802        /// predicates against the BRIN-indexed column.
1803        column_type: DataType,
1804    },
1805    /// v7.12.3 — GIN inverted index over a `tsvector` column.
1806    ///
1807    /// Storage shape: `lexeme word → Vec<RowLocator>`. The posting
1808    /// list per word is appended in row-order, so range scans are
1809    /// O(matching rows) once the per-word lookup is done. Multi-
1810    /// term queries intersect / union posting lists.
1811    ///
1812    /// `IndexKey::from_value(TsVector)` returns `None` — GIN doesn't
1813    /// participate in `try_index_seek` (which is BTree-equality-keyed).
1814    /// The engine consults this index through `try_gin_lookup` on
1815    /// `WHERE col @@ tsquery` predicates instead.
1816    ///
1817    /// Backed by a `PersistentBTreeMap` so `Catalog::clone` (the
1818    /// per-write snapshot) stays O(1) — same structural-sharing
1819    /// invariant as BTree.
1820    Gin(PersistentBTreeMap<alloc::string::String, Vec<RowLocator>>),
1821    /// v7.15.0 — `USING gin (col gin_trgm_ops)` over a `TEXT`
1822    /// column. Posting lists map `trigram` (PG-compatible 3-byte
1823    /// shingle on the lower-cased + space-padded input) to row
1824    /// locators. The planner uses this index to accelerate
1825    /// `WHERE col LIKE '…'` / `ILIKE '…'` / `similarity(col, q) >
1826    /// t` — every literal run of length ≥ 1 in the pattern
1827    /// produces a trigram set, the engine intersects the posting
1828    /// lists, and the LIKE / similarity predicate is re-evaluated
1829    /// per candidate row to filter the over-approximation.
1830    /// Persisted via tag-4 index payload in `FILE_VERSION` 24+.
1831    GinTrgm(PersistentBTreeMap<alloc::string::String, Vec<RowLocator>>),
1832    /// v7.17.0 Phase 2.2 — MySQL `FULLTEXT KEY (col)` over a
1833    /// `TEXT` / `VARCHAR` column. Posting lists map
1834    /// `tsvector('simple') lexeme` to row locators. At insert /
1835    /// build time the engine derives the lexemes from the cell
1836    /// via the same lower-case tokenisation rule as
1837    /// `to_tsvector('simple', ...)` — the column itself stays a
1838    /// plain text type on disk (mysqldump round-trips would be
1839    /// broken otherwise). The planner uses this index to
1840    /// accelerate MySQL-shape `MATCH(col) AGAINST('term')`
1841    /// queries by mapping them onto the existing tsquery `@@`
1842    /// walker. Persisted via tag-5 index payload in
1843    /// `FILE_VERSION` 33+.
1844    GinFulltext(PersistentBTreeMap<alloc::string::String, Vec<RowLocator>>),
1845    /// v7.37.8(sentori Epic 5 P2)— `USING gin (col)` over a
1846    /// `JSON` / `JSONB` column. Posting lists map a canonical
1847    /// `(path, leaf)` token(see [`crate::jsonb_gin::extract_tokens`])
1848    /// to row locators so the planner can resolve
1849    /// `<col> @> <jsonb_literal>` to a candidate row set via
1850    /// posting-list intersection + per-row `json::contains`
1851    /// re-verification. Pre-7.37.8 the same DDL loaded as a
1852    /// BTree fallback so `pg_dump` JSONB-GIN scripts kept loading
1853    /// without query-time acceleration. Persisted via tag-6 index
1854    /// payload in `FILE_VERSION` 51+.
1855    GinJsonb(PersistentBTreeMap<alloc::string::String, Vec<RowLocator>>),
1856}
1857
1858impl IndexKind {
1859    /// v7.31 (memory campaign, C2) — bytes this index variant holds
1860    /// resident in RAM, computed by walking its OWN structure rather
1861    /// than a parametric guess made by the engine. Replaces the old
1862    /// `spg_admin::memory_stats` inline match, which charged NSW with
1863    /// a stale `m_max_0 * 8` per node (neighbour slots are `u32` = 4 B
1864    /// since v6.1.x, and most nodes never fill `m_max_0`) and lumped
1865    /// every GIN family index into a flat 1 KiB token — a gross
1866    /// undercount for the text-heavy posting lists that dominate
1867    /// mailrs' footprint. Per-entry container overhead uses the
1868    /// 3-word (24 B on 64-bit) `Vec`/`String` header as the charge.
1869    ///
1870    /// O(index entries): operator/monitoring surface (`memory_stats` /
1871    /// `spg_memory_stats`), not a query path.
1872    #[must_use]
1873    pub fn approx_resident_bytes(&self) -> u64 {
1874        const HEADER: usize = 24; // Vec/String 3-word header on 64-bit.
1875        let loc = core::mem::size_of::<RowLocator>();
1876        match self {
1877            IndexKind::BTree(map) => {
1878                let key = core::mem::size_of::<IndexKey>();
1879                map.iter()
1880                    .map(|(_, locs)| (key + HEADER + locs.len() * loc) as u64)
1881                    .sum()
1882            }
1883            IndexKind::Nsw(g) => {
1884                // `levels` is one byte per node; each layer's adjacency
1885                // is a `Vec<u32>` per node whose actual length we walk
1886                // (the dense layer-0 list dominates, but upper layers
1887                // are sparse — the old estimate ignored that).
1888                let mut b = g.levels.len() as u64;
1889                for layer in &g.layers {
1890                    for nbrs in layer.iter() {
1891                        b += (HEADER + nbrs.len() * core::mem::size_of::<u32>()) as u64;
1892                    }
1893                }
1894                b
1895            }
1896            // BRIN carries NO in-memory key→locator map (the (min,max)
1897            // summaries live in cold-segment sidecars on disk); the
1898            // resident footprint is just the column-type token.
1899            IndexKind::Brin { .. } => core::mem::size_of::<DataType>() as u64,
1900            IndexKind::Gin(map)
1901            | IndexKind::GinTrgm(map)
1902            | IndexKind::GinFulltext(map)
1903            | IndexKind::GinJsonb(map) => map
1904                .iter()
1905                .map(|(word, postings)| {
1906                    (word.len() + HEADER + HEADER + postings.len() * loc) as u64
1907                })
1908                .sum(),
1909        }
1910    }
1911}
1912
1913/// Multi-layer HNSW graph (v2.13). Each node is assigned a `top_level`;
1914/// it appears in layers `0..=top_level`. Higher layers are sparser, so
1915/// search starts from the entry at the top layer, greedy-descends to
1916/// layer 0, and beam-searches there. Layer 0 keeps a larger neighbour
1917/// budget (`m_max_0 = 2 * m` per the HNSW paper); upper layers cap at
1918/// `m`. The struct name stays `NswGraph` so external users / on-disk
1919/// callers don't have to track a rename — the algorithm changed, the
1920/// data slot didn't.
1921#[derive(Debug, Clone)]
1922pub struct NswGraph {
1923    /// Max neighbours per node on layers ≥ 1.
1924    pub m: usize,
1925    /// Max neighbours on layer 0 (the dense bottom layer). HNSW
1926    /// convention: `m_max_0 = 2 * m`.
1927    pub m_max_0: usize,
1928    /// Entry point — the node that sits on the topmost layer. Search
1929    /// always starts here.
1930    pub entry: Option<usize>,
1931    /// Top layer of the entry node (== `layers.len() - 1` when populated).
1932    pub entry_level: u8,
1933    /// `levels[i]` = top layer of node `i`. Nodes whose vector cell is
1934    /// NULL / non-Vector have `levels[i] = 0` and no neighbour entries.
1935    ///
1936    /// v5.5.0: backed by `PersistentVec` so `NswGraph::clone` (and the
1937    /// `Catalog::clone` on every group-commit write that contains it) is O(1)
1938    /// structural-sharing instead of an O(N) element copy.
1939    pub levels: PersistentVec<u8>,
1940    /// `layers[l][i]` = neighbours of node `i` at layer `l`. Inner vec
1941    /// is empty when node `i` doesn't reach layer `l`.
1942    ///
1943    /// v5.5.0: the per-node middle dimension (the O(N) one) is a
1944    /// `PersistentVec`; the outer layer dimension stays a plain `Vec`
1945    /// (layer count ≤ 8, so its clone is O(1) in practice) and the inner
1946    /// neighbour list stays a `Vec` (bounded by `m_max_0`).
1947    ///
1948    /// v6.1.x: neighbour slot widened from `usize` (8 B on 64-bit) to
1949    /// `u32` (4 B). Row indices are catalog-bounded by `u32::MAX` (4G
1950    /// rows per table); the cast at the NSW boundary asserts this. At
1951    /// 1M dim-128 SQ8, layer 0 adjacency alone shrinks by ~128 MiB
1952    /// — the largest single contribution to the v6.0.5-measured
1953    /// 624 MiB ambition gap. On-disk format already used u32 LE, so
1954    /// this is a pure in-memory layout change; no `FILE_VERSION` bump.
1955    pub layers: Vec<PersistentVec<Vec<u32>>>,
1956}
1957
1958impl NswGraph {
1959    fn new(m: usize) -> Self {
1960        Self {
1961            m,
1962            m_max_0: m.saturating_mul(2),
1963            entry: None,
1964            entry_level: 0,
1965            levels: PersistentVec::new(),
1966            layers: alloc::vec![PersistentVec::new()],
1967        }
1968    }
1969
1970    /// Max-neighbour budget for layer `l`.
1971    pub const fn cap_for_layer(&self, layer: u8) -> usize {
1972        if layer == 0 { self.m_max_0 } else { self.m }
1973    }
1974}
1975
1976/// Deterministic level assignment, seeded on the row index so the same
1977/// insert order reproduces the same topology. Distribution is roughly
1978/// HNSW-flavoured with `mL ≈ 1/ln(M) ≈ 0.36` for M=16: each 4-bit
1979/// chunk that comes up zero promotes the node one layer (so P(level ≥
1980/// L) ≈ (1/16)^L).
1981#[allow(clippy::verbose_bit_mask)] // clippy suggests trailing_zeros(); we need an explicit MAX cap and a stable distribution shape.
1982pub fn nsw_assign_level(row_idx: usize) -> u8 {
1983    const MAX_LEVEL: u8 = 7; // 7 ⇒ ~16^7 ≈ 2.7e8 expected nodes between promotions; ample.
1984    // SplitMix-style mixer — cheap and seedable.
1985    let mut x = (row_idx as u64).wrapping_mul(0x9E37_79B9_7F4A_7C15);
1986    x ^= x >> 30;
1987    x = x.wrapping_mul(0xBF58_476D_1CE4_E5B9);
1988    x ^= x >> 27;
1989    x = x.wrapping_mul(0x94D0_49BB_1331_11EB);
1990    x ^= x >> 31;
1991    // Count contiguous low-end zero nibbles (4-bit chunks). Each zero
1992    // nibble has probability 1/16, mirroring HNSW's `mL ≈ 1/ln(M)` for
1993    // M=16. `trailing_zeros / 4` would lose the ordering when x = 0, so
1994    // a plain loop with a cap is clearer.
1995    let mut level: u8 = 0;
1996    while x & 0xF == 0 && level < MAX_LEVEL {
1997        level += 1;
1998        x >>= 4;
1999    }
2000    level
2001}
2002
2003impl Index {
2004    fn new_btree(name: String, column_position: usize) -> Self {
2005        Self {
2006            name,
2007            column_position,
2008            kind: IndexKind::BTree(PersistentBTreeMap::new()),
2009            included_columns: Vec::new(),
2010            partial_predicate: None,
2011            expression: None,
2012            is_unique: false,
2013            extra_column_positions: Vec::new(),
2014        }
2015    }
2016
2017    fn new_nsw(name: String, column_position: usize, m: usize) -> Self {
2018        Self {
2019            name,
2020            column_position,
2021            kind: IndexKind::Nsw(NswGraph::new(m)),
2022            included_columns: Vec::new(),
2023            partial_predicate: None,
2024            expression: None,
2025            is_unique: false,
2026            extra_column_positions: Vec::new(),
2027        }
2028    }
2029
2030    /// v6.7.1 — BRIN index constructor. BRIN carries no in-memory
2031    /// data; the `column_type` snapshot is used by the segment
2032    /// encoder + planner for type-checking range predicates.
2033    fn new_brin(name: String, column_position: usize, column_type: DataType) -> Self {
2034        Self {
2035            name,
2036            column_position,
2037            kind: IndexKind::Brin { column_type },
2038            included_columns: Vec::new(),
2039            partial_predicate: None,
2040            expression: None,
2041            is_unique: false,
2042            extra_column_positions: Vec::new(),
2043        }
2044    }
2045
2046    /// v7.12.3 — GIN inverted-index constructor. Empty posting-list
2047    /// map; caller (typically [`Table::add_gin_index`] or
2048    /// [`Table::restore_gin_index`]) populates it from existing rows
2049    /// or from a deserialised snapshot.
2050    fn new_gin(name: String, column_position: usize) -> Self {
2051        Self {
2052            name,
2053            column_position,
2054            kind: IndexKind::Gin(PersistentBTreeMap::new()),
2055            included_columns: Vec::new(),
2056            partial_predicate: None,
2057            expression: None,
2058            is_unique: false,
2059            extra_column_positions: Vec::new(),
2060        }
2061    }
2062
2063    /// v7.15.0 — `gin_trgm_ops`-flavoured GIN constructor. Same
2064    /// shape as `new_gin` but the posting-list keys are 3-byte
2065    /// trigram shingles (`pg_trgm`-compatible) and the column
2066    /// type is `TEXT` / `VARCHAR` (not `TSVECTOR`).
2067    fn new_gin_trgm(name: String, column_position: usize) -> Self {
2068        Self {
2069            name,
2070            column_position,
2071            kind: IndexKind::GinTrgm(PersistentBTreeMap::new()),
2072            included_columns: Vec::new(),
2073            partial_predicate: None,
2074            expression: None,
2075            is_unique: false,
2076            extra_column_positions: Vec::new(),
2077        }
2078    }
2079
2080    /// v7.17.0 Phase 2.2 — MySQL `FULLTEXT KEY` GIN constructor.
2081    /// Same shape as `new_gin_trgm` but the posting-list keys
2082    /// are lower-cased word lexemes (`to_tsvector('simple', col)`
2083    /// equivalent) instead of trigrams, and the column type is
2084    /// `TEXT` / `VARCHAR` (not `TSVECTOR`).
2085    fn new_gin_fulltext(name: String, column_position: usize) -> Self {
2086        Self {
2087            name,
2088            column_position,
2089            kind: IndexKind::GinFulltext(PersistentBTreeMap::new()),
2090            included_columns: Vec::new(),
2091            partial_predicate: None,
2092            expression: None,
2093            is_unique: false,
2094            extra_column_positions: Vec::new(),
2095        }
2096    }
2097
2098    /// v7.37.8(sentori Epic 5 P2)— JSONB-GIN constructor. Same
2099    /// shape as the other GIN-family indexes; posting-list keys
2100    /// are the canonical `(path, leaf)` tokens emitted by
2101    /// `crate::jsonb_gin::extract_tokens`. Maintains posting
2102    /// lists from `Value::Json` cells(JSONB is a synonym for the
2103    /// same in-memory string-backed Value).
2104    fn new_gin_jsonb(name: String, column_position: usize) -> Self {
2105        Self {
2106            name,
2107            column_position,
2108            kind: IndexKind::GinJsonb(PersistentBTreeMap::new()),
2109            included_columns: Vec::new(),
2110            partial_predicate: None,
2111            expression: None,
2112            is_unique: false,
2113            extra_column_positions: Vec::new(),
2114        }
2115    }
2116
2117    /// v7.34.4 — descending-order iterator over `(IndexKey, locators)`
2118    /// pairs for a BTree index, with O(log N) descent to the rightmost
2119    /// leaf and lazy emission thereafter. Returns an empty iterator
2120    /// for non-BTree index kinds — callers handle both uniformly.
2121    /// Used by the ORDER BY `<indexed col>` DESC + LIMIT N executor
2122    /// path: walking only the first N matches off the rightmost leaf
2123    /// avoids the per-row materialisation + partial-sort cost on
2124    /// large tables (mailrs `content_worker` at 250 k rows).
2125    pub fn iter_desc(
2126        &self,
2127    ) -> alloc::boxed::Box<dyn Iterator<Item = (&IndexKey, &alloc::vec::Vec<RowLocator>)> + '_>
2128    {
2129        match &self.kind {
2130            IndexKind::BTree(m) => alloc::boxed::Box::new(m.iter_rev()),
2131            IndexKind::Nsw(_)
2132            | IndexKind::Brin { .. }
2133            | IndexKind::Gin(_)
2134            | IndexKind::GinTrgm(_)
2135            | IndexKind::GinFulltext(_)
2136            | IndexKind::GinJsonb(_) => alloc::boxed::Box::new(core::iter::empty()),
2137        }
2138    }
2139
2140    /// v7.34.4 — ascending-order iterator over `(IndexKey, locators)`
2141    /// pairs. Mirror of `iter_desc` for ORDER BY ... ASC + LIMIT N.
2142    pub fn iter_asc(
2143        &self,
2144    ) -> alloc::boxed::Box<dyn Iterator<Item = (&IndexKey, &alloc::vec::Vec<RowLocator>)> + '_>
2145    {
2146        match &self.kind {
2147            IndexKind::BTree(m) => alloc::boxed::Box::new(m.iter()),
2148            IndexKind::Nsw(_)
2149            | IndexKind::Brin { .. }
2150            | IndexKind::Gin(_)
2151            | IndexKind::GinTrgm(_)
2152            | IndexKind::GinFulltext(_)
2153            | IndexKind::GinJsonb(_) => alloc::boxed::Box::new(core::iter::empty()),
2154        }
2155    }
2156
2157    /// Look up the locators stored under `key` (B-tree only). Returns
2158    /// an empty slice when the key is absent or the index isn't a
2159    /// BTree — callers can treat both cases uniformly.
2160    ///
2161    /// v5.1: return type widened from `&[usize]` to `&[RowLocator]`.
2162    /// Pre-v5.2 callers can read the slice and `.as_hot().unwrap()`
2163    /// each entry (no `Cold` variants exist until the freezer lands);
2164    /// post-v5.2 callers dispatch hot vs. cold per locator.
2165    pub fn lookup_eq(&self, key: &IndexKey) -> &[RowLocator] {
2166        match &self.kind {
2167            IndexKind::BTree(m) => m.get(key).map_or(&[][..], Vec::as_slice),
2168            // BRIN / NSW / GIN / trigram-GIN / fulltext-GIN have
2169            // no IndexKey-keyed map; lookup is a no-op. GIN uses
2170            // [`Index::gin_lookup_word`] instead.
2171            IndexKind::Nsw(_)
2172            | IndexKind::Brin { .. }
2173            | IndexKind::Gin(_)
2174            | IndexKind::GinTrgm(_)
2175            | IndexKind::GinFulltext(_)
2176            | IndexKind::GinJsonb(_) => &[][..],
2177        }
2178    }
2179
2180    /// v7.37.43 (INSUBQ B-2) — specialised lookup for integer-PK probes.
2181    /// `try_count_star_pk_in_subquery_fast` already holds an `i64` (the
2182    /// inner survivor key); skip the `IndexKey::from_value` enum-dispatch
2183    /// trip and build the key inline. ~20 ns × N_survivors saved on
2184    /// the INSUBQ hot loop.
2185    #[inline]
2186    pub fn lookup_eq_i64(&self, n: i64) -> &[RowLocator] {
2187        match &self.kind {
2188            IndexKind::BTree(m) => m.get(&IndexKey::Int(n)).map_or(&[][..], Vec::as_slice),
2189            IndexKind::Nsw(_)
2190            | IndexKind::Brin { .. }
2191            | IndexKind::Gin(_)
2192            | IndexKind::GinTrgm(_)
2193            | IndexKind::GinFulltext(_)
2194            | IndexKind::GinJsonb(_) => &[][..],
2195        }
2196    }
2197
2198    /// v7.12.3 — GIN posting-list lookup. Returns the row locators
2199    /// whose `tsvector` cell contains `word`. Empty when the word is
2200    /// absent from the index or this isn't a GIN index.
2201    pub fn gin_lookup_word(&self, word: &str) -> &[RowLocator] {
2202        match &self.kind {
2203            // v7.17.0 Phase 2.2 — fulltext-GIN shares the same
2204            // lexeme-keyed posting list shape as the
2205            // tsvector-typed GIN, so the same lookup applies.
2206            IndexKind::Gin(m) | IndexKind::GinFulltext(m) => {
2207                m.get(&String::from(word)).map_or(&[][..], Vec::as_slice)
2208            }
2209            IndexKind::BTree(_)
2210            | IndexKind::Nsw(_)
2211            | IndexKind::Brin { .. }
2212            | IndexKind::GinTrgm(_)
2213            | IndexKind::GinJsonb(_) => &[][..],
2214        }
2215    }
2216
2217    /// v7.15.0 — trigram-GIN posting-list lookup. Returns the row
2218    /// locators whose indexed `TEXT` cell contains the trigram
2219    /// `tri`. Empty when the trigram is absent or this isn't a
2220    /// trigram-GIN index.
2221    pub fn gin_trgm_lookup(&self, tri: &str) -> &[RowLocator] {
2222        match &self.kind {
2223            IndexKind::GinTrgm(m) => m.get(&String::from(tri)).map_or(&[][..], Vec::as_slice),
2224            IndexKind::BTree(_)
2225            | IndexKind::Nsw(_)
2226            | IndexKind::Brin { .. }
2227            | IndexKind::Gin(_)
2228            | IndexKind::GinFulltext(_)
2229            | IndexKind::GinJsonb(_) => &[][..],
2230        }
2231    }
2232
2233    /// v7.37.8(sentori Epic 5 P2)— JSONB-GIN posting-list lookup.
2234    /// Returns the row locators whose indexed JSONB cell carries
2235    /// the canonical `token`(see [`crate::jsonb_gin::extract_tokens`]).
2236    /// Empty when the token is absent or this isn't a JSONB-GIN
2237    /// index. Planners drive `<col> @> <jsonb_literal>` through here.
2238    pub fn gin_jsonb_lookup(&self, token: &str) -> &[RowLocator] {
2239        match &self.kind {
2240            IndexKind::GinJsonb(m) => m.get(&String::from(token)).map_or(&[][..], Vec::as_slice),
2241            IndexKind::BTree(_)
2242            | IndexKind::Nsw(_)
2243            | IndexKind::Brin { .. }
2244            | IndexKind::Gin(_)
2245            | IndexKind::GinTrgm(_)
2246            | IndexKind::GinFulltext(_) => &[][..],
2247        }
2248    }
2249
2250    /// Borrow the NSW graph (if this is an NSW index). Callers that need
2251    /// the graph for a kNN search go through here.
2252    pub const fn nsw(&self) -> Option<&NswGraph> {
2253        match &self.kind {
2254            IndexKind::Nsw(g) => Some(g),
2255            IndexKind::BTree(_)
2256            | IndexKind::Brin { .. }
2257            | IndexKind::Gin(_)
2258            | IndexKind::GinTrgm(_)
2259            | IndexKind::GinFulltext(_)
2260            | IndexKind::GinJsonb(_) => None,
2261        }
2262    }
2263
2264    /// v6.7.1 — true when this index is a BRIN (block range) index.
2265    /// Used by the segment encoder to opt into BRIN sidecar emission
2266    /// at freeze time, and by the planner to opt into page-skipping
2267    /// on range predicates.
2268    pub const fn is_brin(&self) -> bool {
2269        matches!(self.kind, IndexKind::Brin { .. })
2270    }
2271
2272    /// v7.15.0 — true when this index is a trigram GIN
2273    /// (`gin_trgm_ops`-flavoured). Used by the LIKE planner to
2274    /// opt into trigram acceleration.
2275    pub const fn is_gin_trgm(&self) -> bool {
2276        matches!(self.kind, IndexKind::GinTrgm(_))
2277    }
2278
2279    /// v7.12.3 — true when this index is a GIN inverted index.
2280    /// Used by the planner to opt into posting-list acceleration on
2281    /// `WHERE col @@ tsquery` predicates.
2282    pub const fn is_gin(&self) -> bool {
2283        matches!(self.kind, IndexKind::Gin(_))
2284    }
2285
2286    /// v7.17.0 Phase 2.2 — true when this index is a fulltext
2287    /// GIN over a TEXT / VARCHAR column (MySQL `FULLTEXT KEY`
2288    /// surface). Used by the planner to opt the FULLTEXT-indexed
2289    /// column into MATCH AGAINST acceleration.
2290    pub const fn is_gin_fulltext(&self) -> bool {
2291        matches!(self.kind, IndexKind::GinFulltext(_))
2292    }
2293
2294    /// v7.37.8(sentori Epic 5 P2)— true when this index is a
2295    /// real JSONB-GIN(posting-list backed). Used by the planner
2296    /// to opt `<col> @> <jsonb_literal>` into posting-list seek.
2297    pub const fn is_gin_jsonb(&self) -> bool {
2298        matches!(self.kind, IndexKind::GinJsonb(_))
2299    }
2300}
2301
2302/// In-memory table: schema + a persistent row vector + secondary indices.
2303///
2304/// v4.39: `rows` is a [`PersistentVec`] (Bitmapped Vector Trie, 32-way) so
2305/// `Table::clone()` is `O(1)` — the whole reason for v4.39's existence is
2306/// to make `Catalog::clone()` cheap inside the v4.34 auto-commit wrap.
2307///
2308/// v5.2.1: `hot_bytes` tracks the encoded byte size of every row currently
2309/// in [`Self::rows`], summed over rows. Updated incrementally by `insert`
2310/// (+= encoded row size), `delete_rows` (-= removed rows' encoded sizes),
2311/// and `update_row` (-= old size, += new size). The value is what the
2312/// v5.2 freezer reads to decide when to demote cold rows — when the
2313/// catalog-wide sum crosses `SPG_HOT_TIER_BYTES` (default 4 GiB) the
2314/// freezer thread wakes. v5.2.1 ships measurement only; the freezer
2315/// itself lands in v5.2.2. Stored as `u64` so a single field clone in
2316/// `Catalog::clone` stays at the O(1) invariant v4.39 built.
2317/// v7.34 (crash-recovery P0 #2) — one row-level physical redo record.
2318/// Row-level redo replaces statement-based WAL replay (which re-executes
2319/// each SQL through the full engine — O(records × catalog_rows), the
2320/// superlinear recovery hang root-caused on the mailrs crash-recovery
2321/// P0). A `RowChange` is the exact storage mutation the engine applied
2322/// (`Table::insert` / `update_row` / `delete_rows`); replaying it on a
2323/// catalog restored from the matching checkpoint reproduces the state
2324/// WITHOUT re-validating uniqueness/FK/parse/plan — O(changed rows).
2325///
2326/// Positions are physical, not key-based: `serialize`/`deserialize`
2327/// preserve row order exactly (rows written + read back in `self.rows`
2328/// order) and the mutation ops are deterministic, so the same op sequence
2329/// replayed from the same checkpoint reproduces the same positions. This
2330/// matches PostgreSQL's physical redo and supports tables with no primary
2331/// key. (Caveat handled at replay integration: a post-checkpoint cold-tier
2332/// freeze shifts hot positions and must itself be logged or fenced by a
2333/// checkpoint — see `row-level-redo-design`.)
2334#[derive(Debug, Clone, PartialEq)]
2335pub enum RowChange {
2336    /// Append `row` to `table`.
2337    Insert { table: String, row: Row<'static> },
2338    /// Replace the row at physical `pos` in `table` with `new_row`.
2339    Update {
2340        table: String,
2341        pos: usize,
2342        new_row: Vec<Value<'static>>,
2343    },
2344    /// Remove the rows at the given physical `positions` from `table`.
2345    Delete {
2346        table: String,
2347        positions: Vec<usize>,
2348    },
2349}
2350
2351/// v7.34 (crash-recovery P0 #2) — encode a row-level redo log to bytes for
2352/// a WAL record. Self-describing: the writer's `FILE_VERSION` leads so a
2353/// later spg can decode it via the version-gated value codec. Layout:
2354/// `[u8 version][u32 count]` then per change `[u8 op][str table]` and,
2355/// per op, `Insert [u32 n][value×n]`, `Update [u32 pos][u32 n][value×n]`,
2356/// `Delete [u32 n][u32 pos×n]`. Positions are physical (u32 ≤ 4 G rows).
2357#[must_use]
2358pub fn encode_redo_log(changes: &[RowChange]) -> Vec<u8> {
2359    let mut out = Vec::new();
2360    out.push(FILE_VERSION);
2361    codec::write_u32(&mut out, changes.len() as u32);
2362    let write_values = |out: &mut Vec<u8>, vals: &[Value<'static>]| {
2363        codec::write_u32(out, vals.len() as u32);
2364        for v in vals {
2365            codec::write_value(out, v);
2366        }
2367    };
2368    for change in changes {
2369        match change {
2370            RowChange::Insert { table, row } => {
2371                out.push(0);
2372                codec::write_str(&mut out, table);
2373                write_values(&mut out, &row.values);
2374            }
2375            RowChange::Update {
2376                table,
2377                pos,
2378                new_row,
2379            } => {
2380                out.push(1);
2381                codec::write_str(&mut out, table);
2382                codec::write_u32(&mut out, *pos as u32);
2383                write_values(&mut out, new_row);
2384            }
2385            RowChange::Delete { table, positions } => {
2386                out.push(2);
2387                codec::write_str(&mut out, table);
2388                codec::write_u32(&mut out, positions.len() as u32);
2389                for p in positions {
2390                    codec::write_u32(&mut out, *p as u32);
2391                }
2392            }
2393        }
2394    }
2395    out
2396}
2397
2398/// v7.34 — decode a row-level redo log written by [`encode_redo_log`].
2399/// A truncated / corrupt buffer is a hard error (the embedding layer
2400/// frames each record with its own length + CRC; a frame that decodes
2401/// short is corruption, not a torn tail).
2402pub fn decode_redo_log(bytes: &[u8]) -> Result<Vec<RowChange>, StorageError> {
2403    let version = *bytes
2404        .first()
2405        .ok_or_else(|| StorageError::Corrupt("redo log: empty".into()))?;
2406    let mut cur = codec::Cursor::new(bytes).with_codec_version(version);
2407    let _version = cur.read_u8()?;
2408    let count = cur.read_u32()? as usize;
2409    let mut read_values =
2410        |cur: &mut codec::Cursor<'_>| -> Result<Vec<Value<'static>>, StorageError> {
2411            let n = cur.read_u32()? as usize;
2412            let mut vals = Vec::with_capacity(n);
2413            for _ in 0..n {
2414                vals.push(cur.read_value()?);
2415            }
2416            Ok(vals)
2417        };
2418    let mut changes = Vec::with_capacity(count);
2419    for _ in 0..count {
2420        let op = cur.read_u8()?;
2421        let table = cur.read_str()?;
2422        let change = match op {
2423            0 => RowChange::Insert {
2424                table,
2425                row: Row::new(read_values(&mut cur)?),
2426            },
2427            1 => {
2428                let pos = cur.read_u32()? as usize;
2429                RowChange::Update {
2430                    table,
2431                    pos,
2432                    new_row: read_values(&mut cur)?,
2433                }
2434            }
2435            2 => {
2436                let n = cur.read_u32()? as usize;
2437                let mut positions = Vec::with_capacity(n);
2438                for _ in 0..n {
2439                    positions.push(cur.read_u32()? as usize);
2440                }
2441                RowChange::Delete { table, positions }
2442            }
2443            other => {
2444                return Err(StorageError::Corrupt(alloc::format!(
2445                    "redo log: unknown op {other}"
2446                )));
2447            }
2448        };
2449        changes.push(change);
2450    }
2451    Ok(changes)
2452}
2453
2454#[derive(Debug, Clone)]
2455pub struct Table {
2456    schema: TableSchema,
2457    rows: PersistentVec<Row<'static>>,
2458    indices: Vec<Index>,
2459    hot_bytes: u64,
2460    /// v6.7.0 — cached count of rows currently materialised in the
2461    /// cold tier via `RowLocator::Cold` entries across THIS table's
2462    /// indices. Populated by `ANALYZE` (walks every BTree index and
2463    /// counts Cold locators); the count survives until the next
2464    /// ANALYZE recomputes it. Surfaced via `spg_statistic.cold_row_count`
2465    /// and `spg_stat_segment.table_name`.
2466    ///
2467    /// Honest scope: this is a CACHED count, not a live one.
2468    /// Freezer / promote / DELETE don't currently update the cache
2469    /// incrementally — they invalidate it by setting the
2470    /// `cold_row_count_stale` flag, and the next ANALYZE re-walks.
2471    /// Incremental maintenance is a v6.7.x candidate if observation
2472    /// shows the ANALYZE walk cost dominates.
2473    cold_row_count: u64,
2474    /// v6.7.0 — set when the cached `cold_row_count` may be wrong
2475    /// because rows moved into / out of the cold tier since the last
2476    /// ANALYZE. The virtual-table surface reports the cached value
2477    /// regardless (operators run ANALYZE to refresh).
2478    cold_row_count_stale: bool,
2479    /// v7.34 (crash-recovery P0 #2) — row-level redo capture buffer.
2480    /// `None` (default, in-memory mode) captures nothing — zero overhead.
2481    /// `Some` (set by the engine when persistence is on, before a
2482    /// mutating call) makes `insert` / `update_row` / `delete_rows`
2483    /// record the physical [`RowChange`] they applied, which the engine
2484    /// drains after the statement and writes to the WAL in place of the
2485    /// SQL text. Transient: never serialized; a `Catalog::clone` between
2486    /// enable and drain copies it (cheap — empty in the steady state).
2487    redo_log: Option<Vec<RowChange>>,
2488}
2489
2490/// Catalog: insertion-ordered `Vec<Table>` for stable iter / serialize,
2491/// plus a `BTreeMap<String, usize>` sidecar index so `get` / `get_mut`
2492/// run in O(log n) instead of the old linear scan with per-element
2493/// string compares.
2494///
2495/// A pure `BTreeMap<String, Table>` was tried in an interim version
2496/// of v3.1.2 and regressed the single-table catalog benches by ~10%
2497/// (the per-element `BTreeMap` overhead outweighs the lookup win
2498/// when n is small). The sidecar shape preserves the insertion-order
2499/// iteration the on-disk encoding relies on and keeps `last_mut`
2500/// (used by the deserialize hot path) cheap.
2501#[derive(Debug, Clone, Default)]
2502pub struct Catalog {
2503    tables: Vec<Table>,
2504    /// `name → tables[index]`. Kept in lock-step with `tables`.
2505    /// `create_table` is the only write path.
2506    by_name: BTreeMap<String, usize>,
2507    /// v5.1: in-memory cold-tier segments. Side-loaded via
2508    /// [`Catalog::load_segment_bytes`] — they live outside the
2509    /// catalog snapshot (caller persists them as separate files
2510    /// and re-loads on boot, until v5.3's `CatalogManifest` makes
2511    /// that wiring automatic). `RowLocator::Cold { segment_id, .. }`
2512    /// indexes this `Vec`. Cleared on `Catalog::new` / fresh
2513    /// `deserialize`.
2514    ///
2515    /// `Arc` wrap keeps `Catalog::clone` at O(N segments) bumps
2516    /// (rather than O(total segment bytes) memcpy) so the v4.42
2517    /// group-commit pre-image rollback invariant — clone is
2518    /// effectively free — survives the cold-tier addition.
2519    ///
2520    /// v6.7.3 — slots became `Option<…>` so cold-segment compaction
2521    /// can tombstone merged sources without breaking the
2522    /// `segment_id = index_into_vec` contract that on-disk
2523    /// `RowLocator::Cold { segment_id }` already serialized.
2524    /// `None` slot = the segment was retired by compaction; the
2525    /// physical file may still be on disk (next CHECKPOINT writes
2526    /// a manifest that no longer lists it, and the file becomes
2527    /// an orphan eligible for offline cleanup).
2528    cold_segments: Vec<Option<Arc<OwnedSegment>>>,
2529    /// v7.12.4 — user-defined functions (PL/pgSQL + SQL).
2530    /// Keyed by function name (PG overloading is out of scope).
2531    /// Bodies are stored as the raw source text the parser saw
2532    /// between `$$ ... $$`; the engine re-parses on each
2533    /// invocation. This keeps `spg-storage` free of `spg-sql`
2534    /// dependency — same pattern as partial-index predicates.
2535    functions: BTreeMap<String, FunctionDef>,
2536    /// v7.12.4 — triggers in insertion order. Multiple triggers
2537    /// per table / event fire in this order (matching PG's
2538    /// alphabetical-by-default with insertion-stable tie-break
2539    /// behaviour — we just keep insertion order for now).
2540    triggers: Vec<TriggerDef>,
2541    /// v7.17.0 — catalogued SEQUENCE objects (Phase 1.1). Each
2542    /// `nextval(name)` reaches in here, atomically increments
2543    /// `last_value` / flips `is_called`, returns the new value.
2544    /// Persisted in catalog FILE_VERSION 26+; older catalogs
2545    /// deserialise with an empty map.
2546    sequences: BTreeMap<String, SequenceDef>,
2547    /// v7.17.0 — catalogued VIEW objects (Phase 1.2). Each
2548    /// `SELECT FROM v` at engine exec-time looks up `v` here and
2549    /// prepends the view body as a synthetic CTE. Persisted in
2550    /// catalog FILE_VERSION 27+; older catalogs deserialise with
2551    /// an empty map.
2552    views: BTreeMap<String, ViewDef>,
2553    /// v7.17.0 — catalogued MATERIALIZED VIEW source registry
2554    /// (Phase 1.3). Maps name → SELECT source. The materialised
2555    /// rows themselves live as a regular `Table` with the same
2556    /// name; REFRESH re-parses + re-executes the source against
2557    /// the table. Persisted in catalog FILE_VERSION 28+;
2558    /// older catalogs deserialise with an empty map.
2559    materialized_views: BTreeMap<String, String>,
2560    /// v7.17.0 — catalogued user-defined ENUM types (Phase 1.4).
2561    /// Maps name → label list. Columns reference these by name
2562    /// via `ColumnSchema.user_enum_type`. Persisted in catalog
2563    /// FILE_VERSION 29+; older catalogs deserialise with an empty
2564    /// map.
2565    enum_types: BTreeMap<String, EnumDef>,
2566    /// v7.17.0 — catalogued user-defined DOMAIN types (Phase 1.5).
2567    /// Maps name → base + CHECK constraints. Columns reference
2568    /// these by name via `ColumnSchema.user_domain_type`.
2569    /// Persisted in catalog FILE_VERSION 30+; older catalogs
2570    /// deserialise with an empty map.
2571    domain_types: BTreeMap<String, DomainDef>,
2572    /// v7.37.42-T2 ζ-B — catalogued user-defined COMPOSITE types
2573    /// (`CREATE TYPE name AS (field_name field_type, …)`). Columns
2574    /// reference these by name via
2575    /// `ColumnSchema.user_composite_type` (parallel to
2576    /// `user_enum_type` / `user_domain_type`). Persisted in catalog
2577    /// FILE_VERSION 52+; older catalogs deserialise with an empty
2578    /// map.
2579    composite_types: BTreeMap<String, CompositeDef>,
2580    /// v7.17.0 — schema-namespace registry (Phase 1.6). Tracks
2581    /// which schemas exist. `public`, `pg_catalog`, and
2582    /// `information_schema` are built-in and always present.
2583    /// Schema-qualified table references still strip the prefix
2584    /// at lookup time per v7.16-and-earlier — full
2585    /// schema-as-isolation is v7.18+ scope. Persisted in catalog
2586    /// FILE_VERSION 31+; older catalogs deserialise with just
2587    /// the built-ins.
2588    schemas: alloc::collections::BTreeSet<String>,
2589}
2590
2591/// v7.12.4 — catalogued user-defined function. `body` is the raw
2592/// source text between `$$ ... $$`; the engine re-parses it on
2593/// invocation. This keeps the storage codec stable when the
2594/// PL/pgSQL surface grows (no breaking-change risk on the disk
2595/// format).
2596#[derive(Debug, Clone, PartialEq, Eq)]
2597pub struct FunctionDef {
2598    pub name: String,
2599    /// Display form of the argument list, e.g.
2600    /// `"(name TEXT, ts TIMESTAMP)"`. Empty `"()"` for the trigger
2601    /// function shape. Parser-side canonicalised before storage.
2602    pub args_repr: String,
2603    /// Display form of the return type, e.g. `"TRIGGER"` /
2604    /// `"INT"` / `"SETOF text"`. The engine special-cases
2605    /// `"TRIGGER"` (case-insensitive) to gate trigger-only
2606    /// semantics (NEW/OLD).
2607    pub returns: String,
2608    /// `LANGUAGE` clause, lowercased. `"plpgsql"` / `"sql"`.
2609    pub language: String,
2610    /// Source body of the function. PL/pgSQL: includes the
2611    /// surrounding `BEGIN ... END;`. SQL: includes the
2612    /// statement(s). The engine re-parses on invocation; bad
2613    /// bodies surface as a parse error at CALL time, not CREATE.
2614    pub body: String,
2615}
2616
2617/// v7.12.4 — catalogued trigger. References its function by
2618/// name; the function must exist at TRIGGER creation time
2619/// (forward references are deferred to v7.12.5+).
2620#[derive(Debug, Clone, PartialEq, Eq)]
2621pub struct TriggerDef {
2622    pub name: String,
2623    /// Watched table. Trigger is dropped when the table drops.
2624    pub table: String,
2625    /// `"BEFORE"` / `"AFTER"` / `"INSTEAD OF"`. Stored as the
2626    /// uppercased keyword so deserialised catalogs round-trip
2627    /// without canonicalisation surprises.
2628    pub timing: String,
2629    /// Each entry is one of `"INSERT"` / `"UPDATE"` / `"DELETE"`
2630    /// / `"TRUNCATE"`. `INSERT OR UPDATE` parses to two entries.
2631    pub events: Vec<String>,
2632    /// `"ROW"` / `"STATEMENT"`. v7.12.4 ships `"ROW"` only;
2633    /// `"STATEMENT"` parses and persists but the executor
2634    /// refuses it at trigger fire time.
2635    pub for_each: String,
2636    /// Name of the PL/pgSQL function to invoke.
2637    pub function: String,
2638    /// v7.13.0 — `UPDATE OF col, col, …` column-list filter
2639    /// (mailrs round-5 G7). Non-empty means the trigger fires
2640    /// only when at least one of these columns appears in the
2641    /// UPDATE's SET list. Empty = no column filter. Stored in
2642    /// catalog FILE_VERSION 23+; older catalogs deserialise with
2643    /// an empty vec.
2644    pub update_columns: Vec<String>,
2645    /// v7.16.1 — whether the trigger fires when its watched
2646    /// event occurs. Toggled by `ALTER TABLE … { ENABLE |
2647    /// DISABLE } TRIGGER …`; pg_dump --disable-triggers wraps
2648    /// every data block with a DISABLE/ENABLE pair so the
2649    /// rows already-computed in prod don't get re-rewritten.
2650    /// Defaults to `true` at CREATE TRIGGER time. Stored in
2651    /// catalog FILE_VERSION 25+; older catalogs deserialise
2652    /// with `enabled = true`.
2653    pub enabled: bool,
2654}
2655
2656/// v7.17.0 — catalogued SEQUENCE. PG semantics: a counter object
2657/// returning monotonically increasing values via `nextval(name)`.
2658/// `last_value` is the most recent value handed out; `is_called`
2659/// is false until the first `nextval`/`setval`. Stored separately
2660/// from tables in the catalog.
2661#[derive(Debug, Clone, PartialEq, Eq)]
2662pub struct SequenceDef {
2663    pub name: String,
2664    /// Data type — narrows the i64 range. PG default BIGINT.
2665    pub data_type: SequenceDataType,
2666    pub start: i64,
2667    pub increment: i64,
2668    pub min_value: i64,
2669    pub max_value: i64,
2670    pub cache: i64,
2671    pub cycle: bool,
2672    /// `OWNED BY` target — `(table, column)` or NONE.
2673    pub owned_by: Option<(String, String)>,
2674    /// Most recently handed-out value. Meaningless when
2675    /// `is_called == false`; in that case the NEXT `nextval`
2676    /// will return `start`.
2677    pub last_value: i64,
2678    pub is_called: bool,
2679}
2680
2681/// v7.17.0 — sequence integer width.
2682#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2683pub enum SequenceDataType {
2684    SmallInt,
2685    Int,
2686    BigInt,
2687}
2688
2689/// v7.17.0 Phase 1.6 — built-in schema names that every Catalog
2690/// understands without an explicit CREATE SCHEMA. Used by
2691/// [`Catalog::schema_exists`] and the engine's schema-qualified
2692/// lookup path.
2693#[must_use]
2694pub fn is_builtin_schema(name: &str) -> bool {
2695    name.eq_ignore_ascii_case("public")
2696        || name.eq_ignore_ascii_case("pg_catalog")
2697        || name.eq_ignore_ascii_case("information_schema")
2698}
2699
2700/// v7.17.0 — parse a PG-canonical UUID text representation into the
2701/// 16-byte network-order layout used by `Value::Uuid`. Accepted input
2702/// shapes (all case-insensitive):
2703///   * Canonical hyphenated 8-4-4-4-12 (`550e8400-e29b-41d4-a716-446655440000`)
2704///   * Unhyphenated 32-char hex (`550e8400e29b41d4a716446655440000`)
2705///   * Either form wrapped in `{ ... }`
2706///
2707/// Returns `None` for any malformed input (wrong length, non-hex
2708/// characters, misplaced hyphens). The caller surfaces a SQL error
2709/// at coercion time — silent acceptance of garbage would mask
2710/// application bugs and is exactly the divergence from PG that
2711/// breaks the 0-change cutover promise.
2712#[must_use]
2713pub fn parse_uuid_str(input: &str) -> Option<[u8; 16]> {
2714    let s = input.trim();
2715    // Strip surrounding braces if present.
2716    let s = if let Some(inner) = s.strip_prefix('{').and_then(|x| x.strip_suffix('}')) {
2717        inner
2718    } else {
2719        s
2720    };
2721    // Two valid shapes after braces are stripped: 32 hex chars or
2722    // the canonical 36-char hyphenated form.
2723    let hex: String = match s.len() {
2724        32 => s.to_ascii_lowercase(),
2725        36 => {
2726            // Hyphens must be exactly at positions 8, 13, 18, 23.
2727            let b = s.as_bytes();
2728            if b[8] != b'-' || b[13] != b'-' || b[18] != b'-' || b[23] != b'-' {
2729                return None;
2730            }
2731            let mut out = String::with_capacity(32);
2732            out.push_str(&s[0..8]);
2733            out.push_str(&s[9..13]);
2734            out.push_str(&s[14..18]);
2735            out.push_str(&s[19..23]);
2736            out.push_str(&s[24..36]);
2737            out.make_ascii_lowercase();
2738            out
2739        }
2740        _ => return None,
2741    };
2742    let bytes = hex.as_bytes();
2743    let mut out = [0u8; 16];
2744    for i in 0..16 {
2745        let hi = hex_nibble(bytes[i * 2])?;
2746        let lo = hex_nibble(bytes[i * 2 + 1])?;
2747        out[i] = (hi << 4) | lo;
2748    }
2749    Some(out)
2750}
2751
2752fn hex_nibble(b: u8) -> Option<u8> {
2753    match b {
2754        b'0'..=b'9' => Some(b - b'0'),
2755        b'a'..=b'f' => Some(10 + b - b'a'),
2756        b'A'..=b'F' => Some(10 + b - b'A'),
2757        _ => None,
2758    }
2759}
2760
2761/// v7.17.0 — render a `Value::Uuid` payload as the canonical
2762/// lowercase 8-4-4-4-12 hyphenated form PG `text` cast surfaces.
2763#[must_use]
2764pub fn format_uuid(b: &[u8; 16]) -> String {
2765    const HEX: &[u8; 16] = b"0123456789abcdef";
2766    let mut out = String::with_capacity(36);
2767    for (i, byte) in b.iter().enumerate() {
2768        if matches!(i, 4 | 6 | 8 | 10) {
2769            out.push('-');
2770        }
2771        out.push(HEX[(byte >> 4) as usize] as char);
2772        out.push(HEX[(byte & 0x0f) as usize] as char);
2773    }
2774    out
2775}
2776
2777/// v7.17.0 Phase 1.5 — catalogued user-defined DOMAIN. A domain
2778/// is a named CHECK-constrained alias over a built-in type;
2779/// columns bound to it inherit the base type plus the CHECK
2780/// predicates + NOT NULL + DEFAULT at INSERT/UPDATE time.
2781/// `default` / `checks` are stored as Display-form source so
2782/// `spg-storage` stays free of `spg-sql` dependency — same
2783/// pattern as FunctionDef / ViewDef.
2784#[derive(Debug, Clone, PartialEq, Eq)]
2785pub struct DomainDef {
2786    pub name: String,
2787    pub base_type: DataType,
2788    pub nullable: bool,
2789    pub default: Option<String>,
2790    pub checks: Vec<String>,
2791}
2792
2793/// v7.17.0 Phase 1.4 — catalogued user-defined ENUM type. The
2794/// label vector is order-preserving (PG enum ordering follows the
2795/// declared order). At INSERT/UPDATE on a column bound to this
2796/// enum, the engine looks up the value against `labels` and
2797/// rejects non-members.
2798#[derive(Debug, Clone, PartialEq, Eq)]
2799pub struct EnumDef {
2800    pub name: String,
2801    pub labels: Vec<String>,
2802}
2803
2804/// v7.37.42-T2 ζ-B — catalogued user-defined COMPOSITE type
2805/// (`CREATE TYPE name AS (field_name field_type, ...)`). Order
2806/// matters: PG composite literals are positional, and SPG mirrors
2807/// that. Stored as ordered `(name, DataType)` pairs to keep the
2808/// codec straightforward and to allow eventual `Value::Composite`
2809/// bodies to encode positionally. Persisted in catalog FILE_VERSION
2810/// 52+; older catalogs deserialise with an empty composite_types
2811/// map. Composite types can be used as a column type by spelling
2812/// the composite's name; the resolution from
2813/// `ColumnSchema.user_composite_type = Some(name)` happens at the
2814/// engine boundary (parallel to `user_enum_type` /
2815/// `user_domain_type`). The dense storage shape — JSON-text body
2816/// keyed by the composite's field list — keeps the codec free of
2817/// recursive `Value` bodies until the full Value::Composite arena
2818/// migration in a later phase.
2819#[derive(Debug, Clone, PartialEq, Eq)]
2820pub struct CompositeDef {
2821    pub name: String,
2822    /// Ordered `(field_name, field_type)` pairs. PG composite
2823    /// literals are positional, so order is part of the type's
2824    /// identity.
2825    pub fields: Vec<(String, DataType)>,
2826}
2827
2828/// v7.17.0 Phase 1.2 — catalogued VIEW. The body is stored as the
2829/// raw source text the parser saw between `AS` and the statement
2830/// terminator; the engine re-parses on each invocation. Same
2831/// pattern as `FunctionDef` — keeps `spg-storage` free of
2832/// `spg-sql` dependency.
2833#[derive(Debug, Clone, PartialEq, Eq)]
2834pub struct ViewDef {
2835    pub name: String,
2836    /// Optional `(col, col, …)` rename list. Empty when the body's
2837    /// projected names are used directly.
2838    pub columns: Vec<String>,
2839    /// Raw SELECT source. Display-rendered at storage time so the
2840    /// catalog round-trips a deterministic form regardless of
2841    /// whitespace / comments in the original input. Re-parsed at
2842    /// SELECT-from-view time to materialise as a synthetic CTE.
2843    pub body: String,
2844}
2845
2846impl SequenceDataType {
2847    /// PG default min/max per AS clause.
2848    pub fn default_bounds(self, increment_positive: bool) -> (i64, i64) {
2849        match self {
2850            Self::SmallInt => {
2851                if increment_positive {
2852                    (1, i64::from(i16::MAX))
2853                } else {
2854                    (i64::from(i16::MIN), -1)
2855                }
2856            }
2857            Self::Int => {
2858                if increment_positive {
2859                    (1, i64::from(i32::MAX))
2860                } else {
2861                    (i64::from(i32::MIN), -1)
2862                }
2863            }
2864            Self::BigInt => {
2865                if increment_positive {
2866                    (1, i64::MAX)
2867                } else {
2868                    (i64::MIN, -1)
2869                }
2870            }
2871        }
2872    }
2873}
2874
2875impl Catalog {
2876    pub const fn new() -> Self {
2877        Self {
2878            tables: Vec::new(),
2879            by_name: BTreeMap::new(),
2880            cold_segments: Vec::new(),
2881            functions: BTreeMap::new(),
2882            triggers: Vec::new(),
2883            sequences: BTreeMap::new(),
2884            views: BTreeMap::new(),
2885            materialized_views: BTreeMap::new(),
2886            enum_types: BTreeMap::new(),
2887            domain_types: BTreeMap::new(),
2888            composite_types: BTreeMap::new(),
2889            schemas: alloc::collections::BTreeSet::new(),
2890        }
2891    }
2892
2893    /// v7.12.4 — read-only view of catalogued user-defined
2894    /// functions. Engine callers go through here to look up the
2895    /// function body before re-parsing it for invocation.
2896    pub const fn functions(&self) -> &BTreeMap<String, FunctionDef> {
2897        &self.functions
2898    }
2899
2900    /// v7.12.4 — register a new user-defined function. With
2901    /// `or_replace = false`, errors if the name is taken. The
2902    /// engine validates the body before passing it here.
2903    pub fn create_function(
2904        &mut self,
2905        def: FunctionDef,
2906        or_replace: bool,
2907    ) -> Result<(), StorageError> {
2908        if !or_replace && self.functions.contains_key(&def.name) {
2909            return Err(StorageError::Corrupt(format!(
2910                "function {:?} already exists (drop or use CREATE OR REPLACE)",
2911                def.name
2912            )));
2913        }
2914        self.functions.insert(def.name.clone(), def);
2915        Ok(())
2916    }
2917
2918    /// v7.12.4 — remove a user-defined function by name. Returns
2919    /// `true` if a function was removed, `false` if none matched.
2920    /// Caller decides whether to surface `if_exists` semantics.
2921    pub fn drop_function(&mut self, name: &str) -> bool {
2922        self.functions.remove(name).is_some()
2923    }
2924
2925    /// v7.17.0 — read-only handle to catalogued sequences.
2926    pub const fn sequences(&self) -> &BTreeMap<String, SequenceDef> {
2927        &self.sequences
2928    }
2929
2930    /// v7.17.0 — register a new SEQUENCE. Errors if `name`
2931    /// collides with an existing sequence and `if_not_exists`
2932    /// is false.
2933    pub fn create_sequence(
2934        &mut self,
2935        def: SequenceDef,
2936        if_not_exists: bool,
2937    ) -> Result<(), StorageError> {
2938        if self.sequences.contains_key(&def.name) {
2939            if if_not_exists {
2940                return Ok(());
2941            }
2942            return Err(StorageError::Corrupt(format!(
2943                "sequence {:?} already exists",
2944                def.name
2945            )));
2946        }
2947        self.sequences.insert(def.name.clone(), def);
2948        Ok(())
2949    }
2950
2951    /// v7.17.0 — remove a SEQUENCE by name. Returns `true` if a
2952    /// sequence was removed, `false` if none matched. Caller
2953    /// surfaces IF EXISTS semantics.
2954    pub fn drop_sequence(&mut self, name: &str) -> bool {
2955        self.sequences.remove(name).is_some()
2956    }
2957
2958    /// v7.17.0 — atomic nextval. Increments `last_value` per
2959    /// `increment`, returns the new value, sets `is_called`.
2960    /// Returns an error on CYCLE-less overflow.
2961    pub fn sequence_next_value(&mut self, name: &str) -> Result<i64, StorageError> {
2962        let Some(seq) = self.sequences.get_mut(name) else {
2963            return Err(StorageError::Corrupt(format!(
2964                "sequence {name:?} does not exist"
2965            )));
2966        };
2967        // PG semantics: when !is_called (fresh sequence or
2968        // setval(_, false)), the next nextval returns the stored
2969        // `last_value`. When is_called, it advances by `increment`
2970        // and CYCLE-wraps on overflow.
2971        let candidate = if seq.is_called {
2972            let next = seq.last_value.checked_add(seq.increment).ok_or_else(|| {
2973                StorageError::Corrupt(format!("sequence {name:?} arithmetic overflow"))
2974            })?;
2975            if seq.increment > 0 {
2976                if next > seq.max_value {
2977                    if seq.cycle {
2978                        seq.min_value
2979                    } else {
2980                        return Err(StorageError::Corrupt(format!(
2981                            "sequence {name:?} reached MAXVALUE ({})",
2982                            seq.max_value
2983                        )));
2984                    }
2985                } else {
2986                    next
2987                }
2988            } else if next < seq.min_value {
2989                if seq.cycle {
2990                    seq.max_value
2991                } else {
2992                    return Err(StorageError::Corrupt(format!(
2993                        "sequence {name:?} reached MINVALUE ({})",
2994                        seq.min_value
2995                    )));
2996                }
2997            } else {
2998                next
2999            }
3000        } else {
3001            seq.last_value
3002        };
3003        seq.last_value = candidate;
3004        seq.is_called = true;
3005        Ok(candidate)
3006    }
3007
3008    /// v7.17.0 — currval. Errors if the session has never called
3009    /// nextval on this sequence (PG semantics). At the catalog
3010    /// level we approximate "session" with "is_called persisted";
3011    /// the engine session-tracking layer can wrap this for the
3012    /// strict per-session semantics later.
3013    pub fn sequence_current_value(&self, name: &str) -> Result<i64, StorageError> {
3014        let Some(seq) = self.sequences.get(name) else {
3015            return Err(StorageError::Corrupt(format!(
3016                "sequence {name:?} does not exist"
3017            )));
3018        };
3019        if !seq.is_called {
3020            return Err(StorageError::Corrupt(format!(
3021                "currval of sequence {name:?} is not yet defined in this session"
3022            )));
3023        }
3024        Ok(seq.last_value)
3025    }
3026
3027    /// v7.17.0 — setval(name, value [, is_called]). PG returns
3028    /// `value` regardless. `is_called=true` means the NEXT
3029    /// nextval will return `value + increment`; `is_called=false`
3030    /// means the next nextval will return `value`.
3031    pub fn sequence_set_value(
3032        &mut self,
3033        name: &str,
3034        value: i64,
3035        is_called: bool,
3036    ) -> Result<i64, StorageError> {
3037        let Some(seq) = self.sequences.get_mut(name) else {
3038            return Err(StorageError::Corrupt(format!(
3039                "sequence {name:?} does not exist"
3040            )));
3041        };
3042        seq.last_value = value;
3043        seq.is_called = is_called;
3044        Ok(value)
3045    }
3046
3047    /// v7.17.0 Phase 1.2 — read-only handle to catalogued views.
3048    pub const fn views(&self) -> &BTreeMap<String, ViewDef> {
3049        &self.views
3050    }
3051
3052    /// v7.17.0 Phase 1.2 — install a VIEW. `or_replace=true`
3053    /// overwrites an existing entry; `if_not_exists=true` is a
3054    /// silent no-op when the name is taken. Errors if both flags
3055    /// are off and the name collides.
3056    pub fn create_view(
3057        &mut self,
3058        def: ViewDef,
3059        or_replace: bool,
3060        if_not_exists: bool,
3061    ) -> Result<(), StorageError> {
3062        if self.views.contains_key(&def.name) {
3063            if or_replace {
3064                self.views.insert(def.name.clone(), def);
3065                return Ok(());
3066            }
3067            if if_not_exists {
3068                return Ok(());
3069            }
3070            return Err(StorageError::Corrupt(format!(
3071                "view {:?} already exists",
3072                def.name
3073            )));
3074        }
3075        // Reject name collision with tables / sequences — same
3076        // namespace per PG.
3077        if self.by_name.contains_key(&def.name) {
3078            return Err(StorageError::Corrupt(format!(
3079                "view {:?} would shadow an existing table",
3080                def.name
3081            )));
3082        }
3083        if self.sequences.contains_key(&def.name) {
3084            return Err(StorageError::Corrupt(format!(
3085                "view {:?} would shadow an existing sequence",
3086                def.name
3087            )));
3088        }
3089        self.views.insert(def.name.clone(), def);
3090        Ok(())
3091    }
3092
3093    /// v7.17.0 Phase 1.2 — remove a view by name. Returns true if
3094    /// a view was removed.
3095    pub fn drop_view(&mut self, name: &str) -> bool {
3096        self.views.remove(name).is_some()
3097    }
3098
3099    /// v7.17.0 Phase 1.3 — read-only handle to the materialised-
3100    /// view source registry. Each entry pairs with a regular
3101    /// table of the same name that holds the cached rows.
3102    pub const fn materialized_views(&self) -> &BTreeMap<String, String> {
3103        &self.materialized_views
3104    }
3105
3106    /// v7.17.0 Phase 1.3 — register a source for a materialised
3107    /// view. Caller has already created the backing table.
3108    pub fn register_materialized_view(&mut self, name: String, body: String) {
3109        self.materialized_views.insert(name, body);
3110    }
3111
3112    /// v7.17.0 Phase 1.3 — drop the source registry entry. Returns
3113    /// true if a source was unregistered. Caller separately drops
3114    /// the backing table.
3115    pub fn drop_materialized_view_source(&mut self, name: &str) -> bool {
3116        self.materialized_views.remove(name).is_some()
3117    }
3118
3119    /// v7.17.0 Phase 1.4 — read-only handle to user-defined ENUM
3120    /// catalog.
3121    pub const fn enum_types(&self) -> &BTreeMap<String, EnumDef> {
3122        &self.enum_types
3123    }
3124
3125    /// v7.17.0 Phase 1.4 — install a new ENUM type. Errors if
3126    /// `name` collides with an existing enum (no IF NOT EXISTS
3127    /// per PG semantics for CREATE TYPE).
3128    pub fn create_enum_type(&mut self, def: EnumDef) -> Result<(), StorageError> {
3129        if self.enum_types.contains_key(&def.name) {
3130            return Err(StorageError::Corrupt(format!(
3131                "type {:?} already exists",
3132                def.name
3133            )));
3134        }
3135        self.enum_types.insert(def.name.clone(), def);
3136        Ok(())
3137    }
3138
3139    /// v7.17.0 Phase 1.4 — drop an ENUM type by name. Returns
3140    /// true if a type was removed.
3141    pub fn drop_enum_type(&mut self, name: &str) -> bool {
3142        self.enum_types.remove(name).is_some()
3143    }
3144
3145    /// v7.17.0 Phase 1.5 — read-only handle to DOMAIN catalog.
3146    pub const fn domain_types(&self) -> &BTreeMap<String, DomainDef> {
3147        &self.domain_types
3148    }
3149
3150    /// v7.17.0 Phase 1.5 — install a DOMAIN. Errors on collision
3151    /// with an existing domain.
3152    pub fn create_domain_type(&mut self, def: DomainDef) -> Result<(), StorageError> {
3153        if self.domain_types.contains_key(&def.name) {
3154            return Err(StorageError::Corrupt(format!(
3155                "domain {:?} already exists",
3156                def.name
3157            )));
3158        }
3159        self.domain_types.insert(def.name.clone(), def);
3160        Ok(())
3161    }
3162
3163    /// v7.17.0 Phase 1.5 — drop a DOMAIN by name.
3164    pub fn drop_domain_type(&mut self, name: &str) -> bool {
3165        self.domain_types.remove(name).is_some()
3166    }
3167
3168    /// v7.37.42-T2 ζ-B — read-only handle to user-defined COMPOSITE
3169    /// catalog. Used by the engine to resolve
3170    /// `ColumnSchema.user_composite_type` lookups + by
3171    /// information_schema-style introspection.
3172    pub const fn composite_types(&self) -> &BTreeMap<String, CompositeDef> {
3173        &self.composite_types
3174    }
3175
3176    /// v7.37.42-T2 ζ-B — install a new COMPOSITE type. Errors if
3177    /// `name` already exists in the composite registry (PG forbids
3178    /// IF NOT EXISTS on CREATE TYPE composite; the engine surfaces
3179    /// the collision with the existing name).
3180    pub fn create_composite_type(&mut self, def: CompositeDef) -> Result<(), StorageError> {
3181        if self.composite_types.contains_key(&def.name) {
3182            return Err(StorageError::Corrupt(format!(
3183                "type {:?} already exists",
3184                def.name
3185            )));
3186        }
3187        self.composite_types.insert(def.name.clone(), def);
3188        Ok(())
3189    }
3190
3191    /// v7.37.42-T2 ζ-B — drop a COMPOSITE type by name. Returns
3192    /// true if a type was removed.
3193    pub fn drop_composite_type(&mut self, name: &str) -> bool {
3194        self.composite_types.remove(name).is_some()
3195    }
3196
3197    /// v7.17.0 Phase 1.6 — read-only handle to the user-created
3198    /// schema registry. Built-in schemas (`public`, `pg_catalog`,
3199    /// `information_schema`) are NOT included here; use
3200    /// [`schema_exists`](Self::schema_exists) for the full
3201    /// check.
3202    pub const fn user_schemas(&self) -> &alloc::collections::BTreeSet<String> {
3203        &self.schemas
3204    }
3205
3206    /// v7.17.0 Phase 1.6 — schema-name resolver. Returns true
3207    /// for built-in schemas + every user-CREATEd one. Used by
3208    /// CREATE SCHEMA collision checks and (future) by
3209    /// information_schema.schemata.
3210    pub fn schema_exists(&self, name: &str) -> bool {
3211        is_builtin_schema(name) || self.schemas.contains(name)
3212    }
3213
3214    /// v7.17.0 Phase 1.6 — register a new schema. Errors if the
3215    /// name already exists and `if_not_exists=false`. Built-in
3216    /// names cannot be redeclared.
3217    pub fn create_schema(&mut self, name: String, if_not_exists: bool) -> Result<(), StorageError> {
3218        if is_builtin_schema(&name) {
3219            if if_not_exists {
3220                return Ok(());
3221            }
3222            return Err(StorageError::Corrupt(format!(
3223                "schema {name:?} is built-in and cannot be redeclared"
3224            )));
3225        }
3226        if self.schemas.contains(&name) {
3227            if if_not_exists {
3228                return Ok(());
3229            }
3230            return Err(StorageError::Corrupt(format!(
3231                "schema {name:?} already exists"
3232            )));
3233        }
3234        self.schemas.insert(name);
3235        Ok(())
3236    }
3237
3238    /// v7.17.0 Phase 1.6 — drop a user-created schema. Returns
3239    /// true if a schema was removed. Built-in names always
3240    /// return false (cannot be dropped). Tables that previously
3241    /// used the schema as a prefix keep their bare name and stay
3242    /// queryable — this is the "prefix routing, not isolation"
3243    /// posture documented in v7.17 Phase 1.6.
3244    pub fn drop_schema(&mut self, name: &str) -> Result<bool, StorageError> {
3245        if is_builtin_schema(name) {
3246            return Err(StorageError::Corrupt(format!(
3247                "schema {name:?} is built-in and cannot be dropped"
3248            )));
3249        }
3250        Ok(self.schemas.remove(name))
3251    }
3252
3253    /// v7.17.0 — ALTER SEQUENCE option merge. Caller-provided
3254    /// updates overwrite the matching fields; unset fields keep
3255    /// their stored values. RESTART variants update last_value
3256    /// directly per PG: `RESTART` resets to current `start`;
3257    /// `RESTART WITH n` resets to `n`.
3258    #[allow(clippy::too_many_arguments)]
3259    pub fn alter_sequence(
3260        &mut self,
3261        name: &str,
3262        increment: Option<i64>,
3263        min_value: Option<i64>,
3264        max_value: Option<i64>,
3265        start: Option<i64>,
3266        restart: Option<Option<i64>>,
3267        cache: Option<i64>,
3268        cycle: Option<bool>,
3269        owned_by: Option<Option<(String, String)>>,
3270    ) -> Result<(), StorageError> {
3271        let Some(seq) = self.sequences.get_mut(name) else {
3272            return Err(StorageError::Corrupt(format!(
3273                "sequence {name:?} does not exist"
3274            )));
3275        };
3276        if let Some(v) = increment {
3277            seq.increment = v;
3278        }
3279        if let Some(v) = min_value {
3280            seq.min_value = v;
3281        }
3282        if let Some(v) = max_value {
3283            seq.max_value = v;
3284        }
3285        if let Some(v) = start {
3286            seq.start = v;
3287        }
3288        if let Some(restart_value) = restart {
3289            seq.last_value = restart_value.unwrap_or(seq.start);
3290            seq.is_called = false;
3291        }
3292        if let Some(v) = cache {
3293            seq.cache = v;
3294        }
3295        if let Some(v) = cycle {
3296            seq.cycle = v;
3297        }
3298        if let Some(v) = owned_by {
3299            seq.owned_by = v;
3300        }
3301        Ok(())
3302    }
3303
3304    /// v7.12.4 — read-only slice of all catalogued triggers.
3305    /// Engine row-write paths filter this by (table, event,
3306    /// timing) and fire matches in slice order.
3307    pub fn triggers(&self) -> &[TriggerDef] {
3308        &self.triggers
3309    }
3310
3311    /// v7.15.0 — mutable handle to the trigger slice for
3312    /// `ALTER TABLE … RENAME COLUMN`, which rewrites every
3313    /// `update_columns` entry that referenced the renamed
3314    /// column.
3315    pub fn triggers_mut(&mut self) -> &mut Vec<TriggerDef> {
3316        &mut self.triggers
3317    }
3318
3319    /// v7.12.4 — register a new trigger. With `or_replace = false`,
3320    /// errors when a trigger with the same name already exists on
3321    /// the same table (PG scoping rule — trigger names are
3322    /// per-table, not global). Trigger function must already
3323    /// exist in the catalog at registration time.
3324    pub fn create_trigger(
3325        &mut self,
3326        def: TriggerDef,
3327        or_replace: bool,
3328    ) -> Result<(), StorageError> {
3329        if !self.by_name.contains_key(&def.table) {
3330            return Err(StorageError::TableNotFound {
3331                name: def.table.clone(),
3332            });
3333        }
3334        if !self.functions.contains_key(&def.function) {
3335            return Err(StorageError::Corrupt(format!(
3336                "trigger {:?} references unknown function {:?}",
3337                def.name, def.function
3338            )));
3339        }
3340        let dup = self
3341            .triggers
3342            .iter()
3343            .position(|t| t.name == def.name && t.table == def.table);
3344        match (dup, or_replace) {
3345            (Some(_), false) => Err(StorageError::Corrupt(format!(
3346                "trigger {:?} already exists on table {:?}",
3347                def.name, def.table
3348            ))),
3349            (Some(i), true) => {
3350                self.triggers[i] = def;
3351                Ok(())
3352            }
3353            (None, _) => {
3354                self.triggers.push(def);
3355                Ok(())
3356            }
3357        }
3358    }
3359
3360    /// v7.12.4 — remove a trigger by `(name, table)`. Returns
3361    /// `true` if one was removed.
3362    pub fn drop_trigger(&mut self, name: &str, table: &str) -> bool {
3363        let before = self.triggers.len();
3364        self.triggers
3365            .retain(|t| !(t.name == name && t.table == table));
3366        before != self.triggers.len()
3367    }
3368
3369    pub fn create_table(&mut self, schema: TableSchema) -> Result<(), StorageError> {
3370        if self.by_name.contains_key(&schema.name) {
3371            return Err(StorageError::DuplicateTable {
3372                name: schema.name.clone(),
3373            });
3374        }
3375        let idx = self.tables.len();
3376        let name = schema.name.clone();
3377        self.tables.push(Table::new(schema));
3378        self.by_name.insert(name, idx);
3379        Ok(())
3380    }
3381
3382    pub fn get(&self, name: &str) -> Option<&Table> {
3383        let idx = *self.by_name.get(name)?;
3384        self.tables.get(idx)
3385    }
3386
3387    pub fn get_mut(&mut self, name: &str) -> Option<&mut Table> {
3388        let idx = *self.by_name.get(name)?;
3389        self.tables.get_mut(idx)
3390    }
3391
3392    /// v7.37.42 (docker-fair SCALARSQ attack) — resolve a table name to
3393    /// its insertion-order index ONCE, so callers that need to fetch the
3394    /// same table many times (per-row PK probes in correlated scalar
3395    /// subqueries) can avoid the per-call `BTreeMap<String, usize>` string
3396    /// descent. The returned index is stable for the lifetime of the
3397    /// catalog snapshot the caller holds (same engine read guard).
3398    pub fn tables_position_of(&self, name: &str) -> Option<usize> {
3399        self.by_name.get(name).copied()
3400    }
3401
3402    /// Direct positional fetch counterpart to [`tables_position_of`].
3403    /// `idx` must come from `tables_position_of` against the same catalog
3404    /// snapshot — out-of-range returns `None`.
3405    pub fn tables_at(&self, idx: usize) -> Option<&Table> {
3406        self.tables.get(idx)
3407    }
3408
3409    /// v7.34 (crash-recovery P0 #2) — replay a row-level redo log onto
3410    /// this catalog (the [`RowChange`] physical-redo apply primitive that
3411    /// row-level WAL recovery will use in place of statement re-execution).
3412    /// Applies each change in order via the same `Table` mutators the
3413    /// engine used — no uniqueness/FK/parse/plan: the original execution
3414    /// already validated, replay trusts and applies. Positions are
3415    /// physical and only valid when replayed from the matching checkpoint
3416    /// baseline in original order (see [`RowChange`] docs).
3417    ///
3418    /// A change naming an absent table, or whose position is out of range,
3419    /// is a corrupt/misaligned log and surfaces as an error rather than a
3420    /// silent skip.
3421    pub fn apply_redo(&mut self, changes: &[RowChange]) -> Result<(), StorageError> {
3422        // v7.37.5 (mailrs crash-recovery Ask 3) — true batched replay.
3423        // Pre-v7.37.5 each `RowChange::Delete` record ran a fresh
3424        // O(N) PersistentVec rebuild + O(N × indices × log N)
3425        // `rebuild_indices()` — 5000 records × 100k rows × 13 indices
3426        // ≈ 27 min on the mailrs prod-shape WAL.
3427        //
3428        // The strategy: group consecutive changes by table, and for
3429        // each run, compose all the row-level mutations through a
3430        // single "live" tracking vector + a per-table operation log,
3431        // then apply rows + indices ONCE at the end. The result:
3432        //  - DELETE blow-up: O(records × rows × indices × log rows)
3433        //    → O(rows × indices × log rows) — one rebuild per run.
3434        //  - Row-position semantics preserved: positions in a later
3435        //    `Delete` / `Update` record reference the layout produced
3436        //    by every earlier change; we walk the live-vector
3437        //    forward as each change is processed so positions
3438        //    translate correctly to the ORIGINAL row index space.
3439        //
3440        // For correctness, even with this batching `apply_redo`
3441        // remains in-order: a single per-table run only batches
3442        // a contiguous slice of changes targeting that table; a
3443        // mid-run change targeting a DIFFERENT table forces a
3444        // flush of the current run.
3445        let mut runs: alloc::vec::Vec<(String, alloc::vec::Vec<&RowChange>)> =
3446            alloc::vec::Vec::new();
3447        for change in changes {
3448            let table = match change {
3449                RowChange::Insert { table, .. }
3450                | RowChange::Update { table, .. }
3451                | RowChange::Delete { table, .. } => table.clone(),
3452            };
3453            if runs.last().map(|(t, _)| t.as_str()) != Some(table.as_str()) {
3454                runs.push((table, alloc::vec::Vec::new()));
3455            }
3456            runs.last_mut().unwrap().1.push(change);
3457        }
3458        for (table_name, run) in runs {
3459            self.apply_redo_run_on_table(&table_name, &run)?;
3460        }
3461        Ok(())
3462    }
3463
3464    /// v7.37.5 — apply a contiguous slice of `RowChange`s all
3465    /// targeting the same `table_name`. Composes row mutations
3466    /// through a single live-tracking vector + a single tail
3467    /// for appended `Insert`s + a single in-place edit set for
3468    /// `Update`s, then writes the final row layout to
3469    /// `self.rows` and rebuilds indices ONCE.
3470    fn apply_redo_run_on_table(
3471        &mut self,
3472        table_name: &str,
3473        run: &[&RowChange],
3474    ) -> Result<(), StorageError> {
3475        // Look up the table once; the unchecked unwrap is safe
3476        // because the caller just resolved `table_name` for each
3477        // change.
3478        let table = self.get_mut(table_name).ok_or_else(|| {
3479            StorageError::Corrupt(alloc::format!("redo: unknown table {table_name:?}"))
3480        })?;
3481        // Live-tracking over both pre-existing rows and tail-
3482        // appended Insert rows. `live[i] = true` initially for
3483        // every existing row. Appended Inserts extend with `true`.
3484        // A `Delete` flips entries to `false` (using the position
3485        // mapping that walks live indices in order). An `Update`
3486        // edits in place — collected into an overlay map keyed by
3487        // ORIGINAL row position so later Updates win.
3488        let original_rows: alloc::vec::Vec<Row<'static>> = table.rows().iter().cloned().collect();
3489        let mut live: alloc::vec::Vec<bool> = alloc::vec![true; original_rows.len()];
3490        let mut tail: alloc::vec::Vec<Row<'static>> = alloc::vec::Vec::new();
3491        // Overlay: index into ORIGINAL row space (existing rows
3492        // 0..original_rows.len()) or into tail (offset
3493        // original_rows.len()). Map -> new values.
3494        let mut overlay: alloc::collections::BTreeMap<usize, alloc::vec::Vec<Value<'static>>> =
3495            alloc::collections::BTreeMap::new();
3496        // Helper: given a "current" position (i.e. position in
3497        // the post-prior-deletes layout), translate to the
3498        // ABSOLUTE position in the unified live + tail space
3499        // by walking the live vector + tail. Returns None when
3500        // the position is out of range.
3501        fn translate(live: &[bool], tail_len: usize, current_pos: usize) -> Option<usize> {
3502            // Walk live[..] counting live entries until we hit
3503            // current_pos. Then if not yet matched, dip into tail.
3504            let mut seen = 0usize;
3505            for (i, &alive) in live.iter().enumerate() {
3506                if alive {
3507                    if seen == current_pos {
3508                        return Some(i);
3509                    }
3510                    seen += 1;
3511                }
3512            }
3513            // Position lives in tail. tail_len rows in the tail
3514            // are all live (we haven't deleted any tail rows in
3515            // this simplification; if we did, we'd extend `live`).
3516            let off = current_pos - seen;
3517            if off < tail_len {
3518                Some(live.len() + off)
3519            } else {
3520                None
3521            }
3522        }
3523        for change in run {
3524            match *change {
3525                RowChange::Insert { row, .. } => {
3526                    // Validate against schema before recording the
3527                    // change so a corrupt log surfaces as an error
3528                    // rather than silently mis-applying.
3529                    if row.len() != table.schema().columns.len() {
3530                        return Err(StorageError::ArityMismatch {
3531                            expected: table.schema().columns.len(),
3532                            actual: row.len(),
3533                        });
3534                    }
3535                    tail.push(row.clone());
3536                }
3537                RowChange::Update { pos, new_row, .. } => {
3538                    if new_row.len() != table.schema().columns.len() {
3539                        return Err(StorageError::ArityMismatch {
3540                            expected: table.schema().columns.len(),
3541                            actual: new_row.len(),
3542                        });
3543                    }
3544                    let abs = translate(&live, tail.len(), *pos).ok_or_else(|| {
3545                        StorageError::Corrupt(alloc::format!(
3546                            "redo: update_row position {pos} out of bounds in table {table_name:?}",
3547                        ))
3548                    })?;
3549                    // Tail edits are applied directly to `tail`
3550                    // (we own it); existing-row edits land in
3551                    // the overlay map keyed by original index.
3552                    if abs < live.len() {
3553                        overlay.insert(abs, new_row.clone());
3554                    } else {
3555                        tail[abs - live.len()] = Row::new(new_row.clone());
3556                    }
3557                }
3558                RowChange::Delete { positions, .. } => {
3559                    // De-dup + sort so the translate walk stays
3560                    // monotone (the second translate doesn't have
3561                    // to redo work the first one did, in principle;
3562                    // we keep it simple here and re-walk per
3563                    // position). Bounds-filter silently mirrors
3564                    // `Table::delete_rows`.
3565                    let mut sorted: alloc::vec::Vec<usize> = positions.clone();
3566                    sorted.sort_unstable();
3567                    sorted.dedup();
3568                    // Walk live[] once per Delete record to
3569                    // translate all positions in this record's
3570                    // post-prior-deletes layout to absolute
3571                    // indices. We MUST defer the live[] flip
3572                    // until after all positions are translated
3573                    // so two positions in the same record
3574                    // (e.g. [3, 7]) reference the same layout.
3575                    let mut to_flip_live: alloc::vec::Vec<usize> = alloc::vec::Vec::new();
3576                    let mut to_flip_tail: alloc::vec::Vec<usize> = alloc::vec::Vec::new();
3577                    // Two-pointer walk: live[i] scanned monotonically,
3578                    // sorted positions consumed in order.
3579                    let mut seen = 0usize;
3580                    let mut sp = sorted.iter().peekable();
3581                    for (i, &alive) in live.iter().enumerate() {
3582                        if !alive {
3583                            continue;
3584                        }
3585                        while let Some(&&p) = sp.peek() {
3586                            if seen == p {
3587                                to_flip_live.push(i);
3588                                sp.next();
3589                            } else {
3590                                break;
3591                            }
3592                        }
3593                        if sp.peek().is_none() {
3594                            break;
3595                        }
3596                        seen += 1;
3597                    }
3598                    // Remaining positions fall into the tail.
3599                    for &p in sp {
3600                        // p >= seen and refers to the (p - seen)-th
3601                        // entry in tail. Filter out-of-bounds.
3602                        let off = p - seen;
3603                        if off < tail.len() {
3604                            to_flip_tail.push(off);
3605                        }
3606                    }
3607                    for i in to_flip_live {
3608                        live[i] = false;
3609                        // Any pending overlay edit for this
3610                        // index is moot — the row is gone.
3611                        overlay.remove(&i);
3612                    }
3613                    // Tail deletes: remove in REVERSE order so
3614                    // shifting indices stay valid.
3615                    to_flip_tail.sort_unstable();
3616                    to_flip_tail.dedup();
3617                    for off in to_flip_tail.into_iter().rev() {
3618                        tail.remove(off);
3619                        // Re-key tail-relative overlay entries that
3620                        // were past `off` — in practice tail edits
3621                        // are applied directly so the overlay map
3622                        // only holds existing-row keys; nothing to
3623                        // do here.
3624                    }
3625                }
3626            }
3627        }
3628        // Compose the final row layout: keep existing rows where
3629        // live[i] = true, applying overlay edits in place; then
3630        // append the surviving tail.
3631        let mut new_rows: PersistentVec<Row> = PersistentVec::new();
3632        let mut new_hot_bytes: u64 = 0;
3633        let schema_snapshot = table.schema().clone();
3634        for (i, row) in original_rows.into_iter().enumerate() {
3635            if !live[i] {
3636                continue;
3637            }
3638            let final_row = if let Some(new_values) = overlay.remove(&i) {
3639                Row::new(new_values)
3640            } else {
3641                row
3642            };
3643            new_hot_bytes = new_hot_bytes
3644                .saturating_add(row_body_encoded_len(&final_row, &schema_snapshot) as u64);
3645            new_rows.push_mut(final_row);
3646        }
3647        for row in tail {
3648            new_hot_bytes =
3649                new_hot_bytes.saturating_add(row_body_encoded_len(&row, &schema_snapshot) as u64);
3650            new_rows.push_mut(row);
3651        }
3652        table.set_rows_and_rebuild_indices(new_rows, new_hot_bytes);
3653        Ok(())
3654    }
3655
3656    fn table_for_redo(&mut self, name: &str) -> Result<&mut Table, StorageError> {
3657        self.get_mut(name)
3658            .ok_or_else(|| StorageError::Corrupt(alloc::format!("redo: unknown table {name:?}")))
3659    }
3660
3661    /// v7.34 (crash-recovery P0 #2) — enable row-level redo capture on
3662    /// every table (the engine calls this before a mutating statement
3663    /// when persistence is on; idempotent, keeps any in-flight capture).
3664    pub fn enable_redo_all(&mut self) {
3665        for t in &mut self.tables {
3666            t.enable_redo();
3667        }
3668    }
3669
3670    /// v7.34 — drain the row-level redo captured across all tables, in
3671    /// table order then per-table apply order, and stop capturing. The
3672    /// engine calls this after a successful mutating statement and writes
3673    /// the returned [`RowChange`]s to the WAL in place of the SQL text.
3674    pub fn drain_redo(&mut self) -> Vec<RowChange> {
3675        let mut all = Vec::new();
3676        for t in &mut self.tables {
3677            all.extend(t.take_redo());
3678        }
3679        all
3680    }
3681
3682    pub fn table_count(&self) -> usize {
3683        self.tables.len()
3684    }
3685
3686    /// v7.14.0 — remove a table by name. Returns `true` when the
3687    /// table existed (and is now gone), `false` when it didn't.
3688    /// Used by `DROP TABLE` from pg_dump / mysqldump preambles
3689    /// where the dump re-creates schema and starts with
3690    /// `DROP TABLE IF EXISTS`.
3691    pub fn drop_table(&mut self, name: &str) -> bool {
3692        let Some(idx) = self.by_name.remove(name) else {
3693            return false;
3694        };
3695        // swap_remove invalidates the trailing index → rebuild
3696        // by_name for affected entries.
3697        self.tables.swap_remove(idx);
3698        // Re-stamp moved table's index slot in by_name.
3699        if idx < self.tables.len() {
3700            let moved_name = self.tables[idx].schema.name.clone();
3701            self.by_name.insert(moved_name, idx);
3702        }
3703        true
3704    }
3705
3706    /// v7.16.2 — rename a table (mailrs round-10 A.5). Updates
3707    /// the schema name, the catalog name → index map, and
3708    /// rewrites every reference dangling at the table name:
3709    ///   * every FK on every OTHER table whose `parent_table`
3710    ///     pointed at the old name now points at the new
3711    ///     name, so FK enforcement keeps working
3712    ///   * every trigger watching the table updates its `table`
3713    ///     field
3714    /// Returns `Ok` on success; `Err(StorageError::TableNotFound)`
3715    /// when the old name isn't in the catalog and
3716    /// `Err(StorageError::DuplicateTable)` when the new name is
3717    /// already taken.
3718    pub fn rename_table(&mut self, old: &str, new: &str) -> Result<(), StorageError> {
3719        if old == new {
3720            return Ok(());
3721        }
3722        if self.by_name.contains_key(new) {
3723            return Err(StorageError::Corrupt(format!(
3724                "rename_table: target name {new:?} already exists"
3725            )));
3726        }
3727        let idx = self
3728            .by_name
3729            .remove(old)
3730            .ok_or_else(|| StorageError::TableNotFound { name: old.into() })?;
3731        self.tables[idx].schema.name = new.to_string();
3732        self.by_name.insert(new.to_string(), idx);
3733        for t in &mut self.tables {
3734            for fk in &mut t.schema.foreign_keys {
3735                if fk.parent_table == old {
3736                    fk.parent_table = new.to_string();
3737                }
3738            }
3739        }
3740        for trig in &mut self.triggers {
3741            if trig.table == old {
3742                trig.table = new.to_string();
3743            }
3744        }
3745        Ok(())
3746    }
3747
3748    /// v7.16.2 — rename an index by name. Walks every table
3749    /// since the index lives on its owning table; updates the
3750    /// name in place. Errors with `IndexNotFound` when no
3751    /// index matches. mailrs round-10 A.5.
3752    pub fn rename_index(&mut self, old: &str, new: &str) -> Result<(), StorageError> {
3753        if old == new {
3754            return Ok(());
3755        }
3756        // Reject the new name if it already exists anywhere.
3757        for t in &self.tables {
3758            if t.indices.iter().any(|i| i.name == new) {
3759                return Err(StorageError::Corrupt(format!(
3760                    "rename_index: target name {new:?} already exists"
3761                )));
3762            }
3763        }
3764        for t in &mut self.tables {
3765            for i in &mut t.indices {
3766                if i.name == old {
3767                    i.name = new.to_string();
3768                    return Ok(());
3769                }
3770            }
3771        }
3772        Err(StorageError::IndexNotFound { name: old.into() })
3773    }
3774
3775    /// v7.14.0 — remove a named index across the catalog.
3776    /// Returns `true` when found + dropped.
3777    pub fn drop_named_index(&mut self, name: &str) -> bool {
3778        for t in &mut self.tables {
3779            let before = t.indices.len();
3780            t.indices.retain(|i| i.name != name);
3781            if t.indices.len() != before {
3782                return true;
3783            }
3784        }
3785        false
3786    }
3787
3788    /// Borrow-free copy of every table's name in catalog order
3789    /// (= insertion order, matching the on-disk encoding).
3790    pub fn table_names(&self) -> Vec<String> {
3791        self.tables.iter().map(|t| t.schema.name.clone()).collect()
3792    }
3793
3794    /// v5.1: register a cold-tier segment that already lives in
3795    /// memory (caller did the file read). Returns the
3796    /// `segment_id` that `RowLocator::Cold { segment_id, .. }`
3797    /// will reference — currently this is just the index into
3798    /// `cold_segments`, but treat it as an opaque token.
3799    ///
3800    /// Storage is `no_std`, so file I/O is the caller's
3801    /// responsibility — `spg-server` reads the file and forwards
3802    /// the bytes here. The bytes stay resident in the catalog
3803    /// for the life of the `Catalog`, parsed only once.
3804    pub fn load_segment_bytes(&mut self, bytes: Vec<u8>) -> Result<u32, StorageError> {
3805        let id = u32::try_from(self.cold_segments.len()).map_err(|_| {
3806            StorageError::Corrupt("cold segment count would exceed u32::MAX".into())
3807        })?;
3808        let seg = OwnedSegment::from_bytes(bytes)
3809            .map_err(|e| StorageError::Corrupt(format!("cold segment parse failed: {e}")))?;
3810        self.cold_segments.push(Some(Arc::new(seg)));
3811        Ok(id)
3812    }
3813
3814    /// v6.7.3 — register a cold-tier segment at a specific id. Used
3815    /// by the spg-server manifest-boot path so segments whose
3816    /// neighbouring ids were retired by compaction still get back
3817    /// the same `segment_id` they had pre-restart (the
3818    /// `RowLocator::Cold { segment_id }` baked into the BTree-index
3819    /// snapshot persists across restart and must continue to
3820    /// resolve).
3821    ///
3822    /// Pads the Vec with `None` slots up to `target_id` if needed.
3823    /// Errors when the target slot is already occupied (would
3824    /// stomp another segment), the parse fails, or `target_id`
3825    /// exceeds `u32::MAX`.
3826    pub fn load_segment_bytes_at(
3827        &mut self,
3828        target_id: u32,
3829        bytes: Vec<u8>,
3830    ) -> Result<(), StorageError> {
3831        let seg = OwnedSegment::from_bytes(bytes)
3832            .map_err(|e| StorageError::Corrupt(format!("cold segment parse failed: {e}")))?;
3833        let idx = target_id as usize;
3834        while self.cold_segments.len() <= idx {
3835            self.cold_segments.push(None);
3836        }
3837        if self.cold_segments[idx].is_some() {
3838            return Err(StorageError::Corrupt(format!(
3839                "load_segment_bytes_at: segment_id {target_id} already occupied"
3840            )));
3841        }
3842        self.cold_segments[idx] = Some(Arc::new(seg));
3843        Ok(())
3844    }
3845
3846    /// v6.7.3 — retire a cold-tier segment slot (compaction-driven).
3847    /// The physical file is the caller's concern (typically kept
3848    /// on disk until the next CHECKPOINT writes a manifest that
3849    /// no longer lists it); this just flips the in-memory slot
3850    /// to `None` so later cold lookups for `segment_id` resolve
3851    /// as "unknown" instead of returning a stale row.
3852    ///
3853    /// No-op when the slot is already `None`. Errors only when
3854    /// `segment_id` is out of bounds.
3855    pub fn tombstone_segment(&mut self, segment_id: u32) -> Result<(), StorageError> {
3856        let idx = segment_id as usize;
3857        if idx >= self.cold_segments.len() {
3858            return Err(StorageError::Corrupt(format!(
3859                "tombstone_segment: segment_id {segment_id} out of bounds (len={})",
3860                self.cold_segments.len()
3861            )));
3862        }
3863        self.cold_segments[idx] = None;
3864        Ok(())
3865    }
3866
3867    /// Number of *active* (non-tombstoned) cold segments.
3868    #[must_use]
3869    pub fn cold_segment_count(&self) -> usize {
3870        self.cold_segments.iter().filter(|s| s.is_some()).count()
3871    }
3872
3873    /// v7.37.42 (docker-fair SCALARSQ attack 3) — short-circuit guard
3874    /// for scan loops that conditionally walk the cold tier. Returns
3875    /// `false` when the catalog has never loaded a cold segment (or all
3876    /// segments are tombstoned), so callers can skip the per-table cold
3877    /// PK-index walk entirely on hot-only databases. O(N segments);
3878    /// typical N is small (single-digit) so the check is sub-µs.
3879    #[must_use]
3880    pub fn has_any_cold_segments(&self) -> bool {
3881        self.cold_segments.iter().any(Option::is_some)
3882    }
3883
3884    /// Slot count including tombstones (= the next id the
3885    /// no-arg `load_segment_bytes` would allocate).
3886    #[must_use]
3887    pub fn cold_segment_slot_count(&self) -> usize {
3888        self.cold_segments.len()
3889    }
3890
3891    /// v6.2.7 — list every *active* cold-tier segment id known to
3892    /// this catalog (skips compaction tombstones since v6.7.3).
3893    /// Used by EXPLAIN ANALYZE to annotate scan nodes with the
3894    /// segments they could have walked.
3895    #[must_use]
3896    pub fn cold_segment_ids_global(&self) -> Vec<u32> {
3897        self.cold_segments
3898            .iter()
3899            .enumerate()
3900            .filter_map(|(i, s)| s.as_ref().map(|_| i as u32))
3901            .collect()
3902    }
3903
3904    /// v5.2.1: sum of `Table::hot_bytes` across every table. The v5.2
3905    /// freezer compares this against `SPG_HOT_TIER_BYTES` (parsed at
3906    /// server startup; default 4 GiB) and wakes when the budget is
3907    /// crossed. Pre-freezer (v5.2.1) this is measurement-only — the
3908    /// counter exposes whether the budget is being approached without
3909    /// triggering any demotion.
3910    #[must_use]
3911    pub fn hot_tier_bytes(&self) -> u64 {
3912        self.tables
3913            .iter()
3914            .map(Table::hot_bytes)
3915            .fold(0u64, u64::saturating_add)
3916    }
3917
3918    /// v5.2.2: freeze the **first** `max_rows` rows of `table_name`'s
3919    /// hot tier into a brand-new cold-tier segment. The named `BTree`
3920    /// index supplies the per-row PK (its column must be an integer
3921    /// type — v5.2.2 only supports `IndexKey::Int` PKs, matching the
3922    /// `index_key_as_u64` constraint used by the cold-tier lookup
3923    /// path). On success returns a [`FreezeReport`] with the
3924    /// freshly-allocated segment id, the count of rows that moved,
3925    /// the encoded segment bytes (so the caller can persist them to
3926    /// disk for later reload via `SPG_PRELOAD_COLD_SEGMENT`), and the
3927    /// hot-tier byte delta that was reclaimed.
3928    ///
3929    /// **Semantics**:
3930    /// 1. The first `max_rows` rows (by hot-tier position — same as
3931    ///    insertion order under v4.39 `PersistentVec`) are read.
3932    /// 2. Rows are sorted ascending by PK and serialised into a new
3933    ///    segment via [`encode_segment`].
3934    /// 3. The hot rows are dropped via [`Table::delete_rows`]; the
3935    ///    `rebuild_indices` it triggers regenerates `Hot` locators
3936    ///    for every remaining row (their positions shift down by
3937    ///    `max_rows`). Existing `Cold` locators in this index — from
3938    ///    a previous freeze — are also rebuilt **but with empty
3939    ///    payload** since rebuild reads only `self.rows`; this
3940    ///    routine re-registers them at the end of the call so the
3941    ///    user-visible state preserves all prior cold locators.
3942    /// 4. The new segment is loaded into `self.cold_segments` via
3943    ///    [`Catalog::load_segment_bytes`] (allocating a fresh
3944    ///    `segment_id`). New `Cold` locators are registered on the
3945    ///    named index — one per frozen row.
3946    ///
3947    /// **v5.2.2 limits** (relaxed in later sub-versions):
3948    /// - INSERT-only flow: subsequent UPDATE/DELETE on a frozen row
3949    ///   returns a stale-locator error (no promote-on-write until
3950    ///   v5.2.3).
3951    /// - Single-table scope: callers iterate tables themselves.
3952    /// - All-or-nothing: returns `Err` and leaves catalog unchanged
3953    ///   if any step fails before the atomic swap point.
3954    ///
3955    /// Errors:
3956    /// - [`StorageError::Corrupt`] for missing table/index, non-`BTree`
3957    ///   index, non-integer PK column, `max_rows == 0`, or
3958    ///   `max_rows > row_count`.
3959    /// - The encoder's [`SegmentError`] surfaces as `Corrupt` (the
3960    ///   only realistic source is "a single row is larger than the
3961    ///   page size"; SPG schemas don't hit it in practice).
3962    pub fn freeze_oldest_to_cold(
3963        &mut self,
3964        table_name: &str,
3965        index_name: &str,
3966        max_rows: usize,
3967    ) -> Result<FreezeReport, StorageError> {
3968        // --- validation phase: never mutates ---------------------
3969        if max_rows == 0 {
3970            return Err(StorageError::Corrupt(
3971                "freeze_oldest_to_cold: max_rows must be > 0".into(),
3972            ));
3973        }
3974        let table = self.get(table_name).ok_or_else(|| {
3975            StorageError::Corrupt(format!(
3976                "freeze_oldest_to_cold: table {table_name:?} not found"
3977            ))
3978        })?;
3979        if max_rows > table.rows.len() {
3980            return Err(StorageError::Corrupt(format!(
3981                "freeze_oldest_to_cold: max_rows {max_rows} > row_count {}",
3982                table.rows.len()
3983            )));
3984        }
3985        let idx = table
3986            .indices
3987            .iter()
3988            .find(|i| i.name == index_name)
3989            .ok_or_else(|| {
3990                StorageError::Corrupt(format!(
3991                    "freeze_oldest_to_cold: index {index_name:?} not found on {table_name:?}"
3992                ))
3993            })?;
3994        if !matches!(idx.kind, IndexKind::BTree(_)) {
3995            return Err(StorageError::Corrupt(format!(
3996                "freeze_oldest_to_cold: index {index_name:?} is NSW; only BTree indices may freeze"
3997            )));
3998        }
3999        let column_position = idx.column_position;
4000
4001        // --- segment build phase: reads only --------------------
4002        let schema = table.schema.clone();
4003        let mut to_freeze: Vec<(u64, Vec<u8>, IndexKey)> = Vec::with_capacity(max_rows);
4004        for row_idx in 0..max_rows {
4005            let row = table.rows.get(row_idx).expect("bounds-checked above");
4006            let key = IndexKey::from_value(&row.values[column_position]).ok_or_else(|| {
4007                StorageError::Corrupt(format!(
4008                    "freeze_oldest_to_cold: row {row_idx} has NULL / non-key value in index column"
4009                ))
4010            })?;
4011            let pk_u64 = index_key_as_u64(&key).ok_or_else(|| {
4012                StorageError::Corrupt(format!(
4013                    "freeze_oldest_to_cold: index {index_name:?} column type is non-integer; \
4014                     v5.2.2 cold tier requires IndexKey::Int (Text PK lands in v5.5+)"
4015                ))
4016            })?;
4017            to_freeze.push((pk_u64, encode_row_body_dense(row, &schema), key));
4018        }
4019        // encode_segment requires ascending u64 keys. Sort by PK
4020        // before encoding; the caller's row-position order is not
4021        // necessarily PK order (e.g. workloads that insert random
4022        // PKs).
4023        to_freeze.sort_by_key(|(k, _, _)| *k);
4024        // Reject duplicate PKs — encode_segment also rejects them
4025        // (`SegmentError::UnsortedKey`), but the resulting error
4026        // message there is misleading. Surface a clearer one.
4027        for w in to_freeze.windows(2) {
4028            if w[0].0 == w[1].0 {
4029                return Err(StorageError::Corrupt(format!(
4030                    "freeze_oldest_to_cold: duplicate PK {} in freeze batch",
4031                    w[0].0
4032                )));
4033            }
4034        }
4035        // Snapshot the (key, locator) pairs that will be registered
4036        // post-swap. Cloning the IndexKey out before the move makes
4037        // the registration loop borrow-free.
4038        let post_swap_keys: Vec<IndexKey> = to_freeze.iter().map(|(_, _, k)| k.clone()).collect();
4039        // Segment encode is now infallible w.r.t. ordering. Map the
4040        // `SegmentError` into a `StorageError::Corrupt` so the
4041        // public surface stays one error type.
4042        let seg_rows: Vec<(u64, Vec<u8>)> = to_freeze
4043            .into_iter()
4044            .map(|(k, body, _)| (k, body))
4045            .collect();
4046        let frozen_rows = seg_rows.len();
4047        let (seg_bytes, _meta) = encode_segment(seg_rows.into_iter(), 0.01, SEGMENT_PAGE_BYTES)
4048            .map_err(|e| StorageError::Corrupt(format!("freeze_oldest_to_cold: encode: {e}")))?;
4049
4050        // --- atomic swap phase: mutations only past this point ---
4051        // v5.2.3 made `Table::rebuild_indices` preserve every Cold
4052        // locator across the per-table rebuild, so `delete_rows`
4053        // below no longer wipes prior-freeze cold entries. The pre-
4054        // v5.2.3 capture-then-re-register that used to live here
4055        // was removed in v5.3.1 — keeping it would double-count
4056        // every prior-frozen key's Cold locator on each subsequent
4057        // freeze.
4058        let bytes_before = self.get(table_name).expect("just validated").hot_bytes();
4059        let positions: Vec<usize> = (0..max_rows).collect();
4060        let t_mut = self
4061            .get_mut(table_name)
4062            .expect("just validated; still present");
4063        let removed = t_mut.delete_rows(&positions);
4064        debug_assert_eq!(removed, max_rows, "delete_rows count matches request");
4065        let bytes_after = t_mut.hot_bytes();
4066        let bytes_freed = bytes_before.saturating_sub(bytes_after);
4067
4068        let segment_id = self
4069            .load_segment_bytes(seg_bytes.clone())
4070            .map_err(|e| StorageError::Corrupt(format!("freeze_oldest_to_cold: load: {e}")))?;
4071        let new_cold = post_swap_keys.into_iter().map(|k| {
4072            (
4073                k,
4074                RowLocator::Cold {
4075                    segment_id,
4076                    page_offset: 0,
4077                },
4078            )
4079        });
4080        let t_mut = self.get_mut(table_name).expect("still present");
4081        t_mut.register_cold_locators(index_name, new_cold)?;
4082
4083        Ok(FreezeReport {
4084            segment_id,
4085            frozen_rows,
4086            bytes_freed,
4087            segment_bytes: seg_bytes,
4088        })
4089    }
4090
4091    /// v5.1: borrow the cold segment at `segment_id`. Used by the
4092    /// spg-server preload path to enumerate (key, locator) pairs
4093    /// after loading a segment, so it can call
4094    /// [`Table::register_cold_locators`] without re-parsing the
4095    /// bytes.
4096    #[must_use]
4097    pub fn cold_segment(&self, segment_id: u32) -> Option<&OwnedSegment> {
4098        self.cold_segments
4099            .get(segment_id as usize)
4100            .and_then(|s| s.as_deref())
4101    }
4102
4103    /// v5.1: resolve a single `RowLocator::Cold` to its underlying
4104    /// `Row`. Decoupled from [`Catalog::lookup_by_pk`] so callers
4105    /// iterating a multi-locator slice (e.g. the engine's index
4106    /// seek path) can dispatch per locator instead of getting back
4107    /// only the first row for a key. Returns `None` when the
4108    /// segment isn't registered, the key isn't `u64`-coercible, or
4109    /// the segment doesn't actually carry the key (bloom or page-
4110    /// index reject).
4111    pub fn resolve_cold_locator(
4112        &self,
4113        table_name: &str,
4114        segment_id: u32,
4115        key: &IndexKey,
4116    ) -> Option<Row<'static>> {
4117        let t = self.get(table_name)?;
4118        let u64_key = index_key_as_u64(key)?;
4119        let seg = self.cold_segments.get(segment_id as usize)?.as_ref()?;
4120        let payload = seg.lookup(u64_key)?;
4121        let (row, _) = decode_row_body_dense(&payload, &t.schema, seg.codec_version()).ok()?;
4122        Some(row)
4123    }
4124
4125    /// v5.1: indexed PK lookup that dispatches per locator,
4126    /// returning the first matching row from either the hot tier
4127    /// (`Table::rows`) or a registered cold segment.
4128    ///
4129    /// The cold path requires the index column to be coercible to
4130    /// a `u64` (the segment's PK type) and the segment payload to
4131    /// be a [`encode_row_body_dense`]-encoded row body for the
4132    /// same schema. v5.1 ships this for BIGINT / INT / SMALLINT
4133    /// PKs; other types fall through to hot-only behavior.
4134    ///
4135    /// Returns `None` if (a) the table or index doesn't exist,
4136    /// (b) the key isn't in the index at all, or (c) the key was
4137    /// resolved to a stale locator (Hot index out of range, Cold
4138    /// segment id unknown, segment lookup miss). Does not surface
4139    /// segment-decode errors — those would indicate corrupted
4140    /// cold-tier files and should be caught at
4141    /// [`Catalog::load_segment_bytes`] time.
4142    pub fn lookup_by_pk(&self, table: &str, index_name: &str, key: &IndexKey) -> Option<Row<'_>> {
4143        let t = self.get(table)?;
4144        let idx = t.indices.iter().find(|i| i.name == index_name)?;
4145        let locators = idx.lookup_eq(key);
4146        let cold_u64_key = index_key_as_u64(key);
4147        for loc in locators {
4148            match *loc {
4149                RowLocator::Hot(i) => {
4150                    if let Some(row) = t.rows.get(i) {
4151                        return Some(row.clone());
4152                    }
4153                }
4154                RowLocator::Cold {
4155                    segment_id,
4156                    page_offset: _,
4157                } => {
4158                    let Some(u64_key) = cold_u64_key else {
4159                        // Key type not coercible to u64 — cold tier
4160                        // only handles BIGINT/INT/SMALLINT in v5.1.
4161                        continue;
4162                    };
4163                    let Some(seg) = self
4164                        .cold_segments
4165                        .get(segment_id as usize)
4166                        .and_then(|s| s.as_deref())
4167                    else {
4168                        // v6.7.3 — `None` slot = compaction
4169                        // retired this segment; the live locator
4170                        // on a freshly-compacted index points to
4171                        // the merged segment_id, so a Cold hit
4172                        // here against a tombstone means the BTree
4173                        // entry hasn't been swapped yet (mid-
4174                        // compaction reader race) or the caller is
4175                        // looking up a stale snapshot. Skip — the
4176                        // next locator in the list, if any, is
4177                        // typically the merged segment.
4178                        continue;
4179                    };
4180                    let Some(payload) = seg.lookup(u64_key) else {
4181                        continue;
4182                    };
4183                    let (row, _) =
4184                        decode_row_body_dense(&payload, &t.schema, seg.codec_version()).ok()?;
4185                    return Some(row);
4186                }
4187            }
4188        }
4189        None
4190    }
4191
4192    /// v5.2.3: promote a frozen row back to the hot tier so an
4193    /// UPDATE / DELETE can mutate it. Reads the cold-tier row body
4194    /// (decoded from its registered segment), pushes it into
4195    /// `table.rows` via [`Table::insert`] (which also adds a fresh
4196    /// `Hot(new_idx)` locator on `index_name`), then retires the
4197    /// shadowed `Cold` locator via
4198    /// [`Table::remove_cold_locators_for_key`]. The cold-tier row
4199    /// in the segment file becomes garbage — recoverable when a
4200    /// future cold-segment compaction job lands.
4201    ///
4202    /// Returns:
4203    /// - `Ok(Some(new_hot_idx))` when the key resolved through a
4204    ///   cold locator and the promote completed. `new_hot_idx` is
4205    ///   the position the row now occupies in `table.rows`.
4206    /// - `Ok(None)` when the key has no Cold locator on the index
4207    ///   (already hot, or wasn't present at all). Callers treat this
4208    ///   as "nothing to do here, fall back to the hot-only path".
4209    ///
4210    /// Errors when the table / index doesn't exist, the index isn't
4211    /// `BTree`, the cold segment is missing / can't decode the row,
4212    /// or the inferred row body fails `Table::insert` validation.
4213    pub fn promote_cold_row(
4214        &mut self,
4215        table_name: &str,
4216        index_name: &str,
4217        key: &IndexKey,
4218    ) -> Result<Option<usize>, StorageError> {
4219        let cold_loc = self.find_cold_locator(table_name, index_name, key)?;
4220        let Some((segment_id, _page_offset)) = cold_loc else {
4221            return Ok(None);
4222        };
4223        let u64_key = index_key_as_u64(key).ok_or_else(|| {
4224            StorageError::Corrupt(
4225                "promote_cold_row: key type not coercible to u64 (cold tier requires integer PK)"
4226                    .into(),
4227            )
4228        })?;
4229        // Read the row body from the segment. Borrow the segment +
4230        // schema short-term so we can then take `&mut self` for the
4231        // hot-side insert.
4232        let schema = self
4233            .get(table_name)
4234            .ok_or_else(|| {
4235                StorageError::Corrupt(format!("promote_cold_row: table {table_name:?} not found"))
4236            })?
4237            .schema
4238            .clone();
4239        let seg = self
4240            .cold_segments
4241            .get(segment_id as usize)
4242            .and_then(|s| s.as_ref())
4243            .ok_or_else(|| {
4244                StorageError::Corrupt(format!(
4245                    "promote_cold_row: segment {segment_id} not registered on catalog"
4246                ))
4247            })?;
4248        let payload = seg.lookup(u64_key).ok_or_else(|| {
4249            StorageError::Corrupt(format!(
4250                "promote_cold_row: key {u64_key} resolves to segment {segment_id} \
4251                 but the segment's bloom/page lookup didn't return a row"
4252            ))
4253        })?;
4254        let (row, _consumed) = decode_row_body_dense(&payload, &schema, seg.codec_version())?;
4255        // Insert the promoted row into the hot tier. `Table::insert`
4256        // appends to `self.rows`, adds a `Hot(new_idx)` locator to
4257        // every BTree index covering the row's keyed columns, and
4258        // increments `hot_bytes`.
4259        let t = self
4260            .get_mut(table_name)
4261            .expect("table existed at lookup time");
4262        t.insert(row)?;
4263        let new_hot_idx =
4264            t.rows.len().checked_sub(1).ok_or_else(|| {
4265                StorageError::Corrupt("promote_cold_row: empty after insert".into())
4266            })?;
4267        // The hot insert added Hot(new_idx) alongside the still-
4268        // present Cold locator. Drop the Cold entry so future
4269        // lookups return only the fresh hot row.
4270        t.remove_cold_locators_for_key(index_name, key)?;
4271        Ok(Some(new_hot_idx))
4272    }
4273
4274    /// v5.2.3: shadow a frozen row's index entry. Used by DELETE
4275    /// when the row to remove lives in a cold-tier segment — the
4276    /// row body stays in the segment file (becoming garbage) but
4277    /// every `Cold` locator for `key` on `index_name` is removed
4278    /// so PK lookups stop returning it.
4279    ///
4280    /// Returns the number of cold locators retired (0 when the key
4281    /// has no cold entries — the DELETE fell on a hot row or a
4282    /// key that was already absent). Errors when the table /
4283    /// index doesn't exist or the index isn't `BTree`.
4284    ///
4285    /// Cold-segment compaction (which merges shadowed-heavy
4286    /// segments and reclaims their disk footprint) lands in a
4287    /// later v5.x sub-version; until then, repeated UPDATE/DELETE
4288    /// of cold rows can amplify cold-segment disk usage by up to
4289    /// 1-2× — still well under typical LSM-tree shadowing because
4290    /// SPG segments are bulk-baked, not write-merged.
4291    pub fn shadow_cold_row(
4292        &mut self,
4293        table_name: &str,
4294        index_name: &str,
4295        key: &IndexKey,
4296    ) -> Result<usize, StorageError> {
4297        let t = self.get_mut(table_name).ok_or_else(|| {
4298            StorageError::Corrupt(format!("shadow_cold_row: table {table_name:?} not found"))
4299        })?;
4300        t.remove_cold_locators_for_key(index_name, key)
4301    }
4302
4303    /// v6.7.4 — read-only slice preparation for the parallel
4304    /// freezer. Walks rows in `row_range`, builds the
4305    /// `(pk_u64, encoded_body, IndexKey)` triples that the
4306    /// coordinator's k-way merge consumes, sorts the slice by
4307    /// `pk_u64`, and returns a [`FreezeSlice`].
4308    ///
4309    /// Caller invariants:
4310    /// - `row_range.end <= table.rows.len()` (caller's job to
4311    ///   compute the partition).
4312    /// - All slices passed to `commit_freeze_slices` must cover a
4313    ///   contiguous half-open range `[0, total_max_rows)` with no
4314    ///   gaps and no overlaps. The coordinator validates this
4315    ///   invariant before committing.
4316    ///
4317    /// `&self`-only — multiple workers can run this concurrently
4318    /// against the same `Catalog` reference under the engine's
4319    /// write lock (workers don't mutate; the coordinator does).
4320    pub fn prepare_freeze_slice(
4321        &self,
4322        table_name: &str,
4323        index_name: &str,
4324        row_range: core::ops::Range<usize>,
4325    ) -> Result<FreezeSlice, StorageError> {
4326        let table = self.get(table_name).ok_or_else(|| {
4327            StorageError::Corrupt(format!(
4328                "prepare_freeze_slice: table {table_name:?} not found"
4329            ))
4330        })?;
4331        let idx = table
4332            .indices
4333            .iter()
4334            .find(|i| i.name == index_name)
4335            .ok_or_else(|| {
4336                StorageError::Corrupt(format!(
4337                    "prepare_freeze_slice: index {index_name:?} not found on {table_name:?}"
4338                ))
4339            })?;
4340        if !matches!(idx.kind, IndexKind::BTree(_)) {
4341            return Err(StorageError::Corrupt(format!(
4342                "prepare_freeze_slice: index {index_name:?} is NSW; only BTree indices may freeze"
4343            )));
4344        }
4345        if row_range.end > table.rows.len() {
4346            return Err(StorageError::Corrupt(format!(
4347                "prepare_freeze_slice: row_range end {} > row_count {}",
4348                row_range.end,
4349                table.rows.len()
4350            )));
4351        }
4352        let column_position = idx.column_position;
4353        let schema = table.schema.clone();
4354        let mut rows: Vec<(u64, Vec<u8>, IndexKey)> = Vec::with_capacity(row_range.len());
4355        for row_idx in row_range.clone() {
4356            let row = table.rows.get(row_idx).expect("bounds-checked above");
4357            let key = IndexKey::from_value(&row.values[column_position]).ok_or_else(|| {
4358                StorageError::Corrupt(format!(
4359                    "prepare_freeze_slice: row {row_idx} has NULL / non-key value in index column"
4360                ))
4361            })?;
4362            let pk_u64 = index_key_as_u64(&key).ok_or_else(|| {
4363                StorageError::Corrupt(format!(
4364                    "prepare_freeze_slice: index {index_name:?} column type is non-integer; \
4365                     v5.2.2 cold tier requires IndexKey::Int (Text PK lands in v5.5+)"
4366                ))
4367            })?;
4368            rows.push((pk_u64, encode_row_body_dense(row, &schema), key));
4369        }
4370        rows.sort_by_key(|(k, _, _)| *k);
4371        Ok(FreezeSlice { row_range, rows })
4372    }
4373
4374    /// v6.7.4 — coordinator commit step. Merges N
4375    /// [`FreezeSlice`]s into one segment via the standard
4376    /// [`encode_segment`] path, atomically swaps the catalog
4377    /// state (delete the union row range + register Cold
4378    /// locators + load the segment).
4379    ///
4380    /// Validates that the slices cover a contiguous, gap-free,
4381    /// overlap-free half-open range starting at index 0 (the
4382    /// freezer always freezes "oldest first" — same semantics as
4383    /// the single-threaded [`Catalog::freeze_oldest_to_cold`]).
4384    ///
4385    /// Empty `slices` → no-op success (returns a zero-row report
4386    /// without mutating). Total row count = `Σ slice.rows.len()`.
4387    pub fn commit_freeze_slices(
4388        &mut self,
4389        table_name: &str,
4390        index_name: &str,
4391        slices: Vec<FreezeSlice>,
4392    ) -> Result<FreezeReport, StorageError> {
4393        // --- validation phase: never mutates ---------------------
4394        let table = self.get(table_name).ok_or_else(|| {
4395            StorageError::Corrupt(format!(
4396                "commit_freeze_slices: table {table_name:?} not found"
4397            ))
4398        })?;
4399        let idx = table
4400            .indices
4401            .iter()
4402            .find(|i| i.name == index_name)
4403            .ok_or_else(|| {
4404                StorageError::Corrupt(format!(
4405                    "commit_freeze_slices: index {index_name:?} not found on {table_name:?}"
4406                ))
4407            })?;
4408        if !matches!(idx.kind, IndexKind::BTree(_)) {
4409            return Err(StorageError::Corrupt(format!(
4410                "commit_freeze_slices: index {index_name:?} is NSW; only BTree indices may freeze"
4411            )));
4412        }
4413        // Validate slice coverage: contiguous from 0, no gaps, no
4414        // overlaps. Allow the caller to pass slices in any order —
4415        // sort by row_range.start first.
4416        let mut ordered = slices;
4417        ordered.sort_by_key(|s| s.row_range.start);
4418        // Drop fully-empty slices that fell out of an uneven
4419        // partition; they carry no data but contribute to the
4420        // contiguity check, so keep them in line.
4421        let mut expected_start = 0usize;
4422        for s in &ordered {
4423            if s.row_range.start != expected_start {
4424                return Err(StorageError::Corrupt(format!(
4425                    "commit_freeze_slices: gap/overlap at row {}; expected start {}",
4426                    s.row_range.start, expected_start
4427                )));
4428            }
4429            expected_start = s.row_range.end;
4430        }
4431        let max_rows = expected_start;
4432        if max_rows > table.rows.len() {
4433            return Err(StorageError::Corrupt(format!(
4434                "commit_freeze_slices: total row range {} exceeds row_count {}",
4435                max_rows,
4436                table.rows.len()
4437            )));
4438        }
4439        if max_rows == 0 {
4440            return Ok(FreezeReport {
4441                segment_id: u32::MAX,
4442                frozen_rows: 0,
4443                bytes_freed: 0,
4444                segment_bytes: Vec::new(),
4445            });
4446        }
4447
4448        // --- segment build phase: reads only --------------------
4449        // K-way merge of already-sorted slices. Each slice's rows
4450        // are ascending by pk_u64; we keep a per-slice cursor and
4451        // pull the next-smallest head until every cursor drains.
4452        let total_rows: usize = ordered.iter().map(|s| s.rows.len()).sum();
4453        if total_rows != max_rows {
4454            return Err(StorageError::Corrupt(format!(
4455                "commit_freeze_slices: total slice rows {total_rows} ≠ row_range coverage {max_rows}"
4456            )));
4457        }
4458        let mut cursors: Vec<usize> = alloc::vec![0; ordered.len()];
4459        let mut merged: Vec<(u64, Vec<u8>, IndexKey)> = Vec::with_capacity(total_rows);
4460        loop {
4461            // Pick the slice whose head row has the smallest key
4462            // and isn't yet exhausted.
4463            let mut pick: Option<usize> = None;
4464            for (i, c) in cursors.iter().enumerate() {
4465                let slice = &ordered[i];
4466                if *c >= slice.rows.len() {
4467                    continue;
4468                }
4469                match pick {
4470                    None => pick = Some(i),
4471                    Some(j) => {
4472                        if slice.rows[*c].0 < ordered[j].rows[cursors[j]].0 {
4473                            pick = Some(i);
4474                        }
4475                    }
4476                }
4477            }
4478            let Some(i) = pick else { break };
4479            let row = ordered[i].rows[cursors[i]].clone();
4480            cursors[i] += 1;
4481            merged.push(row);
4482        }
4483        // Reject duplicate PKs — same error as the single-threaded
4484        // path so callers get a uniform surface.
4485        for w in merged.windows(2) {
4486            if w[0].0 == w[1].0 {
4487                return Err(StorageError::Corrupt(format!(
4488                    "commit_freeze_slices: duplicate PK {} across slices",
4489                    w[0].0
4490                )));
4491            }
4492        }
4493        let post_swap_keys: Vec<IndexKey> = merged.iter().map(|(_, _, k)| k.clone()).collect();
4494        let seg_rows: Vec<(u64, Vec<u8>)> =
4495            merged.into_iter().map(|(k, body, _)| (k, body)).collect();
4496        let frozen_rows = seg_rows.len();
4497        let (seg_bytes, _meta) = encode_segment(seg_rows.into_iter(), 0.01, SEGMENT_PAGE_BYTES)
4498            .map_err(|e| StorageError::Corrupt(format!("commit_freeze_slices: encode: {e}")))?;
4499
4500        // --- atomic swap phase: mutations only past this point ---
4501        let bytes_before = self.get(table_name).expect("just validated").hot_bytes();
4502        let positions: Vec<usize> = (0..max_rows).collect();
4503        let t_mut = self
4504            .get_mut(table_name)
4505            .expect("just validated; still present");
4506        let removed = t_mut.delete_rows(&positions);
4507        debug_assert_eq!(removed, max_rows, "delete_rows count matches request");
4508        let bytes_after = t_mut.hot_bytes();
4509        let bytes_freed = bytes_before.saturating_sub(bytes_after);
4510
4511        let segment_id = self
4512            .load_segment_bytes(seg_bytes.clone())
4513            .map_err(|e| StorageError::Corrupt(format!("commit_freeze_slices: load: {e}")))?;
4514        let new_cold = post_swap_keys.into_iter().map(|k| {
4515            (
4516                k,
4517                RowLocator::Cold {
4518                    segment_id,
4519                    page_offset: 0,
4520                },
4521            )
4522        });
4523        let t_mut = self.get_mut(table_name).expect("still present");
4524        t_mut.register_cold_locators(index_name, new_cold)?;
4525
4526        Ok(FreezeReport {
4527            segment_id,
4528            frozen_rows,
4529            bytes_freed,
4530            segment_bytes: seg_bytes,
4531        })
4532    }
4533
4534    /// v6.7.3 — compact every cold segment on `(table, index)` whose
4535    /// `OwnedSegment::bytes().len()` is below `target_segment_bytes`
4536    /// into a single larger merged segment. Rows present in source
4537    /// segment payloads but no longer referenced by any
4538    /// `RowLocator::Cold` on the index (DELETE'd + frozen rows
4539    /// retired via [`Catalog::shadow_cold_row`]) are GC'd in the
4540    /// merge.
4541    ///
4542    /// **Semantics**:
4543    /// 1. Walk the BTree index to collect every Cold locator that
4544    ///    targets a small (< threshold) segment. Each such
4545    ///    `(key, segment_id)` becomes a row in the merged segment;
4546    ///    payload is looked up from the source segment in-place.
4547    /// 2. Encode the collected rows into one new segment via
4548    ///    [`encode_segment`]; register it via
4549    ///    [`Catalog::load_segment_bytes`] (allocating a fresh
4550    ///    `merged_segment_id` at the end of `cold_segments`).
4551    /// 3. Rewrite the BTree index in one pass: every
4552    ///    `RowLocator::Cold { segment_id ∈ sources }` becomes
4553    ///    `RowLocator::Cold { segment_id = merged_id, page_offset = 0 }`.
4554    ///    Hot locators are untouched.
4555    /// 4. Tombstone every source slot via
4556    ///    [`Catalog::tombstone_segment`]. Source segment payloads
4557    ///    are no longer reachable through the catalog; the on-disk
4558    ///    files are the caller's concern.
4559    ///
4560    /// On fewer than 2 candidate segments the catalog is **not**
4561    /// mutated and a no-op report (`merged_segment_id: None`,
4562    /// `sources: []`) is returned. This is the routine case — a
4563    /// freshly-frozen table has at most 1 small segment, no merge
4564    /// possible.
4565    ///
4566    /// Atomicity: every mutating step runs after the read-only
4567    /// gather phase, so a panic before the merge encode leaves the
4568    /// catalog unchanged. The mutation block itself (load + rewrite +
4569    /// tombstone) takes only `&mut self` — callers serialise the
4570    /// engine write lock outside this function.
4571    ///
4572    /// Errors when the table / index doesn't exist, the index isn't
4573    /// `BTree`, the index column type isn't u64-coercible (cold-tier
4574    /// pre-condition), or a source segment fails its in-place
4575    /// row-body lookup (would indicate prior catalog corruption).
4576    pub fn compact_cold_segments(
4577        &mut self,
4578        table_name: &str,
4579        index_name: &str,
4580        target_segment_bytes: u64,
4581    ) -> Result<CompactReport, StorageError> {
4582        // --- validation phase ----------------------------------
4583        let t = self.get(table_name).ok_or_else(|| {
4584            StorageError::Corrupt(format!(
4585                "compact_cold_segments: table {table_name:?} not found"
4586            ))
4587        })?;
4588        let idx = t
4589            .indices
4590            .iter()
4591            .find(|i| i.name == index_name)
4592            .ok_or_else(|| {
4593                StorageError::Corrupt(format!(
4594                    "compact_cold_segments: index {index_name:?} not found on {table_name:?}"
4595                ))
4596            })?;
4597        let map = match &idx.kind {
4598            IndexKind::BTree(m) => m,
4599            IndexKind::Nsw(_)
4600            | IndexKind::Brin { .. }
4601            | IndexKind::Gin(_)
4602            | IndexKind::GinTrgm(_)
4603            | IndexKind::GinFulltext(_)
4604            | IndexKind::GinJsonb(_) => {
4605                return Err(StorageError::Corrupt(format!(
4606                    "compact_cold_segments: index {index_name:?} is not BTree; \
4607                     compaction applies only to BTree cold-tier indices"
4608                )));
4609            }
4610        };
4611
4612        // --- gather phase --------------------------------------
4613        // Step A: every segment_id this BTree index Cold-references.
4614        let mut referenced_ids: BTreeSet<u32> = BTreeSet::new();
4615        for (_key, locators) in map.iter() {
4616            for loc in locators {
4617                if let RowLocator::Cold { segment_id, .. } = loc {
4618                    referenced_ids.insert(*segment_id);
4619                }
4620            }
4621        }
4622        // Step B: keep only the small + still-active ones.
4623        let candidate_set: BTreeSet<u32> = referenced_ids
4624            .into_iter()
4625            .filter(|id| {
4626                self.cold_segments
4627                    .get(*id as usize)
4628                    .and_then(|s| s.as_deref())
4629                    .is_some_and(|s| (s.bytes().len() as u64) < target_segment_bytes)
4630            })
4631            .collect();
4632        if candidate_set.len() < 2 {
4633            return Ok(CompactReport {
4634                sources: Vec::new(),
4635                merged_segment_id: None,
4636                merged_segment_bytes: Vec::new(),
4637                merged_rows: 0,
4638                deleted_rows_pruned: 0,
4639                bytes_reclaimed_estimate: 0,
4640            });
4641        }
4642        // Step C: pre-count source rows for the deleted-pruned metric.
4643        let mut source_row_count: usize = 0;
4644        let mut source_byte_total: u64 = 0;
4645        for &id in &candidate_set {
4646            let seg = self.cold_segments[id as usize]
4647                .as_ref()
4648                .expect("candidate selected only when slot is Some");
4649            source_row_count = source_row_count.saturating_add(seg.meta().num_rows as usize);
4650            source_byte_total = source_byte_total.saturating_add(seg.bytes().len() as u64);
4651        }
4652        // Step D: collect (key, body) pairs from every live Cold
4653        // locator pointing at a candidate. dedupe by key — one
4654        // BTree key resolves to at most one cold payload (the
4655        // freezer + promote/shadow flow keeps Cold locators
4656        // unique per key).
4657        let mut collected: BTreeMap<u64, (Vec<u8>, IndexKey)> = BTreeMap::new();
4658        for (key, locators) in map.iter() {
4659            for loc in locators {
4660                let RowLocator::Cold { segment_id, .. } = loc else {
4661                    continue;
4662                };
4663                if !candidate_set.contains(segment_id) {
4664                    continue;
4665                }
4666                let u64_key = index_key_as_u64(key).ok_or_else(|| {
4667                    StorageError::Corrupt(format!(
4668                        "compact_cold_segments: index {index_name:?} has non-integer Cold key; \
4669                         cold tier requires IndexKey::Int (Text PK lands in v5.5+)"
4670                    ))
4671                })?;
4672                let seg = self.cold_segments[*segment_id as usize]
4673                    .as_ref()
4674                    .expect("candidate slot guaranteed Some above");
4675                let payload = seg.lookup(u64_key).ok_or_else(|| {
4676                    StorageError::Corrupt(format!(
4677                        "compact_cold_segments: BTree {index_name:?} points key={u64_key} \
4678                         at segment {segment_id} but the segment lookup missed"
4679                    ))
4680                })?;
4681                collected.insert(u64_key, (payload, key.clone()));
4682                break;
4683            }
4684        }
4685        let merged_rows = collected.len();
4686        let deleted_rows_pruned = source_row_count.saturating_sub(merged_rows);
4687
4688        // Step E: encode the merged segment. `BTreeMap<u64, _>`
4689        // iteration is ascending by key, which is what
4690        // `encode_segment` requires.
4691        let seg_rows: Vec<(u64, Vec<u8>)> = collected
4692            .iter()
4693            .map(|(k, (body, _))| (*k, body.clone()))
4694            .collect();
4695        let (seg_bytes, _meta) = encode_segment(seg_rows.into_iter(), 0.01, SEGMENT_PAGE_BYTES)
4696            .map_err(|e| StorageError::Corrupt(format!("compact_cold_segments: encode: {e}")))?;
4697        let merged_bytes_len = seg_bytes.len() as u64;
4698
4699        // --- atomic mutation phase ------------------------------
4700        let merged_segment_id = self
4701            .load_segment_bytes(seg_bytes.clone())
4702            .map_err(|e| StorageError::Corrupt(format!("compact_cold_segments: load: {e}")))?;
4703
4704        // Rewrite the BTree index: every Cold locator pointing at
4705        // a candidate source becomes a Cold locator pointing at
4706        // the merged segment. Use a flat collect-then-replace
4707        // pattern so we never hold a `&self` borrow across the
4708        // `&mut self` write.
4709        let entries: Vec<(IndexKey, Vec<RowLocator>)> = {
4710            let t = self
4711                .get(table_name)
4712                .expect("table existed at the start of this fn");
4713            let idx = t
4714                .indices
4715                .iter()
4716                .find(|i| i.name == index_name)
4717                .expect("index existed at the start of this fn");
4718            let IndexKind::BTree(map) = &idx.kind else {
4719                unreachable!("validated above");
4720            };
4721            map.iter().map(|(k, v)| (k.clone(), v.clone())).collect()
4722        };
4723        let t_mut = self
4724            .get_mut(table_name)
4725            .expect("table existed at the start of this fn");
4726        let idx_mut = t_mut
4727            .indices
4728            .iter_mut()
4729            .find(|i| i.name == index_name)
4730            .expect("index existed at the start of this fn");
4731        let IndexKind::BTree(map_mut) = &mut idx_mut.kind else {
4732            unreachable!("validated above");
4733        };
4734        for (key, locators) in entries {
4735            let mut new_locs: Vec<RowLocator> = Vec::with_capacity(locators.len());
4736            let mut changed = false;
4737            for loc in &locators {
4738                match *loc {
4739                    RowLocator::Cold {
4740                        segment_id,
4741                        page_offset: _,
4742                    } if candidate_set.contains(&segment_id) => {
4743                        let replacement = RowLocator::Cold {
4744                            segment_id: merged_segment_id,
4745                            page_offset: 0,
4746                        };
4747                        if !new_locs.contains(&replacement) {
4748                            new_locs.push(replacement);
4749                        }
4750                        changed = true;
4751                    }
4752                    other => new_locs.push(other),
4753                }
4754            }
4755            if changed {
4756                map_mut.insert_mut(key, new_locs);
4757            }
4758        }
4759
4760        // Tombstone every source slot. Last step — failures here
4761        // would leave the segment double-referenced in both
4762        // memory + manifest, but `tombstone_segment` only errors
4763        // on out-of-bounds, which we've already validated.
4764        for &id in &candidate_set {
4765            self.tombstone_segment(id)?;
4766        }
4767
4768        let bytes_reclaimed_estimate = source_byte_total.saturating_sub(merged_bytes_len);
4769        Ok(CompactReport {
4770            sources: candidate_set.into_iter().collect(),
4771            merged_segment_id: Some(merged_segment_id),
4772            merged_segment_bytes: seg_bytes,
4773            merged_rows,
4774            deleted_rows_pruned,
4775            bytes_reclaimed_estimate,
4776        })
4777    }
4778
4779    /// Internal helper: scan `(table, index)` for a `Cold` locator
4780    /// keyed by `key`. Returns `Ok(Some((segment_id, page_offset)))`
4781    /// when found, `Ok(None)` when the key has only hot entries
4782    /// or no entries at all, `Err` on the same input-validation
4783    /// errors as the public `promote_cold_row` / `shadow_cold_row`.
4784    fn find_cold_locator(
4785        &self,
4786        table_name: &str,
4787        index_name: &str,
4788        key: &IndexKey,
4789    ) -> Result<Option<(u32, u32)>, StorageError> {
4790        let t = self.get(table_name).ok_or_else(|| {
4791            StorageError::Corrupt(format!("find_cold_locator: table {table_name:?} not found"))
4792        })?;
4793        let idx = t
4794            .indices
4795            .iter()
4796            .find(|i| i.name == index_name)
4797            .ok_or_else(|| {
4798                StorageError::Corrupt(format!(
4799                    "find_cold_locator: index {index_name:?} not found on {table_name:?}"
4800                ))
4801            })?;
4802        if !matches!(idx.kind, IndexKind::BTree(_)) {
4803            return Err(StorageError::Corrupt(format!(
4804                "find_cold_locator: index {index_name:?} is NSW; promote-on-write only applies to BTree indices"
4805            )));
4806        }
4807        for loc in idx.lookup_eq(key) {
4808            if let RowLocator::Cold {
4809                segment_id,
4810                page_offset,
4811            } = *loc
4812            {
4813                return Ok(Some((segment_id, page_offset)));
4814            }
4815        }
4816        Ok(None)
4817    }
4818}
4819
4820/// Coerce an [`IndexKey`] to the `u64` that v5.1 cold-tier
4821/// segments use as their on-disk PK. Returns `None` for keys that
4822/// aren't representable as `u64` — Text PKs need a hash mapping
4823/// the segment writer baked in (deferred to v5.2+), Bool PKs are
4824/// almost never wide enough to be sharded into a cold tier.
4825fn index_key_as_u64(key: &IndexKey) -> Option<u64> {
4826    match key {
4827        // Reinterpret the i64 bit pattern as u64. Cold-tier segments
4828        // are sorted by this u64 view, so the chosen interpretation
4829        // only has to match between insert (bake_segment / freezer)
4830        // and lookup — using cast_unsigned keeps both sides honest
4831        // and silences clippy::cast_sign_loss.
4832        IndexKey::Int(n) => Some(n.cast_unsigned()),
4833        // Text / Bool / Uuid PKs aren't representable as u64 and so
4834        // can't participate in the u64-sorted cold-tier segment
4835        // PK layout. Same deferral story as Text — lookup falls
4836        // through the in-memory btree.
4837        IndexKey::Text(_) | IndexKey::Bool(_) | IndexKey::Uuid(_) => None,
4838    }
4839}
4840
4841#[derive(Debug, Clone, PartialEq, Eq)]
4842#[non_exhaustive]
4843pub enum StorageError {
4844    DuplicateTable {
4845        name: String,
4846    },
4847    TableNotFound {
4848        name: String,
4849    },
4850    ArityMismatch {
4851        expected: usize,
4852        actual: usize,
4853    },
4854    TypeMismatch {
4855        column: String,
4856        expected: DataType,
4857        actual: DataType,
4858        position: usize,
4859    },
4860    NullInNotNull {
4861        column: String,
4862    },
4863    /// Index with this name already exists on the table.
4864    DuplicateIndex {
4865        name: String,
4866    },
4867    /// Column referenced by an index doesn't exist on the table.
4868    ColumnNotFound {
4869        column: String,
4870    },
4871    /// On-disk format failed to parse — corrupted file, wrong magic, truncated
4872    /// payload, or unknown tag bytes.
4873    Corrupt(String),
4874    /// v6.0.4 — ALTER INDEX targeted an index name that doesn't
4875    /// exist on any table in this catalog.
4876    IndexNotFound {
4877        name: String,
4878    },
4879    /// v6.0.4 — operation requested isn't supported on this index
4880    /// kind / column type (e.g. ALTER INDEX REBUILD on a `BTree`
4881    /// index, or REBUILD WITH (encoding=…) on a non-vector column).
4882    Unsupported(String),
4883}
4884
4885impl fmt::Display for StorageError {
4886    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
4887        match self {
4888            Self::DuplicateTable { name } => write!(f, "table already exists: {name}"),
4889            Self::TableNotFound { name } => write!(f, "table not found: {name}"),
4890            Self::ArityMismatch { expected, actual } => write!(
4891                f,
4892                "row arity mismatch: expected {expected} columns, got {actual}"
4893            ),
4894            Self::TypeMismatch {
4895                column,
4896                expected,
4897                actual,
4898                position,
4899            } => write!(
4900                f,
4901                "type mismatch in column {column:?} (position {position}): expected {expected}, got {actual}"
4902            ),
4903            Self::NullInNotNull { column } => {
4904                write!(f, "NULL value in NOT NULL column {column:?}")
4905            }
4906            Self::DuplicateIndex { name } => write!(f, "index already exists: {name}"),
4907            Self::ColumnNotFound { column } => write!(f, "column not found: {column}"),
4908            Self::Corrupt(detail) => write!(f, "corrupt on-disk format: {detail}"),
4909            Self::IndexNotFound { name } => write!(f, "index not found: {name}"),
4910            Self::Unsupported(detail) => write!(f, "unsupported: {detail}"),
4911        }
4912    }
4913}
4914
4915impl ColumnSchema {
4916    pub fn new(name: impl Into<String>, ty: DataType, nullable: bool) -> Self {
4917        Self {
4918            name: name.into(),
4919            ty,
4920            nullable,
4921            default: None,
4922            runtime_default: None,
4923            auto_increment: false,
4924            user_enum_type: None,
4925            user_domain_type: None,
4926            on_update_runtime: None,
4927            collation: Collation::Binary,
4928            is_unsigned: false,
4929            inline_enum_variants: None,
4930            inline_set_variants: None,
4931            generated_stored_expr: None,
4932        }
4933    }
4934
4935    /// Builder-style helper to attach a default value to an otherwise
4936    /// plain column schema. Used by the engine when CREATE TABLE
4937    /// specifies `column TYPE DEFAULT <expr>`.
4938    #[must_use]
4939    pub fn with_default(mut self, default: Value<'static>) -> Self {
4940        self.default = Some(default);
4941        self
4942    }
4943
4944    /// v7.9.21 — builder for runtime-evaluated defaults
4945    /// (`DEFAULT now()`, `DEFAULT CURRENT_TIMESTAMP`, …).
4946    /// `expr` is the Expr's `Display` form, re-parsed by the
4947    /// engine at each INSERT.
4948    #[must_use]
4949    pub fn with_runtime_default(mut self, expr: impl Into<String>) -> Self {
4950        self.runtime_default = Some(expr.into());
4951        self
4952    }
4953
4954    /// Builder-style helper to mark a column as `AUTO_INCREMENT`.
4955    #[must_use]
4956    pub const fn with_auto_increment(mut self) -> Self {
4957        self.auto_increment = true;
4958        self
4959    }
4960}
4961
4962impl TableSchema {
4963    pub fn new(name: impl Into<String>, columns: Vec<ColumnSchema>) -> Self {
4964        Self {
4965            name: name.into(),
4966            columns,
4967            hot_tier_bytes: None,
4968            foreign_keys: Vec::new(),
4969            uniqueness_constraints: Vec::new(),
4970            checks: Vec::new(),
4971            partition_role: None,
4972        }
4973    }
4974}
4975
4976// =========================================================================
4977// Persistent binary format for the catalog.
4978//
4979// Layout (little-endian throughout):
4980//
4981//   [magic "SPGDB001" 8 bytes][version u8]
4982//   [table_count u32]
4983//   for each table:
4984//       [name_len u16][name bytes]
4985//       [col_count u16]
4986//       for each col:
4987//           [name_len u16][name bytes]
4988//           [type_tag u8 + optional payload]
4989//               1=Int 2=BigInt 3=Float 4=Text 5=Bool
4990//               6=Vector(u32 dim)
4991//               7=SmallInt
4992//               8=Varchar(u32 max)
4993//               9=Char(u32 size)
4994//               10=Numeric(u8 precision, u8 scale)
4995//               11=Date
4996//               12=Timestamp
4997//           [nullable u8]   0/1
4998//           [default_tag u8] 0=none 1=value (followed by [value_tag u8] + bytes)
4999//       [row_count u32]
5000//       for each row, for each col, one [value_tag u8] + value bytes:
5001//           tag 0 (Null)     → no body
5002//           tag 1 (Int)      → i32 LE
5003//           tag 2 (BigInt)   → i64 LE
5004//           tag 3 (Float)    → f64 LE
5005//           tag 4 (Text)     → u16 LE len + UTF-8 bytes
5006//           tag 5 (Bool)     → u8 0/1
5007//           tag 6 (Vector)   → u32 LE dim + dim×f32 LE
5008//           tag 7 (SmallInt) → i16 LE
5009//           tag 8 (Numeric)  → i128 LE (16 bytes) + u8 scale
5010//           tag 9 (Date)     → i32 LE (days since Unix epoch)
5011//           tag 10 (Timestamp) → i64 LE (microseconds since Unix epoch)
5012//
5013// Bumped to version 3 when NUMERIC was added; to version 4 when
5014// AUTO_INCREMENT (per-column flag) + NSW index `kind` byte landed;
5015// to version 5 when DATE / TIMESTAMP were added; to version 6 when
5016// NSW graph topology started travelling on disk (v2.7); to version 7
5017// when the NSW topology became multi-layer HNSW (v2.13); to version 8
5018// when row encoding switched to schema-driven dense layout (v3.0.2 —
5019// per-row NULL bitmap + per-column fixed-width body, no per-cell type
5020// tag).
5021// =========================================================================
5022
5023const FILE_MAGIC: &[u8; 8] = b"SPGDB001";
5024/// Current catalog snapshot format version emitted by [`Catalog::serialize`].
5025///
5026/// v9 (v5.2) extends v8 by serialising `BTree` index entries directly — every
5027/// `(IndexKey, Vec<RowLocator>)` pair travels on disk with the v5.1
5028/// `RowLocator::write_le` tag-prefixed codec. v8 `BTree` indices stored no
5029/// entries at all (the map was rebuilt from `Table::rows` on load); v9
5030/// preserves on-disk Cold locators so freezer-produced cold-tier index
5031/// entries survive a catalog snapshot round-trip. v8 readers are accepted
5032/// by version dispatch in [`Catalog::deserialize`] — every entry decodes
5033/// as `RowLocator::Hot(_)` via `add_index` rebuild, identical to v5.1
5034/// behaviour.
5035/// v6.7.2 — bumped from 10 to 11 to append per-table
5036/// `hot_tier_bytes: Option<u64>` after the per-table indices
5037/// section. v10 catalogs (v6.7.1) load with `hot_tier_bytes =
5038/// None` for every table (the deserialiser short-circuits when
5039/// version < 11). v11 snapshots written by a pre-v6.7.2 binary
5040/// fail loudly at the version check, matching the v6.1.2 /
5041/// v6.1.4 / v6.2.0 / v6.7.1 envelope-bump upgrade fences.
5042///
5043/// v6.8.0 — bumped from 11 to 12: per-index
5044/// `included_columns: Vec<u16>` appended at the tail of each
5045/// index payload. v11 (= v6.7.2) catalogs load with
5046/// `included_columns = Vec::new()` for every index — same
5047/// "older readers, append-only extension" pattern as the v6.7.2
5048/// hot_tier_bytes byte.
5049/// v7.13.0 — bumped from 22 to 23. mailrs round-5 G3 / G10.
5050/// Per-table appendix gains two new sections:
5051///   * `checks: Vec<String>` — CHECK predicate sources (Display
5052///     form of the AST Expr); re-parsed on INSERT/UPDATE to
5053///     enforce against candidate rows. Same persistence pattern
5054///     as `Index::partial_predicate`.
5055///   * Per `UniquenessConstraint`: trailing `nulls_not_distinct:
5056///     u8` flag for PG 15+ `UNIQUE NULLS NOT DISTINCT (cols)`
5057///     semantics.
5058/// v22 catalogs deserialise with empty `checks` and every UC
5059/// at `nulls_not_distinct = false`.
5060/// v24 introduces:
5061///   * Index kind tag 4 = trigram-GIN (`gin_trgm_ops`-flavoured
5062///     `USING gin` over a TEXT/VARCHAR column). Payload shape is
5063///     identical to tag-3 GIN (String → Vec<RowLocator>); the
5064///     keys are PG-compatible 3-byte trigram shingles instead of
5065///     tsvector lexemes. v23 catalogs deserialise unchanged — no
5066///     v23 writer ever emitted tag 4.
5067/// v25 introduces:
5068///   * Per `TriggerDef`: trailing `enabled: u8` flag (mailrs
5069///     round-9 A.2.b — `ALTER TABLE … { ENABLE | DISABLE }
5070///     TRIGGER …`). v24 catalogs deserialise with every trigger
5071///     `enabled = true`, matching pre-v7.16.1 behaviour.
5072/// v26 introduces (v7.17.0 Phase 1.1):
5073///   * Trailing SEQUENCE catalog block after triggers. Encoded
5074///     as `u32 count` followed by per-sequence:
5075///     `name`, `data_type: u8` (0=SmallInt,1=Int,2=BigInt),
5076///     `start i64`, `increment i64`, `min_value i64`,
5077///     `max_value i64`, `cache i64`, `cycle u8`,
5078///     `owned_by_tag u8` (0=NONE, 1=Column → `table`,`column`),
5079///     `last_value i64`, `is_called u8`. v25-and-below catalogs
5080///     deserialise with an empty sequences map.
5081/// v27 introduces (v7.17.0 Phase 1.2):
5082///   * Trailing VIEW catalog block after sequences. Encoded as
5083///     `u32 count` followed by per-view:
5084///     `name`, `column_count u16`, then column names, then
5085///     `body` long-string. v26-and-below catalogs deserialise
5086///     with an empty views map.
5087/// v28 introduces (v7.17.0 Phase 1.3):
5088///   * Trailing MATERIALIZED VIEW source registry block after
5089///     views. Encoded as `u32 count` followed by per-entry:
5090///     `name`, `body` long-string. The materialised rows live
5091///     as a regular Table of the same name (already covered by
5092///     the pre-existing tables block). v27-and-below catalogs
5093///     deserialise with an empty map.
5094/// v29 introduces (v7.17.0 Phase 1.4):
5095///   * Per-table user_enum_type appendix (after the CHECK
5096///     appendix). Layout: `u16 count` followed by per-binding
5097///     `[u16 col_pos][str enum_name]`. Only columns whose
5098///     `user_enum_type` is Some land here; the catalog stays
5099///     compact for the common no-enum case.
5100///   * Trailing ENUM types catalog block after materialized
5101///     views. Encoded as `u32 count` followed by per-entry:
5102///     `name`, `u16 label_count`, then `label_count` short
5103///     strings. v28-and-below catalogs deserialise with an
5104///     empty enum_types map and every column's
5105///     `user_enum_type = None`.
5106/// v30 introduces (v7.17.0 Phase 1.5):
5107///   * Per-table user_domain_type appendix (after the
5108///     user_enum_type appendix). Same shape as the enum one.
5109///   * Trailing DOMAIN types catalog block after the enum
5110///     block. Encoded as `u32 count` followed by per-entry:
5111///     `name`, `data_type` byte, `nullable u8`,
5112///     `default_present u8` + optional default string,
5113///     `u16 check_count` then `check_count` Display-form
5114///     CHECK strings. v29-and-below catalogs deserialise with
5115///     an empty domain_types map and `user_domain_type = None`.
5116/// v31 introduces (v7.17.0 Phase 1.6):
5117///   * Trailing user-schemas block after the DOMAIN block.
5118///     Encoded as `u32 count` followed by `count` schema-name
5119///     short strings. Built-in schemas (`public`, `pg_catalog`,
5120///     `information_schema`) are NOT serialised — they're
5121///     hardcoded in `is_builtin_schema`. v30-and-below catalogs
5122///     deserialise with an empty user-schemas set.
5123/// v32 introduces (v7.17.0 Phase 2.1):
5124///   * Per-table on_update_runtime appendix (after the
5125///     user_domain_type appendix). Layout: `u16 count` followed
5126///     by per-binding `[u16 col_pos][str expr_src]`. Only
5127///     columns whose `on_update_runtime` is Some land here;
5128///     the catalog stays compact when no MySQL-shaped table
5129///     uses the attribute. v31-and-below catalogs deserialise
5130///     with every column's `on_update_runtime = None`.
5131/// v33 introduces (v7.17.0 Phase 2.2):
5132///   * Index kind tag 5 = fulltext-GIN (MySQL `FULLTEXT KEY`
5133///     surface over a TEXT / VARCHAR column). Payload shape is
5134///     identical to tag-3 / tag-4 GIN (`String → Vec<RowLocator>`);
5135///     the keys are lower-cased word lexemes (same rule as
5136///     `to_tsvector('simple', text)`). v32 catalogs deserialise
5137///     unchanged — no v32 writer ever emitted tag 5, and FULLTEXT
5138///     KEY was silently dropped pre-v7.17 so no rebuild shim is
5139///     needed for round-tripped catalogs.
5140/// v34 introduces (v7.17.0 Phase 2.5):
5141///   * Per-table collation appendix (after the on_update_runtime
5142///     appendix). Sparse layout: only columns whose `collation`
5143///     is non-Binary land here. `u16 count` then per-binding
5144///     `[u16 col_pos][u8 collation_tag]` where the tag matches
5145///     `Collation::TAG_*`. Snapshots written by v33-and-below
5146///     readers deserialise every column with `collation =
5147///     Binary`, preserving the prior byte-wise compare
5148///     semantics. Unknown tags read back as Binary too — keeps
5149///     a forward-compat path if a future v35 adds variants
5150///     and someone rolls back to a v34 reader.
5151/// v35 introduces (v7.17.0 Phase 4.4):
5152///   * Per-table is_unsigned appendix (after the collation
5153///     appendix). Sparse layout: only `is_unsigned = true`
5154///     columns land. `u16 count` then per-binding `[u16 col_pos]`.
5155///     v34-and-below catalogs deserialise every column as
5156///     `is_unsigned = false`, preserving the prior silent-
5157///     accept behaviour for negative inserts on UNSIGNED columns.
5158/// v46 introduces (v7.23, mailrs round-14):
5159///   * Escaped short-string codec — `write_str` lengths >= 0xFFFF
5160///     emit `[u16 0xFFFF][u32 real_len]` so TEXT cells (mail bodies,
5161///     document text) above 64 KiB encode instead of panicking.
5162///     One-way upgrade: v45-and-below readers reject v46 catalogs
5163///     loudly via the version gate; v46 readers decode v45 catalogs
5164///     with the plain-u16 rules (0xFFFF is a legitimate length
5165///     there).
5166/// v47 introduces (v7.27, mailrs round-21):
5167///   * Escaped lengths for the REMAINING u16-length cell payloads —
5168///     BYTEA cells, TEXT[] elements, tsvector lexemes and tsquery
5169///     terms — the same `[u16 0xFFFF][u32 real_len]` escape v46
5170///     gave short strings. Round-14 fixed TEXT and missed these;
5171///     round-21 fired the BYTEA twin during a production migration.
5172///     One-way upgrade, same posture as v46.
5173/// v48 introduces (v7.37.5 β-P2, sentori cutover window):
5174///   * `INTERVAL` becomes a real column type. Catalog tag 34 in
5175///     `write_data_type`; per-row body is a fixed 16 bytes
5176///     (i64 micros + i32 days + i32 months, LE, PG-byte-equal
5177///     field order). The runtime-only days collapse is gone —
5178///     `'1 day'` and `'24 hours'` are stored distinctly. One-way
5179///     upgrade: v47 catalogs without INTERVAL columns deserialise
5180///     identically; v47 readers fed a v48 catalog that contains
5181///     INTERVAL hit the explicit "unknown data type tag: 34"
5182///     fence in `read_data_type`.
5183/// v49 introduces (v7.37.6-B, sentori Epic 2 P0):
5184///   * Per-table partition role appendix(declarative
5185///     `PARTITION BY RANGE` parent / range child / DEFAULT
5186///     child)。Layout, written **after** the inline_set_variants
5187///     appendix and **before** the per-table block close:
5188///       `[u8 role_tag]`
5189///         0 = `None`(普通表,后向兼容默认)
5190///         1 = `Parent`:  `[u8 kind_tag (0=Range)]`
5191///                        `[u16 key_col_count]` `(× u16 col_pos)`
5192///                        `[u16 tmpl_count]` `(× str source)`
5193///         2 = `Range`:   `[str parent_name]` `[Bound]` `[Bound]`
5194///         3 = `Default`: `[str parent_name]`
5195///     `PartitionBound` codec:
5196///       `[u8 bound_tag]` 0=MinValue 1=MaxValue 2=TimestampTz(`[i64 LE micros]`)
5197///     v48-and-below readers stop after the inline_set_variants
5198///     block — they don't see this appendix and deserialise every
5199///     table with `partition_role = None`. v49 writers always emit
5200///     `[0]` for plain tables, so the encoding stays one-byte-cheap.
5201/// v50 introduces (v7.37.7, sentori Epic 3 P1):
5202///   * Per-table `generated_stored_expr` appendix(stored generated
5203///     columns — `GENERATED ALWAYS AS (<expr>) STORED`)。Layout,
5204///     written **after** the partition_role appendix and before
5205///     the per-table block close:
5206///       `[u16 binding_count]`
5207///       `binding_count × { [u16 col_pos][str expr_source] }`
5208///     Sparse — only generated columns land here, so plain-shape
5209///     catalogs stay byte-for-byte identical save for the new
5210///     u16 zero count. v49-and-below readers stop after the
5211///     partition_role appendix; v50 readers default every column
5212///     to `generated_stored_expr = None` when this block is absent.
5213/// v51 introduces (v7.37.8, sentori Epic 5 P2):
5214///   * Per-index tag byte 6 = `GinJsonb`(real posting-list GIN
5215///     over a JSONB column). Payload shape mirrors tag-3 / 4 / 5:
5216///     `[u32 posting_list_count]` then `(str token, u32 locator_count,
5217///     locators …)` per posting list. Same `write_str` /
5218///     `RowLocator::write_le` codec as the rest of the GIN family.
5219///     v50 catalogs never wrote tag 6(the same DDL loaded as a
5220///     BTree fallback); v51 readers see tag 6 explicitly and dispatch
5221///     into `IndexKind::GinJsonb`.
5222/// v52 introduces (v7.37.42-T2 ζ-B composite + domain metasystem):
5223///   * Trailing COMPOSITE-types catalog block after the
5224///     user-schemas block. Encoded as `u32 count` followed by
5225///     per-entry: `name`, `u16 field_count`, then `field_count`
5226///     `[str field_name][data_type]` pairs (`write_data_type` is
5227///     reused). v51-and-below catalogs deserialise with an empty
5228///     composite_types map; v52 readers tolerate v51 catalogs by
5229///     stopping at the schema block (no composite block present
5230///     ⇒ empty map). Composite types are referenced by columns
5231///     via `ColumnSchema.user_composite_type`, mirroring the
5232///     `user_enum_type` / `user_domain_type` pattern. The block
5233///     lands here (not as a per-table appendix) so dropping the
5234///     composite type registers globally and DROP TYPE can find it
5235///     without a table scan.
5236const FILE_VERSION: u8 = 52;
5237/// Oldest format version [`Catalog::deserialize`] still accepts. v8 is the
5238/// v3.0.2 dense-row layout; pre-v8 catalogs require an offline migration.
5239const MIN_SUPPORTED_FILE_VERSION: u8 = 8;
5240
5241// IndexKey wire format (v9):
5242//   tag 0 = Int  → [i64 LE]
5243//   tag 1 = Text → [u16 LE len + UTF-8 bytes] (via write_str / read_str)
5244//   tag 2 = Bool → [u8 0/1]
5245const INDEX_KEY_TAG_INT: u8 = 0;
5246const INDEX_KEY_TAG_TEXT: u8 = 1;
5247const INDEX_KEY_TAG_BOOL: u8 = 2;
5248/// v7.17.0 — `IndexKey::Uuid([u8; 16])`. Body = raw 16 bytes
5249/// (RFC 4122 byte order). Persisted only in FILE_VERSION 36+
5250/// catalogs.
5251const INDEX_KEY_TAG_UUID: u8 = 3;
5252
5253impl Catalog {
5254    /// Serialize the whole catalog (schema + every row) into a self-contained
5255    /// byte buffer. Format is documented above the impl block.
5256    pub fn serialize(&self) -> Vec<u8> {
5257        let mut out = Vec::with_capacity(64);
5258        out.extend_from_slice(FILE_MAGIC);
5259        out.push(FILE_VERSION);
5260        write_u32(
5261            &mut out,
5262            u32::try_from(self.tables.len()).expect("≤ 4G tables"),
5263        );
5264        for t in &self.tables {
5265            write_str(&mut out, &t.schema.name);
5266            write_u16(
5267                &mut out,
5268                u16::try_from(t.schema.columns.len()).expect("≤ 65k columns/table"),
5269            );
5270            for c in &t.schema.columns {
5271                write_str(&mut out, &c.name);
5272                write_data_type(&mut out, c.ty);
5273                out.push(u8::from(c.nullable));
5274                match &c.default {
5275                    None => out.push(0),
5276                    Some(v) => {
5277                        out.push(1);
5278                        write_value(&mut out, v);
5279                    }
5280                }
5281                out.push(u8::from(c.auto_increment));
5282            }
5283            write_u32(
5284                &mut out,
5285                u32::try_from(t.rows.len()).expect("≤ 4G rows/table"),
5286            );
5287            // v3.0.2 dense row encoding (FILE_VERSION 8): per-row NULL
5288            // bitmap, then tightly-packed bodies. Identical wire format
5289            // as before — extracted into `encode_row_body_dense` so cold-
5290            // tier segments (v5.1+) can share the encoding.
5291            for row in &t.rows {
5292                out.extend_from_slice(&encode_row_body_dense(row, &t.schema));
5293            }
5294            // Index definitions. Per-index payload:
5295            //   [name][col_pos u16][kind u8]
5296            //     kind 0 = B-tree           (no params — rebuilt on load)
5297            //     kind 1 = NSW graph        (u16 M + serialized graph)
5298            // For NSW the graph topology travels on disk so startup
5299            // doesn't re-run the O(n²M) rebuild — see v2.7 notes.
5300            write_u16(
5301                &mut out,
5302                u16::try_from(t.indices.len()).expect("≤ 65k indices/table"),
5303            );
5304            for idx in &t.indices {
5305                write_str(&mut out, &idx.name);
5306                write_u16(
5307                    &mut out,
5308                    u16::try_from(idx.column_position).expect("≤ 65k columns/table"),
5309                );
5310                match &idx.kind {
5311                    IndexKind::BTree(map) => {
5312                        out.push(0);
5313                        // v9: serialise the full PB map. Each entry's
5314                        // RowLocator list travels with the tag-prefixed
5315                        // codec from `row_locator::write_le`, so freezer-
5316                        // produced Cold locators survive a snapshot
5317                        // round-trip. v8 BTree wrote nothing here and
5318                        // rebuilt from rows — v9 readers tolerate v8 by
5319                        // version dispatch in `Catalog::deserialize`.
5320                        write_u32(
5321                            &mut out,
5322                            u32::try_from(map.len()).expect("≤ 4G index entries/index"),
5323                        );
5324                        for (key, locators) in map {
5325                            write_index_key(&mut out, key);
5326                            write_u32(
5327                                &mut out,
5328                                u32::try_from(locators.len()).expect("≤ 4G locators/key"),
5329                            );
5330                            for loc in locators {
5331                                loc.write_le(&mut out);
5332                            }
5333                        }
5334                    }
5335                    IndexKind::Nsw(g) => {
5336                        out.push(1);
5337                        write_u16(&mut out, u16::try_from(g.m).expect("≤ 65k NSW neighbours"));
5338                        write_nsw_graph(&mut out, g);
5339                    }
5340                    IndexKind::Brin { column_type } => {
5341                        // v6.7.1 — tag byte 2 = BRIN. Payload is the
5342                        // column type code (1 byte mapping to the
5343                        // shared DataType numeric encoding); no
5344                        // further data — BRIN summaries live in
5345                        // cold segments, not the catalog.
5346                        out.push(2);
5347                        write_data_type(&mut out, *column_type);
5348                    }
5349                    IndexKind::Gin(map) => {
5350                        // v7.12.3 — tag byte 3 = GIN. Payload mirrors
5351                        // the BTree encoding but with String (lexeme
5352                        // word) keys instead of IndexKey. Tag-prefixed
5353                        // RowLocator codec so freezer-produced Cold
5354                        // locators survive snapshot round-trip.
5355                        // FILE_VERSION 21+; v20 catalogs never wrote a
5356                        // GIN index (the AM degraded to BTree fallback
5357                        // pre-v7.12.3), so no migration shim is needed.
5358                        out.push(3);
5359                        write_u32(
5360                            &mut out,
5361                            u32::try_from(map.len()).expect("≤ 4G GIN posting lists"),
5362                        );
5363                        for (word, locators) in map {
5364                            write_str(&mut out, word);
5365                            write_u32(
5366                                &mut out,
5367                                u32::try_from(locators.len()).expect("≤ 4G locators/posting list"),
5368                            );
5369                            for loc in locators {
5370                                loc.write_le(&mut out);
5371                            }
5372                        }
5373                    }
5374                    IndexKind::GinTrgm(map) => {
5375                        // v7.15.0 — tag byte 4 = GinTrgm
5376                        // (`gin_trgm_ops` GIN over a TEXT column).
5377                        // Payload shape is identical to tag-3 GIN —
5378                        // `String → Vec<RowLocator>` posting lists.
5379                        // The String keys are 3-byte trigrams instead
5380                        // of tsvector lexemes; the deserializer
5381                        // dispatches on the tag, not the key shape.
5382                        // FILE_VERSION 24+; v23 catalogs never wrote
5383                        // a trigram-GIN.
5384                        out.push(4);
5385                        write_u32(
5386                            &mut out,
5387                            u32::try_from(map.len()).expect("≤ 4G trigram-GIN posting lists"),
5388                        );
5389                        for (tri, locators) in map {
5390                            write_str(&mut out, tri);
5391                            write_u32(
5392                                &mut out,
5393                                u32::try_from(locators.len()).expect("≤ 4G locators/posting list"),
5394                            );
5395                            for loc in locators {
5396                                loc.write_le(&mut out);
5397                            }
5398                        }
5399                    }
5400                    IndexKind::GinFulltext(map) => {
5401                        // v7.17.0 Phase 2.2 — tag byte 5 =
5402                        // GinFulltext (MySQL `FULLTEXT KEY` GIN
5403                        // over a TEXT/VARCHAR column). Payload
5404                        // shape mirrors tag-3 / tag-4 GIN —
5405                        // `String → Vec<RowLocator>` posting
5406                        // lists keyed by lower-cased word
5407                        // lexemes. FILE_VERSION 33+; v32 catalogs
5408                        // never wrote a fulltext-GIN (FULLTEXT
5409                        // KEY was silently dropped pre-v7.17).
5410                        out.push(5);
5411                        write_u32(
5412                            &mut out,
5413                            u32::try_from(map.len()).expect("≤ 4G fulltext-GIN posting lists"),
5414                        );
5415                        for (lex, locators) in map {
5416                            write_str(&mut out, lex);
5417                            write_u32(
5418                                &mut out,
5419                                u32::try_from(locators.len()).expect("≤ 4G locators/posting list"),
5420                            );
5421                            for loc in locators {
5422                                loc.write_le(&mut out);
5423                            }
5424                        }
5425                    }
5426                    IndexKind::GinJsonb(map) => {
5427                        // v7.37.8 — tag byte 6 = GinJsonb
5428                        // (real posting-list GIN over a JSONB
5429                        // column; sentori Epic 5 P2). Payload
5430                        // shape mirrors tag-3 / 4 / 5 — keys are
5431                        // the canonical `(path, leaf)` tokens
5432                        // from `jsonb_gin::extract_tokens`.
5433                        // FILE_VERSION 51+; v50 catalogs never
5434                        // wrote a JSONB-GIN (the same DDL loaded
5435                        // as a BTree fallback).
5436                        out.push(6);
5437                        write_u32(
5438                            &mut out,
5439                            u32::try_from(map.len()).expect("≤ 4G JSONB-GIN posting lists"),
5440                        );
5441                        for (token, locators) in map {
5442                            write_str(&mut out, token);
5443                            write_u32(
5444                                &mut out,
5445                                u32::try_from(locators.len()).expect("≤ 4G locators/posting list"),
5446                            );
5447                            for loc in locators {
5448                                loc.write_le(&mut out);
5449                            }
5450                        }
5451                    }
5452                }
5453                // v6.8.0 — included_columns appendix per index.
5454                // Layout: [u16 num_included][num × u16 column_position].
5455                // v11 readers stop before this u16 (deserialise loop
5456                // gated on version >= 12); v12+ readers always
5457                // consume it. Empty Vec serialises as a bare 0u16.
5458                write_u16(
5459                    &mut out,
5460                    u16::try_from(idx.included_columns.len()).expect("≤ 65k INCLUDE columns/index"),
5461                );
5462                for col_pos in &idx.included_columns {
5463                    write_u16(
5464                        &mut out,
5465                        u16::try_from(*col_pos).expect("≤ 65k columns/table"),
5466                    );
5467                }
5468                // v6.8.1 — partial_predicate appendix per index.
5469                // Layout: [u8 has_pred][u16 LE len][bytes (if has_pred)].
5470                // Same v12 gate as included_columns.
5471                match &idx.partial_predicate {
5472                    None => out.push(0),
5473                    Some(pred) => {
5474                        out.push(1);
5475                        write_str(&mut out, pred);
5476                    }
5477                }
5478                // v6.8.2 — expression appendix. Same shape as
5479                // partial_predicate.
5480                match &idx.expression {
5481                    None => out.push(0),
5482                    Some(expr) => {
5483                        out.push(1);
5484                        write_str(&mut out, expr);
5485                    }
5486                }
5487                // v7.9.29 — is_unique appendix (FILE_VERSION 16+).
5488                // Single byte 0/1. v15-and-below readers stop before
5489                // this byte; v16 readers always consume it. mailrs K1.
5490                out.push(u8::from(idx.is_unique));
5491                // v7.9.29 — extra_column_positions appendix.
5492                // Layout: [u16 count][count × u16 column_position].
5493                write_u16(
5494                    &mut out,
5495                    u16::try_from(idx.extra_column_positions.len())
5496                        .expect("≤ 65k extra cols / index"),
5497                );
5498                for cp in &idx.extra_column_positions {
5499                    write_u16(&mut out, u16::try_from(*cp).expect("≤ 65k columns/table"));
5500                }
5501            }
5502            // v6.7.2 — per-table hot_tier_bytes Option<u64>.
5503            // Layout: [u8 has_value][u64 LE value (if has_value)].
5504            // v10 readers stop before this byte (deserialise loop
5505            // gated on version >= 11); v11+ readers always
5506            // consume it.
5507            match t.schema.hot_tier_bytes {
5508                None => out.push(0),
5509                Some(n) => {
5510                    out.push(1);
5511                    out.extend_from_slice(&n.to_le_bytes());
5512                }
5513            }
5514            // v7.6.1 — FOREIGN KEY appendix (catalog FILE_VERSION 13+).
5515            // Layout: [u16 LE fk_count]
5516            //   per fk:
5517            //     [u8 has_name] [str name (if has_name)]
5518            //     [u16 LE local_arity] [u16 LE local_pos]*arity
5519            //     [str parent_table]
5520            //     [u16 LE parent_arity] [u16 LE parent_pos]*arity
5521            //     [u8 on_delete_tag] [u8 on_update_tag]
5522            // Older catalogs (v12 and below) skip this block entirely;
5523            // their reader stops before this byte.
5524            write_u16(
5525                &mut out,
5526                u16::try_from(t.schema.foreign_keys.len()).expect("≤ 65k FKs/table"),
5527            );
5528            for fk in &t.schema.foreign_keys {
5529                match &fk.name {
5530                    None => out.push(0),
5531                    Some(n) => {
5532                        out.push(1);
5533                        write_str(&mut out, n);
5534                    }
5535                }
5536                write_u16(
5537                    &mut out,
5538                    u16::try_from(fk.local_columns.len()).expect("≤ 65k FK columns"),
5539                );
5540                for &p in &fk.local_columns {
5541                    write_u16(&mut out, u16::try_from(p).expect("≤ 65k columns/table"));
5542                }
5543                write_str(&mut out, &fk.parent_table);
5544                write_u16(
5545                    &mut out,
5546                    u16::try_from(fk.parent_columns.len()).expect("≤ 65k FK parent columns"),
5547                );
5548                for &p in &fk.parent_columns {
5549                    write_u16(&mut out, u16::try_from(p).expect("≤ 65k columns/table"));
5550                }
5551                out.push(fk.on_delete.tag());
5552                out.push(fk.on_update.tag());
5553            }
5554            // v7.9.19 — UniquenessConstraint appendix (catalog
5555            // FILE_VERSION 15+). Layout per table after the FK
5556            // block:
5557            //   [u16 count]
5558            //     per constraint:
5559            //       [u8 is_primary_key]
5560            //       [u16 arity][u16 col_pos]*arity
5561            // Older catalogs (v14 and below) skip this block.
5562            write_u16(
5563                &mut out,
5564                u16::try_from(t.schema.uniqueness_constraints.len())
5565                    .expect("≤ 65k uniqueness constraints/table"),
5566            );
5567            for uc in &t.schema.uniqueness_constraints {
5568                out.push(u8::from(uc.is_primary_key));
5569                write_u16(
5570                    &mut out,
5571                    u16::try_from(uc.columns.len()).expect("≤ 65k cols in uniqueness constraint"),
5572                );
5573                for &p in &uc.columns {
5574                    write_u16(&mut out, u16::try_from(p).expect("≤ 65k columns/table"));
5575                }
5576                // v7.13.0 — `nulls_not_distinct` flag
5577                // (FILE_VERSION 23+). Always written by writers at
5578                // version 23+; deserialise gates on `version >= 23`
5579                // so v22-and-below catalogs round-trip cleanly.
5580                out.push(u8::from(uc.nulls_not_distinct));
5581            }
5582            // v7.9.21 — runtime_default appendix per table.
5583            // Layout: [u16 count] then for each:
5584            //   [u16 col_pos][str expr]
5585            // Only columns whose runtime_default is Some land here;
5586            // catalog stays compact for the common literal-default
5587            // case.
5588            let mut rt_defaults: Vec<(usize, &str)> = Vec::new();
5589            for (i, c) in t.schema.columns.iter().enumerate() {
5590                if let Some(e) = &c.runtime_default {
5591                    rt_defaults.push((i, e.as_str()));
5592                }
5593            }
5594            write_u16(
5595                &mut out,
5596                u16::try_from(rt_defaults.len()).expect("≤ 65k runtime defaults/table"),
5597            );
5598            for (pos, expr) in rt_defaults {
5599                write_u16(&mut out, u16::try_from(pos).expect("≤ 65k columns/table"));
5600                write_str(&mut out, expr);
5601            }
5602            // v7.13.0 — CHECK constraint appendix per table.
5603            // Layout: [u16 count] then `count` Display-form
5604            // expression strings. Re-parsed on every INSERT/UPDATE
5605            // by the engine. FILE_VERSION 23+ only; v22 readers
5606            // never reach this block because the writer also moves
5607            // to v23 in lock-step.
5608            write_u16(
5609                &mut out,
5610                u16::try_from(t.schema.checks.len()).expect("≤ 65k CHECK constraints/table"),
5611            );
5612            for c in &t.schema.checks {
5613                write_str(&mut out, c.as_str());
5614            }
5615            // v7.17.0 Phase 1.4 — per-table user_enum_type
5616            // appendix. Layout: [u16 count] then
5617            // [u16 col_pos][str enum_name] per binding. Only
5618            // columns whose user_enum_type is Some land here.
5619            let mut enum_bindings: Vec<(usize, &str)> = Vec::new();
5620            for (i, c) in t.schema.columns.iter().enumerate() {
5621                if let Some(e) = &c.user_enum_type {
5622                    enum_bindings.push((i, e.as_str()));
5623                }
5624            }
5625            write_u16(
5626                &mut out,
5627                u16::try_from(enum_bindings.len()).expect("≤ 65k enum-typed columns/table"),
5628            );
5629            for (pos, ename) in enum_bindings {
5630                write_u16(&mut out, u16::try_from(pos).expect("≤ 65k columns/table"));
5631                write_str(&mut out, ename);
5632            }
5633            // v7.17.0 Phase 1.5 — per-table user_domain_type
5634            // appendix. Same layout as the enum one. v29-and-
5635            // below readers stop after the enum appendix.
5636            let mut domain_bindings: Vec<(usize, &str)> = Vec::new();
5637            for (i, c) in t.schema.columns.iter().enumerate() {
5638                if let Some(d) = &c.user_domain_type {
5639                    domain_bindings.push((i, d.as_str()));
5640                }
5641            }
5642            write_u16(
5643                &mut out,
5644                u16::try_from(domain_bindings.len()).expect("≤ 65k domain-typed columns/table"),
5645            );
5646            for (pos, dname) in domain_bindings {
5647                write_u16(&mut out, u16::try_from(pos).expect("≤ 65k columns/table"));
5648                write_str(&mut out, dname);
5649            }
5650            // v7.17.0 Phase 2.1 — per-table on_update_runtime
5651            // appendix. Sparse: only ON UPDATE-bound columns.
5652            let mut on_update_bindings: Vec<(usize, &str)> = Vec::new();
5653            for (i, c) in t.schema.columns.iter().enumerate() {
5654                if let Some(e) = &c.on_update_runtime {
5655                    on_update_bindings.push((i, e.as_str()));
5656                }
5657            }
5658            write_u16(
5659                &mut out,
5660                u16::try_from(on_update_bindings.len()).expect("≤ 65k ON UPDATE columns/table"),
5661            );
5662            for (pos, expr_src) in on_update_bindings {
5663                write_u16(&mut out, u16::try_from(pos).expect("≤ 65k columns/table"));
5664                write_str(&mut out, expr_src);
5665            }
5666            // v7.17.0 Phase 2.5 — per-table collation appendix.
5667            // Sparse: only non-Binary columns land. Layout:
5668            // `[u16 count][u16 col_pos][u8 tag] × count`.
5669            let mut coll_bindings: Vec<(usize, u8)> = Vec::new();
5670            for (i, c) in t.schema.columns.iter().enumerate() {
5671                let tag = match c.collation {
5672                    Collation::Binary => continue,
5673                    Collation::CaseInsensitive => Collation::TAG_CASE_INSENSITIVE,
5674                };
5675                coll_bindings.push((i, tag));
5676            }
5677            write_u16(
5678                &mut out,
5679                u16::try_from(coll_bindings.len()).expect("≤ 65k collation bindings/table"),
5680            );
5681            for (pos, tag) in coll_bindings {
5682                write_u16(&mut out, u16::try_from(pos).expect("≤ 65k columns/table"));
5683                out.push(tag);
5684            }
5685            // v7.17.0 Phase 4.4 — per-table is_unsigned appendix.
5686            // Sparse: only UNSIGNED columns land. Layout:
5687            // `[u16 count][u16 col_pos] × count`.
5688            let mut unsigned_bindings: Vec<usize> = Vec::new();
5689            for (i, c) in t.schema.columns.iter().enumerate() {
5690                if c.is_unsigned {
5691                    unsigned_bindings.push(i);
5692                }
5693            }
5694            write_u16(
5695                &mut out,
5696                u16::try_from(unsigned_bindings.len()).expect("≤ 65k UNSIGNED columns/table"),
5697            );
5698            for pos in unsigned_bindings {
5699                write_u16(&mut out, u16::try_from(pos).expect("≤ 65k columns/table"));
5700            }
5701            // v7.17.0 Phase 3.P0-36 — per-table inline_enum_variants
5702            // appendix. Sparse: only ENUM columns land. Layout:
5703            // `[u16 count] then per binding [u16 col_pos]
5704            // [u16 variant_count] then variant strings`.
5705            // FILE_VERSION 41+; v40 readers never reach this block.
5706            let mut enum_inline_bindings: Vec<(usize, &[String])> = Vec::new();
5707            for (i, c) in t.schema.columns.iter().enumerate() {
5708                if let Some(vs) = &c.inline_enum_variants {
5709                    enum_inline_bindings.push((i, vs.as_slice()));
5710                }
5711            }
5712            write_u16(
5713                &mut out,
5714                u16::try_from(enum_inline_bindings.len()).expect("≤ 65k inline-ENUM columns/table"),
5715            );
5716            for (pos, variants) in enum_inline_bindings {
5717                write_u16(&mut out, u16::try_from(pos).expect("≤ 65k columns/table"));
5718                write_u16(
5719                    &mut out,
5720                    u16::try_from(variants.len()).expect("≤ 65k variants/ENUM"),
5721                );
5722                for v in variants {
5723                    write_str(&mut out, v.as_str());
5724                }
5725            }
5726            // v7.17.0 Phase 3.P0-37 — per-table inline_set_variants
5727            // appendix. Same layout as the inline ENUM block.
5728            // FILE_VERSION 42+; v41 readers never reach this block.
5729            let mut set_inline_bindings: Vec<(usize, &[String])> = Vec::new();
5730            for (i, c) in t.schema.columns.iter().enumerate() {
5731                if let Some(vs) = &c.inline_set_variants {
5732                    set_inline_bindings.push((i, vs.as_slice()));
5733                }
5734            }
5735            write_u16(
5736                &mut out,
5737                u16::try_from(set_inline_bindings.len()).expect("≤ 65k inline-SET columns/table"),
5738            );
5739            for (pos, variants) in set_inline_bindings {
5740                write_u16(&mut out, u16::try_from(pos).expect("≤ 65k columns/table"));
5741                write_u16(
5742                    &mut out,
5743                    u16::try_from(variants.len()).expect("≤ 65k variants/SET"),
5744                );
5745                for v in variants {
5746                    write_str(&mut out, v.as_str());
5747                }
5748            }
5749            // v7.37.6-B — partition role appendix(FILE_VERSION 49+)。
5750            // Layout 详见 FILE_VERSION 49 docstring。普通表 = 单字节 0。
5751            write_partition_role(&mut out, t.schema.partition_role.as_ref());
5752            // v7.37.7 — per-table generated_stored_expr appendix
5753            // (FILE_VERSION 50+). Sparse: only columns whose
5754            // generated_stored_expr is Some land here.
5755            let mut gen_bindings: Vec<(usize, &str)> = Vec::new();
5756            for (i, c) in t.schema.columns.iter().enumerate() {
5757                if let Some(src) = &c.generated_stored_expr {
5758                    gen_bindings.push((i, src.as_str()));
5759                }
5760            }
5761            write_u16(
5762                &mut out,
5763                u16::try_from(gen_bindings.len()).expect("≤ 65k GENERATED STORED columns/table"),
5764            );
5765            for (pos, src) in gen_bindings {
5766                write_u16(&mut out, u16::try_from(pos).expect("≤ 65k columns/table"));
5767                write_str(&mut out, src);
5768            }
5769        }
5770        // v7.12.4 — catalog-wide appendix: user-defined functions
5771        // then triggers. FILE_VERSION 22+ only. v21 and earlier
5772        // readers stop after the last table; v22 readers always
5773        // consume two `u32` counts (possibly zero).
5774        //
5775        // Function entry layout:
5776        //   [str name] [str args_repr] [str returns]
5777        //   [str language] [str body]
5778        // Trigger entry layout:
5779        //   [str name] [str table] [str timing]
5780        //   [u16 event_count] (event_count × str)
5781        //   [str for_each] [str function]
5782        write_u32(
5783            &mut out,
5784            u32::try_from(self.functions.len()).expect("≤ 4G functions"),
5785        );
5786        for fd in self.functions.values() {
5787            write_str(&mut out, &fd.name);
5788            write_str(&mut out, &fd.args_repr);
5789            write_str(&mut out, &fd.returns);
5790            write_str(&mut out, &fd.language);
5791            write_str_long(&mut out, &fd.body);
5792        }
5793        write_u32(
5794            &mut out,
5795            u32::try_from(self.triggers.len()).expect("≤ 4G triggers"),
5796        );
5797        for td in &self.triggers {
5798            write_str(&mut out, &td.name);
5799            write_str(&mut out, &td.table);
5800            write_str(&mut out, &td.timing);
5801            write_u16(
5802                &mut out,
5803                u16::try_from(td.events.len()).expect("≤ 65k events / trigger"),
5804            );
5805            for ev in &td.events {
5806                write_str(&mut out, ev);
5807            }
5808            write_str(&mut out, &td.for_each);
5809            write_str(&mut out, &td.function);
5810            // v7.13.0 — `UPDATE OF cols` filter
5811            // (FILE_VERSION 23+). v22 readers omit; v23 writers
5812            // always emit (possibly zero).
5813            write_u16(
5814                &mut out,
5815                u16::try_from(td.update_columns.len()).expect("≤ 65k cols / trigger"),
5816            );
5817            for c in &td.update_columns {
5818                write_str(&mut out, c);
5819            }
5820            // v7.16.1 — TriggerDef.enabled (FILE_VERSION 25+).
5821            out.push(u8::from(td.enabled));
5822        }
5823        // v7.17.0 Phase 1.1 — SEQUENCE catalog block (FILE_VERSION 26+).
5824        write_u32(
5825            &mut out,
5826            u32::try_from(self.sequences.len()).expect("≤ 4G sequences"),
5827        );
5828        for seq in self.sequences.values() {
5829            write_str(&mut out, &seq.name);
5830            out.push(match seq.data_type {
5831                SequenceDataType::SmallInt => 0,
5832                SequenceDataType::Int => 1,
5833                SequenceDataType::BigInt => 2,
5834            });
5835            out.extend_from_slice(&seq.start.to_le_bytes());
5836            out.extend_from_slice(&seq.increment.to_le_bytes());
5837            out.extend_from_slice(&seq.min_value.to_le_bytes());
5838            out.extend_from_slice(&seq.max_value.to_le_bytes());
5839            out.extend_from_slice(&seq.cache.to_le_bytes());
5840            out.push(u8::from(seq.cycle));
5841            match &seq.owned_by {
5842                None => out.push(0),
5843                Some((table, column)) => {
5844                    out.push(1);
5845                    write_str(&mut out, table);
5846                    write_str(&mut out, column);
5847                }
5848            }
5849            out.extend_from_slice(&seq.last_value.to_le_bytes());
5850            out.push(u8::from(seq.is_called));
5851        }
5852        // v7.17.0 Phase 1.2 — VIEW catalog block (FILE_VERSION 27+).
5853        write_u32(
5854            &mut out,
5855            u32::try_from(self.views.len()).expect("≤ 4G views"),
5856        );
5857        for view in self.views.values() {
5858            write_str(&mut out, &view.name);
5859            write_u16(
5860                &mut out,
5861                u16::try_from(view.columns.len()).expect("≤ 65k cols / view"),
5862            );
5863            for c in &view.columns {
5864                write_str(&mut out, c);
5865            }
5866            write_str_long(&mut out, &view.body);
5867        }
5868        // v7.17.0 Phase 1.3 — MATERIALIZED VIEW source registry
5869        // (FILE_VERSION 28+). The backing rows live as a regular
5870        // table of the same name already in the tables block.
5871        write_u32(
5872            &mut out,
5873            u32::try_from(self.materialized_views.len()).expect("≤ 4G materialized views"),
5874        );
5875        for (name, body) in &self.materialized_views {
5876            write_str(&mut out, name);
5877            write_str_long(&mut out, body);
5878        }
5879        // v7.17.0 Phase 1.4 — ENUM types catalog block
5880        // (FILE_VERSION 29+).
5881        write_u32(
5882            &mut out,
5883            u32::try_from(self.enum_types.len()).expect("≤ 4G enum types"),
5884        );
5885        for e in self.enum_types.values() {
5886            write_str(&mut out, &e.name);
5887            write_u16(
5888                &mut out,
5889                u16::try_from(e.labels.len()).expect("≤ 65k labels / enum"),
5890            );
5891            for l in &e.labels {
5892                write_str(&mut out, l);
5893            }
5894        }
5895        // v7.17.0 Phase 1.5 — DOMAIN types catalog block
5896        // (FILE_VERSION 30+).
5897        write_u32(
5898            &mut out,
5899            u32::try_from(self.domain_types.len()).expect("≤ 4G domain types"),
5900        );
5901        for d in self.domain_types.values() {
5902            write_str(&mut out, &d.name);
5903            write_data_type(&mut out, d.base_type);
5904            out.push(u8::from(d.nullable));
5905            match &d.default {
5906                None => out.push(0),
5907                Some(s) => {
5908                    out.push(1);
5909                    write_str(&mut out, s);
5910                }
5911            }
5912            write_u16(
5913                &mut out,
5914                u16::try_from(d.checks.len()).expect("≤ 65k CHECKs / domain"),
5915            );
5916            for c in &d.checks {
5917                write_str(&mut out, c);
5918            }
5919        }
5920        // v7.17.0 Phase 1.6 — user-schemas registry
5921        // (FILE_VERSION 31+). Built-ins are hardcoded in
5922        // `is_builtin_schema` and not persisted.
5923        write_u32(
5924            &mut out,
5925            u32::try_from(self.schemas.len()).expect("≤ 4G schemas"),
5926        );
5927        for name in &self.schemas {
5928            write_str(&mut out, name);
5929        }
5930        // v7.37.42-T2 ζ-B — COMPOSITE types catalog block
5931        // (FILE_VERSION 52+). Each entry: name, u16 field_count,
5932        // then field_count `[str field_name][data_type]` pairs.
5933        write_u32(
5934            &mut out,
5935            u32::try_from(self.composite_types.len()).expect("≤ 4G composite types"),
5936        );
5937        for c in self.composite_types.values() {
5938            write_str(&mut out, &c.name);
5939            write_u16(
5940                &mut out,
5941                u16::try_from(c.fields.len()).expect("≤ 65k fields / composite"),
5942            );
5943            for (fname, fty) in &c.fields {
5944                write_str(&mut out, fname);
5945                write_data_type(&mut out, *fty);
5946            }
5947        }
5948        out
5949    }
5950
5951    /// Deserialize a previously-serialized catalog. Rejects bad magic, version
5952    /// mismatch, unknown tags, truncation, and trailing bytes.
5953    pub fn deserialize(buf: &[u8]) -> Result<Self, StorageError> {
5954        let mut cur = Cursor::new(buf);
5955        let magic = cur.take(8)?;
5956        if magic != FILE_MAGIC {
5957            return Err(StorageError::Corrupt(format!(
5958                "bad magic: expected SPGDB001, got {magic:?}"
5959            )));
5960        }
5961        let version = cur.read_u8()?;
5962        if !(MIN_SUPPORTED_FILE_VERSION..=FILE_VERSION).contains(&version) {
5963            return Err(StorageError::Corrupt(format!(
5964                "unsupported file version: {version} (supported: {MIN_SUPPORTED_FILE_VERSION}..={FILE_VERSION})"
5965            )));
5966        }
5967        // v7.23/v7.27 — escape decoding is version-gated (see
5968        // STR_LEN_ESCAPE / Cursor::codec_version).
5969        cur.codec_version = version;
5970        let table_count = cur.read_u32()? as usize;
5971        let mut cat = Self::new();
5972        for _ in 0..table_count {
5973            deserialize_table(&mut cur, &mut cat, version)?;
5974        }
5975        // v7.12.4 — catalog-wide function + trigger appendix.
5976        // FILE_VERSION 22+ only; v21 and earlier catalogs stop
5977        // after the last table.
5978        if version >= 22 {
5979            let fn_count = cur.read_u32()? as usize;
5980            for _ in 0..fn_count {
5981                let name = cur.read_str()?;
5982                let args_repr = cur.read_str()?;
5983                let returns = cur.read_str()?;
5984                let language = cur.read_str()?;
5985                let body = cur.read_str_long()?;
5986                cat.functions.insert(
5987                    name.clone(),
5988                    FunctionDef {
5989                        name,
5990                        args_repr,
5991                        returns,
5992                        language,
5993                        body,
5994                    },
5995                );
5996            }
5997            let trg_count = cur.read_u32()? as usize;
5998            for _ in 0..trg_count {
5999                let name = cur.read_str()?;
6000                let table = cur.read_str()?;
6001                let timing = cur.read_str()?;
6002                let ev_count = cur.read_u16()? as usize;
6003                let mut events = Vec::with_capacity(ev_count);
6004                for _ in 0..ev_count {
6005                    events.push(cur.read_str()?);
6006                }
6007                let for_each = cur.read_str()?;
6008                let function = cur.read_str()?;
6009                // v7.13.0 — trailing `UPDATE OF cols` filter
6010                // (FILE_VERSION 23+ only; v22 catalogs omit and
6011                // deserialise with an empty vec).
6012                let update_columns = if version >= 23 {
6013                    let n = cur.read_u16()? as usize;
6014                    let mut cols = Vec::with_capacity(n);
6015                    for _ in 0..n {
6016                        cols.push(cur.read_str()?);
6017                    }
6018                    cols
6019                } else {
6020                    Vec::new()
6021                };
6022                // v7.16.1 — TriggerDef.enabled (FILE_VERSION 25+).
6023                // v24-and-below catalogs deserialise with `true`
6024                // — pre-v7.16.1 every trigger always fired.
6025                let enabled = if version >= 25 {
6026                    cur.read_u8()? != 0
6027                } else {
6028                    true
6029                };
6030                cat.triggers.push(TriggerDef {
6031                    name,
6032                    table,
6033                    timing,
6034                    events,
6035                    for_each,
6036                    function,
6037                    update_columns,
6038                    enabled,
6039                });
6040            }
6041        }
6042        // v7.17.0 Phase 1.1 — SEQUENCE block (FILE_VERSION 26+).
6043        // v25-and-below catalogs omit; we leave the map empty.
6044        if version >= 26 {
6045            let seq_count = cur.read_u32()? as usize;
6046            for _ in 0..seq_count {
6047                let name = cur.read_str()?;
6048                let data_type = match cur.read_u8()? {
6049                    0 => SequenceDataType::SmallInt,
6050                    1 => SequenceDataType::Int,
6051                    2 => SequenceDataType::BigInt,
6052                    other => {
6053                        return Err(StorageError::Corrupt(format!(
6054                            "unknown SEQUENCE data-type tag {other}"
6055                        )));
6056                    }
6057                };
6058                let start = cur.read_i64()?;
6059                let increment = cur.read_i64()?;
6060                let min_value = cur.read_i64()?;
6061                let max_value = cur.read_i64()?;
6062                let cache = cur.read_i64()?;
6063                let cycle = cur.read_u8()? != 0;
6064                let owned_by = match cur.read_u8()? {
6065                    0 => None,
6066                    1 => {
6067                        let t = cur.read_str()?;
6068                        let c = cur.read_str()?;
6069                        Some((t, c))
6070                    }
6071                    other => {
6072                        return Err(StorageError::Corrupt(format!(
6073                            "unknown SEQUENCE owned-by tag {other}"
6074                        )));
6075                    }
6076                };
6077                let last_value = cur.read_i64()?;
6078                let is_called = cur.read_u8()? != 0;
6079                cat.sequences.insert(
6080                    name.clone(),
6081                    SequenceDef {
6082                        name,
6083                        data_type,
6084                        start,
6085                        increment,
6086                        min_value,
6087                        max_value,
6088                        cache,
6089                        cycle,
6090                        owned_by,
6091                        last_value,
6092                        is_called,
6093                    },
6094                );
6095            }
6096        }
6097        // v7.17.0 Phase 1.2 — VIEW block (FILE_VERSION 27+).
6098        // v26-and-below catalogs omit; we leave the map empty.
6099        if version >= 27 {
6100            let view_count = cur.read_u32()? as usize;
6101            for _ in 0..view_count {
6102                let name = cur.read_str()?;
6103                let col_count = cur.read_u16()? as usize;
6104                let mut columns = Vec::with_capacity(col_count);
6105                for _ in 0..col_count {
6106                    columns.push(cur.read_str()?);
6107                }
6108                let body = cur.read_str_long()?;
6109                cat.views.insert(
6110                    name.clone(),
6111                    ViewDef {
6112                        name,
6113                        columns,
6114                        body,
6115                    },
6116                );
6117            }
6118        }
6119        // v7.17.0 Phase 1.3 — MATERIALIZED VIEW source registry
6120        // (FILE_VERSION 28+). v27-and-below catalogs omit.
6121        if version >= 28 {
6122            let mv_count = cur.read_u32()? as usize;
6123            for _ in 0..mv_count {
6124                let name = cur.read_str()?;
6125                let body = cur.read_str_long()?;
6126                cat.materialized_views.insert(name, body);
6127            }
6128        }
6129        // v7.17.0 Phase 1.4 — ENUM types catalog block
6130        // (FILE_VERSION 29+).
6131        if version >= 29 {
6132            let etype_count = cur.read_u32()? as usize;
6133            for _ in 0..etype_count {
6134                let name = cur.read_str()?;
6135                let label_count = cur.read_u16()? as usize;
6136                let mut labels = Vec::with_capacity(label_count);
6137                for _ in 0..label_count {
6138                    labels.push(cur.read_str()?);
6139                }
6140                cat.enum_types
6141                    .insert(name.clone(), EnumDef { name, labels });
6142            }
6143        }
6144        // v7.17.0 Phase 1.5 — DOMAIN types catalog block
6145        // (FILE_VERSION 30+).
6146        if version >= 30 {
6147            let dtype_count = cur.read_u32()? as usize;
6148            for _ in 0..dtype_count {
6149                let name = cur.read_str()?;
6150                let base_type = cur.read_data_type()?;
6151                let nullable = cur.read_u8()? != 0;
6152                let default = match cur.read_u8()? {
6153                    0 => None,
6154                    1 => Some(cur.read_str()?),
6155                    other => {
6156                        return Err(StorageError::Corrupt(format!(
6157                            "unknown DOMAIN default tag {other}"
6158                        )));
6159                    }
6160                };
6161                let check_count = cur.read_u16()? as usize;
6162                let mut checks = Vec::with_capacity(check_count);
6163                for _ in 0..check_count {
6164                    checks.push(cur.read_str()?);
6165                }
6166                cat.domain_types.insert(
6167                    name.clone(),
6168                    DomainDef {
6169                        name,
6170                        base_type,
6171                        nullable,
6172                        default,
6173                        checks,
6174                    },
6175                );
6176            }
6177        }
6178        // v7.17.0 Phase 1.6 — user-schemas registry
6179        // (FILE_VERSION 31+).
6180        if version >= 31 {
6181            let sch_count = cur.read_u32()? as usize;
6182            for _ in 0..sch_count {
6183                let name = cur.read_str()?;
6184                cat.schemas.insert(name);
6185            }
6186        }
6187        // v7.37.42-T2 ζ-B — COMPOSITE types catalog block
6188        // (FILE_VERSION 52+). v51-and-below readers stop at the
6189        // user-schemas block; v52 readers fed a v51 catalog see no
6190        // composite block and default to an empty map.
6191        if version >= 52 {
6192            let ctype_count = cur.read_u32()? as usize;
6193            for _ in 0..ctype_count {
6194                let name = cur.read_str()?;
6195                let field_count = cur.read_u16()? as usize;
6196                let mut fields = Vec::with_capacity(field_count);
6197                for _ in 0..field_count {
6198                    let fname = cur.read_str()?;
6199                    let fty = cur.read_data_type()?;
6200                    fields.push((fname, fty));
6201                }
6202                cat.composite_types
6203                    .insert(name.clone(), CompositeDef { name, fields });
6204            }
6205        }
6206        if cur.pos < buf.len() {
6207            return Err(StorageError::Corrupt(format!(
6208                "trailing bytes: {} unread",
6209                buf.len() - cur.pos
6210            )));
6211        }
6212        Ok(cat)
6213    }
6214}
6215
6216#[cfg(test)]
6217mod tests;