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        for change in changes {
3423            match change {
3424                RowChange::Insert { table, row } => {
3425                    self.table_for_redo(table)?.insert(row.clone())?;
3426                }
3427                RowChange::Update {
3428                    table,
3429                    pos,
3430                    new_row,
3431                } => {
3432                    self.table_for_redo(table)?
3433                        .update_row(*pos, new_row.clone())?;
3434                }
3435                RowChange::Delete { table, positions } => {
3436                    self.table_for_redo(table)?.delete_rows(positions);
3437                }
3438            }
3439        }
3440        Ok(())
3441    }
3442
3443    fn table_for_redo(&mut self, name: &str) -> Result<&mut Table, StorageError> {
3444        self.get_mut(name)
3445            .ok_or_else(|| StorageError::Corrupt(alloc::format!("redo: unknown table {name:?}")))
3446    }
3447
3448    /// v7.34 (crash-recovery P0 #2) — enable row-level redo capture on
3449    /// every table (the engine calls this before a mutating statement
3450    /// when persistence is on; idempotent, keeps any in-flight capture).
3451    pub fn enable_redo_all(&mut self) {
3452        for t in &mut self.tables {
3453            t.enable_redo();
3454        }
3455    }
3456
3457    /// v7.34 — drain the row-level redo captured across all tables, in
3458    /// table order then per-table apply order, and stop capturing. The
3459    /// engine calls this after a successful mutating statement and writes
3460    /// the returned [`RowChange`]s to the WAL in place of the SQL text.
3461    pub fn drain_redo(&mut self) -> Vec<RowChange> {
3462        let mut all = Vec::new();
3463        for t in &mut self.tables {
3464            all.extend(t.take_redo());
3465        }
3466        all
3467    }
3468
3469    pub fn table_count(&self) -> usize {
3470        self.tables.len()
3471    }
3472
3473    /// v7.14.0 — remove a table by name. Returns `true` when the
3474    /// table existed (and is now gone), `false` when it didn't.
3475    /// Used by `DROP TABLE` from pg_dump / mysqldump preambles
3476    /// where the dump re-creates schema and starts with
3477    /// `DROP TABLE IF EXISTS`.
3478    pub fn drop_table(&mut self, name: &str) -> bool {
3479        let Some(idx) = self.by_name.remove(name) else {
3480            return false;
3481        };
3482        // swap_remove invalidates the trailing index → rebuild
3483        // by_name for affected entries.
3484        self.tables.swap_remove(idx);
3485        // Re-stamp moved table's index slot in by_name.
3486        if idx < self.tables.len() {
3487            let moved_name = self.tables[idx].schema.name.clone();
3488            self.by_name.insert(moved_name, idx);
3489        }
3490        true
3491    }
3492
3493    /// v7.16.2 — rename a table (mailrs round-10 A.5). Updates
3494    /// the schema name, the catalog name → index map, and
3495    /// rewrites every reference dangling at the table name:
3496    ///   * every FK on every OTHER table whose `parent_table`
3497    ///     pointed at the old name now points at the new
3498    ///     name, so FK enforcement keeps working
3499    ///   * every trigger watching the table updates its `table`
3500    ///     field
3501    /// Returns `Ok` on success; `Err(StorageError::TableNotFound)`
3502    /// when the old name isn't in the catalog and
3503    /// `Err(StorageError::DuplicateTable)` when the new name is
3504    /// already taken.
3505    pub fn rename_table(&mut self, old: &str, new: &str) -> Result<(), StorageError> {
3506        if old == new {
3507            return Ok(());
3508        }
3509        if self.by_name.contains_key(new) {
3510            return Err(StorageError::Corrupt(format!(
3511                "rename_table: target name {new:?} already exists"
3512            )));
3513        }
3514        let idx = self
3515            .by_name
3516            .remove(old)
3517            .ok_or_else(|| StorageError::TableNotFound { name: old.into() })?;
3518        self.tables[idx].schema.name = new.to_string();
3519        self.by_name.insert(new.to_string(), idx);
3520        for t in &mut self.tables {
3521            for fk in &mut t.schema.foreign_keys {
3522                if fk.parent_table == old {
3523                    fk.parent_table = new.to_string();
3524                }
3525            }
3526        }
3527        for trig in &mut self.triggers {
3528            if trig.table == old {
3529                trig.table = new.to_string();
3530            }
3531        }
3532        Ok(())
3533    }
3534
3535    /// v7.16.2 — rename an index by name. Walks every table
3536    /// since the index lives on its owning table; updates the
3537    /// name in place. Errors with `IndexNotFound` when no
3538    /// index matches. mailrs round-10 A.5.
3539    pub fn rename_index(&mut self, old: &str, new: &str) -> Result<(), StorageError> {
3540        if old == new {
3541            return Ok(());
3542        }
3543        // Reject the new name if it already exists anywhere.
3544        for t in &self.tables {
3545            if t.indices.iter().any(|i| i.name == new) {
3546                return Err(StorageError::Corrupt(format!(
3547                    "rename_index: target name {new:?} already exists"
3548                )));
3549            }
3550        }
3551        for t in &mut self.tables {
3552            for i in &mut t.indices {
3553                if i.name == old {
3554                    i.name = new.to_string();
3555                    return Ok(());
3556                }
3557            }
3558        }
3559        Err(StorageError::IndexNotFound { name: old.into() })
3560    }
3561
3562    /// v7.14.0 — remove a named index across the catalog.
3563    /// Returns `true` when found + dropped.
3564    pub fn drop_named_index(&mut self, name: &str) -> bool {
3565        for t in &mut self.tables {
3566            let before = t.indices.len();
3567            t.indices.retain(|i| i.name != name);
3568            if t.indices.len() != before {
3569                return true;
3570            }
3571        }
3572        false
3573    }
3574
3575    /// Borrow-free copy of every table's name in catalog order
3576    /// (= insertion order, matching the on-disk encoding).
3577    pub fn table_names(&self) -> Vec<String> {
3578        self.tables.iter().map(|t| t.schema.name.clone()).collect()
3579    }
3580
3581    /// v5.1: register a cold-tier segment that already lives in
3582    /// memory (caller did the file read). Returns the
3583    /// `segment_id` that `RowLocator::Cold { segment_id, .. }`
3584    /// will reference — currently this is just the index into
3585    /// `cold_segments`, but treat it as an opaque token.
3586    ///
3587    /// Storage is `no_std`, so file I/O is the caller's
3588    /// responsibility — `spg-server` reads the file and forwards
3589    /// the bytes here. The bytes stay resident in the catalog
3590    /// for the life of the `Catalog`, parsed only once.
3591    pub fn load_segment_bytes(&mut self, bytes: Vec<u8>) -> Result<u32, StorageError> {
3592        let id = u32::try_from(self.cold_segments.len()).map_err(|_| {
3593            StorageError::Corrupt("cold segment count would exceed u32::MAX".into())
3594        })?;
3595        let seg = OwnedSegment::from_bytes(bytes)
3596            .map_err(|e| StorageError::Corrupt(format!("cold segment parse failed: {e}")))?;
3597        self.cold_segments.push(Some(Arc::new(seg)));
3598        Ok(id)
3599    }
3600
3601    /// v6.7.3 — register a cold-tier segment at a specific id. Used
3602    /// by the spg-server manifest-boot path so segments whose
3603    /// neighbouring ids were retired by compaction still get back
3604    /// the same `segment_id` they had pre-restart (the
3605    /// `RowLocator::Cold { segment_id }` baked into the BTree-index
3606    /// snapshot persists across restart and must continue to
3607    /// resolve).
3608    ///
3609    /// Pads the Vec with `None` slots up to `target_id` if needed.
3610    /// Errors when the target slot is already occupied (would
3611    /// stomp another segment), the parse fails, or `target_id`
3612    /// exceeds `u32::MAX`.
3613    pub fn load_segment_bytes_at(
3614        &mut self,
3615        target_id: u32,
3616        bytes: Vec<u8>,
3617    ) -> Result<(), StorageError> {
3618        let seg = OwnedSegment::from_bytes(bytes)
3619            .map_err(|e| StorageError::Corrupt(format!("cold segment parse failed: {e}")))?;
3620        let idx = target_id as usize;
3621        while self.cold_segments.len() <= idx {
3622            self.cold_segments.push(None);
3623        }
3624        if self.cold_segments[idx].is_some() {
3625            return Err(StorageError::Corrupt(format!(
3626                "load_segment_bytes_at: segment_id {target_id} already occupied"
3627            )));
3628        }
3629        self.cold_segments[idx] = Some(Arc::new(seg));
3630        Ok(())
3631    }
3632
3633    /// v6.7.3 — retire a cold-tier segment slot (compaction-driven).
3634    /// The physical file is the caller's concern (typically kept
3635    /// on disk until the next CHECKPOINT writes a manifest that
3636    /// no longer lists it); this just flips the in-memory slot
3637    /// to `None` so later cold lookups for `segment_id` resolve
3638    /// as "unknown" instead of returning a stale row.
3639    ///
3640    /// No-op when the slot is already `None`. Errors only when
3641    /// `segment_id` is out of bounds.
3642    pub fn tombstone_segment(&mut self, segment_id: u32) -> Result<(), StorageError> {
3643        let idx = segment_id as usize;
3644        if idx >= self.cold_segments.len() {
3645            return Err(StorageError::Corrupt(format!(
3646                "tombstone_segment: segment_id {segment_id} out of bounds (len={})",
3647                self.cold_segments.len()
3648            )));
3649        }
3650        self.cold_segments[idx] = None;
3651        Ok(())
3652    }
3653
3654    /// Number of *active* (non-tombstoned) cold segments.
3655    #[must_use]
3656    pub fn cold_segment_count(&self) -> usize {
3657        self.cold_segments.iter().filter(|s| s.is_some()).count()
3658    }
3659
3660    /// v7.37.42 (docker-fair SCALARSQ attack 3) — short-circuit guard
3661    /// for scan loops that conditionally walk the cold tier. Returns
3662    /// `false` when the catalog has never loaded a cold segment (or all
3663    /// segments are tombstoned), so callers can skip the per-table cold
3664    /// PK-index walk entirely on hot-only databases. O(N segments);
3665    /// typical N is small (single-digit) so the check is sub-µs.
3666    #[must_use]
3667    pub fn has_any_cold_segments(&self) -> bool {
3668        self.cold_segments.iter().any(Option::is_some)
3669    }
3670
3671    /// Slot count including tombstones (= the next id the
3672    /// no-arg `load_segment_bytes` would allocate).
3673    #[must_use]
3674    pub fn cold_segment_slot_count(&self) -> usize {
3675        self.cold_segments.len()
3676    }
3677
3678    /// v6.2.7 — list every *active* cold-tier segment id known to
3679    /// this catalog (skips compaction tombstones since v6.7.3).
3680    /// Used by EXPLAIN ANALYZE to annotate scan nodes with the
3681    /// segments they could have walked.
3682    #[must_use]
3683    pub fn cold_segment_ids_global(&self) -> Vec<u32> {
3684        self.cold_segments
3685            .iter()
3686            .enumerate()
3687            .filter_map(|(i, s)| s.as_ref().map(|_| i as u32))
3688            .collect()
3689    }
3690
3691    /// v5.2.1: sum of `Table::hot_bytes` across every table. The v5.2
3692    /// freezer compares this against `SPG_HOT_TIER_BYTES` (parsed at
3693    /// server startup; default 4 GiB) and wakes when the budget is
3694    /// crossed. Pre-freezer (v5.2.1) this is measurement-only — the
3695    /// counter exposes whether the budget is being approached without
3696    /// triggering any demotion.
3697    #[must_use]
3698    pub fn hot_tier_bytes(&self) -> u64 {
3699        self.tables
3700            .iter()
3701            .map(Table::hot_bytes)
3702            .fold(0u64, u64::saturating_add)
3703    }
3704
3705    /// v5.2.2: freeze the **first** `max_rows` rows of `table_name`'s
3706    /// hot tier into a brand-new cold-tier segment. The named `BTree`
3707    /// index supplies the per-row PK (its column must be an integer
3708    /// type — v5.2.2 only supports `IndexKey::Int` PKs, matching the
3709    /// `index_key_as_u64` constraint used by the cold-tier lookup
3710    /// path). On success returns a [`FreezeReport`] with the
3711    /// freshly-allocated segment id, the count of rows that moved,
3712    /// the encoded segment bytes (so the caller can persist them to
3713    /// disk for later reload via `SPG_PRELOAD_COLD_SEGMENT`), and the
3714    /// hot-tier byte delta that was reclaimed.
3715    ///
3716    /// **Semantics**:
3717    /// 1. The first `max_rows` rows (by hot-tier position — same as
3718    ///    insertion order under v4.39 `PersistentVec`) are read.
3719    /// 2. Rows are sorted ascending by PK and serialised into a new
3720    ///    segment via [`encode_segment`].
3721    /// 3. The hot rows are dropped via [`Table::delete_rows`]; the
3722    ///    `rebuild_indices` it triggers regenerates `Hot` locators
3723    ///    for every remaining row (their positions shift down by
3724    ///    `max_rows`). Existing `Cold` locators in this index — from
3725    ///    a previous freeze — are also rebuilt **but with empty
3726    ///    payload** since rebuild reads only `self.rows`; this
3727    ///    routine re-registers them at the end of the call so the
3728    ///    user-visible state preserves all prior cold locators.
3729    /// 4. The new segment is loaded into `self.cold_segments` via
3730    ///    [`Catalog::load_segment_bytes`] (allocating a fresh
3731    ///    `segment_id`). New `Cold` locators are registered on the
3732    ///    named index — one per frozen row.
3733    ///
3734    /// **v5.2.2 limits** (relaxed in later sub-versions):
3735    /// - INSERT-only flow: subsequent UPDATE/DELETE on a frozen row
3736    ///   returns a stale-locator error (no promote-on-write until
3737    ///   v5.2.3).
3738    /// - Single-table scope: callers iterate tables themselves.
3739    /// - All-or-nothing: returns `Err` and leaves catalog unchanged
3740    ///   if any step fails before the atomic swap point.
3741    ///
3742    /// Errors:
3743    /// - [`StorageError::Corrupt`] for missing table/index, non-`BTree`
3744    ///   index, non-integer PK column, `max_rows == 0`, or
3745    ///   `max_rows > row_count`.
3746    /// - The encoder's [`SegmentError`] surfaces as `Corrupt` (the
3747    ///   only realistic source is "a single row is larger than the
3748    ///   page size"; SPG schemas don't hit it in practice).
3749    pub fn freeze_oldest_to_cold(
3750        &mut self,
3751        table_name: &str,
3752        index_name: &str,
3753        max_rows: usize,
3754    ) -> Result<FreezeReport, StorageError> {
3755        // --- validation phase: never mutates ---------------------
3756        if max_rows == 0 {
3757            return Err(StorageError::Corrupt(
3758                "freeze_oldest_to_cold: max_rows must be > 0".into(),
3759            ));
3760        }
3761        let table = self.get(table_name).ok_or_else(|| {
3762            StorageError::Corrupt(format!(
3763                "freeze_oldest_to_cold: table {table_name:?} not found"
3764            ))
3765        })?;
3766        if max_rows > table.rows.len() {
3767            return Err(StorageError::Corrupt(format!(
3768                "freeze_oldest_to_cold: max_rows {max_rows} > row_count {}",
3769                table.rows.len()
3770            )));
3771        }
3772        let idx = table
3773            .indices
3774            .iter()
3775            .find(|i| i.name == index_name)
3776            .ok_or_else(|| {
3777                StorageError::Corrupt(format!(
3778                    "freeze_oldest_to_cold: index {index_name:?} not found on {table_name:?}"
3779                ))
3780            })?;
3781        if !matches!(idx.kind, IndexKind::BTree(_)) {
3782            return Err(StorageError::Corrupt(format!(
3783                "freeze_oldest_to_cold: index {index_name:?} is NSW; only BTree indices may freeze"
3784            )));
3785        }
3786        let column_position = idx.column_position;
3787
3788        // --- segment build phase: reads only --------------------
3789        let schema = table.schema.clone();
3790        let mut to_freeze: Vec<(u64, Vec<u8>, IndexKey)> = Vec::with_capacity(max_rows);
3791        for row_idx in 0..max_rows {
3792            let row = table.rows.get(row_idx).expect("bounds-checked above");
3793            let key = IndexKey::from_value(&row.values[column_position]).ok_or_else(|| {
3794                StorageError::Corrupt(format!(
3795                    "freeze_oldest_to_cold: row {row_idx} has NULL / non-key value in index column"
3796                ))
3797            })?;
3798            let pk_u64 = index_key_as_u64(&key).ok_or_else(|| {
3799                StorageError::Corrupt(format!(
3800                    "freeze_oldest_to_cold: index {index_name:?} column type is non-integer; \
3801                     v5.2.2 cold tier requires IndexKey::Int (Text PK lands in v5.5+)"
3802                ))
3803            })?;
3804            to_freeze.push((pk_u64, encode_row_body_dense(row, &schema), key));
3805        }
3806        // encode_segment requires ascending u64 keys. Sort by PK
3807        // before encoding; the caller's row-position order is not
3808        // necessarily PK order (e.g. workloads that insert random
3809        // PKs).
3810        to_freeze.sort_by_key(|(k, _, _)| *k);
3811        // Reject duplicate PKs — encode_segment also rejects them
3812        // (`SegmentError::UnsortedKey`), but the resulting error
3813        // message there is misleading. Surface a clearer one.
3814        for w in to_freeze.windows(2) {
3815            if w[0].0 == w[1].0 {
3816                return Err(StorageError::Corrupt(format!(
3817                    "freeze_oldest_to_cold: duplicate PK {} in freeze batch",
3818                    w[0].0
3819                )));
3820            }
3821        }
3822        // Snapshot the (key, locator) pairs that will be registered
3823        // post-swap. Cloning the IndexKey out before the move makes
3824        // the registration loop borrow-free.
3825        let post_swap_keys: Vec<IndexKey> = to_freeze.iter().map(|(_, _, k)| k.clone()).collect();
3826        // Segment encode is now infallible w.r.t. ordering. Map the
3827        // `SegmentError` into a `StorageError::Corrupt` so the
3828        // public surface stays one error type.
3829        let seg_rows: Vec<(u64, Vec<u8>)> = to_freeze
3830            .into_iter()
3831            .map(|(k, body, _)| (k, body))
3832            .collect();
3833        let frozen_rows = seg_rows.len();
3834        let (seg_bytes, _meta) = encode_segment(seg_rows.into_iter(), 0.01, SEGMENT_PAGE_BYTES)
3835            .map_err(|e| StorageError::Corrupt(format!("freeze_oldest_to_cold: encode: {e}")))?;
3836
3837        // --- atomic swap phase: mutations only past this point ---
3838        // v5.2.3 made `Table::rebuild_indices` preserve every Cold
3839        // locator across the per-table rebuild, so `delete_rows`
3840        // below no longer wipes prior-freeze cold entries. The pre-
3841        // v5.2.3 capture-then-re-register that used to live here
3842        // was removed in v5.3.1 — keeping it would double-count
3843        // every prior-frozen key's Cold locator on each subsequent
3844        // freeze.
3845        let bytes_before = self.get(table_name).expect("just validated").hot_bytes();
3846        let positions: Vec<usize> = (0..max_rows).collect();
3847        let t_mut = self
3848            .get_mut(table_name)
3849            .expect("just validated; still present");
3850        let removed = t_mut.delete_rows(&positions);
3851        debug_assert_eq!(removed, max_rows, "delete_rows count matches request");
3852        let bytes_after = t_mut.hot_bytes();
3853        let bytes_freed = bytes_before.saturating_sub(bytes_after);
3854
3855        let segment_id = self
3856            .load_segment_bytes(seg_bytes.clone())
3857            .map_err(|e| StorageError::Corrupt(format!("freeze_oldest_to_cold: load: {e}")))?;
3858        let new_cold = post_swap_keys.into_iter().map(|k| {
3859            (
3860                k,
3861                RowLocator::Cold {
3862                    segment_id,
3863                    page_offset: 0,
3864                },
3865            )
3866        });
3867        let t_mut = self.get_mut(table_name).expect("still present");
3868        t_mut.register_cold_locators(index_name, new_cold)?;
3869
3870        Ok(FreezeReport {
3871            segment_id,
3872            frozen_rows,
3873            bytes_freed,
3874            segment_bytes: seg_bytes,
3875        })
3876    }
3877
3878    /// v5.1: borrow the cold segment at `segment_id`. Used by the
3879    /// spg-server preload path to enumerate (key, locator) pairs
3880    /// after loading a segment, so it can call
3881    /// [`Table::register_cold_locators`] without re-parsing the
3882    /// bytes.
3883    #[must_use]
3884    pub fn cold_segment(&self, segment_id: u32) -> Option<&OwnedSegment> {
3885        self.cold_segments
3886            .get(segment_id as usize)
3887            .and_then(|s| s.as_deref())
3888    }
3889
3890    /// v5.1: resolve a single `RowLocator::Cold` to its underlying
3891    /// `Row`. Decoupled from [`Catalog::lookup_by_pk`] so callers
3892    /// iterating a multi-locator slice (e.g. the engine's index
3893    /// seek path) can dispatch per locator instead of getting back
3894    /// only the first row for a key. Returns `None` when the
3895    /// segment isn't registered, the key isn't `u64`-coercible, or
3896    /// the segment doesn't actually carry the key (bloom or page-
3897    /// index reject).
3898    pub fn resolve_cold_locator(
3899        &self,
3900        table_name: &str,
3901        segment_id: u32,
3902        key: &IndexKey,
3903    ) -> Option<Row<'static>> {
3904        let t = self.get(table_name)?;
3905        let u64_key = index_key_as_u64(key)?;
3906        let seg = self.cold_segments.get(segment_id as usize)?.as_ref()?;
3907        let payload = seg.lookup(u64_key)?;
3908        let (row, _) = decode_row_body_dense(&payload, &t.schema, seg.codec_version()).ok()?;
3909        Some(row)
3910    }
3911
3912    /// v5.1: indexed PK lookup that dispatches per locator,
3913    /// returning the first matching row from either the hot tier
3914    /// (`Table::rows`) or a registered cold segment.
3915    ///
3916    /// The cold path requires the index column to be coercible to
3917    /// a `u64` (the segment's PK type) and the segment payload to
3918    /// be a [`encode_row_body_dense`]-encoded row body for the
3919    /// same schema. v5.1 ships this for BIGINT / INT / SMALLINT
3920    /// PKs; other types fall through to hot-only behavior.
3921    ///
3922    /// Returns `None` if (a) the table or index doesn't exist,
3923    /// (b) the key isn't in the index at all, or (c) the key was
3924    /// resolved to a stale locator (Hot index out of range, Cold
3925    /// segment id unknown, segment lookup miss). Does not surface
3926    /// segment-decode errors — those would indicate corrupted
3927    /// cold-tier files and should be caught at
3928    /// [`Catalog::load_segment_bytes`] time.
3929    pub fn lookup_by_pk(&self, table: &str, index_name: &str, key: &IndexKey) -> Option<Row<'_>> {
3930        let t = self.get(table)?;
3931        let idx = t.indices.iter().find(|i| i.name == index_name)?;
3932        let locators = idx.lookup_eq(key);
3933        let cold_u64_key = index_key_as_u64(key);
3934        for loc in locators {
3935            match *loc {
3936                RowLocator::Hot(i) => {
3937                    if let Some(row) = t.rows.get(i) {
3938                        return Some(row.clone());
3939                    }
3940                }
3941                RowLocator::Cold {
3942                    segment_id,
3943                    page_offset: _,
3944                } => {
3945                    let Some(u64_key) = cold_u64_key else {
3946                        // Key type not coercible to u64 — cold tier
3947                        // only handles BIGINT/INT/SMALLINT in v5.1.
3948                        continue;
3949                    };
3950                    let Some(seg) = self
3951                        .cold_segments
3952                        .get(segment_id as usize)
3953                        .and_then(|s| s.as_deref())
3954                    else {
3955                        // v6.7.3 — `None` slot = compaction
3956                        // retired this segment; the live locator
3957                        // on a freshly-compacted index points to
3958                        // the merged segment_id, so a Cold hit
3959                        // here against a tombstone means the BTree
3960                        // entry hasn't been swapped yet (mid-
3961                        // compaction reader race) or the caller is
3962                        // looking up a stale snapshot. Skip — the
3963                        // next locator in the list, if any, is
3964                        // typically the merged segment.
3965                        continue;
3966                    };
3967                    let Some(payload) = seg.lookup(u64_key) else {
3968                        continue;
3969                    };
3970                    let (row, _) =
3971                        decode_row_body_dense(&payload, &t.schema, seg.codec_version()).ok()?;
3972                    return Some(row);
3973                }
3974            }
3975        }
3976        None
3977    }
3978
3979    /// v5.2.3: promote a frozen row back to the hot tier so an
3980    /// UPDATE / DELETE can mutate it. Reads the cold-tier row body
3981    /// (decoded from its registered segment), pushes it into
3982    /// `table.rows` via [`Table::insert`] (which also adds a fresh
3983    /// `Hot(new_idx)` locator on `index_name`), then retires the
3984    /// shadowed `Cold` locator via
3985    /// [`Table::remove_cold_locators_for_key`]. The cold-tier row
3986    /// in the segment file becomes garbage — recoverable when a
3987    /// future cold-segment compaction job lands.
3988    ///
3989    /// Returns:
3990    /// - `Ok(Some(new_hot_idx))` when the key resolved through a
3991    ///   cold locator and the promote completed. `new_hot_idx` is
3992    ///   the position the row now occupies in `table.rows`.
3993    /// - `Ok(None)` when the key has no Cold locator on the index
3994    ///   (already hot, or wasn't present at all). Callers treat this
3995    ///   as "nothing to do here, fall back to the hot-only path".
3996    ///
3997    /// Errors when the table / index doesn't exist, the index isn't
3998    /// `BTree`, the cold segment is missing / can't decode the row,
3999    /// or the inferred row body fails `Table::insert` validation.
4000    pub fn promote_cold_row(
4001        &mut self,
4002        table_name: &str,
4003        index_name: &str,
4004        key: &IndexKey,
4005    ) -> Result<Option<usize>, StorageError> {
4006        let cold_loc = self.find_cold_locator(table_name, index_name, key)?;
4007        let Some((segment_id, _page_offset)) = cold_loc else {
4008            return Ok(None);
4009        };
4010        let u64_key = index_key_as_u64(key).ok_or_else(|| {
4011            StorageError::Corrupt(
4012                "promote_cold_row: key type not coercible to u64 (cold tier requires integer PK)"
4013                    .into(),
4014            )
4015        })?;
4016        // Read the row body from the segment. Borrow the segment +
4017        // schema short-term so we can then take `&mut self` for the
4018        // hot-side insert.
4019        let schema = self
4020            .get(table_name)
4021            .ok_or_else(|| {
4022                StorageError::Corrupt(format!("promote_cold_row: table {table_name:?} not found"))
4023            })?
4024            .schema
4025            .clone();
4026        let seg = self
4027            .cold_segments
4028            .get(segment_id as usize)
4029            .and_then(|s| s.as_ref())
4030            .ok_or_else(|| {
4031                StorageError::Corrupt(format!(
4032                    "promote_cold_row: segment {segment_id} not registered on catalog"
4033                ))
4034            })?;
4035        let payload = seg.lookup(u64_key).ok_or_else(|| {
4036            StorageError::Corrupt(format!(
4037                "promote_cold_row: key {u64_key} resolves to segment {segment_id} \
4038                 but the segment's bloom/page lookup didn't return a row"
4039            ))
4040        })?;
4041        let (row, _consumed) = decode_row_body_dense(&payload, &schema, seg.codec_version())?;
4042        // Insert the promoted row into the hot tier. `Table::insert`
4043        // appends to `self.rows`, adds a `Hot(new_idx)` locator to
4044        // every BTree index covering the row's keyed columns, and
4045        // increments `hot_bytes`.
4046        let t = self
4047            .get_mut(table_name)
4048            .expect("table existed at lookup time");
4049        t.insert(row)?;
4050        let new_hot_idx =
4051            t.rows.len().checked_sub(1).ok_or_else(|| {
4052                StorageError::Corrupt("promote_cold_row: empty after insert".into())
4053            })?;
4054        // The hot insert added Hot(new_idx) alongside the still-
4055        // present Cold locator. Drop the Cold entry so future
4056        // lookups return only the fresh hot row.
4057        t.remove_cold_locators_for_key(index_name, key)?;
4058        Ok(Some(new_hot_idx))
4059    }
4060
4061    /// v5.2.3: shadow a frozen row's index entry. Used by DELETE
4062    /// when the row to remove lives in a cold-tier segment — the
4063    /// row body stays in the segment file (becoming garbage) but
4064    /// every `Cold` locator for `key` on `index_name` is removed
4065    /// so PK lookups stop returning it.
4066    ///
4067    /// Returns the number of cold locators retired (0 when the key
4068    /// has no cold entries — the DELETE fell on a hot row or a
4069    /// key that was already absent). Errors when the table /
4070    /// index doesn't exist or the index isn't `BTree`.
4071    ///
4072    /// Cold-segment compaction (which merges shadowed-heavy
4073    /// segments and reclaims their disk footprint) lands in a
4074    /// later v5.x sub-version; until then, repeated UPDATE/DELETE
4075    /// of cold rows can amplify cold-segment disk usage by up to
4076    /// 1-2× — still well under typical LSM-tree shadowing because
4077    /// SPG segments are bulk-baked, not write-merged.
4078    pub fn shadow_cold_row(
4079        &mut self,
4080        table_name: &str,
4081        index_name: &str,
4082        key: &IndexKey,
4083    ) -> Result<usize, StorageError> {
4084        let t = self.get_mut(table_name).ok_or_else(|| {
4085            StorageError::Corrupt(format!("shadow_cold_row: table {table_name:?} not found"))
4086        })?;
4087        t.remove_cold_locators_for_key(index_name, key)
4088    }
4089
4090    /// v6.7.4 — read-only slice preparation for the parallel
4091    /// freezer. Walks rows in `row_range`, builds the
4092    /// `(pk_u64, encoded_body, IndexKey)` triples that the
4093    /// coordinator's k-way merge consumes, sorts the slice by
4094    /// `pk_u64`, and returns a [`FreezeSlice`].
4095    ///
4096    /// Caller invariants:
4097    /// - `row_range.end <= table.rows.len()` (caller's job to
4098    ///   compute the partition).
4099    /// - All slices passed to `commit_freeze_slices` must cover a
4100    ///   contiguous half-open range `[0, total_max_rows)` with no
4101    ///   gaps and no overlaps. The coordinator validates this
4102    ///   invariant before committing.
4103    ///
4104    /// `&self`-only — multiple workers can run this concurrently
4105    /// against the same `Catalog` reference under the engine's
4106    /// write lock (workers don't mutate; the coordinator does).
4107    pub fn prepare_freeze_slice(
4108        &self,
4109        table_name: &str,
4110        index_name: &str,
4111        row_range: core::ops::Range<usize>,
4112    ) -> Result<FreezeSlice, StorageError> {
4113        let table = self.get(table_name).ok_or_else(|| {
4114            StorageError::Corrupt(format!(
4115                "prepare_freeze_slice: table {table_name:?} not found"
4116            ))
4117        })?;
4118        let idx = table
4119            .indices
4120            .iter()
4121            .find(|i| i.name == index_name)
4122            .ok_or_else(|| {
4123                StorageError::Corrupt(format!(
4124                    "prepare_freeze_slice: index {index_name:?} not found on {table_name:?}"
4125                ))
4126            })?;
4127        if !matches!(idx.kind, IndexKind::BTree(_)) {
4128            return Err(StorageError::Corrupt(format!(
4129                "prepare_freeze_slice: index {index_name:?} is NSW; only BTree indices may freeze"
4130            )));
4131        }
4132        if row_range.end > table.rows.len() {
4133            return Err(StorageError::Corrupt(format!(
4134                "prepare_freeze_slice: row_range end {} > row_count {}",
4135                row_range.end,
4136                table.rows.len()
4137            )));
4138        }
4139        let column_position = idx.column_position;
4140        let schema = table.schema.clone();
4141        let mut rows: Vec<(u64, Vec<u8>, IndexKey)> = Vec::with_capacity(row_range.len());
4142        for row_idx in row_range.clone() {
4143            let row = table.rows.get(row_idx).expect("bounds-checked above");
4144            let key = IndexKey::from_value(&row.values[column_position]).ok_or_else(|| {
4145                StorageError::Corrupt(format!(
4146                    "prepare_freeze_slice: row {row_idx} has NULL / non-key value in index column"
4147                ))
4148            })?;
4149            let pk_u64 = index_key_as_u64(&key).ok_or_else(|| {
4150                StorageError::Corrupt(format!(
4151                    "prepare_freeze_slice: index {index_name:?} column type is non-integer; \
4152                     v5.2.2 cold tier requires IndexKey::Int (Text PK lands in v5.5+)"
4153                ))
4154            })?;
4155            rows.push((pk_u64, encode_row_body_dense(row, &schema), key));
4156        }
4157        rows.sort_by_key(|(k, _, _)| *k);
4158        Ok(FreezeSlice { row_range, rows })
4159    }
4160
4161    /// v6.7.4 — coordinator commit step. Merges N
4162    /// [`FreezeSlice`]s into one segment via the standard
4163    /// [`encode_segment`] path, atomically swaps the catalog
4164    /// state (delete the union row range + register Cold
4165    /// locators + load the segment).
4166    ///
4167    /// Validates that the slices cover a contiguous, gap-free,
4168    /// overlap-free half-open range starting at index 0 (the
4169    /// freezer always freezes "oldest first" — same semantics as
4170    /// the single-threaded [`Catalog::freeze_oldest_to_cold`]).
4171    ///
4172    /// Empty `slices` → no-op success (returns a zero-row report
4173    /// without mutating). Total row count = `Σ slice.rows.len()`.
4174    pub fn commit_freeze_slices(
4175        &mut self,
4176        table_name: &str,
4177        index_name: &str,
4178        slices: Vec<FreezeSlice>,
4179    ) -> Result<FreezeReport, StorageError> {
4180        // --- validation phase: never mutates ---------------------
4181        let table = self.get(table_name).ok_or_else(|| {
4182            StorageError::Corrupt(format!(
4183                "commit_freeze_slices: table {table_name:?} not found"
4184            ))
4185        })?;
4186        let idx = table
4187            .indices
4188            .iter()
4189            .find(|i| i.name == index_name)
4190            .ok_or_else(|| {
4191                StorageError::Corrupt(format!(
4192                    "commit_freeze_slices: index {index_name:?} not found on {table_name:?}"
4193                ))
4194            })?;
4195        if !matches!(idx.kind, IndexKind::BTree(_)) {
4196            return Err(StorageError::Corrupt(format!(
4197                "commit_freeze_slices: index {index_name:?} is NSW; only BTree indices may freeze"
4198            )));
4199        }
4200        // Validate slice coverage: contiguous from 0, no gaps, no
4201        // overlaps. Allow the caller to pass slices in any order —
4202        // sort by row_range.start first.
4203        let mut ordered = slices;
4204        ordered.sort_by_key(|s| s.row_range.start);
4205        // Drop fully-empty slices that fell out of an uneven
4206        // partition; they carry no data but contribute to the
4207        // contiguity check, so keep them in line.
4208        let mut expected_start = 0usize;
4209        for s in &ordered {
4210            if s.row_range.start != expected_start {
4211                return Err(StorageError::Corrupt(format!(
4212                    "commit_freeze_slices: gap/overlap at row {}; expected start {}",
4213                    s.row_range.start, expected_start
4214                )));
4215            }
4216            expected_start = s.row_range.end;
4217        }
4218        let max_rows = expected_start;
4219        if max_rows > table.rows.len() {
4220            return Err(StorageError::Corrupt(format!(
4221                "commit_freeze_slices: total row range {} exceeds row_count {}",
4222                max_rows,
4223                table.rows.len()
4224            )));
4225        }
4226        if max_rows == 0 {
4227            return Ok(FreezeReport {
4228                segment_id: u32::MAX,
4229                frozen_rows: 0,
4230                bytes_freed: 0,
4231                segment_bytes: Vec::new(),
4232            });
4233        }
4234
4235        // --- segment build phase: reads only --------------------
4236        // K-way merge of already-sorted slices. Each slice's rows
4237        // are ascending by pk_u64; we keep a per-slice cursor and
4238        // pull the next-smallest head until every cursor drains.
4239        let total_rows: usize = ordered.iter().map(|s| s.rows.len()).sum();
4240        if total_rows != max_rows {
4241            return Err(StorageError::Corrupt(format!(
4242                "commit_freeze_slices: total slice rows {total_rows} ≠ row_range coverage {max_rows}"
4243            )));
4244        }
4245        let mut cursors: Vec<usize> = alloc::vec![0; ordered.len()];
4246        let mut merged: Vec<(u64, Vec<u8>, IndexKey)> = Vec::with_capacity(total_rows);
4247        loop {
4248            // Pick the slice whose head row has the smallest key
4249            // and isn't yet exhausted.
4250            let mut pick: Option<usize> = None;
4251            for (i, c) in cursors.iter().enumerate() {
4252                let slice = &ordered[i];
4253                if *c >= slice.rows.len() {
4254                    continue;
4255                }
4256                match pick {
4257                    None => pick = Some(i),
4258                    Some(j) => {
4259                        if slice.rows[*c].0 < ordered[j].rows[cursors[j]].0 {
4260                            pick = Some(i);
4261                        }
4262                    }
4263                }
4264            }
4265            let Some(i) = pick else { break };
4266            let row = ordered[i].rows[cursors[i]].clone();
4267            cursors[i] += 1;
4268            merged.push(row);
4269        }
4270        // Reject duplicate PKs — same error as the single-threaded
4271        // path so callers get a uniform surface.
4272        for w in merged.windows(2) {
4273            if w[0].0 == w[1].0 {
4274                return Err(StorageError::Corrupt(format!(
4275                    "commit_freeze_slices: duplicate PK {} across slices",
4276                    w[0].0
4277                )));
4278            }
4279        }
4280        let post_swap_keys: Vec<IndexKey> = merged.iter().map(|(_, _, k)| k.clone()).collect();
4281        let seg_rows: Vec<(u64, Vec<u8>)> =
4282            merged.into_iter().map(|(k, body, _)| (k, body)).collect();
4283        let frozen_rows = seg_rows.len();
4284        let (seg_bytes, _meta) = encode_segment(seg_rows.into_iter(), 0.01, SEGMENT_PAGE_BYTES)
4285            .map_err(|e| StorageError::Corrupt(format!("commit_freeze_slices: encode: {e}")))?;
4286
4287        // --- atomic swap phase: mutations only past this point ---
4288        let bytes_before = self.get(table_name).expect("just validated").hot_bytes();
4289        let positions: Vec<usize> = (0..max_rows).collect();
4290        let t_mut = self
4291            .get_mut(table_name)
4292            .expect("just validated; still present");
4293        let removed = t_mut.delete_rows(&positions);
4294        debug_assert_eq!(removed, max_rows, "delete_rows count matches request");
4295        let bytes_after = t_mut.hot_bytes();
4296        let bytes_freed = bytes_before.saturating_sub(bytes_after);
4297
4298        let segment_id = self
4299            .load_segment_bytes(seg_bytes.clone())
4300            .map_err(|e| StorageError::Corrupt(format!("commit_freeze_slices: load: {e}")))?;
4301        let new_cold = post_swap_keys.into_iter().map(|k| {
4302            (
4303                k,
4304                RowLocator::Cold {
4305                    segment_id,
4306                    page_offset: 0,
4307                },
4308            )
4309        });
4310        let t_mut = self.get_mut(table_name).expect("still present");
4311        t_mut.register_cold_locators(index_name, new_cold)?;
4312
4313        Ok(FreezeReport {
4314            segment_id,
4315            frozen_rows,
4316            bytes_freed,
4317            segment_bytes: seg_bytes,
4318        })
4319    }
4320
4321    /// v6.7.3 — compact every cold segment on `(table, index)` whose
4322    /// `OwnedSegment::bytes().len()` is below `target_segment_bytes`
4323    /// into a single larger merged segment. Rows present in source
4324    /// segment payloads but no longer referenced by any
4325    /// `RowLocator::Cold` on the index (DELETE'd + frozen rows
4326    /// retired via [`Catalog::shadow_cold_row`]) are GC'd in the
4327    /// merge.
4328    ///
4329    /// **Semantics**:
4330    /// 1. Walk the BTree index to collect every Cold locator that
4331    ///    targets a small (< threshold) segment. Each such
4332    ///    `(key, segment_id)` becomes a row in the merged segment;
4333    ///    payload is looked up from the source segment in-place.
4334    /// 2. Encode the collected rows into one new segment via
4335    ///    [`encode_segment`]; register it via
4336    ///    [`Catalog::load_segment_bytes`] (allocating a fresh
4337    ///    `merged_segment_id` at the end of `cold_segments`).
4338    /// 3. Rewrite the BTree index in one pass: every
4339    ///    `RowLocator::Cold { segment_id ∈ sources }` becomes
4340    ///    `RowLocator::Cold { segment_id = merged_id, page_offset = 0 }`.
4341    ///    Hot locators are untouched.
4342    /// 4. Tombstone every source slot via
4343    ///    [`Catalog::tombstone_segment`]. Source segment payloads
4344    ///    are no longer reachable through the catalog; the on-disk
4345    ///    files are the caller's concern.
4346    ///
4347    /// On fewer than 2 candidate segments the catalog is **not**
4348    /// mutated and a no-op report (`merged_segment_id: None`,
4349    /// `sources: []`) is returned. This is the routine case — a
4350    /// freshly-frozen table has at most 1 small segment, no merge
4351    /// possible.
4352    ///
4353    /// Atomicity: every mutating step runs after the read-only
4354    /// gather phase, so a panic before the merge encode leaves the
4355    /// catalog unchanged. The mutation block itself (load + rewrite +
4356    /// tombstone) takes only `&mut self` — callers serialise the
4357    /// engine write lock outside this function.
4358    ///
4359    /// Errors when the table / index doesn't exist, the index isn't
4360    /// `BTree`, the index column type isn't u64-coercible (cold-tier
4361    /// pre-condition), or a source segment fails its in-place
4362    /// row-body lookup (would indicate prior catalog corruption).
4363    pub fn compact_cold_segments(
4364        &mut self,
4365        table_name: &str,
4366        index_name: &str,
4367        target_segment_bytes: u64,
4368    ) -> Result<CompactReport, StorageError> {
4369        // --- validation phase ----------------------------------
4370        let t = self.get(table_name).ok_or_else(|| {
4371            StorageError::Corrupt(format!(
4372                "compact_cold_segments: table {table_name:?} not found"
4373            ))
4374        })?;
4375        let idx = t
4376            .indices
4377            .iter()
4378            .find(|i| i.name == index_name)
4379            .ok_or_else(|| {
4380                StorageError::Corrupt(format!(
4381                    "compact_cold_segments: index {index_name:?} not found on {table_name:?}"
4382                ))
4383            })?;
4384        let map = match &idx.kind {
4385            IndexKind::BTree(m) => m,
4386            IndexKind::Nsw(_)
4387            | IndexKind::Brin { .. }
4388            | IndexKind::Gin(_)
4389            | IndexKind::GinTrgm(_)
4390            | IndexKind::GinFulltext(_)
4391            | IndexKind::GinJsonb(_) => {
4392                return Err(StorageError::Corrupt(format!(
4393                    "compact_cold_segments: index {index_name:?} is not BTree; \
4394                     compaction applies only to BTree cold-tier indices"
4395                )));
4396            }
4397        };
4398
4399        // --- gather phase --------------------------------------
4400        // Step A: every segment_id this BTree index Cold-references.
4401        let mut referenced_ids: BTreeSet<u32> = BTreeSet::new();
4402        for (_key, locators) in map.iter() {
4403            for loc in locators {
4404                if let RowLocator::Cold { segment_id, .. } = loc {
4405                    referenced_ids.insert(*segment_id);
4406                }
4407            }
4408        }
4409        // Step B: keep only the small + still-active ones.
4410        let candidate_set: BTreeSet<u32> = referenced_ids
4411            .into_iter()
4412            .filter(|id| {
4413                self.cold_segments
4414                    .get(*id as usize)
4415                    .and_then(|s| s.as_deref())
4416                    .is_some_and(|s| (s.bytes().len() as u64) < target_segment_bytes)
4417            })
4418            .collect();
4419        if candidate_set.len() < 2 {
4420            return Ok(CompactReport {
4421                sources: Vec::new(),
4422                merged_segment_id: None,
4423                merged_segment_bytes: Vec::new(),
4424                merged_rows: 0,
4425                deleted_rows_pruned: 0,
4426                bytes_reclaimed_estimate: 0,
4427            });
4428        }
4429        // Step C: pre-count source rows for the deleted-pruned metric.
4430        let mut source_row_count: usize = 0;
4431        let mut source_byte_total: u64 = 0;
4432        for &id in &candidate_set {
4433            let seg = self.cold_segments[id as usize]
4434                .as_ref()
4435                .expect("candidate selected only when slot is Some");
4436            source_row_count = source_row_count.saturating_add(seg.meta().num_rows as usize);
4437            source_byte_total = source_byte_total.saturating_add(seg.bytes().len() as u64);
4438        }
4439        // Step D: collect (key, body) pairs from every live Cold
4440        // locator pointing at a candidate. dedupe by key — one
4441        // BTree key resolves to at most one cold payload (the
4442        // freezer + promote/shadow flow keeps Cold locators
4443        // unique per key).
4444        let mut collected: BTreeMap<u64, (Vec<u8>, IndexKey)> = BTreeMap::new();
4445        for (key, locators) in map.iter() {
4446            for loc in locators {
4447                let RowLocator::Cold { segment_id, .. } = loc else {
4448                    continue;
4449                };
4450                if !candidate_set.contains(segment_id) {
4451                    continue;
4452                }
4453                let u64_key = index_key_as_u64(key).ok_or_else(|| {
4454                    StorageError::Corrupt(format!(
4455                        "compact_cold_segments: index {index_name:?} has non-integer Cold key; \
4456                         cold tier requires IndexKey::Int (Text PK lands in v5.5+)"
4457                    ))
4458                })?;
4459                let seg = self.cold_segments[*segment_id as usize]
4460                    .as_ref()
4461                    .expect("candidate slot guaranteed Some above");
4462                let payload = seg.lookup(u64_key).ok_or_else(|| {
4463                    StorageError::Corrupt(format!(
4464                        "compact_cold_segments: BTree {index_name:?} points key={u64_key} \
4465                         at segment {segment_id} but the segment lookup missed"
4466                    ))
4467                })?;
4468                collected.insert(u64_key, (payload, key.clone()));
4469                break;
4470            }
4471        }
4472        let merged_rows = collected.len();
4473        let deleted_rows_pruned = source_row_count.saturating_sub(merged_rows);
4474
4475        // Step E: encode the merged segment. `BTreeMap<u64, _>`
4476        // iteration is ascending by key, which is what
4477        // `encode_segment` requires.
4478        let seg_rows: Vec<(u64, Vec<u8>)> = collected
4479            .iter()
4480            .map(|(k, (body, _))| (*k, body.clone()))
4481            .collect();
4482        let (seg_bytes, _meta) = encode_segment(seg_rows.into_iter(), 0.01, SEGMENT_PAGE_BYTES)
4483            .map_err(|e| StorageError::Corrupt(format!("compact_cold_segments: encode: {e}")))?;
4484        let merged_bytes_len = seg_bytes.len() as u64;
4485
4486        // --- atomic mutation phase ------------------------------
4487        let merged_segment_id = self
4488            .load_segment_bytes(seg_bytes.clone())
4489            .map_err(|e| StorageError::Corrupt(format!("compact_cold_segments: load: {e}")))?;
4490
4491        // Rewrite the BTree index: every Cold locator pointing at
4492        // a candidate source becomes a Cold locator pointing at
4493        // the merged segment. Use a flat collect-then-replace
4494        // pattern so we never hold a `&self` borrow across the
4495        // `&mut self` write.
4496        let entries: Vec<(IndexKey, Vec<RowLocator>)> = {
4497            let t = self
4498                .get(table_name)
4499                .expect("table existed at the start of this fn");
4500            let idx = t
4501                .indices
4502                .iter()
4503                .find(|i| i.name == index_name)
4504                .expect("index existed at the start of this fn");
4505            let IndexKind::BTree(map) = &idx.kind else {
4506                unreachable!("validated above");
4507            };
4508            map.iter().map(|(k, v)| (k.clone(), v.clone())).collect()
4509        };
4510        let t_mut = self
4511            .get_mut(table_name)
4512            .expect("table existed at the start of this fn");
4513        let idx_mut = t_mut
4514            .indices
4515            .iter_mut()
4516            .find(|i| i.name == index_name)
4517            .expect("index existed at the start of this fn");
4518        let IndexKind::BTree(map_mut) = &mut idx_mut.kind else {
4519            unreachable!("validated above");
4520        };
4521        for (key, locators) in entries {
4522            let mut new_locs: Vec<RowLocator> = Vec::with_capacity(locators.len());
4523            let mut changed = false;
4524            for loc in &locators {
4525                match *loc {
4526                    RowLocator::Cold {
4527                        segment_id,
4528                        page_offset: _,
4529                    } if candidate_set.contains(&segment_id) => {
4530                        let replacement = RowLocator::Cold {
4531                            segment_id: merged_segment_id,
4532                            page_offset: 0,
4533                        };
4534                        if !new_locs.contains(&replacement) {
4535                            new_locs.push(replacement);
4536                        }
4537                        changed = true;
4538                    }
4539                    other => new_locs.push(other),
4540                }
4541            }
4542            if changed {
4543                map_mut.insert_mut(key, new_locs);
4544            }
4545        }
4546
4547        // Tombstone every source slot. Last step — failures here
4548        // would leave the segment double-referenced in both
4549        // memory + manifest, but `tombstone_segment` only errors
4550        // on out-of-bounds, which we've already validated.
4551        for &id in &candidate_set {
4552            self.tombstone_segment(id)?;
4553        }
4554
4555        let bytes_reclaimed_estimate = source_byte_total.saturating_sub(merged_bytes_len);
4556        Ok(CompactReport {
4557            sources: candidate_set.into_iter().collect(),
4558            merged_segment_id: Some(merged_segment_id),
4559            merged_segment_bytes: seg_bytes,
4560            merged_rows,
4561            deleted_rows_pruned,
4562            bytes_reclaimed_estimate,
4563        })
4564    }
4565
4566    /// Internal helper: scan `(table, index)` for a `Cold` locator
4567    /// keyed by `key`. Returns `Ok(Some((segment_id, page_offset)))`
4568    /// when found, `Ok(None)` when the key has only hot entries
4569    /// or no entries at all, `Err` on the same input-validation
4570    /// errors as the public `promote_cold_row` / `shadow_cold_row`.
4571    fn find_cold_locator(
4572        &self,
4573        table_name: &str,
4574        index_name: &str,
4575        key: &IndexKey,
4576    ) -> Result<Option<(u32, u32)>, StorageError> {
4577        let t = self.get(table_name).ok_or_else(|| {
4578            StorageError::Corrupt(format!("find_cold_locator: table {table_name:?} not found"))
4579        })?;
4580        let idx = t
4581            .indices
4582            .iter()
4583            .find(|i| i.name == index_name)
4584            .ok_or_else(|| {
4585                StorageError::Corrupt(format!(
4586                    "find_cold_locator: index {index_name:?} not found on {table_name:?}"
4587                ))
4588            })?;
4589        if !matches!(idx.kind, IndexKind::BTree(_)) {
4590            return Err(StorageError::Corrupt(format!(
4591                "find_cold_locator: index {index_name:?} is NSW; promote-on-write only applies to BTree indices"
4592            )));
4593        }
4594        for loc in idx.lookup_eq(key) {
4595            if let RowLocator::Cold {
4596                segment_id,
4597                page_offset,
4598            } = *loc
4599            {
4600                return Ok(Some((segment_id, page_offset)));
4601            }
4602        }
4603        Ok(None)
4604    }
4605}
4606
4607/// Coerce an [`IndexKey`] to the `u64` that v5.1 cold-tier
4608/// segments use as their on-disk PK. Returns `None` for keys that
4609/// aren't representable as `u64` — Text PKs need a hash mapping
4610/// the segment writer baked in (deferred to v5.2+), Bool PKs are
4611/// almost never wide enough to be sharded into a cold tier.
4612fn index_key_as_u64(key: &IndexKey) -> Option<u64> {
4613    match key {
4614        // Reinterpret the i64 bit pattern as u64. Cold-tier segments
4615        // are sorted by this u64 view, so the chosen interpretation
4616        // only has to match between insert (bake_segment / freezer)
4617        // and lookup — using cast_unsigned keeps both sides honest
4618        // and silences clippy::cast_sign_loss.
4619        IndexKey::Int(n) => Some(n.cast_unsigned()),
4620        // Text / Bool / Uuid PKs aren't representable as u64 and so
4621        // can't participate in the u64-sorted cold-tier segment
4622        // PK layout. Same deferral story as Text — lookup falls
4623        // through the in-memory btree.
4624        IndexKey::Text(_) | IndexKey::Bool(_) | IndexKey::Uuid(_) => None,
4625    }
4626}
4627
4628#[derive(Debug, Clone, PartialEq, Eq)]
4629#[non_exhaustive]
4630pub enum StorageError {
4631    DuplicateTable {
4632        name: String,
4633    },
4634    TableNotFound {
4635        name: String,
4636    },
4637    ArityMismatch {
4638        expected: usize,
4639        actual: usize,
4640    },
4641    TypeMismatch {
4642        column: String,
4643        expected: DataType,
4644        actual: DataType,
4645        position: usize,
4646    },
4647    NullInNotNull {
4648        column: String,
4649    },
4650    /// Index with this name already exists on the table.
4651    DuplicateIndex {
4652        name: String,
4653    },
4654    /// Column referenced by an index doesn't exist on the table.
4655    ColumnNotFound {
4656        column: String,
4657    },
4658    /// On-disk format failed to parse — corrupted file, wrong magic, truncated
4659    /// payload, or unknown tag bytes.
4660    Corrupt(String),
4661    /// v6.0.4 — ALTER INDEX targeted an index name that doesn't
4662    /// exist on any table in this catalog.
4663    IndexNotFound {
4664        name: String,
4665    },
4666    /// v6.0.4 — operation requested isn't supported on this index
4667    /// kind / column type (e.g. ALTER INDEX REBUILD on a `BTree`
4668    /// index, or REBUILD WITH (encoding=…) on a non-vector column).
4669    Unsupported(String),
4670}
4671
4672impl fmt::Display for StorageError {
4673    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
4674        match self {
4675            Self::DuplicateTable { name } => write!(f, "table already exists: {name}"),
4676            Self::TableNotFound { name } => write!(f, "table not found: {name}"),
4677            Self::ArityMismatch { expected, actual } => write!(
4678                f,
4679                "row arity mismatch: expected {expected} columns, got {actual}"
4680            ),
4681            Self::TypeMismatch {
4682                column,
4683                expected,
4684                actual,
4685                position,
4686            } => write!(
4687                f,
4688                "type mismatch in column {column:?} (position {position}): expected {expected}, got {actual}"
4689            ),
4690            Self::NullInNotNull { column } => {
4691                write!(f, "NULL value in NOT NULL column {column:?}")
4692            }
4693            Self::DuplicateIndex { name } => write!(f, "index already exists: {name}"),
4694            Self::ColumnNotFound { column } => write!(f, "column not found: {column}"),
4695            Self::Corrupt(detail) => write!(f, "corrupt on-disk format: {detail}"),
4696            Self::IndexNotFound { name } => write!(f, "index not found: {name}"),
4697            Self::Unsupported(detail) => write!(f, "unsupported: {detail}"),
4698        }
4699    }
4700}
4701
4702impl ColumnSchema {
4703    pub fn new(name: impl Into<String>, ty: DataType, nullable: bool) -> Self {
4704        Self {
4705            name: name.into(),
4706            ty,
4707            nullable,
4708            default: None,
4709            runtime_default: None,
4710            auto_increment: false,
4711            user_enum_type: None,
4712            user_domain_type: None,
4713            on_update_runtime: None,
4714            collation: Collation::Binary,
4715            is_unsigned: false,
4716            inline_enum_variants: None,
4717            inline_set_variants: None,
4718            generated_stored_expr: None,
4719        }
4720    }
4721
4722    /// Builder-style helper to attach a default value to an otherwise
4723    /// plain column schema. Used by the engine when CREATE TABLE
4724    /// specifies `column TYPE DEFAULT <expr>`.
4725    #[must_use]
4726    pub fn with_default(mut self, default: Value<'static>) -> Self {
4727        self.default = Some(default);
4728        self
4729    }
4730
4731    /// v7.9.21 — builder for runtime-evaluated defaults
4732    /// (`DEFAULT now()`, `DEFAULT CURRENT_TIMESTAMP`, …).
4733    /// `expr` is the Expr's `Display` form, re-parsed by the
4734    /// engine at each INSERT.
4735    #[must_use]
4736    pub fn with_runtime_default(mut self, expr: impl Into<String>) -> Self {
4737        self.runtime_default = Some(expr.into());
4738        self
4739    }
4740
4741    /// Builder-style helper to mark a column as `AUTO_INCREMENT`.
4742    #[must_use]
4743    pub const fn with_auto_increment(mut self) -> Self {
4744        self.auto_increment = true;
4745        self
4746    }
4747}
4748
4749impl TableSchema {
4750    pub fn new(name: impl Into<String>, columns: Vec<ColumnSchema>) -> Self {
4751        Self {
4752            name: name.into(),
4753            columns,
4754            hot_tier_bytes: None,
4755            foreign_keys: Vec::new(),
4756            uniqueness_constraints: Vec::new(),
4757            checks: Vec::new(),
4758            partition_role: None,
4759        }
4760    }
4761}
4762
4763// =========================================================================
4764// Persistent binary format for the catalog.
4765//
4766// Layout (little-endian throughout):
4767//
4768//   [magic "SPGDB001" 8 bytes][version u8]
4769//   [table_count u32]
4770//   for each table:
4771//       [name_len u16][name bytes]
4772//       [col_count u16]
4773//       for each col:
4774//           [name_len u16][name bytes]
4775//           [type_tag u8 + optional payload]
4776//               1=Int 2=BigInt 3=Float 4=Text 5=Bool
4777//               6=Vector(u32 dim)
4778//               7=SmallInt
4779//               8=Varchar(u32 max)
4780//               9=Char(u32 size)
4781//               10=Numeric(u8 precision, u8 scale)
4782//               11=Date
4783//               12=Timestamp
4784//           [nullable u8]   0/1
4785//           [default_tag u8] 0=none 1=value (followed by [value_tag u8] + bytes)
4786//       [row_count u32]
4787//       for each row, for each col, one [value_tag u8] + value bytes:
4788//           tag 0 (Null)     → no body
4789//           tag 1 (Int)      → i32 LE
4790//           tag 2 (BigInt)   → i64 LE
4791//           tag 3 (Float)    → f64 LE
4792//           tag 4 (Text)     → u16 LE len + UTF-8 bytes
4793//           tag 5 (Bool)     → u8 0/1
4794//           tag 6 (Vector)   → u32 LE dim + dim×f32 LE
4795//           tag 7 (SmallInt) → i16 LE
4796//           tag 8 (Numeric)  → i128 LE (16 bytes) + u8 scale
4797//           tag 9 (Date)     → i32 LE (days since Unix epoch)
4798//           tag 10 (Timestamp) → i64 LE (microseconds since Unix epoch)
4799//
4800// Bumped to version 3 when NUMERIC was added; to version 4 when
4801// AUTO_INCREMENT (per-column flag) + NSW index `kind` byte landed;
4802// to version 5 when DATE / TIMESTAMP were added; to version 6 when
4803// NSW graph topology started travelling on disk (v2.7); to version 7
4804// when the NSW topology became multi-layer HNSW (v2.13); to version 8
4805// when row encoding switched to schema-driven dense layout (v3.0.2 —
4806// per-row NULL bitmap + per-column fixed-width body, no per-cell type
4807// tag).
4808// =========================================================================
4809
4810const FILE_MAGIC: &[u8; 8] = b"SPGDB001";
4811/// Current catalog snapshot format version emitted by [`Catalog::serialize`].
4812///
4813/// v9 (v5.2) extends v8 by serialising `BTree` index entries directly — every
4814/// `(IndexKey, Vec<RowLocator>)` pair travels on disk with the v5.1
4815/// `RowLocator::write_le` tag-prefixed codec. v8 `BTree` indices stored no
4816/// entries at all (the map was rebuilt from `Table::rows` on load); v9
4817/// preserves on-disk Cold locators so freezer-produced cold-tier index
4818/// entries survive a catalog snapshot round-trip. v8 readers are accepted
4819/// by version dispatch in [`Catalog::deserialize`] — every entry decodes
4820/// as `RowLocator::Hot(_)` via `add_index` rebuild, identical to v5.1
4821/// behaviour.
4822/// v6.7.2 — bumped from 10 to 11 to append per-table
4823/// `hot_tier_bytes: Option<u64>` after the per-table indices
4824/// section. v10 catalogs (v6.7.1) load with `hot_tier_bytes =
4825/// None` for every table (the deserialiser short-circuits when
4826/// version < 11). v11 snapshots written by a pre-v6.7.2 binary
4827/// fail loudly at the version check, matching the v6.1.2 /
4828/// v6.1.4 / v6.2.0 / v6.7.1 envelope-bump upgrade fences.
4829///
4830/// v6.8.0 — bumped from 11 to 12: per-index
4831/// `included_columns: Vec<u16>` appended at the tail of each
4832/// index payload. v11 (= v6.7.2) catalogs load with
4833/// `included_columns = Vec::new()` for every index — same
4834/// "older readers, append-only extension" pattern as the v6.7.2
4835/// hot_tier_bytes byte.
4836/// v7.13.0 — bumped from 22 to 23. mailrs round-5 G3 / G10.
4837/// Per-table appendix gains two new sections:
4838///   * `checks: Vec<String>` — CHECK predicate sources (Display
4839///     form of the AST Expr); re-parsed on INSERT/UPDATE to
4840///     enforce against candidate rows. Same persistence pattern
4841///     as `Index::partial_predicate`.
4842///   * Per `UniquenessConstraint`: trailing `nulls_not_distinct:
4843///     u8` flag for PG 15+ `UNIQUE NULLS NOT DISTINCT (cols)`
4844///     semantics.
4845/// v22 catalogs deserialise with empty `checks` and every UC
4846/// at `nulls_not_distinct = false`.
4847/// v24 introduces:
4848///   * Index kind tag 4 = trigram-GIN (`gin_trgm_ops`-flavoured
4849///     `USING gin` over a TEXT/VARCHAR column). Payload shape is
4850///     identical to tag-3 GIN (String → Vec<RowLocator>); the
4851///     keys are PG-compatible 3-byte trigram shingles instead of
4852///     tsvector lexemes. v23 catalogs deserialise unchanged — no
4853///     v23 writer ever emitted tag 4.
4854/// v25 introduces:
4855///   * Per `TriggerDef`: trailing `enabled: u8` flag (mailrs
4856///     round-9 A.2.b — `ALTER TABLE … { ENABLE | DISABLE }
4857///     TRIGGER …`). v24 catalogs deserialise with every trigger
4858///     `enabled = true`, matching pre-v7.16.1 behaviour.
4859/// v26 introduces (v7.17.0 Phase 1.1):
4860///   * Trailing SEQUENCE catalog block after triggers. Encoded
4861///     as `u32 count` followed by per-sequence:
4862///     `name`, `data_type: u8` (0=SmallInt,1=Int,2=BigInt),
4863///     `start i64`, `increment i64`, `min_value i64`,
4864///     `max_value i64`, `cache i64`, `cycle u8`,
4865///     `owned_by_tag u8` (0=NONE, 1=Column → `table`,`column`),
4866///     `last_value i64`, `is_called u8`. v25-and-below catalogs
4867///     deserialise with an empty sequences map.
4868/// v27 introduces (v7.17.0 Phase 1.2):
4869///   * Trailing VIEW catalog block after sequences. Encoded as
4870///     `u32 count` followed by per-view:
4871///     `name`, `column_count u16`, then column names, then
4872///     `body` long-string. v26-and-below catalogs deserialise
4873///     with an empty views map.
4874/// v28 introduces (v7.17.0 Phase 1.3):
4875///   * Trailing MATERIALIZED VIEW source registry block after
4876///     views. Encoded as `u32 count` followed by per-entry:
4877///     `name`, `body` long-string. The materialised rows live
4878///     as a regular Table of the same name (already covered by
4879///     the pre-existing tables block). v27-and-below catalogs
4880///     deserialise with an empty map.
4881/// v29 introduces (v7.17.0 Phase 1.4):
4882///   * Per-table user_enum_type appendix (after the CHECK
4883///     appendix). Layout: `u16 count` followed by per-binding
4884///     `[u16 col_pos][str enum_name]`. Only columns whose
4885///     `user_enum_type` is Some land here; the catalog stays
4886///     compact for the common no-enum case.
4887///   * Trailing ENUM types catalog block after materialized
4888///     views. Encoded as `u32 count` followed by per-entry:
4889///     `name`, `u16 label_count`, then `label_count` short
4890///     strings. v28-and-below catalogs deserialise with an
4891///     empty enum_types map and every column's
4892///     `user_enum_type = None`.
4893/// v30 introduces (v7.17.0 Phase 1.5):
4894///   * Per-table user_domain_type appendix (after the
4895///     user_enum_type appendix). Same shape as the enum one.
4896///   * Trailing DOMAIN types catalog block after the enum
4897///     block. Encoded as `u32 count` followed by per-entry:
4898///     `name`, `data_type` byte, `nullable u8`,
4899///     `default_present u8` + optional default string,
4900///     `u16 check_count` then `check_count` Display-form
4901///     CHECK strings. v29-and-below catalogs deserialise with
4902///     an empty domain_types map and `user_domain_type = None`.
4903/// v31 introduces (v7.17.0 Phase 1.6):
4904///   * Trailing user-schemas block after the DOMAIN block.
4905///     Encoded as `u32 count` followed by `count` schema-name
4906///     short strings. Built-in schemas (`public`, `pg_catalog`,
4907///     `information_schema`) are NOT serialised — they're
4908///     hardcoded in `is_builtin_schema`. v30-and-below catalogs
4909///     deserialise with an empty user-schemas set.
4910/// v32 introduces (v7.17.0 Phase 2.1):
4911///   * Per-table on_update_runtime appendix (after the
4912///     user_domain_type appendix). Layout: `u16 count` followed
4913///     by per-binding `[u16 col_pos][str expr_src]`. Only
4914///     columns whose `on_update_runtime` is Some land here;
4915///     the catalog stays compact when no MySQL-shaped table
4916///     uses the attribute. v31-and-below catalogs deserialise
4917///     with every column's `on_update_runtime = None`.
4918/// v33 introduces (v7.17.0 Phase 2.2):
4919///   * Index kind tag 5 = fulltext-GIN (MySQL `FULLTEXT KEY`
4920///     surface over a TEXT / VARCHAR column). Payload shape is
4921///     identical to tag-3 / tag-4 GIN (`String → Vec<RowLocator>`);
4922///     the keys are lower-cased word lexemes (same rule as
4923///     `to_tsvector('simple', text)`). v32 catalogs deserialise
4924///     unchanged — no v32 writer ever emitted tag 5, and FULLTEXT
4925///     KEY was silently dropped pre-v7.17 so no rebuild shim is
4926///     needed for round-tripped catalogs.
4927/// v34 introduces (v7.17.0 Phase 2.5):
4928///   * Per-table collation appendix (after the on_update_runtime
4929///     appendix). Sparse layout: only columns whose `collation`
4930///     is non-Binary land here. `u16 count` then per-binding
4931///     `[u16 col_pos][u8 collation_tag]` where the tag matches
4932///     `Collation::TAG_*`. Snapshots written by v33-and-below
4933///     readers deserialise every column with `collation =
4934///     Binary`, preserving the prior byte-wise compare
4935///     semantics. Unknown tags read back as Binary too — keeps
4936///     a forward-compat path if a future v35 adds variants
4937///     and someone rolls back to a v34 reader.
4938/// v35 introduces (v7.17.0 Phase 4.4):
4939///   * Per-table is_unsigned appendix (after the collation
4940///     appendix). Sparse layout: only `is_unsigned = true`
4941///     columns land. `u16 count` then per-binding `[u16 col_pos]`.
4942///     v34-and-below catalogs deserialise every column as
4943///     `is_unsigned = false`, preserving the prior silent-
4944///     accept behaviour for negative inserts on UNSIGNED columns.
4945/// v46 introduces (v7.23, mailrs round-14):
4946///   * Escaped short-string codec — `write_str` lengths >= 0xFFFF
4947///     emit `[u16 0xFFFF][u32 real_len]` so TEXT cells (mail bodies,
4948///     document text) above 64 KiB encode instead of panicking.
4949///     One-way upgrade: v45-and-below readers reject v46 catalogs
4950///     loudly via the version gate; v46 readers decode v45 catalogs
4951///     with the plain-u16 rules (0xFFFF is a legitimate length
4952///     there).
4953/// v47 introduces (v7.27, mailrs round-21):
4954///   * Escaped lengths for the REMAINING u16-length cell payloads —
4955///     BYTEA cells, TEXT[] elements, tsvector lexemes and tsquery
4956///     terms — the same `[u16 0xFFFF][u32 real_len]` escape v46
4957///     gave short strings. Round-14 fixed TEXT and missed these;
4958///     round-21 fired the BYTEA twin during a production migration.
4959///     One-way upgrade, same posture as v46.
4960/// v48 introduces (v7.37.5 β-P2, sentori cutover window):
4961///   * `INTERVAL` becomes a real column type. Catalog tag 34 in
4962///     `write_data_type`; per-row body is a fixed 16 bytes
4963///     (i64 micros + i32 days + i32 months, LE, PG-byte-equal
4964///     field order). The runtime-only days collapse is gone —
4965///     `'1 day'` and `'24 hours'` are stored distinctly. One-way
4966///     upgrade: v47 catalogs without INTERVAL columns deserialise
4967///     identically; v47 readers fed a v48 catalog that contains
4968///     INTERVAL hit the explicit "unknown data type tag: 34"
4969///     fence in `read_data_type`.
4970/// v49 introduces (v7.37.6-B, sentori Epic 2 P0):
4971///   * Per-table partition role appendix(declarative
4972///     `PARTITION BY RANGE` parent / range child / DEFAULT
4973///     child)。Layout, written **after** the inline_set_variants
4974///     appendix and **before** the per-table block close:
4975///       `[u8 role_tag]`
4976///         0 = `None`(普通表,后向兼容默认)
4977///         1 = `Parent`:  `[u8 kind_tag (0=Range)]`
4978///                        `[u16 key_col_count]` `(× u16 col_pos)`
4979///                        `[u16 tmpl_count]` `(× str source)`
4980///         2 = `Range`:   `[str parent_name]` `[Bound]` `[Bound]`
4981///         3 = `Default`: `[str parent_name]`
4982///     `PartitionBound` codec:
4983///       `[u8 bound_tag]` 0=MinValue 1=MaxValue 2=TimestampTz(`[i64 LE micros]`)
4984///     v48-and-below readers stop after the inline_set_variants
4985///     block — they don't see this appendix and deserialise every
4986///     table with `partition_role = None`. v49 writers always emit
4987///     `[0]` for plain tables, so the encoding stays one-byte-cheap.
4988/// v50 introduces (v7.37.7, sentori Epic 3 P1):
4989///   * Per-table `generated_stored_expr` appendix(stored generated
4990///     columns — `GENERATED ALWAYS AS (<expr>) STORED`)。Layout,
4991///     written **after** the partition_role appendix and before
4992///     the per-table block close:
4993///       `[u16 binding_count]`
4994///       `binding_count × { [u16 col_pos][str expr_source] }`
4995///     Sparse — only generated columns land here, so plain-shape
4996///     catalogs stay byte-for-byte identical save for the new
4997///     u16 zero count. v49-and-below readers stop after the
4998///     partition_role appendix; v50 readers default every column
4999///     to `generated_stored_expr = None` when this block is absent.
5000/// v51 introduces (v7.37.8, sentori Epic 5 P2):
5001///   * Per-index tag byte 6 = `GinJsonb`(real posting-list GIN
5002///     over a JSONB column). Payload shape mirrors tag-3 / 4 / 5:
5003///     `[u32 posting_list_count]` then `(str token, u32 locator_count,
5004///     locators …)` per posting list. Same `write_str` /
5005///     `RowLocator::write_le` codec as the rest of the GIN family.
5006///     v50 catalogs never wrote tag 6(the same DDL loaded as a
5007///     BTree fallback); v51 readers see tag 6 explicitly and dispatch
5008///     into `IndexKind::GinJsonb`.
5009/// v52 introduces (v7.37.42-T2 ζ-B composite + domain metasystem):
5010///   * Trailing COMPOSITE-types catalog block after the
5011///     user-schemas block. Encoded as `u32 count` followed by
5012///     per-entry: `name`, `u16 field_count`, then `field_count`
5013///     `[str field_name][data_type]` pairs (`write_data_type` is
5014///     reused). v51-and-below catalogs deserialise with an empty
5015///     composite_types map; v52 readers tolerate v51 catalogs by
5016///     stopping at the schema block (no composite block present
5017///     ⇒ empty map). Composite types are referenced by columns
5018///     via `ColumnSchema.user_composite_type`, mirroring the
5019///     `user_enum_type` / `user_domain_type` pattern. The block
5020///     lands here (not as a per-table appendix) so dropping the
5021///     composite type registers globally and DROP TYPE can find it
5022///     without a table scan.
5023const FILE_VERSION: u8 = 52;
5024/// Oldest format version [`Catalog::deserialize`] still accepts. v8 is the
5025/// v3.0.2 dense-row layout; pre-v8 catalogs require an offline migration.
5026const MIN_SUPPORTED_FILE_VERSION: u8 = 8;
5027
5028// IndexKey wire format (v9):
5029//   tag 0 = Int  → [i64 LE]
5030//   tag 1 = Text → [u16 LE len + UTF-8 bytes] (via write_str / read_str)
5031//   tag 2 = Bool → [u8 0/1]
5032const INDEX_KEY_TAG_INT: u8 = 0;
5033const INDEX_KEY_TAG_TEXT: u8 = 1;
5034const INDEX_KEY_TAG_BOOL: u8 = 2;
5035/// v7.17.0 — `IndexKey::Uuid([u8; 16])`. Body = raw 16 bytes
5036/// (RFC 4122 byte order). Persisted only in FILE_VERSION 36+
5037/// catalogs.
5038const INDEX_KEY_TAG_UUID: u8 = 3;
5039
5040impl Catalog {
5041    /// Serialize the whole catalog (schema + every row) into a self-contained
5042    /// byte buffer. Format is documented above the impl block.
5043    pub fn serialize(&self) -> Vec<u8> {
5044        let mut out = Vec::with_capacity(64);
5045        out.extend_from_slice(FILE_MAGIC);
5046        out.push(FILE_VERSION);
5047        write_u32(
5048            &mut out,
5049            u32::try_from(self.tables.len()).expect("≤ 4G tables"),
5050        );
5051        for t in &self.tables {
5052            write_str(&mut out, &t.schema.name);
5053            write_u16(
5054                &mut out,
5055                u16::try_from(t.schema.columns.len()).expect("≤ 65k columns/table"),
5056            );
5057            for c in &t.schema.columns {
5058                write_str(&mut out, &c.name);
5059                write_data_type(&mut out, c.ty);
5060                out.push(u8::from(c.nullable));
5061                match &c.default {
5062                    None => out.push(0),
5063                    Some(v) => {
5064                        out.push(1);
5065                        write_value(&mut out, v);
5066                    }
5067                }
5068                out.push(u8::from(c.auto_increment));
5069            }
5070            write_u32(
5071                &mut out,
5072                u32::try_from(t.rows.len()).expect("≤ 4G rows/table"),
5073            );
5074            // v3.0.2 dense row encoding (FILE_VERSION 8): per-row NULL
5075            // bitmap, then tightly-packed bodies. Identical wire format
5076            // as before — extracted into `encode_row_body_dense` so cold-
5077            // tier segments (v5.1+) can share the encoding.
5078            for row in &t.rows {
5079                out.extend_from_slice(&encode_row_body_dense(row, &t.schema));
5080            }
5081            // Index definitions. Per-index payload:
5082            //   [name][col_pos u16][kind u8]
5083            //     kind 0 = B-tree           (no params — rebuilt on load)
5084            //     kind 1 = NSW graph        (u16 M + serialized graph)
5085            // For NSW the graph topology travels on disk so startup
5086            // doesn't re-run the O(n²M) rebuild — see v2.7 notes.
5087            write_u16(
5088                &mut out,
5089                u16::try_from(t.indices.len()).expect("≤ 65k indices/table"),
5090            );
5091            for idx in &t.indices {
5092                write_str(&mut out, &idx.name);
5093                write_u16(
5094                    &mut out,
5095                    u16::try_from(idx.column_position).expect("≤ 65k columns/table"),
5096                );
5097                match &idx.kind {
5098                    IndexKind::BTree(map) => {
5099                        out.push(0);
5100                        // v9: serialise the full PB map. Each entry's
5101                        // RowLocator list travels with the tag-prefixed
5102                        // codec from `row_locator::write_le`, so freezer-
5103                        // produced Cold locators survive a snapshot
5104                        // round-trip. v8 BTree wrote nothing here and
5105                        // rebuilt from rows — v9 readers tolerate v8 by
5106                        // version dispatch in `Catalog::deserialize`.
5107                        write_u32(
5108                            &mut out,
5109                            u32::try_from(map.len()).expect("≤ 4G index entries/index"),
5110                        );
5111                        for (key, locators) in map {
5112                            write_index_key(&mut out, key);
5113                            write_u32(
5114                                &mut out,
5115                                u32::try_from(locators.len()).expect("≤ 4G locators/key"),
5116                            );
5117                            for loc in locators {
5118                                loc.write_le(&mut out);
5119                            }
5120                        }
5121                    }
5122                    IndexKind::Nsw(g) => {
5123                        out.push(1);
5124                        write_u16(&mut out, u16::try_from(g.m).expect("≤ 65k NSW neighbours"));
5125                        write_nsw_graph(&mut out, g);
5126                    }
5127                    IndexKind::Brin { column_type } => {
5128                        // v6.7.1 — tag byte 2 = BRIN. Payload is the
5129                        // column type code (1 byte mapping to the
5130                        // shared DataType numeric encoding); no
5131                        // further data — BRIN summaries live in
5132                        // cold segments, not the catalog.
5133                        out.push(2);
5134                        write_data_type(&mut out, *column_type);
5135                    }
5136                    IndexKind::Gin(map) => {
5137                        // v7.12.3 — tag byte 3 = GIN. Payload mirrors
5138                        // the BTree encoding but with String (lexeme
5139                        // word) keys instead of IndexKey. Tag-prefixed
5140                        // RowLocator codec so freezer-produced Cold
5141                        // locators survive snapshot round-trip.
5142                        // FILE_VERSION 21+; v20 catalogs never wrote a
5143                        // GIN index (the AM degraded to BTree fallback
5144                        // pre-v7.12.3), so no migration shim is needed.
5145                        out.push(3);
5146                        write_u32(
5147                            &mut out,
5148                            u32::try_from(map.len()).expect("≤ 4G GIN posting lists"),
5149                        );
5150                        for (word, locators) in map {
5151                            write_str(&mut out, word);
5152                            write_u32(
5153                                &mut out,
5154                                u32::try_from(locators.len()).expect("≤ 4G locators/posting list"),
5155                            );
5156                            for loc in locators {
5157                                loc.write_le(&mut out);
5158                            }
5159                        }
5160                    }
5161                    IndexKind::GinTrgm(map) => {
5162                        // v7.15.0 — tag byte 4 = GinTrgm
5163                        // (`gin_trgm_ops` GIN over a TEXT column).
5164                        // Payload shape is identical to tag-3 GIN —
5165                        // `String → Vec<RowLocator>` posting lists.
5166                        // The String keys are 3-byte trigrams instead
5167                        // of tsvector lexemes; the deserializer
5168                        // dispatches on the tag, not the key shape.
5169                        // FILE_VERSION 24+; v23 catalogs never wrote
5170                        // a trigram-GIN.
5171                        out.push(4);
5172                        write_u32(
5173                            &mut out,
5174                            u32::try_from(map.len()).expect("≤ 4G trigram-GIN posting lists"),
5175                        );
5176                        for (tri, locators) in map {
5177                            write_str(&mut out, tri);
5178                            write_u32(
5179                                &mut out,
5180                                u32::try_from(locators.len()).expect("≤ 4G locators/posting list"),
5181                            );
5182                            for loc in locators {
5183                                loc.write_le(&mut out);
5184                            }
5185                        }
5186                    }
5187                    IndexKind::GinFulltext(map) => {
5188                        // v7.17.0 Phase 2.2 — tag byte 5 =
5189                        // GinFulltext (MySQL `FULLTEXT KEY` GIN
5190                        // over a TEXT/VARCHAR column). Payload
5191                        // shape mirrors tag-3 / tag-4 GIN —
5192                        // `String → Vec<RowLocator>` posting
5193                        // lists keyed by lower-cased word
5194                        // lexemes. FILE_VERSION 33+; v32 catalogs
5195                        // never wrote a fulltext-GIN (FULLTEXT
5196                        // KEY was silently dropped pre-v7.17).
5197                        out.push(5);
5198                        write_u32(
5199                            &mut out,
5200                            u32::try_from(map.len()).expect("≤ 4G fulltext-GIN posting lists"),
5201                        );
5202                        for (lex, locators) in map {
5203                            write_str(&mut out, lex);
5204                            write_u32(
5205                                &mut out,
5206                                u32::try_from(locators.len()).expect("≤ 4G locators/posting list"),
5207                            );
5208                            for loc in locators {
5209                                loc.write_le(&mut out);
5210                            }
5211                        }
5212                    }
5213                    IndexKind::GinJsonb(map) => {
5214                        // v7.37.8 — tag byte 6 = GinJsonb
5215                        // (real posting-list GIN over a JSONB
5216                        // column; sentori Epic 5 P2). Payload
5217                        // shape mirrors tag-3 / 4 / 5 — keys are
5218                        // the canonical `(path, leaf)` tokens
5219                        // from `jsonb_gin::extract_tokens`.
5220                        // FILE_VERSION 51+; v50 catalogs never
5221                        // wrote a JSONB-GIN (the same DDL loaded
5222                        // as a BTree fallback).
5223                        out.push(6);
5224                        write_u32(
5225                            &mut out,
5226                            u32::try_from(map.len()).expect("≤ 4G JSONB-GIN posting lists"),
5227                        );
5228                        for (token, locators) in map {
5229                            write_str(&mut out, token);
5230                            write_u32(
5231                                &mut out,
5232                                u32::try_from(locators.len()).expect("≤ 4G locators/posting list"),
5233                            );
5234                            for loc in locators {
5235                                loc.write_le(&mut out);
5236                            }
5237                        }
5238                    }
5239                }
5240                // v6.8.0 — included_columns appendix per index.
5241                // Layout: [u16 num_included][num × u16 column_position].
5242                // v11 readers stop before this u16 (deserialise loop
5243                // gated on version >= 12); v12+ readers always
5244                // consume it. Empty Vec serialises as a bare 0u16.
5245                write_u16(
5246                    &mut out,
5247                    u16::try_from(idx.included_columns.len()).expect("≤ 65k INCLUDE columns/index"),
5248                );
5249                for col_pos in &idx.included_columns {
5250                    write_u16(
5251                        &mut out,
5252                        u16::try_from(*col_pos).expect("≤ 65k columns/table"),
5253                    );
5254                }
5255                // v6.8.1 — partial_predicate appendix per index.
5256                // Layout: [u8 has_pred][u16 LE len][bytes (if has_pred)].
5257                // Same v12 gate as included_columns.
5258                match &idx.partial_predicate {
5259                    None => out.push(0),
5260                    Some(pred) => {
5261                        out.push(1);
5262                        write_str(&mut out, pred);
5263                    }
5264                }
5265                // v6.8.2 — expression appendix. Same shape as
5266                // partial_predicate.
5267                match &idx.expression {
5268                    None => out.push(0),
5269                    Some(expr) => {
5270                        out.push(1);
5271                        write_str(&mut out, expr);
5272                    }
5273                }
5274                // v7.9.29 — is_unique appendix (FILE_VERSION 16+).
5275                // Single byte 0/1. v15-and-below readers stop before
5276                // this byte; v16 readers always consume it. mailrs K1.
5277                out.push(u8::from(idx.is_unique));
5278                // v7.9.29 — extra_column_positions appendix.
5279                // Layout: [u16 count][count × u16 column_position].
5280                write_u16(
5281                    &mut out,
5282                    u16::try_from(idx.extra_column_positions.len())
5283                        .expect("≤ 65k extra cols / index"),
5284                );
5285                for cp in &idx.extra_column_positions {
5286                    write_u16(&mut out, u16::try_from(*cp).expect("≤ 65k columns/table"));
5287                }
5288            }
5289            // v6.7.2 — per-table hot_tier_bytes Option<u64>.
5290            // Layout: [u8 has_value][u64 LE value (if has_value)].
5291            // v10 readers stop before this byte (deserialise loop
5292            // gated on version >= 11); v11+ readers always
5293            // consume it.
5294            match t.schema.hot_tier_bytes {
5295                None => out.push(0),
5296                Some(n) => {
5297                    out.push(1);
5298                    out.extend_from_slice(&n.to_le_bytes());
5299                }
5300            }
5301            // v7.6.1 — FOREIGN KEY appendix (catalog FILE_VERSION 13+).
5302            // Layout: [u16 LE fk_count]
5303            //   per fk:
5304            //     [u8 has_name] [str name (if has_name)]
5305            //     [u16 LE local_arity] [u16 LE local_pos]*arity
5306            //     [str parent_table]
5307            //     [u16 LE parent_arity] [u16 LE parent_pos]*arity
5308            //     [u8 on_delete_tag] [u8 on_update_tag]
5309            // Older catalogs (v12 and below) skip this block entirely;
5310            // their reader stops before this byte.
5311            write_u16(
5312                &mut out,
5313                u16::try_from(t.schema.foreign_keys.len()).expect("≤ 65k FKs/table"),
5314            );
5315            for fk in &t.schema.foreign_keys {
5316                match &fk.name {
5317                    None => out.push(0),
5318                    Some(n) => {
5319                        out.push(1);
5320                        write_str(&mut out, n);
5321                    }
5322                }
5323                write_u16(
5324                    &mut out,
5325                    u16::try_from(fk.local_columns.len()).expect("≤ 65k FK columns"),
5326                );
5327                for &p in &fk.local_columns {
5328                    write_u16(&mut out, u16::try_from(p).expect("≤ 65k columns/table"));
5329                }
5330                write_str(&mut out, &fk.parent_table);
5331                write_u16(
5332                    &mut out,
5333                    u16::try_from(fk.parent_columns.len()).expect("≤ 65k FK parent columns"),
5334                );
5335                for &p in &fk.parent_columns {
5336                    write_u16(&mut out, u16::try_from(p).expect("≤ 65k columns/table"));
5337                }
5338                out.push(fk.on_delete.tag());
5339                out.push(fk.on_update.tag());
5340            }
5341            // v7.9.19 — UniquenessConstraint appendix (catalog
5342            // FILE_VERSION 15+). Layout per table after the FK
5343            // block:
5344            //   [u16 count]
5345            //     per constraint:
5346            //       [u8 is_primary_key]
5347            //       [u16 arity][u16 col_pos]*arity
5348            // Older catalogs (v14 and below) skip this block.
5349            write_u16(
5350                &mut out,
5351                u16::try_from(t.schema.uniqueness_constraints.len())
5352                    .expect("≤ 65k uniqueness constraints/table"),
5353            );
5354            for uc in &t.schema.uniqueness_constraints {
5355                out.push(u8::from(uc.is_primary_key));
5356                write_u16(
5357                    &mut out,
5358                    u16::try_from(uc.columns.len()).expect("≤ 65k cols in uniqueness constraint"),
5359                );
5360                for &p in &uc.columns {
5361                    write_u16(&mut out, u16::try_from(p).expect("≤ 65k columns/table"));
5362                }
5363                // v7.13.0 — `nulls_not_distinct` flag
5364                // (FILE_VERSION 23+). Always written by writers at
5365                // version 23+; deserialise gates on `version >= 23`
5366                // so v22-and-below catalogs round-trip cleanly.
5367                out.push(u8::from(uc.nulls_not_distinct));
5368            }
5369            // v7.9.21 — runtime_default appendix per table.
5370            // Layout: [u16 count] then for each:
5371            //   [u16 col_pos][str expr]
5372            // Only columns whose runtime_default is Some land here;
5373            // catalog stays compact for the common literal-default
5374            // case.
5375            let mut rt_defaults: Vec<(usize, &str)> = Vec::new();
5376            for (i, c) in t.schema.columns.iter().enumerate() {
5377                if let Some(e) = &c.runtime_default {
5378                    rt_defaults.push((i, e.as_str()));
5379                }
5380            }
5381            write_u16(
5382                &mut out,
5383                u16::try_from(rt_defaults.len()).expect("≤ 65k runtime defaults/table"),
5384            );
5385            for (pos, expr) in rt_defaults {
5386                write_u16(&mut out, u16::try_from(pos).expect("≤ 65k columns/table"));
5387                write_str(&mut out, expr);
5388            }
5389            // v7.13.0 — CHECK constraint appendix per table.
5390            // Layout: [u16 count] then `count` Display-form
5391            // expression strings. Re-parsed on every INSERT/UPDATE
5392            // by the engine. FILE_VERSION 23+ only; v22 readers
5393            // never reach this block because the writer also moves
5394            // to v23 in lock-step.
5395            write_u16(
5396                &mut out,
5397                u16::try_from(t.schema.checks.len()).expect("≤ 65k CHECK constraints/table"),
5398            );
5399            for c in &t.schema.checks {
5400                write_str(&mut out, c.as_str());
5401            }
5402            // v7.17.0 Phase 1.4 — per-table user_enum_type
5403            // appendix. Layout: [u16 count] then
5404            // [u16 col_pos][str enum_name] per binding. Only
5405            // columns whose user_enum_type is Some land here.
5406            let mut enum_bindings: Vec<(usize, &str)> = Vec::new();
5407            for (i, c) in t.schema.columns.iter().enumerate() {
5408                if let Some(e) = &c.user_enum_type {
5409                    enum_bindings.push((i, e.as_str()));
5410                }
5411            }
5412            write_u16(
5413                &mut out,
5414                u16::try_from(enum_bindings.len()).expect("≤ 65k enum-typed columns/table"),
5415            );
5416            for (pos, ename) in enum_bindings {
5417                write_u16(&mut out, u16::try_from(pos).expect("≤ 65k columns/table"));
5418                write_str(&mut out, ename);
5419            }
5420            // v7.17.0 Phase 1.5 — per-table user_domain_type
5421            // appendix. Same layout as the enum one. v29-and-
5422            // below readers stop after the enum appendix.
5423            let mut domain_bindings: Vec<(usize, &str)> = Vec::new();
5424            for (i, c) in t.schema.columns.iter().enumerate() {
5425                if let Some(d) = &c.user_domain_type {
5426                    domain_bindings.push((i, d.as_str()));
5427                }
5428            }
5429            write_u16(
5430                &mut out,
5431                u16::try_from(domain_bindings.len()).expect("≤ 65k domain-typed columns/table"),
5432            );
5433            for (pos, dname) in domain_bindings {
5434                write_u16(&mut out, u16::try_from(pos).expect("≤ 65k columns/table"));
5435                write_str(&mut out, dname);
5436            }
5437            // v7.17.0 Phase 2.1 — per-table on_update_runtime
5438            // appendix. Sparse: only ON UPDATE-bound columns.
5439            let mut on_update_bindings: Vec<(usize, &str)> = Vec::new();
5440            for (i, c) in t.schema.columns.iter().enumerate() {
5441                if let Some(e) = &c.on_update_runtime {
5442                    on_update_bindings.push((i, e.as_str()));
5443                }
5444            }
5445            write_u16(
5446                &mut out,
5447                u16::try_from(on_update_bindings.len()).expect("≤ 65k ON UPDATE columns/table"),
5448            );
5449            for (pos, expr_src) in on_update_bindings {
5450                write_u16(&mut out, u16::try_from(pos).expect("≤ 65k columns/table"));
5451                write_str(&mut out, expr_src);
5452            }
5453            // v7.17.0 Phase 2.5 — per-table collation appendix.
5454            // Sparse: only non-Binary columns land. Layout:
5455            // `[u16 count][u16 col_pos][u8 tag] × count`.
5456            let mut coll_bindings: Vec<(usize, u8)> = Vec::new();
5457            for (i, c) in t.schema.columns.iter().enumerate() {
5458                let tag = match c.collation {
5459                    Collation::Binary => continue,
5460                    Collation::CaseInsensitive => Collation::TAG_CASE_INSENSITIVE,
5461                };
5462                coll_bindings.push((i, tag));
5463            }
5464            write_u16(
5465                &mut out,
5466                u16::try_from(coll_bindings.len()).expect("≤ 65k collation bindings/table"),
5467            );
5468            for (pos, tag) in coll_bindings {
5469                write_u16(&mut out, u16::try_from(pos).expect("≤ 65k columns/table"));
5470                out.push(tag);
5471            }
5472            // v7.17.0 Phase 4.4 — per-table is_unsigned appendix.
5473            // Sparse: only UNSIGNED columns land. Layout:
5474            // `[u16 count][u16 col_pos] × count`.
5475            let mut unsigned_bindings: Vec<usize> = Vec::new();
5476            for (i, c) in t.schema.columns.iter().enumerate() {
5477                if c.is_unsigned {
5478                    unsigned_bindings.push(i);
5479                }
5480            }
5481            write_u16(
5482                &mut out,
5483                u16::try_from(unsigned_bindings.len()).expect("≤ 65k UNSIGNED columns/table"),
5484            );
5485            for pos in unsigned_bindings {
5486                write_u16(&mut out, u16::try_from(pos).expect("≤ 65k columns/table"));
5487            }
5488            // v7.17.0 Phase 3.P0-36 — per-table inline_enum_variants
5489            // appendix. Sparse: only ENUM columns land. Layout:
5490            // `[u16 count] then per binding [u16 col_pos]
5491            // [u16 variant_count] then variant strings`.
5492            // FILE_VERSION 41+; v40 readers never reach this block.
5493            let mut enum_inline_bindings: Vec<(usize, &[String])> = Vec::new();
5494            for (i, c) in t.schema.columns.iter().enumerate() {
5495                if let Some(vs) = &c.inline_enum_variants {
5496                    enum_inline_bindings.push((i, vs.as_slice()));
5497                }
5498            }
5499            write_u16(
5500                &mut out,
5501                u16::try_from(enum_inline_bindings.len()).expect("≤ 65k inline-ENUM columns/table"),
5502            );
5503            for (pos, variants) in enum_inline_bindings {
5504                write_u16(&mut out, u16::try_from(pos).expect("≤ 65k columns/table"));
5505                write_u16(
5506                    &mut out,
5507                    u16::try_from(variants.len()).expect("≤ 65k variants/ENUM"),
5508                );
5509                for v in variants {
5510                    write_str(&mut out, v.as_str());
5511                }
5512            }
5513            // v7.17.0 Phase 3.P0-37 — per-table inline_set_variants
5514            // appendix. Same layout as the inline ENUM block.
5515            // FILE_VERSION 42+; v41 readers never reach this block.
5516            let mut set_inline_bindings: Vec<(usize, &[String])> = Vec::new();
5517            for (i, c) in t.schema.columns.iter().enumerate() {
5518                if let Some(vs) = &c.inline_set_variants {
5519                    set_inline_bindings.push((i, vs.as_slice()));
5520                }
5521            }
5522            write_u16(
5523                &mut out,
5524                u16::try_from(set_inline_bindings.len()).expect("≤ 65k inline-SET columns/table"),
5525            );
5526            for (pos, variants) in set_inline_bindings {
5527                write_u16(&mut out, u16::try_from(pos).expect("≤ 65k columns/table"));
5528                write_u16(
5529                    &mut out,
5530                    u16::try_from(variants.len()).expect("≤ 65k variants/SET"),
5531                );
5532                for v in variants {
5533                    write_str(&mut out, v.as_str());
5534                }
5535            }
5536            // v7.37.6-B — partition role appendix(FILE_VERSION 49+)。
5537            // Layout 详见 FILE_VERSION 49 docstring。普通表 = 单字节 0。
5538            write_partition_role(&mut out, t.schema.partition_role.as_ref());
5539            // v7.37.7 — per-table generated_stored_expr appendix
5540            // (FILE_VERSION 50+). Sparse: only columns whose
5541            // generated_stored_expr is Some land here.
5542            let mut gen_bindings: Vec<(usize, &str)> = Vec::new();
5543            for (i, c) in t.schema.columns.iter().enumerate() {
5544                if let Some(src) = &c.generated_stored_expr {
5545                    gen_bindings.push((i, src.as_str()));
5546                }
5547            }
5548            write_u16(
5549                &mut out,
5550                u16::try_from(gen_bindings.len()).expect("≤ 65k GENERATED STORED columns/table"),
5551            );
5552            for (pos, src) in gen_bindings {
5553                write_u16(&mut out, u16::try_from(pos).expect("≤ 65k columns/table"));
5554                write_str(&mut out, src);
5555            }
5556        }
5557        // v7.12.4 — catalog-wide appendix: user-defined functions
5558        // then triggers. FILE_VERSION 22+ only. v21 and earlier
5559        // readers stop after the last table; v22 readers always
5560        // consume two `u32` counts (possibly zero).
5561        //
5562        // Function entry layout:
5563        //   [str name] [str args_repr] [str returns]
5564        //   [str language] [str body]
5565        // Trigger entry layout:
5566        //   [str name] [str table] [str timing]
5567        //   [u16 event_count] (event_count × str)
5568        //   [str for_each] [str function]
5569        write_u32(
5570            &mut out,
5571            u32::try_from(self.functions.len()).expect("≤ 4G functions"),
5572        );
5573        for fd in self.functions.values() {
5574            write_str(&mut out, &fd.name);
5575            write_str(&mut out, &fd.args_repr);
5576            write_str(&mut out, &fd.returns);
5577            write_str(&mut out, &fd.language);
5578            write_str_long(&mut out, &fd.body);
5579        }
5580        write_u32(
5581            &mut out,
5582            u32::try_from(self.triggers.len()).expect("≤ 4G triggers"),
5583        );
5584        for td in &self.triggers {
5585            write_str(&mut out, &td.name);
5586            write_str(&mut out, &td.table);
5587            write_str(&mut out, &td.timing);
5588            write_u16(
5589                &mut out,
5590                u16::try_from(td.events.len()).expect("≤ 65k events / trigger"),
5591            );
5592            for ev in &td.events {
5593                write_str(&mut out, ev);
5594            }
5595            write_str(&mut out, &td.for_each);
5596            write_str(&mut out, &td.function);
5597            // v7.13.0 — `UPDATE OF cols` filter
5598            // (FILE_VERSION 23+). v22 readers omit; v23 writers
5599            // always emit (possibly zero).
5600            write_u16(
5601                &mut out,
5602                u16::try_from(td.update_columns.len()).expect("≤ 65k cols / trigger"),
5603            );
5604            for c in &td.update_columns {
5605                write_str(&mut out, c);
5606            }
5607            // v7.16.1 — TriggerDef.enabled (FILE_VERSION 25+).
5608            out.push(u8::from(td.enabled));
5609        }
5610        // v7.17.0 Phase 1.1 — SEQUENCE catalog block (FILE_VERSION 26+).
5611        write_u32(
5612            &mut out,
5613            u32::try_from(self.sequences.len()).expect("≤ 4G sequences"),
5614        );
5615        for seq in self.sequences.values() {
5616            write_str(&mut out, &seq.name);
5617            out.push(match seq.data_type {
5618                SequenceDataType::SmallInt => 0,
5619                SequenceDataType::Int => 1,
5620                SequenceDataType::BigInt => 2,
5621            });
5622            out.extend_from_slice(&seq.start.to_le_bytes());
5623            out.extend_from_slice(&seq.increment.to_le_bytes());
5624            out.extend_from_slice(&seq.min_value.to_le_bytes());
5625            out.extend_from_slice(&seq.max_value.to_le_bytes());
5626            out.extend_from_slice(&seq.cache.to_le_bytes());
5627            out.push(u8::from(seq.cycle));
5628            match &seq.owned_by {
5629                None => out.push(0),
5630                Some((table, column)) => {
5631                    out.push(1);
5632                    write_str(&mut out, table);
5633                    write_str(&mut out, column);
5634                }
5635            }
5636            out.extend_from_slice(&seq.last_value.to_le_bytes());
5637            out.push(u8::from(seq.is_called));
5638        }
5639        // v7.17.0 Phase 1.2 — VIEW catalog block (FILE_VERSION 27+).
5640        write_u32(
5641            &mut out,
5642            u32::try_from(self.views.len()).expect("≤ 4G views"),
5643        );
5644        for view in self.views.values() {
5645            write_str(&mut out, &view.name);
5646            write_u16(
5647                &mut out,
5648                u16::try_from(view.columns.len()).expect("≤ 65k cols / view"),
5649            );
5650            for c in &view.columns {
5651                write_str(&mut out, c);
5652            }
5653            write_str_long(&mut out, &view.body);
5654        }
5655        // v7.17.0 Phase 1.3 — MATERIALIZED VIEW source registry
5656        // (FILE_VERSION 28+). The backing rows live as a regular
5657        // table of the same name already in the tables block.
5658        write_u32(
5659            &mut out,
5660            u32::try_from(self.materialized_views.len()).expect("≤ 4G materialized views"),
5661        );
5662        for (name, body) in &self.materialized_views {
5663            write_str(&mut out, name);
5664            write_str_long(&mut out, body);
5665        }
5666        // v7.17.0 Phase 1.4 — ENUM types catalog block
5667        // (FILE_VERSION 29+).
5668        write_u32(
5669            &mut out,
5670            u32::try_from(self.enum_types.len()).expect("≤ 4G enum types"),
5671        );
5672        for e in self.enum_types.values() {
5673            write_str(&mut out, &e.name);
5674            write_u16(
5675                &mut out,
5676                u16::try_from(e.labels.len()).expect("≤ 65k labels / enum"),
5677            );
5678            for l in &e.labels {
5679                write_str(&mut out, l);
5680            }
5681        }
5682        // v7.17.0 Phase 1.5 — DOMAIN types catalog block
5683        // (FILE_VERSION 30+).
5684        write_u32(
5685            &mut out,
5686            u32::try_from(self.domain_types.len()).expect("≤ 4G domain types"),
5687        );
5688        for d in self.domain_types.values() {
5689            write_str(&mut out, &d.name);
5690            write_data_type(&mut out, d.base_type);
5691            out.push(u8::from(d.nullable));
5692            match &d.default {
5693                None => out.push(0),
5694                Some(s) => {
5695                    out.push(1);
5696                    write_str(&mut out, s);
5697                }
5698            }
5699            write_u16(
5700                &mut out,
5701                u16::try_from(d.checks.len()).expect("≤ 65k CHECKs / domain"),
5702            );
5703            for c in &d.checks {
5704                write_str(&mut out, c);
5705            }
5706        }
5707        // v7.17.0 Phase 1.6 — user-schemas registry
5708        // (FILE_VERSION 31+). Built-ins are hardcoded in
5709        // `is_builtin_schema` and not persisted.
5710        write_u32(
5711            &mut out,
5712            u32::try_from(self.schemas.len()).expect("≤ 4G schemas"),
5713        );
5714        for name in &self.schemas {
5715            write_str(&mut out, name);
5716        }
5717        // v7.37.42-T2 ζ-B — COMPOSITE types catalog block
5718        // (FILE_VERSION 52+). Each entry: name, u16 field_count,
5719        // then field_count `[str field_name][data_type]` pairs.
5720        write_u32(
5721            &mut out,
5722            u32::try_from(self.composite_types.len()).expect("≤ 4G composite types"),
5723        );
5724        for c in self.composite_types.values() {
5725            write_str(&mut out, &c.name);
5726            write_u16(
5727                &mut out,
5728                u16::try_from(c.fields.len()).expect("≤ 65k fields / composite"),
5729            );
5730            for (fname, fty) in &c.fields {
5731                write_str(&mut out, fname);
5732                write_data_type(&mut out, *fty);
5733            }
5734        }
5735        out
5736    }
5737
5738    /// Deserialize a previously-serialized catalog. Rejects bad magic, version
5739    /// mismatch, unknown tags, truncation, and trailing bytes.
5740    pub fn deserialize(buf: &[u8]) -> Result<Self, StorageError> {
5741        let mut cur = Cursor::new(buf);
5742        let magic = cur.take(8)?;
5743        if magic != FILE_MAGIC {
5744            return Err(StorageError::Corrupt(format!(
5745                "bad magic: expected SPGDB001, got {magic:?}"
5746            )));
5747        }
5748        let version = cur.read_u8()?;
5749        if !(MIN_SUPPORTED_FILE_VERSION..=FILE_VERSION).contains(&version) {
5750            return Err(StorageError::Corrupt(format!(
5751                "unsupported file version: {version} (supported: {MIN_SUPPORTED_FILE_VERSION}..={FILE_VERSION})"
5752            )));
5753        }
5754        // v7.23/v7.27 — escape decoding is version-gated (see
5755        // STR_LEN_ESCAPE / Cursor::codec_version).
5756        cur.codec_version = version;
5757        let table_count = cur.read_u32()? as usize;
5758        let mut cat = Self::new();
5759        for _ in 0..table_count {
5760            deserialize_table(&mut cur, &mut cat, version)?;
5761        }
5762        // v7.12.4 — catalog-wide function + trigger appendix.
5763        // FILE_VERSION 22+ only; v21 and earlier catalogs stop
5764        // after the last table.
5765        if version >= 22 {
5766            let fn_count = cur.read_u32()? as usize;
5767            for _ in 0..fn_count {
5768                let name = cur.read_str()?;
5769                let args_repr = cur.read_str()?;
5770                let returns = cur.read_str()?;
5771                let language = cur.read_str()?;
5772                let body = cur.read_str_long()?;
5773                cat.functions.insert(
5774                    name.clone(),
5775                    FunctionDef {
5776                        name,
5777                        args_repr,
5778                        returns,
5779                        language,
5780                        body,
5781                    },
5782                );
5783            }
5784            let trg_count = cur.read_u32()? as usize;
5785            for _ in 0..trg_count {
5786                let name = cur.read_str()?;
5787                let table = cur.read_str()?;
5788                let timing = cur.read_str()?;
5789                let ev_count = cur.read_u16()? as usize;
5790                let mut events = Vec::with_capacity(ev_count);
5791                for _ in 0..ev_count {
5792                    events.push(cur.read_str()?);
5793                }
5794                let for_each = cur.read_str()?;
5795                let function = cur.read_str()?;
5796                // v7.13.0 — trailing `UPDATE OF cols` filter
5797                // (FILE_VERSION 23+ only; v22 catalogs omit and
5798                // deserialise with an empty vec).
5799                let update_columns = if version >= 23 {
5800                    let n = cur.read_u16()? as usize;
5801                    let mut cols = Vec::with_capacity(n);
5802                    for _ in 0..n {
5803                        cols.push(cur.read_str()?);
5804                    }
5805                    cols
5806                } else {
5807                    Vec::new()
5808                };
5809                // v7.16.1 — TriggerDef.enabled (FILE_VERSION 25+).
5810                // v24-and-below catalogs deserialise with `true`
5811                // — pre-v7.16.1 every trigger always fired.
5812                let enabled = if version >= 25 {
5813                    cur.read_u8()? != 0
5814                } else {
5815                    true
5816                };
5817                cat.triggers.push(TriggerDef {
5818                    name,
5819                    table,
5820                    timing,
5821                    events,
5822                    for_each,
5823                    function,
5824                    update_columns,
5825                    enabled,
5826                });
5827            }
5828        }
5829        // v7.17.0 Phase 1.1 — SEQUENCE block (FILE_VERSION 26+).
5830        // v25-and-below catalogs omit; we leave the map empty.
5831        if version >= 26 {
5832            let seq_count = cur.read_u32()? as usize;
5833            for _ in 0..seq_count {
5834                let name = cur.read_str()?;
5835                let data_type = match cur.read_u8()? {
5836                    0 => SequenceDataType::SmallInt,
5837                    1 => SequenceDataType::Int,
5838                    2 => SequenceDataType::BigInt,
5839                    other => {
5840                        return Err(StorageError::Corrupt(format!(
5841                            "unknown SEQUENCE data-type tag {other}"
5842                        )));
5843                    }
5844                };
5845                let start = cur.read_i64()?;
5846                let increment = cur.read_i64()?;
5847                let min_value = cur.read_i64()?;
5848                let max_value = cur.read_i64()?;
5849                let cache = cur.read_i64()?;
5850                let cycle = cur.read_u8()? != 0;
5851                let owned_by = match cur.read_u8()? {
5852                    0 => None,
5853                    1 => {
5854                        let t = cur.read_str()?;
5855                        let c = cur.read_str()?;
5856                        Some((t, c))
5857                    }
5858                    other => {
5859                        return Err(StorageError::Corrupt(format!(
5860                            "unknown SEQUENCE owned-by tag {other}"
5861                        )));
5862                    }
5863                };
5864                let last_value = cur.read_i64()?;
5865                let is_called = cur.read_u8()? != 0;
5866                cat.sequences.insert(
5867                    name.clone(),
5868                    SequenceDef {
5869                        name,
5870                        data_type,
5871                        start,
5872                        increment,
5873                        min_value,
5874                        max_value,
5875                        cache,
5876                        cycle,
5877                        owned_by,
5878                        last_value,
5879                        is_called,
5880                    },
5881                );
5882            }
5883        }
5884        // v7.17.0 Phase 1.2 — VIEW block (FILE_VERSION 27+).
5885        // v26-and-below catalogs omit; we leave the map empty.
5886        if version >= 27 {
5887            let view_count = cur.read_u32()? as usize;
5888            for _ in 0..view_count {
5889                let name = cur.read_str()?;
5890                let col_count = cur.read_u16()? as usize;
5891                let mut columns = Vec::with_capacity(col_count);
5892                for _ in 0..col_count {
5893                    columns.push(cur.read_str()?);
5894                }
5895                let body = cur.read_str_long()?;
5896                cat.views.insert(
5897                    name.clone(),
5898                    ViewDef {
5899                        name,
5900                        columns,
5901                        body,
5902                    },
5903                );
5904            }
5905        }
5906        // v7.17.0 Phase 1.3 — MATERIALIZED VIEW source registry
5907        // (FILE_VERSION 28+). v27-and-below catalogs omit.
5908        if version >= 28 {
5909            let mv_count = cur.read_u32()? as usize;
5910            for _ in 0..mv_count {
5911                let name = cur.read_str()?;
5912                let body = cur.read_str_long()?;
5913                cat.materialized_views.insert(name, body);
5914            }
5915        }
5916        // v7.17.0 Phase 1.4 — ENUM types catalog block
5917        // (FILE_VERSION 29+).
5918        if version >= 29 {
5919            let etype_count = cur.read_u32()? as usize;
5920            for _ in 0..etype_count {
5921                let name = cur.read_str()?;
5922                let label_count = cur.read_u16()? as usize;
5923                let mut labels = Vec::with_capacity(label_count);
5924                for _ in 0..label_count {
5925                    labels.push(cur.read_str()?);
5926                }
5927                cat.enum_types
5928                    .insert(name.clone(), EnumDef { name, labels });
5929            }
5930        }
5931        // v7.17.0 Phase 1.5 — DOMAIN types catalog block
5932        // (FILE_VERSION 30+).
5933        if version >= 30 {
5934            let dtype_count = cur.read_u32()? as usize;
5935            for _ in 0..dtype_count {
5936                let name = cur.read_str()?;
5937                let base_type = cur.read_data_type()?;
5938                let nullable = cur.read_u8()? != 0;
5939                let default = match cur.read_u8()? {
5940                    0 => None,
5941                    1 => Some(cur.read_str()?),
5942                    other => {
5943                        return Err(StorageError::Corrupt(format!(
5944                            "unknown DOMAIN default tag {other}"
5945                        )));
5946                    }
5947                };
5948                let check_count = cur.read_u16()? as usize;
5949                let mut checks = Vec::with_capacity(check_count);
5950                for _ in 0..check_count {
5951                    checks.push(cur.read_str()?);
5952                }
5953                cat.domain_types.insert(
5954                    name.clone(),
5955                    DomainDef {
5956                        name,
5957                        base_type,
5958                        nullable,
5959                        default,
5960                        checks,
5961                    },
5962                );
5963            }
5964        }
5965        // v7.17.0 Phase 1.6 — user-schemas registry
5966        // (FILE_VERSION 31+).
5967        if version >= 31 {
5968            let sch_count = cur.read_u32()? as usize;
5969            for _ in 0..sch_count {
5970                let name = cur.read_str()?;
5971                cat.schemas.insert(name);
5972            }
5973        }
5974        // v7.37.42-T2 ζ-B — COMPOSITE types catalog block
5975        // (FILE_VERSION 52+). v51-and-below readers stop at the
5976        // user-schemas block; v52 readers fed a v51 catalog see no
5977        // composite block and default to an empty map.
5978        if version >= 52 {
5979            let ctype_count = cur.read_u32()? as usize;
5980            for _ in 0..ctype_count {
5981                let name = cur.read_str()?;
5982                let field_count = cur.read_u16()? as usize;
5983                let mut fields = Vec::with_capacity(field_count);
5984                for _ in 0..field_count {
5985                    let fname = cur.read_str()?;
5986                    let fty = cur.read_data_type()?;
5987                    fields.push((fname, fty));
5988                }
5989                cat.composite_types
5990                    .insert(name.clone(), CompositeDef { name, fields });
5991            }
5992        }
5993        if cur.pos < buf.len() {
5994            return Err(StorageError::Corrupt(format!(
5995                "trailing bytes: {} unread",
5996                buf.len() - cur.pos
5997            )));
5998        }
5999        Ok(cat)
6000    }
6001}
6002
6003#[cfg(test)]
6004mod tests;