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 bignum;
15pub mod bloom;
16mod codec;
17pub mod fts_simple;
18pub mod halfvec;
19pub mod jsonb_gin;
20mod nsw;
21pub mod persistent;
22pub mod persistent_btree;
23pub mod posting;
24pub mod quantize;
25pub mod row_header;
26pub mod row_locator;
27pub mod segment;
28pub mod snapshot;
29mod table;
30pub mod trgm;
31pub mod vacuum;
32
33pub use self::bloom::{BloomError, BloomFilter};
34// v7.31 monster tier-3 cut 3 — on-disk codec moved to `codec`; the
35// public dense-row surface keeps its `spg_storage::*` paths, and the
36// low-level write/read primitives stay crate-visible for the
37// `Catalog::serialize`/`deserialize` methods that remain in this file.
38pub(crate) use self::codec::*;
39pub use self::codec::{
40    decode_row_body_dense, decode_row_body_dense_pruned, encode_row_body_dense,
41    encode_row_body_dense_into, encode_row_body_dense_masked_into, row_body_encoded_len,
42};
43// v7.31 monster tier-3 cut 2 — HNSW algorithms moved to `nsw`; the
44// public vector-search surface keeps its `spg_storage::*` paths via
45// these re-exports, and `nsw_insert_at` stays crate-visible for the
46// `Table` insert paths in the `table` module.
47pub(crate) use self::nsw::nsw_insert_at;
48pub use self::nsw::{NswMetric, cosine_dot_norms_f32, inner_product_f32, nsw_index_on, nsw_query};
49pub use self::posting::PostingList;
50
51/// The list handed back for an absent key, so callers cannot tell an
52/// absent key from an empty posting list — the property the old
53/// `&[][..]` return had, kept.
54static EMPTY_POSTINGS: crate::posting::PostingList = crate::posting::PostingList::new();
55pub use self::row_locator::{RowLocator, RowLocatorError};
56pub use self::segment::{
57    BRIN_SIDECAR_MAGIC, BrinSummary, OwnedSegment, SEGMENT_COMPRESS_ALGO_LZSS,
58    SEGMENT_COMPRESS_ALGO_NONE, SEGMENT_MAGIC, SEGMENT_MAGIC_V2, SEGMENT_PAGE_BYTES, SegmentError,
59    SegmentMeta, SegmentReader, derive_brin_summaries, encode_segment, wrap_v2_envelope,
60    wrap_v2_envelope_with_brin,
61};
62
63use alloc::borrow::Cow;
64use alloc::boxed::Box;
65use alloc::collections::{BTreeMap, BTreeSet};
66use alloc::format;
67use alloc::string::{String, ToString};
68use alloc::sync::Arc;
69use alloc::vec::Vec;
70use core::fmt;
71
72use self::persistent::PersistentVec;
73use self::persistent_btree::PersistentBTreeMap;
74
75/// In-cell encoding for `DataType::Vector`. Mirrors
76/// `spg_sql::ast::VecEncoding` — kept here so storage stays
77/// dep-free of `spg-sql`. The engine bridges between the two
78/// at DDL-execution time.
79///
80/// `F32` is the pre-v6 default: each cell holds a raw `Vec<f32>`.
81/// `Sq8` (v6.0.1) stores `Sq8Vector { min, max, bytes: Vec<u8> }`
82/// per cell; 4× compression vs `F32` with recall@10 ≥ 0.95 on
83/// natural embeddings (Gaussian / unit-sphere corpora).
84/// `F16` (v6.0.3, DDL keyword `HALF`) stores each element as
85/// IEEE-754 binary16; 2× compression and bit-exact dequantise.
86#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
87pub enum VecEncoding {
88    #[default]
89    F32,
90    Sq8,
91    F16,
92}
93
94impl fmt::Display for VecEncoding {
95    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
96        match self {
97            Self::F32 => f.write_str("F32"),
98            Self::Sq8 => f.write_str("SQ8"),
99            Self::F16 => f.write_str("HALF"),
100        }
101    }
102}
103
104/// Runtime type tags. `Vector { dim, encoding }` / `Varchar(max)` /
105/// `Char(size)` are parameterised; the parameter travels with both
106/// the column schema and the on-wire serialised representation.
107#[derive(Debug, Clone, Copy, PartialEq, Eq)]
108pub enum DataType {
109    /// 16-bit signed. Backed by `Value::SmallInt(i16)`; arithmetic that
110    /// would overflow surfaces as a type error at INSERT time.
111    SmallInt,
112    Int,    // 32-bit signed
113    BigInt, // 64-bit signed
114    Float,  // f64 (PG double precision)
115    /// v7.38 (read01, T-float4) — `real` / `float4`: 32-bit IEEE float (PG
116    /// `real`). Backed by `Value::Real(f32)`; behaves like `Float` for most
117    /// dispatch but renders / stores at f32 precision.
118    Real,
119    Text,
120    /// `VARCHAR(n)` — same byte representation as `Text`, but INSERT
121    /// rejects values longer than `n` Unicode characters.
122    Varchar(u32),
123    /// `CHAR(n)` — same representation as `Text`, but INSERT right-pads
124    /// with U+0020 to exactly `n` Unicode characters (or rejects when
125    /// the input is already longer).
126    Char(u32),
127    Bool,
128    /// pgvector-style fixed-dimension vector. `encoding` selects
129    /// the in-cell representation (`F32` = pre-v6 raw f32 buffer;
130    /// `Sq8` = v6.0.1 8-bit scalar-quantised). The DDL grammar
131    /// surfaces encoding via the optional `USING <encoding>`
132    /// clause: `VECTOR(128) USING SQ8`.
133    Vector {
134        dim: u32,
135        encoding: VecEncoding,
136    },
137    /// `NUMERIC(precision, scale)` — exact fixed-point decimal stored as
138    /// a scaled `i128`. `precision` caps total decimal digits, `scale`
139    /// fixes digits after the decimal point. v1.12 supports up to
140    /// precision 38 (the i128-safe ceiling). `NUMERIC` and `NUMERIC(p)`
141    /// surface as `Numeric { precision: p, scale: 0 }`.
142    Numeric {
143        /// v7.39 (round 272) — widened from u8. PG's declared precision
144        /// runs to 1000; at u8 it could not even be spelled, and the
145        /// parser rejected anything past 38 (i128's width) outright.
146        precision: u16,
147        /// v7.39 (round 271) — widened alongside the value's scale.
148        /// v7.39 (round 273) — and signed: PG's DECLARED scale runs
149        /// -1000..=1000, where a negative one rounds to tens / hundreds.
150        /// A VALUE's display scale is always non-negative.
151        scale: i16,
152    },
153    /// `DATE` — calendar date with day precision, stored as `i32` days
154    /// since the Unix epoch (1970-01-01).
155    Date,
156    /// `TIMESTAMP` (a.k.a. `MySQL` `DATETIME`) — instant with microsecond
157    /// precision, stored as `i64` microseconds since the Unix epoch.
158    Timestamp,
159    /// v7.9.2 `TIMESTAMPTZ` — bit-identical to `Timestamp` on disk
160    /// (i64 microseconds, UTC by convention). Carried as a distinct
161    /// type tag so the PG-wire layer can advertise OID 1184 (PG's
162    /// `timestamp with time zone`) and `sqlx`/`pgx`/JDBC clients
163    /// decode into their TZ-aware datetime types. The internal
164    /// semantics are unchanged: SPG never stored per-row offsets,
165    /// and neither did PG — `TIMESTAMPTZ` in PG is also UTC i64.
166    Timestamptz,
167    /// v7.39 (round 291) — PG's `name`: the type its catalogs use for
168    /// identifiers. Text truncated to NAMEDATALEN-1 (63) bytes, with
169    /// its own type identity — `pg_typeof('abc'::name)` is `name`, and
170    /// `CREATE TABLE t (a name)` is legal SQL that SPG rejected.
171    Name,
172    /// v7.39 (round 640) — PG's `xid`: a transaction id. [`Value::Xid`]
173    /// has existed since round 512, so a `'5'::xid` literal already knew
174    /// what it was; this is the DECLARED half, which nothing had. Without
175    /// it `pg_typeof(NULL::xid)` answered `bigint`, `pg_type` could not
176    /// list oid 28 — leaving the 48 `pg_attribute` rows that describe
177    /// `xmin` / `xmax` pointing at a type no catalog carried — and
178    /// `CREATE TABLE t (a xid)` was refused as an unknown type.
179    ///
180    /// On disk it is the 8-byte body its BIGINT sibling writes, and it
181    /// reads back as a `Value::Xid`, so a stored column and a literal are
182    /// the same thing to everything downstream.
183    ///
184    /// What is NOT yet true of the identity: PG gives `xid` equality and
185    /// hashing and no ordering operator at all, so `min` / `max` /
186    /// `count(DISTINCT …)` / `<=` all error there and all answer here.
187    /// Measured, not assumed — and left for the operator surface rather
188    /// than claimed by this comment.
189    Xid,
190    /// v7.39 (round 640) — PG's `xid8`: the same transaction id, 64 bits
191    /// wide and monotonic. Unlike [`DataType::Xid`] it has no value of
192    /// its own; a cell is a `Value::BigInt` and only the declared type
193    /// witnesses it. That is enough for `pg_typeof`, the catalogs and
194    /// the wire OID, and not enough to refuse a bigint where PG refuses
195    /// one. `pg_current_xact_id()` returns this type on PG.
196    Xid8,
197    /// v7.39 (round 667) — PG's `oid`: an unsigned 32-bit object
198    /// identifier. Modelled exactly like [`DataType::Xid8`] above: it has
199    /// no value of its own, a cell is a `Value::BigInt`, and only the
200    /// declared type witnesses it.
201    ///
202    /// That deliberately buys less than a full value type. What it buys:
203    /// `CREATE TABLE t(o OID)` is accepted (it was rejected outright with
204    /// `type "oid" does not exist`, while the neighbouring `XID` worked),
205    /// `pg_typeof` answers `oid` rather than `bigint`, and the catalogs
206    /// report their own key columns honestly. What it does NOT buy is
207    /// refusing a bigint where PG refuses an oid — `sum(oid)` and
208    /// `avg(oid)` still answer here and error on PG, because at runtime
209    /// the cell is indistinguishable from a bigint. Round 664 tried to
210    /// close those two by name and withdrew: a guard keyed on the name
211    /// would have caught `sum(bigint)` with it.
212    ///
213    /// The cast itself was already right before this — `4294967296::oid`
214    /// and `'abc'::oid` produce PG's errors word for word, and `(-1)::oid`
215    /// wraps to 4294967295 as PG does. Only the resulting type was lost,
216    /// because `conversions.rs` mapped the target to `BigInt`.
217    Oid,
218    /// `INTERVAL` — calendar-aware span (months + microseconds). v2.11
219    /// supports INTERVAL only as a runtime intermediate (literals,
220    /// arithmetic results); on-disk encoding is rejected so this branch
221    /// can't appear in a `ColumnSchema`.
222    Interval,
223    /// v4.9: `JSON` — text-backed JSON document. We don't parse
224    /// the content (no path operators or jsonb functions yet) —
225    /// the column accepts any TEXT-compatible value and round-trips
226    /// it verbatim. PG OID 114 on the wire.
227    Json,
228    /// v7.9.0: `JSONB` — semantically identical to `Json` on
229    /// the storage side (same `Value::Json` cells, same
230    /// row codec), but advertised as PG OID 3802 on the wire
231    /// so `sqlx`-style clients that bind `jsonb` columns
232    /// decode correctly. mailrs migration blocker #3.
233    Jsonb,
234    /// v7.10.4: `BYTES` / `BYTEA` — variable-length raw binary.
235    /// Backed by `Value::Bytes(Vec<u8>)`. PG wire OID 17. Literal
236    /// forms accepted by parser/engine: PG hex form `'\xDEADBEEF'`
237    /// (case-insensitive hex pairs) and escape form
238    /// `'foo\\000bar'` (the latter decoded at coercion time when
239    /// the target column is BYTEA — TEXT columns leave the
240    /// backslash sequence verbatim).
241    Bytes,
242    /// v7.10.9: `TEXT[]` — single-dimension TEXT array. Elements
243    /// may be NULL (PG semantics). PG wire OID 1009. Literal
244    /// forms: `ARRAY['a', 'b', NULL]` and the PG external form
245    /// `'{a,b,NULL}'::TEXT[]`. Engine implements `= ANY(arr)`,
246    /// `<> ALL(arr)`, and 1-based indexing `arr[i]`. Catalog
247    /// FILE_VERSION 18+; older snapshots reject this DataType
248    /// (forward-only by design — TEXT[] columns aren't readable
249    /// on a pre-v7.10 binary).
250    TextArray,
251    /// v7.11.12: `INT[]` — single-dimension i32 array. PG wire
252    /// OID 1007 (_int4). Same `ARRAY[...]` / `'{1,2,3}'::INT[]`
253    /// literal surface as TEXT[]. Catalog FILE_VERSION 19+.
254    IntArray,
255    /// v7.11.12: `BIGINT[]` — single-dimension i64 array. PG
256    /// wire OID 1016 (_int8). Catalog FILE_VERSION 19+.
257    BigIntArray,
258    /// v7.39 (round 694) — `oid[]`. It exists for the reason
259    /// [`DataType::Oid`] does: mapping it onto `BigIntArray` answers
260    /// `pg_typeof('{1,2}'::oid[])` with `bigint[]`, which is the defect
261    /// round 667 closed for the scalar.
262    OidArray,
263    /// v7.39.11 — PG's `int2vector`: the type its catalogs use for
264    /// `pg_index.indkey` and `pg_index.indoption`. It IS an array of
265    /// `smallint` — `a.attnum = ANY (i.indkey)` is how Django, Rails,
266    /// sqlalchemy and every hand-written schema-diff query ask which
267    /// columns an index covers — but its output function prints the
268    /// elements space-separated with no braces, and its subscripts
269    /// start at 0 rather than 1. Carrying it as `text` (which SPG did
270    /// through 7.39.10) got the printing right and made every array
271    /// operation raise; carrying it as `smallint[]` would trade one
272    /// of those for the other. It is its own type here for the same
273    /// reason it is one there.
274    Int2Vector,
275    /// v7.39.11 — PG's `oidvector`: `pg_index.indclass`,
276    /// `pg_index.indcollation`, `pg_proc.proargtypes`. `int2vector`
277    /// with `oid` elements; see [`DataType::Int2Vector`].
278    OidVector,
279    /// v7.37.5 β-P4 — `INTERVAL[]` — single-dimension array of
280    /// `IntervalSpan { months, days, micros }`. PG wire OID 1187
281    /// (`_interval`). Catalog tag 35 + per-cell body
282    /// `[u16 count][per elem: u8 null + (if non-null) 16-byte
283    /// interval body in LE PG-byte-equal field order]`.
284    /// FILE_VERSION 48+.
285    IntervalArray,
286    /// v7.37.5 γ — full PG array-of-scalar family. Catalog tags
287    /// 36..48; wire OIDs from PG `pg_type.dat`. Per-element body
288    /// uses the scalar's existing `write_value_body` shape.
289    /// FILE_VERSION 48+ (same window as β; no separate bump).
290    BoolArray, // PG `_bool`        OID 1000, tag 36
291    SmallIntArray,    // PG `_int2`        OID 1005, tag 37
292    FloatArray,       // PG `_float8`      OID 1022, tag 38
293    NumericArray,     // PG `_numeric`     OID 1231, tag 39
294    DateArray,        // PG `_date`        OID 1182, tag 40
295    TimestampArray,   // PG `_timestamp`   OID 1115, tag 41
296    TimestamptzArray, // PG `_timestamptz` OID 1185, tag 42
297    UuidArray,        // PG `_uuid`        OID 2951, tag 43
298    JsonArray,        // PG `_json`        OID 199,  tag 44
299    JsonbArray,       // PG `_jsonb`       OID 3807, tag 45
300    BytesArray,       // PG `_bytea`       OID 1001, tag 46
301    VarcharArray,     // PG `_varchar`     OID 1015, tag 47
302    CharArray,        // PG `_bpchar`      OID 1014, tag 48
303    RealArray,        // PG `_float4`      OID 1021, tag 79
304    TimeArray,        // PG `_time`        OID 1183, tag 80
305    TimeTzArray,      // PG `_timetz`      OID 1270, tag 81
306    InetArray,        // PG `_inet`        OID 1041, tag 82
307    XmlArray,         // PG `_xml`         OID 143,  tag 83
308    /// v7.37.5 δ — PG 14+ multirange types. A multirange is an
309    /// ordered collection of non-overlapping ranges of the same
310    /// element kind (e.g. `int4multirange(int4range(1,5),
311    /// int4range(10,15))` → `{[1,5),[10,15)}`). The same DataType
312    /// variant covers all six builtin multiranges; `RangeKind`
313    /// pins the element type so encode/decode/display can route
314    /// off one switch (parallel to `Range(RangeKind)`).
315    /// Wire OIDs: int4multirange=4451, int8multirange=4537,
316    /// nummultirange=4536, tsmultirange=4533, tstzmultirange=4534,
317    /// datemultirange=4535. Catalog tag 49 + 1-byte RangeKind on
318    /// the dense type-tag side. FILE_VERSION 48+ (same window as
319    /// β/γ, no separate bump).
320    Multirange(RangeKind),
321    /// v7.37.5 ε — PG geometry scalar family. Mirrors PG's seven
322    /// builtin geometric types one-for-one. Body shapes (LE):
323    ///   Point   = 16 B fixed (f64 x + f64 y)            OID 600
324    ///   Lseg    = 32 B fixed (Point p1 + Point p2)      OID 601
325    ///   Path    = varlena ([u8 closed][u32 n][Point*n]) OID 602
326    ///   Box     = 32 B fixed (Point ur + Point ll)      OID 603
327    ///   Polygon = varlena ([u32 n][Point*n])            OID 604
328    ///   Line    = 24 B fixed (f64 a + f64 b + f64 c)    OID 628
329    ///   Circle  = 24 B fixed (Point center + f64 r)     OID 718
330    /// Catalog tags 50..56. FILE_VERSION 48+ (same window as β/γ/δ;
331    /// no separate bump). Geometric operators (`<->` / `@>` / `&&`
332    /// / `<<` / `>>` / `~=`) are a planner-integration follow-up,
333    /// parallel to the Range operator defer in e2e_pg_range.rs.
334    Point,
335    Lseg,
336    Path,
337    PgBox,
338    Polygon,
339    Line,
340    Circle,
341    /// v7.37.5 ζ-A — PG network address family. Body shapes (LE):
342    ///   Inet     = 18 B fixed (u8 family + u8 bits + 16 B addr)  OID 869
343    ///   Cidr     = 18 B fixed (same shape as Inet; CIDR rejects
344    ///                          host bits at parse / coerce)       OID 650
345    ///   Macaddr  = 6 B fixed                                      OID 829
346    ///   Macaddr8 = 8 B fixed (EUI-64)                             OID 774
347    /// Catalog tags 57-60. FILE_VERSION 48+. `family = 4` is IPv4
348    /// (uses the first 4 bytes of the 16-B addr slot, rest 0);
349    /// `family = 6` is IPv6 (full 16 B).
350    Inet,
351    Cidr,
352    Macaddr,
353    Macaddr8,
354    /// v7.39 (read01 pg_lsn.c) — PG `pg_lsn` (WAL location). 8 bytes,
355    /// rendered `%X/%X`. Catalog tag 66. OID 3220.
356    PgLsn,
357    /// v7.37.5 ζ-A — PG bit string. Body = `[u32 nbits][ceil(nbits/8) bytes]`,
358    /// big-endian within each byte (matches PG binary).
359    ///   Bit         OID 1560 (fixed-length, but SPG carries the
360    ///                         length per cell — column declaration
361    ///                         `BIT(n)` constrains at coerce time)
362    ///   BitVarying  OID 1562 (variable-length, declared as `VARBIT`)
363    /// Catalog tags 61-62.
364    /// v7.39 (round 281) — `BIT(n)`: a FIXED-length bit string. `0`
365    /// means the type was written without a typmod, which PG treats as
366    /// `bit(1)`. Column assignment requires the length to match
367    /// exactly; an explicit cast pads or truncates instead.
368    Bit(u32),
369    /// v7.39 (round 281) — `BIT VARYING(n)`: `n` is a MAXIMUM, and `0`
370    /// means unbounded (`varbit` with no typmod).
371    BitVarying(u32),
372    /// v7.37.5 ζ-A — PG `xml`. Body identical to TEXT (storage is
373    /// the verbatim XML string; no parse-time validation). Only
374    /// the wire OID (142) differs. Catalog tag 63.
375    Xml,
376    /// v7.37.5 ζ-A — PG `"char"` (the internal single-byte type,
377    /// distinct from `CHAR(n)` / `BPCHAR`). Body = 1 byte raw.
378    /// OID 18. Catalog tag 64.
379    Char1,
380    /// v7.37.5 ζ-A — `MONEY[]`. Body = `[u16 count][per elem: u8 null
381    /// + (non-null) i64 LE cents]`. OID 791. Catalog tag 65.
382    MoneyArray,
383    /// v7.12.0: PG `tsvector` — ordered, deduplicated set of
384    /// `(lexeme, positions, weight)` tuples. PG wire OID 3614.
385    /// Catalog FILE_VERSION 20+. Storage shape is row-codec
386    /// tag 22; the schema-agnostic `write_value` path emits tag
387    /// 18. Literal: `'foo:1 bar:2,3'::tsvector` (PG external
388    /// form). G-CRIT-3 entry — v7.12.0 only ships the type +
389    /// codec; matching `@@` lands in v7.12.2.
390    TsVector,
391    /// v7.12.0: PG `tsquery` — parse tree of lexemes joined by
392    /// `&` `|` `!` and phrase operators. PG wire OID 3615.
393    /// Catalog FILE_VERSION 20+.
394    TsQuery,
395    /// v7.17.0: PG `uuid` — 128-bit identifier stored as
396    /// `Value::Uuid([u8; 16])`. PG wire OID 2950. Canonical
397    /// text form is lowercase 8-4-4-4-12 hyphenated; input
398    /// also accepts uppercase, unhyphenated, and brace-wrapped
399    /// forms (`{xxxx…}`). Catalog FILE_VERSION 36+; tag 24 on
400    /// the dense type-tag side, tag 20 on the schema-agnostic
401    /// value side. The drop-in PG/MySQL surface for Django /
402    /// Rails / Hibernate "id UUID PRIMARY KEY DEFAULT
403    /// gen_random_uuid()" default-PK pattern.
404    Uuid,
405    /// v7.17.0 Phase 3.P0-32: PG `time` (without time zone) — i64
406    /// microseconds since 00:00:00. PG wire OID 1083. Display:
407    /// canonical zero-padded `HH:MM:SS` when fractional is zero,
408    /// `HH:MM:SS.ffffff` otherwise. Catalog FILE_VERSION 37+;
409    /// tag 25 on the dense type-tag side, tag 21 on the schema-
410    /// agnostic value side. The wall-clock-of-day half of PG's
411    /// date/time triplet (date / time / timestamp).
412    Time,
413    /// v7.17.0 Phase 3.P0-33: MySQL `YEAR` — u16 in range
414    /// 1901..=2155 plus the special zero-year sentinel 0. No
415    /// dedicated PG OID (advertised as INT4 / OID 23 on the wire
416    /// — psql renders integers, MySQL CLI renders 4-digit
417    /// zero-padded text). Display always 4 digits: `0000` for the
418    /// zero-year, `1985` / `2007` / etc otherwise. Catalog
419    /// FILE_VERSION 38+; tag 26 on the dense type-tag side, tag
420    /// 22 on the schema-agnostic value side.
421    Year,
422    /// v7.17.0 Phase 3.P0-34: PG `time with time zone` (TIMETZ) —
423    /// i64 microseconds since 00:00:00 in the local wall clock
424    /// PLUS i32 offset-from-UTC in seconds. PG wire OID 1266.
425    /// Display: `HH:MM:SS[.ffffff]±HH[:MM]` (PG `timetz_out`).
426    /// Range: offset in ±50400 seconds (±14 hours). Catalog
427    /// FILE_VERSION 39+; tag 27 on the dense type-tag side, tag
428    /// 23 on the schema-agnostic value side.
429    TimeTz,
430    /// v7.17.0 Phase 3.P0-35: PG `money` — i64 cents (locale-
431    /// independent storage). PG wire OID 790. Display: en_US
432    /// locale (`$N,NNN.CC`, negative → `-$1.23`). Input accepts
433    /// `$N.NN`, `$N,NNN.NN`, bare integer (treated as major
434    /// units), optional leading `-`. Range: full i64. Catalog
435    /// FILE_VERSION 40+; tag 28 on the dense type-tag side, tag
436    /// 24 on the schema-agnostic value side.
437    Money,
438    /// v7.17.0 Phase 3.P0-38: PG range type. The same DataType
439    /// variant covers all six builtin ranges (int4range,
440    /// int8range, numrange, tsrange, tstzrange, daterange) —
441    /// `RangeKind` pins the element type so encode / decode /
442    /// display can route off one switch. Catalog FILE_VERSION
443    /// 43+; tag 29 + a 1-byte RangeKind on the dense type-tag
444    /// side, tag 25 on the schema-agnostic value side.
445    Range(RangeKind),
446    /// v7.17.0 Phase 3.P0-39: PG `hstore` extension type — flat
447    /// `text => text` map with NULL value support. Catalog
448    /// FILE_VERSION 44+; tag 30 on the dense type-tag side, tag
449    /// 26 on the schema-agnostic value side. The contrib OID is
450    /// installation-dependent in real PG; SPG advertises it via
451    /// dynamic lookup, falling back to TEXT (OID 25) on the wire
452    /// when the installed `hstore` extension hasn't claimed an
453    /// OID yet.
454    Hstore,
455    /// v7.17.0 Phase 3.P0-40: PG `int[][]` — 2-dimensional INT
456    /// matrix. Storage: row-major Vec<Vec<Option<i32>>>. All
457    /// rows must share the same column count. Wire OID 1007
458    /// (same as INT[]; the dimension count travels in the data
459    /// header, not the OID). Catalog FILE_VERSION 45+; tag 31
460    /// on the dense type-tag side, tag 27 on the schema-agnostic
461    /// value side.
462    IntArray2D,
463    /// v7.17.0 Phase 3.P0-40: PG `bigint[][]` — 2-dimensional
464    /// BIGINT matrix. Storage / OID / tags mirror IntArray2D.
465    /// Tag 32 dense, tag 28 schema-agnostic.
466    BigIntArray2D,
467    /// v7.17.0 Phase 3.P0-40: PG `text[][]` — 2-dimensional TEXT
468    /// matrix. Storage: row-major Vec<Vec<Option<String>>>.
469    /// Tag 33 dense, tag 29 schema-agnostic.
470    TextArray2D,
471    /// v7.39 (read01 round 75) — `bool[][]`. BOOL is the ONE element type whose
472    /// ARRAY rendering differs from its scalar one (`t` vs `true`), so a
473    /// text-backed 2-D cannot be PG-faithful for it: rendering the whole array
474    /// wants `t`, and subscripting a cell to text wants `false`. Every other
475    /// element type renders the same either way, which is why this is the only
476    /// typed 2-D variant SPG needs.
477    BoolArray2D,
478}
479
480/// v7.17.0 Phase 3.P0-38 — pins the element type of a range value
481/// or column. Wire OIDs: Int4=3904, Int8=3926, Num=3906,
482/// Ts=3908, TsTz=3910, Date=3912.
483#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
484pub enum RangeKind {
485    Int4,
486    Int8,
487    Num,
488    Ts,
489    TsTz,
490    Date,
491}
492
493impl RangeKind {
494    pub const fn tag(self) -> u8 {
495        match self {
496            Self::Int4 => 0,
497            Self::Int8 => 1,
498            Self::Num => 2,
499            Self::Ts => 3,
500            Self::TsTz => 4,
501            Self::Date => 5,
502        }
503    }
504    pub const fn from_tag(t: u8) -> Option<Self> {
505        Some(match t {
506            0 => Self::Int4,
507            1 => Self::Int8,
508            2 => Self::Num,
509            3 => Self::Ts,
510            4 => Self::TsTz,
511            5 => Self::Date,
512            _ => return None,
513        })
514    }
515    pub const fn keyword(self) -> &'static str {
516        match self {
517            Self::Int4 => "INT4RANGE",
518            Self::Int8 => "INT8RANGE",
519            Self::Num => "NUMRANGE",
520            Self::Ts => "TSRANGE",
521            Self::TsTz => "TSTZRANGE",
522            Self::Date => "DATERANGE",
523        }
524    }
525}
526
527impl fmt::Display for DataType {
528    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
529        match self {
530            Self::SmallInt => f.write_str("SMALLINT"),
531            Self::Int => f.write_str("INT"),
532            Self::BigInt => f.write_str("BIGINT"),
533            Self::Xid => f.write_str("XID"),
534            Self::Xid8 => f.write_str("XID8"),
535            Self::Oid => f.write_str("OID"),
536            Self::OidArray => f.write_str("OID[]"),
537            Self::Int2Vector => f.write_str("INT2VECTOR"),
538            Self::OidVector => f.write_str("OIDVECTOR"),
539            Self::Float => f.write_str("FLOAT"),
540            Self::Real => f.write_str("REAL"),
541            Self::Text => f.write_str("TEXT"),
542            Self::Varchar(n) => write!(f, "VARCHAR({n})"),
543            Self::Char(n) => write!(f, "CHAR({n})"),
544            Self::Bool => f.write_str("BOOL"),
545            Self::Vector { dim, encoding } => match encoding {
546                VecEncoding::F32 => write!(f, "VECTOR({dim})"),
547                VecEncoding::Sq8 => write!(f, "VECTOR({dim}) USING SQ8"),
548                VecEncoding::F16 => write!(f, "VECTOR({dim}) USING HALF"),
549            },
550            Self::Numeric { precision, scale } => {
551                if *scale == 0 {
552                    write!(f, "NUMERIC({precision})")
553                } else {
554                    write!(f, "NUMERIC({precision}, {scale})")
555                }
556            }
557            Self::Date => f.write_str("DATE"),
558            Self::Timestamp => f.write_str("TIMESTAMP"),
559            Self::Timestamptz => f.write_str("TIMESTAMPTZ"),
560            Self::Name => f.write_str("NAME"),
561            Self::Interval => f.write_str("INTERVAL"),
562            Self::Json => f.write_str("JSON"),
563            Self::Jsonb => f.write_str("JSONB"),
564            Self::Bytes => f.write_str("BYTEA"),
565            Self::TextArray => f.write_str("TEXT[]"),
566            Self::IntArray => f.write_str("INT[]"),
567            Self::BigIntArray => f.write_str("BIGINT[]"),
568            Self::IntervalArray => f.write_str("INTERVAL[]"),
569            Self::BoolArray => f.write_str("BOOL[]"),
570            Self::SmallIntArray => f.write_str("SMALLINT[]"),
571            Self::FloatArray => f.write_str("FLOAT[]"),
572            Self::NumericArray => f.write_str("NUMERIC[]"),
573            Self::DateArray => f.write_str("DATE[]"),
574            Self::TimestampArray => f.write_str("TIMESTAMP[]"),
575            Self::TimestamptzArray => f.write_str("TIMESTAMPTZ[]"),
576            Self::UuidArray => f.write_str("UUID[]"),
577            Self::JsonArray => f.write_str("JSON[]"),
578            Self::JsonbArray => f.write_str("JSONB[]"),
579            Self::BytesArray => f.write_str("BYTEA[]"),
580            Self::VarcharArray => f.write_str("VARCHAR[]"),
581            Self::CharArray => f.write_str("CHAR[]"),
582            Self::RealArray => f.write_str("REAL[]"),
583            Self::TimeArray => f.write_str("TIME[]"),
584            Self::TimeTzArray => f.write_str("TIMETZ[]"),
585            Self::InetArray => f.write_str("INET[]"),
586            Self::XmlArray => f.write_str("XML[]"),
587            Self::Multirange(k) => f.write_str(match k {
588                RangeKind::Int4 => "INT4MULTIRANGE",
589                RangeKind::Int8 => "INT8MULTIRANGE",
590                RangeKind::Num => "NUMMULTIRANGE",
591                RangeKind::Ts => "TSMULTIRANGE",
592                RangeKind::TsTz => "TSTZMULTIRANGE",
593                RangeKind::Date => "DATEMULTIRANGE",
594            }),
595            Self::Point => f.write_str("POINT"),
596            Self::Lseg => f.write_str("LSEG"),
597            Self::Path => f.write_str("PATH"),
598            Self::PgBox => f.write_str("BOX"),
599            Self::Polygon => f.write_str("POLYGON"),
600            Self::Line => f.write_str("LINE"),
601            Self::Circle => f.write_str("CIRCLE"),
602            Self::Inet => f.write_str("INET"),
603            Self::Cidr => f.write_str("CIDR"),
604            Self::Macaddr => f.write_str("MACADDR"),
605            Self::Macaddr8 => f.write_str("MACADDR8"),
606            Self::PgLsn => f.write_str("PG_LSN"),
607            Self::Bit(0) => f.write_str("BIT"),
608            Self::Bit(n) => write!(f, "BIT({n})"),
609            Self::BitVarying(0) => f.write_str("VARBIT"),
610            Self::BitVarying(n) => write!(f, "VARBIT({n})"),
611            Self::Xml => f.write_str("XML"),
612            Self::Char1 => f.write_str("\"char\""),
613            Self::MoneyArray => f.write_str("MONEY[]"),
614            Self::TsVector => f.write_str("TSVECTOR"),
615            Self::TsQuery => f.write_str("TSQUERY"),
616            Self::Uuid => f.write_str("UUID"),
617            Self::Time => f.write_str("TIME"),
618            Self::Year => f.write_str("YEAR"),
619            Self::TimeTz => f.write_str("TIMETZ"),
620            Self::Money => f.write_str("MONEY"),
621            Self::Range(k) => f.write_str(k.keyword()),
622            Self::Hstore => f.write_str("HSTORE"),
623            Self::IntArray2D => f.write_str("INT[][]"),
624            Self::BigIntArray2D => f.write_str("BIGINT[][]"),
625            Self::TextArray2D => f.write_str("TEXT[][]"),
626            Self::BoolArray2D => f.write_str("BOOL[][]"),
627        }
628    }
629}
630
631/// v7.12.0 — one entry in a `Value::TsVector`. The lexeme is the
632/// (already-tokenised + stemmed in v7.12.1+) word; `positions` is
633/// a strictly-ascending list of 1-based positions; `weight` is the
634/// PG weight letter (A=3, B=2, C=1, D=0) — v7.12.0 defaults every
635/// lexeme to D, the v7.12.2 ranking path consumes the weight.
636#[derive(Debug, Clone, PartialEq, Eq)]
637pub struct TsLexeme {
638    pub word: String,
639    pub positions: Vec<u16>,
640    pub weight: u8,
641}
642
643/// v7.12.0 — parse tree for a PG `tsquery`. v7.12.0 ships the
644/// type + codec only; the `to_tsquery` / `plainto_tsquery` lexer
645/// lands in v7.12.1 and the `@@` evaluator in v7.12.2.
646#[derive(Debug, Clone, PartialEq, Eq)]
647pub enum TsQueryAst {
648    /// Single lexeme term. The `weight_mask` is the PG-style
649    /// bitmask of accepted weights (`A=1<<3`, `B=1<<2`, `C=1<<1`,
650    /// `D=1<<0`); `0` = any weight. v7.12.0 always sets it to 0.
651    Term {
652        word: String,
653        weight_mask: u8,
654    },
655    And(Box<TsQueryAst>, Box<TsQueryAst>),
656    Or(Box<TsQueryAst>, Box<TsQueryAst>),
657    Not(Box<TsQueryAst>),
658    /// `phrase <distance> phrase`. v7.12.0 only persists this; the
659    /// match semantics arrive in v7.12.2 alongside `@@`.
660    Phrase {
661        left: Box<TsQueryAst>,
662        right: Box<TsQueryAst>,
663        distance: u16,
664    },
665}
666
667/// v7.38.19 — whether an `interval` is finite, and if not, which way.
668///
669/// PostgreSQL has no NaN interval — measured, not assumed: `'nan'::interval`
670/// is a syntax error on 18.4 while `'infinity'` and `'-infinity'` parse —
671/// so this carries three states where `NumericKind` carries four.
672#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Default)]
673pub enum IntervalKind {
674    #[default]
675    Finite,
676    NegInf,
677    PosInf,
678}
679
680impl IntervalKind {
681    /// PostgreSQL's own representation of the two infinities, measured
682    /// off the wire rather than read out of its source.
683    ///
684    /// ```text
685    /// COPY (SELECT 'infinity'::interval)  TO STDOUT (FORMAT binary)
686    ///   … 7fffffffffffffff 7fffffff 7fffffff
687    /// COPY (SELECT '-infinity'::interval) TO STDOUT (FORMAT binary)
688    ///   … 8000000000000000 80000000 80000000
689    /// COPY (SELECT '1 day'::interval)     TO STDOUT (FORMAT binary)
690    ///   … 0000000000000000 00000001 00000000
691    /// ```
692    ///
693    /// All three fields at their extreme, which is why SPG can carry an
694    /// explicit `kind` in memory -- so the compiler names every site
695    /// that has to decide what infinity means there -- and still write
696    /// sixteen bytes on disk and on the wire. No finite interval reaches
697    /// the triple: PostgreSQL reserves it, so no value PostgreSQL ever
698    /// produced holds it either, and a file written before this version
699    /// cannot contain one.
700    #[must_use]
701    pub const fn from_fields(months: i32, days: i32, micros: i64) -> Self {
702        if micros == i64::MAX && days == i32::MAX && months == i32::MAX {
703            Self::PosInf
704        } else if micros == i64::MIN && days == i32::MIN && months == i32::MIN {
705            Self::NegInf
706        } else {
707            Self::Finite
708        }
709    }
710
711    /// The three fields this kind is written as. `Finite` hands back
712    /// what it was given.
713    #[must_use]
714    pub const fn to_fields(self, months: i32, days: i32, micros: i64) -> (i32, i32, i64) {
715        match self {
716            Self::Finite => (months, days, micros),
717            Self::PosInf => (i32::MAX, i32::MAX, i64::MAX),
718            Self::NegInf => (i32::MIN, i32::MIN, i64::MIN),
719        }
720    }
721
722    #[must_use]
723    pub const fn is_finite(self) -> bool {
724        matches!(self, Self::Finite)
725    }
726
727    /// Where this kind sits in the total order.
728    ///
729    /// v7.38.19 — PostgreSQL 18.4, measured: `'-infinity' < '-100 years'`
730    /// and `'infinity' > '100 years'` are both true, and `'infinity' =
731    /// 'infinity'` is true. So the rank decides first and the numbers
732    /// only speak between two finite values.
733    ///
734    /// Every comparison of two intervals asks THIS -- the ordering
735    /// comparator, the value comparator and the binary operators each
736    /// had their own copy of the span arithmetic, and three copies of a
737    /// question is how they come to disagree.
738    #[must_use]
739    pub const fn rank(self) -> i8 {
740        match self {
741            Self::NegInf => -1,
742            Self::Finite => 0,
743            Self::PosInf => 1,
744        }
745    }
746}
747
748/// A row-cell value, including SQL `NULL`. `Float` uses `f64`; NaN compares
749/// non-equal to itself (PG behaviour) — `PartialEq` is derived so callers
750/// must opt into NaN-aware comparison if they need stronger guarantees.
751///
752/// v7.37.42-arena Phase 1: parameterised on `'arena` so heap-bearing
753/// variants (Text/Json/Xml/Bytes/Vector/BitString.bytes) can borrow from
754/// a per-query bump arena (`Cow::Borrowed(&'arena ...)`). Persistent /
755/// catalog Values use `Value<'static>` (alias `ValueOwned`) with
756/// `Cow::Owned(...)`. Phase 1 keeps Range/Multirange recursive `Box<Value>`
757/// at `'static` (owned) — arena migration deferred to a later phase.
758/// Array-of-Option<String> variants (TextArray etc.) also stay owned in
759/// Phase 1; their nested shape is awkward for the simple Cow lift and the
760/// SCALARSQ hot path doesn't touch them.
761/// v7.38 (read01, T6) — the IEEE-style class of a NUMERIC value. `Finite` is the
762/// ordinary fixed-point case; the specials mirror PG's `'NaN'` / `'Infinity'` /
763/// `'-Infinity'`. Derived `PartialEq` gives `NaN == NaN` — correct for NUMERIC
764/// (unlike float's NaN ≠ NaN); the total order (`-Inf < finite < +Inf < NaN`)
765/// lives in the comparison paths, not in `Ord`.
766#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Hash)]
767pub enum NumericKind {
768    #[default]
769    Finite,
770    NaN,
771    PosInf,
772    NegInf,
773}
774
775#[derive(Debug, Clone, PartialEq)]
776#[non_exhaustive]
777pub enum Value<'arena> {
778    SmallInt(i16),
779    Int(i32),
780    BigInt(i64),
781    Float(f64),
782    /// v7.38 (read01, T-float4) — PG `real` (32-bit IEEE float).
783    Real(f32),
784    Text(Cow<'arena, str>),
785    Bool(bool),
786    Vector(Cow<'arena, [f32]>),
787    /// v6.0.1: 8-bit scalar-quantised vector cell. Lives in
788    /// columns declared `VECTOR(N) USING SQ8`. Layout per cell:
789    /// `Sq8Vector { min: f32, max: f32, bytes: Vec<u8> }` —
790    /// 4× compression vs `Vector(Vec<f32>)`. The wire layer
791    /// dequantises to `f32` on SELECT; INSERT path quantises
792    /// incoming `Vector(Vec<f32>)` cells into this variant.
793    Sq8Vector(crate::quantize::Sq8Vector),
794    /// v6.0.3: IEEE-754 binary16 vector cell. Lives in columns
795    /// declared `VECTOR(N) USING HALF`. Stores raw u16 LE bits
796    /// (2× compression vs `Vector(Vec<f32>)`). Wire / display
797    /// paths dequantise to f32 bit-exactly; INSERT path converts
798    /// incoming f32 vectors at the engine boundary.
799    HalfVector(crate::halfvec::HalfVector),
800    /// Exact fixed-point decimal. `scaled` holds the value as
801    /// `actual * 10^scale` so the storage type is always integral —
802    /// arithmetic never falls back to floating-point. v7.38 (read01, T6) —
803    /// `kind` classifies the value as finite (the common case, using
804    /// `scaled`/`scale`) or one of PG's NUMERIC specials (NaN / ±Infinity),
805    /// which ignore `scaled`/`scale` (canonicalized to 0).
806    Numeric {
807        scaled: i128,
808        /// v7.39 (round 271) — widened from u8. PG's numeric carries a
809        /// display scale up to 16383; at u8 a literal with 256 decimal
810        /// places could not be represented at all, and the conversion
811        /// aborted the query with an internal error.
812        scale: u16,
813        kind: NumericKind,
814    },
815    /// v7.38 (read01, T3) — an exact NUMERIC whose mantissa overflows `i128`
816    /// (PG's NUMERIC is unbounded). Boxed so the common finite case keeps its
817    /// small footprint; specials never take this form (they stay `Numeric`).
818    NumericBig(alloc::boxed::Box<crate::bignum::BigNumeric>),
819    /// Days since the Unix epoch (1970-01-01). Negative for earlier dates.
820    Date(i32),
821    /// Microseconds since the Unix epoch (1970-01-01T00:00:00Z).
822    Timestamp(i64),
823    /// Calendar span: `months` + `days` + `micros`. Three fields are
824    /// required for PG byte-equal: `'1 day'` ≠ `'24 hours'` (DST,
825    /// month-boundary, and the on-wire `pg_type` `interval` are all
826    /// `i64 micros + i32 days + i32 months`). v7.37.5 β widened from
827    /// `{months, micros}`; column storage lands in the same window.
828    Interval {
829        months: i32,
830        days: i32,
831        micros: i64,
832        /// v7.38.19 — finite, or one of the two infinities.
833        ///
834        /// PostgreSQL 17 gave `interval` an infinite value and SPG had
835        /// none, so `'infinity'::interval` was refused outright and the
836        /// subtraction error the ledger described was one symptom of
837        /// that, not the defect.
838        ///
839        /// A field beside the numbers rather than a sentinel inside
840        /// them, which is the shape `Value::Numeric` already uses for
841        /// exactly this question — and a field on THIS variant rather
842        /// than a new one, so the compiler names every site that has to
843        /// decide what infinity means there. A new variant would have
844        /// compiled everywhere on the first try and let a `_` arm
845        /// answer for it at one of a hundred and five of them.
846        kind: IntervalKind,
847    },
848    /// v4.9 `JSON` — raw JSON text. No structural validation
849    /// happens at the storage layer; whatever the parser hands us
850    /// round-trips verbatim. Equality is byte-wise.
851    Json(Cow<'arena, str>),
852    /// v7.10.4 `BYTEA` — raw binary blob. Equality is byte-wise.
853    /// Layout matches `Text`'s length-prefixed shape (`[u32 LE
854    /// len][bytes]`) under tag 18; the engine accepts PG hex
855    /// literals (`'\xDEADBEEF'`) and escape literals at the
856    /// coercion boundary.
857    Bytes(Cow<'arena, [u8]>),
858    /// v7.10.9 `TEXT[]` — single-dimension TEXT array with
859    /// optional NULL elements. Equality is element-wise. PG's
860    /// NULL-element comparison semantics: NULL ≠ NULL inside
861    /// arrays under `=`, so `[NULL] != [NULL]` (the engine
862    /// honours this).
863    TextArray(Vec<Option<String>>),
864    /// v7.11.12 `INT[]` — single-dimension i32 array with optional
865    /// NULL elements. Codec mirrors TextArray with i32 LE per
866    /// element instead of length-prefixed UTF-8.
867    IntArray(Vec<Option<i32>>),
868    /// v7.11.12 `BIGINT[]` — single-dimension i64 array with optional
869    /// NULL elements.
870    BigIntArray(Vec<Option<i64>>),
871    /// v7.37.5 β-P4 `INTERVAL[]` — single-dimension array of
872    /// `IntervalSpan { months, days, micros }` with optional NULL
873    /// elements. PG external form quotes each non-NULL element
874    /// (`{"1 day","24:00:00",NULL}`) because interval text contains
875    /// spaces and colons. Storage codec follows the BigIntArray
876    /// shape with a 16-byte per-element body.
877    IntervalArray(Vec<Option<IntervalSpan>>),
878    /// v7.37.5 γ — single-dimension arrays of the remaining PG
879    /// scalar types. Each carries `Vec<Option<T>>` with the
880    /// scalar's natural Rust shape; element NULLs are first-class
881    /// (per PG: `{1,NULL,3}` is a 3-element array, not a 2-element
882    /// one). Codec follows the IntervalArray shape — `[u16 count]
883    /// [per elem: u8 null + (non-null) scalar body]`.
884    BoolArray(Vec<Option<bool>>),
885    SmallIntArray(Vec<Option<i16>>),
886    /// v7.39.11 — PG `int2vector`. An array of `smallint` that prints
887    /// space-separated and subscripts from 0; see
888    /// [`DataType::Int2Vector`]. PG's own vectors never hold NULLs, so
889    /// the elements are plain.
890    Int2Vector(Vec<i16>),
891    /// v7.39.11 — PG `oidvector`; see [`Value::Int2Vector`].
892    OidVector(Vec<u32>),
893    FloatArray(Vec<Option<f64>>),
894    /// PG `NUMERIC[]` — `(scaled: i128, scale: u16)` per element.
895    NumericArray(Vec<Option<(i128, u16)>>),
896    DateArray(Vec<Option<i32>>),
897    TimestampArray(Vec<Option<i64>>),
898    TimestamptzArray(Vec<Option<i64>>),
899    UuidArray(Vec<Option<[u8; 16]>>),
900    JsonArray(Vec<Option<String>>),
901    JsonbArray(Vec<Option<String>>),
902    BytesArray(Vec<Option<Vec<u8>>>),
903    VarcharArray(Vec<Option<String>>),
904    CharArray(Vec<Option<String>>),
905    /// v7.40.0 — the five array spellings PostgreSQL 18.6 accepts
906    /// that SPG's postfix-`[]` map refused. Measured there first:
907    /// twenty-four spellings accepted, eighteen reachable here,
908    /// `oid[]` needing only routing, and these five needing a type.
909    ///
910    /// PG wire OIDs `_float4` 1021, `_time` 1183, `_timetz` 1270,
911    /// `_inet` 1041, `_xml` 143.
912    RealArray(Vec<Option<f32>>),
913    TimeArray(Vec<Option<i64>>),
914    /// `(us, offset_secs)` per element — the pair [`Value::TimeTz`]
915    /// carries, and the pair `timetz_sort_key` orders by.
916    TimeTzArray(Vec<Option<(i64, i32)>>),
917    /// `(family, bits, addr)` per element — [`Value::Inet`]'s shape.
918    InetArray(Vec<Option<(u8, u8, [u8; 16])>>),
919    XmlArray(Vec<Option<String>>),
920    /// v7.37.5 δ — PG 14+ multirange. `ranges` is a Vec of
921    /// non-overlapping bounds spans of the shared `kind`. PG's
922    /// canonical text form is `{[a,b),[c,d),...}` (comma-separated
923    /// ranges in braces; `{}` for the empty multirange). SPG's
924    /// constructor enforces no overlap/coalescing — for now the
925    /// engine trusts the caller (mirrors PG's `_construct_array`
926    /// pattern). Catalog tag 49 + 1-byte RangeKind on the dense
927    /// type-tag side; schema-less path is unreachable (multirange
928    /// is column-typed only).
929    Multirange {
930        kind: RangeKind,
931        ranges: Vec<RangeSpan>,
932    },
933    /// v7.37.5 ε — PG geometry scalars. Per-type Vec/struct shape;
934    /// codec body shape is described on the matching DataType
935    /// variant. PG canonical text forms:
936    ///   Point   `(x,y)`
937    ///   Lseg    `[(x1,y1),(x2,y2)]`
938    ///   Path    open `[(x,y),(x,y),...]` / closed `((x,y),(x,y),...)`
939    ///   Box     `(ux,uy),(lx,ly)` (PG normalises to upper-right + lower-left)
940    ///   Polygon `((x,y),(x,y),...)` (implicit closed)
941    ///   Line    `{a,b,c}` (Ax + By + C = 0)
942    ///   Circle  `<(x,y),r>`
943    Point(Point2D),
944    Lseg(Point2D, Point2D),
945    /// `closed = true` is `((p,p,...))`; `false` is `[(p,p,...)]`.
946    Path {
947        points: Vec<Point2D>,
948        closed: bool,
949    },
950    /// PG `box` — stored as `(upper_right, lower_left)` (PG's
951    /// normalised order). The engine accepts both endpoint
952    /// orderings at parse time and normalises here.
953    PgBox(Point2D, Point2D),
954    Polygon(Vec<Point2D>),
955    Line {
956        a: f64,
957        b: f64,
958        c: f64,
959    },
960    Circle {
961        center: Point2D,
962        radius: f64,
963    },
964    /// v7.37.5 ζ-A — PG `inet`. `family = 4` (IPv4) or `6` (IPv6).
965    /// `bits` is the netmask bit count (0..=32 for IPv4, 0..=128
966    /// for IPv6). `addr` is right-padded with zeros when family=4
967    /// (first 4 bytes are the address).
968    Inet {
969        family: u8,
970        bits: u8,
971        addr: [u8; 16],
972    },
973    /// v7.37.5 ζ-A — PG `cidr`. Same shape as Inet; CIDR's
974    /// invariant (host bits zero) is enforced at parse / coerce.
975    Cidr {
976        family: u8,
977        bits: u8,
978        addr: [u8; 16],
979    },
980    /// v7.37.5 ζ-A — PG `macaddr`. 6 bytes (XX:XX:XX:XX:XX:XX).
981    Macaddr([u8; 6]),
982    /// v7.37.5 ζ-A — PG `macaddr8`. 8 bytes (EUI-64).
983    Macaddr8([u8; 8]),
984    /// v7.39 (read01 pg_lsn.c) — PG `pg_lsn`, a 64-bit WAL location.
985    PgLsn(u64),
986    /// v7.39 (read01 ruleutils.c) — PG `regclass`: an OID-typed relation
987    /// reference that renders as the relation name. SPG carries BOTH
988    /// (the synthetic oid for catalog joins, the name for display) so
989    /// `conrelid = 't'::regclass` and `'t'::regclass::text` agree.
990    /// Eval-only (no column storage).
991    RegClass(i64, alloc::boxed::Box<str>),
992    /// v7.39 (round 342, V65) — PG `regproc`: an OID-typed FUNCTION
993    /// reference that renders as the function name. Same dual shape
994    /// [`Value::RegClass`] carries, and for the same reason: without the
995    /// oid half, `pg_proc.oid = 'f'::regproc` cannot join, and a callee
996    /// cannot tell `pg_get_functiondef('f'::regproc)` — which PG answers
997    /// — from `pg_get_functiondef('f')` — which PG rejects.
998    /// Eval-only (no column storage).
999    RegProc(i64, alloc::boxed::Box<str>),
1000    /// v7.39 (round 648) — PG `regtype`: an OID-typed TYPE reference
1001    /// that renders as the type name. The third of the shape
1002    /// [`Value::RegClass`] and [`Value::RegProc`] carry, and the one
1003    /// that was missing it: `::regtype` produced a plain `Value::Text`
1004    /// holding the canonical name, so `'text'::regtype::oid` tried to
1005    /// parse the NAME as a number and answered `invalid input syntax
1006    /// for type oid: "text"` where PG answers 25. `pg_typeof` on one
1007    /// said `text` rather than `regtype` for the same reason.
1008    ///
1009    /// Eval-only (no column storage).
1010    RegType(i64, alloc::boxed::Box<str>),
1011    /// v7.39 (round 512) — PG `xid` and `cid`, the transaction and command
1012    /// ids the `xmin` / `xmax` / `cmin` / `cmax` system columns carry.
1013    ///
1014    /// Their own types rather than integers, because PG deliberately gives
1015    /// them almost no operators: measured on PG18, `xmin + 1` is "operator
1016    /// does not exist: xid + integer", `xmin > 0` likewise, `xmin::bigint`
1017    /// is "cannot cast type xid to bigint", and there is no `max(xid)`.
1018    /// Carrying them as BigInt would quietly allow all four.
1019    ///
1020    /// Eval-only (no column storage).
1021    Xid(u32),
1022    Cid(u32),
1023    /// v7.39 (round 511) — PG `tid`, the physical row identity `ctid`
1024    /// carries: a block number and a one-based offset inside it, rendered
1025    /// `(block,offset)`.
1026    ///
1027    /// It is a real type rather than a two-field record because the idiom
1028    /// that makes `ctid` worth having — `DELETE … WHERE ctid NOT IN (SELECT
1029    /// min(ctid) … GROUP BY key)` — needs `min()` over it, and PG has no
1030    /// `min(record)`. Ordering is by block then offset, so `(0,2) < (0,9) <
1031    /// (0,10)`; a text form would order those `(0,10) < (0,2) < (0,9)` and
1032    /// the dedup would keep the wrong row.
1033    ///
1034    /// Eval-only (no column storage).
1035    Tid(u32, u32),
1036    /// v7.37.5 ζ-A — PG `bit` / `bit varying`. `nbits` is the
1037    /// actual bit count; `bytes` is the packed representation
1038    /// (big-endian within each byte; final byte right-padded
1039    /// with 0s if `nbits % 8 != 0`).
1040    BitString {
1041        nbits: u32,
1042        bytes: Cow<'arena, [u8]>,
1043    },
1044    /// v7.37.5 ζ-A — PG `xml`. Stored verbatim as a string; no
1045    /// parse-time validation (matches the SPG JSON convention).
1046    Xml(Cow<'arena, str>),
1047    /// v7.37.5 ζ-A — PG `"char"` (internal single-byte type,
1048    /// distinct from CHAR(n)).
1049    Char1(u8),
1050    /// v7.38 (read01, T11) — PG `bpchar` / CHAR(n): blank-padded fixed-length
1051    /// string. Stored space-padded to the declared width (as PG does + for wire
1052    /// display); length / comparison / ::text / concat all ignore the trailing
1053    /// blanks (handled at those sites).
1054    BpChar(Cow<'arena, str>),
1055    /// v7.37.5 ζ-A — PG `money[]`.
1056    MoneyArray(Vec<Option<i64>>),
1057    /// v7.12.0 `tsvector` — sorted-by-word, deduped lexeme set with
1058    /// positions + weights. The engine enforces sort/dedup on
1059    /// construction; consumers can rely on `lexemes.windows(2)`
1060    /// being strictly ascending by `word`.
1061    TsVector(Vec<TsLexeme>),
1062    /// v7.12.0 `tsquery` — boolean / phrase parse tree over
1063    /// lexemes. Engine builds via `to_tsquery` family.
1064    TsQuery(TsQueryAst),
1065    /// v7.17.0 `uuid` — 128-bit identifier. Stored as 16 bytes
1066    /// (big-endian / network-byte order, same as RFC 4122).
1067    /// Display normalises to canonical lowercase 8-4-4-4-12
1068    /// hyphenated form. Equality is byte-wise.
1069    Uuid([u8; 16]),
1070    /// v7.17.0 Phase 3.P0-32 — PG `time` (without time zone) —
1071    /// i64 microseconds since 00:00:00. Range 0..86_400_000_000.
1072    /// Display: `HH:MM:SS` zero-padded, with optional `.ffffff`
1073    /// suffix when fractional is non-zero.
1074    Time(i64),
1075    /// v7.17.0 Phase 3.P0-33 — MySQL `YEAR` — u16 in range
1076    /// 1901..=2155 plus the special zero-year sentinel 0.
1077    /// Display always 4 digits zero-padded (`0000` for the
1078    /// sentinel; `1985`/`2007` otherwise).
1079    Year(u16),
1080    /// v7.17.0 Phase 3.P0-34 — PG `time with time zone` — i64
1081    /// microseconds since 00:00:00 in the LOCAL wall clock PLUS
1082    /// an i32 offset-from-UTC in seconds. PG preserves the
1083    /// offset on output, so the wall-clock value is NOT shifted
1084    /// to UTC at storage time. Offset range: ±50400 seconds
1085    /// (±14 hours).
1086    TimeTz {
1087        us: i64,
1088        offset_secs: i32,
1089    },
1090    /// v7.17.0 Phase 3.P0-35 — PG `money` — i64 cents
1091    /// (locale-independent storage; the en_US locale renders on
1092    /// display via `$N,NNN.CC`).
1093    Money(i64),
1094    /// v7.17.0 Phase 3.P0-39 — PG `hstore` value: flat
1095    /// `text => text` map with NULL value support. Insertion
1096    /// order preserved on input; duplicate keys take last-write-
1097    /// wins at parse time.
1098    Hstore(Vec<(String, Option<String>)>),
1099    /// v7.17.0 Phase 3.P0-40 — 2D INT matrix (row-major).
1100    IntArray2D(Vec<Vec<Option<i32>>>),
1101    /// v7.17.0 Phase 3.P0-40 — 2D BIGINT matrix (row-major).
1102    BigIntArray2D(Vec<Vec<Option<i64>>>),
1103    /// v7.17.0 Phase 3.P0-40 — 2D TEXT matrix (row-major).
1104    TextArray2D(Vec<Vec<Option<String>>>),
1105    /// v7.39 (read01 round 75) — see `DataType::BoolArray2D`.
1106    BoolArray2D(Vec<Vec<Option<bool>>>),
1107    /// v7.17.0 Phase 3.P0-38 — PG range value. One shape covers
1108    /// all six builtin range types; `kind` pins the element type
1109    /// (must match the column's `DataType::Range(kind)`).
1110    /// `lower` / `upper` are `None` for the unbounded sides;
1111    /// `lower_inc` / `upper_inc` mirror the canonical PG
1112    /// `[` / `(` / `]` / `)` bracket inclusivity. `empty=true`
1113    /// supersedes all other fields (the empty range has no
1114    /// bounds).
1115    Range {
1116        kind: RangeKind,
1117        // v7.37.42-arena Phase 1: Range bounds stay owned ('static).
1118        // Recursive arena lifetimes are awkward to migrate at this
1119        // phase and the SCALARSQ hot path doesn't construct ranges.
1120        lower: Option<alloc::boxed::Box<Value<'static>>>,
1121        upper: Option<alloc::boxed::Box<Value<'static>>>,
1122        lower_inc: bool,
1123        upper_inc: bool,
1124        empty: bool,
1125    },
1126    /// v7.38 (read01, T9) — a composite / record value (a `row(...)`
1127    /// constructor or a whole-row reference). Fields are `(name, value)`; the
1128    /// names are `f1..fN` for an anonymous `row(...)` or the source column
1129    /// names for a table row. Transient — flows through row_to_json / to_json
1130    /// and the composite text form `(a,b)`; not a storable column type here.
1131    Composite(alloc::vec::Vec<(alloc::string::String, Value<'static>)>),
1132    Null,
1133}
1134
1135/// Owned `Value` — heap-bearing variants are `Cow::Owned`. Used everywhere
1136/// a Value must outlive a query-scoped arena (catalog defaults, persistent
1137/// storage, public APIs).
1138pub type ValueOwned = Value<'static>;
1139
1140/// v7.37.5 ε — PG `point` building block. Shared by every other
1141/// geometric type (lseg / path / box / polygon / circle all
1142/// reduce to compositions of `Point2D`). Packed `{x: f64, y: f64}`,
1143/// 16 B, on-disk LE field order matches the PG binary point
1144/// format byte-for-byte (so a future binary BIND path lands
1145/// without rearrangement).
1146#[derive(Debug, Clone, Copy, PartialEq)]
1147pub struct Point2D {
1148    pub x: f64,
1149    pub y: f64,
1150}
1151
1152/// v7.37.5 δ — single-range bounds without the kind tag. Used as
1153/// the element type of `Value::Multirange { kind, ranges }` so a
1154/// multirange carries one shared `RangeKind` plus N bounds-only
1155/// spans (saves 1 byte/elem vs duplicating the kind). The five
1156/// other fields mirror `Value::Range` exactly.
1157#[derive(Debug, Clone, PartialEq)]
1158pub struct RangeSpan {
1159    // v7.37.42-arena Phase 1: stays owned ('static) — same rationale as
1160    // Range bounds above.
1161    pub lower: Option<alloc::boxed::Box<Value<'static>>>,
1162    pub upper: Option<alloc::boxed::Box<Value<'static>>>,
1163    pub lower_inc: bool,
1164    pub upper_inc: bool,
1165    pub empty: bool,
1166}
1167
1168/// v7.37.5 β-P4 — element type for `Value::IntervalArray`. Mirrors
1169/// the `{months, days, micros}` shape of scalar `Value::Interval`,
1170/// broken out as a named struct so `IntervalArray`'s element type
1171/// is concrete (24 bytes, packed) instead of an enum-boxed Value.
1172/// All three dimensions are independent — `IntervalSpan { days: 1,
1173/// .. }` is distinct from `IntervalSpan { micros: 86_400_000_000,
1174/// .. }` per PG byte-equal.
1175#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1176pub struct IntervalSpan {
1177    pub months: i32,
1178    pub days: i32,
1179    pub micros: i64,
1180    /// v7.38.19 — see [`IntervalKind`].
1181    pub kind: IntervalKind,
1182}
1183
1184impl<'arena> Value<'arena> {
1185    /// Type tag, or `None` for `NULL` (unknown at value level).
1186    pub fn data_type(&self) -> Option<DataType> {
1187        match self {
1188            Self::SmallInt(_) => Some(DataType::SmallInt),
1189            Self::Int(_) => Some(DataType::Int),
1190            Self::BigInt(_) => Some(DataType::BigInt),
1191            Self::Float(_) => Some(DataType::Float),
1192            Self::Real(_) => Some(DataType::Real),
1193            // `Text` covers both unbounded TEXT and bounded VARCHAR/CHAR
1194            // — the constraint lives on the column schema, not the value.
1195            Self::Text(_) => Some(DataType::Text),
1196            Self::Bool(_) => Some(DataType::Bool),
1197            Self::Vector(v) => Some(DataType::Vector {
1198                dim: u32::try_from(v.len()).expect("vector dim ≤ u32"),
1199                encoding: VecEncoding::F32,
1200            }),
1201            Self::Sq8Vector(q) => Some(DataType::Vector {
1202                dim: u32::try_from(q.bytes.len()).expect("vector dim ≤ u32"),
1203                encoding: VecEncoding::Sq8,
1204            }),
1205            Self::HalfVector(h) => Some(DataType::Vector {
1206                dim: u32::try_from(h.dim()).expect("vector dim ≤ u32"),
1207                encoding: VecEncoding::F16,
1208            }),
1209            // `Value::Numeric` doesn't carry its precision (the column
1210            // schema does); we surface precision=0 as "unknown" and let
1211            // the engine reconcile against the column type at coercion
1212            // time.
1213            // v7.39 (round 273) — a VALUE's display scale is unsigned and
1214            // never exceeds PG's 16383 ceiling, so it always fits the
1215            // signed declared-scale field this describes itself with.
1216            Self::Numeric { scale, .. } => Some(DataType::Numeric {
1217                precision: 0,
1218                scale: i16::try_from(*scale).unwrap_or(i16::MAX),
1219            }),
1220            Self::NumericBig(b) => Some(DataType::Numeric {
1221                precision: 0,
1222                scale: i16::try_from(b.scale()).unwrap_or(i16::MAX),
1223            }),
1224            Self::Date(_) => Some(DataType::Date),
1225            Self::Timestamp(_) => Some(DataType::Timestamp),
1226            Self::Interval { .. } => Some(DataType::Interval),
1227            Self::Json(_) => Some(DataType::Json),
1228            Self::Bytes(_) => Some(DataType::Bytes),
1229            Self::TextArray(_) => Some(DataType::TextArray),
1230            Self::IntArray(_) => Some(DataType::IntArray),
1231            Self::BigIntArray(_) => Some(DataType::BigIntArray),
1232            Self::IntervalArray(_) => Some(DataType::IntervalArray),
1233            Self::BoolArray(_) => Some(DataType::BoolArray),
1234            Self::SmallIntArray(_) => Some(DataType::SmallIntArray),
1235            Self::Int2Vector(_) => Some(DataType::Int2Vector),
1236            Self::OidVector(_) => Some(DataType::OidVector),
1237            Self::FloatArray(_) => Some(DataType::FloatArray),
1238            Self::NumericArray(_) => Some(DataType::NumericArray),
1239            Self::DateArray(_) => Some(DataType::DateArray),
1240            Self::TimestampArray(_) => Some(DataType::TimestampArray),
1241            Self::TimestamptzArray(_) => Some(DataType::TimestamptzArray),
1242            Self::RealArray(_) => Some(DataType::RealArray),
1243            Self::TimeArray(_) => Some(DataType::TimeArray),
1244            Self::TimeTzArray(_) => Some(DataType::TimeTzArray),
1245            Self::InetArray(_) => Some(DataType::InetArray),
1246            Self::XmlArray(_) => Some(DataType::XmlArray),
1247            Self::UuidArray(_) => Some(DataType::UuidArray),
1248            Self::JsonArray(_) => Some(DataType::JsonArray),
1249            Self::JsonbArray(_) => Some(DataType::JsonbArray),
1250            Self::BytesArray(_) => Some(DataType::BytesArray),
1251            Self::VarcharArray(_) => Some(DataType::VarcharArray),
1252            Self::CharArray(_) => Some(DataType::CharArray),
1253            Self::Multirange { kind, .. } => Some(DataType::Multirange(*kind)),
1254            Self::Point(_) => Some(DataType::Point),
1255            Self::Lseg(_, _) => Some(DataType::Lseg),
1256            Self::Path { .. } => Some(DataType::Path),
1257            Self::PgBox(_, _) => Some(DataType::PgBox),
1258            Self::Polygon(_) => Some(DataType::Polygon),
1259            Self::Line { .. } => Some(DataType::Line),
1260            Self::Circle { .. } => Some(DataType::Circle),
1261            Self::Inet { .. } => Some(DataType::Inet),
1262            Self::Cidr { .. } => Some(DataType::Cidr),
1263            Self::Macaddr(_) => Some(DataType::Macaddr),
1264            Self::Macaddr8(_) => Some(DataType::Macaddr8),
1265            Self::PgLsn(_) => Some(DataType::PgLsn),
1266            // BitString could be either Bit or BitVarying; column
1267            // schema decides. Default to BitVarying when called
1268            // schema-less (rare; storage path is always
1269            // schema-aware so this only matters for diagnostics).
1270            Self::BitString { .. } => Some(DataType::BitVarying(0)),
1271            Self::Xml(_) => Some(DataType::Xml),
1272            Self::Char1(_) => Some(DataType::Char1),
1273            // BpChar reports its declared width from the padded length.
1274            Self::BpChar(s) => Some(DataType::Char(
1275                u32::try_from(s.chars().count()).unwrap_or(0),
1276            )),
1277            Self::MoneyArray(_) => Some(DataType::MoneyArray),
1278            Self::TsVector(_) => Some(DataType::TsVector),
1279            Self::TsQuery(_) => Some(DataType::TsQuery),
1280            Self::Uuid(_) => Some(DataType::Uuid),
1281            Self::Time(_) => Some(DataType::Time),
1282            Self::Year(_) => Some(DataType::Year),
1283            Self::TimeTz { .. } => Some(DataType::TimeTz),
1284            Self::Money(_) => Some(DataType::Money),
1285            Self::Range { kind, .. } => Some(DataType::Range(*kind)),
1286            Self::Hstore(_) => Some(DataType::Hstore),
1287            Self::IntArray2D(_) => Some(DataType::IntArray2D),
1288            Self::BigIntArray2D(_) => Some(DataType::BigIntArray2D),
1289            Self::TextArray2D(_) => Some(DataType::TextArray2D),
1290            Self::BoolArray2D(_) => Some(DataType::BoolArray2D),
1291            // v7.38 (read01, T9) — a transient composite/record has no storable
1292            // column DataType (it flows through row_to_json / to_json).
1293            Self::Composite(_) => None,
1294            // v7.39 (read01 ruleutils.c) — regclass is eval-only (dual
1295            // oid+name shape); no column storage type.
1296            // v7.39 (round 640) — `xid` became a column type, so its value
1297            // has a DataType to answer with. `cid` and `tid` are equally
1298            // legal column types on PG (measured: `CREATE TABLE t (a cid,
1299            // b tid)` is accepted), but SPG's grammar has no keyword for
1300            // them yet; they stay eval-only rather than half-declared.
1301            Self::Xid(_) => Some(DataType::Xid),
1302            Self::RegClass(..)
1303            | Self::RegProc(..)
1304            | Self::RegType(..)
1305            | Self::Tid(..)
1306            | Self::Cid(_) => None,
1307            Self::Null => None,
1308        }
1309    }
1310
1311    pub const fn is_null(&self) -> bool {
1312        matches!(self, Self::Null)
1313    }
1314
1315    /// v7.37.42-arena Phase 1: lift any `Value<'arena>` (possibly
1316    /// borrowing from a bump arena) into a fully-owned `Value<'static>`.
1317    /// Used at boundaries that must outlive the per-query arena
1318    /// (catalog write, public QueryResult emit, sqlx materialise).
1319    ///
1320    /// For the recursive Range/Multirange variants — bounds are already
1321    /// `Box<Value<'static>>` per Phase 1 design, so we just rebuild the
1322    /// outer enum at `'static`.
1323    pub fn into_owned(self) -> Value<'static> {
1324        match self {
1325            Value::SmallInt(n) => Value::SmallInt(n),
1326            Value::Int(n) => Value::Int(n),
1327            Value::BigInt(n) => Value::BigInt(n),
1328            Value::Float(f) => Value::Float(f),
1329            Value::Real(f) => Value::Real(f),
1330            Value::Text(s) => Value::Text(Cow::Owned(s.into_owned())),
1331            Value::Bool(b) => Value::Bool(b),
1332            Value::Vector(v) => Value::Vector(Cow::Owned(v.into_owned())),
1333            Value::Sq8Vector(q) => Value::Sq8Vector(q),
1334            Value::HalfVector(h) => Value::HalfVector(h),
1335            Value::Numeric {
1336                scaled,
1337                scale,
1338                kind,
1339            } => Value::Numeric {
1340                scaled,
1341                scale,
1342                kind,
1343            },
1344            Value::NumericBig(b) => Value::NumericBig(b),
1345            Value::Date(d) => Value::Date(d),
1346            Value::Timestamp(t) => Value::Timestamp(t),
1347            Value::Interval {
1348                months,
1349                days,
1350                micros,
1351                kind,
1352            } => Value::Interval {
1353                months,
1354                days,
1355                micros,
1356                kind,
1357            },
1358            Value::Json(s) => Value::Json(Cow::Owned(s.into_owned())),
1359            Value::Bytes(b) => Value::Bytes(Cow::Owned(b.into_owned())),
1360            Value::TextArray(v) => Value::TextArray(v),
1361            Value::IntArray(v) => Value::IntArray(v),
1362            Value::BigIntArray(v) => Value::BigIntArray(v),
1363            Value::IntervalArray(v) => Value::IntervalArray(v),
1364            Value::BoolArray(v) => Value::BoolArray(v),
1365            Value::SmallIntArray(v) => Value::SmallIntArray(v),
1366            Value::Int2Vector(v) => Value::Int2Vector(v),
1367            Value::OidVector(v) => Value::OidVector(v),
1368            Value::FloatArray(v) => Value::FloatArray(v),
1369            Value::NumericArray(v) => Value::NumericArray(v),
1370            Value::DateArray(v) => Value::DateArray(v),
1371            Value::TimestampArray(v) => Value::TimestampArray(v),
1372            Value::TimestamptzArray(v) => Value::TimestamptzArray(v),
1373            Value::UuidArray(v) => Value::UuidArray(v),
1374            Value::JsonArray(v) => Value::JsonArray(v),
1375            Value::JsonbArray(v) => Value::JsonbArray(v),
1376            Value::BytesArray(v) => Value::BytesArray(v),
1377            Value::VarcharArray(v) => Value::VarcharArray(v),
1378            Value::CharArray(v) => Value::CharArray(v),
1379            Value::RealArray(v) => Value::RealArray(v),
1380            Value::TimeArray(v) => Value::TimeArray(v),
1381            Value::TimeTzArray(v) => Value::TimeTzArray(v),
1382            Value::InetArray(v) => Value::InetArray(v),
1383            Value::XmlArray(v) => Value::XmlArray(v),
1384            Value::Multirange { kind, ranges } => Value::Multirange { kind, ranges },
1385            // v7.38 (read01, T9) — Composite fields are already `Value<'static>`.
1386            Value::Composite(fields) => Value::Composite(fields),
1387            Value::RegClass(oid, name) => Value::RegClass(oid, name),
1388            Value::Tid(b, o) => Value::Tid(b, o),
1389            Value::Xid(x) => Value::Xid(x),
1390            Value::Cid(c) => Value::Cid(c),
1391            Value::RegProc(oid, name) => Value::RegProc(oid, name),
1392            Value::RegType(oid, name) => Value::RegType(oid, name),
1393            Value::Point(p) => Value::Point(p),
1394            Value::Lseg(a, b) => Value::Lseg(a, b),
1395            Value::Path { points, closed } => Value::Path { points, closed },
1396            Value::PgBox(a, b) => Value::PgBox(a, b),
1397            Value::Polygon(p) => Value::Polygon(p),
1398            Value::Line { a, b, c } => Value::Line { a, b, c },
1399            Value::Circle { center, radius } => Value::Circle { center, radius },
1400            Value::Inet { family, bits, addr } => Value::Inet { family, bits, addr },
1401            Value::Cidr { family, bits, addr } => Value::Cidr { family, bits, addr },
1402            Value::Macaddr(m) => Value::Macaddr(m),
1403            Value::Macaddr8(m) => Value::Macaddr8(m),
1404            Value::PgLsn(l) => Value::PgLsn(l),
1405            Value::BitString { nbits, bytes } => Value::BitString {
1406                nbits,
1407                bytes: Cow::Owned(bytes.into_owned()),
1408            },
1409            Value::Xml(s) => Value::Xml(Cow::Owned(s.into_owned())),
1410            Value::Char1(c) => Value::Char1(c),
1411            Value::BpChar(s) => Value::BpChar(Cow::Owned(s.into_owned())),
1412            Value::MoneyArray(v) => Value::MoneyArray(v),
1413            Value::TsVector(v) => Value::TsVector(v),
1414            Value::TsQuery(q) => Value::TsQuery(q),
1415            Value::Uuid(u) => Value::Uuid(u),
1416            Value::Time(t) => Value::Time(t),
1417            Value::Year(y) => Value::Year(y),
1418            Value::TimeTz { us, offset_secs } => Value::TimeTz { us, offset_secs },
1419            Value::Money(m) => Value::Money(m),
1420            Value::Range {
1421                kind,
1422                lower,
1423                upper,
1424                lower_inc,
1425                upper_inc,
1426                empty,
1427            } => Value::Range {
1428                kind,
1429                lower,
1430                upper,
1431                lower_inc,
1432                upper_inc,
1433                empty,
1434            },
1435            Value::Hstore(h) => Value::Hstore(h),
1436            Value::IntArray2D(a) => Value::IntArray2D(a),
1437            Value::BigIntArray2D(a) => Value::BigIntArray2D(a),
1438            Value::TextArray2D(a) => Value::TextArray2D(a),
1439            Value::BoolArray2D(a) => Value::BoolArray2D(a),
1440            Value::Null => Value::Null,
1441        }
1442    }
1443
1444    /// v7.37.42-arena Phase 4 — copy heap payloads into the supplied
1445    /// bump arena, yielding a `Value<'a>` whose Cow-variant payloads
1446    /// are arena-borrowed (or stay as small owned scalars for the
1447    /// `Copy`-able variants).
1448    ///
1449    /// Used at the catalog ↔ ephemeral boundary: a `ColumnSchema.default`
1450    /// is `Value<'static>` but INSERT-time eval may want it stamped into
1451    /// the per-statement arena alongside other arena-built scalars.
1452    ///
1453    /// Allocates only into the supplied arena; the input `&self` keeps
1454    /// its own storage. For `Copy`-able / nested-owned variants the
1455    /// implementation falls back to `clone()` (the nested heap blocks
1456    /// stay on the global allocator, which is fine — the boundary
1457    /// requirement is just "no aliasing of caller-owned strings").
1458    pub fn clone_into<'a>(&self, arena: &'a bumpalo::Bump) -> Value<'a> {
1459        match self {
1460            Value::Text(s) => Value::Text(Cow::Borrowed(arena.alloc_str(s))),
1461            Value::Json(s) => Value::Json(Cow::Borrowed(arena.alloc_str(s))),
1462            Value::Xml(s) => Value::Xml(Cow::Borrowed(arena.alloc_str(s))),
1463            Value::BpChar(s) => Value::BpChar(Cow::Borrowed(arena.alloc_str(s))),
1464            Value::Bytes(b) => {
1465                let slot = arena.alloc_slice_copy::<u8>(b);
1466                Value::Bytes(Cow::Borrowed(slot))
1467            }
1468            Value::Vector(v) => {
1469                let slot = arena.alloc_slice_copy::<f32>(v);
1470                Value::Vector(Cow::Borrowed(slot))
1471            }
1472            Value::BitString { nbits, bytes } => {
1473                let slot = arena.alloc_slice_copy::<u8>(bytes);
1474                Value::BitString {
1475                    nbits: *nbits,
1476                    bytes: Cow::Borrowed(slot),
1477                }
1478            }
1479            // Copy-able scalars + variants whose nested heap blocks are
1480            // `'static` regardless of `'arena` (TextArray, JsonArray,
1481            // Hstore, TsVector, Range bounds, …). Clone the heap block
1482            // via the standard `into_owned()` path then lift the
1483            // resulting `Value<'static>` to `Value<'a>` via the Cow
1484            // variance — `'static` covers any lifetime.
1485            other => other.clone().into_owned(),
1486        }
1487    }
1488}
1489
1490impl Value<'static> {
1491    /// v7.37.42-arena Phase 1 — owned-Text constructor. The variant now
1492    /// holds `Cow<'arena, str>`, so the previous `Value::Text(String)`
1493    /// shape no longer compiles directly. This helper preserves the
1494    /// historical ergonomics: `Value::text("foo")` or
1495    /// `Value::text(String::from("foo"))`.
1496    pub fn text<S: Into<String>>(s: S) -> Self {
1497        Value::Text(Cow::Owned(s.into()))
1498    }
1499
1500    /// v7.38 (read01, T6) — a finite NUMERIC from its fixed-point parts.
1501    pub const fn numeric(scaled: i128, scale: u16) -> Self {
1502        Value::Numeric {
1503            scaled,
1504            scale,
1505            kind: NumericKind::Finite,
1506        }
1507    }
1508
1509    /// v7.38 (read01, T6) — a special NUMERIC (NaN / ±Infinity). The fixed-point
1510    /// fields are canonicalized to 0 so equal specials compare byte-identical.
1511    pub const fn numeric_special(kind: NumericKind) -> Self {
1512        Value::Numeric {
1513            scaled: 0,
1514            scale: 0,
1515            kind,
1516        }
1517    }
1518
1519    /// v7.37.42-arena Phase 1 — owned-Json constructor (mirrors `text`).
1520    pub fn json<S: Into<String>>(s: S) -> Self {
1521        Value::Json(Cow::Owned(s.into()))
1522    }
1523
1524    /// v7.37.42-arena Phase 1 — owned-Xml constructor.
1525    pub fn xml<S: Into<String>>(s: S) -> Self {
1526        Value::Xml(Cow::Owned(s.into()))
1527    }
1528
1529    /// v7.37.42-arena Phase 1 — owned-Bytes constructor.
1530    pub fn bytes<B: Into<Vec<u8>>>(b: B) -> Self {
1531        Value::Bytes(Cow::Owned(b.into()))
1532    }
1533
1534    /// v7.37.42-arena Phase 1 — owned-Vector constructor.
1535    pub fn vector<V: Into<Vec<f32>>>(v: V) -> Self {
1536        Value::Vector(Cow::Owned(v.into()))
1537    }
1538
1539    /// v7.37.42-arena Phase 1 — owned-BitString constructor.
1540    pub fn bit_string<B: Into<Vec<u8>>>(nbits: u32, bytes: B) -> Self {
1541        Value::BitString {
1542            nbits,
1543            bytes: Cow::Owned(bytes.into()),
1544        }
1545    }
1546}
1547
1548/// One table row — values are positional and must match
1549/// `TableSchema.columns` in length and (modulo NULL) in `DataType`.
1550///
1551/// v7.37.42-arena Phase 1: parameterised on `'arena` so per-query rows
1552/// can borrow from a bump arena. The owned shape (`Row<'static>`, alias
1553/// `RowOwned`) is what catalog storage, public APIs, and tests use.
1554#[derive(Debug, Clone, PartialEq)]
1555pub struct Row<'arena> {
1556    pub values: Vec<Value<'arena>>,
1557}
1558
1559/// Owned `Row` — values are `Value<'static>`. Used everywhere a row must
1560/// outlive a query-scoped arena.
1561pub type RowOwned = Row<'static>;
1562
1563impl<'arena> Row<'arena> {
1564    pub const fn new(values: Vec<Value<'arena>>) -> Self {
1565        Self { values }
1566    }
1567
1568    pub fn len(&self) -> usize {
1569        self.values.len()
1570    }
1571
1572    pub fn is_empty(&self) -> bool {
1573        self.values.is_empty()
1574    }
1575}
1576
1577impl<'arena> Row<'arena> {
1578    /// v7.37.42-arena Phase 4 — copy every cell into the supplied bump
1579    /// arena, yielding a `Row<'a>` whose Cow-payloads are arena-borrowed.
1580    /// Boundary helper for catalog defaults → DML eval handoff and
1581    /// arena-local row scratch.
1582    pub fn clone_into<'a>(&self, arena: &'a bumpalo::Bump) -> Row<'a> {
1583        Row {
1584            values: self.values.iter().map(|v| v.clone_into(arena)).collect(),
1585        }
1586    }
1587
1588    /// v7.37.42-arena Phase 4 — lift this `Row<'arena>` to a fully-owned
1589    /// `Row<'static>` for catalog write / WAL serialisation. Equivalent
1590    /// to `Row::from_arena(self)` but consumes by value at any lifetime
1591    /// (callers can write `row.into_owned()` mirroring `Value::into_owned`).
1592    pub fn into_owned(self) -> Row<'static> {
1593        Row {
1594            values: self.values.into_iter().map(Value::into_owned).collect(),
1595        }
1596    }
1597}
1598
1599impl Row<'static> {
1600    /// v7.37.42-arena Phase 1 — lift any `Row<'arena>` (possibly arena-
1601    /// borrowed) into a fully-owned `Row<'static>`. Mirrors
1602    /// `Value::into_owned`.
1603    pub fn from_arena(row: Row<'_>) -> Self {
1604        Self {
1605            values: row.values.into_iter().map(Value::into_owned).collect(),
1606        }
1607    }
1608}
1609
1610/// Each bool is an independent, separately-persisted column attribute
1611/// (`nullable`, `auto_increment`, `is_unsigned`, `identity_always`) that the
1612/// catalog appendix reads and writes by name. Packing them into a bitflags
1613/// word would buy nothing and would put a decoding step between the on-disk
1614/// format and every reader of the schema.
1615#[allow(clippy::struct_excessive_bools)]
1616#[derive(Debug, Clone, PartialEq)]
1617pub struct ColumnSchema {
1618    pub name: String,
1619    pub ty: DataType,
1620    pub nullable: bool,
1621    /// Optional `DEFAULT` value, frozen at CREATE TABLE time. `None`
1622    /// means "no default" (so omitted columns become NULL, or error
1623    /// out when the column is NOT NULL). Literal defaults take this
1624    /// path.
1625    ///
1626    /// v7.37.42-arena Phase 1: explicitly `Value<'static>` — catalog
1627    /// defaults must outlive any per-query arena.
1628    pub default: Option<Value<'static>>,
1629    /// v7.9.21 — for DEFAULT expressions that need INSERT-time
1630    /// evaluation (e.g. `DEFAULT now()`, `DEFAULT CURRENT_TIMESTAMP`),
1631    /// the Display form of the expression. The engine re-parses
1632    /// it on each INSERT default-fill, evaluates against an empty
1633    /// row context, and coerces to the column type. mailrs G4.
1634    /// Persisted in catalog FILE_VERSION 15+; older catalogs
1635    /// deserialise with None.
1636    pub runtime_default: Option<String>,
1637    /// MySQL-style `AUTO_INCREMENT`. When set, an INSERT that leaves
1638    /// this column unbound (or sets it to NULL) gets the next integer
1639    /// computed from the column's current max + 1.
1640    /// v7.39 (round 676) — the collation NAME as written, when the column
1641    /// carried an explicit `COLLATE`.
1642    ///
1643    /// `spg_sql::Collation` cannot carry it: it is a two-variant MySQL enum
1644    /// and `from_collation_name` folds `C`, `POSIX`, `en_US` and `default`
1645    /// all into `Binary`. Without the name `pg_attribute.attcollation` can
1646    /// only ever report the type's default, which is what F36 records as
1647    /// "the declaration is taken and ignored".
1648    ///
1649    /// None means the column was written without a `COLLATE` clause and
1650    /// takes its type's collation. Persisted through the v88 appendix,
1651    /// which costs two bytes for a table that declares none.
1652    pub collation_name: Option<String>,
1653    pub auto_increment: bool,
1654    /// v7.17.0 Phase 1.4 — when the column is bound to a user-
1655    /// defined ENUM type (the parser saw an unknown type ident
1656    /// and the engine resolved it against `catalog.enum_types`),
1657    /// this carries the enum name so INSERT/UPDATE can validate
1658    /// the cell value against the enum's labels. `ty` is
1659    /// `DataType::Text` in that case. Persisted in catalog
1660    /// FILE_VERSION 29+; older catalogs deserialise with None.
1661    pub user_enum_type: Option<String>,
1662    /// v7.17.0 Phase 1.5 — when the column is bound to a user-
1663    /// defined DOMAIN (the parser saw an unknown type ident and
1664    /// the engine resolved it against `catalog.domain_types`),
1665    /// this carries the domain name. `ty` is the domain's base
1666    /// type; INSERT/UPDATE re-evaluates the domain's CHECK list
1667    /// + NOT NULL against the cell value. Persisted in catalog
1668    /// FILE_VERSION 30+; older catalogs deserialise with None.
1669    pub user_domain_type: Option<String>,
1670    /// v7.39 (read01 round 56) — when the column is bound to a user-defined
1671    /// COMPOSITE type. `ty` stays `DataType::Jsonb` (the on-disk form), but the
1672    /// engine REHYDRATES the stored JSON into a `Value::Composite` on read, so
1673    /// field access `(p).x`, `= ROW(…)`, ordering and the canonical `(2,b)`
1674    /// text form all work — they were already implemented on Value::Composite;
1675    /// what was missing was that the column never recorded WHICH composite type
1676    /// it holds (this field's doc comment existed for two releases, the field
1677    /// itself did not). Persisted in the composite-column appendix
1678    /// (FILE_VERSION 63+); older catalogs deserialise with None.
1679    pub user_composite_type: Option<String>,
1680    /// v7.39 (read01 round 59) — column-level privileges (PG
1681    /// `pg_attribute.attacl`). `GRANT SELECT (pub) ON t TO dan` lands here and
1682    /// does NOT touch the table's `relacl`. Empty = no column grant, which is
1683    /// every column until one is made.
1684    pub acl: Vec<AclItem>,
1685    /// v7.17.0 Phase 2.1 — MySQL `ON UPDATE CURRENT_TIMESTAMP`
1686    /// column attribute. When `Some(expr_src)`, an UPDATE that
1687    /// does NOT bind this column overrides the new value with
1688    /// the engine-evaluated expression (always `now()` in
1689    /// v7.17.0). Stored as Display-form source so storage
1690    /// stays free of spg-sql; the engine re-parses at UPDATE
1691    /// time. Persisted in catalog FILE_VERSION 32+; older
1692    /// catalogs deserialise with None — preserves the existing
1693    /// "silent ignore" behaviour for snapshots written before
1694    /// the upgrade.
1695    pub on_update_runtime: Option<String>,
1696    /// v7.17.0 Phase 2.5 — text collation. Pre-2.5 SPG accepted
1697    /// `COLLATE <name>` clauses but discarded the name, so a
1698    /// column declared `COLLATE "case_insensitive"` (or any
1699    /// MySQL `_ci` collation) still compared byte-wise — a
1700    /// Tier-S silent failure where `WHERE name = 'foo'` never
1701    /// matched stored `'Foo'`. This carries the parser-derived
1702    /// classification so the engine's WHERE evaluator can route
1703    /// text equality through a case-aware compare. `Binary` (the
1704    /// default) preserves the prior byte-wise behaviour. Only
1705    /// CaseInsensitive lands in the catalog appendix — Binary
1706    /// columns stay implicit, keeping snapshots compact.
1707    /// Persisted in catalog FILE_VERSION 34+; older catalogs
1708    /// deserialise every column as `Binary`.
1709    pub collation: Collation,
1710    /// v7.17.0 Phase 4.4 — MySQL `UNSIGNED` modifier flag. Drives
1711    /// engine-side INSERT / UPDATE range enforcement (rejects
1712    /// negative values on UNSIGNED int columns). Pre-4.4 the
1713    /// parser consumed and discarded the keyword silently, so
1714    /// every UNSIGNED column quietly accepted negatives — a
1715    /// Tier-A correctness drift. Sparse: only UNSIGNED columns
1716    /// land in the catalog appendix; the default `false` keeps
1717    /// snapshots compact for the common signed-int path.
1718    /// Persisted in catalog FILE_VERSION 35+; older catalogs
1719    /// deserialise every column as `is_unsigned = false`.
1720    pub is_unsigned: bool,
1721    /// v7.17.0 Phase 3.P0-36 — MySQL inline `ENUM('a','b','c')`
1722    /// value list. Distinct from `user_enum_type` (which points
1723    /// to a separately CREATE TYPE'd PG enum); this carries the
1724    /// column-local list MySQL DDL declares inline. When `Some`,
1725    /// `ty` is `DataType::Text` and INSERT/UPDATE validates the
1726    /// cell value against this list. Variant ORDER is preserved
1727    /// (MySQL uses it for `ORDER BY col`). Sparse: only ENUM
1728    /// columns land in the catalog appendix.
1729    /// Persisted in catalog FILE_VERSION 41+; older catalogs
1730    /// deserialise with None — preserves silent-drop behaviour
1731    /// for snapshots written before P0-36.
1732    pub inline_enum_variants: Option<Vec<String>>,
1733    /// v7.17.0 Phase 3.P0-37 — MySQL inline `SET('a','b','c')`
1734    /// variant list. Storage is TEXT (canonical comma-joined in
1735    /// definition order, de-duplicated). INSERT/UPDATE validates
1736    /// every comma-separated token against this list. Sparse:
1737    /// only SET columns land in the catalog appendix.
1738    /// Persisted in catalog FILE_VERSION 42+; older catalogs
1739    /// deserialise with None.
1740    pub inline_set_variants: Option<Vec<String>>,
1741    /// v7.37.7(sentori Epic 3 P1)— `GENERATED ALWAYS AS (<expr>)
1742    /// STORED` computed-column source. When `Some`, INSERT / UPDATE
1743    /// recompute the cell against the candidate row(re-parse the
1744    /// stored Display form and evaluate)and overwrite any
1745    /// user-supplied value, matching PG's stored-generated-column
1746    /// semantics. `None` (the default) preserves the regular
1747    /// "column value is whatever the caller passed" path.
1748    /// Persisted in catalog FILE_VERSION 50+; older catalogs
1749    /// deserialise with None.
1750    pub generated_stored_expr: Option<String>,
1751    /// v7.38 (read01) — `GENERATED ALWAYS AS IDENTITY`. Both identity
1752    /// flavours set `auto_increment`; this additionally marks the ALWAYS
1753    /// flavour, whose explicit INSERT value PG rejects ("cannot insert a
1754    /// non-DEFAULT value into column …") unless `OVERRIDING SYSTEM VALUE`.
1755    /// `false` (serial / `BY DEFAULT`) keeps the permissive path. In-memory
1756    /// only for now — not yet in the catalog appendix, so a reloaded table
1757    /// deserialises as `false` (the pre-existing permissive behaviour).
1758    pub identity_always: bool,
1759    /// v7.38 (read01) — the DEFAULT expression's source text, deparsed to
1760    /// PG-compatible form at CREATE TABLE time (e.g. `0`, `(3 + 4)`,
1761    /// `'hi'::text`, `now()`, `CURRENT_DATE`). Distinct from `default`
1762    /// (the coerced value the INSERT path fills) and `runtime_default`
1763    /// (the recompute-per-row Display form): those lose the source
1764    /// spelling, so `information_schema.columns.column_default` /
1765    /// `pg_attrdef` / `pg_get_expr` reported the coerced render
1766    /// (`0.00` for `numeric(10,2) DEFAULT 0`) instead of PG's `0`.
1767    /// `None` for a column with no explicit default. Persisted in catalog
1768    /// FILE_VERSION 58+; older catalogs deserialise with None.
1769    pub default_text: Option<String>,
1770    /// v7.39 (round 220) — `ALTER TABLE … ALTER COLUMN … RESTART [WITH n]`
1771    /// on an identity column. SPG's identity allocation is a max+1 scan;
1772    /// this floor lifts the next allocated value to at least `n`
1773    /// (`max(max+1, n)`) — exactly what a dump-restore RESTART needs, and
1774    /// safer than PG for a backward RESTART (no duplicate-key landmine).
1775    /// Persisted in the FILE_VERSION 73+ sparse appendix; older catalogs
1776    /// deserialise with None.
1777    pub auto_restart: Option<i64>,
1778    /// v7.39 (read01 round 78) — this column is the ONLY column of a FROM item
1779    /// that calls a function returning a BASE type, so the item's row type IS
1780    /// this column: a whole-row reference collapses to the value
1781    /// (`SELECT j FROM jsonb_array_elements('[1]') AS j` → `1`, PG). Runtime
1782    /// only — a catalogued table column is never one, and it is not persisted.
1783    pub scalar_row_source: bool,
1784    /// v7.39 (round 386, type-fidelity epic P1) — the declared MySQL narrow
1785    /// integer width (TINYINT / MEDIUMINT) whose range the storage `ty`
1786    /// (SmallInt / Int) is too wide to enforce. `None` for every other
1787    /// column. Drives the epic-P2 write-path range check. Persisted in the
1788    /// FILE_VERSION 81+ sparse appendix; older catalogs deserialise as None.
1789    pub mysql_int_width: Option<MysqlIntWidth>,
1790    /// v7.39 (round 424, type-fidelity epic) — the declared MySQL
1791    /// fractional-seconds precision of a temporal column: `DATETIME(3)` is
1792    /// `Some(3)`, a BARE `DATETIME` / `TIME` / `TIMESTAMP` is `Some(0)`
1793    /// (MySQL's default is zero — the fraction is dropped on write), and
1794    /// `None` means "not a MySQL-declared temporal column", which is every
1795    /// PG column and leaves microsecond behaviour untouched.
1796    ///
1797    /// Drives write-path truncation (toward zero) and render padding
1798    /// (exactly this many digits, `.000` when the fraction is zero).
1799    /// Persisted in the FILE_VERSION 82+ sparse appendix; older catalogs
1800    /// deserialise as None.
1801    pub mysql_fsp: Option<u8>,
1802    /// v7.39.2 — this column was DECLARED `TIMESTAMP` in a MySQL
1803    /// session.
1804    ///
1805    /// MySQL and MariaDB both keep `timestamp` and `datetime` apart in
1806    /// `SHOW CREATE TABLE`, `SHOW COLUMNS` and `information_schema`
1807    /// (measured on 9.7.2 and 12.3.3); SPG stores both as
1808    /// `DataType::Timestamp` and so reported `datetime` for both. A
1809    /// client dumping and reloading had the column's declared type
1810    /// SILENTLY CHANGED — and MySQL's TIMESTAMP is not DATETIME: it has
1811    /// a different range and converts to and from UTC.
1812    ///
1813    /// What this records is the SPELLING, which is the half a dump
1814    /// round-trips. The storage and the semantics are unchanged, and
1815    /// that gap is written down rather than papered over.
1816    ///
1817    /// Persisted in the FILE_VERSION 93+ sparse appendix; older
1818    /// catalogs deserialise as `false`.
1819    pub mysql_declared_timestamp: bool,
1820    /// v7.39.3 — a MySQL `FLOAT(m,d)` / `DOUBLE(m,d)`'s declared pair.
1821    ///
1822    /// The digits are NOT a display hint, which is what SPG's comment
1823    /// claimed and 7.39.2 recorded as a residual: MySQL 9.7.2 ROUNDS on
1824    /// write (3.14159265358979 into either stores 3.14) and refuses a
1825    /// value wider than `m` with errno 1264. SPG accepted the syntax and
1826    /// kept the full double, so a column declared for money held more
1827    /// precision than the schema said and every reader saw a different
1828    /// number from MySQL's.
1829    ///
1830    /// Persisted in the FILE_VERSION 94+ sparse appendix; older catalogs
1831    /// deserialise as None, which is "no declared pair".
1832    pub mysql_float_md: Option<(u8, u8)>,
1833}
1834
1835/// v7.17.0 Phase 2.5 — column-level text collation. Drives the
1836/// engine's WHERE / GROUP BY equality routing for `Value::Text`.
1837/// Only two variants are modelled in v7.17:
1838///   * `Binary`  — byte-wise comparison (the SPG default;
1839///                 matches PG `COLLATE "C"` / `pg_catalog.default`
1840///                 and MySQL `*_bin`).
1841///   * `CaseInsensitive` — ASCII case-folded comparison (like
1842///                 MySQL `*_ci` collations; PG has NO built-in
1843///                 collation of this name — round-761 audit: a
1844///                 nondeterministic ICU collation must be CREATEd
1845///                 there first). Non-ASCII bytes
1846///                 still compare byte-wise; full ICU folding is
1847///                 out of v7.17 scope.
1848/// New variants append at the end — older catalogs read missing
1849/// columns as `Binary`.
1850#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1851pub enum Collation {
1852    Binary,
1853    CaseInsensitive,
1854}
1855
1856/// v7.39 (round 386, type-fidelity epic P1) — the declared MySQL narrow
1857/// integer type for a column whose storage `DataType` cannot express it.
1858/// MySQL `TINYINT` (i8, -128..127) collapses to `DataType::SmallInt` (i16)
1859/// and `MEDIUMINT` (24-bit) to `DataType::Int` (i32) — both wider than the
1860/// declared type, so a range check against `ty` alone accepts out-of-range
1861/// values (`INSERT 128 INTO TINYINT` is stored silently where MariaDB
1862/// strict raises ERROR 1264). This annotation records the lost width so the
1863/// write path (epic P2) can enforce the real bounds. `SMALLINT` / `INT` /
1864/// `BIGINT` need no marker — their storage `DataType` is already faithful.
1865/// Sparse: only TINYINT / MEDIUMINT columns carry it; persisted in the
1866/// FILE_VERSION 81+ appendix, older catalogs deserialise as None.
1867#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1868pub enum MysqlIntWidth {
1869    /// MySQL `TINYINT` — signed -128..127, unsigned 0..255. Storage i16.
1870    Tiny,
1871    /// MySQL `SMALLINT UNSIGNED` — 0..65535. Storage widened to i32 (a
1872    /// signed SMALLINT keeps `DataType::SmallInt` and carries no marker).
1873    Small,
1874    /// MySQL `MEDIUMINT` — signed -8388608..8388607, unsigned 0..16777215.
1875    /// Storage i32.
1876    Medium,
1877    /// MySQL `INT UNSIGNED` — 0..4294967295. Storage widened to i64 (a
1878    /// signed INT keeps `DataType::Int` and carries no marker).
1879    Int,
1880    /// v7.39 (round 471, epic P4b) — MySQL `BIGINT UNSIGNED` —
1881    /// 0..18446744073709551615. i64 stops at 2^63-1, so the storage tag is
1882    /// widened to `Numeric` (i128-backed, scale 0), which already compares,
1883    /// orders, indexes and renders as an exact integer. A signed BIGINT
1884    /// keeps `DataType::BigInt` and carries no marker.
1885    Big,
1886}
1887
1888/// v7.39 (round 363, M4 P1) — MySQL's default accent- and
1889/// case-insensitive fold (`utf8mb4_uca1400_ai_ci`).
1890///
1891/// This is the primitive M4 rests on: a session on the MySQL dialect
1892/// compares, groups, sorts and de-duplicates text by its FOLDED form, so
1893/// `Foo` = `foo` = `FOO` and, because the default collation is accent-
1894/// insensitive too, `Bär` = `bar`. The later stages (read path, then the
1895/// UNIQUE / index write path) all route through here so they cannot fold
1896/// differently from one another.
1897///
1898/// The fold is more than case + strip-combining: MariaDB EXPANDS some
1899/// letters — `ß` → `ss`, `æ` → `ae`, `œ` → `oe` — which is why the result
1900/// is built as a `String` rather than mapped char-for-char. Every mapping
1901/// below was measured on MariaDB 11 (`'Bär'='bar'` is 1, `'straße'=
1902/// 'strasse'` is 1, `'a'='æ'` is 0, `'s'='ß'` is 0). Characters with no
1903/// entry keep their lower-cased self, so ASCII and unknown scripts pass
1904/// through unchanged.
1905#[must_use]
1906pub fn mysql_ci_fold(s: &str) -> String {
1907    let mut out = String::with_capacity(s.len());
1908    for ch in s.chars() {
1909        // Lower-case first (`À` → `à`, `Æ` → `æ`), then fold the base.
1910        for lc in ch.to_lowercase() {
1911            match fold_latin_base(lc) {
1912                Some(base) => out.push_str(base),
1913                None => out.push(lc),
1914            }
1915        }
1916    }
1917    out
1918}
1919
1920/// The fold used to COMPARE / GROUP / de-dup text on the MySQL dialect:
1921/// case- and accent-insensitive, and **trailing spaces significant**.
1922///
1923/// v7.38.17 — this used to strip trailing spaces first, and its comment
1924/// said why: "measured on MariaDB 11". MariaDB's default collation is
1925/// PAD SPACE, so that measurement was right about MariaDB. SPG
1926/// advertises `8.0.0-spg-v…` on the MySQL wire, and MySQL 8.0's default
1927/// `utf8mb4_0900_ai_ci` is **NO PAD**. The rule had been calibrated
1928/// against the engine we do not claim to be.
1929///
1930/// Measured today, MySQL 9.7.2 against MariaDB 12.3.2, each in its own
1931/// default collation, over rows `'alpha'` and `'alpha  '`:
1932///
1933/// | | MySQL | MariaDB |
1934/// |---|---|---|
1935/// | `WHERE s = 'alpha'` | 1 | 1,2 |
1936/// | `s IN ('alpha','beta')` | 1,3,4 | 1,2,3,4 |
1937/// | `COUNT(DISTINCT s)` | 3 | 2 |
1938/// | `GROUP BY s` groups | 3 | 2 |
1939/// | `JOIN ON v.s = r.s` | 1/10, 2/20 | all four pairs |
1940///
1941/// SPG answered MariaDB's four and MySQL's join — the same question
1942/// decided differently by two paths, which is the shape v7.38.13,
1943/// v7.38.14 and v7.38.16 were each spent on.
1944///
1945/// `CHAR(n)` is a separate question and keeps its old answer: BOTH
1946/// engines ignore a CHAR's trailing spaces, because that is a property
1947/// of the TYPE rather than of the collation. Use
1948/// [`mysql_compare_fold_char`] for a `BpChar` cell.
1949///
1950/// Only literal spaces ever padded — a tab is significant either way —
1951/// and neither function is used by `LIKE`, whose pattern treats a
1952/// trailing space literally.
1953/// Whether a collation of this NAME orders by bytes.
1954///
1955/// v7.38.18 (S0) — pure string classification, and it lives here because
1956/// storage has to ask it: an index whose column collates by a locale
1957/// cannot key on the raw text, and the write path is here. The engine's
1958/// `collate::is_byte_wise` delegates to this one, for the reason the SQL
1959/// type spellings have one owner.
1960///
1961/// `C`, `POSIX`, MySQL's `binary` and every `_bin` family member. The
1962/// encoding suffix rides along: PG publishes `C.utf8` beside `C`.
1963pub fn collation_is_byte_wise(collation: &str) -> bool {
1964    let name = collation.trim();
1965    let base = name.split(['.', '@']).next().unwrap_or(name);
1966    base.eq_ignore_ascii_case("C")
1967        || base.eq_ignore_ascii_case("POSIX")
1968        || base.eq_ignore_ascii_case("binary")
1969        || base
1970            .rsplit_once('_')
1971            .is_some_and(|(_, tail)| tail.eq_ignore_ascii_case("bin"))
1972}
1973
1974/// v7.38.18 (S0/S2) — does an index on a column of this collation key
1975/// by an ICU SORT KEY rather than by the raw text?
1976///
1977/// True for a locale collation (`en_US.utf8`, `de_DE`), which orders by
1978/// rules a byte comparison cannot express.
1979///
1980/// False for byte-wise names, and false for MySQL's folding collations
1981/// (`utf8mb4_0900_ai_ci` and family). Those fold rather than collate,
1982/// and the engine has folded them since v7.37 — routing them here made
1983/// an indexed `s = 'ALPHA'` over the MySQL wire answer nothing where
1984/// MySQL 9.7.1 answers one row, because ICU at PG's strength does not
1985/// call `ALPHA` and `alpha` equal.
1986///
1987/// One owner for the same reason the byte-wise question has one: the
1988/// engine builds the PROBE and this crate builds the ENTRIES, and a
1989/// probe built in another space finds nothing — which reads exactly
1990/// like "no matching rows".
1991pub fn collation_uses_sort_key(collation: &str) -> bool {
1992    if collation_is_byte_wise(collation) {
1993        return false;
1994    }
1995    let name = collation.trim();
1996    let base = name.split(['.', '@']).next().unwrap_or(name);
1997    let lower = base.to_ascii_lowercase();
1998    !(lower.ends_with("_ci") || lower.ends_with("_cs"))
1999}
2000
2001pub fn mysql_compare_fold(s: &str) -> String {
2002    mysql_ci_fold(s)
2003}
2004
2005/// The comparison form of one text value under the MySQL default
2006/// collation, or `None` for a value that is not text.
2007///
2008/// v7.38.18 — one function, applied to each side SEPARATELY, because
2009/// the pair is not the unit. Several sites matched
2010/// `(Text, Text) | (BpChar, BpChar)` and folded a pair; a CHAR compared
2011/// against a VARCHAR or against a literal is neither shape, so it fell
2012/// through and was compared by bytes — with the CHAR still carrying its
2013/// padding. `CASE c WHEN 'ALPHA'` on a `CHAR(8)` holding `'alpha'`
2014/// answered ELSE where MySQL 9.7.2 answers the branch.
2015///
2016/// Folding per value also states the rule correctly: whether trailing
2017/// spaces count is a property of EACH side's own type, so a pair whose
2018/// sides differ has two answers rather than one.
2019pub fn mysql_fold_value(v: &Value<'_>) -> Option<String> {
2020    match v {
2021        Value::BpChar(s) => Some(mysql_compare_fold_char(s)),
2022        Value::Text(s) => Some(mysql_compare_fold(s)),
2023        _ => None,
2024    }
2025}
2026
2027/// [`mysql_compare_fold`] for a `CHAR(n)` cell, whose trailing spaces
2028/// are padding rather than data.
2029///
2030/// Measured on both engines: over `'alpha'` and `'alpha  '` in a
2031/// `CHAR(8)`, `WHERE s = 'alpha'` returns both rows and
2032/// `COUNT(DISTINCT s)` is 2 (four rows folding to two values) — MySQL
2033/// 9.7.2 and MariaDB 12.3.2 agree, unlike the VARCHAR case above.
2034pub fn mysql_compare_fold_char(s: &str) -> String {
2035    mysql_ci_fold(s.trim_end_matches(' '))
2036}
2037
2038/// The base letter(s) a lower-cased Latin character folds to, or `None`
2039/// when it is already a base / has no fold. Expansions (`ß` → `ss`) are
2040/// why this returns a string.
2041fn fold_latin_base(c: char) -> Option<&'static str> {
2042    Some(match c {
2043        'à' | 'á' | 'â' | 'ã' | 'ä' | 'å' | 'ā' | 'ă' | 'ą' => "a",
2044        'æ' => "ae",
2045        'ç' | 'ć' | 'č' | 'ĉ' | 'ċ' => "c",
2046        'ð' | 'ď' | 'đ' => "d",
2047        'è' | 'é' | 'ê' | 'ë' | 'ē' | 'ĕ' | 'ė' | 'ę' | 'ě' => "e",
2048        'ĝ' | 'ğ' | 'ġ' | 'ģ' => "g",
2049        'ì' | 'í' | 'î' | 'ï' | 'ĩ' | 'ī' | 'ĭ' | 'į' => "i",
2050        'ĵ' => "j",
2051        'ķ' => "k",
2052        'ł' | 'ĺ' | 'ļ' | 'ľ' => "l",
2053        'ñ' | 'ń' | 'ņ' | 'ň' => "n",
2054        'ò' | 'ó' | 'ô' | 'õ' | 'ö' | 'ø' | 'ō' | 'ŏ' | 'ő' => "o",
2055        'œ' => "oe",
2056        'ŕ' | 'ŗ' | 'ř' => "r",
2057        'ś' | 'š' | 'ŝ' | 'ş' => "s",
2058        'ß' => "ss",
2059        'ţ' | 'ť' | 'ŧ' => "t",
2060        'ù' | 'ú' | 'û' | 'ü' | 'ũ' | 'ū' | 'ŭ' | 'ů' | 'ű' | 'ų' => "u",
2061        'ý' | 'ÿ' => "y",
2062        'ź' | 'ž' | 'ż' => "z",
2063        _ => return None,
2064    })
2065}
2066
2067#[allow(clippy::derivable_impls)]
2068impl Default for Collation {
2069    fn default() -> Self {
2070        Self::Binary
2071    }
2072}
2073
2074impl Collation {
2075    /// Wire tag persisted in the FILE_VERSION 34+ catalog appendix.
2076    /// Stable: future variants append above the recognised range
2077    /// and unknown tags read back as `Binary` for forward-compat
2078    /// on rollback.
2079    pub const TAG_BINARY: u8 = 0;
2080    pub const TAG_CASE_INSENSITIVE: u8 = 1;
2081}
2082
2083/// v7.39 (RLS) — the command a policy applies to. `ALL` is the default and
2084/// covers every command; the others scope the policy to one statement kind.
2085/// Persisted as a single byte in the policy appendix (FILE_VERSION 59+).
2086#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2087pub enum PolicyCmd {
2088    All,
2089    Select,
2090    Insert,
2091    Update,
2092    Delete,
2093}
2094
2095impl PolicyCmd {
2096    /// PG `pg_policy.polcmd` single-char encoding.
2097    #[must_use]
2098    pub const fn as_pg_char(self) -> char {
2099        match self {
2100            Self::All => '*',
2101            Self::Select => 'r',
2102            Self::Insert => 'a',
2103            Self::Update => 'w',
2104            Self::Delete => 'd',
2105        }
2106    }
2107
2108    /// PG `pg_policies.cmd` word form.
2109    #[must_use]
2110    pub const fn as_pg_word(self) -> &'static str {
2111        match self {
2112            Self::All => "ALL",
2113            Self::Select => "SELECT",
2114            Self::Insert => "INSERT",
2115            Self::Update => "UPDATE",
2116            Self::Delete => "DELETE",
2117        }
2118    }
2119
2120    #[must_use]
2121    pub const fn to_wire_byte(self) -> u8 {
2122        match self {
2123            Self::All => 0,
2124            Self::Select => 1,
2125            Self::Insert => 2,
2126            Self::Update => 3,
2127            Self::Delete => 4,
2128        }
2129    }
2130
2131    #[must_use]
2132    pub const fn from_wire_byte(b: u8) -> Option<Self> {
2133        match b {
2134            0 => Some(Self::All),
2135            1 => Some(Self::Select),
2136            2 => Some(Self::Insert),
2137            3 => Some(Self::Update),
2138            4 => Some(Self::Delete),
2139            _ => None,
2140        }
2141    }
2142}
2143
2144/// v7.39 (RLS) — one `CREATE POLICY` object, stored per table. The `using_expr`
2145/// / `with_check_expr` hold the qualifying expression's `Display` form
2146/// (re-parsed and evaluated per row at enforcement time, exactly like
2147/// `TableSchema.checks`); `None` means the clause was absent. `roles` empty =
2148/// PUBLIC. Persisted in the policy appendix (FILE_VERSION 59+).
2149#[derive(Debug, Clone, PartialEq)]
2150pub struct PolicyDef {
2151    pub name: String,
2152    pub cmd: PolicyCmd,
2153    /// `true` = PERMISSIVE (default, OR-combined), `false` = RESTRICTIVE
2154    /// (AND-combined).
2155    pub permissive: bool,
2156    pub roles: Vec<String>,
2157    pub using_expr: Option<String>,
2158    pub with_check_expr: Option<String>,
2159}
2160
2161#[derive(Debug, Clone, PartialEq)]
2162pub struct TableSchema {
2163    pub name: String,
2164    pub columns: Vec<ColumnSchema>,
2165    /// v6.7.2 — per-table hot-tier byte budget override. `None`
2166    /// falls through to the global `SPG_HOT_TIER_BYTES` setting;
2167    /// `Some(n)` overrides it for this specific table. Set via
2168    /// `ALTER TABLE t SET hot_tier_bytes = X`. Persisted in
2169    /// catalog FILE_VERSION 11+.
2170    pub hot_tier_bytes: Option<u64>,
2171    /// v7.6.1 — FOREIGN KEY constraints declared on this table.
2172    /// Engine maintains this in lock-step with `spg-sql`'s parser
2173    /// AST; the storage layer carries the on-disk shape so a
2174    /// catalog snapshot round-trips without external mapping.
2175    /// Persisted in catalog FILE_VERSION 13+. Older catalogs
2176    /// deserialise with an empty vec.
2177    pub foreign_keys: Vec<ForeignKeyConstraint>,
2178    /// v7.9.19 — composite UNIQUE / PRIMARY KEY constraints
2179    /// declared at the table level. Each entry's leading column
2180    /// has a BTree index (created via the constraint), and INSERT
2181    /// path enforces the full-tuple uniqueness via a scan keyed
2182    /// by the leading column. Persisted in catalog FILE_VERSION
2183    /// 15+. Older catalogs (≤ 14) deserialise with an empty vec.
2184    pub uniqueness_constraints: Vec<UniquenessConstraint>,
2185    /// v7.39 (round 210) — `EXCLUDE` constraints declared at the table level.
2186    /// Enforced on INSERT/UPDATE by a full live-row scan re-checking each
2187    /// element's operator (no equality index can answer overlap). Persisted
2188    /// in catalog FILE_VERSION 72+; older catalogs deserialise with an empty
2189    /// vec.
2190    pub exclusion_constraints: Vec<ExclusionConstraint>,
2191    /// v7.13.0 — `CHECK (<expr>)` predicates declared on this
2192    /// table. Both column-level inline `CHECK (…)` and
2193    /// table-level `CHECK (…)` fold into this list. Each entry
2194    /// is the AST Expr's `Display` form, re-parsed on every
2195    /// INSERT/UPDATE and evaluated against the candidate row.
2196    /// A false / NULL result rejects the mutation (PG semantics).
2197    /// Persisted in catalog FILE_VERSION 23+. Older catalogs
2198    /// deserialise with an empty vec. v7.39 (read01 round 48) — each entry
2199    /// now carries the user's constraint name too (FILE_VERSION 60+).
2200    pub checks: Vec<CheckConstraint>,
2201    /// v7.37.6-B — declarative partition role(sentori Epic 2 P0).
2202    /// `None` = 普通表(后向兼容,< v49 catalog 默认 None)。
2203    /// `Some(Parent { … })` = `CREATE TABLE p (...) PARTITION BY RANGE (key_col)` 父表 —
2204    /// 父表自己 `rows` 永远空,INSERT 在引擎层路由到命中的 child。
2205    /// `Some(Range { … })` = `CREATE TABLE c PARTITION OF p FOR VALUES FROM (a) TO (b)` 范围子表。
2206    /// `Some(Default { … })` = `CREATE TABLE c PARTITION OF p DEFAULT` 兜底子表。
2207    /// 持久化于 FILE_VERSION 49+。
2208    pub partition_role: Option<PartitionRole>,
2209    /// v7.39 (RLS) — `CREATE POLICY` objects on this table, independent of the
2210    /// `row_security` flag (PG stores policies even on non-RLS tables; they
2211    /// only take effect once RLS is enabled). Persisted in the policy appendix
2212    /// (FILE_VERSION 59+). Older catalogs deserialise with an empty vec.
2213    pub policies: Vec<PolicyDef>,
2214    /// v7.39 (RLS) — `ALTER TABLE … ENABLE ROW LEVEL SECURITY`
2215    /// (PG `pg_class.relrowsecurity`). Fresh table = `false`.
2216    pub row_security: bool,
2217    /// v7.39 (RLS) — `ALTER TABLE … FORCE ROW LEVEL SECURITY`
2218    /// (PG `pg_class.relforcerowsecurity`); subjects the table owner to RLS
2219    /// too. Fresh table = `false`.
2220    pub force_row_security: bool,
2221    /// v7.39 (read01 round 57, ACL) — the role that owns this table: whoever
2222    /// ran CREATE TABLE (PG `pg_class.relowner`). The owner holds every
2223    /// privilege implicitly and is the only role that may ALTER / DROP it.
2224    /// `None` = an image written before FILE_VERSION 64, which predates roles
2225    /// entirely; those tables read back as owned by the login role.
2226    pub owner: Option<String>,
2227    /// v7.39 (read01 round 57, ACL) — explicit GRANTs on this table
2228    /// (PG `pg_class.relacl`). EMPTY means "never granted": PG leaves relacl
2229    /// NULL while only the owner's implicit privileges apply, and materialises
2230    /// the whole list — owner's default entry included — on the first GRANT.
2231    /// Once materialised it stays, even after every grant is revoked.
2232    pub acl: Vec<AclItem>,
2233}
2234
2235/// v7.39 (read01 round 57) — one PG `aclitem`: what `grantee` may do to a
2236/// table, and who granted it. Renders as `grantee=privs/grantor`, with an
2237/// EMPTY grantee meaning PUBLIC (`=r/owner`).
2238#[derive(Debug, Clone, PartialEq, Eq)]
2239pub struct AclItem {
2240    /// The role the privileges are held by. Empty string = PUBLIC.
2241    pub grantee: String,
2242    /// Bitmask over `priv_bits`: which privileges are held.
2243    pub privs: u16,
2244    /// Bitmask over `priv_bits`: which of them carry WITH GRANT OPTION
2245    /// (PG renders those with a trailing `*` — `r*`).
2246    pub grantable: u16,
2247    /// The role that ran the GRANT.
2248    pub grantor: String,
2249}
2250
2251/// v7.39 (read01 round 57) — the table-privilege bits, in PG's `aclitem`
2252/// rendering order (`arwdDxtm`). The order matters: `relacl` output is
2253/// byte-compared against PG.
2254pub mod priv_bits {
2255    pub const INSERT: u16 = 1 << 0; // a
2256    pub const SELECT: u16 = 1 << 1; // r
2257    pub const UPDATE: u16 = 1 << 2; // w
2258    pub const DELETE: u16 = 1 << 3; // d
2259    pub const TRUNCATE: u16 = 1 << 4; // D
2260    pub const REFERENCES: u16 = 1 << 5; // x
2261    pub const TRIGGER: u16 = 1 << 6; // t
2262    pub const MAINTAIN: u16 = 1 << 7; // m
2263    /// v7.39 (read01 round 60) — the non-table privileges. They share the
2264    /// bitmask because an aclitem is an aclitem whatever it hangs off; which
2265    /// bits are MEANINGFUL depends on the object (a sequence has r / w / U, a
2266    /// schema has U / C, a database has C / c / T).
2267    pub const USAGE: u16 = 1 << 8; // U
2268    pub const CREATE: u16 = 1 << 9; // C
2269    pub const CONNECT: u16 = 1 << 10; // c
2270    pub const TEMPORARY: u16 = 1 << 11; // T
2271    pub const EXECUTE: u16 = 1 << 12; // X
2272    /// Every TABLE privilege — what `GRANT ALL ON <table>` grants and what a
2273    /// table's owner holds.
2274    pub const ALL: u16 =
2275        INSERT | SELECT | UPDATE | DELETE | TRUNCATE | REFERENCES | TRIGGER | MAINTAIN;
2276    /// `GRANT ALL ON SEQUENCE` — PG renders a sequence owner's default as `rwU`.
2277    pub const ALL_SEQUENCE: u16 = SELECT | UPDATE | USAGE;
2278    /// `GRANT ALL ON SCHEMA` — `UC`.
2279    pub const ALL_SCHEMA: u16 = USAGE | CREATE;
2280    /// `GRANT ALL ON DATABASE` — `CTc`.
2281    pub const ALL_DATABASE: u16 = CREATE | CONNECT | TEMPORARY;
2282    /// `GRANT ALL ON FUNCTION` — just `X`.
2283    pub const ALL_FUNCTION: u16 = EXECUTE;
2284}
2285
2286/// v7.37.6-B — partition 三态(parent / range child / default child)。
2287#[derive(Debug, Clone, PartialEq, Eq)]
2288pub enum PartitionRole {
2289    Parent {
2290        kind: PartitionKind,
2291        /// 父表 columns 中 key 列的下标(单列 v7.37.6-B,
2292        /// `Vec` 为将来扩多列预留)。
2293        key_column_positions: Vec<usize>,
2294        /// `CREATE INDEX ON parent (…)` 的 Display-form 源串。
2295        /// child 创建时再 parse + 在 child 上 execute,这样 future
2296        /// child 也自动继承父表索引。fan-out 实施在引擎层。
2297        index_template_sources: Vec<String>,
2298    },
2299    Range {
2300        parent_name: String,
2301        /// 半开区间下界(`>=`,SQL `FROM (lower)`).
2302        lower: PartitionBound,
2303        /// 半开区间上界(`<`,SQL `TO (upper)`).
2304        upper: PartitionBound,
2305    },
2306    /// v7.37.16 (16.1) — LIST child:行属于本 child iff key ∈ values。
2307    /// `values` 在 child 创建时从 SQL `FOR VALUES IN (lit, …)` 求值;
2308    /// 跟 PG 一样,显式 NULL ∈ values 由 caller 单独处理(不在
2309    /// PartitionBound 内表达 NULL)。
2310    List {
2311        parent_name: String,
2312        values: Vec<PartitionBound>,
2313    },
2314    /// v7.39 (round 645) — PG 表继承的 CHILD:`CREATE TABLE c (…)
2315    /// INHERITS (p1, p2)`。跟分区 child 的三个本质区别(实测 PG18):
2316    ///   * 父表**自己有行**(分区父表永远空),所以父表的联合体要含自身;
2317    ///   * `INSERT INTO 父表` **不路由**到 child(分区会路由);
2318    ///   * `DROP TABLE 父表` 不带 CASCADE **报错**(分区父表连子表一起删)。
2319    /// 多父继承合法,故 `parent_names` 是 Vec;`pg_inherits.inhseqno`
2320    /// 正是父表在这个列表里的位置(1-based)。
2321    Inherits {
2322        parent_names: Vec<String>,
2323    },
2324    /// v7.37.16 (16.2) — HASH child:行属于本 child iff
2325    /// `pg_compatible_hash(key) mod modulus == remainder`。
2326    /// PG 强制 `0 ≤ remainder < modulus`;parser/DDL 层先 gate。
2327    Hash {
2328        parent_name: String,
2329        modulus: u32,
2330        remainder: u32,
2331    },
2332    Default {
2333        parent_name: String,
2334    },
2335}
2336
2337/// v7.37.6-B — 分区策略。
2338///
2339/// - `Range`:半开区间 `[lower, upper)`(v7.37.6-B 初始)
2340/// - `List` (v7.37.16):枚举集合 — 行属于 partition iff key ∈ children list
2341/// - `Hash` (v7.37.16):`hash(key) mod modulus == remainder`
2342#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2343pub enum PartitionKind {
2344    Range,
2345    List,
2346    Hash,
2347}
2348
2349/// v7.37.6-B — partition 边界 literal。
2350///
2351/// v7.37.6-B 仅 `TimestampTz`(i64 microseconds since epoch);
2352/// v7.37.16 (16.6) 加全 PG 内建可比类型,匹配 `Value` 的对应 variant
2353/// 以避免 LIST membership 比较时的类型转换。
2354///
2355/// `MinValue` / `MaxValue` 对应 SQL `MINVALUE` / `MAXVALUE`,仅
2356/// Range 策略有意义(LIST 无 minvalue/maxvalue 概念,HASH 不
2357/// 使用 PartitionBound)。
2358#[derive(Debug, Clone, PartialEq, Eq)]
2359pub enum PartitionBound {
2360    MinValue,
2361    MaxValue,
2362    TimestampTz(i64),
2363    /// v7.37.16 (16.6) — BIGINT partition key.
2364    BigInt(i64),
2365    /// v7.37.16 (16.6) — INTEGER partition key (also covers
2366    /// `SERIAL` since SPG decomposes it to INTEGER + sequence).
2367    Int(i32),
2368    /// v7.37.16 (16.6) — SMALLINT partition key.
2369    SmallInt(i16),
2370    /// v7.37.16 (16.6) — DATE partition key. Stored as days
2371    /// since the Unix epoch (matches `Value::Date`).
2372    Date(i32),
2373    /// v7.37.16 (16.6) — TEXT / VARCHAR partition key.
2374    Text(alloc::string::String),
2375}
2376
2377impl PartitionBound {
2378    /// v7.37.16 (16.6) — true iff this bound's underlying value
2379    /// equals `other`'s. Used for LIST partition membership
2380    /// checks. Returns false for `MinValue` / `MaxValue`
2381    /// (sentinels — never literal equality).
2382    #[must_use]
2383    pub fn equals_value(&self, other: &Value<'_>) -> bool {
2384        match (self, other) {
2385            (PartitionBound::TimestampTz(a), Value::Timestamp(b)) => a == b,
2386            (PartitionBound::BigInt(a), Value::BigInt(b)) => a == b,
2387            (PartitionBound::Int(a), Value::Int(b)) => a == b,
2388            (PartitionBound::SmallInt(a), Value::SmallInt(b)) => a == b,
2389            (PartitionBound::Date(a), Value::Date(b)) => a == b,
2390            (PartitionBound::Text(a), Value::Text(b)) => a.as_str() == b.as_ref(),
2391            _ => false,
2392        }
2393    }
2394}
2395
2396/// v7.9.19 — composite UNIQUE / PRIMARY KEY constraint persisted
2397/// on the table schema. The leading column always has a BTree
2398/// index (created at CREATE TABLE time); INSERT enforcement
2399/// scans that index for collisions on the full column tuple.
2400/// v7.39 (read01 round 48) — a `CHECK` constraint: the SQL name the user
2401/// gave it (via `ADD CONSTRAINT <name> CHECK (...)` or the inline
2402/// `CONSTRAINT <name> CHECK (...)` form) plus the predicate source. `None`
2403/// name = unnamed, in which case `pg_constraint` synthesises PG's
2404/// `<table>_<col>_check` form. Names are persisted in the constraint-name
2405/// appendix (FILE_VERSION 60+); older catalogs deserialise with `None`.
2406#[derive(Debug, Clone, PartialEq, Eq)]
2407pub struct CheckConstraint {
2408    pub name: Option<String>,
2409    /// The AST Expr's `Display` form, re-parsed on every INSERT/UPDATE.
2410    pub expr: String,
2411    /// v7.39 (round 652) — `false` for a constraint added `NOT VALID`: the
2412    /// rows already in the table were never scanned against it, and
2413    /// `pg_constraint.convalidated` says so. It does NOT weaken the check on
2414    /// new rows — INSERT and UPDATE enforce it either way, as in PG.
2415    /// `VALIDATE CONSTRAINT` does the deferred scan and flips it. Persisted
2416    /// by the FILE_VERSION 87 appendix; older catalogs deserialise as `true`,
2417    /// which is what every constraint they could hold actually was.
2418    pub validated: bool,
2419}
2420
2421#[derive(Debug, Clone, PartialEq, Eq)]
2422pub struct UniquenessConstraint {
2423    /// `true` when this constraint was declared as `PRIMARY KEY`
2424    /// (vs `UNIQUE`). Semantically PK implies NOT NULL on all
2425    /// referenced columns; the engine enforces that at CREATE
2426    /// TABLE time.
2427    pub is_primary_key: bool,
2428    /// Column positions on the parent table. ≥ 1 element. For
2429    /// single-column UNIQUE this is exactly one position; the
2430    /// BTree index alone enforces it.
2431    pub columns: Vec<usize>,
2432    /// v7.13.0 — `UNIQUE NULLS NOT DISTINCT` modifier
2433    /// (mailrs round-5 G10; PG 15+ surface). When `true`, two
2434    /// rows whose constrained columns are all NULL collide on
2435    /// the constraint. Default (`false`) is the SQL-standard
2436    /// `NULLS DISTINCT` behaviour where any NULL passes.
2437    /// Persisted in catalog FILE_VERSION 23+.
2438    pub nulls_not_distinct: bool,
2439    /// v7.39 (read01 round 48) — the constraint's SQL name when the user
2440    /// supplied one (`ADD CONSTRAINT <name> PRIMARY KEY/UNIQUE (...)`, or
2441    /// the inline `CONSTRAINT <name>` form). `None` = unnamed, in which
2442    /// case `pg_constraint` synthesises PG's `<table>_pkey` /
2443    /// `<table>_<col>_key` form. DROP CONSTRAINT resolves the stored name
2444    /// first and falls back to the synthesised one, so catalogs written
2445    /// before this field (< FILE_VERSION 60) keep working unchanged.
2446    pub name: Option<String>,
2447    /// v7.39 (round 711) — `[NOT] DEFERRABLE`. Round 621 taught the parser
2448    /// to CONSUME the clause on PK/UNIQUE (the FK path had stored it since
2449    /// round 288); this is the storing half. Persisted in the v89 timing
2450    /// appendix.
2451    pub deferrable: bool,
2452    /// `INITIALLY DEFERRED`: the check belongs to COMMIT, not the
2453    /// statement, unless `SET CONSTRAINTS … IMMEDIATE` pulls it in.
2454    pub initially_deferred: bool,
2455}
2456
2457/// v7.39 (round 210) — an `EXCLUDE` constraint. Forbids two distinct live
2458/// rows from satisfying, for EVERY element, `new.col <op> existing.col`
2459/// (e.g. `EXCLUDE USING gist (during WITH &&)` = no two `during` ranges
2460/// overlap). Unlike a uniqueness constraint the operator is not equality,
2461/// so enforcement is a full live-row scan re-checking the operator (a real
2462/// GiST index that answers overlap in O(log n) is a later perf phase). A
2463/// NULL in any element column exempts the row (matching PG / UNIQUE NULL
2464/// semantics). Persisted in catalog FILE_VERSION 72+.
2465#[derive(Debug, Clone, PartialEq, Eq)]
2466pub struct ExclusionConstraint {
2467    /// The constraint's SQL name. PG auto-names an unnamed EXCLUDE
2468    /// `<table>_<leading-col>_excl`; the engine synthesises that at CREATE
2469    /// TABLE time so this is always populated.
2470    pub name: String,
2471    /// Access method spelled after `USING` (`gist`, `spgist`, …), lower-cased.
2472    /// `None` = no `USING` clause. Purely cosmetic for enforcement; it round-
2473    /// trips into `pg_get_constraintdef`.
2474    pub method: Option<String>,
2475    /// One `(column-position, operator-spelling)` pair per element, in
2476    /// declaration order. The operator spelling is the wire token (`&&`,
2477    /// `=`, `@>`, `<@`, `&<`, `&>`) evaluated against each existing row.
2478    pub elements: Vec<(usize, String)>,
2479}
2480
2481/// v7.6.1 — Storage-layer mirror of `spg_sql::ast::ForeignKeyConstraint`.
2482/// The engine's CREATE TABLE path translates between the two; keeping
2483/// them separate preserves the no-deps boundary between
2484/// `spg-storage` and `spg-sql`.
2485#[derive(Debug, Clone, PartialEq, Eq)]
2486pub struct ForeignKeyConstraint {
2487    /// Optional user-supplied constraint name (`CONSTRAINT <name>`
2488    /// prefix). Used by `ALTER TABLE DROP CONSTRAINT <name>` in
2489    /// v7.6.8; ignored by enforcement.
2490    pub name: Option<String>,
2491    /// Positions of local columns in this table's column list.
2492    /// Same arity as `parent_columns`.
2493    pub local_columns: Vec<usize>,
2494    /// Referenced parent table name.
2495    pub parent_table: String,
2496    /// Positions of parent columns in the parent's column list.
2497    /// Engine resolves these at CREATE TABLE time (after the parent
2498    /// schema is known) so enforcement paths can skip the name
2499    /// lookup on every row.
2500    pub parent_columns: Vec<usize>,
2501    /// Referential action when a parent row is deleted.
2502    pub on_delete: FkAction,
2503    /// Referential action when a parent row's referenced columns
2504    /// are updated.
2505    pub on_update: FkAction,
2506    /// v7.38 (read01, T29) — `MATCH SIMPLE | FULL`. Defaults to `Simple`.
2507    pub match_type: MatchType,
2508    /// v7.39 (round 288) — `[NOT] DEFERRABLE`.
2509    pub deferrable: bool,
2510    /// `INITIALLY DEFERRED`: the check runs at COMMIT rather than at
2511    /// the statement, unless `SET CONSTRAINTS … IMMEDIATE` pulls it in.
2512    pub initially_deferred: bool,
2513}
2514
2515/// v7.38 (read01, T29) — FK MATCH type. Mirrors `spg_sql::ast::MatchType`.
2516#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
2517pub enum MatchType {
2518    #[default]
2519    Simple,
2520    Full,
2521}
2522
2523impl MatchType {
2524    /// On-disk tag byte (catalog appendix, `FILE_VERSION` 55+).
2525    pub const fn tag(self) -> u8 {
2526        match self {
2527            Self::Simple => 0,
2528            Self::Full => 1,
2529        }
2530    }
2531    pub const fn from_tag(b: u8) -> Option<Self> {
2532        Some(match b {
2533            0 => Self::Simple,
2534            1 => Self::Full,
2535            _ => return None,
2536        })
2537    }
2538}
2539
2540/// v7.6.1 — referential action tag. Mirrors `spg_sql::ast::FkAction`.
2541#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2542pub enum FkAction {
2543    Restrict,
2544    Cascade,
2545    SetNull,
2546    SetDefault,
2547    NoAction,
2548}
2549
2550impl FkAction {
2551    /// On-disk tag byte (v13 catalog appendix).
2552    pub const fn tag(self) -> u8 {
2553        match self {
2554            Self::Restrict => 0,
2555            Self::Cascade => 1,
2556            Self::SetNull => 2,
2557            Self::SetDefault => 3,
2558            Self::NoAction => 4,
2559        }
2560    }
2561    pub const fn from_tag(b: u8) -> Option<Self> {
2562        Some(match b {
2563            0 => Self::Restrict,
2564            1 => Self::Cascade,
2565            2 => Self::SetNull,
2566            3 => Self::SetDefault,
2567            4 => Self::NoAction,
2568            _ => return None,
2569        })
2570    }
2571}
2572
2573impl TableSchema {
2574    pub fn column_position(&self, name: &str) -> Option<usize> {
2575        self.columns.iter().position(|c| c.name == name)
2576    }
2577}
2578
2579/// Key type accepted by secondary indices. Float / NULL / Vector values
2580/// can't participate in a B-tree index — `f64` is only `PartialOrd`, NULL
2581/// has SQL-three-valued semantics, and Vector belongs to the (future) HNSW
2582/// path. Index lookups on those columns fall back to full scan.
2583#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
2584pub enum IndexKey {
2585    Int(i64),
2586    Text(String),
2587    Bool(bool),
2588    /// v7.17.0 — `Value::Uuid` index key. Comparison is byte-wise
2589    /// (RFC 4122 byte order) so PRIMARY KEY UUID lookups land on
2590    /// the same fast-path as Int / Text.
2591    Uuid([u8; 16]),
2592    /// r1039 — `Value::Bytes` (bytea). PG orders bytea by plain byte
2593    /// comparison, shorter-prefix first (`'' < \x00 < \x0000 < \x01ff <
2594    /// \xff`, measured on 18.4), which is exactly `Vec<u8>`'s `Ord`.
2595    Bytes(Vec<u8>),
2596    /// r1039 — exact decimal, in the canonical form described on
2597    /// [`NumericKey`].
2598    ///
2599    /// r1040 — BOXED, and the box is load-bearing for every OTHER index.
2600    /// A `NumericKey` is 48 bytes against `Text(String)`'s 24, so inline
2601    /// it set the size of the whole enum and every B-tree node in every
2602    /// index grew with it: 32 bytes per key to 48, align 8 to 16.
2603    /// Measured through the release sweep, `SELECT pad FROM t ORDER BY
2604    /// id` over 400,000 rows — a walk of the primary key's index — went
2605    /// 39.4-40.6 ms to 42.3-44.1, in both leg orders. The indirection is
2606    /// charged to numeric keys, which are new, instead of to every index
2607    /// that existed already.
2608    Numeric(alloc::boxed::Box<NumericKey>),
2609    /// v7.38.1 (L12) — a NULL component INSIDE a composite key, and
2610    /// nothing else. `IndexKey::from_value(Value::Null)` still returns
2611    /// `None`, so single-column B-trees never hold one, and no probe
2612    /// path ever BUILDS one (`col = NULL` is not a match in SQL) — the
2613    /// variant is only reachable through a composite key's component
2614    /// list, where it exists so that a row like `(2, 3, NULL)` stays
2615    /// findable by a PREFIX probe on `(w, d)`. Declared last: slice
2616    /// `Ord` then sorts NULL components after every value, PG's
2617    /// NULLS LAST.
2618    Null,
2619}
2620
2621/// r1039 — an exact-decimal index key, canonical so that representation
2622/// equality IS value equality.
2623///
2624/// That property is the whole reason this is a struct rather than the
2625/// `(scaled, scale)` pair the value carries. `1.5` and `1.50` are the
2626/// same NUMERIC (PG18.4: `1.5::numeric = 1.50::numeric` is true) and
2627/// arrive here as `(15, 1)` and `(150, 2)`. A B-tree keyed on the raw
2628/// pair would file them apart, so `WHERE n = 1.5` would miss a row stored
2629/// as `1.50` — an index changing the answer, which is the one thing an
2630/// index may never do. `BigNumeric::cmp` carries the same warning and
2631/// declines to implement `Ord` for exactly this reason; a KEY cannot
2632/// decline, so it normalizes instead.
2633///
2634/// Canonical form: significant decimal digits with no leading and no
2635/// trailing zeros, most significant first, plus the decimal exponent of
2636/// the leading digit. Zero is the empty digit vector with `neg == false`
2637/// and `exp == 0`, so there is no `-0`.
2638///
2639/// Ordering is PG's, measured: `-Infinity < -1 < 0 < 1 < Infinity < NaN`,
2640/// and `NaN = NaN`.
2641#[derive(Debug, Clone, PartialEq, Eq)]
2642pub struct NumericKey {
2643    /// 0 = -Infinity, 1 = finite, 2 = +Infinity, 3 = NaN. Ordering the
2644    /// classes by this byte is what puts NaN on top, where PG keeps it.
2645    class: u8,
2646    /// Finite only, and never set for zero.
2647    neg: bool,
2648    /// Decimal exponent of the leading significant digit; 0 for zero.
2649    exp: i32,
2650    /// r1040 — the first [`HEAD_DIGITS`] significant digits, LEFT-ALIGNED
2651    /// (multiplied up so the leading digit always sits at 10^36). That
2652    /// alignment is what makes an integer comparison of two heads the same
2653    /// answer as a digit-by-digit one: `12` and `1` become 1.2e36 and
2654    /// 1.0e36, which order the way the digit strings do, where the bare
2655    /// integers 12 and 1 would not.
2656    ///
2657    /// Zero for the value zero and for every special.
2658    ///
2659    /// This started as a `Vec<u8>` of digits, which is correct and cost
2660    /// an allocation per key and a slice comparison per sort comparison.
2661    /// `ORDER BY <numeric>` builds one key per row and compares n log n
2662    /// times: 200,000 rows measured 65.4 ms against 39.6 for the f64
2663    /// projection that had been returning rows in the wrong order.
2664    head: u128,
2665    /// Significant digits past the 37th, one per byte, no trailing zeros.
2666    /// Empty for everything an `i128` mantissa can hold with room to
2667    /// spare — and an empty `Vec` does not allocate, which is the point.
2668    tail: Vec<u8>,
2669}
2670
2671/// Significant digits carried in [`NumericKey::head`]. 37 is the most
2672/// that can be left-aligned inside a `u128`: the largest such value is
2673/// 9.99…e36, and `u128::MAX` is 3.4e38.
2674const HEAD_DIGITS: u32 = 37;
2675/// `10^36` — where a left-aligned leading digit sits.
2676const HEAD_SCALE: u128 = 1_000_000_000_000_000_000_000_000_000_000_000_000;
2677
2678/// The `class` byte of [`NumericKey`], in PG's order.
2679const NUM_CLASS_NEG_INF: u8 = 0;
2680const NUM_CLASS_FINITE: u8 = 1;
2681const NUM_CLASS_POS_INF: u8 = 2;
2682const NUM_CLASS_NAN: u8 = 3;
2683
2684impl NumericKey {
2685    /// The key for a `Value::Numeric`'s three fields.
2686    ///
2687    /// Public because the ORDER BY key wants the same canonical form the
2688    /// index key uses: two sort keys that disagree about which of two
2689    /// NUMERICs is larger is the same class of defect as an index that
2690    /// disagrees with a scan, and one definition is how they stay honest.
2691    #[must_use]
2692    pub fn from_numeric(scaled: i128, scale: u16, kind: NumericKind) -> Self {
2693        match kind {
2694            NumericKind::Finite => {
2695                let mut buf = [0u8; 40];
2696                let n = digits_of_u128(scaled.unsigned_abs(), &mut buf);
2697                Self::finite(scaled < 0, &buf[..n], i32::from(scale))
2698            }
2699            NumericKind::NaN => Self::special(NUM_CLASS_NAN),
2700            NumericKind::PosInf => Self::special(NUM_CLASS_POS_INF),
2701            NumericKind::NegInf => Self::special(NUM_CLASS_NEG_INF),
2702        }
2703    }
2704
2705    /// The key for an exact integer — no scale, so no rounding.
2706    #[must_use]
2707    pub fn from_i128(n: i128) -> Self {
2708        let mut buf = [0u8; 40];
2709        let len = digits_of_u128(n.unsigned_abs(), &mut buf);
2710        Self::finite(n < 0, &buf[..len], 0)
2711    }
2712
2713    /// The key for a mantissa that overflowed `i128`. The two
2714    /// representations of one value land on one key.
2715    #[must_use]
2716    pub fn from_big(b: &crate::bignum::BigNumeric) -> Self {
2717        let (neg, limbs, scale) = b.parts();
2718        Self::finite(neg, &digits_of_limbs(limbs), i32::from(scale))
2719    }
2720
2721    /// The `f64` this key means, for the one comparison PG defines that
2722    /// way: `numeric` against `float8` demotes the numeric.
2723    ///
2724    /// Lossy by construction — that is the point, and it is why nothing
2725    /// else uses it.
2726    #[must_use]
2727    #[allow(clippy::cast_precision_loss)]
2728    pub fn to_f64(&self) -> f64 {
2729        match self.class {
2730            NUM_CLASS_NAN => return f64::NAN,
2731            NUM_CLASS_POS_INF => return f64::INFINITY,
2732            NUM_CLASS_NEG_INF => return f64::NEG_INFINITY,
2733            _ => {}
2734        }
2735        if self.head == 0 {
2736            return 0.0;
2737        }
2738        // `head` is `d.ddd… × 10^36`; the value is that leading digit and
2739        // its followers at `exp`. The tail is below f64's resolution by
2740        // construction (it starts at the 38th significant digit).
2741        let mantissa = self.head as f64 / HEAD_SCALE as f64;
2742        let out = mantissa * pow10_f64(self.exp);
2743        if self.neg { -out } else { out }
2744    }
2745
2746    /// The significant decimal digits, most significant first — the form
2747    /// the catalog codec writes, and the one `from_parts` reads back.
2748    #[must_use]
2749    pub fn digits(&self) -> Vec<u8> {
2750        let mut out = Vec::new();
2751        if self.head != 0 {
2752            let mut h = self.head;
2753            for _ in 0..HEAD_DIGITS {
2754                let d = u8::try_from(h / HEAD_SCALE).unwrap_or(0);
2755                out.push(d);
2756                h = (h % HEAD_SCALE) * 10;
2757            }
2758            while out.last() == Some(&0) {
2759                out.pop();
2760            }
2761        }
2762        out.extend_from_slice(&self.tail);
2763        out
2764    }
2765
2766    /// The wire parts, for the catalog codec.
2767    #[must_use]
2768    pub fn parts(&self) -> (u8, bool, i32) {
2769        (self.class, self.neg, self.exp)
2770    }
2771
2772    /// Rebuild from the wire parts. Returns `None` on parts that are not
2773    /// canonical, so a corrupt catalog cannot smuggle in a key whose `Eq`
2774    /// and `Ord` disagree.
2775    #[must_use]
2776    pub fn from_parts(class: u8, neg: bool, exp: i32, digits: &[u8]) -> Option<Self> {
2777        if class > NUM_CLASS_NAN || digits.iter().any(|d| *d > 9) {
2778            return None;
2779        }
2780        if class != NUM_CLASS_FINITE && (neg || exp != 0 || !digits.is_empty()) {
2781            return None;
2782        }
2783        if digits.is_empty() {
2784            if neg || exp != 0 {
2785                return None;
2786            }
2787            return Some(Self::special(class));
2788        }
2789        if digits[0] == 0 || digits[digits.len() - 1] == 0 {
2790            return None;
2791        }
2792        Some(Self {
2793            class,
2794            neg,
2795            exp,
2796            head: head_of(digits),
2797            tail: digits.iter().skip(HEAD_DIGITS as usize).copied().collect(),
2798        })
2799    }
2800
2801    /// Canonicalize `(-1)^neg · <digits as an integer> · 10^-scale`.
2802    ///
2803    /// `digits` is most-significant-first and may carry leading and
2804    /// trailing zeros; both are stripped, which is what makes `1.5` and
2805    /// `1.50` land on the same key.
2806    fn finite(neg: bool, digits: &[u8], scale: i32) -> Self {
2807        let lead = digits.iter().position(|d| *d != 0).unwrap_or(digits.len());
2808        let digits = &digits[lead..];
2809        if digits.is_empty() {
2810            return Self::special(NUM_CLASS_FINITE);
2811        }
2812        // The leading digit's exponent, taken BEFORE trailing zeros go:
2813        // dropping low-order digits does not move the leading one.
2814        let exp = i32::try_from(digits.len()).unwrap_or(i32::MAX) - 1 - scale;
2815        let mut end = digits.len();
2816        while end > 0 && digits[end - 1] == 0 {
2817            end -= 1;
2818        }
2819        let digits = &digits[..end];
2820        Self {
2821            class: NUM_CLASS_FINITE,
2822            neg,
2823            exp,
2824            head: head_of(digits),
2825            tail: digits.iter().skip(HEAD_DIGITS as usize).copied().collect(),
2826        }
2827    }
2828
2829    fn special(class: u8) -> Self {
2830        Self {
2831            class,
2832            neg: false,
2833            exp: 0,
2834            head: 0,
2835            tail: Vec::new(),
2836        }
2837    }
2838}
2839
2840/// The first [`HEAD_DIGITS`] of `digits`, left-aligned so the leading one
2841/// sits at `10^36`.
2842fn head_of(digits: &[u8]) -> u128 {
2843    let mut head: u128 = 0;
2844    let take = (HEAD_DIGITS as usize).min(digits.len());
2845    for d in &digits[..take] {
2846        head = head * 10 + u128::from(*d);
2847    }
2848    for _ in take..HEAD_DIGITS as usize {
2849        head *= 10;
2850    }
2851    head
2852}
2853
2854/// Decimal digits of `mag` into `buf`, most significant first; returns how
2855/// many were written. Zero writes none.
2856///
2857/// r1040 — split at `u64` on purpose. A `u128` divide is a called routine,
2858/// not an instruction, and this loop runs once per digit per key.
2859fn digits_of_u128(mag: u128, buf: &mut [u8; 40]) -> usize {
2860    if mag == 0 {
2861        return 0;
2862    }
2863    let mut rev = [0u8; 40];
2864    let mut n = 0usize;
2865    let mut big = mag;
2866    // Peel nineteen digits at a time — the most a `u64` holds — so the
2867    // wide divide runs at most twice.
2868    while big > u128::from(u64::MAX) {
2869        let mut chunk = u64::try_from(big % 10_000_000_000_000_000_000_u128).unwrap_or(0);
2870        big /= 10_000_000_000_000_000_000_u128;
2871        for _ in 0..19 {
2872            rev[n] = u8::try_from(chunk % 10).unwrap_or(0);
2873            chunk /= 10;
2874            n += 1;
2875        }
2876    }
2877    let mut small = u64::try_from(big).unwrap_or(0);
2878    while small > 0 {
2879        rev[n] = u8::try_from(small % 10).unwrap_or(0);
2880        small /= 10;
2881        n += 1;
2882    }
2883    for i in 0..n {
2884        buf[i] = rev[n - 1 - i];
2885    }
2886    n
2887}
2888
2889/// Decimal digits of a base-10^9 little-endian limb vector, most
2890/// significant first. Every limb but the leading one is padded to its
2891/// full nine digits — that padding is the whole point, since a limb of 5
2892/// in the middle of a number means `000000005`.
2893fn digits_of_limbs(limbs: &[u32]) -> Vec<u8> {
2894    let mut out = Vec::new();
2895    let mut buf = [0u8; 40];
2896    for (i, limb) in limbs.iter().enumerate().rev() {
2897        let n = digits_of_u128(u128::from(*limb), &mut buf);
2898        if i + 1 == limbs.len() {
2899            out.extend_from_slice(&buf[..n]);
2900        } else {
2901            out.extend(core::iter::repeat_n(0u8, 9 - n));
2902            out.extend_from_slice(&buf[..n]);
2903        }
2904    }
2905    out
2906}
2907
2908/// `10^e` as an `f64`, for any `e` a canonical key can carry.
2909#[allow(clippy::cast_precision_loss)]
2910fn pow10_f64(e: i32) -> f64 {
2911    let mut out = 1.0_f64;
2912    let mag = e.unsigned_abs();
2913    for _ in 0..mag {
2914        out *= 10.0;
2915    }
2916    if e < 0 { 1.0 / out } else { out }
2917}
2918
2919impl Ord for NumericKey {
2920    fn cmp(&self, other: &Self) -> core::cmp::Ordering {
2921        use core::cmp::Ordering;
2922        if self.class != other.class {
2923            return self.class.cmp(&other.class);
2924        }
2925        if self.class != NUM_CLASS_FINITE {
2926            // Each of the three specials is a single value, and PG holds
2927            // `'NaN'::numeric = 'NaN'::numeric` true.
2928            return Ordering::Equal;
2929        }
2930        // Zero first: it is stored with `neg == false` and `exp == 0`, so
2931        // the magnitude comparison below would put it above every value
2932        // smaller than 1 rather than between the negatives and positives.
2933        match (self.head == 0, other.head == 0) {
2934            (true, true) => return Ordering::Equal,
2935            (true, false) => {
2936                return if other.neg {
2937                    Ordering::Greater
2938                } else {
2939                    Ordering::Less
2940                };
2941            }
2942            (false, true) => {
2943                return if self.neg {
2944                    Ordering::Less
2945                } else {
2946                    Ordering::Greater
2947                };
2948            }
2949            (false, false) => {}
2950        }
2951        match (self.neg, other.neg) {
2952            (false, true) => return Ordering::Greater,
2953            (true, false) => return Ordering::Less,
2954            _ => {}
2955        }
2956        // Same sign, both non-zero: more integer digits is bigger, and at
2957        // equal exponent the left-aligned heads compare as one integer —
2958        // the alignment is what makes that the same answer as comparing
2959        // the digit strings. The tail only speaks when the first 37
2960        // significant digits are identical.
2961        let mag = self
2962            .exp
2963            .cmp(&other.exp)
2964            .then_with(|| self.head.cmp(&other.head))
2965            .then_with(|| self.tail.cmp(&other.tail));
2966        if self.neg { mag.reverse() } else { mag }
2967    }
2968}
2969
2970impl PartialOrd for NumericKey {
2971    fn partial_cmp(&self, other: &Self) -> Option<core::cmp::Ordering> {
2972        Some(self.cmp(other))
2973    }
2974}
2975
2976/// v7.39.13 — the ONE definition of how two `timetz` values order.
2977///
2978/// PostgreSQL 18.6 orders them by a PAIR: the UTC-equivalent instant,
2979/// then the OFFSET DESCENDING. Values naming one instant in different
2980/// zones are DISTINCT there — `'07:00:00+00' = '02:00:00-05'` is FALSE
2981/// — and the offset half was missing from every surface that had an
2982/// answer at all.
2983///
2984/// A B-tree over the instant alone does not merely sort badly: it
2985/// CHANGES ANSWERS. Measured on this engine with the six-row fixture in
2986/// `e2e_timetz_order_v73913`, `WHERE k > '07:00:00+00'`:
2987///
2988/// ```text
2989///   no index   2, 6      (PostgreSQL 18.6: 2, 6)
2990///   index      <nothing>
2991/// ```
2992///
2993/// The range starts above one instant, and the two rows that share that
2994/// instant while sorting ABOVE the bound live below it in a key space
2995/// that has dropped the zone. A superset and a re-check cannot save a
2996/// seek that returns too FEW.
2997///
2998/// The instant shifts left by 17 bits and the offset sits underneath —
2999/// an offset is at most ±57,600 seconds and an instant at most about
3000/// 1.44e11 microseconds, so the two never meet and the whole key stays
3001/// inside `i64`.
3002pub fn timetz_sort_key(us: i64, offset_secs: i32) -> i64 {
3003    let utc = us - i64::from(offset_secs) * 1_000_000;
3004    (utc << 17) - i64::from(offset_secs)
3005}
3006
3007impl IndexKey {
3008    /// v7.37.43 (INSUBQ B-4) — inline-friendly BigInt fast path.
3009    /// `try_count_star_pk_in_subquery_fast` (and any other hot loop
3010    /// probing an integer PK) already holds an `i64`; this builds the
3011    /// `IndexKey` without going through the generic `from_value`
3012    /// dispatch tree.
3013    #[inline]
3014    pub fn from_i64(n: i64) -> Self {
3015        Self::Int(n)
3016    }
3017
3018    /// r1039 — the key a value takes when the INDEXED COLUMN is `ty`, or
3019    /// `None` when it takes none (→ the caller falls back to a scan).
3020    ///
3021    /// Every key under one index comes from one column, so they all live
3022    /// in one key SPACE. A probe built in a different space finds nothing
3023    /// — and "nothing" is indistinguishable from "no matching rows",
3024    /// which is how round 564 and r1037 both turned an index into a wrong
3025    /// answer (a TEXT key sought against a DATE-keyed and a UUID-keyed
3026    /// index).
3027    ///
3028    /// The two spaces this round adds make that trap reachable again from
3029    /// a new direction: `WHERE n = 2` on a NUMERIC column produces
3030    /// `Value::Int`, and an integer key would look in a space nothing
3031    /// lives in. So NUMERIC columns take integers by converting them
3032    /// exactly, and refuse anything they cannot convert; BYTEA columns
3033    /// take only `Value::Bytes`; and no other column may be keyed in
3034    /// either of the two new spaces.
3035    ///
3036    /// Use this wherever the key comes from a LITERAL or from another
3037    /// table's value. [`IndexKey::from_value`] stays right for building
3038    /// the index itself, where the value is the column's own.
3039    pub fn from_value_for_column(v: &Value<'_>, ty: DataType) -> Option<Self> {
3040        match ty {
3041            DataType::Numeric { .. } => match v {
3042                Value::SmallInt(n) => Some(Self::exact_int_key(i128::from(*n))),
3043                Value::Int(n) => Some(Self::exact_int_key(i128::from(*n))),
3044                Value::BigInt(n) => Some(Self::exact_int_key(i128::from(*n))),
3045                Value::Numeric { .. } | Value::NumericBig(_) => Self::from_value(v),
3046                // Float included: `2.0::float8` and `2.0::numeric` are not
3047                // the same value to a B-tree, and rounding one into the
3048                // other's space is how a seek reaches the wrong row.
3049                _ => None,
3050            },
3051            DataType::Bytes => match v {
3052                Value::Bytes(b) => Some(Self::Bytes(b.to_vec())),
3053                _ => None,
3054            },
3055            _ => match Self::from_value(v) {
3056                Some(Self::Numeric(_) | Self::Bytes(_)) => None,
3057                other => other,
3058            },
3059        }
3060    }
3061
3062    /// An integer as a NUMERIC key. Exact by construction — no scale, no
3063    /// rounding — which is why the conversion is allowed at all.
3064    fn exact_int_key(n: i128) -> Self {
3065        Self::Numeric(alloc::boxed::Box::new(NumericKey::from_i128(n)))
3066    }
3067
3068    pub fn from_value(v: &Value<'_>) -> Option<Self> {
3069        match v {
3070            // v7.37.43 (INSUBQ B-4) — BigInt hits first (the dominant
3071            // INSUBQ shape probes PK as BigInt). Tiny micro-win.
3072            Value::BigInt(n) => Some(Self::Int(*n)),
3073            Value::SmallInt(n) => Some(Self::Int(i64::from(*n))),
3074            Value::Int(n) => Some(Self::Int(i64::from(*n))),
3075            Value::Text(s) => Some(Self::Text(s.clone().into_owned())),
3076            // v7.38 (read01, T11) — bpchar keys compare blank-insensitively.
3077            Value::BpChar(s) => Some(Self::Text(s.trim_end_matches(' ').to_string())),
3078            Value::Bool(b) => Some(Self::Bool(*b)),
3079            // Date/Timestamp use their integer storage repr as the
3080            // index key — same order semantics, same comparison.
3081            Value::Date(d) => Some(Self::Int(i64::from(*d))),
3082            Value::Timestamp(t) => Some(Self::Int(*t)),
3083            // v7.17.0: UUID indexable via byte-wise ordering. Lookup
3084            // on `id = '...'::uuid` resolves through the secondary
3085            // index rather than full-scan.
3086            Value::Uuid(b) => Some(Self::Uuid(*b)),
3087            // v7.17.0 Phase 3.P0-32: TIME indexable via i64 — same
3088            // order semantics as Date/Timestamp.
3089            Value::Time(us) => Some(Self::Int(*us)),
3090            // v7.17.0 Phase 3.P0-33: YEAR indexable as i64 — u16
3091            // widens losslessly and gives the natural calendar
3092            // ordering.
3093            Value::Year(y) => Some(Self::Int(i64::from(*y))),
3094            // v7.17.0 Phase 3.P0-34: TIMETZ indexable by its
3095            // UTC-equivalent microseconds (local wall - offset).
3096            // Without normalising, two values for the same
3097            // physical instant in different zones would sort
3098            // wrong. Matches PG's TIMETZ index behaviour.
3099            Value::TimeTz { us, offset_secs } => {
3100                Some(Self::Int(timetz_sort_key(*us, *offset_secs)))
3101            }
3102            // v7.17.0 Phase 3.P0-35: MONEY indexable as i64 cents
3103            // (no scaling needed — natural numeric ordering).
3104            Value::Money(c) => Some(Self::Int(*c)),
3105            // v7.17.0 Phase 3.P0-38: ranges are NOT indexable in
3106            // v7.17.0 — they'd need a custom comparator (PG uses
3107            // SP-GiST for this). Skip.
3108            Value::Range { .. } => None,
3109            // v7.17.0 Phase 3.P0-39: hstore is NOT indexable in
3110            // v7.17.0 — map columns need GIN with bespoke ops.
3111            Value::Hstore(_) => None,
3112            // r1039 — exact decimals index through the canonical
3113            // [`NumericKey`], which is what makes `1.5` and `1.50` one key.
3114            Value::NumericBig(b) => Some(Self::Numeric(alloc::boxed::Box::new(NumericKey::from_big(b)))),
3115            Value::Numeric {
3116                scaled,
3117                scale,
3118                kind,
3119            } => Some(Self::Numeric(alloc::boxed::Box::new(
3120                NumericKey::from_numeric(*scaled, *scale, *kind),
3121            ))),
3122            // r1039 — bytea orders by plain byte comparison, which is
3123            // `Vec<u8>`'s own.
3124            Value::Bytes(b) => Some(Self::Bytes(b.to_vec())),
3125            // v7.17.0 Phase 3.P0-40: 2D arrays aren't indexable.
3126            Value::IntArray2D(_)
3127            | Value::BigIntArray2D(_)
3128            | Value::TextArray2D(_)
3129            | Value::BoolArray2D(_) => None,
3130            // v7.37.5 β-P4: INTERVAL[] isn't indexable (PG uses
3131            // GIN/intarray for array-contains queries; SPG plans
3132            // that as a separate axis under v7.37.8 GIN-on-jsonb).
3133            Value::IntervalArray(_) => None,
3134            // v7.37.5 γ — none of the array-of-scalar family is
3135            // B-tree indexable. Same reason as IntervalArray: PG
3136            // serves array-contains / array-overlap queries via
3137            // GIN, and SPG's GIN axis lands in v7.37.8.
3138            Value::BoolArray(_)
3139            | Value::SmallIntArray(_)
3140            | Value::Int2Vector(_)
3141            | Value::OidVector(_)
3142            | Value::FloatArray(_)
3143            | Value::NumericArray(_)
3144            | Value::DateArray(_)
3145            | Value::TimestampArray(_)
3146            | Value::TimestamptzArray(_)
3147            | Value::UuidArray(_)
3148            | Value::JsonArray(_)
3149            | Value::JsonbArray(_)
3150            | Value::BytesArray(_)
3151            | Value::VarcharArray(_)
3152            | Value::CharArray(_)
3153            // v7.40.0 — and the five this version adds. Same reason:
3154            // an array is not a B-tree key.
3155            | Value::RealArray(_)
3156            | Value::TimeArray(_)
3157            | Value::TimeTzArray(_)
3158            | Value::InetArray(_)
3159            | Value::XmlArray(_)
3160            // v7.37.5 δ — multirange not indexable (PG uses GiST/
3161            // SP-GiST + a custom operator class; SPG plans the same
3162            // axis under v7.37.8 with ranges).
3163            | Value::Multirange { .. }
3164            // v7.37.5 ε — geometric scalars not B-tree indexable
3165            // (PG uses GiST/SP-GiST for these too; SPG plans the
3166            // same axis under v7.37.8).
3167            | Value::Point(_)
3168            | Value::Lseg(_, _)
3169            | Value::Path { .. }
3170            | Value::PgBox(_, _)
3171            | Value::Polygon(_)
3172            | Value::Line { .. }
3173            | Value::Circle { .. }
3174            // v7.37.5 ζ-A — network / bit / xml / "char" / money[].
3175            // INET / CIDR / MACADDR / MACADDR8 could be B-tree
3176            // indexable (PG does this), but the byte-wise compare
3177            // family-blind would mis-order IPv4 vs IPv6; left as
3178            // a follow-up under v7.37.8 GIN window.
3179            | Value::Inet { .. }
3180            | Value::Cidr { .. }
3181            | Value::Macaddr(_)
3182            | Value::Macaddr8(_)
3183            | Value::PgLsn(_)
3184            | Value::BitString { .. }
3185            | Value::Xml(_)
3186            | Value::Char1(_)
3187            | Value::MoneyArray(_)
3188            | Value::Composite(_)
3189            | Value::Tid(..)
3190            | Value::Xid(_)
3191            | Value::Cid(_)
3192            | Value::RegClass(..)
3193            | Value::RegProc(..)
3194            | Value::RegType(..) => None,
3195            // Interval isn't index-eligible (and can't reach this path
3196            // through column storage anyway). Float / Real stay out
3197            // because `f64` is only `PartialOrd`.
3198            Value::Null
3199            | Value::Float(_)
3200            | Value::Vector(_)
3201            | Value::Sq8Vector(_)
3202            | Value::HalfVector(_)
3203            | Value::Interval { .. }
3204            | Value::Json(_)
3205            | Value::TextArray(_)
3206            | Value::IntArray(_)
3207            | Value::BigIntArray(_)
3208            | Value::TsVector(_)
3209            | Value::TsQuery(_)
3210            | Value::Real(_) => None,
3211        }
3212    }
3213}
3214
3215/// A single-column secondary index. v2.0 carries either a B-tree map
3216/// (the default — used for equality / range lookups on scalar columns)
3217/// or a navigable-small-world graph (used for kNN over vector
3218/// columns).
3219#[derive(Debug, Clone)]
3220pub struct Index {
3221    pub name: String,
3222    pub column_position: usize,
3223    pub kind: IndexKind,
3224    /// v6.8.0 — column positions of `INCLUDE (col1, col2, …)`
3225    /// non-key columns. Carries the planner's "this query is
3226    /// covered by the index" signal; lookup paths still resolve
3227    /// via the `RowLocator` to fetch the row body, but EXPLAIN
3228    /// surfaces the covered-scan annotation so operators can
3229    /// confirm the planner sees the coverage.
3230    ///
3231    /// Empty `Vec` = no `INCLUDE` clause (the legacy shape). v12
3232    /// catalog snapshots deserialise with an empty vec.
3233    pub included_columns: Vec<usize>,
3234    /// v6.8.1 — partial-index predicate stored as its canonical
3235    /// Display form (the engine re-parses it on the maintenance
3236    /// path). `None` = unconditional index (the legacy shape).
3237    /// Persisted as `[u8 has_pred][u16 LE len][bytes]` on the
3238    /// catalog snapshot (FILE_VERSION 12, appended after
3239    /// `included_columns`).
3240    /// v7.39.13 — `true` when SPG built this index to serve probes on a
3241    /// constraint's non-leading columns, rather than because anyone
3242    /// asked for it.
3243    ///
3244    /// A multi-column `PRIMARY KEY (a, b)` becomes one composite B-tree
3245    /// over the whole key PLUS one single-column B-tree per remaining
3246    /// column, because a composite cannot answer a probe that does not
3247    /// start at its front. PostgreSQL has one index per constraint and
3248    /// no others, so those extras appeared in `pg_index` as indexes a
3249    /// schema reader never created and PostgreSQL would never show —
3250    /// and for an INLINE composite key the catalog listed two of them
3251    /// and no primary key at all.
3252    ///
3253    /// Recorded rather than guessed. Deciding it from the name is what
3254    /// v7.39.11 removed and v7.39.12 reintroduced as a prefix match, in
3255    /// both cases because nothing in storage said so.
3256    pub constraint_internal: bool,
3257    /// v7.39.13 — `true` when this IS a constraint's own index: the one
3258    /// PostgreSQL creates for a `PRIMARY KEY` / `UNIQUE`, and the only
3259    /// one it shows.
3260    ///
3261    /// Recorded, because the alternative is matching an index's columns
3262    /// against a constraint's and calling a hit the constraint's index.
3263    /// v7.39.12 did that by prefix and mislabelled a user's own index;
3264    /// doing it by EXACT columns still renames `CREATE INDEX idx_d_a ON
3265    /// d (a)` to the name of the `UNIQUE (a)` beside it, and still
3266    /// claims an expression index on `(a + 1)` is the key.
3267    pub constraint_backing: bool,
3268    pub partial_predicate: Option<String>,
3269    /// v6.8.2 — expression-index key, stored as the expression's
3270    /// canonical Display form. `None` = bare column-reference
3271    /// index (the legacy shape). Persisted alongside
3272    /// `partial_predicate` on the v12 catalog snapshot.
3273    pub expression: Option<String>,
3274    /// v7.39 (read01 round 52) — `CREATE UNIQUE INDEX … NULLS NOT DISTINCT`
3275    /// (PG 15+): a NULL in the key no longer exempts the row, so two
3276    /// all-NULL keys collide. Default `false` = SQL-standard NULLS DISTINCT.
3277    /// Persisted in the index appendix (FILE_VERSION 62+); older catalogs
3278    /// deserialise with `false`.
3279    pub nulls_not_distinct: bool,
3280    /// v7.39 (round 537) — the key column's ordering clause, as written.
3281    ///
3282    /// SPG's index does not scan in a direction, so this changes no
3283    /// lookup; `pg_indexes.indexdef` is a reproduction of the DDL and
3284    /// dropping the clause made `CREATE INDEX i ON t (a DESC NULLS
3285    /// LAST)` read back as `(a)` — a dump lost it and a schema diff saw
3286    /// drift every run. `nulls_first` is `None` when the statement did
3287    /// not say, in which case PG's default applies and neither word is
3288    /// rendered.
3289    pub descending: bool,
3290    pub nulls_first: Option<bool>,
3291    /// v7.39 (round 538) — an explicit `COLLATE` on the key, as written.
3292    /// SPG orders text by bytes, so it changes no comparison; PG prints
3293    /// it because a named collation and an inherited one are different
3294    /// objects even where they sort identically.
3295    pub collation: Option<String>,
3296    /// v7.9.29 — `CREATE UNIQUE INDEX …`. When true the engine
3297    /// rejects INSERTs whose key already appears in this index
3298    /// (combined with `partial_predicate` when present — only
3299    /// rows matching the predicate enter the uniqueness check).
3300    /// Catalog FILE_VERSION 16+; older snapshots deserialise
3301    /// with `false`. mailrs K1.
3302    pub is_unique: bool,
3303    /// v7.9.29 — extra (non-leading) column positions for
3304    /// multi-column indexes (`CREATE INDEX … (a, b, c)`). The
3305    /// planner today still only uses the leading
3306    /// `column_position` for index seeks, but UNIQUE INDEX
3307    /// enforcement walks the full tuple so partial-unique
3308    /// invariants like CalDAV `(calendar_id, uid,
3309    /// recurrence_id)` are enforced correctly. Catalog
3310    /// FILE_VERSION 16+; older snapshots deserialise empty.
3311    pub extra_column_positions: Vec<usize>,
3312    /// v7.39.11 — each extra key column's `DESC` / `NULLS FIRST`,
3313    /// positionally aligned with `extra_column_positions`. An empty
3314    /// vec, and any position past its end, means the PG default:
3315    /// ascending, nulls last.
3316    ///
3317    /// SPG's index does not scan in a per-column direction, so this
3318    /// changes no lookup — the same reason `descending` exists for the
3319    /// LEADING column. `pg_get_indexdef` is a reproduction of the DDL,
3320    /// and without this `CREATE INDEX i ON t (a, b DESC)` read back as
3321    /// `(a, b)`: a dump lost the clause and a schema diff saw drift
3322    /// every run. Reported by sentori against 7.39.10; round 537 fixed
3323    /// the identical thing for the leading column.
3324    pub extra_orders: Vec<KeyOrder>,
3325    /// v7.40.0 — MySQL's index prefix, `KEY kb (b(4))`, as declared.
3326    ///
3327    /// Recorded for the same reason `descending`, `nulls_first` and
3328    /// `collation` are: `SHOW INDEX` reports it as `Sub_part` and
3329    /// `SHOW CREATE TABLE` reproduces it, and a declaration that is
3330    /// accepted and then unrecorded reads back as a different schema.
3331    ///
3332    /// The index itself keys the WHOLE column. A full key answers every
3333    /// lookup a prefix key answers, and answers it at least as
3334    /// precisely — the difference is index size, which is not
3335    /// observable in any answer. It IS observable for a UNIQUE prefix
3336    /// key, where MySQL rejects two rows sharing the prefix; that form
3337    /// is refused at DDL rather than under-enforced (see `ddl.rs`).
3338    pub prefix_len: Option<u32>,
3339}
3340
3341/// v7.39.11 — one index key column's ordering clause, as written.
3342#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
3343pub struct KeyOrder {
3344    pub descending: bool,
3345    /// `None` when the statement did not say, in which case PG's
3346    /// default applies and neither word is rendered.
3347    pub nulls_first: Option<bool>,
3348}
3349
3350/// Default neighbor degree (M) for the NSW graph. Picked at construction
3351/// time and persisted with the index.
3352pub const NSW_DEFAULT_M: usize = 16;
3353
3354/// v5.2.2: outcome of a successful [`Catalog::freeze_oldest_to_cold`]
3355/// call. The catalog state has already been mutated by the time this
3356/// is returned (hot rows dropped + segment registered + Cold locators
3357/// flipped). The caller's only remaining concern is `segment_bytes` —
3358/// persist them to disk under `<db>.spg/segments/seg_<id>.spg` so a
3359/// future restart can reload via the v5.1 `SPG_PRELOAD_COLD_SEGMENT`
3360/// path. (v5.3's manifest will subsume this manual step.)
3361#[derive(Debug, Clone)]
3362pub struct FreezeReport {
3363    /// Id allocated by [`Catalog::load_segment_bytes`] for the new
3364    /// cold-tier segment. Stable across the call's success path.
3365    pub segment_id: u32,
3366    /// Number of rows that moved hot → cold. Equals the `max_rows`
3367    /// the caller asked for (the API is strict on the count).
3368    pub frozen_rows: usize,
3369    /// Hot-tier bytes reclaimed by the freeze — the
3370    /// [`Table::hot_bytes`] delta before vs after. Useful to feed
3371    /// back into the freezer's budget check on the next tick.
3372    pub bytes_freed: u64,
3373    /// Encoded segment bytes, byte-identical to what
3374    /// [`encode_segment`] produced. The catalog already owns a
3375    /// copy inside `cold_segments`; this hand-off lets the caller
3376    /// persist them without re-encoding.
3377    pub segment_bytes: Vec<u8>,
3378}
3379
3380/// v6.7.4 — read-only output of [`Catalog::prepare_freeze_slice`].
3381/// Carries every row body + key in a contiguous hot-row range,
3382/// already encoded and sorted by PK so the coordinator's merge
3383/// step is a k-way merge over already-sorted streams.
3384///
3385/// `Vec<FreezeSlice>` from N independent workers feeds
3386/// [`Catalog::commit_freeze_slices`], which concats + encodes the
3387/// merged segment + atomically swaps the catalog state.
3388#[derive(Debug, Clone)]
3389pub struct FreezeSlice {
3390    /// Hot-row index range this slice covered (half-open, in the
3391    /// table's `rows: PersistentVec` ordering at call time). The
3392    /// commit step uses this to compute the union range that
3393    /// gets passed to [`Table::delete_rows`].
3394    pub row_range: core::ops::Range<usize>,
3395    /// `(pk_u64, encoded_row_body, IndexKey)` triples, sorted
3396    /// ascending by `pk_u64`. Per-slice sort happens inside
3397    /// `prepare_freeze_slice`; the coordinator does only a
3398    /// k-way merge to reach the global PK ordering
3399    /// [`encode_segment`] requires.
3400    pub rows: Vec<(u64, Vec<u8>, IndexKey)>,
3401}
3402
3403/// v6.7.3 — outcome of a [`Catalog::compact_cold_segments`] call.
3404/// The catalog state has already been mutated when this is returned:
3405/// the merged segment is loaded into `cold_segments`, the source
3406/// segment slots are tombstoned (`None`), and every BTree-index
3407/// `RowLocator::Cold` that previously pointed at a source now
3408/// points at the merged segment. The caller's remaining job is to
3409/// persist `merged_segment_bytes` under
3410/// `<db>.spg/segments/seg_<merged_segment_id>.spg` and update the
3411/// in-memory `segment_id → path` map (remove the source ids, add
3412/// the merged id) so the next CHECKPOINT writes a manifest that
3413/// no longer lists the retired sources.
3414///
3415/// On a no-op (fewer than 2 candidate segments under the threshold),
3416/// `merged_segment_id` is `None` and `sources` is empty; the
3417/// catalog was not mutated.
3418#[derive(Debug, Clone)]
3419pub struct CompactReport {
3420    /// Source segment ids that were merged + tombstoned.
3421    pub sources: Vec<u32>,
3422    /// Id allocated for the merged segment. `None` on no-op.
3423    pub merged_segment_id: Option<u32>,
3424    /// Encoded merged-segment bytes (empty on no-op).
3425    pub merged_segment_bytes: Vec<u8>,
3426    /// Number of rows that landed in the merged segment.
3427    pub merged_rows: usize,
3428    /// `Σ source.num_rows − merged_rows`. Rows present in source
3429    /// segment payloads but unreferenced by any live BTree
3430    /// `Cold` locator — DELETE'd-but-still-frozen rows that
3431    /// compaction GC'd during the merge.
3432    pub deleted_rows_pruned: usize,
3433    /// `Σ source.bytes() − merged.bytes()`. Estimate of on-disk
3434    /// space the merge will reclaim once the source segment files
3435    /// are GC'd. Saturating subtract — never negative.
3436    pub bytes_reclaimed_estimate: u64,
3437}
3438
3439#[derive(Debug, Clone)]
3440pub enum IndexKind {
3441    /// v4.40: structural-sharing B-tree over `IndexKey`. Replaces the v0.8
3442    /// `BTreeMap<IndexKey, Vec<usize>>` — `Index::clone` is now an `Arc`
3443    /// bump regardless of index size, so `Catalog::clone` inside the
3444    /// v4.34 auto-commit wrap stays O(1) even for tables with secondary
3445    /// indices (the case that bottlenecked v4.39 at 1M rows in the
3446    /// sweep).
3447    ///
3448    /// v5.1: value type widened from `Vec<usize>` to `Vec<RowLocator>` so
3449    /// a single key can point to a mix of hot-tier rows (`RowLocator::Hot`,
3450    /// equivalent to the pre-v5 `usize` row index) and cold-tier rows
3451    /// (`RowLocator::Cold { segment_id, page_offset }`) once the v5.2
3452    /// freezer starts producing them. Pre-v5.2 only `Hot` entries appear
3453    /// — the on-disk encoding stays at `FILE_VERSION` 8 (raw u64 row index)
3454    /// because every locator round-trips through `RowLocator::from_legacy_v8_u64`
3455    /// without information loss. `FILE_VERSION` 9 with tagged encoding lands
3456    /// alongside the first freezer commit (v5.1 step 2b / v5.2).
3457    BTree(PersistentBTreeMap<IndexKey, crate::posting::PostingList>),
3458    /// Navigable-small-world graph for vector kNN search.
3459    Nsw(NswGraph),
3460    /// v6.7.1 — BRIN (Block Range INdex). Pure metadata: BRIN
3461    /// indexes carry NO in-memory key→locator map. The (min,
3462    /// max) summaries live in each cold-tier segment's v2
3463    /// envelope sidecar; the BRIN entry in `Table.indices` only
3464    /// records THAT a BRIN index exists on this column so the
3465    /// segment encoder + planner can opt into the summary path.
3466    Brin {
3467        /// The cell type at `column_position` at CREATE INDEX time.
3468        /// Used by the planner to type-check WHERE-clause range
3469        /// predicates against the BRIN-indexed column.
3470        column_type: DataType,
3471        /// v7.38.11 — one `(min, max)` per [`BRIN_RANGE_ROWS`] slots of
3472        /// the hot tier, so a range predicate can skip the ranges that
3473        /// cannot contain a match.
3474        ///
3475        /// Maintenance is WIDEN-ONLY and that is the whole safety
3476        /// argument: an insert widens its range, an update widens, and
3477        /// a delete leaves the range alone. A range left wider than the
3478        /// rows it now covers is correct and merely less selective —
3479        /// which is exactly PG's contract for a lossy index, since the
3480        /// predicate is re-checked on every row the summary lets
3481        /// through. A summary may over-report; it can never
3482        /// under-report, so no matching row can be skipped.
3483        ///
3484        /// `None` for a range whose rows carry no comparable key (all
3485        /// NULL, say), and such a range is never skipped.
3486        summaries: alloc::vec::Vec<Option<(i64, i64)>>,
3487    },
3488    /// v7.12.3 — GIN inverted index over a `tsvector` column.
3489    ///
3490    /// Storage shape: `lexeme word → Vec<RowLocator>`. The posting
3491    /// list per word is appended in row-order, so range scans are
3492    /// O(matching rows) once the per-word lookup is done. Multi-
3493    /// term queries intersect / union posting lists.
3494    ///
3495    /// `IndexKey::from_value(TsVector)` returns `None` — GIN doesn't
3496    /// participate in `try_index_seek` (which is BTree-equality-keyed).
3497    /// The engine consults this index through `try_gin_lookup` on
3498    /// `WHERE col @@ tsquery` predicates instead.
3499    ///
3500    /// Backed by a `PersistentBTreeMap` so `Catalog::clone` (the
3501    /// per-write snapshot) stays O(1) — same structural-sharing
3502    /// invariant as BTree.
3503    Gin(PersistentBTreeMap<alloc::string::String, crate::posting::PostingList>),
3504    /// v7.15.0 — `USING gin (col gin_trgm_ops)` over a `TEXT`
3505    /// column. Posting lists map `trigram` (PG-compatible 3-byte
3506    /// shingle on the lower-cased + space-padded input) to row
3507    /// locators. The planner uses this index to accelerate
3508    /// `WHERE col LIKE '…'` / `ILIKE '…'` / `similarity(col, q) >
3509    /// t` — every literal run of length ≥ 1 in the pattern
3510    /// produces a trigram set, the engine intersects the posting
3511    /// lists, and the LIKE / similarity predicate is re-evaluated
3512    /// per candidate row to filter the over-approximation.
3513    /// Persisted via tag-4 index payload in `FILE_VERSION` 24+.
3514    GinTrgm(PersistentBTreeMap<alloc::string::String, crate::posting::PostingList>),
3515    /// v7.17.0 Phase 2.2 — MySQL `FULLTEXT KEY (col)` over a
3516    /// `TEXT` / `VARCHAR` column. Posting lists map
3517    /// `tsvector('simple') lexeme` to row locators. At insert /
3518    /// build time the engine derives the lexemes from the cell
3519    /// via the same lower-case tokenisation rule as
3520    /// `to_tsvector('simple', ...)` — the column itself stays a
3521    /// plain text type on disk (mysqldump round-trips would be
3522    /// broken otherwise). The planner uses this index to
3523    /// accelerate MySQL-shape `MATCH(col) AGAINST('term')`
3524    /// queries by mapping them onto the existing tsquery `@@`
3525    /// walker. Persisted via tag-5 index payload in
3526    /// `FILE_VERSION` 33+.
3527    GinFulltext(PersistentBTreeMap<alloc::string::String, crate::posting::PostingList>),
3528    /// v7.37.8(sentori Epic 5 P2)— `USING gin (col)` over a
3529    /// `JSON` / `JSONB` column. Posting lists map a canonical
3530    /// `(path, leaf)` token(see [`crate::jsonb_gin::extract_tokens`])
3531    /// to row locators so the planner can resolve
3532    /// `<col> @> <jsonb_literal>` to a candidate row set via
3533    /// posting-list intersection + per-row `json::contains`
3534    /// re-verification. Pre-7.37.8 the same DDL loaded as a
3535    /// BTree fallback so `pg_dump` JSONB-GIN scripts kept loading
3536    /// without query-time acceleration. Persisted via tag-6 index
3537    /// payload in `FILE_VERSION` 51+.
3538    GinJsonb(PersistentBTreeMap<alloc::string::String, crate::posting::PostingList>),
3539    /// v7.38.1 (L12) — a REAL multi-column B-tree: the key is the whole
3540    /// column tuple, `[leading, extras…]`, ordered lexicographically by
3541    /// slice `Ord`. That ordering is the entire design: every key
3542    /// sharing a prefix is contiguous, so an equality on a PREFIX of
3543    /// the columns is one `O(log N)` descent plus a bounded walk, and a
3544    /// full-tuple equality is a point `get`. The single-column `BTree`
3545    /// kind used to stand in for multi-column DDL by keying on the
3546    /// leading column only and carrying the rest as metadata — TPC-C's
3547    /// `customer (c_w_id, c_d_id, c_last, c_first)` then answered a
3548    /// three-column equality with every row of one warehouse and a
3549    /// per-row filter over 30 000 candidates.
3550    ///
3551    /// Rows where any component column is NULL (or of an unkeyable
3552    /// type) are NOT entered: this index serves `=` probes, and in SQL
3553    /// `col = v` never selects a NULL. Uniqueness keeps its own
3554    /// full-tuple walk with NULLS-DISTINCT semantics on the
3555    /// enforcement path, exactly as before.
3556    ///
3557    /// Persisted via tag-7 index payload in `FILE_VERSION` 91+.
3558    BTreeMulti(PersistentBTreeMap<alloc::boxed::Box<[IndexKey]>, crate::posting::PostingList>),
3559}
3560
3561impl IndexKind {
3562    /// v7.31 (memory campaign, C2) — bytes this index variant holds
3563    /// resident in RAM, computed by walking its OWN structure rather
3564    /// than a parametric guess made by the engine. Replaces the old
3565    /// `spg_admin::memory_stats` inline match, which charged NSW with
3566    /// a stale `m_max_0 * 8` per node (neighbour slots are `u32` = 4 B
3567    /// since v6.1.x, and most nodes never fill `m_max_0`) and lumped
3568    /// every GIN family index into a flat 1 KiB token — a gross
3569    /// undercount for the text-heavy posting lists that dominate
3570    /// mailrs' footprint. Per-entry container overhead uses the
3571    /// 3-word (24 B on 64-bit) `Vec`/`String` header as the charge.
3572    ///
3573    /// O(index entries): operator/monitoring surface (`memory_stats` /
3574    /// `spg_memory_stats`), not a query path.
3575    #[must_use]
3576    pub fn approx_resident_bytes(&self) -> u64 {
3577        const HEADER: usize = 24; // Vec/String 3-word header on 64-bit.
3578        let loc = core::mem::size_of::<RowLocator>();
3579        match self {
3580            IndexKind::BTree(map) => {
3581                let key = core::mem::size_of::<IndexKey>();
3582                map.iter()
3583                    .map(|(_, locs)| (key + HEADER + locs.len() * loc) as u64)
3584                    .sum()
3585            }
3586            // v7.38.1 (L12) — multi keys own a boxed slice of components.
3587            IndexKind::BTreeMulti(map) => {
3588                let key = core::mem::size_of::<IndexKey>();
3589                map.iter()
3590                    .map(|(k, locs)| (HEADER + k.len() * key + HEADER + locs.len() * loc) as u64)
3591                    .sum()
3592            }
3593            IndexKind::Nsw(g) => {
3594                // `levels` is one byte per node; each layer's adjacency
3595                // is a `Vec<u32>` per node whose actual length we walk
3596                // (the dense layer-0 list dominates, but upper layers
3597                // are sparse — the old estimate ignored that).
3598                let mut b = g.levels.len() as u64;
3599                for layer in &g.layers {
3600                    for nbrs in layer.iter() {
3601                        b += (HEADER + nbrs.len() * core::mem::size_of::<u32>()) as u64;
3602                    }
3603                }
3604                b
3605            }
3606            // BRIN carries NO in-memory key→locator map (the (min,max)
3607            // summaries live in cold-segment sidecars on disk); the
3608            // resident footprint is just the column-type token.
3609            IndexKind::Brin { .. } => core::mem::size_of::<DataType>() as u64,
3610            IndexKind::Gin(map)
3611            | IndexKind::GinTrgm(map)
3612            | IndexKind::GinFulltext(map)
3613            | IndexKind::GinJsonb(map) => map
3614                .iter()
3615                .map(|(word, postings)| {
3616                    (word.len() + HEADER + HEADER + postings.len() * loc) as u64
3617                })
3618                .sum(),
3619        }
3620    }
3621}
3622
3623/// Multi-layer HNSW graph (v2.13). Each node is assigned a `top_level`;
3624/// it appears in layers `0..=top_level`. Higher layers are sparser, so
3625/// search starts from the entry at the top layer, greedy-descends to
3626/// layer 0, and beam-searches there. Layer 0 keeps a larger neighbour
3627/// budget (`m_max_0 = 2 * m` per the HNSW paper); upper layers cap at
3628/// `m`. The struct name stays `NswGraph` so external users / on-disk
3629/// callers don't have to track a rename — the algorithm changed, the
3630/// data slot didn't.
3631#[derive(Debug, Clone)]
3632pub struct NswGraph {
3633    /// Max neighbours per node on layers ≥ 1.
3634    pub m: usize,
3635    /// Max neighbours on layer 0 (the dense bottom layer). HNSW
3636    /// convention: `m_max_0 = 2 * m`.
3637    pub m_max_0: usize,
3638    /// Entry point — the node that sits on the topmost layer. Search
3639    /// always starts here.
3640    pub entry: Option<usize>,
3641    /// Top layer of the entry node (== `layers.len() - 1` when populated).
3642    pub entry_level: u8,
3643    /// `levels[i]` = top layer of node `i`. Nodes whose vector cell is
3644    /// NULL / non-Vector have `levels[i] = 0` and no neighbour entries.
3645    ///
3646    /// v5.5.0: backed by `PersistentVec` so `NswGraph::clone` (and the
3647    /// `Catalog::clone` on every group-commit write that contains it) is O(1)
3648    /// structural-sharing instead of an O(N) element copy.
3649    pub levels: PersistentVec<u8>,
3650    /// `layers[l][i]` = neighbours of node `i` at layer `l`. Inner vec
3651    /// is empty when node `i` doesn't reach layer `l`.
3652    ///
3653    /// v5.5.0: the per-node middle dimension (the O(N) one) is a
3654    /// `PersistentVec`; the outer layer dimension stays a plain `Vec`
3655    /// (layer count ≤ 8, so its clone is O(1) in practice) and the inner
3656    /// neighbour list stays a `Vec` (bounded by `m_max_0`).
3657    ///
3658    /// v6.1.x: neighbour slot widened from `usize` (8 B on 64-bit) to
3659    /// `u32` (4 B). Row indices are catalog-bounded by `u32::MAX` (4G
3660    /// rows per table); the cast at the NSW boundary asserts this. At
3661    /// 1M dim-128 SQ8, layer 0 adjacency alone shrinks by ~128 MiB
3662    /// — the largest single contribution to the v6.0.5-measured
3663    /// 624 MiB ambition gap. On-disk format already used u32 LE, so
3664    /// this is a pure in-memory layout change; no `FILE_VERSION` bump.
3665    pub layers: Vec<PersistentVec<Vec<u32>>>,
3666}
3667
3668impl NswGraph {
3669    fn new(m: usize) -> Self {
3670        Self {
3671            m,
3672            m_max_0: m.saturating_mul(2),
3673            entry: None,
3674            entry_level: 0,
3675            levels: PersistentVec::new(),
3676            layers: alloc::vec![PersistentVec::new()],
3677        }
3678    }
3679
3680    /// Max-neighbour budget for layer `l`.
3681    pub const fn cap_for_layer(&self, layer: u8) -> usize {
3682        if layer == 0 { self.m_max_0 } else { self.m }
3683    }
3684}
3685
3686/// Deterministic level assignment, seeded on the row index so the same
3687/// insert order reproduces the same topology. Distribution is roughly
3688/// HNSW-flavoured with `mL ≈ 1/ln(M) ≈ 0.36` for M=16: each 4-bit
3689/// chunk that comes up zero promotes the node one layer (so P(level ≥
3690/// L) ≈ (1/16)^L).
3691#[allow(clippy::verbose_bit_mask)] // clippy suggests trailing_zeros(); we need an explicit MAX cap and a stable distribution shape.
3692pub fn nsw_assign_level(row_idx: usize) -> u8 {
3693    const MAX_LEVEL: u8 = 7; // 7 ⇒ ~16^7 ≈ 2.7e8 expected nodes between promotions; ample.
3694    // SplitMix-style mixer — cheap and seedable.
3695    let mut x = (row_idx as u64).wrapping_mul(0x9E37_79B9_7F4A_7C15);
3696    x ^= x >> 30;
3697    x = x.wrapping_mul(0xBF58_476D_1CE4_E5B9);
3698    x ^= x >> 27;
3699    x = x.wrapping_mul(0x94D0_49BB_1331_11EB);
3700    x ^= x >> 31;
3701    // Count contiguous low-end zero nibbles (4-bit chunks). Each zero
3702    // nibble has probability 1/16, mirroring HNSW's `mL ≈ 1/ln(M)` for
3703    // M=16. `trailing_zeros / 4` would lose the ordering when x = 0, so
3704    // a plain loop with a cap is clearer.
3705    let mut level: u8 = 0;
3706    while x & 0xF == 0 && level < MAX_LEVEL {
3707        level += 1;
3708        x >>= 4;
3709    }
3710    level
3711}
3712
3713/// v7.38.1 (L12) — the composite key `values` takes in a multi-column
3714/// B-tree over `[lead, extras…]`. A NULL component keys as
3715/// [`IndexKey::Null`] (declared to sort last, PG's NULLS LAST) so the
3716/// row stays findable by prefix probes on the columns before it. `None`
3717/// = some non-null component has no key form; the row is then not
3718/// entered, which is why creation gates every component column's type
3719/// through [`multi_component_type_ok`].
3720///
3721/// v7.39.13 — this keys by the VALUE while every probe keys by the
3722/// COLUMN ([`IndexKey::from_value_for_column`]), and that is safe
3723/// because a third thing makes the two agree: `Table::insert_keyed`
3724/// refuses a value whose type is not the column's, so a `NUMERIC`
3725/// column cannot hold the `Value::Int(2)` that would key as `Int(2)`
3726/// where the probe built `Numeric(2)`.
3727///
3728/// Written down because the possibility looks live and is not. Keying
3729/// by column here was implemented and then reverted: it added a schema
3730/// lookup per component per row to the write path to re-check a
3731/// contract insert already enforces, and the test written to make it
3732/// bite could not construct the divergent row at all —
3733/// `TypeMismatch { column: "n", expected: Numeric, actual: Int }`.
3734pub(crate) fn compose_multi_key(
3735    values: &[Value<'_>],
3736    lead: usize,
3737    extras: &[usize],
3738) -> Option<alloc::boxed::Box<[IndexKey]>> {
3739    let mut comps: Vec<IndexKey> = Vec::with_capacity(1 + extras.len());
3740    for pos in core::iter::once(lead).chain(extras.iter().copied()) {
3741        let v = values.get(pos)?;
3742        if matches!(v, Value::Null) {
3743            comps.push(IndexKey::Null);
3744        } else {
3745            comps.push(IndexKey::from_value(v)?);
3746        }
3747    }
3748    Some(comps.into_boxed_slice())
3749}
3750
3751/// v7.38.1 (L12) — component-type gate for multi-column B-trees: every
3752/// NON-NULL value of these types keys through
3753/// [`IndexKey::from_value_for_column`], so a row can only be absent
3754/// from the index when creation raced a type this answer does not
3755/// allow. A type answering `false` simply keeps its index on the
3756/// leading-column path — a slower plan, never a wrong answer.
3757///
3758/// v7.39.13 — EXHAUSTIVE, and that is the whole point of rewriting it.
3759///
3760/// It was a `matches!` over eleven names, so every one of the other
3761/// sixty-three `DataType`s answered `false` by falling off the end, and
3762/// nothing in the tree could say which of them meant it. Two of the
3763/// misses were reported from production as separate defects and were
3764/// one hole: `timestamptz` (v7.39.13, sentori's access path) and
3765/// `numeric`, which this version's own perf gate caught the same day
3766/// with a composite index over `(n numeric, id)` that never became a
3767/// composite tree —
3768///
3769/// ```text
3770///   10,000 rows   WHERE n = 1.23 ORDER BY id DESC LIMIT 20
3771///     SPG 0.497-0.520 ms   PG 18.6 0.183-0.234 ms
3772///   50,000 rows   the same query
3773///     SPG 0.975-0.991 ms   PG 18.6 0.195-0.439 ms
3774/// ```
3775///
3776/// Twenty rows behind a seek do not cost twice as much on five times
3777/// the table. It was a scan and a sort, exactly as `timestamptz` was.
3778///
3779/// Written as a match with no wildcard, a new `DataType` does not
3780/// compile until someone answers for it. That is the mechanical part;
3781/// the arms are grouped by the reason, so the answer is also readable.
3782pub(crate) fn multi_component_type_ok(ty: DataType) -> bool {
3783    match ty {
3784        // Integers, and everything whose storage IS an i64 with the
3785        // same order: dates, both timestamps, times, money, year.
3786        DataType::SmallInt
3787        | DataType::Int
3788        | DataType::BigInt
3789        | DataType::Date
3790        | DataType::Timestamp
3791        // `timestamptz` keys exactly as `timestamp` does: both hold the
3792        // same i64 of UTC microseconds, and the zone lives in the
3793        // column's type rather than in the value.
3794        | DataType::Timestamptz
3795        | DataType::Time
3796        | DataType::TimeTz
3797        | DataType::Year
3798        | DataType::Money => true,
3799        // Text, in every declared width. `bpchar` keys blank-trimmed,
3800        // which is how it compares.
3801        DataType::Text | DataType::Varchar(_) | DataType::Char(_) => true,
3802        DataType::Bool | DataType::Uuid => true,
3803        // Exact decimal, through the canonical `NumericKey` that makes
3804        // `1.5` and `1.50` one key. Safe as a component only since
3805        // `compose_multi_key` began keying by COLUMN TYPE.
3806        DataType::Numeric { .. } => true,
3807        // bytea orders by plain byte comparison, which is `Vec<u8>`'s.
3808        DataType::Bytes => true,
3809        // `f64` is `PartialOrd` and nothing else: a B-tree cannot hold
3810        // a key whose comparison may decline to answer.
3811        DataType::Float | DataType::Real => false,
3812
3813        // Object identifiers and `name` reach storage as values
3814        // `IndexKey::from_value` returns `None` for. Not a decision
3815        // about the type — a statement about the key form it has.
3816        DataType::Name | DataType::Xid | DataType::Xid8 | DataType::Oid => false,
3817        // Documents and the semi-structured family: PostgreSQL serves
3818        // these with GIN, not with a B-tree over the whole value.
3819        DataType::Json | DataType::Jsonb | DataType::Hstore | DataType::Xml => false,
3820        // Full-text.
3821        DataType::TsVector | DataType::TsQuery => false,
3822        // Vectors: ordered by distance to a query, which is not an
3823        // order at all until the query exists.
3824        DataType::Vector { .. } => false,
3825        // Intervals, ranges and multiranges have no total order that
3826        // a B-tree probe could use; PostgreSQL uses GiST/SP-GiST.
3827        DataType::Interval | DataType::Range(_) | DataType::Multirange(_) => false,
3828        // Geometry: GiST/SP-GiST there too.
3829        DataType::Point
3830        | DataType::Lseg
3831        | DataType::Path
3832        | DataType::PgBox
3833        | DataType::Polygon
3834        | DataType::Line
3835        | DataType::Circle => false,
3836        // Network and bit strings. `inet`/`cidr` COULD be B-tree keyed
3837        // — PostgreSQL does — but a family-blind byte compare would
3838        // mis-order IPv4 against IPv6, so the key form does not exist
3839        // here yet.
3840        DataType::Inet
3841        | DataType::Cidr
3842        | DataType::Macaddr
3843        | DataType::Macaddr8
3844        | DataType::PgLsn
3845        | DataType::Bit(_)
3846        | DataType::BitVarying(_)
3847        | DataType::Char1 => false,
3848        // Arrays, of every element type and both dimensionalities.
3849        // PostgreSQL answers containment over these with GIN.
3850        DataType::TextArray
3851        | DataType::IntArray
3852        | DataType::BigIntArray
3853        | DataType::OidArray
3854        | DataType::Int2Vector
3855        | DataType::OidVector
3856        | DataType::IntervalArray
3857        | DataType::BoolArray
3858        | DataType::SmallIntArray
3859        | DataType::FloatArray
3860        | DataType::NumericArray
3861        | DataType::DateArray
3862        | DataType::TimestampArray
3863        | DataType::TimestamptzArray
3864        | DataType::UuidArray
3865        | DataType::JsonArray
3866        | DataType::JsonbArray
3867        | DataType::BytesArray
3868        | DataType::VarcharArray
3869        | DataType::CharArray
3870        // v7.40.0 — the five array spellings this version adds. Same
3871        // answer as every other array: PostgreSQL answers containment
3872        // over these with GIN, not a B-tree over the whole value.
3873        | DataType::RealArray
3874        | DataType::TimeArray
3875        | DataType::TimeTzArray
3876        | DataType::InetArray
3877        | DataType::XmlArray
3878        | DataType::MoneyArray
3879        | DataType::IntArray2D
3880        | DataType::BigIntArray2D
3881        | DataType::TextArray2D
3882        | DataType::BoolArray2D => false,
3883    }
3884}
3885
3886impl Index {
3887    /// Any key this B-tree currently holds, or `None` if it holds none.
3888    ///
3889    /// A probe built from a query literal has to be the same SHAPE as the
3890    /// keys the maintenance side made, or `lookup_eq` misses every row and
3891    /// the caller reads the empty answer as "no rows match". One stored
3892    /// key settles it: an index keys one expression, whose values are one
3893    /// type.
3894    pub fn sample_key(&self) -> Option<&IndexKey> {
3895        match &self.kind {
3896            IndexKind::BTree(map) => map.iter().next().map(|(k, _)| k),
3897            _ => None,
3898        }
3899    }
3900
3901    /// v7.38.19 — the largest integer key this index holds.
3902    ///
3903    /// For the one question it answers — what number comes next for a
3904    /// `serial` column — a tree already knows, and knew all along.
3905    /// [`Table::next_auto_value`] read every row instead:
3906    ///
3907    /// ```text
3908    ///   rows in the table    one INSERT      PostgreSQL 18
3909    ///      1,000              1.831 ms          1.245
3910    ///     10,000              1.814             1.289
3911    ///     50,000              2.703             1.386
3912    ///    200,000              3.666             1.375
3913    /// ```
3914    ///
3915    /// Theirs is flat because a sequence is a counter. Ours grew with
3916    /// the table, so an ingest workload got slower the longer it ran.
3917    ///
3918    /// A dead row version's key is still in the tree, so this can be
3919    /// HIGHER than the maximum over live rows. That is the safe
3920    /// direction — it hands out a value no row has ever held — and it
3921    /// is the direction PostgreSQL goes too, which never reuses a
3922    /// number a deleted row was given.
3923    ///
3924    /// `None` = no B-tree, or its keys are not integers, and the caller
3925    /// falls back to the scan.
3926    pub fn max_int_key(&self) -> Option<i64> {
3927        let IndexKind::BTree(map) = &self.kind else {
3928            return None;
3929        };
3930        match map.iter_rev().next()? {
3931            (IndexKey::Int(n), _) => Some(*n),
3932            _ => None,
3933        }
3934    }
3935
3936    fn new_btree(name: String, column_position: usize) -> Self {
3937        Self {
3938            name,
3939            column_position,
3940            kind: IndexKind::BTree(PersistentBTreeMap::new()),
3941            included_columns: Vec::new(),
3942            constraint_internal: false,
3943            constraint_backing: false,
3944            partial_predicate: None,
3945            expression: None,
3946            is_unique: false,
3947            nulls_not_distinct: false,
3948            descending: false,
3949            nulls_first: None,
3950            collation: None,
3951            extra_column_positions: Vec::new(),
3952            extra_orders: Vec::new(),
3953            prefix_len: None,
3954        }
3955    }
3956
3957    /// v7.38.1 (L12) — a real multi-column B-tree shell. The caller
3958    /// sets `extra_column_positions` before the first row enters; the
3959    /// key arity is `1 + extras` from then on.
3960    fn new_btree_multi(name: String, column_position: usize) -> Self {
3961        Self {
3962            kind: IndexKind::BTreeMulti(PersistentBTreeMap::new()),
3963            ..Self::new_btree(name, column_position)
3964        }
3965    }
3966
3967    /// v7.38.1 (L12) — the composite key this row takes in a
3968    /// [`IndexKind::BTreeMulti`] index. NULL components key as
3969    /// [`IndexKey::Null`] so prefix probes still find the row; `None`
3970    /// only when a non-null component produces no key, which creation's
3971    /// component-type gate makes unreachable for well-formed indexes.
3972    pub fn multi_key_for_row(&self, values: &[Value<'_>]) -> Option<alloc::boxed::Box<[IndexKey]>> {
3973        compose_multi_key(values, self.column_position, &self.extra_column_positions)
3974    }
3975
3976    fn new_nsw(name: String, column_position: usize, m: usize) -> Self {
3977        Self {
3978            name,
3979            column_position,
3980            kind: IndexKind::Nsw(NswGraph::new(m)),
3981            included_columns: Vec::new(),
3982            constraint_internal: false,
3983            constraint_backing: false,
3984            partial_predicate: None,
3985            expression: None,
3986            is_unique: false,
3987            nulls_not_distinct: false,
3988            descending: false,
3989            nulls_first: None,
3990            collation: None,
3991            extra_column_positions: Vec::new(),
3992            extra_orders: Vec::new(),
3993            prefix_len: None,
3994        }
3995    }
3996
3997    /// v6.7.1 — BRIN index constructor. BRIN carries no in-memory
3998    /// data; the `column_type` snapshot is used by the segment
3999    /// encoder + planner for type-checking range predicates.
4000    fn new_brin(name: String, column_position: usize, column_type: DataType) -> Self {
4001        Self {
4002            name,
4003            column_position,
4004            kind: IndexKind::Brin {
4005                column_type,
4006                summaries: alloc::vec::Vec::new(),
4007            },
4008            included_columns: Vec::new(),
4009            constraint_internal: false,
4010            constraint_backing: false,
4011            partial_predicate: None,
4012            expression: None,
4013            is_unique: false,
4014            nulls_not_distinct: false,
4015            descending: false,
4016            nulls_first: None,
4017            collation: None,
4018            extra_column_positions: Vec::new(),
4019            extra_orders: Vec::new(),
4020            prefix_len: None,
4021        }
4022    }
4023
4024    /// v7.12.3 — GIN inverted-index constructor. Empty posting-list
4025    /// map; caller (typically [`Table::add_gin_index`] or
4026    /// [`Table::restore_gin_index`]) populates it from existing rows
4027    /// or from a deserialised snapshot.
4028    fn new_gin(name: String, column_position: usize) -> Self {
4029        Self {
4030            name,
4031            column_position,
4032            kind: IndexKind::Gin(PersistentBTreeMap::new()),
4033            included_columns: Vec::new(),
4034            constraint_internal: false,
4035            constraint_backing: false,
4036            partial_predicate: None,
4037            expression: None,
4038            is_unique: false,
4039            nulls_not_distinct: false,
4040            descending: false,
4041            nulls_first: None,
4042            collation: None,
4043            extra_column_positions: Vec::new(),
4044            extra_orders: Vec::new(),
4045            prefix_len: None,
4046        }
4047    }
4048
4049    /// v7.15.0 — `gin_trgm_ops`-flavoured GIN constructor. Same
4050    /// shape as `new_gin` but the posting-list keys are 3-byte
4051    /// trigram shingles (`pg_trgm`-compatible) and the column
4052    /// type is `TEXT` / `VARCHAR` (not `TSVECTOR`).
4053    fn new_gin_trgm(name: String, column_position: usize) -> Self {
4054        Self {
4055            name,
4056            column_position,
4057            kind: IndexKind::GinTrgm(PersistentBTreeMap::new()),
4058            included_columns: Vec::new(),
4059            constraint_internal: false,
4060            constraint_backing: false,
4061            partial_predicate: None,
4062            expression: None,
4063            is_unique: false,
4064            nulls_not_distinct: false,
4065            descending: false,
4066            nulls_first: None,
4067            collation: None,
4068            extra_column_positions: Vec::new(),
4069            extra_orders: Vec::new(),
4070            prefix_len: None,
4071        }
4072    }
4073
4074    /// v7.17.0 Phase 2.2 — MySQL `FULLTEXT KEY` GIN constructor.
4075    /// Same shape as `new_gin_trgm` but the posting-list keys
4076    /// are lower-cased word lexemes (`to_tsvector('simple', col)`
4077    /// equivalent) instead of trigrams, and the column type is
4078    /// `TEXT` / `VARCHAR` (not `TSVECTOR`).
4079    fn new_gin_fulltext(name: String, column_position: usize) -> Self {
4080        Self {
4081            name,
4082            column_position,
4083            kind: IndexKind::GinFulltext(PersistentBTreeMap::new()),
4084            included_columns: Vec::new(),
4085            constraint_internal: false,
4086            constraint_backing: false,
4087            partial_predicate: None,
4088            expression: None,
4089            is_unique: false,
4090            nulls_not_distinct: false,
4091            descending: false,
4092            nulls_first: None,
4093            collation: None,
4094            extra_column_positions: Vec::new(),
4095            extra_orders: Vec::new(),
4096            prefix_len: None,
4097        }
4098    }
4099
4100    /// v7.37.8(sentori Epic 5 P2)— JSONB-GIN constructor. Same
4101    /// shape as the other GIN-family indexes; posting-list keys
4102    /// are the canonical `(path, leaf)` tokens emitted by
4103    /// `crate::jsonb_gin::extract_tokens`. Maintains posting
4104    /// lists from `Value::Json` cells(JSONB is a synonym for the
4105    /// same in-memory string-backed Value).
4106    fn new_gin_jsonb(name: String, column_position: usize) -> Self {
4107        Self {
4108            name,
4109            column_position,
4110            kind: IndexKind::GinJsonb(PersistentBTreeMap::new()),
4111            included_columns: Vec::new(),
4112            constraint_internal: false,
4113            constraint_backing: false,
4114            partial_predicate: None,
4115            expression: None,
4116            is_unique: false,
4117            nulls_not_distinct: false,
4118            descending: false,
4119            nulls_first: None,
4120            collation: None,
4121            extra_column_positions: Vec::new(),
4122            extra_orders: Vec::new(),
4123            prefix_len: None,
4124        }
4125    }
4126
4127    /// v7.34.4 — descending-order iterator over `(IndexKey, locators)`
4128    /// pairs for a BTree index, with O(log N) descent to the rightmost
4129    /// leaf and lazy emission thereafter. Returns an empty iterator
4130    /// for non-BTree index kinds — callers handle both uniformly.
4131    /// Used by the ORDER BY `<indexed col>` DESC + LIMIT N executor
4132    /// path: walking only the first N matches off the rightmost leaf
4133    /// avoids the per-row materialisation + partial-sort cost on
4134    /// large tables (mailrs `content_worker` at 250 k rows).
4135    pub fn iter_desc(
4136        &self,
4137    ) -> alloc::boxed::Box<dyn Iterator<Item = (&IndexKey, &crate::posting::PostingList)> + '_>
4138    {
4139        match &self.kind {
4140            IndexKind::BTree(m) => alloc::boxed::Box::new(m.iter_rev()),
4141            // v7.38.1 (L12) — projecting the leading component of a
4142            // composite key preserves order: keys sort by the whole
4143            // tuple, so the leading component is non-increasing here
4144            // (non-decreasing in iter_asc), exactly what an ORDER BY
4145            // on the leading column needs.
4146            IndexKind::BTreeMulti(m) => {
4147                alloc::boxed::Box::new(m.iter_rev().map(|(k, l)| (&k[0], l)))
4148            }
4149            IndexKind::Nsw(_)
4150            | IndexKind::Brin { .. }
4151            | IndexKind::Gin(_)
4152            | IndexKind::GinTrgm(_)
4153            | IndexKind::GinFulltext(_)
4154            | IndexKind::GinJsonb(_) => alloc::boxed::Box::new(core::iter::empty()),
4155        }
4156    }
4157
4158    /// v7.34.4 — ascending-order iterator over `(IndexKey, locators)`
4159    /// pairs. Mirror of `iter_desc` for ORDER BY ... ASC + LIMIT N.
4160    pub fn iter_asc(
4161        &self,
4162    ) -> alloc::boxed::Box<dyn Iterator<Item = (&IndexKey, &crate::posting::PostingList)> + '_>
4163    {
4164        match &self.kind {
4165            IndexKind::BTree(m) => alloc::boxed::Box::new(m.iter()),
4166            // v7.38.1 (L12) — see iter_desc: the leading component of
4167            // a tuple-sorted walk is itself in order.
4168            IndexKind::BTreeMulti(m) => alloc::boxed::Box::new(m.iter().map(|(k, l)| (&k[0], l))),
4169            IndexKind::Nsw(_)
4170            | IndexKind::Brin { .. }
4171            | IndexKind::Gin(_)
4172            | IndexKind::GinTrgm(_)
4173            | IndexKind::GinFulltext(_)
4174            | IndexKind::GinJsonb(_) => alloc::boxed::Box::new(core::iter::empty()),
4175        }
4176    }
4177
4178    /// Look up the locators stored under `key` (B-tree only). Returns
4179    /// an empty slice when the key is absent or the index isn't a
4180    /// BTree — callers can treat both cases uniformly.
4181    ///
4182    /// v5.1: return type widened from `&[usize]` to `&[RowLocator]`.
4183    /// Pre-v5.2 callers can read the slice and `.as_hot().unwrap()`
4184    /// each entry (no `Cold` variants exist until the freezer lands);
4185    /// post-v5.2 callers dispatch hot vs. cold per locator.
4186    pub fn lookup_eq(&self, key: &IndexKey) -> &crate::posting::PostingList {
4187        match &self.kind {
4188            IndexKind::BTree(m) => m.get(key).map_or(&EMPTY_POSTINGS, |l| l),
4189            // BRIN / NSW / GIN / trigram-GIN / fulltext-GIN have
4190            // no IndexKey-keyed map; lookup is a no-op. GIN uses
4191            // [`Index::gin_lookup_word`] instead.
4192            IndexKind::Nsw(_)
4193            | IndexKind::Brin { .. }
4194            | IndexKind::Gin(_)
4195            | IndexKind::GinTrgm(_)
4196            | IndexKind::GinFulltext(_)
4197            | IndexKind::GinJsonb(_)
4198            | IndexKind::BTreeMulti(_) => &EMPTY_POSTINGS,
4199        }
4200    }
4201
4202    /// v7.37.43 (INSUBQ B-2) — specialised lookup for integer-PK probes.
4203    /// `try_count_star_pk_in_subquery_fast` already holds an `i64` (the
4204    /// inner survivor key); skip the `IndexKey::from_value` enum-dispatch
4205    /// trip and build the key inline. ~20 ns × N_survivors saved on
4206    /// the INSUBQ hot loop.
4207    #[inline]
4208    pub fn lookup_eq_i64(&self, n: i64) -> &crate::posting::PostingList {
4209        match &self.kind {
4210            IndexKind::BTree(m) => m.get(&IndexKey::Int(n)).map_or(&EMPTY_POSTINGS, |l| l),
4211            IndexKind::Nsw(_)
4212            | IndexKind::Brin { .. }
4213            | IndexKind::Gin(_)
4214            | IndexKind::GinTrgm(_)
4215            | IndexKind::GinFulltext(_)
4216            | IndexKind::GinJsonb(_)
4217            | IndexKind::BTreeMulti(_) => &EMPTY_POSTINGS,
4218        }
4219    }
4220
4221    /// v7.38 (perf, index range scan) — flatten the row locators for every key
4222    /// in `[lo, hi]` (bounds per `core::ops::Bound`) via the BTree's `O(log N +
4223    /// k)` range walk. Returns `None` once more than `cap` locators accumulate
4224    /// — a "this range isn't selective enough, seq-scan instead" signal that
4225    /// stops a wide range from materialising a near-full table's worth of rows
4226    /// through the index. BTree only (other kinds → None).
4227    pub fn lookup_range_capped(
4228        &self,
4229        lo: core::ops::Bound<&IndexKey>,
4230        hi: core::ops::Bound<&IndexKey>,
4231        cap: usize,
4232    ) -> Option<Vec<RowLocator>> {
4233        self.lookup_range_capped_by(lo, hi, cap, |_| true)
4234    }
4235
4236    /// v7.39 (round 490) — the same range walk, but the caller decides
4237    /// which locators are worth carrying, and the cap counts only those.
4238    ///
4239    /// A BTree index holds one locator per row VERSION. On a churned table
4240    /// the dead versions are still in there: round 490 measured a
4241    /// 1000-row range handing back 61 000 locators after 60
4242    /// delete-and-reinsert cycles with the background vacuum switched off.
4243    /// Every caller then dropped the dead ones — the mutation paths and the
4244    /// SELECT range path all test `is_row_visible` and `continue` — but only
4245    /// after they had been collected into a `Vec`, sorted, and walked.
4246    ///
4247    /// Handing the predicate down means the walk keeps ~1000, and the cap
4248    /// (which exists so an index walk never costs more than the scan it
4249    /// replaces) is once again measured in rows a caller will actually look
4250    /// at. Round 461 had to add the dead count to the budget to stop the
4251    /// seek being refused outright; with the filter here that compensation
4252    /// is no longer needed.
4253    pub fn lookup_range_capped_by(
4254        &self,
4255        lo: core::ops::Bound<&IndexKey>,
4256        hi: core::ops::Bound<&IndexKey>,
4257        cap: usize,
4258        keep: impl Fn(RowLocator) -> bool,
4259    ) -> Option<Vec<RowLocator>> {
4260        match &self.kind {
4261            IndexKind::BTree(m) => {
4262                let mut out: Vec<RowLocator> = Vec::new();
4263                for (_, locs) in m.range(lo, hi) {
4264                    out.extend(locs.iter().copied().filter(|l| keep(*l)));
4265                    if out.len() > cap {
4266                        return None;
4267                    }
4268                }
4269                Some(out)
4270            }
4271            IndexKind::Nsw(_)
4272            | IndexKind::Brin { .. }
4273            | IndexKind::Gin(_)
4274            | IndexKind::GinTrgm(_)
4275            | IndexKind::GinFulltext(_)
4276            | IndexKind::GinJsonb(_)
4277            | IndexKind::BTreeMulti(_) => None,
4278        }
4279    }
4280
4281    /// v7.38.1 (L12) — full-tuple point lookup on a [`IndexKind::BTreeMulti`]
4282    /// index. `key` must carry exactly as many components as the index
4283    /// has columns; anything else (including a probe against a
4284    /// non-multi index) finds nothing, and "nothing" here is safe
4285    /// because the caller falls back to a scan, never to an answer.
4286    pub fn lookup_eq_multi(&self, key: &[IndexKey]) -> &crate::posting::PostingList {
4287        match &self.kind {
4288            IndexKind::BTreeMulti(m) if key.len() == 1 + self.extra_column_positions.len() => {
4289                m.get_by(key).map_or(&EMPTY_POSTINGS, |l| l)
4290            }
4291            _ => &EMPTY_POSTINGS,
4292        }
4293    }
4294
4295    /// v7.38.1 (L12) — locators for every key whose leading components
4296    /// equal `prefix`, on a [`IndexKind::BTreeMulti`] index. Slice
4297    /// ordering keeps a prefix's keys contiguous, so this is one
4298    /// descent to `[prefix]` and a walk that stops at the first key
4299    /// leaving the prefix. Same cap/keep contract as
4300    /// [`Index::lookup_range_capped_by`]: `None` = not selective
4301    /// enough (or not a multi index), fall back.
4302    /// v7.39.13 — the keys under a composite index's PREFIX, in the
4303    /// tree's order, lazily.
4304    ///
4305    /// `WHERE project_id = ? ORDER BY received_at DESC LIMIT 20` behind
4306    /// an index on `(project_id, received_at)` is one seek and twenty
4307    /// steps. SPG had no way to express it: `lookup_prefix_capped_by`
4308    /// materialises the whole group and caps, and `iter_desc` starts at
4309    /// the tree's own end, so the walk would cross every later project
4310    /// first. Sentori measured that shape as `Seq Scan -> Sort` against
4311    /// PostgreSQL's `Limit -> Index Scan`.
4312    ///
4313    /// The bound is a prefix, not a key: a tuple `[p]` sorts BELOW every
4314    /// longer tuple starting with `p`, so no single key names the
4315    /// group's top. `range_rev_by` takes the two predicates instead.
4316    ///
4317    /// `None` for anything that is not a composite B-tree, or a prefix
4318    /// longer than the key.
4319    pub fn iter_prefix_desc<'a>(
4320        &'a self,
4321        prefix: &'a [IndexKey],
4322    ) -> Option<impl Iterator<Item = (&'a [IndexKey], &'a crate::posting::PostingList)> + 'a> {
4323        let IndexKind::BTreeMulti(m) = &self.kind else {
4324            return None;
4325        };
4326        if prefix.is_empty() || prefix.len() > 1 + self.extra_column_positions.len() {
4327            return None;
4328        }
4329        let p = prefix.len();
4330        Some(
4331            m.range_rev_by(
4332                move |k: &alloc::boxed::Box<[IndexKey]>| k[..core::cmp::min(k.len(), p)] > *prefix,
4333                move |k: &alloc::boxed::Box<[IndexKey]>| k[..core::cmp::min(k.len(), p)] < *prefix,
4334            )
4335            .map(|(k, v)| (&k[..], v)),
4336        )
4337    }
4338
4339    /// The ascending mirror of [`Self::iter_prefix_desc`]. Forward
4340    /// `range` can express this one with a key bound — every tuple in
4341    /// the group sorts at or after the prefix tuple itself — so it
4342    /// takes that road and stops on the same predicate.
4343    pub fn iter_prefix_asc<'a>(
4344        &'a self,
4345        prefix: &'a [IndexKey],
4346    ) -> Option<impl Iterator<Item = (&'a [IndexKey], &'a crate::posting::PostingList)> + 'a> {
4347        let IndexKind::BTreeMulti(m) = &self.kind else {
4348            return None;
4349        };
4350        if prefix.is_empty() || prefix.len() > 1 + self.extra_column_positions.len() {
4351            return None;
4352        }
4353        let p = prefix.len();
4354        let lo: alloc::boxed::Box<[IndexKey]> = prefix.to_vec().into_boxed_slice();
4355        Some(
4356            m.range(core::ops::Bound::Included(&lo), core::ops::Bound::Unbounded)
4357                .take_while(move |(k, _)| k.len() >= p && k[..p] == *prefix)
4358                .map(|(k, v)| (&k[..], v))
4359                .collect::<Vec<_>>()
4360                .into_iter(),
4361        )
4362    }
4363
4364    pub fn lookup_prefix_capped_by(
4365        &self,
4366        prefix: &[IndexKey],
4367        cap: usize,
4368        keep: impl Fn(RowLocator) -> bool,
4369    ) -> Option<Vec<RowLocator>> {
4370        let IndexKind::BTreeMulti(m) = &self.kind else {
4371            return None;
4372        };
4373        if prefix.is_empty() || prefix.len() > 1 + self.extra_column_positions.len() {
4374            return None;
4375        }
4376        let lo: alloc::boxed::Box<[IndexKey]> = prefix.to_vec().into_boxed_slice();
4377        let mut out: Vec<RowLocator> = Vec::new();
4378        for (k, locs) in m.range(core::ops::Bound::Included(&lo), core::ops::Bound::Unbounded) {
4379            if k.len() < prefix.len() || k[..prefix.len()] != *prefix {
4380                break;
4381            }
4382            out.extend(locs.iter().copied().filter(|l| keep(*l)));
4383            if out.len() > cap {
4384                return None;
4385            }
4386        }
4387        Some(out)
4388    }
4389
4390    /// v7.38.19 — a RANGE on the composite tree's leading column.
4391    ///
4392    /// Tuples order lexicographically, so every key whose first
4393    /// component is `x` sorts at or after the one-element tuple `[x]`
4394    /// and before `[x']` for any larger `x'`. That makes a leading-
4395    /// column range one contiguous run, walked exactly like the
4396    /// single-column range walk — the only difference is that the
4397    /// comparison is against `k[0]` rather than the whole key.
4398    ///
4399    /// Without this, `WHERE project_id > 90` on a table whose only
4400    /// index was `(project_id, kind)` read every row: 4.067 ms against
4401    /// PostgreSQL 18's 0.220, on a predicate matching nothing. The same
4402    /// query with a single-column index took 0.165, which is what says
4403    /// the range was never the problem.
4404    pub fn lookup_leading_range_capped_by(
4405        &self,
4406        lo: core::ops::Bound<&IndexKey>,
4407        hi: core::ops::Bound<&IndexKey>,
4408        cap: usize,
4409        keep: impl Fn(RowLocator) -> bool,
4410    ) -> Option<Vec<RowLocator>> {
4411        let IndexKind::BTreeMulti(m) = &self.kind else {
4412            return None;
4413        };
4414        // The start of the run. An EXCLUDED lower bound cannot be
4415        // handed to the map as-is: `[x]` sorts BEFORE `[x, y]`, so
4416        // excluding `[x]` would still admit every tuple that begins
4417        // with `x`. Start at `[x]` included and drop those tuples by
4418        // the per-key test below, which compares the component.
4419        let lo_key: Option<alloc::boxed::Box<[IndexKey]>> = match lo {
4420            core::ops::Bound::Included(k) | core::ops::Bound::Excluded(k) => {
4421                Some(alloc::vec![k.clone()].into_boxed_slice())
4422            }
4423            core::ops::Bound::Unbounded => None,
4424        };
4425        let start = match &lo_key {
4426            Some(k) => core::ops::Bound::Included(k),
4427            None => core::ops::Bound::Unbounded,
4428        };
4429        let mut out: Vec<RowLocator> = Vec::new();
4430        for (k, locs) in m.range(start, core::ops::Bound::Unbounded) {
4431            let Some(first) = k.first() else { continue };
4432            match lo {
4433                core::ops::Bound::Excluded(b) if first == b => continue,
4434                _ => {}
4435            }
4436            match hi {
4437                core::ops::Bound::Included(b) if first > b => break,
4438                core::ops::Bound::Excluded(b) if first >= b => break,
4439                _ => {}
4440            }
4441            out.extend(locs.iter().copied().filter(|l| keep(*l)));
4442            if out.len() > cap {
4443                return None;
4444            }
4445        }
4446        Some(out)
4447    }
4448
4449    /// v7.39 (round 560) — the index range as (key, locator) pairs.
4450    ///
4451    /// `lookup_range_capped_by` throws the KEY away and returns only
4452    /// locators, so a query whose projection is exactly the indexed
4453    /// column still goes to the row store for a value the walk already
4454    /// had in hand — paying per row for something the index knows.
4455    ///
4456    /// Uncapped on purpose: an index-only walk touches no row, so the
4457    /// selectivity ceiling that keeps a seek from being worse than the
4458    /// scan it replaces does not apply to it.
4459    ///
4460    /// v7.39 (round 562) — and it does not collect, either. This
4461    /// returned a `Vec<(IndexKey, RowLocator)>`: for a 100k-row range,
4462    /// 100k key clones into a `Vec::new()` that doubles its way up to
4463    /// several MB, all to be walked once and dropped. A profile of the
4464    /// server serving that query put 20% of the connection thread's CPU
4465    /// on the collect alone, with another 18% in the allocator beside
4466    /// it. The caller consumes the pairs in order and needs the key
4467    /// only by reference, so it can have the walk itself.
4468    pub fn range_keyed(
4469        &self,
4470        lo: core::ops::Bound<&IndexKey>,
4471        hi: core::ops::Bound<&IndexKey>,
4472    ) -> Option<impl Iterator<Item = (&IndexKey, RowLocator)> + '_> {
4473        match &self.kind {
4474            IndexKind::BTree(m) => Some(
4475                m.range(lo, hi)
4476                    .flat_map(|(k, locs)| locs.iter().map(move |l| (k, *l))),
4477            ),
4478            IndexKind::Nsw(_)
4479            | IndexKind::Brin { .. }
4480            | IndexKind::Gin(_)
4481            | IndexKind::GinTrgm(_)
4482            | IndexKind::GinFulltext(_)
4483            | IndexKind::GinJsonb(_)
4484            | IndexKind::BTreeMulti(_) => None,
4485        }
4486    }
4487
4488    /// v7.12.3 — GIN posting-list lookup. Returns the row locators
4489    /// whose `tsvector` cell contains `word`. Empty when the word is
4490    /// absent from the index or this isn't a GIN index.
4491    pub fn gin_lookup_word(&self, word: &str) -> &crate::posting::PostingList {
4492        match &self.kind {
4493            // v7.17.0 Phase 2.2 — fulltext-GIN shares the same
4494            // lexeme-keyed posting list shape as the
4495            // tsvector-typed GIN, so the same lookup applies.
4496            IndexKind::Gin(m) | IndexKind::GinFulltext(m) => {
4497                m.get(&String::from(word)).map_or(&EMPTY_POSTINGS, |l| l)
4498            }
4499            IndexKind::BTree(_)
4500            | IndexKind::Nsw(_)
4501            | IndexKind::Brin { .. }
4502            | IndexKind::GinTrgm(_)
4503            | IndexKind::GinJsonb(_)
4504            | IndexKind::BTreeMulti(_) => &EMPTY_POSTINGS,
4505        }
4506    }
4507
4508    /// v7.15.0 — trigram-GIN posting-list lookup. Returns the row
4509    /// locators whose indexed `TEXT` cell contains the trigram
4510    /// `tri`. Empty when the trigram is absent or this isn't a
4511    /// trigram-GIN index.
4512    pub fn gin_trgm_lookup(&self, tri: &str) -> &crate::posting::PostingList {
4513        match &self.kind {
4514            IndexKind::GinTrgm(m) => m.get(&String::from(tri)).map_or(&EMPTY_POSTINGS, |l| l),
4515            IndexKind::BTree(_)
4516            | IndexKind::Nsw(_)
4517            | IndexKind::Brin { .. }
4518            | IndexKind::Gin(_)
4519            | IndexKind::GinFulltext(_)
4520            | IndexKind::GinJsonb(_)
4521            | IndexKind::BTreeMulti(_) => &EMPTY_POSTINGS,
4522        }
4523    }
4524
4525    /// v7.37.8(sentori Epic 5 P2)— JSONB-GIN posting-list lookup.
4526    /// Returns the row locators whose indexed JSONB cell carries
4527    /// the canonical `token`(see [`crate::jsonb_gin::extract_tokens`]).
4528    /// Empty when the token is absent or this isn't a JSONB-GIN
4529    /// index. Planners drive `<col> @> <jsonb_literal>` through here.
4530    pub fn gin_jsonb_lookup(&self, token: &str) -> &crate::posting::PostingList {
4531        match &self.kind {
4532            IndexKind::GinJsonb(m) => m.get(&String::from(token)).map_or(&EMPTY_POSTINGS, |l| l),
4533            IndexKind::BTree(_)
4534            | IndexKind::Nsw(_)
4535            | IndexKind::Brin { .. }
4536            | IndexKind::Gin(_)
4537            | IndexKind::GinTrgm(_)
4538            | IndexKind::GinFulltext(_)
4539            | IndexKind::BTreeMulti(_) => &EMPTY_POSTINGS,
4540        }
4541    }
4542
4543    /// Borrow the NSW graph (if this is an NSW index). Callers that need
4544    /// the graph for a kNN search go through here.
4545    pub const fn nsw(&self) -> Option<&NswGraph> {
4546        match &self.kind {
4547            IndexKind::Nsw(g) => Some(g),
4548            IndexKind::BTree(_)
4549            | IndexKind::Brin { .. }
4550            | IndexKind::Gin(_)
4551            | IndexKind::GinTrgm(_)
4552            | IndexKind::GinFulltext(_)
4553            | IndexKind::GinJsonb(_)
4554            | IndexKind::BTreeMulti(_) => None,
4555        }
4556    }
4557
4558    /// v6.7.1 — true when this index is a BRIN (block range) index.
4559    /// Used by the segment encoder to opt into BRIN sidecar emission
4560    /// at freeze time, and by the planner to opt into page-skipping
4561    /// on range predicates.
4562    pub const fn is_brin(&self) -> bool {
4563        matches!(self.kind, IndexKind::Brin { .. })
4564    }
4565
4566    /// v7.15.0 — true when this index is a trigram GIN
4567    /// (`gin_trgm_ops`-flavoured). Used by the LIKE planner to
4568    /// opt into trigram acceleration.
4569    pub const fn is_gin_trgm(&self) -> bool {
4570        matches!(self.kind, IndexKind::GinTrgm(_))
4571    }
4572
4573    /// v7.12.3 — true when this index is a GIN inverted index.
4574    /// Used by the planner to opt into posting-list acceleration on
4575    /// `WHERE col @@ tsquery` predicates.
4576    pub const fn is_gin(&self) -> bool {
4577        matches!(self.kind, IndexKind::Gin(_))
4578    }
4579
4580    /// v7.17.0 Phase 2.2 — true when this index is a fulltext
4581    /// GIN over a TEXT / VARCHAR column (MySQL `FULLTEXT KEY`
4582    /// surface). Used by the planner to opt the FULLTEXT-indexed
4583    /// column into MATCH AGAINST acceleration.
4584    pub const fn is_gin_fulltext(&self) -> bool {
4585        matches!(self.kind, IndexKind::GinFulltext(_))
4586    }
4587
4588    /// v7.37.8(sentori Epic 5 P2)— true when this index is a
4589    /// real JSONB-GIN(posting-list backed). Used by the planner
4590    /// to opt `<col> @> <jsonb_literal>` into posting-list seek.
4591    pub const fn is_gin_jsonb(&self) -> bool {
4592        matches!(self.kind, IndexKind::GinJsonb(_))
4593    }
4594}
4595
4596/// In-memory table: schema + a persistent row vector + secondary indices.
4597///
4598/// v4.39: `rows` is a [`PersistentVec`] (Bitmapped Vector Trie, 32-way) so
4599/// `Table::clone()` is `O(1)` — the whole reason for v4.39's existence is
4600/// to make `Catalog::clone()` cheap inside the v4.34 auto-commit wrap.
4601///
4602/// v5.2.1: `hot_bytes` tracks the encoded byte size of every row currently
4603/// in [`Self::rows`], summed over rows. Updated incrementally by `insert`
4604/// (+= encoded row size), `delete_rows` (-= removed rows' encoded sizes),
4605/// and `update_row` (-= old size, += new size). The value is what the
4606/// v5.2 freezer reads to decide when to demote cold rows — when the
4607/// catalog-wide sum crosses `SPG_HOT_TIER_BYTES` (default 4 GiB) the
4608/// freezer thread wakes. v5.2.1 ships measurement only; the freezer
4609/// itself lands in v5.2.2. Stored as `u64` so a single field clone in
4610/// `Catalog::clone` stays at the O(1) invariant v4.39 built.
4611/// v7.34 (crash-recovery P0 #2) — one row-level physical redo record.
4612/// Row-level redo replaces statement-based WAL replay (which re-executes
4613/// each SQL through the full engine — O(records × catalog_rows), the
4614/// superlinear recovery hang root-caused on the mailrs crash-recovery
4615/// P0). A `RowChange` is the exact storage mutation the engine applied
4616/// (`Table::insert` / `update_row` / `delete_rows`); replaying it on a
4617/// catalog restored from the matching checkpoint reproduces the state
4618/// WITHOUT re-validating uniqueness/FK/parse/plan — O(changed rows).
4619///
4620/// Positions are physical, not key-based: `serialize`/`deserialize`
4621/// preserve row order exactly (rows written + read back in `self.rows`
4622/// order) and the mutation ops are deterministic, so the same op sequence
4623/// replayed from the same checkpoint reproduces the same positions. This
4624/// matches PostgreSQL's physical redo and supports tables with no primary
4625/// key. (Caveat handled at replay integration: a post-checkpoint cold-tier
4626/// freeze shifts hot positions and must itself be logged or fenced by a
4627/// checkpoint — see `row-level-redo-design`.)
4628/// ## v7.37.15 (Epic W slice 1) — additive MVCC identity metadata
4629///
4630/// Each variant now also carries, additively, the stable
4631/// [`RowId`](row_header::RowId) of the affected row(s) and the
4632/// **writer version** (`xmin` for an insert, `xmax` for a
4633/// delete/update). This is the codec foundation for making
4634/// in-place MVCC tombstones durable across crash/upgrade recovery.
4635///
4636/// Two important properties for the durability path:
4637///
4638/// 1. **Replay resolution is UNCHANGED.** `apply_redo_run_on_table`
4639///    still resolves every change by physical `pos`/`positions`
4640///    exactly as before. The new metadata is *carried but unused*
4641///    by replay in this slice; resolving-by-`RowId` and
4642///    header-preserving replay are later slices.
4643/// 2. **Backward compatibility.** A redo payload written by
4644///    pre-Epic-W code carries no metadata; [`decode_redo_log`]
4645///    fills `rowid`/`rowids` with [`RowId::UNASSIGNED`](row_header::RowId::UNASSIGNED)
4646///    (empty for `Delete`) and `writer_version` with `0`. See the
4647///    codec version gate in [`encode_redo_log`]/[`decode_redo_log`].
4648///
4649/// The `writer_version` is captured as `0` at the storage layer
4650/// (`Table::insert`/`delete_rows`/`update_row` don't have the
4651/// committing `TxId`), then **stamped with the real committing
4652/// version by the engine** after it drains the statement's changes
4653/// (Epic W slice 2 — [`RowChange::set_writer_version`], driven from
4654/// `Engine::writer_version_for_current_stmt`). All changes from one
4655/// statement share the one version. Replay still resolves by
4656/// physical position and does not read `writer_version` — that is a
4657/// later slice (header-preserving replay).
4658#[derive(Debug, Clone, PartialEq)]
4659pub enum RowChange {
4660    /// Append `row` to `table`.
4661    Insert {
4662        table: String,
4663        row: Row<'static>,
4664        /// Epic W: stable id the appended row will receive.
4665        /// [`RowId::UNASSIGNED`](row_header::RowId::UNASSIGNED) when
4666        /// decoded from a pre-Epic-W redo payload.
4667        rowid: row_header::RowId,
4668        /// Epic W: writer version (`xmin`). `0` until the writing
4669        /// `TxId` is threaded to the storage layer (later slice).
4670        writer_version: u64,
4671    },
4672    /// Replace the row at physical `pos` in `table` with `new_row`.
4673    Update {
4674        table: String,
4675        pos: usize,
4676        new_row: Vec<Value<'static>>,
4677        /// Epic W: stable id of the row at `pos`.
4678        /// [`RowId::UNASSIGNED`](row_header::RowId::UNASSIGNED) when
4679        /// decoded from a pre-Epic-W redo payload.
4680        rowid: row_header::RowId,
4681        /// Epic W: writer version (`xmax` of the superseded tuple).
4682        /// `0` until the writing `TxId` is threaded (later slice).
4683        writer_version: u64,
4684    },
4685    /// Remove the rows at the given physical `positions` from `table`.
4686    Delete {
4687        table: String,
4688        positions: Vec<usize>,
4689        /// Epic W: stable ids parallel to `positions` (same length,
4690        /// [`RowId::UNASSIGNED`](row_header::RowId::UNASSIGNED) for an
4691        /// out-of-bounds input position). **Empty** when decoded from
4692        /// a pre-Epic-W redo payload (no metadata was recorded).
4693        rowids: Vec<row_header::RowId>,
4694        /// Epic W: writer version (`xmax`). `0` until the writing
4695        /// `TxId` is threaded to the storage layer (later slice).
4696        writer_version: u64,
4697    },
4698    /// v7.37.15 (Epic W durable-tombstone slice) — an **in-place MVCC
4699    /// delete**: the row(s) named by `rowids` are NOT physically
4700    /// removed; their header `xmax` is stamped so newer snapshots stop
4701    /// seeing them (vacuum reclaims later). This is the redo shape of
4702    /// the gate-on (`SPG_MVCC_INPLACE`) DELETE / UPDATE-old-version /
4703    /// ON-CONFLICT paths, which call [`Table::mark_row_deleted`]
4704    /// instead of `delete_rows`.
4705    ///
4706    /// Unlike `Delete`, the target is named by **stable `RowId`**, not
4707    /// physical position: a tombstone keeps the slot, so position would
4708    /// be ambiguous after later compaction, and the header-preserving
4709    /// replay must re-find the exact row the writer tombstoned. On
4710    /// replay the id is matched against the ids the same redo run
4711    /// produced (an `Insert`'s `rowid`, or the table's ids snapshotted
4712    /// at run start); an id that cannot be resolved is skipped and
4713    /// counted (see `apply_redo_run_on_table`) — this is the documented
4714    /// cross-checkpoint limitation until the V6 envelope persists ids.
4715    Tombstone {
4716        table: String,
4717        /// Stable ids of the tombstoned rows (from `self.rowids()[pos]`
4718        /// at capture). Never empty for a recorded tombstone.
4719        rowids: Vec<row_header::RowId>,
4720        /// The version stamped into each target row's header `xmax`
4721        /// (the deleting statement's writer version).
4722        xmax: u64,
4723    },
4724}
4725
4726impl RowChange {
4727    /// v7.39 (round 736) — which table this change applies to.
4728    #[must_use]
4729    pub fn table_name(&self) -> &str {
4730        match self {
4731            Self::Insert { table, .. }
4732            | Self::Update { table, .. }
4733            | Self::Delete { table, .. }
4734            | Self::Tombstone { table, .. } => table,
4735        }
4736    }
4737
4738    /// v7.37.15 (Epic W slice 2) — stamp the committing writer
4739    /// version onto this change. Every change drained from a single
4740    /// statement shares one version (the statement's `xmin`/`xmax`),
4741    /// so the engine calls this on each drained change with the value
4742    /// from [`Engine::writer_version_for_current_stmt`]. Additive
4743    /// metadata only: replay still resolves by physical position and
4744    /// does not read `writer_version` (that is a later slice).
4745    pub fn set_writer_version(&mut self, v: u64) {
4746        match self {
4747            RowChange::Insert { writer_version, .. }
4748            | RowChange::Update { writer_version, .. }
4749            | RowChange::Delete { writer_version, .. } => *writer_version = v,
4750            // A tombstone captures `xmax` directly from the deleting
4751            // statement's version at record time (via
4752            // `mark_row_deleted`), so it already equals `v`. Keep the
4753            // "one statement, one version" invariant mechanical by
4754            // asserting agreement in debug builds rather than silently
4755            // overwriting a possibly-different value.
4756            RowChange::Tombstone { xmax, .. } => {
4757                debug_assert_eq!(
4758                    *xmax, v,
4759                    "tombstone xmax must match the statement writer version"
4760                );
4761                *xmax = v;
4762            }
4763        }
4764    }
4765}
4766
4767/// v7.37.15 (Epic W slice 1) — leading marker byte of the
4768/// metadata-carrying redo layout. A **pre-Epic-W** redo payload leads
4769/// with `FILE_VERSION` (8..=52 today, rising ~1 per release); this
4770/// marker is `0xFF` and can therefore never collide with a real
4771/// `FILE_VERSION`, so [`decode_redo_log`] tells the two layouts apart
4772/// by inspecting the first byte alone. The compile-time assertion
4773/// below makes the "never collide" invariant a hard build gate: if
4774/// `FILE_VERSION` ever climbs toward `0xFF` the build breaks and forces
4775/// a redesign long before an ambiguity could ship.
4776const REDO_META_MARKER: u8 = 0xFF;
4777/// v7.37.15 (Epic W slice 1) — version of the metadata-carrying redo
4778/// layout that follows [`REDO_META_MARKER`]. Bumped when the per-change
4779/// metadata shape changes; an unknown value is a hard decode error.
4780const REDO_META_VERSION: u8 = 1;
4781
4782/// v7.37.15 (Epic W durable-tombstone slice) — process-wide count of
4783/// [`RowChange::Tombstone`] targets that `apply_redo` could NOT resolve
4784/// to a row by `RowId`. A non-zero value is expected only across a
4785/// checkpoint boundary (the table's ids are reassigned on deserialize
4786/// and the V6 envelope does not yet persist them), where a tombstone
4787/// naming a pre-checkpoint row is left visible rather than mis-applied.
4788/// Surfaced for observability; never affects correctness of the resolved
4789/// tombstones. Read via [`unresolved_tombstone_count`].
4790static UNRESOLVED_TOMBSTONES: core::sync::atomic::AtomicU64 = core::sync::atomic::AtomicU64::new(0);
4791
4792/// v7.39 (flip crash-replay P0) — observability read for the replay
4793/// tombstones that could not be resolved to a row (each one is a
4794/// resurrected delete).
4795#[must_use]
4796pub fn unresolved_tombstones() -> u64 {
4797    UNRESOLVED_TOMBSTONES.load(core::sync::atomic::Ordering::Relaxed)
4798}
4799
4800/// v7.37.15 (Epic W durable-tombstone slice) — read the process-wide
4801/// count of redo tombstones that could not be resolved to a row by
4802/// `RowId` during `apply_redo`. See [`UNRESOLVED_TOMBSTONES`].
4803#[must_use]
4804pub fn unresolved_tombstone_count() -> u64 {
4805    UNRESOLVED_TOMBSTONES.load(core::sync::atomic::Ordering::Relaxed)
4806}
4807// Provably-unambiguous old/new distinction: the pre-Epic-W layout's
4808// first byte is `FILE_VERSION`, which must stay strictly below the
4809// marker forever.
4810const _: () = assert!(FILE_VERSION < REDO_META_MARKER);
4811
4812/// v7.34 (crash-recovery P0 #2), extended v7.37.15 (Epic W slice 1) —
4813/// encode a row-level redo log to bytes for a WAL record.
4814///
4815/// ## Layout (Epic W metadata-carrying form, always emitted now)
4816///
4817/// `[u8 REDO_META_MARKER=0xFF][u8 REDO_META_VERSION][u8 FILE_VERSION]
4818/// [u32 count]` then per change `[u8 op][str table]` and, per op:
4819/// - `Insert [u32 n][value×n][u64 rowid][u64 writer_version]`
4820/// - `Update [u32 pos][u32 n][value×n][u64 rowid][u64 writer_version]`
4821/// - `Delete [u32 n][u32 pos×n][u64 rowid×n][u64 writer_version]`
4822/// - `Tombstone [u32 n][u64 rowid×n][u64 xmax]` (op byte 3; only ever
4823///   emitted under the metadata-carrying layout — the pre-Epic-W layout
4824///   had no in-place tombstone, so a legacy stream can never carry it)
4825///
4826/// Positions are physical (u32 ≤ 4 G rows). The `FILE_VERSION` byte
4827/// still rides along (now the 3rd byte) so the value codec decodes
4828/// string / BYTEA escapes exactly as before.
4829///
4830/// ## Backward compatibility
4831///
4832/// The **pre-Epic-W** layout was `[u8 FILE_VERSION][u32 count]…` with
4833/// no per-change metadata. [`decode_redo_log`] still decodes that form
4834/// (first byte < `0xFF`) byte-for-byte identically — every WAL file
4835/// written by released code replays unchanged.
4836#[must_use]
4837pub fn encode_redo_log(changes: &[RowChange]) -> Vec<u8> {
4838    let mut out = Vec::new();
4839    out.push(REDO_META_MARKER);
4840    out.push(REDO_META_VERSION);
4841    out.push(FILE_VERSION);
4842    codec::write_u32(&mut out, changes.len() as u32);
4843    let write_values = |out: &mut Vec<u8>, vals: &[Value<'static>]| {
4844        codec::write_u32(out, vals.len() as u32);
4845        for v in vals {
4846            codec::write_value(out, v);
4847        }
4848    };
4849    for change in changes {
4850        match change {
4851            RowChange::Insert {
4852                table,
4853                row,
4854                rowid,
4855                writer_version,
4856            } => {
4857                out.push(0);
4858                codec::write_str(&mut out, table);
4859                write_values(&mut out, &row.values);
4860                codec::write_u64(&mut out, rowid.0);
4861                codec::write_u64(&mut out, *writer_version);
4862            }
4863            RowChange::Update {
4864                table,
4865                pos,
4866                new_row,
4867                rowid,
4868                writer_version,
4869            } => {
4870                out.push(1);
4871                codec::write_str(&mut out, table);
4872                codec::write_u32(&mut out, *pos as u32);
4873                write_values(&mut out, new_row);
4874                codec::write_u64(&mut out, rowid.0);
4875                codec::write_u64(&mut out, *writer_version);
4876            }
4877            RowChange::Delete {
4878                table,
4879                positions,
4880                rowids,
4881                writer_version,
4882            } => {
4883                out.push(2);
4884                codec::write_str(&mut out, table);
4885                codec::write_u32(&mut out, positions.len() as u32);
4886                for p in positions {
4887                    codec::write_u32(&mut out, *p as u32);
4888                }
4889                // Epic W: one RowId per position (parallel). Capture
4890                // sites always produce `rowids.len() == positions.len()`;
4891                // this assertion pins that invariant at encode time so a
4892                // mismatch is a loud bug, not a silently short payload.
4893                debug_assert_eq!(
4894                    rowids.len(),
4895                    positions.len(),
4896                    "redo Delete: rowids must be parallel to positions"
4897                );
4898                for rid in rowids {
4899                    codec::write_u64(&mut out, rid.0);
4900                }
4901                codec::write_u64(&mut out, *writer_version);
4902            }
4903            RowChange::Tombstone {
4904                table,
4905                rowids,
4906                xmax,
4907            } => {
4908                out.push(3);
4909                codec::write_str(&mut out, table);
4910                codec::write_u32(&mut out, rowids.len() as u32);
4911                for rid in rowids {
4912                    codec::write_u64(&mut out, rid.0);
4913                }
4914                codec::write_u64(&mut out, *xmax);
4915            }
4916        }
4917    }
4918    out
4919}
4920
4921/// v7.34, extended v7.37.15 (Epic W slice 1) — decode a row-level redo
4922/// log written by [`encode_redo_log`].
4923///
4924/// Decodes **both** the Epic W metadata-carrying layout (first byte
4925/// `REDO_META_MARKER = 0xFF`) and the pre-Epic-W layout (first byte is
4926/// `FILE_VERSION`, always `< 0xFF`). For the old layout the per-change
4927/// metadata is absent, so `rowid`/`rowids` come back
4928/// [`RowId::UNASSIGNED`](row_header::RowId::UNASSIGNED) (empty for
4929/// `Delete`) and `writer_version` comes back `0`.
4930///
4931/// A truncated / corrupt buffer is a hard error — never a panic — the
4932/// embedding layer frames each record with its own length + CRC, so a
4933/// frame that decodes short is corruption, not a torn tail.
4934pub fn decode_redo_log(bytes: &[u8]) -> Result<Vec<RowChange>, StorageError> {
4935    let first = *bytes
4936        .first()
4937        .ok_or_else(|| StorageError::Corrupt("redo log: empty".into()))?;
4938    // Epic W: `0xFF` marker ⇒ metadata-carrying layout; anything else
4939    // is a pre-Epic-W `FILE_VERSION` byte (old layout, no metadata).
4940    let has_meta = first == REDO_META_MARKER;
4941    let (codec_version, header_len) = if has_meta {
4942        let meta_version = *bytes
4943            .get(1)
4944            .ok_or_else(|| StorageError::Corrupt("redo log: short header".into()))?;
4945        if meta_version != REDO_META_VERSION {
4946            return Err(StorageError::Corrupt(alloc::format!(
4947                "redo log: unknown metadata version {meta_version}"
4948            )));
4949        }
4950        let file_version = *bytes
4951            .get(2)
4952            .ok_or_else(|| StorageError::Corrupt("redo log: short header".into()))?;
4953        // header = [marker][meta_version][file_version]
4954        (file_version, 3usize)
4955    } else {
4956        // Old layout: the first byte IS the FILE_VERSION.
4957        (first, 1usize)
4958    };
4959    let mut cur = codec::Cursor::new(bytes).with_codec_version(codec_version);
4960    for _ in 0..header_len {
4961        cur.read_u8()?;
4962    }
4963    let count = cur.read_u32()? as usize;
4964    let mut read_values =
4965        |cur: &mut codec::Cursor<'_>| -> Result<Vec<Value<'static>>, StorageError> {
4966            let n = cur.read_u32()? as usize;
4967            let mut vals = Vec::with_capacity(n);
4968            for _ in 0..n {
4969                vals.push(cur.read_value()?);
4970            }
4971            Ok(vals)
4972        };
4973    let mut changes = Vec::with_capacity(count);
4974    for _ in 0..count {
4975        let op = cur.read_u8()?;
4976        let table = cur.read_str()?;
4977        let change = match op {
4978            0 => {
4979                let row = Row::new(read_values(&mut cur)?);
4980                let (rowid, writer_version) = if has_meta {
4981                    (row_header::RowId(cur.read_u64()?), cur.read_u64()?)
4982                } else {
4983                    (row_header::RowId::UNASSIGNED, 0)
4984                };
4985                RowChange::Insert {
4986                    table,
4987                    row,
4988                    rowid,
4989                    writer_version,
4990                }
4991            }
4992            1 => {
4993                let pos = cur.read_u32()? as usize;
4994                let new_row = read_values(&mut cur)?;
4995                let (rowid, writer_version) = if has_meta {
4996                    (row_header::RowId(cur.read_u64()?), cur.read_u64()?)
4997                } else {
4998                    (row_header::RowId::UNASSIGNED, 0)
4999                };
5000                RowChange::Update {
5001                    table,
5002                    pos,
5003                    new_row,
5004                    rowid,
5005                    writer_version,
5006                }
5007            }
5008            2 => {
5009                let n = cur.read_u32()? as usize;
5010                let mut positions = Vec::with_capacity(n);
5011                for _ in 0..n {
5012                    positions.push(cur.read_u32()? as usize);
5013                }
5014                let (rowids, writer_version) = if has_meta {
5015                    let mut rowids = Vec::with_capacity(n);
5016                    for _ in 0..n {
5017                        rowids.push(row_header::RowId(cur.read_u64()?));
5018                    }
5019                    (rowids, cur.read_u64()?)
5020                } else {
5021                    // Old layout carried no RowId metadata.
5022                    (Vec::new(), 0)
5023                };
5024                RowChange::Delete {
5025                    table,
5026                    positions,
5027                    rowids,
5028                    writer_version,
5029                }
5030            }
5031            // Op 3 is the Epic W in-place tombstone — it only exists in
5032            // the metadata-carrying layout. Guarding on `has_meta` means
5033            // a legacy stream that happens to contain a `3` byte here is
5034            // reported as an unknown op (corruption), never mis-decoded.
5035            3 if has_meta => {
5036                let n = cur.read_u32()? as usize;
5037                let mut rowids = Vec::with_capacity(n);
5038                for _ in 0..n {
5039                    rowids.push(row_header::RowId(cur.read_u64()?));
5040                }
5041                let xmax = cur.read_u64()?;
5042                RowChange::Tombstone {
5043                    table,
5044                    rowids,
5045                    xmax,
5046                }
5047            }
5048            other => {
5049                return Err(StorageError::Corrupt(alloc::format!(
5050                    "redo log: unknown op {other}"
5051                )));
5052            }
5053        };
5054        changes.push(change);
5055    }
5056    Ok(changes)
5057}
5058
5059/// v7.39 (pg_stat knife B) — per-table scan counters, bumped from
5060/// `&self` read paths. Clone (tx shadow catalogs clone tables) copies
5061/// the current values; the counters are volatile like PG's cumulative
5062/// stats.
5063#[derive(Debug, Default)]
5064pub struct ScanStats {
5065    pub seq_scan: core::sync::atomic::AtomicU64,
5066    pub seq_tup_read: core::sync::atomic::AtomicU64,
5067    pub idx_scan: core::sync::atomic::AtomicU64,
5068    pub idx_tup_fetch: core::sync::atomic::AtomicU64,
5069}
5070
5071impl Clone for ScanStats {
5072    fn clone(&self) -> Self {
5073        use core::sync::atomic::{AtomicU64, Ordering};
5074        Self {
5075            seq_scan: AtomicU64::new(self.seq_scan.load(Ordering::Relaxed)),
5076            seq_tup_read: AtomicU64::new(self.seq_tup_read.load(Ordering::Relaxed)),
5077            idx_scan: AtomicU64::new(self.idx_scan.load(Ordering::Relaxed)),
5078            idx_tup_fetch: AtomicU64::new(self.idx_tup_fetch.load(Ordering::Relaxed)),
5079        }
5080    }
5081}
5082
5083/// v7.39 (round 215) — the lower-bound sort key for a range value, used by
5084/// the range-exclusion index. The bound as an `i128` (unbounded lower =
5085/// `i128::MIN`, sorting first) plus an inclusivity rank (inclusive lower
5086/// sorts before exclusive at the same value, `[3` before `(3`). Returns
5087/// `None` for range kinds whose bound isn't an integer scalar (numrange's
5088/// numeric/bignum), for empty ranges, and for non-range values — the caller
5089/// then keeps the O(n) scan rather than risk an unsound order. Int4/Int8/
5090/// Date/Ts/TsTz all reduce here (tstzrange bounds are `Value::Timestamp`).
5091/// Maintenance (index build) and query (overlap probe) MUST agree on this
5092/// key, so both sides call exactly this function.
5093#[must_use]
5094pub fn range_excl_index_key(v: &Value<'_>) -> Option<(i128, u8)> {
5095    let Value::Range {
5096        lower,
5097        lower_inc,
5098        empty,
5099        ..
5100    } = v
5101    else {
5102        return None;
5103    };
5104    if *empty {
5105        return None;
5106    }
5107    let key = match lower {
5108        None => i128::MIN,
5109        Some(b) => match b.as_ref() {
5110            Value::SmallInt(n) => i128::from(*n),
5111            Value::Int(n) => i128::from(*n),
5112            Value::BigInt(n) => i128::from(*n),
5113            Value::Date(n) => i128::from(*n),
5114            Value::Timestamp(n) => i128::from(*n),
5115            _ => return None,
5116        },
5117    };
5118    Some((key, u8::from(!*lower_inc)))
5119}
5120
5121/// v7.39 (round 215) — a per-table range-exclusion index: an incrementally
5122/// maintained map from a range column's lower-bound key
5123/// ([`range_excl_index_key`]) to the physical row locators carrying that
5124/// bound. Lets EXCLUDE enforcement find the few candidate rows a new range
5125/// might overlap in O(log n) instead of scanning every row (measured O(N²),
5126/// r213). Because the stored ranges under a valid `EXCLUDE (col WITH &&)`
5127/// are pairwise disjoint, a candidate overlaps only its predecessor or the
5128/// successors whose lower bound precedes its upper — a handful of probes.
5129///
5130/// NOT persisted: rebuilt from the (persisted) exclusion constraints + rows
5131/// on catalog load, exactly like BRIN re-derives. Backed by a
5132/// `PersistentBTreeMap` so `Table::clone` (the per-write snapshot) stays
5133/// O(1). Locators to tombstoned rows are left in place and filtered by the
5134/// consumer via `is_deleted()` at query time — the established index pattern.
5135#[derive(Debug, Clone)]
5136pub struct ExclRangeIndex {
5137    /// The constrained range column's position in the table.
5138    pub column_position: usize,
5139    /// Lower-bound key → row locators. A key maps to a `Vec` because a
5140    /// tombstoned-then-reinserted bound can transiently collide; live rows
5141    /// under the constraint are disjoint so each key has one live locator.
5142    pub map: PersistentBTreeMap<(i128, u8), crate::posting::PostingList>,
5143}
5144
5145/// v7.38.2 (R2) — see [`Table::tx_write_track`]. Positions are the
5146/// insert-time slots (verified against the header's version at
5147/// extraction, so a shifted slot falls back to the scan); tombstones
5148/// carry the stable RowId, which is what the write-set wants anyway.
5149#[derive(Debug, Clone, Default)]
5150struct TxWriteTrack {
5151    version: u64,
5152    inserted: Vec<(usize, row_header::RowId)>,
5153    tombstoned: Vec<row_header::RowId>,
5154}
5155
5156/// v7.38.11 — hot-tier BRIN granularity: slots per summarised range.
5157///
5158/// 1024 keeps the summary vector three orders of magnitude smaller
5159/// than the table while staying fine enough that a one-day window over
5160/// a 90-day table skips ~99 % of it. A tuning constant, not a format:
5161/// summaries are rebuilt from the rows on load, so changing it costs
5162/// nothing on disk.
5163pub const BRIN_RANGE_ROWS: usize = 1024;
5164
5165/// The comparable scalar a BRIN summary tracks, or `None` for a value
5166/// with no ordering this index can use.
5167///
5168/// Deliberately narrow: only types whose ordering IS the i64 ordering
5169/// of this number. A type added here whose comparison is not that —
5170/// text under a collation, say — would make the summary under-report
5171/// and skip matching rows, which is the one failure this design must
5172/// not have.
5173#[must_use]
5174pub fn brin_scalar(v: &Value<'_>) -> Option<i64> {
5175    match v {
5176        Value::SmallInt(n) => Some(i64::from(*n)),
5177        Value::Int(n) => Some(i64::from(*n)),
5178        Value::BigInt(n) | Value::Timestamp(n) => Some(*n),
5179        Value::Date(d) => Some(i64::from(*d)),
5180        Value::Bool(b) => Some(i64::from(*b)),
5181        _ => None,
5182    }
5183}
5184
5185#[derive(Debug, Clone)]
5186pub struct Table {
5187    schema: TableSchema,
5188    /// v7.38.18 (S2) — the DATABASE's collation, copied in by the
5189    /// catalog that owns this table.
5190    ///
5191    /// A text column that declares no collation inherits it, which is
5192    /// what PostgreSQL does and what `information_schema.columns`
5193    /// reports as NULL. Runtime only, never serialised: it belongs to
5194    /// the catalog, and a table that has been handed around outside one
5195    /// falls back to `C`, which is the answer for every database written
5196    /// before this existed.
5197    db_collation: Option<String>,
5198    /// v7.38.16 — names of the expression indexes whose B-tree currently
5199    /// holds keys derived from the EXPRESSION.
5200    ///
5201    /// Every catalog written before this version stored, under an
5202    /// expression index, the values of its leading column — keys no
5203    /// lookup could ever match, which is why every read path guarded
5204    /// itself with `expression.is_none()` and the index bought nothing
5205    /// while costing 1.9x a plain insert to maintain.
5206    ///
5207    /// Deliberately NOT persisted: a table read off disk starts with the
5208    /// set empty, so those old wrong keys can never answer a query. The
5209    /// engine, which owns the expression evaluator, refills it.
5210    expr_index_complete: alloc::collections::BTreeSet<String>,
5211    /// v7.37.15 (Phase C.1) — stable per-catalog relation identity.
5212    /// [`RelId::UNASSIGNED`](row_header::RelId::UNASSIGNED) until
5213    /// `Catalog::create_table` (or the deserialize dense-assign pass)
5214    /// stamps a real id. Keys the Phase C.4 row-lock table and the
5215    /// Phase C.5 `RelationStore`; survives `DROP TABLE` slot shifts.
5216    rel_id: row_header::RelId,
5217    rows: PersistentVec<Row<'static>>,
5218    /// v7.37.15 (Phase A.2) — per-row MVCC visibility headers
5219    /// parallel to `rows`. `headers.len() == rows.len()` is the
5220    /// load-bearing invariant; debug builds assert it on every
5221    /// scan boundary, release builds rely on it from
5222    /// disciplined insert / delete / update paths.
5223    ///
5224    /// Pre-v7.37.15-loaded tables (every row currently in the
5225    /// fleet) start as `RowHeader::frozen()` — `is_all_visible_fast()`
5226    /// returns `true`, so the per-row visibility gate Phase B
5227    /// adds is a no-op against any snapshot.
5228    ///
5229    /// Headers are NOT yet serialised into the envelope at this
5230    /// commit — on snapshot deserialize every row gets a fresh
5231    /// `RowHeader::frozen()`. Phase D adds the visibility-map
5232    /// + segment-freeze story which makes serialisation
5233    /// meaningful; until then the on-disk story is "the catalog
5234    /// is the set of visible rows."
5235    headers: PersistentVec<row_header::RowHeader>,
5236    /// v7.37.15 (Phase C.1) — stable per-relation row identity
5237    /// parallel to `rows` / `headers`. `rowids[i]` is the never-
5238    /// reused [`RowId`](row_header::RowId) of the row physically at
5239    /// slot `i`; `rowids.len() == rows.len()` joins the same load-
5240    /// bearing lock-step invariant as `headers`. Compaction (delete
5241    /// / vacuum) rebuilds all three vecs together so the id travels
5242    /// with the row while the slot shifts.
5243    ///
5244    /// Introduced additively: allocated + kept lock-step, but index
5245    /// locators still address rows by physical slot at this commit.
5246    /// Later phases migrate the lock table (C.4), HOT chains (D),
5247    /// and the WAL (Epic W) to address by `RowId`.
5248    ///
5249    /// Not yet serialised into the envelope — on load every row is
5250    /// assigned a fresh dense id `1..=len` (see `next_rowid`), which
5251    /// is sufficient while the id is process-local bookkeeping. The
5252    /// V6 envelope (Phase C.6) will persist ids so a WAL redo can
5253    /// name a row across restart.
5254    rowids: PersistentVec<row_header::RowId>,
5255    /// v7.37.15 (Phase C.1) — per-relation monotonic allocator for
5256    /// `rowids`. Starts at 1 (0 is the `RowId::UNASSIGNED` sentinel);
5257    /// every append takes `next_rowid` then increments. Never reused
5258    /// even after the row is deleted / vacuumed, so a stale lock /
5259    /// redo reference can be detected rather than silently aliasing a
5260    /// later row that reused the slot.
5261    ///
5262    /// 7.38.1 (S2.4, MATRIX #20 root cause) — the allocator is SHARED
5263    /// across every `clone()` of the relation (`Arc`), because the
5264    /// monotonic-never-reused promise is a LINEAGE invariant: each
5265    /// open transaction's shadow catalog is a clone, and when clones
5266    /// carried private counters two concurrent shadows minted the
5267    /// same id — duplicate rids in the base after both committed,
5268    /// aliasing every rid-addressed mechanism (locks, tombstones,
5269    /// redo, the rebase unique pre-check).
5270    next_rowid: alloc::sync::Arc<core::sync::atomic::AtomicU64>,
5271    /// v7.37.16 (autovacuum) — live count of tombstoned-but-present hot
5272    /// rows (`headers[i].xmax != XMAX_ALIVE`). Maintained incrementally:
5273    /// `mark_row_deleted` / `mark_rows_deleted` increment (the only
5274    /// tombstone producers), `delete_rows_no_index` recomputes over the
5275    /// survivors (it is the compaction hub every physical removal —
5276    /// including vacuum — flows through), and the v53 snapshot loader
5277    /// recounts verbatim-restored headers. Drives the engine's
5278    /// autovacuum threshold; not persisted (recomputed on load).
5279    dead_rows: u64,
5280    /// v7.39 (pg_stat knife A) — volatile per-table write counters
5281    /// backing `pg_stat_user_tables.n_tup_ins/upd/del`. Not persisted
5282    /// (PG's cumulative stats are shared-memory-volatile too — a
5283    /// restart zeroes them).
5284    stat_tup_ins: u64,
5285    stat_tup_upd: u64,
5286    stat_tup_del: u64,
5287    /// v7.39 (pg_stat knife B) — volatile scan counters
5288    /// (`seq_scan/seq_tup_read/idx_scan/idx_tup_fetch`). Atomics: the
5289    /// read paths that bump them hold only `&Table`.
5290    scan_stats: ScanStats,
5291    /// v7.39 (pg_stat knife C) — wall-clock stamps (unix µs, from the
5292    /// host ClockFn) for pg_stat_user_tables' last_autovacuum /
5293    /// last_analyze. Volatile, like PG's cumulative stats. SPG has no
5294    /// manual-VACUUM statement semantics, so last_vacuum stays NULL.
5295    last_autovacuum_us: Option<i64>,
5296    last_analyze_us: Option<i64>,
5297    indices: Vec<Index>,
5298    hot_bytes: u64,
5299    /// v6.7.0 — cached count of rows currently materialised in the
5300    /// cold tier via `RowLocator::Cold` entries across THIS table's
5301    /// indices. Populated by `ANALYZE` (walks every BTree index and
5302    /// counts Cold locators); the count survives until the next
5303    /// ANALYZE recomputes it. Surfaced via `spg_statistic.cold_row_count`
5304    /// and `spg_stat_segment.table_name`.
5305    ///
5306    /// Honest scope: this is a CACHED count, not a live one.
5307    /// Freezer / promote / DELETE don't currently update the cache
5308    /// incrementally — they invalidate it by setting the
5309    /// `cold_row_count_stale` flag, and the next ANALYZE re-walks.
5310    /// Incremental maintenance is a v6.7.x candidate if observation
5311    /// shows the ANALYZE walk cost dominates.
5312    cold_row_count: u64,
5313    /// v6.7.0 — set when the cached `cold_row_count` may be wrong
5314    /// because rows moved into / out of the cold tier since the last
5315    /// ANALYZE. The virtual-table surface reports the cached value
5316    /// regardless (operators run ANALYZE to refresh).
5317    cold_row_count_stale: bool,
5318    /// v7.34 (crash-recovery P0 #2) — row-level redo capture buffer.
5319    /// `None` (default, in-memory mode) captures nothing — zero overhead.
5320    /// `Some` (set by the engine when persistence is on, before a
5321    /// mutating call) makes `insert` / `update_row` / `delete_rows`
5322    /// record the physical [`RowChange`] they applied, which the engine
5323    /// drains after the statement and writes to the WAL in place of the
5324    /// SQL text. Transient: never serialized; a `Catalog::clone` between
5325    /// enable and drain copies it (cheap — empty in the steady state).
5326    redo_log: Option<Vec<RowChange>>,
5327    /// v7.39 (round 215) — per-`EXCLUDE`-constraint range-overlap indexes,
5328    /// one per single-`&&` constraint on an integer-keyable range column.
5329    /// Maintained incrementally on insert / update / rebuild (mirroring the
5330    /// BTree secondary indexes); NOT serialized — rebuilt from the schema's
5331    /// exclusion constraints on load. Empty for tables with no EXCLUDE
5332    /// constraint (the common case), so `Table::clone` pays nothing.
5333    excl_indexes: Vec<ExclRangeIndex>,
5334    /// v7.38.2 (R2) — incremental write-set track for the RC rebase.
5335    /// `extract_tx_writeset` used to full-scan every header per call —
5336    /// ~200 µs on a 20k-row table, per in-transaction statement, every
5337    /// time a concurrent COMMIT moved the epoch; on tpcb's 100k-row
5338    /// accounts that scan was the c2 concurrency cliff itself. The
5339    /// three version-marking funnels (`insert_with_xmin`,
5340    /// `mark_row_deleted`, `mark_rows_deleted`) record here instead.
5341    ///
5342    /// One track per table, keyed by the LAST writer version: a shadow
5343    /// belongs to one transaction, so a different version claiming the
5344    /// table simply replaces the track (on the committed base that
5345    /// makes memory bounded by the last writer's footprint). Extraction
5346    /// verifies every recorded position still carries the version —
5347    /// any mismatch (compaction, inherited track, pre-track rows)
5348    /// falls back to the full scan, so the fast path can be wrong
5349    /// about NOTHING, only slow.
5350    tx_write_track: Option<TxWriteTrack>,
5351    /// v7.39 (round 493) — the snapshot floor below which a deleted row
5352    /// version is invisible to everyone, as of the statement now running.
5353    ///
5354    /// Runtime only: never serialised, and `0` (the default) prunes
5355    /// nothing, so any path that forgets to set it is merely slower, not
5356    /// wrong. The engine sets it from `vacuum_oldest_active()` — the same
5357    /// floor `vacuum` itself takes — before the statement's inserts.
5358    prune_horizon: u64,
5359}
5360
5361/// Catalog: insertion-ordered `Vec<Table>` for stable iter / serialize,
5362/// plus a `BTreeMap<String, usize>` sidecar index so `get` / `get_mut`
5363/// run in O(log n) instead of the old linear scan with per-element
5364/// string compares.
5365///
5366/// A pure `BTreeMap<String, Table>` was tried in an interim version
5367/// of v3.1.2 and regressed the single-table catalog benches by ~10%
5368/// (the per-element `BTreeMap` overhead outweighs the lookup win
5369/// when n is small). The sidecar shape preserves the insertion-order
5370/// iteration the on-disk encoding relies on and keeps `last_mut`
5371/// (used by the deserialize hot path) cheap.
5372/// v7.39 (pg_stat blks knife) — catalog-wide cold-tier read counter
5373/// backing pg_stat_database.blks_read. Row-granular (SPG has no 8 KB
5374/// page notion): one cold-segment row resolution = one "block read",
5375/// one hot row access = one "block hit" — the hit RATIO monitoring
5376/// dashboards compute keeps its meaning. Volatile like PG's stats.
5377#[derive(Debug, Default)]
5378pub struct ColdReadStats {
5379    pub cold_reads: core::sync::atomic::AtomicU64,
5380}
5381
5382impl Clone for ColdReadStats {
5383    fn clone(&self) -> Self {
5384        Self {
5385            cold_reads: core::sync::atomic::AtomicU64::new(
5386                self.cold_reads.load(core::sync::atomic::Ordering::Relaxed),
5387            ),
5388        }
5389    }
5390}
5391
5392/// 7.38.1 S3.1 (D4) — the non-table catalog families that carry a
5393/// per-transaction dirty window (see `Catalog::dirty_nontable`). One
5394/// entry class per side-map the poisoned-commit merge reconciles.
5395#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
5396pub enum NonTableKind {
5397    Sequence,
5398    View,
5399    MaterializedView,
5400    EnumType,
5401    DomainType,
5402    CompositeType,
5403}
5404
5405#[derive(Debug, Clone, Default)]
5406pub struct Catalog {
5407    /// v7.39 (pg_stat blks knife) — see [`ColdReadStats`].
5408    pub cold_read_stats: ColdReadStats,
5409    tables: Vec<Table>,
5410    /// `name → tables[index]`. Kept in lock-step with `tables`.
5411    /// `create_table` is the only write path.
5412    by_name: BTreeMap<String, usize>,
5413    /// v7.39 (round 436) — the current session's temporary-table namespace.
5414    /// A temp table is stored under `<prefix><name>`, and every lookup tries
5415    /// that first: exactly PG's `pg_temp` search-path rule, and MySQL's
5416    /// "a TEMPORARY table shadows a permanent one of the same name".
5417    ///
5418    /// Process-local, never serialised: the engine sets it per session, and
5419    /// a catalog read back from disk starts with none. Kept here rather than
5420    /// at each of the ~170 engine call sites because `by_name` is private —
5421    /// this is the ONE place a table name becomes an index.
5422    temp_prefix: Option<String>,
5423    /// v7.39.2 — see [`Catalog::set_case_insensitive_names`].
5424    case_insensitive_names: bool,
5425    /// v7.39 (round 496) — the names of tables this catalog handle has had
5426    /// changed since the set was last cleared.
5427    ///
5428    /// Runtime only, never serialised. A transaction's shadow catalog
5429    /// clears it at BEGIN, so at COMMIT the set is exactly the tables the
5430    /// transaction changed — which is what lets a commit that cannot use
5431    /// the row-level merge install only those tables instead of the whole
5432    /// catalog, leaving another session's concurrent work in place.
5433    ///
5434    /// Recorded where the change actually happens (`get_mut`,
5435    /// `create_table`, `drop_table`) rather than from the statement
5436    /// classifier: round 494 tried classification for a correctness gate
5437    /// and it was wrong, because `SELECT lo_write(…)` reads as read-only.
5438    dirty_tables: alloc::collections::BTreeSet<String>,
5439    /// 7.38.1 S3.1 (D4) — the non-table twin of `dirty_tables`: which
5440    /// sequences / views / matviews / enum / domain / composite types
5441    /// THIS window created, altered, renamed or dropped. Counter
5442    /// advances (`nextval`) deliberately do NOT record — counter
5443    /// values merge via `sequence_counters` / `restore_sequence_
5444    /// counters`, and a tx that only consumed ids must not shadow a
5445    /// neighbour's ALTER SEQUENCE. Cleared by `clear_dirty_tables`
5446    /// (one window, both records).
5447    dirty_nontable: alloc::collections::BTreeSet<(NonTableKind, String)>,
5448    /// v7.37.15 (Phase C.1) — monotonic allocator for stable
5449    /// [`RelId`](row_header::RelId)s. Pre-incremented on each
5450    /// `create_table` so real ids start at 1 (0 is `UNASSIGNED`);
5451    /// never reused even after `DROP TABLE`, so a stale lock / redo
5452    /// reference is detectable. Process-local bookkeeping — not yet
5453    /// serialised; `deserialize` re-assigns dense ids on load (the
5454    /// V6 envelope, Phase C.6, will round-trip real ids).
5455    next_rel_id: u64,
5456    /// v5.1: in-memory cold-tier segments. Side-loaded via
5457    /// [`Catalog::load_segment_bytes`] — they live outside the
5458    /// catalog snapshot (caller persists them as separate files
5459    /// and re-loads on boot, until v5.3's `CatalogManifest` makes
5460    /// that wiring automatic). `RowLocator::Cold { segment_id, .. }`
5461    /// indexes this `Vec`. Cleared on `Catalog::new` / fresh
5462    /// `deserialize`.
5463    ///
5464    /// `Arc` wrap keeps `Catalog::clone` at O(N segments) bumps
5465    /// (rather than O(total segment bytes) memcpy) so the v4.42
5466    /// group-commit pre-image rollback invariant — clone is
5467    /// effectively free — survives the cold-tier addition.
5468    ///
5469    /// v6.7.3 — slots became `Option<…>` so cold-segment compaction
5470    /// can tombstone merged sources without breaking the
5471    /// `segment_id = index_into_vec` contract that on-disk
5472    /// `RowLocator::Cold { segment_id }` already serialized.
5473    /// `None` slot = the segment was retired by compaction; the
5474    /// physical file may still be on disk (next CHECKPOINT writes
5475    /// a manifest that no longer lists it, and the file becomes
5476    /// an orphan eligible for offline cleanup).
5477    cold_segments: Vec<Option<Arc<OwnedSegment>>>,
5478    /// v7.12.4 — user-defined functions (PL/pgSQL + SQL).
5479    /// Keyed by function name (PG overloading is out of scope).
5480    /// Bodies are stored as the raw source text the parser saw
5481    /// between `$$ ... $$`; the engine re-parses on each
5482    /// invocation. This keeps `spg-storage` free of `spg-sql`
5483    /// dependency — same pattern as partial-index predicates.
5484    functions: BTreeMap<String, FunctionDef>,
5485    /// v7.12.4 — triggers in insertion order. PG18-measured (round
5486    /// 753): PG fires same-event triggers in NAME order (a_trig
5487    /// before z_trig regardless of creation order); SPG fires in
5488    /// insertion order — a real divergence, ledgered as F31-B2.
5489    triggers: Vec<TriggerDef>,
5490    /// v7.39 (round 139) — query-rewrite RULEs, flat like triggers.
5491    rules: Vec<RuleDef>,
5492    /// v7.39 (round 280) — extended-statistics objects. Recorded so a
5493    /// pg_dump restores them and reflection reports them; the planner
5494    /// does not consult them yet.
5495    statistics_ext: Vec<StatisticsExtDef>,
5496    /// v7.39 (round 287) — server-side large objects, keyed by OID.
5497    /// PG stores them as 2 KB pages in `pg_largeobject`; the page split
5498    /// is a storage detail of ITS heap, so SPG holds the whole byte
5499    /// string and renders the pages on read. What must match is the
5500    /// observable surface: the OIDs, the bytes, and the page rows.
5501    large_objects: alloc::collections::BTreeMap<u32, Vec<u8>>,
5502    /// v7.17.0 — catalogued SEQUENCE objects (Phase 1.1). Each
5503    /// `nextval(name)` reaches in here, atomically increments
5504    /// `last_value` / flips `is_called`, returns the new value.
5505    /// Persisted in catalog FILE_VERSION 26+; older catalogs
5506    /// deserialise with an empty map.
5507    sequences: BTreeMap<String, SequenceDef>,
5508    /// v7.39 (read01 round 60) — the `public` schema's ACL (PG
5509    /// `pg_namespace.nspacl`). EMPTY = PG's default, which is not "nothing":
5510    /// PUBLIC holds USAGE and the owner holds USAGE + CREATE. Materialised on
5511    /// the first GRANT / REVOKE, exactly like a table's relacl.
5512    schema_acl: Vec<AclItem>,
5513    /// v7.39 (read01 round 60) — the database's ACL. EMPTY = PG's default:
5514    /// PUBLIC holds CONNECT + TEMPORARY, the owner holds all three.
5515    database_acl: Vec<AclItem>,
5516    /// v7.17.0 — catalogued VIEW objects (Phase 1.2). Each
5517    /// `SELECT FROM v` at engine exec-time looks up `v` here and
5518    /// prepends the view body as a synthetic CTE. Persisted in
5519    /// catalog FILE_VERSION 27+; older catalogs deserialise with
5520    /// an empty map.
5521    views: BTreeMap<String, ViewDef>,
5522    /// v7.17.0 — catalogued MATERIALIZED VIEW source registry
5523    /// (Phase 1.3). Maps name → SELECT source. The materialised
5524    /// rows themselves live as a regular `Table` with the same
5525    /// name; REFRESH re-parses + re-executes the source against
5526    /// the table. Persisted in catalog FILE_VERSION 28+;
5527    /// older catalogs deserialise with an empty map.
5528    materialized_views: BTreeMap<String, String>,
5529    /// v7.17.0 — catalogued user-defined ENUM types (Phase 1.4).
5530    /// Maps name → label list. Columns reference these by name
5531    /// via `ColumnSchema.user_enum_type`. Persisted in catalog
5532    /// FILE_VERSION 29+; older catalogs deserialise with an empty
5533    /// map.
5534    enum_types: BTreeMap<String, EnumDef>,
5535    /// v7.17.0 — catalogued user-defined DOMAIN types (Phase 1.5).
5536    /// Maps name → base + CHECK constraints. Columns reference
5537    /// these by name via `ColumnSchema.user_domain_type`.
5538    /// Persisted in catalog FILE_VERSION 30+; older catalogs
5539    /// deserialise with an empty map.
5540    domain_types: BTreeMap<String, DomainDef>,
5541    /// v7.39 (read01 round 50) — `COMMENT ON <kind> <obj> IS '…'` store.
5542    /// Keyed by a canonical `"<kind>:<name>"` string (`"table:t"`,
5543    /// `"column:t.c"`, `"index:i"`, `"view:v"`, …) so a new commentable
5544    /// object kind needs no schema change. `COMMENT … IS NULL` removes the
5545    /// entry. Persisted in catalog FILE_VERSION 61+; older catalogs
5546    /// deserialise with an empty map. Read back by obj_description /
5547    /// col_description and the pg_description view.
5548    comments: BTreeMap<String, String>,
5549    /// v7.39 (round 547) — PG's `pg_db_role_setting`: the GUC defaults
5550    /// `ALTER ROLE … SET` / `ALTER DATABASE … SET` record, applied when
5551    /// a session starts.
5552    ///
5553    /// Keyed exactly as PG keys it — `(database, role)` where an empty
5554    /// name is PG's oid 0, meaning "all". So `ALTER ROLE ALL SET` is
5555    /// `("", "")`, `ALTER DATABASE d SET` is `(d, "")`, `ALTER ROLE r
5556    /// SET` is `("", r)` and `ALTER ROLE r IN DATABASE d SET` is
5557    /// `(d, r)`. The value is that scope's parameter list.
5558    db_role_settings: BTreeMap<(String, String), BTreeMap<String, String>>,
5559    /// v7.39 (round 550) — replication slots, by name.
5560    ///
5561    /// A slot in PG is two things: a named record, and a reservation
5562    /// that holds WAL back. SPG keeps the record — which is what every
5563    /// setup script and monitoring query reads — and reports
5564    /// `wal_status = 'unreserved'`, PG's own word for a slot that no
5565    /// longer holds WAL. The whole family used to answer NULL and
5566    /// report success, so `pg_drop_replication_slot('nosuchslot')` said
5567    /// it worked and a setup script created nothing.
5568    ///
5569    /// Value: (plugin, slot_type). `plugin` is empty for a physical slot.
5570    replication_slots: BTreeMap<String, (String, String)>,
5571    /// v7.38.18 (S1) — the collation this database was CREATED with, and
5572    /// the one every text column that declares none is compared under.
5573    ///
5574    /// `None` means `C`, which is what every database written by every
5575    /// earlier version was built with — so an upgrade changes no answer
5576    /// and rebuilds no index. That is the whole migration story, and it
5577    /// is why this is an `Option` rather than a `String` defaulting to
5578    /// `"C"`.
5579    ///
5580    /// Set once, at creation, and never after. PostgreSQL refuses
5581    /// `ALTER DATABASE … LC_COLLATE` and the reason is the one that
5582    /// matters here too: every index key in this database was built
5583    /// under this collation, so it cannot move out from under them.
5584    /// See `docs/DESIGN-2026-08-23-collation.md`.
5585    db_collation: Option<String>,
5586    /// v7.38.19 — every name a `CREATE DATABASE` has asked for.
5587    ///
5588    /// SPG serves one database and answers to any name, so the statement
5589    /// has always been a no-op for naming. `pg_database` then listed one
5590    /// row -- whatever name the current session connected with -- so a
5591    /// database that had just been created, and could be connected to,
5592    /// was absent from the catalogue. `psql \l`, a migration tool asking
5593    /// "does this database exist", and a backup script that enumerates
5594    /// all read that table.
5595    ///
5596    /// Reported by sentori against 7.38.18. Runtime only, like
5597    /// `db_collation`: the statement is audited whenever it records a
5598    /// name, so replay rebuilds the set.
5599    created_databases: alloc::collections::BTreeSet<String>,
5600    /// v7.37.42-T2 ζ-B — catalogued user-defined COMPOSITE types
5601    /// (`CREATE TYPE name AS (field_name field_type, …)`). Columns
5602    /// reference these by name via
5603    /// `ColumnSchema.user_composite_type` (parallel to
5604    /// `user_enum_type` / `user_domain_type`). Persisted in catalog
5605    /// FILE_VERSION 52+; older catalogs deserialise with an empty
5606    /// map.
5607    composite_types: BTreeMap<String, CompositeDef>,
5608    /// v7.17.0 — schema-namespace registry (Phase 1.6). Tracks
5609    /// which schemas exist. `public`, `pg_catalog`, and
5610    /// `information_schema` are built-in and always present.
5611    /// Schema-qualified table references still strip the prefix
5612    /// at lookup time per v7.16-and-earlier — full
5613    /// schema-as-isolation is v7.18+ scope. Persisted in catalog
5614    /// FILE_VERSION 31+; older catalogs deserialise with just
5615    /// the built-ins.
5616    schemas: alloc::collections::BTreeSet<String>,
5617}
5618
5619/// v7.12.4 — catalogued user-defined function. `body` is the raw
5620/// source text between `$$ ... $$`; the engine re-parses it on
5621/// invocation. This keeps the storage codec stable when the
5622/// PL/pgSQL surface grows (no breaking-change risk on the disk
5623/// format).
5624// v7.39 (round 322, V46) — no longer `Eq`: COST / ROWS are f64, as in PG.
5625#[derive(Debug, Clone, PartialEq)]
5626pub struct FunctionDef {
5627    pub name: String,
5628    /// Display form of the argument list, e.g.
5629    /// `"(name TEXT, ts TIMESTAMP)"`. Empty `"()"` for the trigger
5630    /// function shape. Parser-side canonicalised before storage.
5631    pub args_repr: String,
5632    /// Display form of the return type, e.g. `"TRIGGER"` /
5633    /// `"INT"` / `"SETOF text"`. The engine special-cases
5634    /// `"TRIGGER"` (case-insensitive) to gate trigger-only
5635    /// semantics (NEW/OLD).
5636    pub returns: String,
5637    /// `LANGUAGE` clause, lowercased. `"plpgsql"` / `"sql"`.
5638    pub language: String,
5639    /// Source body of the function. PL/pgSQL: includes the
5640    /// surrounding `BEGIN ... END;`. SQL: includes the
5641    /// statement(s). The engine re-parses on invocation; bad
5642    /// bodies surface as a parse error at CALL time, not CREATE.
5643    pub body: String,
5644    /// v7.39 (read01 round 61) — the role that ran CREATE FUNCTION.
5645    pub owner: Option<String>,
5646    /// v7.39 (read01 round 61) — explicit GRANTs (PG `pg_proc.proacl`). EMPTY
5647    /// is NOT "nobody may call it": PG grants EXECUTE to PUBLIC by default, and
5648    /// leaves proacl NULL to say so. The list materialises on the first
5649    /// GRANT / REVOKE.
5650    pub acl: Vec<AclItem>,
5651    /// v7.39 (round 322, V46) — `IMMUTABLE` / `STRICT` / `PARALLEL SAFE` /
5652    /// `SECURITY DEFINER` / `LEAKPROOF` / `COST` / `ROWS`. `strict` is the
5653    /// only one with execution semantics today (a NULL argument yields a
5654    /// NULL result without running the body); the rest are recorded so
5655    /// `pg_get_functiondef` and `pg_proc` report what was declared.
5656    pub volatility: u8,
5657    pub strict: bool,
5658    pub security_definer: bool,
5659    pub leakproof: bool,
5660    pub parallel: u8,
5661    pub cost: Option<f64>,
5662    pub rows: Option<f64>,
5663}
5664
5665/// v7.39 (round 322, V46) — `FunctionDef.volatility` codes: PG's
5666/// `pg_proc.provolatile` letters.
5667pub const FN_VOLATILE: u8 = b'v';
5668pub const FN_IMMUTABLE: u8 = b'i';
5669pub const FN_STABLE: u8 = b's';
5670
5671/// v7.39 (round 322, V46) — `FunctionDef.parallel` codes: PG's
5672/// `pg_proc.proparallel` letters.
5673pub const FN_PARALLEL_UNSAFE: u8 = b'u';
5674pub const FN_PARALLEL_RESTRICTED: u8 = b'r';
5675pub const FN_PARALLEL_SAFE: u8 = b's';
5676
5677/// v7.39 (round 315, V19) — which catalogued function does a persisted
5678/// ACL key refer to?
5679///
5680/// The key was computed by whichever formula was current when the image
5681/// was written, and the multi-word fix changed that formula for bare
5682/// types like `double precision`. A miss therefore does NOT mean "no
5683/// such function": an older image's key would land nowhere and its owner
5684/// and grants would be dropped in silence. Exact match first, then the
5685/// pre-fix formula.
5686#[must_use]
5687pub fn resolve_stored_function_key(
5688    functions: &BTreeMap<String, FunctionDef>,
5689    stored: &str,
5690) -> Option<String> {
5691    if functions.contains_key(stored) {
5692        return Some(stored.to_string());
5693    }
5694    functions
5695        .values()
5696        .find(|f| function_signature_key_legacy(&f.name, &f.args_repr) == stored)
5697        .map(|f| function_signature_key(&f.name, &f.args_repr))
5698}
5699
5700/// v7.39 (round 344, V49) — re-exported from [`spg_sql`], which owns the
5701/// SQL type spellings. This crate carried a byte-identical copy because
5702/// the two were siblings that did not depend on each other; spg-sql is a
5703/// dependency-free leaf, so the dependency is acyclic and the publish
5704/// order already puts it first. One list, one place to keep it right.
5705pub use spg_sql::parser::is_multiword_type_phrase;
5706
5707/// v7.39 (round 315, V19) — the signature key as computed BEFORE the
5708/// multi-word fix, used only to recognise what an older image wrote.
5709///
5710/// The function catalogue recomputes its keys from the stored name and
5711/// argument text on load, so it needs no migration. The ACL block does
5712/// not: it persists the computed key as a string and matches on it. A
5713/// key that changed shape would simply fail to match, and the owner and
5714/// grants would be dropped without a word — so the loader falls back to
5715/// this when the stored key finds nothing.
5716#[must_use]
5717pub fn function_signature_key_legacy(name: &str, args_repr: &str) -> String {
5718    let inner = args_repr
5719        .trim()
5720        .trim_start_matches('(')
5721        .trim_end_matches(')');
5722    let types: Vec<String> = if inner.trim().is_empty() {
5723        Vec::new()
5724    } else {
5725        inner
5726            .split(',')
5727            .map(|part| {
5728                let mut words: Vec<&str> = part.split_whitespace().collect();
5729                if !words.is_empty()
5730                    && (words[0].eq_ignore_ascii_case("OUT")
5731                        || words[0].eq_ignore_ascii_case("INOUT"))
5732                {
5733                    words.remove(0);
5734                }
5735                let ty = if words.len() >= 2 {
5736                    words[1..].join(" ")
5737                } else {
5738                    words.first().map_or(String::new(), |w| (*w).to_string())
5739                };
5740                normalize_type_name(&ty)
5741            })
5742            .collect()
5743    };
5744    format!("{}({})", name.to_ascii_lowercase(), types.join(","))
5745}
5746
5747pub fn function_signature_key(name: &str, args_repr: &str) -> String {
5748    let types = function_arg_types(args_repr);
5749    format!("{}({})", name.to_ascii_lowercase(), types.join(","))
5750}
5751
5752/// The declared argument TYPES of a function, out of its `args_repr`
5753/// (`"(x INT, y DOUBLE PRECISION)"` → `["int", "float"]`). An entry may be a
5754/// bare type with no name (`"(INT)"`).
5755#[must_use]
5756pub fn function_arg_types(args_repr: &str) -> Vec<String> {
5757    let inner = args_repr
5758        .trim()
5759        .trim_start_matches('(')
5760        .trim_end_matches(')');
5761    if inner.trim().is_empty() {
5762        return Vec::new();
5763    }
5764    inner
5765        .split(',')
5766        .map(|part| {
5767            let mut words: Vec<&str> = part.split_whitespace().collect();
5768            // `OUT x INT` / `INOUT x INT` — the mode is not part of the type.
5769            if !words.is_empty()
5770                && (words[0].eq_ignore_ascii_case("OUT") || words[0].eq_ignore_ascii_case("INOUT"))
5771            {
5772                words.remove(0);
5773            }
5774            // v7.39 (round 315, V19) — two or more words is USUALLY
5775            // `name TYPE`, but not when the type itself is spelled in
5776            // several words. `double precision` was read as a parameter
5777            // named "double" of type "precision", so it keyed differently
5778            // from `x double precision` — the same signature written two
5779            // ways did not resolve to the same function. Decide by asking
5780            // whether the whole phrase names a type first; only then is
5781            // the leading word a parameter name.
5782            let whole = words.join(" ");
5783            let ty = if words.len() >= 2 && !is_multiword_type_phrase(&whole) {
5784                words[1..].join(" ")
5785            } else {
5786                whole
5787            };
5788            normalize_type_name(&ty)
5789        })
5790        .collect()
5791}
5792
5793/// v7.39 (read01 round 65) — the declared argument NAMES of a function (`""` for
5794/// a bare type with no name).
5795#[must_use]
5796pub fn function_arg_names(args_repr: &str) -> Vec<String> {
5797    let inner = args_repr
5798        .trim()
5799        .trim_start_matches('(')
5800        .trim_end_matches(')');
5801    if inner.trim().is_empty() {
5802        return Vec::new();
5803    }
5804    inner
5805        .split(',')
5806        .map(|part| {
5807            let mut words: Vec<&str> = part.split_whitespace().collect();
5808            if !words.is_empty()
5809                && (words[0].eq_ignore_ascii_case("OUT") || words[0].eq_ignore_ascii_case("INOUT"))
5810            {
5811                words.remove(0);
5812            }
5813            if words.len() >= 2 {
5814                words[0].to_string()
5815            } else {
5816                String::new()
5817            }
5818        })
5819        .collect()
5820}
5821
5822/// Fold PG's type aliases so a signature key is stable across spellings.
5823/// Unknown names pass through lower-cased — consistency is what the key needs.
5824#[must_use]
5825pub fn normalize_type_name(ty: &str) -> String {
5826    let t = ty.trim().to_ascii_lowercase();
5827    // Peel a precision/length modifier: `numeric(10,2)`, `varchar(64)`.
5828    let base = t.split_once('(').map_or(t.as_str(), |(h, _)| h).trim();
5829    match base {
5830        "int" | "int4" | "integer" => "int",
5831        "bigint" | "int8" => "bigint",
5832        "smallint" | "int2" => "smallint",
5833        "text" | "varchar" | "character varying" | "char" | "character" | "bpchar" => "text",
5834        "bool" | "boolean" => "bool",
5835        "float" | "float8" | "double precision" => "float",
5836        "real" | "float4" => "real",
5837        "numeric" | "decimal" => "numeric",
5838        "timestamptz" | "timestamp with time zone" => "timestamptz",
5839        "timestamp" | "timestamp without time zone" => "timestamp",
5840        other => other,
5841    }
5842    .to_string()
5843}
5844
5845/// v7.12.4 — catalogued trigger. References its function by
5846/// name; the function must exist at TRIGGER creation time
5847/// (forward references are deferred to v7.12.5+).
5848#[derive(Debug, Clone, PartialEq, Eq)]
5849pub struct TriggerDef {
5850    pub name: String,
5851    /// Watched table. Trigger is dropped when the table drops.
5852    pub table: String,
5853    /// `"BEFORE"` / `"AFTER"` / `"INSTEAD OF"`. Stored as the
5854    /// uppercased keyword so deserialised catalogs round-trip
5855    /// without canonicalisation surprises.
5856    pub timing: String,
5857    /// Each entry is one of `"INSERT"` / `"UPDATE"` / `"DELETE"`
5858    /// / `"TRUNCATE"`. `INSERT OR UPDATE` parses to two entries.
5859    pub events: Vec<String>,
5860    /// `"ROW"` / `"STATEMENT"`. v7.12.4 ships `"ROW"` only;
5861    /// `"STATEMENT"` parses and persists but the executor
5862    /// refuses it at trigger fire time.
5863    pub for_each: String,
5864    /// Name of the PL/pgSQL function to invoke.
5865    pub function: String,
5866    /// v7.13.0 — `UPDATE OF col, col, …` column-list filter
5867    /// (mailrs round-5 G7). Non-empty means the trigger fires
5868    /// only when at least one of these columns appears in the
5869    /// UPDATE's SET list. Empty = no column filter. Stored in
5870    /// catalog FILE_VERSION 23+; older catalogs deserialise with
5871    /// an empty vec.
5872    pub update_columns: Vec<String>,
5873    /// v7.16.1 — whether the trigger fires when its watched
5874    /// event occurs. Toggled by `ALTER TABLE … { ENABLE |
5875    /// DISABLE } TRIGGER …`; pg_dump --disable-triggers wraps
5876    /// every data block with a DISABLE/ENABLE pair so the
5877    /// rows already-computed in prod don't get re-rewritten.
5878    /// Defaults to `true` at CREATE TRIGGER time. Stored in
5879    /// catalog FILE_VERSION 25+; older catalogs deserialise
5880    /// with `enabled = true`.
5881    pub enabled: bool,
5882    /// v7.39 (round 138) — the deparsed `WHEN ( condition )` predicate text
5883    /// (re-parsed at fire time to filter row triggers). Empty = no WHEN.
5884    /// Persisted from FILE_VERSION 70; older catalogs read back empty.
5885    pub when_condition: String,
5886}
5887
5888/// v7.39 (round 280) — one `CREATE STATISTICS` object.
5889#[derive(Debug, Clone, PartialEq, Eq)]
5890pub struct StatisticsExtDef {
5891    pub name: String,
5892    pub table: String,
5893    /// PG's single-letter kinds: `d` ndistinct, `f` dependencies,
5894    /// `m` mcv. PG's default set is all three.
5895    pub kinds: Vec<String>,
5896    pub columns: Vec<String>,
5897}
5898
5899/// v7.39 (round 139) — a catalogued query-rewrite RULE. Stored flat like
5900/// `TriggerDef`, keyed by `(name, table)`. Command / WHEN text is deparsed SQL
5901/// re-parsed at rewrite time (the same round-trip trick as
5902/// `TriggerDef.when_condition`). Persisted from FILE_VERSION 71.
5903#[derive(Debug, Clone, PartialEq, Eq)]
5904pub struct RuleDef {
5905    pub name: String,
5906    pub table: String,
5907    /// Event keyword, uppercased: `INSERT` / `UPDATE` / `DELETE` / `SELECT`.
5908    pub event: String,
5909    /// `true` = `DO INSTEAD`, `false` = `DO ALSO`.
5910    pub instead: bool,
5911    /// Deparsed `WHERE` predicate text; empty = unconditional.
5912    pub when_condition: String,
5913    /// Deparsed DO command statements; empty = `NOTHING`.
5914    pub commands: Vec<String>,
5915}
5916
5917/// v7.17.0 — catalogued SEQUENCE. PG semantics: a counter object
5918/// returning monotonically increasing values via `nextval(name)`.
5919/// `last_value` is the most recent value handed out; `is_called`
5920/// is false until the first `nextval`/`setval`. Stored separately
5921/// from tables in the catalog.
5922#[derive(Debug, Clone, PartialEq, Eq)]
5923pub struct SequenceDef {
5924    pub name: String,
5925    /// Data type — narrows the i64 range. PG default BIGINT.
5926    pub data_type: SequenceDataType,
5927    pub start: i64,
5928    pub increment: i64,
5929    pub min_value: i64,
5930    pub max_value: i64,
5931    pub cache: i64,
5932    pub cycle: bool,
5933    /// `OWNED BY` target — `(table, column)` or NONE.
5934    pub owned_by: Option<(String, String)>,
5935    /// Most recently handed-out value. Meaningless when
5936    /// `is_called == false`; in that case the NEXT `nextval`
5937    /// will return `start`.
5938    pub last_value: i64,
5939    pub is_called: bool,
5940    /// v7.39 (read01 round 60) — the role that ran CREATE SEQUENCE. `None` = an
5941    /// image written before FILE_VERSION 66, which predates sequence owners.
5942    pub owner: Option<String>,
5943    /// v7.39 (read01 round 60) — explicit GRANTs on this sequence. A sequence's
5944    /// meaningful privileges are SELECT (`currval`), UPDATE (`setval`) and
5945    /// USAGE (`nextval`).
5946    pub acl: Vec<AclItem>,
5947}
5948
5949/// v7.17.0 — sequence integer width.
5950#[derive(Debug, Clone, Copy, PartialEq, Eq)]
5951pub enum SequenceDataType {
5952    SmallInt,
5953    Int,
5954    BigInt,
5955}
5956
5957/// v7.17.0 Phase 1.6 — built-in schema names that every Catalog
5958/// understands without an explicit CREATE SCHEMA. Used by
5959/// [`Catalog::schema_exists`] and the engine's schema-qualified
5960/// lookup path.
5961#[must_use]
5962pub fn is_builtin_schema(name: &str) -> bool {
5963    name.eq_ignore_ascii_case("public")
5964        || name.eq_ignore_ascii_case("pg_catalog")
5965        || name.eq_ignore_ascii_case("information_schema")
5966}
5967
5968/// v7.17.0 — parse a PG-canonical UUID text representation into the
5969/// 16-byte network-order layout used by `Value::Uuid`. Accepted input
5970/// shapes (all case-insensitive):
5971///   * Canonical hyphenated 8-4-4-4-12 (`550e8400-e29b-41d4-a716-446655440000`)
5972///   * Unhyphenated 32-char hex (`550e8400e29b41d4a716446655440000`)
5973///   * Either form wrapped in `{ ... }`
5974///
5975/// Returns `None` for any malformed input (wrong length, non-hex
5976/// characters, misplaced hyphens). The caller surfaces a SQL error
5977/// at coercion time — silent acceptance of garbage would mask
5978/// application bugs and is exactly the divergence from PG that
5979/// breaks the 0-change cutover promise.
5980#[must_use]
5981pub fn parse_uuid_str(input: &str) -> Option<[u8; 16]> {
5982    let s = input.trim();
5983    // Strip surrounding braces if present.
5984    let s = if let Some(inner) = s.strip_prefix('{').and_then(|x| x.strip_suffix('}')) {
5985        inner
5986    } else {
5987        s
5988    };
5989    // Two valid shapes after braces are stripped: 32 hex chars or
5990    // the canonical 36-char hyphenated form.
5991    let hex: String = match s.len() {
5992        32 => s.to_ascii_lowercase(),
5993        36 => {
5994            // Hyphens must be exactly at positions 8, 13, 18, 23.
5995            let b = s.as_bytes();
5996            if b[8] != b'-' || b[13] != b'-' || b[18] != b'-' || b[23] != b'-' {
5997                return None;
5998            }
5999            let mut out = String::with_capacity(32);
6000            out.push_str(&s[0..8]);
6001            out.push_str(&s[9..13]);
6002            out.push_str(&s[14..18]);
6003            out.push_str(&s[19..23]);
6004            out.push_str(&s[24..36]);
6005            out.make_ascii_lowercase();
6006            out
6007        }
6008        _ => return None,
6009    };
6010    let bytes = hex.as_bytes();
6011    let mut out = [0u8; 16];
6012    for i in 0..16 {
6013        let hi = hex_nibble(bytes[i * 2])?;
6014        let lo = hex_nibble(bytes[i * 2 + 1])?;
6015        out[i] = (hi << 4) | lo;
6016    }
6017    Some(out)
6018}
6019
6020fn hex_nibble(b: u8) -> Option<u8> {
6021    match b {
6022        b'0'..=b'9' => Some(b - b'0'),
6023        b'a'..=b'f' => Some(10 + b - b'a'),
6024        b'A'..=b'F' => Some(10 + b - b'A'),
6025        _ => None,
6026    }
6027}
6028
6029/// v7.17.0 — render a `Value::Uuid` payload as the canonical
6030/// lowercase 8-4-4-4-12 hyphenated form PG `text` cast surfaces.
6031#[must_use]
6032pub fn format_uuid(b: &[u8; 16]) -> String {
6033    const HEX: &[u8; 16] = b"0123456789abcdef";
6034    let mut out = String::with_capacity(36);
6035    for (i, byte) in b.iter().enumerate() {
6036        if matches!(i, 4 | 6 | 8 | 10) {
6037            out.push('-');
6038        }
6039        out.push(HEX[(byte >> 4) as usize] as char);
6040        out.push(HEX[(byte & 0x0f) as usize] as char);
6041    }
6042    out
6043}
6044
6045/// v7.17.0 Phase 1.5 — catalogued user-defined DOMAIN. A domain
6046/// is a named CHECK-constrained alias over a built-in type;
6047/// columns bound to it inherit the base type plus the CHECK
6048/// predicates + NOT NULL + DEFAULT at INSERT/UPDATE time.
6049/// v7.37.17 (Phase E RC rebase) — the write-set one writer version left
6050/// on a table, addressed by stable [`row_header::RowId`]s so it can be
6051/// replayed onto a fresher clone of the relation whose physical slots
6052/// differ. Produced by [`Table::extract_tx_writeset`], consumed by
6053/// [`Table::replay_tx_writeset`].
6054#[derive(Debug, Clone, Default)]
6055pub struct TxWriteSet {
6056    /// INSERTs and UPDATE-new-versions (`header.xmin == v`).
6057    pub inserted: Vec<(row_header::RowId, Row<'static>)>,
6058    /// DELETE / UPDATE-old-version targets (`header.xmax == v`).
6059    pub tombstoned: Vec<row_header::RowId>,
6060}
6061
6062impl TxWriteSet {
6063    #[must_use]
6064    pub fn is_empty(&self) -> bool {
6065        self.inserted.is_empty() && self.tombstoned.is_empty()
6066    }
6067}
6068
6069/// v7.39 (round 260) — one named CHECK on a domain. PG auto-names an
6070/// unnamed one `<domain>_check`, then `_check1`, `_check2`, … (probed).
6071#[derive(Debug, Clone, PartialEq, Eq)]
6072pub struct DomainCheck {
6073    pub name: String,
6074    /// The predicate source, referencing the pseudo-column `VALUE`.
6075    pub expr: String,
6076}
6077
6078/// `default` / `checks` are stored as Display-form source so
6079/// `spg-storage` stays free of `spg-sql` dependency — same
6080/// pattern as FunctionDef / ViewDef.
6081#[derive(Debug, Clone, PartialEq, Eq)]
6082pub struct DomainDef {
6083    pub name: String,
6084    pub base_type: DataType,
6085    pub nullable: bool,
6086    pub default: Option<String>,
6087    /// v7.39 (round 260) — each CHECK carries its constraint NAME, so
6088    /// `ALTER DOMAIN … DROP CONSTRAINT <name>` can find it and the
6089    /// violation message can report the constraint that actually failed.
6090    /// PG's auto-naming for an unnamed check is `<domain>_check`, then
6091    /// `_check1`, `_check2`, … (probed).
6092    pub checks: Vec<DomainCheck>,
6093    /// v7.39 (round 258/259) — when this domain was declared over ANOTHER
6094    /// domain (`CREATE DOMAIN child AS parent CHECK (…)`), the parent's
6095    /// name. `base_type` is the ultimate scalar type either way, so
6096    /// without this the parent's constraints were invisible and a value
6097    /// violating them was silently accepted. PG checks the whole chain,
6098    /// base-first, and an `ALTER DOMAIN` on the parent takes effect for
6099    /// the child immediately (probed) — so the chain is walked at check
6100    /// time rather than copied at CREATE time. Catalog FILE_VERSION 74+.
6101    pub base_domain: Option<String>,
6102}
6103
6104/// v7.17.0 Phase 1.4 — catalogued user-defined ENUM type. The
6105/// label vector is order-preserving (PG enum ordering follows the
6106/// declared order). At INSERT/UPDATE on a column bound to this
6107/// enum, the engine looks up the value against `labels` and
6108/// rejects non-members.
6109#[derive(Debug, Clone, PartialEq, Eq)]
6110pub struct EnumDef {
6111    pub name: String,
6112    pub labels: Vec<String>,
6113}
6114
6115/// v7.37.42-T2 ζ-B — catalogued user-defined COMPOSITE type
6116/// (`CREATE TYPE name AS (field_name field_type, ...)`). Order
6117/// matters: PG composite literals are positional, and SPG mirrors
6118/// that. Stored as ordered `(name, DataType)` pairs to keep the
6119/// codec straightforward and to allow eventual `Value::Composite`
6120/// bodies to encode positionally. Persisted in catalog FILE_VERSION
6121/// 52+; older catalogs deserialise with an empty composite_types
6122/// map. Composite types can be used as a column type by spelling
6123/// the composite's name; the resolution from
6124/// `ColumnSchema.user_composite_type = Some(name)` happens at the
6125/// engine boundary (parallel to `user_enum_type` /
6126/// `user_domain_type`). The dense storage shape — JSON-text body
6127/// keyed by the composite's field list — keeps the codec free of
6128/// recursive `Value` bodies until the full Value::Composite arena
6129/// migration in a later phase.
6130#[derive(Debug, Clone, PartialEq, Eq)]
6131pub struct CompositeDef {
6132    pub name: String,
6133    /// Ordered `(field_name, field_type)` pairs. PG composite
6134    /// literals are positional, so order is part of the type's
6135    /// identity.
6136    pub fields: Vec<(String, DataType)>,
6137    /// v7.39 (round 264) — parallel to `fields`: the USER type name of
6138    /// each field when it is itself a composite (or another named user
6139    /// type). `DataType` has no room for one, so a nested composite
6140    /// field resolved to the parser's Text placeholder and the inner
6141    /// record stayed TEXT — `(x).inner.street` errored, `pg_typeof`
6142    /// said text, and `row_to_json` nested a string instead of an
6143    /// object. Same shape as `ColumnSchema.user_composite_type` and
6144    /// `DomainDef.base_domain`. Catalog FILE_VERSION 76+; an older
6145    /// catalog reads all-None, which is what it meant.
6146    pub field_user_types: Vec<Option<String>>,
6147}
6148
6149/// v7.17.0 Phase 1.2 — catalogued VIEW. The body is stored as the
6150/// raw source text the parser saw between `AS` and the statement
6151/// terminator; the engine re-parses on each invocation. Same
6152/// pattern as `FunctionDef` — keeps `spg-storage` free of
6153/// `spg-sql` dependency.
6154#[derive(Debug, Clone, PartialEq, Eq)]
6155pub struct ViewDef {
6156    pub name: String,
6157    /// Optional `(col, col, …)` rename list. Empty when the body's
6158    /// projected names are used directly.
6159    pub columns: Vec<String>,
6160    /// Raw SELECT source. Display-rendered at storage time so the
6161    /// catalog round-trips a deterministic form regardless of
6162    /// whitespace / comments in the original input. Re-parsed at
6163    /// SELECT-from-view time to materialise as a synthetic CTE.
6164    pub body: String,
6165    /// v7.39 (round 132) — `WITH CHECK OPTION`: 0 = none, 1 = LOCAL,
6166    /// 2 = CASCADED. A storage-local u8 (no dependency on the SQL AST).
6167    /// Persisted from FILE_VERSION 69; older catalogs read back as 0.
6168    pub check_option: u8,
6169}
6170
6171impl SequenceDataType {
6172    /// PG default min/max per AS clause.
6173    pub fn default_bounds(self, increment_positive: bool) -> (i64, i64) {
6174        match self {
6175            Self::SmallInt => {
6176                if increment_positive {
6177                    (1, i64::from(i16::MAX))
6178                } else {
6179                    (i64::from(i16::MIN), -1)
6180                }
6181            }
6182            Self::Int => {
6183                if increment_positive {
6184                    (1, i64::from(i32::MAX))
6185                } else {
6186                    (i64::from(i32::MIN), -1)
6187                }
6188            }
6189            Self::BigInt => {
6190                if increment_positive {
6191                    (1, i64::MAX)
6192                } else {
6193                    (i64::MIN, -1)
6194                }
6195            }
6196        }
6197    }
6198}
6199
6200impl Catalog {
6201    /// v7.37.15 (Phase D) — fleet-wide vacuum pass. Walks every
6202    /// user table and reclaims rows whose delete-commit version is
6203    /// older than `oldest_active_snapshot`. Returns an aggregated
6204    /// report with per-table breakdown so hosts can emit metrics.
6205    ///
6206    /// `dry_run = true` reports the work without doing it. Use it
6207    /// to estimate the cost before scheduling a real pass.
6208    pub fn vacuum_all(
6209        &mut self,
6210        oldest_active_snapshot: u64,
6211        dry_run: bool,
6212    ) -> vacuum::VacuumReport {
6213        let mut total = vacuum::VacuumReport::default();
6214        // Snapshot the table names so we don't hold an immutable
6215        // borrow during the get_mut loop.
6216        let names: Vec<String> = self
6217            .tables
6218            .iter()
6219            .map(|t| t.schema().name.clone())
6220            .collect();
6221        for name in names {
6222            let Some(t) = self.get_mut(&name) else {
6223                continue;
6224            };
6225            let r = t.vacuum(oldest_active_snapshot, dry_run);
6226            if r.rows_reclaimed > 0 {
6227                total.per_table.push((name, r.rows_reclaimed));
6228            }
6229            total.rows_reclaimed += r.rows_reclaimed;
6230            total.rows_examined += r.rows_examined;
6231        }
6232        total
6233    }
6234
6235    pub const fn new() -> Self {
6236        Self {
6237            cold_read_stats: ColdReadStats {
6238                cold_reads: core::sync::atomic::AtomicU64::new(0),
6239            },
6240            tables: Vec::new(),
6241            by_name: BTreeMap::new(),
6242            temp_prefix: None,
6243            case_insensitive_names: false,
6244            dirty_tables: alloc::collections::BTreeSet::new(),
6245            dirty_nontable: alloc::collections::BTreeSet::new(),
6246            next_rel_id: 0,
6247            cold_segments: Vec::new(),
6248            functions: BTreeMap::new(),
6249            triggers: Vec::new(),
6250            rules: Vec::new(),
6251            statistics_ext: Vec::new(),
6252            large_objects: alloc::collections::BTreeMap::new(),
6253            sequences: BTreeMap::new(),
6254            schema_acl: Vec::new(),
6255            database_acl: Vec::new(),
6256            views: BTreeMap::new(),
6257            materialized_views: BTreeMap::new(),
6258            enum_types: BTreeMap::new(),
6259            domain_types: BTreeMap::new(),
6260            comments: BTreeMap::new(),
6261            db_role_settings: BTreeMap::new(),
6262            replication_slots: BTreeMap::new(),
6263            db_collation: None,
6264            created_databases: alloc::collections::BTreeSet::new(),
6265            composite_types: BTreeMap::new(),
6266            schemas: alloc::collections::BTreeSet::new(),
6267        }
6268    }
6269
6270    /// v7.12.4 — read-only view of catalogued user-defined
6271    /// functions. Engine callers go through here to look up the
6272    /// function body before re-parsing it for invocation.
6273    pub const fn functions(&self) -> &BTreeMap<String, FunctionDef> {
6274        &self.functions
6275    }
6276
6277    /// v7.12.4 — register a new user-defined function. With
6278    /// `or_replace = false`, errors if the name is taken. The
6279    /// engine validates the body before passing it here.
6280    pub fn create_function(
6281        &mut self,
6282        def: FunctionDef,
6283        or_replace: bool,
6284    ) -> Result<(), StorageError> {
6285        // v7.39 (read01 round 62) — functions are keyed by SIGNATURE, not by
6286        // name: `f(int)` and `f(text)` are two functions, as in PG. Keying by
6287        // name alone made a second overload an "already exists" error — so a
6288        // pg_dump carrying an overload set could not restore — and, worse, a
6289        // call to one overload silently ran the other.
6290        let key = function_signature_key(&def.name, &def.args_repr);
6291        if !or_replace && self.functions.contains_key(&key) {
6292            return Err(StorageError::Corrupt(format!(
6293                "function {:?} already exists (drop or use CREATE OR REPLACE)",
6294                def.name
6295            )));
6296        }
6297        self.functions.insert(key, def);
6298        Ok(())
6299    }
6300
6301    /// v7.39 (read01 round 62) — every overload of `name`.
6302    #[must_use]
6303    pub fn functions_named(&self, name: &str) -> Vec<&FunctionDef> {
6304        self.functions
6305            .values()
6306            .filter(|f| f.name.eq_ignore_ascii_case(name))
6307            .collect()
6308    }
6309
6310    /// v7.39 (read01 round 62) — one overload, by its signature key.
6311    #[must_use]
6312    pub fn function_by_key(&self, key: &str) -> Option<&FunctionDef> {
6313        self.functions.get(key)
6314    }
6315
6316    /// v7.39 (read01 round 62) — drop ONE overload. `true` if it was there.
6317    pub fn drop_function_by_key(&mut self, key: &str) -> bool {
6318        self.functions.remove(key).is_some()
6319    }
6320
6321    /// v7.12.4 — remove a user-defined function by name. Returns
6322    /// `true` if a function was removed, `false` if none matched.
6323    /// Caller decides whether to surface `if_exists` semantics.
6324    /// v7.39 (read01 round 62) — with no signature, PG drops the function only
6325    /// when the name is unambiguous. SPG mirrors that: this removes EVERY
6326    /// overload of `name`, and the caller (ddl.rs) refuses the ambiguous case
6327    /// before getting here.
6328    pub fn drop_function(&mut self, name: &str) -> bool {
6329        let keys: Vec<String> = self
6330            .functions
6331            .iter()
6332            .filter(|(_, f)| f.name.eq_ignore_ascii_case(name))
6333            .map(|(k, _)| k.clone())
6334            .collect();
6335        let hit = !keys.is_empty();
6336        for k in keys {
6337            self.functions.remove(&k);
6338        }
6339        hit
6340    }
6341
6342    /// v7.17.0 — read-only handle to catalogued sequences.
6343    /// v7.39 (read01 round 60) — the `public` schema's ACL (PG nspacl).
6344    #[must_use]
6345    pub fn schema_acl(&self) -> &[AclItem] {
6346        &self.schema_acl
6347    }
6348
6349    pub fn schema_acl_mut(&mut self) -> &mut Vec<AclItem> {
6350        &mut self.schema_acl
6351    }
6352
6353    /// v7.39 (read01 round 60) — the database's ACL.
6354    #[must_use]
6355    pub fn database_acl(&self) -> &[AclItem] {
6356        &self.database_acl
6357    }
6358
6359    pub fn database_acl_mut(&mut self) -> &mut Vec<AclItem> {
6360        &mut self.database_acl
6361    }
6362
6363    /// v7.39 (read01 round 60) — mutable sequence access, for GRANT.
6364    /// v7.39 (round 469) — resolves the session's temporary sequence
6365    /// first, like its read-only twin. `nextval` and `setval` reach the
6366    /// map through here, so a temporary sequence shadowing a permanent one
6367    /// advances the temporary one — measured against PG18, where the
6368    /// permanent sequence's counter is untouched while the temp exists.
6369    pub fn sequence_mut(&mut self, name: &str) -> Option<&mut SequenceDef> {
6370        let key = self.sequence_key(name);
6371        self.sequences.get_mut(&key)
6372    }
6373
6374    /// v7.39 (read01 round 61) — mutable function access, for GRANT.
6375    pub fn function_mut(&mut self, name: &str) -> Option<&mut FunctionDef> {
6376        self.functions.get_mut(name)
6377    }
6378
6379    /// Every catalogued sequence, temp ones included under their mangled
6380    /// storage names. Listing code filters these through
6381    /// [`Self::listed_name`]; anything resolving ONE name by its logical
6382    /// spelling wants [`Self::sequence`] instead.
6383    pub const fn sequences_all(&self) -> &BTreeMap<String, SequenceDef> {
6384        &self.sequences
6385    }
6386
6387    /// v7.39 (round 469) — resolve one sequence by its logical name, the
6388    /// session's temporary one winning over a permanent one of the same
6389    /// name. The same rule [`Self::resolve_index`] applies to tables.
6390    #[must_use]
6391    pub fn sequence(&self, name: &str) -> Option<&SequenceDef> {
6392        if let Some(mangled) = self.temp_name_for(name)
6393            && let Some(def) = self.sequences.get(&mangled)
6394        {
6395            return Some(def);
6396        }
6397        self.sequences.get(name)
6398    }
6399
6400    /// Does a sequence of this logical name exist for this session?
6401    #[must_use]
6402    pub fn has_sequence(&self, name: &str) -> bool {
6403        self.sequence(name).is_some()
6404    }
6405
6406    /// The storage key a sequence of this logical name resolves to — the
6407    /// session's temp mangling when it has one, else the name itself.
6408    #[must_use]
6409    pub fn sequence_key(&self, name: &str) -> String {
6410        if let Some(mangled) = self.temp_name_for(name)
6411            && self.sequences.contains_key(&mangled)
6412        {
6413            return mangled;
6414        }
6415        name.into()
6416    }
6417
6418    /// v7.17.0 — register a new SEQUENCE. Errors if `name`
6419    /// collides with an existing sequence and `if_not_exists`
6420    /// is false.
6421    pub fn create_sequence(
6422        &mut self,
6423        def: SequenceDef,
6424        if_not_exists: bool,
6425    ) -> Result<(), StorageError> {
6426        if self.sequences.contains_key(&def.name) {
6427            if if_not_exists {
6428                return Ok(());
6429            }
6430            // v7.39 (read01 round 47) — a sequence is a relation to PG (42P07).
6431            return Err(StorageError::Corrupt(format!(
6432                "relation {:?} already exists",
6433                def.name
6434            )));
6435        }
6436        self.mark_nontable_dirty(NonTableKind::Sequence, &def.name);
6437        self.sequences.insert(def.name.clone(), def);
6438        Ok(())
6439    }
6440
6441    /// v7.17.0 — remove a SEQUENCE by name. Returns `true` if a
6442    /// sequence was removed, `false` if none matched. Caller
6443    /// surfaces IF EXISTS semantics.
6444    /// v7.39 (read01 round 49) — `ALTER SEQUENCE old RENAME TO new`.
6445    /// Errors when `old` is missing or `new` is taken; the SequenceDef's own
6446    /// `name` field is rewritten so it stays self-describing.
6447    pub fn rename_sequence(&mut self, old: &str, new: &str) -> Result<(), StorageError> {
6448        if !self.sequences.contains_key(old) {
6449            return Err(StorageError::Corrupt(format!(
6450                "relation {old:?} does not exist"
6451            )));
6452        }
6453        if self.sequences.contains_key(new) {
6454            return Err(StorageError::Corrupt(format!(
6455                "relation {new:?} already exists"
6456            )));
6457        }
6458        self.mark_nontable_dirty(NonTableKind::Sequence, old);
6459        self.mark_nontable_dirty(NonTableKind::Sequence, new);
6460        if let Some(mut def) = self.sequences.remove(old) {
6461            def.name = new.to_string();
6462            self.sequences.insert(new.to_string(), def);
6463        }
6464        Ok(())
6465    }
6466
6467    pub fn drop_sequence(&mut self, name: &str) -> bool {
6468        self.mark_nontable_dirty(NonTableKind::Sequence, name);
6469        self.sequences.remove(name).is_some()
6470    }
6471
6472    /// v7.17.0 — atomic nextval. Increments `last_value` per
6473    /// `increment`, returns the new value, sets `is_called`.
6474    /// Returns an error on CYCLE-less overflow.
6475    /// v7.39 (round 497) — the counter state of every sequence, for
6476    /// carrying across a commit install.
6477    ///
6478    /// A sequence's VALUE is not transactional in PG: `nextval` advances
6479    /// shared state that a rollback does not give back, because two
6480    /// sessions must never receive the same number. SPG keeps sequences in
6481    /// the catalog, and a transaction works on a catalog CLONE, so
6482    /// installing that clone at COMMIT would restore whatever the counter
6483    /// was at BEGIN. These two let the install put the live counters back.
6484    #[must_use]
6485    pub fn sequence_counters(&self) -> Vec<(String, i64, bool)> {
6486        self.sequences
6487            .iter()
6488            .map(|(k, d)| (k.clone(), d.last_value, d.is_called))
6489            .collect()
6490    }
6491
6492    /// Restore counters saved by [`Self::sequence_counters`], for the
6493    /// sequences that still exist. A sequence the transaction CREATED is
6494    /// absent from the saved set and keeps the value it was given.
6495    pub fn restore_sequence_counters(&mut self, saved: &[(String, i64, bool)]) {
6496        for (k, last, called) in saved {
6497            if let Some(d) = self.sequences.get_mut(k) {
6498                d.last_value = *last;
6499                d.is_called = *called;
6500            }
6501        }
6502    }
6503
6504    pub fn sequence_next_value(&mut self, name: &str) -> Result<i64, StorageError> {
6505        let key = self.sequence_key(name);
6506        let Some(seq) = self.sequences.get_mut(&key) else {
6507            return Err(StorageError::TableNotFound { name: name.into() });
6508        };
6509        // PG semantics: when !is_called (fresh sequence or
6510        // setval(_, false)), the next nextval returns the stored
6511        // `last_value`. When is_called, it advances by `increment`
6512        // and CYCLE-wraps on overflow.
6513        let candidate = if seq.is_called {
6514            let next = seq.last_value.checked_add(seq.increment).ok_or_else(|| {
6515                StorageError::Corrupt(format!("sequence {name:?} arithmetic overflow"))
6516            })?;
6517            if seq.increment > 0 {
6518                if next > seq.max_value {
6519                    if seq.cycle {
6520                        seq.min_value
6521                    } else {
6522                        // v7.39 (round 220) — PG's 2200H wording, not a
6523                        // Corrupt-classed error.
6524                        return Err(StorageError::SequenceExhausted {
6525                            name: name.into(),
6526                            limit: seq.max_value,
6527                            is_max: true,
6528                        });
6529                    }
6530                } else {
6531                    next
6532                }
6533            } else if next < seq.min_value {
6534                if seq.cycle {
6535                    seq.max_value
6536                } else {
6537                    return Err(StorageError::SequenceExhausted {
6538                        name: name.into(),
6539                        limit: seq.min_value,
6540                        is_max: false,
6541                    });
6542                }
6543            } else {
6544                next
6545            }
6546        } else {
6547            seq.last_value
6548        };
6549        seq.last_value = candidate;
6550        seq.is_called = true;
6551        Ok(candidate)
6552    }
6553
6554    /// v7.17.0 — currval. Errors if the session has never called
6555    /// nextval on this sequence (PG semantics). At the catalog
6556    /// level we approximate "session" with "is_called persisted";
6557    /// the engine session-tracking layer can wrap this for the
6558    /// strict per-session semantics later.
6559    pub fn sequence_current_value(&self, name: &str) -> Result<i64, StorageError> {
6560        let Some(seq) = self.sequences.get(name) else {
6561            return Err(StorageError::TableNotFound { name: name.into() });
6562        };
6563        if !seq.is_called {
6564            return Err(StorageError::Corrupt(format!(
6565                "currval of sequence {name:?} is not yet defined in this session"
6566            )));
6567        }
6568        Ok(seq.last_value)
6569    }
6570
6571    /// v7.17.0 — setval(name, value [, is_called]). PG returns
6572    /// `value` regardless. `is_called=true` means the NEXT
6573    /// nextval will return `value + increment`; `is_called=false`
6574    /// means the next nextval will return `value`.
6575    pub fn sequence_set_value(
6576        &mut self,
6577        name: &str,
6578        value: i64,
6579        is_called: bool,
6580    ) -> Result<i64, StorageError> {
6581        let key = self.sequence_key(name);
6582        let Some(seq) = self.sequences.get_mut(&key) else {
6583            return Err(StorageError::TableNotFound { name: name.into() });
6584        };
6585        // v7.39 (round 244) — PG refuses a value outside the sequence's
6586        // range (22003); SPG accepted it silently, leaving last_value out
6587        // of bounds.
6588        if value < seq.min_value || value > seq.max_value {
6589            return Err(StorageError::Unsupported(format!(
6590                "setval: value {value} is out of bounds for sequence \"{name}\" ({}..{})",
6591                seq.min_value, seq.max_value
6592            )));
6593        }
6594        seq.last_value = value;
6595        seq.is_called = is_called;
6596        Ok(value)
6597    }
6598
6599    /// v7.17.0 Phase 1.2 — read-only handle to catalogued views. Temp ones
6600    /// are in here under their mangled storage names; listing code filters
6601    /// through [`Self::listed_name`], and anything resolving ONE name by
6602    /// its logical spelling wants [`Self::view`].
6603    pub const fn views_all(&self) -> &BTreeMap<String, ViewDef> {
6604        &self.views
6605    }
6606
6607    /// v7.39 (round 469) — resolve one view by its logical name, the
6608    /// session's temporary one winning over a permanent one of the same
6609    /// name.
6610    #[must_use]
6611    pub fn view(&self, name: &str) -> Option<&ViewDef> {
6612        if let Some(mangled) = self.temp_name_for(name)
6613            && let Some(def) = self.views.get(&mangled)
6614        {
6615            return Some(def);
6616        }
6617        self.views.get(name)
6618    }
6619
6620    /// Does a view of this logical name exist for this session?
6621    #[must_use]
6622    pub fn has_view(&self, name: &str) -> bool {
6623        self.view(name).is_some()
6624    }
6625
6626    /// The storage key a view of this logical name resolves to.
6627    #[must_use]
6628    pub fn view_key(&self, name: &str) -> String {
6629        if let Some(mangled) = self.temp_name_for(name)
6630            && self.views.contains_key(&mangled)
6631        {
6632            return mangled;
6633        }
6634        name.into()
6635    }
6636
6637    /// v7.17.0 Phase 1.2 — install a VIEW. `or_replace=true`
6638    /// overwrites an existing entry; `if_not_exists=true` is a
6639    /// silent no-op when the name is taken. Errors if both flags
6640    /// are off and the name collides.
6641    pub fn create_view(
6642        &mut self,
6643        def: ViewDef,
6644        or_replace: bool,
6645        if_not_exists: bool,
6646    ) -> Result<(), StorageError> {
6647        if self.views.contains_key(&def.name) {
6648            if or_replace {
6649                self.mark_nontable_dirty(NonTableKind::View, &def.name);
6650                self.mark_nontable_dirty(NonTableKind::View, &def.name);
6651                self.views.insert(def.name.clone(), def);
6652                return Ok(());
6653            }
6654            if if_not_exists {
6655                return Ok(());
6656            }
6657            // v7.39 (read01 round 47) — a view is a relation to PG (42P07).
6658            return Err(StorageError::Corrupt(format!(
6659                "relation {:?} already exists",
6660                def.name
6661            )));
6662        }
6663        // Reject name collision with tables / sequences — same
6664        // namespace per PG.
6665        if self.by_name.contains_key(&def.name) {
6666            return Err(StorageError::Corrupt(format!(
6667                "view {:?} would shadow an existing table",
6668                def.name
6669            )));
6670        }
6671        if self.sequences.contains_key(&def.name) {
6672            return Err(StorageError::Corrupt(format!(
6673                "view {:?} would shadow an existing sequence",
6674                def.name
6675            )));
6676        }
6677        self.views.insert(def.name.clone(), def);
6678        Ok(())
6679    }
6680
6681    /// v7.17.0 Phase 1.2 — remove a view by name. Returns true if
6682    /// a view was removed.
6683    pub fn drop_view(&mut self, name: &str) -> bool {
6684        self.mark_nontable_dirty(NonTableKind::View, name);
6685        self.views.remove(name).is_some()
6686    }
6687
6688    /// v7.17.0 Phase 1.3 — read-only handle to the materialised-
6689    /// view source registry. Each entry pairs with a regular
6690    /// table of the same name that holds the cached rows.
6691    pub const fn materialized_views(&self) -> &BTreeMap<String, String> {
6692        &self.materialized_views
6693    }
6694
6695    /// v7.17.0 Phase 1.3 — register a source for a materialised
6696    /// view. Caller has already created the backing table.
6697    pub fn register_materialized_view(&mut self, name: String, body: String) {
6698        self.mark_nontable_dirty(NonTableKind::MaterializedView, &name);
6699        self.materialized_views.insert(name, body);
6700    }
6701
6702    /// v7.17.0 Phase 1.3 — drop the source registry entry. Returns
6703    /// true if a source was unregistered. Caller separately drops
6704    /// the backing table.
6705    pub fn drop_materialized_view_source(&mut self, name: &str) -> bool {
6706        self.mark_nontable_dirty(NonTableKind::MaterializedView, name);
6707        self.materialized_views.remove(name).is_some()
6708    }
6709
6710    /// v7.17.0 Phase 1.4 — read-only handle to user-defined ENUM
6711    /// catalog.
6712    pub const fn enum_types(&self) -> &BTreeMap<String, EnumDef> {
6713        &self.enum_types
6714    }
6715
6716    /// v7.17.0 Phase 1.4 — install a new ENUM type. Errors if
6717    /// `name` collides with an existing enum (no IF NOT EXISTS
6718    /// per PG semantics for CREATE TYPE).
6719    pub fn create_enum_type(&mut self, def: EnumDef) -> Result<(), StorageError> {
6720        if self.enum_types.contains_key(&def.name) {
6721            return Err(StorageError::Corrupt(format!(
6722                "type {:?} already exists",
6723                def.name
6724            )));
6725        }
6726        self.mark_nontable_dirty(NonTableKind::EnumType, &def.name);
6727        self.enum_types.insert(def.name.clone(), def);
6728        Ok(())
6729    }
6730
6731    /// v7.17.0 Phase 1.4 — drop an ENUM type by name. Returns
6732    /// true if a type was removed.
6733    /// v7.37 D.55 — `ALTER TYPE … ADD VALUE`. Appends `label` to an existing
6734    /// enum's ordered label list, or inserts it before/after an existing label.
6735    /// `if_not_exists` makes a duplicate a no-op; otherwise a duplicate errors.
6736    /// Returns `Ok(true)` if a label was added, `Ok(false)` if it already existed
6737    /// (only possible under `if_not_exists`).
6738    /// v7.39 (read01 round 49) — `ALTER TYPE t RENAME VALUE 'old' TO 'new'`.
6739    /// The parser used to swallow this form as a no-op, so the rename was
6740    /// accepted and silently ignored. Renaming in place keeps the label's
6741    /// sort position, which is what PG does (enumsortorder is untouched).
6742    pub fn rename_enum_value(
6743        &mut self,
6744        type_name: &str,
6745        old: &str,
6746        new: &str,
6747    ) -> Result<(), StorageError> {
6748        let def = self
6749            .enum_types
6750            .get_mut(type_name)
6751            .ok_or_else(|| StorageError::Corrupt(format!("type {type_name:?} does not exist")))?;
6752        if def.labels.iter().any(|l| l == new) {
6753            return Err(StorageError::Corrupt(format!(
6754                "enum label {new:?} already exists"
6755            )));
6756        }
6757        let at = def.labels.iter().position(|l| l == old).ok_or_else(|| {
6758            StorageError::Corrupt(format!("{old:?} is not an existing enum label"))
6759        })?;
6760        def.labels[at] = new.to_string();
6761        Ok(())
6762    }
6763
6764    /// v7.39 (read01 round 50) — set (or, with `None`, remove) the comment on
6765    /// an object. `key` is the canonical `"<kind>:<name>"` form.
6766    pub fn set_comment(&mut self, key: &str, text: Option<&str>) {
6767        match text {
6768            Some(t) => {
6769                self.comments.insert(key.to_string(), t.to_string());
6770            }
6771            None => {
6772                self.comments.remove(key);
6773            }
6774        }
6775    }
6776
6777    /// v7.39 (read01 round 50) — the comment on an object, if any.
6778    #[must_use]
6779    pub fn comment(&self, key: &str) -> Option<&str> {
6780        self.comments.get(key).map(String::as_str)
6781    }
6782
6783    /// v7.39 (round 547) — record a GUC default for a scope. An empty
6784    /// database or role name is PG's oid 0 ("all"). `None` value
6785    /// removes just that parameter, as PG's RESET does.
6786    pub fn set_db_role_setting(
6787        &mut self,
6788        database: &str,
6789        role: &str,
6790        param: &str,
6791        value: Option<&str>,
6792    ) {
6793        let key = (database.to_string(), role.to_string());
6794        match value {
6795            Some(v) => {
6796                self.db_role_settings
6797                    .entry(key)
6798                    .or_default()
6799                    .insert(param.to_ascii_lowercase(), v.to_string());
6800            }
6801            None => {
6802                if let Some(m) = self.db_role_settings.get_mut(&key) {
6803                    m.remove(&param.to_ascii_lowercase());
6804                    if m.is_empty() {
6805                        self.db_role_settings.remove(&key);
6806                    }
6807                }
6808            }
6809        }
6810    }
6811
6812    /// v7.39 (round 550) — create a replication slot. `Err` carries
6813    /// PG's own message for a duplicate.
6814    ///
6815    /// # Errors
6816    /// When a slot of that name already exists.
6817    pub fn create_replication_slot(
6818        &mut self,
6819        name: &str,
6820        plugin: &str,
6821        slot_type: &str,
6822    ) -> Result<(), String> {
6823        if self.replication_slots.contains_key(name) {
6824            return Err(alloc::format!("replication slot \"{name}\" already exists"));
6825        }
6826        self.replication_slots.insert(
6827            name.to_string(),
6828            (plugin.to_string(), slot_type.to_string()),
6829        );
6830        Ok(())
6831    }
6832
6833    /// # Errors
6834    /// When no slot of that name exists — PG's message, and the case
6835    /// that used to report success.
6836    pub fn drop_replication_slot(&mut self, name: &str) -> Result<(), String> {
6837        if self.replication_slots.remove(name).is_none() {
6838            return Err(alloc::format!("replication slot \"{name}\" does not exist"));
6839        }
6840        Ok(())
6841    }
6842
6843    #[must_use]
6844    /// v7.38.18 (S1) — the collation this database was created with.
6845    /// `"C"` when nothing was recorded, which is what an older catalog
6846    /// and a default `initdb`-less start both mean.
6847    pub fn db_collation(&self) -> &str {
6848        self.db_collation.as_deref().unwrap_or("C")
6849    }
6850
6851    /// Record the creation collation. Refused once one is set, because
6852    /// every index key already in this database was built under it —
6853    /// the same refusal PostgreSQL gives `ALTER DATABASE … LC_COLLATE`,
6854    /// and for the same reason.
6855    ///
6856    /// `Ok(false)` when the value asked for is the one already in force,
6857    /// so a host that passes its environment on every start is not an
6858    /// error.
6859    pub fn set_db_collation(&mut self, name: &str) -> Result<bool, StorageError> {
6860        if self.db_collation.as_deref() == Some(name) {
6861            return Ok(false);
6862        }
6863        if self.db_collation.is_none() && name.eq_ignore_ascii_case("C") {
6864            return Ok(false);
6865        }
6866        if self.db_collation.is_some() || !self.tables.is_empty() {
6867            return Err(StorageError::Corrupt(format!(
6868                "database collation is already {:?} and cannot be changed; \
6869                 PostgreSQL refuses this too, because every index key here \
6870                 was built under it",
6871                self.db_collation()
6872            )));
6873        }
6874        self.db_collation = Some(name.into());
6875        Ok(true)
6876    }
6877
6878    /// The user said so, in SQL: `CREATE DATABASE … LC_COLLATE 'x'`.
6879    ///
6880    /// Differs from [`Self::set_db_collation`] in one way, and the
6881    /// difference is the whole point: this REPLACES a collation the
6882    /// database already has, as long as no table has been created yet.
6883    /// The refusal in `set_db_collation` exists because index keys were
6884    /// built under the old collation — with no tables, none were.
6885    ///
6886    /// The case it is for: a server stamps the container's `LANG` on a
6887    /// fresh database at startup, and the customer's bootstrap script
6888    /// then says `CREATE DATABASE app LC_COLLATE 'de_DE.utf8'`. What the
6889    /// script asked for beats what the container happened to export.
6890    ///
6891    /// `Ok(false)` when a table already exists — the caller warns rather
6892    /// than failing, because PostgreSQL would have made a SEPARATE
6893    /// database here and returned success, and failing a bootstrap
6894    /// script is a customer change.
6895    pub fn declare_db_collation(&mut self, name: &str) -> bool {
6896        if self.db_collation.as_deref() == Some(name) {
6897            return true;
6898        }
6899        if !self.tables.is_empty() {
6900            return false;
6901        }
6902        self.db_collation = Some(name.into());
6903        true
6904    }
6905
6906    /// Record a name a `CREATE DATABASE` asked for; `true` when new.
6907    pub fn record_created_database(&mut self, name: &str) -> bool {
6908        self.created_databases.insert(name.to_string())
6909    }
6910
6911    /// The names `CREATE DATABASE` has been asked for.
6912    pub const fn created_databases(&self) -> &alloc::collections::BTreeSet<String> {
6913        &self.created_databases
6914    }
6915
6916    pub const fn replication_slots(&self) -> &BTreeMap<String, (String, String)> {
6917        &self.replication_slots
6918    }
6919
6920    /// PG's RESET ALL: drops this scope's whole entry, leaving the
6921    /// other scopes alone — measured on PG18, where `ALTER ROLE r RESET
6922    /// ALL` left the ALL, the database and the role-in-database rows.
6923    pub fn reset_db_role_settings(&mut self, database: &str, role: &str) {
6924        self.db_role_settings
6925            .remove(&(database.to_string(), role.to_string()));
6926    }
6927
6928    #[must_use]
6929    pub const fn db_role_settings(&self) -> &BTreeMap<(String, String), BTreeMap<String, String>> {
6930        &self.db_role_settings
6931    }
6932
6933    /// v7.39 (read01 round 50) — every `(key, text)` pair, for the
6934    /// pg_description view.
6935    #[must_use]
6936    pub const fn comments(&self) -> &BTreeMap<String, String> {
6937        &self.comments
6938    }
6939
6940    /// v7.39 (read01 round 50) — drop every comment whose key names `obj`
6941    /// (the object itself and, for a table, its columns). Called when the
6942    /// object is dropped so a later object of the same name doesn't inherit
6943    /// a stale comment.
6944    pub fn drop_comments_for(&mut self, kind: &str, name: &str) {
6945        let exact = alloc::format!("{kind}:{name}");
6946        let col_prefix = alloc::format!("column:{name}.");
6947        self.comments
6948            .retain(|k, _| *k != exact && !k.starts_with(&col_prefix));
6949    }
6950
6951    pub fn add_enum_value(
6952        &mut self,
6953        type_name: &str,
6954        label: &str,
6955        if_not_exists: bool,
6956        position: Option<(bool, String)>,
6957    ) -> Result<bool, StorageError> {
6958        self.mark_nontable_dirty(NonTableKind::EnumType, type_name);
6959        let def = self
6960            .enum_types
6961            .get_mut(type_name)
6962            .ok_or_else(|| StorageError::Corrupt(format!("type {type_name:?} does not exist")))?;
6963        if def.labels.iter().any(|l| l == label) {
6964            if if_not_exists {
6965                return Ok(false);
6966            }
6967            // v7.39 (read01 round 49) — PG wording (42710 at the wire).
6968            return Err(StorageError::Corrupt(format!(
6969                "enum label {label:?} already exists"
6970            )));
6971        }
6972        match position {
6973            None => def.labels.push(label.to_string()),
6974            Some((is_before, anchor)) => {
6975                let at = def
6976                    .labels
6977                    .iter()
6978                    .position(|l| l == &anchor)
6979                    .ok_or_else(|| {
6980                        StorageError::Corrupt(format!(
6981                            "enum label {anchor:?} does not exist in type {type_name:?}"
6982                        ))
6983                    })?;
6984                let idx = if is_before { at } else { at + 1 };
6985                def.labels.insert(idx, label.to_string());
6986            }
6987        }
6988        Ok(true)
6989    }
6990
6991    pub fn drop_enum_type(&mut self, name: &str) -> bool {
6992        self.mark_nontable_dirty(NonTableKind::EnumType, name);
6993        self.enum_types.remove(name).is_some()
6994    }
6995
6996    /// v7.17.0 Phase 1.5 — read-only handle to DOMAIN catalog.
6997    pub const fn domain_types(&self) -> &BTreeMap<String, DomainDef> {
6998        &self.domain_types
6999    }
7000
7001    /// v7.17.0 Phase 1.5 — install a DOMAIN. Errors on collision
7002    /// with an existing domain.
7003    pub fn create_domain_type(&mut self, def: DomainDef) -> Result<(), StorageError> {
7004        if self.domain_types.contains_key(&def.name) {
7005            return Err(StorageError::Corrupt(format!(
7006                "domain {:?} already exists",
7007                def.name
7008            )));
7009        }
7010        self.mark_nontable_dirty(NonTableKind::DomainType, &def.name);
7011        self.domain_types.insert(def.name.clone(), def);
7012        Ok(())
7013    }
7014
7015    /// v7.17.0 Phase 1.5 — drop a DOMAIN by name.
7016    pub fn drop_domain_type(&mut self, name: &str) -> bool {
7017        self.mark_nontable_dirty(NonTableKind::DomainType, name);
7018        self.domain_types.remove(name).is_some()
7019    }
7020
7021    /// v7.37.42-T2 ζ-B — read-only handle to user-defined COMPOSITE
7022    /// catalog. Used by the engine to resolve
7023    /// `ColumnSchema.user_composite_type` lookups + by
7024    /// information_schema-style introspection.
7025    pub const fn composite_types(&self) -> &BTreeMap<String, CompositeDef> {
7026        &self.composite_types
7027    }
7028
7029    /// v7.37.42-T2 ζ-B — install a new COMPOSITE type. Errors if
7030    /// `name` already exists in the composite registry (PG forbids
7031    /// IF NOT EXISTS on CREATE TYPE composite; the engine surfaces
7032    /// the collision with the existing name).
7033    pub fn create_composite_type(&mut self, def: CompositeDef) -> Result<(), StorageError> {
7034        if self.composite_types.contains_key(&def.name) {
7035            return Err(StorageError::Corrupt(format!(
7036                "type {:?} already exists",
7037                def.name
7038            )));
7039        }
7040        self.mark_nontable_dirty(NonTableKind::CompositeType, &def.name);
7041        self.composite_types.insert(def.name.clone(), def);
7042        Ok(())
7043    }
7044
7045    /// v7.37.42-T2 ζ-B — drop a COMPOSITE type by name. Returns
7046    /// true if a type was removed.
7047    pub fn drop_composite_type(&mut self, name: &str) -> bool {
7048        self.mark_nontable_dirty(NonTableKind::CompositeType, name);
7049        self.composite_types.remove(name).is_some()
7050    }
7051
7052    /// v7.17.0 Phase 1.6 — read-only handle to the user-created
7053    /// schema registry. Built-in schemas (`public`, `pg_catalog`,
7054    /// `information_schema`) are NOT included here; use
7055    /// [`schema_exists`](Self::schema_exists) for the full
7056    /// check.
7057    pub const fn user_schemas(&self) -> &alloc::collections::BTreeSet<String> {
7058        &self.schemas
7059    }
7060
7061    /// v7.17.0 Phase 1.6 — schema-name resolver. Returns true
7062    /// for built-in schemas + every user-CREATEd one. Used by
7063    /// CREATE SCHEMA collision checks and (future) by
7064    /// information_schema.schemata.
7065    pub fn schema_exists(&self, name: &str) -> bool {
7066        is_builtin_schema(name) || self.schemas.contains(name)
7067    }
7068
7069    /// v7.17.0 Phase 1.6 — register a new schema. Errors if the
7070    /// name already exists and `if_not_exists=false`. Built-in
7071    /// names cannot be redeclared.
7072    pub fn create_schema(&mut self, name: String, if_not_exists: bool) -> Result<(), StorageError> {
7073        if is_builtin_schema(&name) {
7074            if if_not_exists {
7075                return Ok(());
7076            }
7077            return Err(StorageError::Corrupt(format!(
7078                "schema {name:?} is built-in and cannot be redeclared"
7079            )));
7080        }
7081        if self.schemas.contains(&name) {
7082            if if_not_exists {
7083                return Ok(());
7084            }
7085            return Err(StorageError::Corrupt(format!(
7086                "schema {name:?} already exists"
7087            )));
7088        }
7089        self.schemas.insert(name);
7090        Ok(())
7091    }
7092
7093    /// v7.17.0 Phase 1.6 — drop a user-created schema. Returns
7094    /// true if a schema was removed. Built-in names always
7095    /// return false (cannot be dropped). Tables that previously
7096    /// used the schema as a prefix keep their bare name and stay
7097    /// queryable — this is the "prefix routing, not isolation"
7098    /// posture documented in v7.17 Phase 1.6.
7099    pub fn drop_schema(&mut self, name: &str) -> Result<bool, StorageError> {
7100        if is_builtin_schema(name) {
7101            return Err(StorageError::Corrupt(format!(
7102                "schema {name:?} is built-in and cannot be dropped"
7103            )));
7104        }
7105        Ok(self.schemas.remove(name))
7106    }
7107
7108    /// v7.17.0 — ALTER SEQUENCE option merge. Caller-provided
7109    /// updates overwrite the matching fields; unset fields keep
7110    /// their stored values. RESTART variants update last_value
7111    /// directly per PG: `RESTART` resets to current `start`;
7112    /// `RESTART WITH n` resets to `n`.
7113    #[allow(clippy::too_many_arguments)]
7114    pub fn alter_sequence(
7115        &mut self,
7116        name: &str,
7117        increment: Option<i64>,
7118        min_value: Option<i64>,
7119        max_value: Option<i64>,
7120        start: Option<i64>,
7121        restart: Option<Option<i64>>,
7122        cache: Option<i64>,
7123        cycle: Option<bool>,
7124        owned_by: Option<Option<(String, String)>>,
7125    ) -> Result<(), StorageError> {
7126        self.mark_nontable_dirty(NonTableKind::Sequence, name);
7127        let Some(seq) = self.sequences.get_mut(name) else {
7128            return Err(StorageError::TableNotFound { name: name.into() });
7129        };
7130        if let Some(v) = increment {
7131            seq.increment = v;
7132        }
7133        if let Some(v) = min_value {
7134            seq.min_value = v;
7135        }
7136        if let Some(v) = max_value {
7137            seq.max_value = v;
7138        }
7139        if let Some(v) = start {
7140            seq.start = v;
7141        }
7142        if let Some(restart_value) = restart {
7143            seq.last_value = restart_value.unwrap_or(seq.start);
7144            seq.is_called = false;
7145        }
7146        if let Some(v) = cache {
7147            seq.cache = v;
7148        }
7149        if let Some(v) = cycle {
7150            seq.cycle = v;
7151        }
7152        if let Some(v) = owned_by {
7153            seq.owned_by = v;
7154        }
7155        Ok(())
7156    }
7157
7158    /// v7.12.4 — read-only slice of all catalogued triggers.
7159    /// Engine row-write paths filter this by (table, event,
7160    /// timing) and fire matches in slice order.
7161    pub fn triggers(&self) -> &[TriggerDef] {
7162        &self.triggers
7163    }
7164
7165    /// v7.15.0 — mutable handle to the trigger slice for
7166    /// `ALTER TABLE … RENAME COLUMN`, which rewrites every
7167    /// `update_columns` entry that referenced the renamed
7168    /// column.
7169    pub fn triggers_mut(&mut self) -> &mut Vec<TriggerDef> {
7170        &mut self.triggers
7171    }
7172
7173    /// v7.12.4 — register a new trigger. With `or_replace = false`,
7174    /// errors when a trigger with the same name already exists on
7175    /// the same table (PG scoping rule — trigger names are
7176    /// per-table, not global). Trigger function must already
7177    /// exist in the catalog at registration time.
7178    pub fn create_trigger(
7179        &mut self,
7180        def: TriggerDef,
7181        or_replace: bool,
7182    ) -> Result<(), StorageError> {
7183        // v7.39 (round 137) — a trigger may target a base table (BEFORE / AFTER)
7184        // or a view (INSTEAD OF). The engine enforces the timing↔target rule;
7185        // storage only requires the relation to exist as one or the other.
7186        if !self.by_name.contains_key(&def.table) && !self.views.contains_key(&def.table) {
7187            return Err(StorageError::TableNotFound {
7188                name: def.table.clone(),
7189            });
7190        }
7191        // v7.39 (read01 round 62) — functions are keyed by SIGNATURE now. A
7192        // trigger names its function by NAME (a trigger function takes no
7193        // arguments), so the existence check goes through the name index.
7194        if self.functions_named(&def.function).is_empty() {
7195            // v7.39 (round 710) — PG's wording: the FUNCTION is what does
7196            // not exist (`function nosuch_fn() does not exist`), and the
7197            // old message rode `Corrupt`'s on-disk banner besides.
7198            return Err(StorageError::Corrupt(format!(
7199                "function {}() does not exist",
7200                def.function
7201            )));
7202        }
7203        let dup = self
7204            .triggers
7205            .iter()
7206            .position(|t| t.name == def.name && t.table == def.table);
7207        match (dup, or_replace) {
7208            (Some(_), false) => Err(StorageError::Corrupt(format!(
7209                "trigger {:?} already exists on table {:?}",
7210                def.name, def.table
7211            ))),
7212            (Some(i), true) => {
7213                self.triggers[i] = def;
7214                Ok(())
7215            }
7216            (None, _) => {
7217                self.triggers.push(def);
7218                Ok(())
7219            }
7220        }
7221    }
7222
7223    /// v7.12.4 — remove a trigger by `(name, table)`. Returns
7224    /// `true` if one was removed.
7225    pub fn drop_trigger(&mut self, name: &str, table: &str) -> bool {
7226        let before = self.triggers.len();
7227        self.triggers
7228            .retain(|t| !(t.name == name && t.table == table));
7229        before != self.triggers.len()
7230    }
7231
7232    /// v7.39 (round 139) — the catalogued query-rewrite RULEs.
7233    pub fn rules(&self) -> &[RuleDef] {
7234        &self.rules
7235    }
7236
7237    /// v7.39 (round 280) — the catalogued extended-statistics objects.
7238    #[must_use]
7239    pub fn statistics_ext(&self) -> &[StatisticsExtDef] {
7240        &self.statistics_ext
7241    }
7242
7243    /// v7.39 (round 287) — every large object, ascending by OID.
7244    #[must_use]
7245    pub fn large_objects(&self) -> &alloc::collections::BTreeMap<u32, Vec<u8>> {
7246        &self.large_objects
7247    }
7248
7249    /// The bytes of one large object, or `None` when no such OID exists.
7250    #[must_use]
7251    pub fn large_object(&self, oid: u32) -> Option<&[u8]> {
7252        self.large_objects.get(&oid).map(Vec::as_slice)
7253    }
7254
7255    /// Create a large object. `oid` of 0 means "pick one" — PG's
7256    /// `lo_create(0)` / `lo_creat(-1)` spelling. Errors when the
7257    /// requested OID is taken.
7258    pub fn create_large_object(&mut self, oid: u32, bytes: Vec<u8>) -> Result<u32, String> {
7259        let id = if oid == 0 {
7260            self.next_large_object_oid()
7261        } else {
7262            oid
7263        };
7264        if self.large_objects.contains_key(&id) {
7265            return Err(format!("large object {id} already exists"));
7266        }
7267        self.large_objects.insert(id, bytes);
7268        Ok(id)
7269    }
7270
7271    /// Overwrite `len` bytes at `offset` (0-based), growing the object
7272    /// with zero bytes if the write starts past the end — PG's
7273    /// `lo_put` semantics.
7274    pub fn put_large_object(&mut self, oid: u32, offset: usize, data: &[u8]) -> Result<(), String> {
7275        let Some(buf) = self.large_objects.get_mut(&oid) else {
7276            return Err(format!("large object {oid} does not exist"));
7277        };
7278        let end = offset.saturating_add(data.len());
7279        if buf.len() < end {
7280            buf.resize(end, 0);
7281        }
7282        buf[offset..end].copy_from_slice(data);
7283        Ok(())
7284    }
7285
7286    /// v7.39 (round 306) — `lo_truncate`. PG's truncate sets the object
7287    /// to exactly `len` bytes in BOTH directions: it shortens, and it
7288    /// GROWS with zero fill when `len` exceeds the current size
7289    /// (measured — `lo_truncate(fd, 8)` over a 4-byte object leaves
7290    /// eight bytes, the last four zero).
7291    pub fn truncate_large_object(&mut self, oid: u32, len: usize) -> Result<(), String> {
7292        let Some(buf) = self.large_objects.get_mut(&oid) else {
7293            return Err(format!("large object {oid} does not exist"));
7294        };
7295        buf.resize(len, 0);
7296        Ok(())
7297    }
7298
7299    /// Remove a large object. `false` when the OID was not there.
7300    pub fn unlink_large_object(&mut self, oid: u32) -> bool {
7301        self.large_objects.remove(&oid).is_some()
7302    }
7303
7304    /// The next free OID in PG's user band.
7305    /// v7.39 (round 343, V40) — large objects have their own oid band.
7306    /// It used to start at 16_384, which is where user TABLES start, so
7307    /// the first large object and the first table shared an oid — and
7308    /// `pg_largeobject_metadata.oid` is joinable against `pg_class.oid`,
7309    /// so a join across them matched a row that has nothing to do with
7310    /// it. (PG cannot collide: every oid there comes off one counter.)
7311    /// An object already stored keeps the oid it was given; only new
7312    /// ones land in the band.
7313    fn next_large_object_oid(&self) -> u32 {
7314        self.large_objects
7315            .keys()
7316            .next_back()
7317            .map_or(500_000, |m| m.saturating_add(1))
7318    }
7319
7320    /// Register one. `Err(name)` when the name is taken.
7321    pub fn create_statistics_ext(&mut self, def: StatisticsExtDef) -> Result<(), String> {
7322        if self.statistics_ext.iter().any(|s| s.name == def.name) {
7323            return Err(def.name);
7324        }
7325        self.statistics_ext.push(def);
7326        Ok(())
7327    }
7328
7329    /// Drop one by name; false when absent.
7330    pub fn drop_statistics_ext(&mut self, name: &str) -> bool {
7331        let before = self.statistics_ext.len();
7332        self.statistics_ext.retain(|s| s.name != name);
7333        before != self.statistics_ext.len()
7334    }
7335
7336    /// v7.39 (round 139) — register a RULE. Its target relation (table or view)
7337    /// must exist; `or_replace` overwrites a same-(name,table) rule.
7338    pub fn create_rule(&mut self, def: RuleDef, or_replace: bool) -> Result<(), StorageError> {
7339        if !self.by_name.contains_key(&def.table) && !self.views.contains_key(&def.table) {
7340            return Err(StorageError::TableNotFound {
7341                name: def.table.clone(),
7342            });
7343        }
7344        let dup = self
7345            .rules
7346            .iter()
7347            .position(|r| r.name == def.name && r.table == def.table);
7348        match (dup, or_replace) {
7349            (Some(_), false) => Err(StorageError::Corrupt(format!(
7350                "rule {:?} for relation {:?} already exists",
7351                def.name, def.table
7352            ))),
7353            (Some(i), true) => {
7354                self.rules[i] = def;
7355                Ok(())
7356            }
7357            (None, _) => {
7358                self.rules.push(def);
7359                Ok(())
7360            }
7361        }
7362    }
7363
7364    /// v7.39 (round 139) — drop a RULE by `(name, table)`.
7365    pub fn drop_rule(&mut self, name: &str, table: &str) -> bool {
7366        let before = self.rules.len();
7367        self.rules.retain(|r| !(r.name == name && r.table == table));
7368        before != self.rules.len()
7369    }
7370
7371    pub fn create_table(&mut self, schema: TableSchema) -> Result<(), StorageError> {
7372        if self.by_name.contains_key(&schema.name) {
7373            return Err(StorageError::DuplicateTable {
7374                name: schema.name.clone(),
7375            });
7376        }
7377        let idx = self.tables.len();
7378        let name = schema.name.clone();
7379        let mut t = Table::new(schema);
7380        // v7.38.18 (S2) — the table inherits the database's collation,
7381        // which is what its undeclared text columns compare under.
7382        t.set_db_collation(self.db_collation());
7383        self.tables.push(t);
7384        self.by_name.insert(name.clone(), idx);
7385        // v7.39 (round 496) — see `dirty_tables`.
7386        self.dirty_tables.insert(name);
7387        // v7.37.15 (Phase C.1) — stamp the new relation with a stable,
7388        // monotonic, never-reused RelId. Pre-increment so ids start at
7389        // 1 (0 = UNASSIGNED); a later DROP TABLE frees the slot but not
7390        // the id.
7391        self.next_rel_id += 1;
7392        let rid = row_header::RelId(self.next_rel_id);
7393        self.tables[idx].set_rel_id(rid);
7394        Ok(())
7395    }
7396
7397    /// v7.39 (round 436) — the session's temporary table of this name wins
7398    /// over a permanent one, as `pg_temp` does in PG's search path and as
7399    /// MySQL's TEMPORARY shadowing does. Every name → index resolution in
7400    /// this catalog goes through here.
7401    fn resolve_index(&self, name: &str) -> Option<usize> {
7402        if let Some(prefix) = &self.temp_prefix {
7403            let mut mangled = String::with_capacity(prefix.len() + name.len());
7404            mangled.push_str(prefix);
7405            mangled.push_str(name);
7406            if let Some(idx) = self.by_name.get(&mangled) {
7407                return Some(*idx);
7408            }
7409            if self.case_insensitive_names
7410                && let Some(idx) = self.index_ignoring_case(&mangled)
7411            {
7412                return Some(idx);
7413            }
7414        }
7415        if let Some(idx) = self.by_name.get(name) {
7416            return Some(*idx);
7417        }
7418        // v7.39.2 — a MySQL session finds the relation under any
7419        // spelling of its name.
7420        //
7421        // The lexer folds an unquoted identifier and leaves a backticked
7422        // one alone, so `CREATE TABLE MyTable` stored `mytable` while
7423        // ``SELECT 1 FROM `MyTable` `` looked for `MyTable` and found
7424        // nothing: the two spellings of one name were two tables.
7425        // `mysqldump` backticks every identifier, so a dump restored
7426        // here and an application that writes the name unquoted were
7427        // looking at different relations.
7428        //
7429        // This is MySQL's `lower_case_table_names = 1` — names compare
7430        // without case — which is what SPG has always half-done, and
7431        // what it now reports. Exact match first, so a catalog that
7432        // already holds two names differing only in case keeps
7433        // answering the way it did.
7434        //
7435        // PostgreSQL sessions never set this: `"MyTable"` and `mytable`
7436        // are two relations there, and the flag is off.
7437        if self.case_insensitive_names {
7438            return self.index_ignoring_case(name);
7439        }
7440        None
7441    }
7442
7443    /// The single relation whose name matches `name` without regard to
7444    /// case, or `None` when there is none — or more than one, which the
7445    /// exact lookup above has already failed to settle.
7446    fn index_ignoring_case(&self, name: &str) -> Option<usize> {
7447        let mut found = None;
7448        for (k, idx) in &self.by_name {
7449            if k.len() == name.len() && k.eq_ignore_ascii_case(name) {
7450                if found.is_some() {
7451                    return None;
7452                }
7453                found = Some(*idx);
7454            }
7455        }
7456        found
7457    }
7458
7459    /// v7.39.2 — does this session compare relation names without case?
7460    ///
7461    /// Per SESSION, and the catalog is shared, so the engine installs it
7462    /// the way it installs `temp_prefix`: on every session switch, into
7463    /// the main catalog and into every open transaction's shadow.
7464    pub fn set_case_insensitive_names(&mut self, on: bool) {
7465        self.case_insensitive_names = on;
7466    }
7467
7468    /// v7.39 (round 436) — install the calling session's temp namespace.
7469    /// `None` disables temp resolution entirely (a session that never made
7470    /// one pays a single `Option` check per lookup).
7471    pub fn set_temp_prefix(&mut self, prefix: Option<String>) {
7472        self.temp_prefix = prefix;
7473    }
7474
7475    /// The mangled storage name a temp table of `name` takes in this
7476    /// session, or `None` when the session has no temp namespace.
7477    #[must_use]
7478    pub fn temp_name_for(&self, name: &str) -> Option<String> {
7479        self.temp_prefix
7480            .as_ref()
7481            .map(|p| alloc::format!("{p}{name}"))
7482    }
7483
7484    pub fn get(&self, name: &str) -> Option<&Table> {
7485        let idx = self.resolve_index(name)?;
7486        self.tables.get(idx)
7487    }
7488
7489    pub fn get_mut(&mut self, name: &str) -> Option<&mut Table> {
7490        let idx = self.resolve_index(name)?;
7491        // v7.39 (round 496) — the choke point for changing a table, so the
7492        // record is taken here. Over-approximate on purpose: a caller that
7493        // takes the handle and writes nothing merely carries that table
7494        // through a commit, which is the old behaviour.
7495        let recorded = self.tables.get(idx).map(|t| t.schema().name.clone());
7496        if let Some(n) = recorded {
7497            self.dirty_tables.insert(n);
7498        }
7499        self.tables.get_mut(idx)
7500    }
7501
7502    /// v7.39 (round 496) — the tables changed through this handle since
7503    /// [`Self::clear_dirty_tables`]. See `dirty_tables`.
7504    #[must_use]
7505    pub fn dirty_tables(&self) -> &alloc::collections::BTreeSet<String> {
7506        &self.dirty_tables
7507    }
7508
7509    /// r1059 — mark one table dirty without taking its handle. The
7510    /// rebase/merge paths replace a tx's shadow with a fresh base
7511    /// clone and must carry the tx's OWN dirty window across (the
7512    /// base's set is an ever-growing history, never cleared).
7513    pub fn mark_table_dirty(&mut self, name: &str) {
7514        self.dirty_tables.insert(name.into());
7515    }
7516
7517    /// v7.39 (round 496) — start a fresh recording window. A transaction's
7518    /// shadow calls this at BEGIN so the set means "changed by this tx".
7519    /// 7.38.1 S3.1 — one window covers both records (tables and the
7520    /// non-table families).
7521    pub fn clear_dirty_tables(&mut self) {
7522        self.dirty_tables.clear();
7523        self.dirty_nontable.clear();
7524    }
7525
7526    /// 7.38.1 S3.1 (D4) — record a non-table object as changed by this
7527    /// window. Called from every create/alter/rename/drop of the six
7528    /// [`NonTableKind`] families; a rename records BOTH names.
7529    fn mark_nontable_dirty(&mut self, kind: NonTableKind, name: &str) {
7530        self.dirty_nontable.insert((kind, name.into()));
7531    }
7532
7533    /// 7.38.1 S3.1 (D4) — reconcile the six non-table families with
7534    /// `base` (the latest committed catalog): every entry this window
7535    /// did NOT touch is taken from base — existence, definition and
7536    /// absence alike — so a neighbour's CREATE / ALTER / DROP of a
7537    /// sequence, view, matview, enum, domain or composite type
7538    /// survives a poisoned transaction's COMMIT. Entries this window
7539    /// DID touch keep the shadow's version (the tx's own DDL wins its
7540    /// own objects, exactly like the dirty-table merge above it).
7541    pub fn merge_nontable_objects_from(&mut self, base: &Catalog) {
7542        use NonTableKind as K;
7543        fn merge_map<V: Clone>(
7544            kind: NonTableKind,
7545            dirty: &alloc::collections::BTreeSet<(NonTableKind, String)>,
7546            mine: &mut BTreeMap<String, V>,
7547            theirs: &BTreeMap<String, V>,
7548        ) {
7549            let names: alloc::vec::Vec<String> =
7550                mine.keys().chain(theirs.keys()).cloned().collect();
7551            for n in names {
7552                if dirty.contains(&(kind, n.clone())) {
7553                    continue;
7554                }
7555                match theirs.get(&n) {
7556                    Some(v) => {
7557                        mine.insert(n, v.clone());
7558                    }
7559                    None => {
7560                        mine.remove(&n);
7561                    }
7562                }
7563            }
7564        }
7565        let dirty = self.dirty_nontable.clone();
7566        merge_map(K::Sequence, &dirty, &mut self.sequences, &base.sequences);
7567        merge_map(K::View, &dirty, &mut self.views, &base.views);
7568        merge_map(
7569            K::MaterializedView,
7570            &dirty,
7571            &mut self.materialized_views,
7572            &base.materialized_views,
7573        );
7574        merge_map(K::EnumType, &dirty, &mut self.enum_types, &base.enum_types);
7575        merge_map(
7576            K::DomainType,
7577            &dirty,
7578            &mut self.domain_types,
7579            &base.domain_types,
7580        );
7581        merge_map(
7582            K::CompositeType,
7583            &dirty,
7584            &mut self.composite_types,
7585            &base.composite_types,
7586        );
7587    }
7588
7589    /// v7.39 (round 496) — put `table` in at `name`, replacing any table
7590    /// already there and keeping the rest of the catalog untouched.
7591    ///
7592    /// The commit-time table-granularity merge needs exactly this: take
7593    /// the latest committed catalog, then overwrite only the tables the
7594    /// transaction changed.
7595    pub fn install_table(&mut self, name: &str, table: Table) {
7596        match self.by_name.get(name).copied() {
7597            Some(idx) => self.tables[idx] = table,
7598            None => {
7599                let idx = self.tables.len();
7600                self.tables.push(table);
7601                self.by_name.insert(name.into(), idx);
7602            }
7603        }
7604        self.dirty_tables.insert(name.into());
7605    }
7606
7607    /// v7.37.42 (docker-fair SCALARSQ attack) — resolve a table name to
7608    /// its insertion-order index ONCE, so callers that need to fetch the
7609    /// same table many times (per-row PK probes in correlated scalar
7610    /// subqueries) can avoid the per-call `BTreeMap<String, usize>` string
7611    /// descent. The returned index is stable for the lifetime of the
7612    /// catalog snapshot the caller holds (same engine read guard).
7613    pub fn tables_position_of(&self, name: &str) -> Option<usize> {
7614        self.resolve_index(name)
7615    }
7616
7617    /// Direct positional fetch counterpart to [`tables_position_of`].
7618    /// `idx` must come from `tables_position_of` against the same catalog
7619    /// snapshot — out-of-range returns `None`.
7620    pub fn tables_at(&self, idx: usize) -> Option<&Table> {
7621        self.tables.get(idx)
7622    }
7623
7624    /// v7.34 (crash-recovery P0 #2) — replay a row-level redo log onto
7625    /// this catalog (the [`RowChange`] physical-redo apply primitive that
7626    /// row-level WAL recovery will use in place of statement re-execution).
7627    /// Applies each change in order via the same `Table` mutators the
7628    /// engine used — no uniqueness/FK/parse/plan: the original execution
7629    /// already validated, replay trusts and applies. Positions are
7630    /// physical and only valid when replayed from the matching checkpoint
7631    /// baseline in original order (see [`RowChange`] docs).
7632    ///
7633    /// A change naming an absent table, or whose position is out of range,
7634    /// is a corrupt/misaligned log and surfaces as an error rather than a
7635    /// silent skip.
7636    pub fn apply_redo(&mut self, changes: &[RowChange]) -> Result<(), StorageError> {
7637        // v7.37.5 (mailrs crash-recovery Ask 3) — true batched replay.
7638        // Pre-v7.37.5 each `RowChange::Delete` record ran a fresh
7639        // O(N) PersistentVec rebuild + O(N × indices × log N)
7640        // `rebuild_indices()` — 5000 records × 100k rows × 13 indices
7641        // ≈ 27 min on the mailrs prod-shape WAL.
7642        //
7643        // The strategy: group consecutive changes by table, and for
7644        // each run, compose all the row-level mutations through a
7645        // single "live" tracking vector + a per-table operation log,
7646        // then apply rows + indices ONCE at the end. The result:
7647        //  - DELETE blow-up: O(records × rows × indices × log rows)
7648        //    → O(rows × indices × log rows) — one rebuild per run.
7649        //  - Row-position semantics preserved: positions in a later
7650        //    `Delete` / `Update` record reference the layout produced
7651        //    by every earlier change; we walk the live-vector
7652        //    forward as each change is processed so positions
7653        //    translate correctly to the ORIGINAL row index space.
7654        //
7655        // For correctness, even with this batching `apply_redo`
7656        // remains in-order: a single per-table run only batches
7657        // a contiguous slice of changes targeting that table; a
7658        // mid-run change targeting a DIFFERENT table forces a
7659        // flush of the current run.
7660        let mut runs: alloc::vec::Vec<(String, alloc::vec::Vec<&RowChange>)> =
7661            alloc::vec::Vec::new();
7662        for change in changes {
7663            // v7.39 (flip crash-replay P0) — a replayed tombstone carries
7664            // the xmax the CRASHED process allocated, but this process's
7665            // version cursor restarted; without advancing it past every
7666            // replayed version, `Snapshot::visible`'s "deletion is in the
7667            // future" branch (xmax > snapshot.version) resurrects every
7668            // replayed delete. Same recovery contract as the snapshot
7669            // loader (`observe_persisted_version`, the pg_control-style
7670            // nextXid recovery).
7671            if let RowChange::Tombstone { xmax, .. } = change {
7672                row_header::observe_persisted_version(*xmax);
7673            }
7674            let table = match change {
7675                RowChange::Insert { table, .. }
7676                | RowChange::Update { table, .. }
7677                | RowChange::Delete { table, .. }
7678                | RowChange::Tombstone { table, .. } => table.clone(),
7679            };
7680            if runs.last().map(|(t, _)| t.as_str()) != Some(table.as_str()) {
7681                runs.push((table, alloc::vec::Vec::new()));
7682            }
7683            runs.last_mut().unwrap().1.push(change);
7684        }
7685        for (table_name, run) in runs {
7686            self.apply_redo_run_on_table(&table_name, &run)?;
7687        }
7688        Ok(())
7689    }
7690
7691    /// v7.37.5 — apply a contiguous slice of `RowChange`s all
7692    /// targeting the same `table_name`. Composes row mutations
7693    /// through a single live-tracking vector + a single tail
7694    /// for appended `Insert`s + a single in-place edit set for
7695    /// `Update`s, then writes the final row layout to
7696    /// `self.rows` and rebuilds indices ONCE.
7697    fn apply_redo_run_on_table(
7698        &mut self,
7699        table_name: &str,
7700        run: &[&RowChange],
7701    ) -> Result<(), StorageError> {
7702        // Look up the table once; the unchecked unwrap is safe
7703        // because the caller just resolved `table_name` for each
7704        // change.
7705        let table = self.get_mut(table_name).ok_or_else(|| {
7706            StorageError::Corrupt(alloc::format!("redo: unknown table {table_name:?}"))
7707        })?;
7708        // Live-tracking over both pre-existing rows and tail-
7709        // appended Insert rows. `live[i] = true` initially for
7710        // every existing row. Appended Inserts extend with `true`.
7711        // A `Delete` flips entries to `false` (using the position
7712        // mapping that walks live indices in order). An `Update`
7713        // edits in place — collected into an overlay map keyed by
7714        // ORIGINAL row position so later Updates win.
7715        let original_rows: alloc::vec::Vec<Row<'static>> = table.rows().iter().cloned().collect();
7716        let mut live: alloc::vec::Vec<bool> = alloc::vec![true; original_rows.len()];
7717        let mut tail: alloc::vec::Vec<Row<'static>> = alloc::vec::Vec::new();
7718        // Overlay: index into ORIGINAL row space (existing rows
7719        // 0..original_rows.len()) or into tail (offset
7720        // original_rows.len()). Map -> new values.
7721        let mut overlay: alloc::collections::BTreeMap<usize, alloc::vec::Vec<Value<'static>>> =
7722            alloc::collections::BTreeMap::new();
7723        // v7.37.15 (Epic W durable-tombstone slice) — extra bookkeeping
7724        // ONLY when this run actually carries an in-place `Tombstone`.
7725        // A tombstone keeps its row physically present but stamps `xmax`
7726        // on the header; the run finalizer `set_rows_and_rebuild_indices`
7727        // freezes every header (and reassigns ids), so we must re-stamp
7728        // in a post-pass keyed by RowId. When the run has no tombstone
7729        // (every default gate-off replay) this is all skipped and the
7730        // path below stays byte-for-byte the legacy one.
7731        let has_tomb = run.iter().any(|c| matches!(c, RowChange::Tombstone { .. }));
7732        // Ids of the pre-existing rows, snapshotted parallel to
7733        // `original_rows`, and ids of the tail rows filled from each
7734        // `Insert`'s carried `rowid`. Together they let a tombstone name
7735        // the exact row the writer stamped, independent of the ids the
7736        // finalizer will hand out. (When `!has_tomb`, both stay empty.)
7737        // v7.39 (flip crash-replay P0) — ids are tracked UNCONDITIONALLY
7738        // now: the finalizer preserves them so a later WAL record's
7739        // tombstone can still name rows this record produced.
7740        let orig_rowids: alloc::vec::Vec<row_header::RowId> =
7741            table.rowids().iter().copied().collect();
7742        // Headers snapshotted in lock-step: the finalizer preserves
7743        // them so earlier records' tombstone stamps survive.
7744        let orig_headers: alloc::vec::Vec<row_header::RowHeader> =
7745            table.headers().iter().copied().collect();
7746        let mut tail_rowids: alloc::vec::Vec<row_header::RowId> = alloc::vec::Vec::new();
7747        // (RowId, xmax) of every row this run tombstones.
7748        let mut tomb_targets: alloc::vec::Vec<(row_header::RowId, u64)> = alloc::vec::Vec::new();
7749        // Helper: given a "current" position (i.e. position in
7750        // the post-prior-deletes layout), translate to the
7751        // ABSOLUTE position in the unified live + tail space
7752        // by walking the live vector + tail. Returns None when
7753        // the position is out of range.
7754        fn translate(live: &[bool], tail_len: usize, current_pos: usize) -> Option<usize> {
7755            // Walk live[..] counting live entries until we hit
7756            // current_pos. Then if not yet matched, dip into tail.
7757            let mut seen = 0usize;
7758            for (i, &alive) in live.iter().enumerate() {
7759                if alive {
7760                    if seen == current_pos {
7761                        return Some(i);
7762                    }
7763                    seen += 1;
7764                }
7765            }
7766            // Position lives in tail. tail_len rows in the tail
7767            // are all live (we haven't deleted any tail rows in
7768            // this simplification; if we did, we'd extend `live`).
7769            let off = current_pos - seen;
7770            if off < tail_len {
7771                Some(live.len() + off)
7772            } else {
7773                None
7774            }
7775        }
7776        for change in run {
7777            match *change {
7778                RowChange::Insert { row, rowid, .. } => {
7779                    // Validate against schema before recording the
7780                    // change so a corrupt log surfaces as an error
7781                    // rather than silently mis-applying.
7782                    if row.len() != table.schema().columns.len() {
7783                        return Err(StorageError::ArityMismatch {
7784                            expected: table.schema().columns.len(),
7785                            actual: row.len(),
7786                        });
7787                    }
7788                    tail.push(row.clone());
7789                    // Keep the id lock-step with `tail` so a later
7790                    // tombstone (this run or a later WAL record) can
7791                    // find the row by the id the writer captured.
7792                    tail_rowids.push(*rowid);
7793                }
7794                RowChange::Update { pos, new_row, .. } => {
7795                    if new_row.len() != table.schema().columns.len() {
7796                        return Err(StorageError::ArityMismatch {
7797                            expected: table.schema().columns.len(),
7798                            actual: new_row.len(),
7799                        });
7800                    }
7801                    let abs = translate(&live, tail.len(), *pos).ok_or_else(|| {
7802                        StorageError::Corrupt(alloc::format!(
7803                            "redo: update_row position {pos} out of bounds in table {table_name:?}",
7804                        ))
7805                    })?;
7806                    // Tail edits are applied directly to `tail`
7807                    // (we own it); existing-row edits land in
7808                    // the overlay map keyed by original index.
7809                    if abs < live.len() {
7810                        overlay.insert(abs, new_row.clone());
7811                    } else {
7812                        tail[abs - live.len()] = Row::new(new_row.clone());
7813                    }
7814                }
7815                RowChange::Delete { positions, .. } => {
7816                    // De-dup + sort so the translate walk stays
7817                    // monotone (the second translate doesn't have
7818                    // to redo work the first one did, in principle;
7819                    // we keep it simple here and re-walk per
7820                    // position). Bounds-filter silently mirrors
7821                    // `Table::delete_rows`.
7822                    let mut sorted: alloc::vec::Vec<usize> = positions.clone();
7823                    sorted.sort_unstable();
7824                    sorted.dedup();
7825                    // Walk live[] once per Delete record to
7826                    // translate all positions in this record's
7827                    // post-prior-deletes layout to absolute
7828                    // indices. We MUST defer the live[] flip
7829                    // until after all positions are translated
7830                    // so two positions in the same record
7831                    // (e.g. [3, 7]) reference the same layout.
7832                    let mut to_flip_live: alloc::vec::Vec<usize> = alloc::vec::Vec::new();
7833                    let mut to_flip_tail: alloc::vec::Vec<usize> = alloc::vec::Vec::new();
7834                    // Two-pointer walk: live[i] scanned monotonically,
7835                    // sorted positions consumed in order.
7836                    let mut seen = 0usize;
7837                    let mut sp = sorted.iter().peekable();
7838                    for (i, &alive) in live.iter().enumerate() {
7839                        if !alive {
7840                            continue;
7841                        }
7842                        while let Some(&&p) = sp.peek() {
7843                            if seen == p {
7844                                to_flip_live.push(i);
7845                                sp.next();
7846                            } else {
7847                                break;
7848                            }
7849                        }
7850                        if sp.peek().is_none() {
7851                            break;
7852                        }
7853                        seen += 1;
7854                    }
7855                    // Remaining positions fall into the tail.
7856                    for &p in sp {
7857                        // p >= seen and refers to the (p - seen)-th
7858                        // entry in tail. Filter out-of-bounds.
7859                        let off = p - seen;
7860                        if off < tail.len() {
7861                            to_flip_tail.push(off);
7862                        }
7863                    }
7864                    for i in to_flip_live {
7865                        live[i] = false;
7866                        // Any pending overlay edit for this
7867                        // index is moot — the row is gone.
7868                        overlay.remove(&i);
7869                    }
7870                    // Tail deletes: remove in REVERSE order so
7871                    // shifting indices stay valid.
7872                    to_flip_tail.sort_unstable();
7873                    to_flip_tail.dedup();
7874                    for off in to_flip_tail.into_iter().rev() {
7875                        tail.remove(off);
7876                        {
7877                            // Keep the id vector lock-step with `tail`.
7878                            tail_rowids.remove(off);
7879                        }
7880                        // Re-key tail-relative overlay entries that
7881                        // were past `off` — in practice tail edits
7882                        // are applied directly so the overlay map
7883                        // only holds existing-row keys; nothing to
7884                        // do here.
7885                    }
7886                }
7887                RowChange::Tombstone { rowids, xmax, .. } => {
7888                    // An in-place tombstone leaves the row physically
7889                    // present — it does not touch `live` / `tail` /
7890                    // `overlay`. Record the (id, xmax) targets; the
7891                    // post-finalizer pass re-stamps `xmax` onto the
7892                    // matching row's (otherwise-frozen) header.
7893                    for rid in rowids {
7894                        tomb_targets.push((*rid, *xmax));
7895                    }
7896                }
7897            }
7898        }
7899        // Compose the final row layout: keep existing rows where
7900        // live[i] = true, applying overlay edits in place; then
7901        // append the surviving tail.
7902        let mut new_rows: PersistentVec<Row> = PersistentVec::new();
7903        let mut new_hot_bytes: u64 = 0;
7904        let schema_snapshot = table.schema().clone();
7905        // Parallel to `new_rows` (only built when `has_tomb`): the RowId
7906        // of each row in its FINAL slot, so the post-pass can map a
7907        // tombstone target id → the slot to re-stamp `xmax` on.
7908        let mut final_rowids: alloc::vec::Vec<row_header::RowId> = alloc::vec::Vec::new();
7909        let mut final_headers: alloc::vec::Vec<row_header::RowHeader> = alloc::vec::Vec::new();
7910        for (i, row) in original_rows.into_iter().enumerate() {
7911            if !live[i] {
7912                continue;
7913            }
7914            let final_row = if let Some(new_values) = overlay.remove(&i) {
7915                Row::new(new_values)
7916            } else {
7917                row
7918            };
7919            new_hot_bytes = new_hot_bytes
7920                .saturating_add(row_body_encoded_len(&final_row, &schema_snapshot) as u64);
7921            new_rows.push_mut(final_row);
7922            final_rowids.push(
7923                orig_rowids
7924                    .get(i)
7925                    .copied()
7926                    .unwrap_or(row_header::RowId::UNASSIGNED),
7927            );
7928            final_headers.push(
7929                orig_headers
7930                    .get(i)
7931                    .copied()
7932                    .unwrap_or_else(row_header::RowHeader::frozen),
7933            );
7934        }
7935        for (off, row) in tail.into_iter().enumerate() {
7936            new_hot_bytes =
7937                new_hot_bytes.saturating_add(row_body_encoded_len(&row, &schema_snapshot) as u64);
7938            new_rows.push_mut(row);
7939            final_rowids.push(
7940                tail_rowids
7941                    .get(off)
7942                    .copied()
7943                    .unwrap_or(row_header::RowId::UNASSIGNED),
7944            );
7945            final_headers.push(row_header::RowHeader::frozen());
7946        }
7947        // v7.39 (flip crash-replay P0) — id-preserving finalizer, so a
7948        // LATER WAL record's tombstone still resolves rows this record
7949        // produced (per-statement replay used to reassign ids between
7950        // records, orphaning every cross-record tombstone target).
7951        table.set_rows_and_rebuild_indices_with_rowids(
7952            new_rows,
7953            new_hot_bytes,
7954            &final_rowids,
7955            &final_headers,
7956        );
7957        // v7.37.15 (Epic W durable-tombstone slice) — header-preserving
7958        // re-stamp. `set_rows_and_rebuild_indices` above froze every
7959        // header, so any row this run tombstoned is currently all-
7960        // visible again. Re-apply the `xmax` stamp by matching the
7961        // tombstone's target RowId against the final-slot id map. This
7962        // is what makes a gate-on DELETE durable across replay without
7963        // changing the on-disk snapshot format (headers/ids are still
7964        // NOT serialised — that is the deferred V6 coupling; see below).
7965        if has_tomb && !tomb_targets.is_empty() {
7966            let mut id_to_slot: alloc::collections::BTreeMap<row_header::RowId, usize> =
7967                alloc::collections::BTreeMap::new();
7968            for (slot, rid) in final_rowids.iter().enumerate() {
7969                if *rid != row_header::RowId::UNASSIGNED {
7970                    id_to_slot.insert(*rid, slot);
7971                }
7972            }
7973            let table = self.get_mut(table_name).ok_or_else(|| {
7974                StorageError::Corrupt(alloc::format!("redo: unknown table {table_name:?}"))
7975            })?;
7976            for (rid, xmax) in &tomb_targets {
7977                match id_to_slot.get(rid) {
7978                    Some(&slot) => {
7979                        // First-deleter-wins + bounds handled inside.
7980                        let _ = table.mark_row_deleted(slot, *xmax);
7981                    }
7982                    None => {
7983                        // The target row was not produced by THIS redo
7984                        // run and its id was not in the run-start
7985                        // snapshot — the documented cross-checkpoint
7986                        // limitation: after a checkpoint restore the
7987                        // table's ids are reassigned (not yet persisted
7988                        // in the envelope), so a tombstone naming a
7989                        // pre-checkpoint row cannot be resolved by id.
7990                        // Skipping leaves the row visible (identical to
7991                        // the pre-Epic-W non-durable behaviour); it is
7992                        // never a correctness regression, only an
7993                        // unclosed durability gap the V6 envelope slice
7994                        // closes. Counted for observability.
7995                        UNRESOLVED_TOMBSTONES.fetch_add(1, core::sync::atomic::Ordering::Relaxed);
7996                    }
7997                }
7998            }
7999        }
8000        Ok(())
8001    }
8002
8003    fn table_for_redo(&mut self, name: &str) -> Result<&mut Table, StorageError> {
8004        self.get_mut(name)
8005            .ok_or_else(|| StorageError::Corrupt(alloc::format!("redo: unknown table {name:?}")))
8006    }
8007
8008    /// v7.34 (crash-recovery P0 #2) — enable row-level redo capture on
8009    /// every table (the engine calls this before a mutating statement
8010    /// when persistence is on; idempotent, keeps any in-flight capture).
8011    pub fn enable_redo_all(&mut self) {
8012        for t in &mut self.tables {
8013            t.enable_redo();
8014        }
8015    }
8016
8017    /// v7.34 — drain the row-level redo captured across all tables, in
8018    /// table order then per-table apply order, and stop capturing. The
8019    /// engine calls this after a successful mutating statement and writes
8020    /// the returned [`RowChange`]s to the WAL in place of the SQL text.
8021    pub fn drain_redo(&mut self) -> Vec<RowChange> {
8022        let mut all = Vec::new();
8023        for t in &mut self.tables {
8024            all.extend(t.take_redo());
8025        }
8026        all
8027    }
8028
8029    pub fn table_count(&self) -> usize {
8030        self.tables.len()
8031    }
8032
8033    /// v7.14.0 — remove a table by name. Returns `true` when the
8034    /// table existed (and is now gone), `false` when it didn't.
8035    /// Used by `DROP TABLE` from pg_dump / mysqldump preambles
8036    /// where the dump re-creates schema and starts with
8037    /// `DROP TABLE IF EXISTS`.
8038    pub fn drop_table(&mut self, name: &str) -> bool {
8039        // v7.39 (round 436) — resolve through the session's temp namespace
8040        // first, exactly as a read would: MariaDB's plain `DROP TABLE tmp`
8041        // drops the TEMPORARY one and leaves a permanent namesake standing
8042        // (measured). Removing by the raw name would have dropped the
8043        // permanent table out from under every other session.
8044        let key = match self.temp_prefix.as_ref() {
8045            Some(p) => {
8046                let mangled = alloc::format!("{p}{name}");
8047                if self.by_name.contains_key(&mangled) {
8048                    mangled
8049                } else {
8050                    name.into()
8051                }
8052            }
8053            None => name.into(),
8054        };
8055        let Some(idx) = self.by_name.remove(&key) else {
8056            return false;
8057        };
8058        // v7.39 (round 496) — see `dirty_tables`. Recorded under the
8059        // RESOLVED key, which is what a commit-time merge looks up.
8060        self.dirty_tables.insert(key.clone());
8061        // swap_remove invalidates the trailing index → rebuild
8062        // by_name for affected entries.
8063        self.tables.swap_remove(idx);
8064        // Re-stamp moved table's index slot in by_name.
8065        if idx < self.tables.len() {
8066            let moved_name = self.tables[idx].schema.name.clone();
8067            self.by_name.insert(moved_name, idx);
8068        }
8069        true
8070    }
8071
8072    /// v7.16.2 — rename a table (mailrs round-10 A.5). Updates
8073    /// the schema name, the catalog name → index map, and
8074    /// rewrites every reference dangling at the table name:
8075    ///   * every FK on every OTHER table whose `parent_table`
8076    ///     pointed at the old name now points at the new
8077    ///     name, so FK enforcement keeps working
8078    ///   * every trigger watching the table updates its `table`
8079    ///     field
8080    /// Returns `Ok` on success; `Err(StorageError::TableNotFound)`
8081    /// when the old name isn't in the catalog and
8082    /// `Err(StorageError::DuplicateTable)` when the new name is
8083    /// already taken.
8084    pub fn rename_table(&mut self, old: &str, new: &str) -> Result<(), StorageError> {
8085        if old == new {
8086            return Ok(());
8087        }
8088        if self.by_name.contains_key(new) {
8089            return Err(StorageError::Corrupt(format!(
8090                "rename_table: target name {new:?} already exists"
8091            )));
8092        }
8093        let idx = self
8094            .by_name
8095            .remove(old)
8096            .ok_or_else(|| StorageError::TableNotFound { name: old.into() })?;
8097        self.tables[idx].schema.name = new.to_string();
8098        self.by_name.insert(new.to_string(), idx);
8099        for t in &mut self.tables {
8100            for fk in &mut t.schema.foreign_keys {
8101                if fk.parent_table == old {
8102                    fk.parent_table = new.to_string();
8103                }
8104            }
8105        }
8106        for trig in &mut self.triggers {
8107            if trig.table == old {
8108                trig.table = new.to_string();
8109            }
8110        }
8111        Ok(())
8112    }
8113
8114    /// v7.16.2 — rename an index by name. Walks every table
8115    /// since the index lives on its owning table; updates the
8116    /// name in place. Errors with `IndexNotFound` when no
8117    /// index matches. mailrs round-10 A.5.
8118    pub fn rename_index(&mut self, old: &str, new: &str) -> Result<(), StorageError> {
8119        if old == new {
8120            return Ok(());
8121        }
8122        // Reject the new name if it already exists anywhere.
8123        for t in &self.tables {
8124            if t.indices.iter().any(|i| i.name == new) {
8125                return Err(StorageError::Corrupt(format!(
8126                    "rename_index: target name {new:?} already exists"
8127                )));
8128            }
8129        }
8130        for t in &mut self.tables {
8131            for i in &mut t.indices {
8132                if i.name == old {
8133                    i.name = new.to_string();
8134                    return Ok(());
8135                }
8136            }
8137        }
8138        Err(StorageError::IndexNotFound { name: old.into() })
8139    }
8140
8141    /// v7.14.0 — remove a named index across the catalog.
8142    /// Returns `true` when found + dropped.
8143    pub fn drop_named_index(&mut self, name: &str) -> bool {
8144        for t in &mut self.tables {
8145            let before = t.indices.len();
8146            t.indices.retain(|i| i.name != name);
8147            if t.indices.len() != before {
8148                return true;
8149            }
8150        }
8151        false
8152    }
8153
8154    /// v7.39.7 — the same drop, scoped to ONE table.
8155    ///
8156    /// MySQL keys an index name inside its table, and `DROP INDEX i ON t`
8157    /// says which. `None` means the table itself is missing, which is a
8158    /// different error from the index being missing — MySQL answers 1146
8159    /// for the first and 1091 for the second.
8160    pub fn drop_named_index_on(&mut self, table: &str, name: &str) -> Option<bool> {
8161        let t = self
8162            .tables
8163            .iter_mut()
8164            .find(|t| t.schema.name.eq_ignore_ascii_case(table))?;
8165        let before = t.indices.len();
8166        t.indices.retain(|i| i.name != name);
8167        Some(t.indices.len() != before)
8168    }
8169
8170    /// Borrow-free copy of every table's name in catalog order
8171    /// (= insertion order, matching the on-disk encoding).
8172    pub fn table_names(&self) -> Vec<String> {
8173        self.tables.iter().map(|t| t.schema.name.clone()).collect()
8174    }
8175
8176    /// v7.39 (round 436) — the marker every session's temporary-table
8177    /// namespace starts with. Public so the catalog synths can tell a
8178    /// temp table from an ordinary one without knowing the session id.
8179    pub const TEMP_NAME_MARKER: &'static str = "__spg_temp_";
8180
8181    /// v7.39 (round 437) — how a stored table name should appear to the
8182    /// CALLING session in a catalog listing (SHOW TABLES, pg_class,
8183    /// information_schema, …):
8184    ///   * an ordinary table → its own name
8185    ///   * this session's temporary table → its logical name, prefix stripped
8186    ///   * another session's temporary table → `None`, i.e. not listed
8187    ///
8188    /// Measured on both oracles: MariaDB 11 and PG 18 each list the calling
8189    /// session's own temporary tables and neither lists anybody else's.
8190    /// Round 436 stored temp tables under a prefix without teaching the
8191    /// listings about it, so the mangled names leaked to every client.
8192    #[must_use]
8193    pub fn listed_name<'a>(&self, stored: &'a str) -> Option<&'a str> {
8194        if !stored.starts_with(Self::TEMP_NAME_MARKER) {
8195            return Some(stored);
8196        }
8197        let prefix = self.temp_prefix.as_ref()?;
8198        stored.strip_prefix(prefix.as_str())
8199    }
8200
8201    /// The listing names of every table this session may see, in catalog
8202    /// order. See [`Catalog::listed_name`].
8203    #[must_use]
8204    pub fn visible_table_names(&self) -> Vec<String> {
8205        self.tables
8206            .iter()
8207            .filter_map(|t| self.listed_name(&t.schema.name).map(String::from))
8208            .collect()
8209    }
8210
8211    /// v5.1: register a cold-tier segment that already lives in
8212    /// memory (caller did the file read). Returns the
8213    /// `segment_id` that `RowLocator::Cold { segment_id, .. }`
8214    /// will reference — currently this is just the index into
8215    /// `cold_segments`, but treat it as an opaque token.
8216    ///
8217    /// Storage is `no_std`, so file I/O is the caller's
8218    /// responsibility — `spg-server` reads the file and forwards
8219    /// the bytes here. The bytes stay resident in the catalog
8220    /// for the life of the `Catalog`, parsed only once.
8221    pub fn load_segment_bytes(&mut self, bytes: Vec<u8>) -> Result<u32, StorageError> {
8222        let id = u32::try_from(self.cold_segments.len()).map_err(|_| {
8223            StorageError::Corrupt("cold segment count would exceed u32::MAX".into())
8224        })?;
8225        let seg = OwnedSegment::from_bytes(bytes)
8226            .map_err(|e| StorageError::Corrupt(format!("cold segment parse failed: {e}")))?;
8227        self.cold_segments.push(Some(Arc::new(seg)));
8228        Ok(id)
8229    }
8230
8231    /// v6.7.3 — register a cold-tier segment at a specific id. Used
8232    /// by the spg-server manifest-boot path so segments whose
8233    /// neighbouring ids were retired by compaction still get back
8234    /// the same `segment_id` they had pre-restart (the
8235    /// `RowLocator::Cold { segment_id }` baked into the BTree-index
8236    /// snapshot persists across restart and must continue to
8237    /// resolve).
8238    ///
8239    /// Pads the Vec with `None` slots up to `target_id` if needed.
8240    /// Errors when the target slot is already occupied (would
8241    /// stomp another segment), the parse fails, or `target_id`
8242    /// exceeds `u32::MAX`.
8243    pub fn load_segment_bytes_at(
8244        &mut self,
8245        target_id: u32,
8246        bytes: Vec<u8>,
8247    ) -> Result<(), StorageError> {
8248        let seg = OwnedSegment::from_bytes(bytes)
8249            .map_err(|e| StorageError::Corrupt(format!("cold segment parse failed: {e}")))?;
8250        let idx = target_id as usize;
8251        while self.cold_segments.len() <= idx {
8252            self.cold_segments.push(None);
8253        }
8254        if self.cold_segments[idx].is_some() {
8255            return Err(StorageError::Corrupt(format!(
8256                "load_segment_bytes_at: segment_id {target_id} already occupied"
8257            )));
8258        }
8259        self.cold_segments[idx] = Some(Arc::new(seg));
8260        Ok(())
8261    }
8262
8263    /// v6.7.3 — retire a cold-tier segment slot (compaction-driven).
8264    /// The physical file is the caller's concern (typically kept
8265    /// on disk until the next CHECKPOINT writes a manifest that
8266    /// no longer lists it); this just flips the in-memory slot
8267    /// to `None` so later cold lookups for `segment_id` resolve
8268    /// as "unknown" instead of returning a stale row.
8269    ///
8270    /// No-op when the slot is already `None`. Errors only when
8271    /// `segment_id` is out of bounds.
8272    pub fn tombstone_segment(&mut self, segment_id: u32) -> Result<(), StorageError> {
8273        let idx = segment_id as usize;
8274        if idx >= self.cold_segments.len() {
8275            return Err(StorageError::Corrupt(format!(
8276                "tombstone_segment: segment_id {segment_id} out of bounds (len={})",
8277                self.cold_segments.len()
8278            )));
8279        }
8280        self.cold_segments[idx] = None;
8281        Ok(())
8282    }
8283
8284    /// Number of *active* (non-tombstoned) cold segments.
8285    #[must_use]
8286    pub fn cold_segment_count(&self) -> usize {
8287        self.cold_segments.iter().filter(|s| s.is_some()).count()
8288    }
8289
8290    /// v7.37.42 (docker-fair SCALARSQ attack 3) — short-circuit guard
8291    /// for scan loops that conditionally walk the cold tier. Returns
8292    /// `false` when the catalog has never loaded a cold segment (or all
8293    /// segments are tombstoned), so callers can skip the per-table cold
8294    /// PK-index walk entirely on hot-only databases. O(N segments);
8295    /// typical N is small (single-digit) so the check is sub-µs.
8296    #[must_use]
8297    pub fn has_any_cold_segments(&self) -> bool {
8298        self.cold_segments.iter().any(Option::is_some)
8299    }
8300
8301    /// Slot count including tombstones (= the next id the
8302    /// no-arg `load_segment_bytes` would allocate).
8303    #[must_use]
8304    pub fn cold_segment_slot_count(&self) -> usize {
8305        self.cold_segments.len()
8306    }
8307
8308    /// v6.2.7 — list every *active* cold-tier segment id known to
8309    /// this catalog (skips compaction tombstones since v6.7.3).
8310    /// Used by EXPLAIN ANALYZE to annotate scan nodes with the
8311    /// segments they could have walked.
8312    #[must_use]
8313    pub fn cold_segment_ids_global(&self) -> Vec<u32> {
8314        self.cold_segments
8315            .iter()
8316            .enumerate()
8317            .filter_map(|(i, s)| s.as_ref().map(|_| i as u32))
8318            .collect()
8319    }
8320
8321    /// v5.2.1: sum of `Table::hot_bytes` across every table. The v5.2
8322    /// freezer compares this against `SPG_HOT_TIER_BYTES` (parsed at
8323    /// server startup; default 4 GiB) and wakes when the budget is
8324    /// crossed. Pre-freezer (v5.2.1) this is measurement-only — the
8325    /// counter exposes whether the budget is being approached without
8326    /// triggering any demotion.
8327    #[must_use]
8328    pub fn hot_tier_bytes(&self) -> u64 {
8329        self.tables
8330            .iter()
8331            .map(Table::hot_bytes)
8332            .fold(0u64, u64::saturating_add)
8333    }
8334
8335    /// v5.2.2: freeze the **first** `max_rows` rows of `table_name`'s
8336    /// hot tier into a brand-new cold-tier segment. The named `BTree`
8337    /// index supplies the per-row PK (its column must be an integer
8338    /// type — v5.2.2 only supports `IndexKey::Int` PKs, matching the
8339    /// `index_key_as_u64` constraint used by the cold-tier lookup
8340    /// path). On success returns a [`FreezeReport`] with the
8341    /// freshly-allocated segment id, the count of rows that moved,
8342    /// the encoded segment bytes (so the caller can persist them to
8343    /// disk for later reload via `SPG_PRELOAD_COLD_SEGMENT`), and the
8344    /// hot-tier byte delta that was reclaimed.
8345    ///
8346    /// **Semantics**:
8347    /// 1. The first `max_rows` rows (by hot-tier position — same as
8348    ///    insertion order under v4.39 `PersistentVec`) are read.
8349    /// 2. Rows are sorted ascending by PK and serialised into a new
8350    ///    segment via [`encode_segment`].
8351    /// 3. The hot rows are dropped via [`Table::delete_rows`]; the
8352    ///    `rebuild_indices` it triggers regenerates `Hot` locators
8353    ///    for every remaining row (their positions shift down by
8354    ///    `max_rows`). Existing `Cold` locators in this index — from
8355    ///    a previous freeze — are also rebuilt **but with empty
8356    ///    payload** since rebuild reads only `self.rows`; this
8357    ///    routine re-registers them at the end of the call so the
8358    ///    user-visible state preserves all prior cold locators.
8359    /// 4. The new segment is loaded into `self.cold_segments` via
8360    ///    [`Catalog::load_segment_bytes`] (allocating a fresh
8361    ///    `segment_id`). New `Cold` locators are registered on the
8362    ///    named index — one per frozen row.
8363    ///
8364    /// **v5.2.2 limits** (relaxed in later sub-versions):
8365    /// - INSERT-only flow: subsequent UPDATE/DELETE on a frozen row
8366    ///   returns a stale-locator error (no promote-on-write until
8367    ///   v5.2.3).
8368    /// - Single-table scope: callers iterate tables themselves.
8369    /// - All-or-nothing: returns `Err` and leaves catalog unchanged
8370    ///   if any step fails before the atomic swap point.
8371    ///
8372    /// Errors:
8373    /// - [`StorageError::Corrupt`] for missing table/index, non-`BTree`
8374    ///   index, non-integer PK column, `max_rows == 0`, or
8375    ///   `max_rows > row_count`.
8376    /// - The encoder's [`SegmentError`] surfaces as `Corrupt` (the
8377    ///   only realistic source is "a single row is larger than the
8378    ///   page size"; SPG schemas don't hit it in practice).
8379    pub fn freeze_oldest_to_cold(
8380        &mut self,
8381        table_name: &str,
8382        index_name: &str,
8383        max_rows: usize,
8384    ) -> Result<FreezeReport, StorageError> {
8385        // --- validation phase: never mutates ---------------------
8386        if max_rows == 0 {
8387            return Err(StorageError::Corrupt(
8388                "freeze_oldest_to_cold: max_rows must be > 0".into(),
8389            ));
8390        }
8391        let table = self.get(table_name).ok_or_else(|| {
8392            StorageError::Corrupt(format!(
8393                "freeze_oldest_to_cold: table {table_name:?} not found"
8394            ))
8395        })?;
8396        if max_rows > table.rows.len() {
8397            return Err(StorageError::Corrupt(format!(
8398                "freeze_oldest_to_cold: max_rows {max_rows} > row_count {}",
8399                table.rows.len()
8400            )));
8401        }
8402        let idx = table
8403            .indices
8404            .iter()
8405            .find(|i| i.name == index_name)
8406            .ok_or_else(|| {
8407                StorageError::Corrupt(format!(
8408                    "freeze_oldest_to_cold: index {index_name:?} not found on {table_name:?}"
8409                ))
8410            })?;
8411        if !matches!(idx.kind, IndexKind::BTree(_)) {
8412            return Err(StorageError::Corrupt(format!(
8413                "freeze_oldest_to_cold: index {index_name:?} is NSW; only BTree indices may freeze"
8414            )));
8415        }
8416        let column_position = idx.column_position;
8417
8418        // --- segment build phase: reads only --------------------
8419        let schema = table.schema.clone();
8420        let mut to_freeze: Vec<(u64, Vec<u8>, IndexKey)> = Vec::with_capacity(max_rows);
8421        for row_idx in 0..max_rows {
8422            let row = table.rows.get(row_idx).expect("bounds-checked above");
8423            let key = IndexKey::from_value(&row.values[column_position]).ok_or_else(|| {
8424                StorageError::Corrupt(format!(
8425                    "freeze_oldest_to_cold: row {row_idx} has NULL / non-key value in index column"
8426                ))
8427            })?;
8428            let pk_u64 = index_key_as_u64(&key).ok_or_else(|| {
8429                StorageError::Corrupt(format!(
8430                    "freeze_oldest_to_cold: index {index_name:?} column type is non-integer; \
8431                     v5.2.2 cold tier requires IndexKey::Int (Text PK lands in v5.5+)"
8432                ))
8433            })?;
8434            to_freeze.push((pk_u64, encode_row_body_dense(row, &schema), key));
8435        }
8436        // encode_segment requires ascending u64 keys. Sort by PK
8437        // before encoding; the caller's row-position order is not
8438        // necessarily PK order (e.g. workloads that insert random
8439        // PKs).
8440        to_freeze.sort_by_key(|(k, _, _)| *k);
8441        // Reject duplicate PKs — encode_segment also rejects them
8442        // (`SegmentError::UnsortedKey`), but the resulting error
8443        // message there is misleading. Surface a clearer one.
8444        for w in to_freeze.windows(2) {
8445            if w[0].0 == w[1].0 {
8446                return Err(StorageError::Corrupt(format!(
8447                    "freeze_oldest_to_cold: duplicate PK {} in freeze batch",
8448                    w[0].0
8449                )));
8450            }
8451        }
8452        // Snapshot the (key, locator) pairs that will be registered
8453        // post-swap. Cloning the IndexKey out before the move makes
8454        // the registration loop borrow-free.
8455        let post_swap_keys: Vec<IndexKey> = to_freeze.iter().map(|(_, _, k)| k.clone()).collect();
8456        // Segment encode is now infallible w.r.t. ordering. Map the
8457        // `SegmentError` into a `StorageError::Corrupt` so the
8458        // public surface stays one error type.
8459        let seg_rows: Vec<(u64, Vec<u8>)> = to_freeze
8460            .into_iter()
8461            .map(|(k, body, _)| (k, body))
8462            .collect();
8463        let frozen_rows = seg_rows.len();
8464        let (seg_bytes, _meta) = encode_segment(seg_rows.into_iter(), 0.01, SEGMENT_PAGE_BYTES)
8465            .map_err(|e| StorageError::Corrupt(format!("freeze_oldest_to_cold: encode: {e}")))?;
8466
8467        // --- atomic swap phase: mutations only past this point ---
8468        // v5.2.3 made `Table::rebuild_indices` preserve every Cold
8469        // locator across the per-table rebuild, so `delete_rows`
8470        // below no longer wipes prior-freeze cold entries. The pre-
8471        // v5.2.3 capture-then-re-register that used to live here
8472        // was removed in v5.3.1 — keeping it would double-count
8473        // every prior-frozen key's Cold locator on each subsequent
8474        // freeze.
8475        let bytes_before = self.get(table_name).expect("just validated").hot_bytes();
8476        let positions: Vec<usize> = (0..max_rows).collect();
8477        let t_mut = self
8478            .get_mut(table_name)
8479            .expect("just validated; still present");
8480        let removed = t_mut.delete_rows(&positions);
8481        debug_assert_eq!(removed, max_rows, "delete_rows count matches request");
8482        let bytes_after = t_mut.hot_bytes();
8483        let bytes_freed = bytes_before.saturating_sub(bytes_after);
8484
8485        let segment_id = self
8486            .load_segment_bytes(seg_bytes.clone())
8487            .map_err(|e| StorageError::Corrupt(format!("freeze_oldest_to_cold: load: {e}")))?;
8488        let new_cold = post_swap_keys.into_iter().map(|k| {
8489            (
8490                k,
8491                RowLocator::Cold {
8492                    segment_id,
8493                    page_offset: 0,
8494                },
8495            )
8496        });
8497        let t_mut = self.get_mut(table_name).expect("still present");
8498        t_mut.register_cold_locators(index_name, new_cold)?;
8499        // r944 — a freeze has to say that it froze something.
8500        //
8501        // `has_cold_rows_fast()` reads the cached count, and neither
8502        // freeze path touched it, so afterwards it answered "no cold
8503        // rows" while cold rows existed. That predicate gates four join
8504        // paths, and a gate that wrongly declines the cold-aware path
8505        // drops the frozen rows from the answer.
8506        //
8507        // Marking it stale rather than adding to it: stale reads as
8508        // true, which is the safe direction, and this function cannot
8509        // know the exact total (rows may already have been cold). ANALYZE
8510        // recomputes the number.
8511        t_mut.mark_cold_row_count_stale();
8512
8513        Ok(FreezeReport {
8514            segment_id,
8515            frozen_rows,
8516            bytes_freed,
8517            segment_bytes: seg_bytes,
8518        })
8519    }
8520
8521    /// v5.1: borrow the cold segment at `segment_id`. Used by the
8522    /// spg-server preload path to enumerate (key, locator) pairs
8523    /// after loading a segment, so it can call
8524    /// [`Table::register_cold_locators`] without re-parsing the
8525    /// bytes.
8526    #[must_use]
8527    pub fn cold_segment(&self, segment_id: u32) -> Option<&OwnedSegment> {
8528        self.cold_segments
8529            .get(segment_id as usize)
8530            .and_then(|s| s.as_deref())
8531    }
8532
8533    /// v5.1: resolve a single `RowLocator::Cold` to its underlying
8534    /// `Row`. Decoupled from [`Catalog::lookup_by_pk`] so callers
8535    /// iterating a multi-locator slice (e.g. the engine's index
8536    /// seek path) can dispatch per locator instead of getting back
8537    /// only the first row for a key. Returns `None` when the
8538    /// segment isn't registered, the key isn't `u64`-coercible, or
8539    /// the segment doesn't actually carry the key (bloom or page-
8540    /// index reject).
8541    pub fn resolve_cold_locator(
8542        &self,
8543        table_name: &str,
8544        segment_id: u32,
8545        key: &IndexKey,
8546    ) -> Option<Row<'static>> {
8547        let t = self.get(table_name)?;
8548        let u64_key = index_key_as_u64(key)?;
8549        let seg = self.cold_segments.get(segment_id as usize)?.as_ref()?;
8550        let payload = seg.lookup(u64_key)?;
8551        let (row, _) = decode_row_body_dense(&payload, &t.schema, seg.codec_version()).ok()?;
8552        // v7.39 (pg_stat blks knife) — one cold-tier "block read".
8553        self.cold_read_stats
8554            .cold_reads
8555            .fetch_add(1, core::sync::atomic::Ordering::Relaxed);
8556        Some(row)
8557    }
8558
8559    /// v5.1: indexed PK lookup that dispatches per locator,
8560    /// returning the first matching row from either the hot tier
8561    /// (`Table::rows`) or a registered cold segment.
8562    ///
8563    /// The cold path requires the index column to be coercible to
8564    /// a `u64` (the segment's PK type) and the segment payload to
8565    /// be a [`encode_row_body_dense`]-encoded row body for the
8566    /// same schema. v5.1 ships this for BIGINT / INT / SMALLINT
8567    /// PKs; other types fall through to hot-only behavior.
8568    ///
8569    /// Returns `None` if (a) the table or index doesn't exist,
8570    /// (b) the key isn't in the index at all, or (c) the key was
8571    /// resolved to a stale locator (Hot index out of range, Cold
8572    /// segment id unknown, segment lookup miss). Does not surface
8573    /// segment-decode errors — those would indicate corrupted
8574    /// cold-tier files and should be caught at
8575    /// [`Catalog::load_segment_bytes`] time.
8576    pub fn lookup_by_pk(&self, table: &str, index_name: &str, key: &IndexKey) -> Option<Row<'_>> {
8577        let t = self.get(table)?;
8578        let idx = t.indices.iter().find(|i| i.name == index_name)?;
8579        let locators = idx.lookup_eq(key);
8580        let cold_u64_key = index_key_as_u64(key);
8581        for loc in locators {
8582            match *loc {
8583                RowLocator::Hot(i) => {
8584                    if let Some(row) = t.rows.get(i) {
8585                        return Some(row.clone());
8586                    }
8587                }
8588                RowLocator::Cold {
8589                    segment_id,
8590                    page_offset: _,
8591                } => {
8592                    let Some(u64_key) = cold_u64_key else {
8593                        // Key type not coercible to u64 — cold tier
8594                        // only handles BIGINT/INT/SMALLINT in v5.1.
8595                        continue;
8596                    };
8597                    let Some(seg) = self
8598                        .cold_segments
8599                        .get(segment_id as usize)
8600                        .and_then(|s| s.as_deref())
8601                    else {
8602                        // v6.7.3 — `None` slot = compaction
8603                        // retired this segment; the live locator
8604                        // on a freshly-compacted index points to
8605                        // the merged segment_id, so a Cold hit
8606                        // here against a tombstone means the BTree
8607                        // entry hasn't been swapped yet (mid-
8608                        // compaction reader race) or the caller is
8609                        // looking up a stale snapshot. Skip — the
8610                        // next locator in the list, if any, is
8611                        // typically the merged segment.
8612                        continue;
8613                    };
8614                    let Some(payload) = seg.lookup(u64_key) else {
8615                        continue;
8616                    };
8617                    let (row, _) =
8618                        decode_row_body_dense(&payload, &t.schema, seg.codec_version()).ok()?;
8619                    return Some(row);
8620                }
8621            }
8622        }
8623        None
8624    }
8625
8626    /// v5.2.3: promote a frozen row back to the hot tier so an
8627    /// UPDATE / DELETE can mutate it. Reads the cold-tier row body
8628    /// (decoded from its registered segment), pushes it into
8629    /// `table.rows` via [`Table::insert`] (which also adds a fresh
8630    /// `Hot(new_idx)` locator on `index_name`), then retires the
8631    /// shadowed `Cold` locator via
8632    /// [`Table::remove_cold_locators_for_key`]. The cold-tier row
8633    /// in the segment file becomes garbage — recoverable when a
8634    /// future cold-segment compaction job lands.
8635    ///
8636    /// Returns:
8637    /// - `Ok(Some(new_hot_idx))` when the key resolved through a
8638    ///   cold locator and the promote completed. `new_hot_idx` is
8639    ///   the position the row now occupies in `table.rows`.
8640    /// - `Ok(None)` when the key has no Cold locator on the index
8641    ///   (already hot, or wasn't present at all). Callers treat this
8642    ///   as "nothing to do here, fall back to the hot-only path".
8643    ///
8644    /// Errors when the table / index doesn't exist, the index isn't
8645    /// `BTree`, the cold segment is missing / can't decode the row,
8646    /// or the inferred row body fails `Table::insert` validation.
8647    pub fn promote_cold_row(
8648        &mut self,
8649        table_name: &str,
8650        index_name: &str,
8651        key: &IndexKey,
8652    ) -> Result<Option<usize>, StorageError> {
8653        let cold_loc = self.find_cold_locator(table_name, index_name, key)?;
8654        let Some((segment_id, _page_offset)) = cold_loc else {
8655            return Ok(None);
8656        };
8657        let u64_key = index_key_as_u64(key).ok_or_else(|| {
8658            StorageError::Corrupt(
8659                "promote_cold_row: key type not coercible to u64 (cold tier requires integer PK)"
8660                    .into(),
8661            )
8662        })?;
8663        // Read the row body from the segment. Borrow the segment +
8664        // schema short-term so we can then take `&mut self` for the
8665        // hot-side insert.
8666        let schema = self
8667            .get(table_name)
8668            .ok_or_else(|| {
8669                StorageError::Corrupt(format!("promote_cold_row: table {table_name:?} not found"))
8670            })?
8671            .schema
8672            .clone();
8673        let seg = self
8674            .cold_segments
8675            .get(segment_id as usize)
8676            .and_then(|s| s.as_ref())
8677            .ok_or_else(|| {
8678                StorageError::Corrupt(format!(
8679                    "promote_cold_row: segment {segment_id} not registered on catalog"
8680                ))
8681            })?;
8682        let payload = seg.lookup(u64_key).ok_or_else(|| {
8683            StorageError::Corrupt(format!(
8684                "promote_cold_row: key {u64_key} resolves to segment {segment_id} \
8685                 but the segment's bloom/page lookup didn't return a row"
8686            ))
8687        })?;
8688        let (row, _consumed) = decode_row_body_dense(&payload, &schema, seg.codec_version())?;
8689        // Insert the promoted row into the hot tier. `Table::insert`
8690        // appends to `self.rows`, adds a `Hot(new_idx)` locator to
8691        // every BTree index covering the row's keyed columns, and
8692        // increments `hot_bytes`.
8693        let t = self
8694            .get_mut(table_name)
8695            .expect("table existed at lookup time");
8696        t.insert(row)?;
8697        let new_hot_idx =
8698            t.rows.len().checked_sub(1).ok_or_else(|| {
8699                StorageError::Corrupt("promote_cold_row: empty after insert".into())
8700            })?;
8701        // The hot insert added Hot(new_idx) alongside the still-
8702        // present Cold locator. Drop the Cold entry so future
8703        // lookups return only the fresh hot row.
8704        t.remove_cold_locators_for_key(index_name, key)?;
8705        Ok(Some(new_hot_idx))
8706    }
8707
8708    /// v5.2.3: shadow a frozen row's index entry. Used by DELETE
8709    /// when the row to remove lives in a cold-tier segment — the
8710    /// row body stays in the segment file (becoming garbage) but
8711    /// every `Cold` locator for `key` on `index_name` is removed
8712    /// so PK lookups stop returning it.
8713    ///
8714    /// Returns the number of cold locators retired (0 when the key
8715    /// has no cold entries — the DELETE fell on a hot row or a
8716    /// key that was already absent). Errors when the table /
8717    /// index doesn't exist or the index isn't `BTree`.
8718    ///
8719    /// Cold-segment compaction (which merges shadowed-heavy
8720    /// segments and reclaims their disk footprint) lands in a
8721    /// later v5.x sub-version; until then, repeated UPDATE/DELETE
8722    /// of cold rows can amplify cold-segment disk usage by up to
8723    /// 1-2× — still well under typical LSM-tree shadowing because
8724    /// SPG segments are bulk-baked, not write-merged.
8725    pub fn shadow_cold_row(
8726        &mut self,
8727        table_name: &str,
8728        index_name: &str,
8729        key: &IndexKey,
8730    ) -> Result<usize, StorageError> {
8731        let t = self.get_mut(table_name).ok_or_else(|| {
8732            StorageError::Corrupt(format!("shadow_cold_row: table {table_name:?} not found"))
8733        })?;
8734        t.remove_cold_locators_for_key(index_name, key)
8735    }
8736
8737    /// v6.7.4 — read-only slice preparation for the parallel
8738    /// freezer. Walks rows in `row_range`, builds the
8739    /// `(pk_u64, encoded_body, IndexKey)` triples that the
8740    /// coordinator's k-way merge consumes, sorts the slice by
8741    /// `pk_u64`, and returns a [`FreezeSlice`].
8742    ///
8743    /// Caller invariants:
8744    /// - `row_range.end <= table.rows.len()` (caller's job to
8745    ///   compute the partition).
8746    /// - All slices passed to `commit_freeze_slices` must cover a
8747    ///   contiguous half-open range `[0, total_max_rows)` with no
8748    ///   gaps and no overlaps. The coordinator validates this
8749    ///   invariant before committing.
8750    ///
8751    /// `&self`-only — multiple workers can run this concurrently
8752    /// against the same `Catalog` reference under the engine's
8753    /// write lock (workers don't mutate; the coordinator does).
8754    pub fn prepare_freeze_slice(
8755        &self,
8756        table_name: &str,
8757        index_name: &str,
8758        row_range: core::ops::Range<usize>,
8759    ) -> Result<FreezeSlice, StorageError> {
8760        let table = self.get(table_name).ok_or_else(|| {
8761            StorageError::Corrupt(format!(
8762                "prepare_freeze_slice: table {table_name:?} not found"
8763            ))
8764        })?;
8765        let idx = table
8766            .indices
8767            .iter()
8768            .find(|i| i.name == index_name)
8769            .ok_or_else(|| {
8770                StorageError::Corrupt(format!(
8771                    "prepare_freeze_slice: index {index_name:?} not found on {table_name:?}"
8772                ))
8773            })?;
8774        if !matches!(idx.kind, IndexKind::BTree(_)) {
8775            return Err(StorageError::Corrupt(format!(
8776                "prepare_freeze_slice: index {index_name:?} is NSW; only BTree indices may freeze"
8777            )));
8778        }
8779        if row_range.end > table.rows.len() {
8780            return Err(StorageError::Corrupt(format!(
8781                "prepare_freeze_slice: row_range end {} > row_count {}",
8782                row_range.end,
8783                table.rows.len()
8784            )));
8785        }
8786        let column_position = idx.column_position;
8787        let schema = table.schema.clone();
8788        let mut rows: Vec<(u64, Vec<u8>, IndexKey)> = Vec::with_capacity(row_range.len());
8789        for row_idx in row_range.clone() {
8790            let row = table.rows.get(row_idx).expect("bounds-checked above");
8791            let key = IndexKey::from_value(&row.values[column_position]).ok_or_else(|| {
8792                StorageError::Corrupt(format!(
8793                    "prepare_freeze_slice: row {row_idx} has NULL / non-key value in index column"
8794                ))
8795            })?;
8796            let pk_u64 = index_key_as_u64(&key).ok_or_else(|| {
8797                StorageError::Corrupt(format!(
8798                    "prepare_freeze_slice: index {index_name:?} column type is non-integer; \
8799                     v5.2.2 cold tier requires IndexKey::Int (Text PK lands in v5.5+)"
8800                ))
8801            })?;
8802            rows.push((pk_u64, encode_row_body_dense(row, &schema), key));
8803        }
8804        rows.sort_by_key(|(k, _, _)| *k);
8805        Ok(FreezeSlice { row_range, rows })
8806    }
8807
8808    /// v6.7.4 — coordinator commit step. Merges N
8809    /// [`FreezeSlice`]s into one segment via the standard
8810    /// [`encode_segment`] path, atomically swaps the catalog
8811    /// state (delete the union row range + register Cold
8812    /// locators + load the segment).
8813    ///
8814    /// Validates that the slices cover a contiguous, gap-free,
8815    /// overlap-free half-open range starting at index 0 (the
8816    /// freezer always freezes "oldest first" — same semantics as
8817    /// the single-threaded [`Catalog::freeze_oldest_to_cold`]).
8818    ///
8819    /// Empty `slices` → no-op success (returns a zero-row report
8820    /// without mutating). Total row count = `Σ slice.rows.len()`.
8821    pub fn commit_freeze_slices(
8822        &mut self,
8823        table_name: &str,
8824        index_name: &str,
8825        slices: Vec<FreezeSlice>,
8826    ) -> Result<FreezeReport, StorageError> {
8827        // --- validation phase: never mutates ---------------------
8828        let table = self.get(table_name).ok_or_else(|| {
8829            StorageError::Corrupt(format!(
8830                "commit_freeze_slices: table {table_name:?} not found"
8831            ))
8832        })?;
8833        let idx = table
8834            .indices
8835            .iter()
8836            .find(|i| i.name == index_name)
8837            .ok_or_else(|| {
8838                StorageError::Corrupt(format!(
8839                    "commit_freeze_slices: index {index_name:?} not found on {table_name:?}"
8840                ))
8841            })?;
8842        if !matches!(idx.kind, IndexKind::BTree(_)) {
8843            return Err(StorageError::Corrupt(format!(
8844                "commit_freeze_slices: index {index_name:?} is NSW; only BTree indices may freeze"
8845            )));
8846        }
8847        // Validate slice coverage: contiguous from 0, no gaps, no
8848        // overlaps. Allow the caller to pass slices in any order —
8849        // sort by row_range.start first.
8850        let mut ordered = slices;
8851        ordered.sort_by_key(|s| s.row_range.start);
8852        // Drop fully-empty slices that fell out of an uneven
8853        // partition; they carry no data but contribute to the
8854        // contiguity check, so keep them in line.
8855        let mut expected_start = 0usize;
8856        for s in &ordered {
8857            if s.row_range.start != expected_start {
8858                return Err(StorageError::Corrupt(format!(
8859                    "commit_freeze_slices: gap/overlap at row {}; expected start {}",
8860                    s.row_range.start, expected_start
8861                )));
8862            }
8863            expected_start = s.row_range.end;
8864        }
8865        let max_rows = expected_start;
8866        if max_rows > table.rows.len() {
8867            return Err(StorageError::Corrupt(format!(
8868                "commit_freeze_slices: total row range {} exceeds row_count {}",
8869                max_rows,
8870                table.rows.len()
8871            )));
8872        }
8873        if max_rows == 0 {
8874            return Ok(FreezeReport {
8875                segment_id: u32::MAX,
8876                frozen_rows: 0,
8877                bytes_freed: 0,
8878                segment_bytes: Vec::new(),
8879            });
8880        }
8881
8882        // --- segment build phase: reads only --------------------
8883        // K-way merge of already-sorted slices. Each slice's rows
8884        // are ascending by pk_u64; we keep a per-slice cursor and
8885        // pull the next-smallest head until every cursor drains.
8886        let total_rows: usize = ordered.iter().map(|s| s.rows.len()).sum();
8887        if total_rows != max_rows {
8888            return Err(StorageError::Corrupt(format!(
8889                "commit_freeze_slices: total slice rows {total_rows} ≠ row_range coverage {max_rows}"
8890            )));
8891        }
8892        let mut cursors: Vec<usize> = alloc::vec![0; ordered.len()];
8893        let mut merged: Vec<(u64, Vec<u8>, IndexKey)> = Vec::with_capacity(total_rows);
8894        loop {
8895            // Pick the slice whose head row has the smallest key
8896            // and isn't yet exhausted.
8897            let mut pick: Option<usize> = None;
8898            for (i, c) in cursors.iter().enumerate() {
8899                let slice = &ordered[i];
8900                if *c >= slice.rows.len() {
8901                    continue;
8902                }
8903                match pick {
8904                    None => pick = Some(i),
8905                    Some(j) => {
8906                        if slice.rows[*c].0 < ordered[j].rows[cursors[j]].0 {
8907                            pick = Some(i);
8908                        }
8909                    }
8910                }
8911            }
8912            let Some(i) = pick else { break };
8913            let row = ordered[i].rows[cursors[i]].clone();
8914            cursors[i] += 1;
8915            merged.push(row);
8916        }
8917        // Reject duplicate PKs — same error as the single-threaded
8918        // path so callers get a uniform surface.
8919        for w in merged.windows(2) {
8920            if w[0].0 == w[1].0 {
8921                return Err(StorageError::Corrupt(format!(
8922                    "commit_freeze_slices: duplicate PK {} across slices",
8923                    w[0].0
8924                )));
8925            }
8926        }
8927        let post_swap_keys: Vec<IndexKey> = merged.iter().map(|(_, _, k)| k.clone()).collect();
8928        let seg_rows: Vec<(u64, Vec<u8>)> =
8929            merged.into_iter().map(|(k, body, _)| (k, body)).collect();
8930        let frozen_rows = seg_rows.len();
8931        let (seg_bytes, _meta) = encode_segment(seg_rows.into_iter(), 0.01, SEGMENT_PAGE_BYTES)
8932            .map_err(|e| StorageError::Corrupt(format!("commit_freeze_slices: encode: {e}")))?;
8933
8934        // --- atomic swap phase: mutations only past this point ---
8935        let bytes_before = self.get(table_name).expect("just validated").hot_bytes();
8936        let positions: Vec<usize> = (0..max_rows).collect();
8937        let t_mut = self
8938            .get_mut(table_name)
8939            .expect("just validated; still present");
8940        let removed = t_mut.delete_rows(&positions);
8941        debug_assert_eq!(removed, max_rows, "delete_rows count matches request");
8942        let bytes_after = t_mut.hot_bytes();
8943        let bytes_freed = bytes_before.saturating_sub(bytes_after);
8944
8945        let segment_id = self
8946            .load_segment_bytes(seg_bytes.clone())
8947            .map_err(|e| StorageError::Corrupt(format!("commit_freeze_slices: load: {e}")))?;
8948        let new_cold = post_swap_keys.into_iter().map(|k| {
8949            (
8950                k,
8951                RowLocator::Cold {
8952                    segment_id,
8953                    page_offset: 0,
8954                },
8955            )
8956        });
8957        let t_mut = self.get_mut(table_name).expect("still present");
8958        t_mut.register_cold_locators(index_name, new_cold)?;
8959        // r944 — a freeze has to say that it froze something.
8960        //
8961        // `has_cold_rows_fast()` reads the cached count, and neither
8962        // freeze path touched it, so afterwards it answered "no cold
8963        // rows" while cold rows existed. That predicate gates four join
8964        // paths, and a gate that wrongly declines the cold-aware path
8965        // drops the frozen rows from the answer.
8966        //
8967        // Marking it stale rather than adding to it: stale reads as
8968        // true, which is the safe direction, and this function cannot
8969        // know the exact total (rows may already have been cold). ANALYZE
8970        // recomputes the number.
8971        t_mut.mark_cold_row_count_stale();
8972
8973        Ok(FreezeReport {
8974            segment_id,
8975            frozen_rows,
8976            bytes_freed,
8977            segment_bytes: seg_bytes,
8978        })
8979    }
8980
8981    /// v6.7.3 — compact every cold segment on `(table, index)` whose
8982    /// `OwnedSegment::bytes().len()` is below `target_segment_bytes`
8983    /// into a single larger merged segment. Rows present in source
8984    /// segment payloads but no longer referenced by any
8985    /// `RowLocator::Cold` on the index (DELETE'd + frozen rows
8986    /// retired via [`Catalog::shadow_cold_row`]) are GC'd in the
8987    /// merge.
8988    ///
8989    /// **Semantics**:
8990    /// 1. Walk the BTree index to collect every Cold locator that
8991    ///    targets a small (< threshold) segment. Each such
8992    ///    `(key, segment_id)` becomes a row in the merged segment;
8993    ///    payload is looked up from the source segment in-place.
8994    /// 2. Encode the collected rows into one new segment via
8995    ///    [`encode_segment`]; register it via
8996    ///    [`Catalog::load_segment_bytes`] (allocating a fresh
8997    ///    `merged_segment_id` at the end of `cold_segments`).
8998    /// 3. Rewrite the BTree index in one pass: every
8999    ///    `RowLocator::Cold { segment_id ∈ sources }` becomes
9000    ///    `RowLocator::Cold { segment_id = merged_id, page_offset = 0 }`.
9001    ///    Hot locators are untouched.
9002    /// 4. Tombstone every source slot via
9003    ///    [`Catalog::tombstone_segment`]. Source segment payloads
9004    ///    are no longer reachable through the catalog; the on-disk
9005    ///    files are the caller's concern.
9006    ///
9007    /// On fewer than 2 candidate segments the catalog is **not**
9008    /// mutated and a no-op report (`merged_segment_id: None`,
9009    /// `sources: []`) is returned. This is the routine case — a
9010    /// freshly-frozen table has at most 1 small segment, no merge
9011    /// possible.
9012    ///
9013    /// Atomicity: every mutating step runs after the read-only
9014    /// gather phase, so a panic before the merge encode leaves the
9015    /// catalog unchanged. The mutation block itself (load + rewrite +
9016    /// tombstone) takes only `&mut self` — callers serialise the
9017    /// engine write lock outside this function.
9018    ///
9019    /// Errors when the table / index doesn't exist, the index isn't
9020    /// `BTree`, the index column type isn't u64-coercible (cold-tier
9021    /// pre-condition), or a source segment fails its in-place
9022    /// row-body lookup (would indicate prior catalog corruption).
9023    pub fn compact_cold_segments(
9024        &mut self,
9025        table_name: &str,
9026        index_name: &str,
9027        target_segment_bytes: u64,
9028    ) -> Result<CompactReport, StorageError> {
9029        // --- validation phase ----------------------------------
9030        let t = self.get(table_name).ok_or_else(|| {
9031            StorageError::Corrupt(format!(
9032                "compact_cold_segments: table {table_name:?} not found"
9033            ))
9034        })?;
9035        let idx = t
9036            .indices
9037            .iter()
9038            .find(|i| i.name == index_name)
9039            .ok_or_else(|| {
9040                StorageError::Corrupt(format!(
9041                    "compact_cold_segments: index {index_name:?} not found on {table_name:?}"
9042                ))
9043            })?;
9044        let map = match &idx.kind {
9045            IndexKind::BTree(m) => m,
9046            IndexKind::Nsw(_)
9047            | IndexKind::Brin { .. }
9048            | IndexKind::Gin(_)
9049            | IndexKind::GinTrgm(_)
9050            | IndexKind::GinFulltext(_)
9051            | IndexKind::GinJsonb(_)
9052            | IndexKind::BTreeMulti(_) => {
9053                return Err(StorageError::Corrupt(format!(
9054                    "compact_cold_segments: index {index_name:?} is not BTree; \
9055                     compaction applies only to BTree cold-tier indices"
9056                )));
9057            }
9058        };
9059
9060        // --- gather phase --------------------------------------
9061        // Step A: every segment_id this BTree index Cold-references.
9062        let mut referenced_ids: BTreeSet<u32> = BTreeSet::new();
9063        for (_key, locators) in map.iter() {
9064            for loc in locators {
9065                if let RowLocator::Cold { segment_id, .. } = loc {
9066                    referenced_ids.insert(*segment_id);
9067                }
9068            }
9069        }
9070        // Step B: keep only the small + still-active ones.
9071        let candidate_set: BTreeSet<u32> = referenced_ids
9072            .into_iter()
9073            .filter(|id| {
9074                self.cold_segments
9075                    .get(*id as usize)
9076                    .and_then(|s| s.as_deref())
9077                    .is_some_and(|s| (s.bytes().len() as u64) < target_segment_bytes)
9078            })
9079            .collect();
9080        if candidate_set.len() < 2 {
9081            return Ok(CompactReport {
9082                sources: Vec::new(),
9083                merged_segment_id: None,
9084                merged_segment_bytes: Vec::new(),
9085                merged_rows: 0,
9086                deleted_rows_pruned: 0,
9087                bytes_reclaimed_estimate: 0,
9088            });
9089        }
9090        // Step C: pre-count source rows for the deleted-pruned metric.
9091        let mut source_row_count: usize = 0;
9092        let mut source_byte_total: u64 = 0;
9093        for &id in &candidate_set {
9094            let seg = self.cold_segments[id as usize]
9095                .as_ref()
9096                .expect("candidate selected only when slot is Some");
9097            source_row_count = source_row_count.saturating_add(seg.meta().num_rows as usize);
9098            source_byte_total = source_byte_total.saturating_add(seg.bytes().len() as u64);
9099        }
9100        // Step D: collect (key, body) pairs from every live Cold
9101        // locator pointing at a candidate. dedupe by key — one
9102        // BTree key resolves to at most one cold payload (the
9103        // freezer + promote/shadow flow keeps Cold locators
9104        // unique per key).
9105        let mut collected: BTreeMap<u64, (Vec<u8>, IndexKey)> = BTreeMap::new();
9106        for (key, locators) in map.iter() {
9107            for loc in locators {
9108                let RowLocator::Cold { segment_id, .. } = loc else {
9109                    continue;
9110                };
9111                if !candidate_set.contains(segment_id) {
9112                    continue;
9113                }
9114                let u64_key = index_key_as_u64(key).ok_or_else(|| {
9115                    StorageError::Corrupt(format!(
9116                        "compact_cold_segments: index {index_name:?} has non-integer Cold key; \
9117                         cold tier requires IndexKey::Int (Text PK lands in v5.5+)"
9118                    ))
9119                })?;
9120                let seg = self.cold_segments[*segment_id as usize]
9121                    .as_ref()
9122                    .expect("candidate slot guaranteed Some above");
9123                let payload = seg.lookup(u64_key).ok_or_else(|| {
9124                    StorageError::Corrupt(format!(
9125                        "compact_cold_segments: BTree {index_name:?} points key={u64_key} \
9126                         at segment {segment_id} but the segment lookup missed"
9127                    ))
9128                })?;
9129                collected.insert(u64_key, (payload, key.clone()));
9130                break;
9131            }
9132        }
9133        let merged_rows = collected.len();
9134        let deleted_rows_pruned = source_row_count.saturating_sub(merged_rows);
9135
9136        // Step E: encode the merged segment. `BTreeMap<u64, _>`
9137        // iteration is ascending by key, which is what
9138        // `encode_segment` requires.
9139        let seg_rows: Vec<(u64, Vec<u8>)> = collected
9140            .iter()
9141            .map(|(k, (body, _))| (*k, body.clone()))
9142            .collect();
9143        let (seg_bytes, _meta) = encode_segment(seg_rows.into_iter(), 0.01, SEGMENT_PAGE_BYTES)
9144            .map_err(|e| StorageError::Corrupt(format!("compact_cold_segments: encode: {e}")))?;
9145        let merged_bytes_len = seg_bytes.len() as u64;
9146
9147        // --- atomic mutation phase ------------------------------
9148        let merged_segment_id = self
9149            .load_segment_bytes(seg_bytes.clone())
9150            .map_err(|e| StorageError::Corrupt(format!("compact_cold_segments: load: {e}")))?;
9151
9152        // Rewrite the BTree index: every Cold locator pointing at
9153        // a candidate source becomes a Cold locator pointing at
9154        // the merged segment. Use a flat collect-then-replace
9155        // pattern so we never hold a `&self` borrow across the
9156        // `&mut self` write.
9157        let entries: Vec<(IndexKey, crate::posting::PostingList)> = {
9158            let t = self
9159                .get(table_name)
9160                .expect("table existed at the start of this fn");
9161            let idx = t
9162                .indices
9163                .iter()
9164                .find(|i| i.name == index_name)
9165                .expect("index existed at the start of this fn");
9166            let IndexKind::BTree(map) = &idx.kind else {
9167                unreachable!("validated above");
9168            };
9169            map.iter().map(|(k, v)| (k.clone(), v.clone())).collect()
9170        };
9171        let t_mut = self
9172            .get_mut(table_name)
9173            .expect("table existed at the start of this fn");
9174        let idx_mut = t_mut
9175            .indices
9176            .iter_mut()
9177            .find(|i| i.name == index_name)
9178            .expect("index existed at the start of this fn");
9179        let IndexKind::BTree(map_mut) = &mut idx_mut.kind else {
9180            unreachable!("validated above");
9181        };
9182        for (key, locators) in entries {
9183            let mut new_locs = crate::posting::PostingList::new();
9184            let mut changed = false;
9185            for loc in &locators {
9186                match *loc {
9187                    RowLocator::Cold {
9188                        segment_id,
9189                        page_offset: _,
9190                    } if candidate_set.contains(&segment_id) => {
9191                        let replacement = RowLocator::Cold {
9192                            segment_id: merged_segment_id,
9193                            page_offset: 0,
9194                        };
9195                        if !new_locs.contains(replacement) {
9196                            new_locs.push(replacement);
9197                        }
9198                        changed = true;
9199                    }
9200                    other => new_locs.push(other),
9201                }
9202            }
9203            if changed {
9204                map_mut.insert_mut(key, new_locs);
9205            }
9206        }
9207
9208        // Tombstone every source slot. Last step — failures here
9209        // would leave the segment double-referenced in both
9210        // memory + manifest, but `tombstone_segment` only errors
9211        // on out-of-bounds, which we've already validated.
9212        for &id in &candidate_set {
9213            self.tombstone_segment(id)?;
9214        }
9215
9216        let bytes_reclaimed_estimate = source_byte_total.saturating_sub(merged_bytes_len);
9217        Ok(CompactReport {
9218            sources: candidate_set.into_iter().collect(),
9219            merged_segment_id: Some(merged_segment_id),
9220            merged_segment_bytes: seg_bytes,
9221            merged_rows,
9222            deleted_rows_pruned,
9223            bytes_reclaimed_estimate,
9224        })
9225    }
9226
9227    /// Internal helper: scan `(table, index)` for a `Cold` locator
9228    /// keyed by `key`. Returns `Ok(Some((segment_id, page_offset)))`
9229    /// when found, `Ok(None)` when the key has only hot entries
9230    /// or no entries at all, `Err` on the same input-validation
9231    /// errors as the public `promote_cold_row` / `shadow_cold_row`.
9232    fn find_cold_locator(
9233        &self,
9234        table_name: &str,
9235        index_name: &str,
9236        key: &IndexKey,
9237    ) -> Result<Option<(u32, u32)>, StorageError> {
9238        let t = self.get(table_name).ok_or_else(|| {
9239            StorageError::Corrupt(format!("find_cold_locator: table {table_name:?} not found"))
9240        })?;
9241        let idx = t
9242            .indices
9243            .iter()
9244            .find(|i| i.name == index_name)
9245            .ok_or_else(|| {
9246                StorageError::Corrupt(format!(
9247                    "find_cold_locator: index {index_name:?} not found on {table_name:?}"
9248                ))
9249            })?;
9250        if !matches!(idx.kind, IndexKind::BTree(_)) {
9251            return Err(StorageError::Corrupt(format!(
9252                "find_cold_locator: index {index_name:?} is NSW; promote-on-write only applies to BTree indices"
9253            )));
9254        }
9255        for loc in idx.lookup_eq(key) {
9256            if let RowLocator::Cold {
9257                segment_id,
9258                page_offset,
9259            } = *loc
9260            {
9261                return Ok(Some((segment_id, page_offset)));
9262            }
9263        }
9264        Ok(None)
9265    }
9266}
9267
9268/// Coerce an [`IndexKey`] to the `u64` that v5.1 cold-tier
9269/// segments use as their on-disk PK. Returns `None` for keys that
9270/// aren't representable as `u64` — Text PKs need a hash mapping
9271/// the segment writer baked in (deferred to v5.2+), Bool PKs are
9272/// almost never wide enough to be sharded into a cold tier.
9273fn index_key_as_u64(key: &IndexKey) -> Option<u64> {
9274    match key {
9275        // Reinterpret the i64 bit pattern as u64. Cold-tier segments
9276        // are sorted by this u64 view, so the chosen interpretation
9277        // only has to match between insert (bake_segment / freezer)
9278        // and lookup — using cast_unsigned keeps both sides honest
9279        // and silences clippy::cast_sign_loss.
9280        IndexKey::Int(n) => Some(n.cast_unsigned()),
9281        // Text / Bool / Uuid / Bytes / Numeric PKs aren't representable
9282        // as u64 and so can't participate in the u64-sorted cold-tier
9283        // segment PK layout. Same deferral story as Text — lookup falls
9284        // through the in-memory btree.
9285        IndexKey::Text(_)
9286        | IndexKey::Bool(_)
9287        | IndexKey::Uuid(_)
9288        | IndexKey::Bytes(_)
9289        | IndexKey::Numeric(_)
9290        | IndexKey::Null => None,
9291    }
9292}
9293
9294#[derive(Debug, Clone, PartialEq, Eq)]
9295#[non_exhaustive]
9296pub enum StorageError {
9297    DuplicateTable {
9298        name: String,
9299    },
9300    TableNotFound {
9301        name: String,
9302    },
9303    ArityMismatch {
9304        expected: usize,
9305        actual: usize,
9306    },
9307    TypeMismatch {
9308        column: String,
9309        expected: DataType,
9310        actual: DataType,
9311        position: usize,
9312    },
9313    NullInNotNull {
9314        column: String,
9315    },
9316    /// Index with this name already exists on the table.
9317    DuplicateIndex {
9318        name: String,
9319    },
9320    /// Column referenced by an index doesn't exist on the table.
9321    ColumnNotFound {
9322        column: String,
9323    },
9324    /// On-disk format failed to parse — corrupted file, wrong magic, truncated
9325    /// payload, or unknown tag bytes.
9326    Corrupt(String),
9327    /// v6.0.4 — ALTER INDEX targeted an index name that doesn't
9328    /// exist on any table in this catalog.
9329    IndexNotFound {
9330        name: String,
9331    },
9332    /// v6.0.4 — operation requested isn't supported on this index
9333    /// kind / column type (e.g. ALTER INDEX REBUILD on a `BTree`
9334    /// index, or REBUILD WITH (encoding=…) on a non-vector column).
9335    Unsupported(String),
9336    /// v7.39 (round 220) — a CYCLE-less sequence ran past its bound.
9337    /// PG's 2200H phrasing: `nextval: reached maximum value of
9338    /// sequence "s" (n)` (`is_max: false` = the MINVALUE direction).
9339    SequenceExhausted {
9340        name: String,
9341        limit: i64,
9342        is_max: bool,
9343    },
9344}
9345
9346impl fmt::Display for StorageError {
9347    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
9348        match self {
9349            // v7.39 (read01 round 47) — PG's 42P07 wording.
9350            Self::DuplicateTable { name } => write!(f, "relation \"{name}\" already exists"),
9351            // v7.39 (read01 round 47) — PG's wording for a missing relation
9352            // (42P01). DROP TABLE says "table" and raises its own error at
9353            // the engine; every other path (SELECT / ALTER / …) says
9354            // "relation", which is what this carries.
9355            Self::TableNotFound { name } => write!(f, "relation \"{name}\" does not exist"),
9356            Self::ArityMismatch { expected, actual } => write!(
9357                f,
9358                "row arity mismatch: expected {expected} columns, got {actual}"
9359            ),
9360            Self::TypeMismatch {
9361                column,
9362                expected,
9363                actual,
9364                position,
9365            } => write!(
9366                f,
9367                "type mismatch in column {column:?} (position {position}): expected {expected}, got {actual}"
9368            ),
9369            Self::NullInNotNull { column } => {
9370                // v7.39 (SQLSTATE fidelity) — PG's 23502 phrasing (the
9371                // relation-qualified long form is added by engine call
9372                // sites that know the table name).
9373                write!(
9374                    f,
9375                    "null value in column \"{column}\" violates not-null constraint"
9376                )
9377            }
9378            // v7.39 (read01 round 47) — an index is a relation to PG (42P07).
9379            Self::DuplicateIndex { name } => write!(f, "relation \"{name}\" already exists"),
9380            // v7.39 (round 701) — PG's wording, and the same fix `EvalError::
9381            // ColumnNotFound` took in read01 round 81 with the same reason:
9382            // "column not found: x" matches none of the wire layer's `does
9383            // not exist` patterns, so a missing column reached the client as
9384            // the generic error class. The eval-side variant was changed and
9385            // the storage-side one was not, so which sentence you got
9386            // depended on which layer noticed — `CREATE INDEX ix ON t(nope)`
9387            // came out of storage and kept the old spelling.
9388            Self::ColumnNotFound { column } => write!(f, "column \"{column}\" does not exist"),
9389            Self::Corrupt(detail) => write!(f, "corrupt on-disk format: {detail}"),
9390            Self::IndexNotFound { name } => write!(f, "index \"{name}\" does not exist"),
9391            Self::Unsupported(detail) => write!(f, "unsupported: {detail}"),
9392            // v7.39 (round 220) — PG's exact 2200H wording.
9393            Self::SequenceExhausted {
9394                name,
9395                limit,
9396                is_max,
9397            } => write!(
9398                f,
9399                "nextval: reached {} value of sequence \"{name}\" ({limit})",
9400                if *is_max { "maximum" } else { "minimum" }
9401            ),
9402        }
9403    }
9404}
9405
9406impl ColumnSchema {
9407    pub fn new(name: impl Into<String>, ty: DataType, nullable: bool) -> Self {
9408        Self {
9409            name: name.into(),
9410            ty,
9411            nullable,
9412            collation_name: None,
9413            default: None,
9414            runtime_default: None,
9415            auto_increment: false,
9416            user_enum_type: None,
9417            user_domain_type: None,
9418            user_composite_type: None,
9419            acl: Vec::new(),
9420            on_update_runtime: None,
9421            collation: Collation::Binary,
9422            is_unsigned: false,
9423            inline_enum_variants: None,
9424            inline_set_variants: None,
9425            generated_stored_expr: None,
9426            identity_always: false,
9427            default_text: None,
9428            auto_restart: None,
9429            scalar_row_source: false,
9430            mysql_int_width: None,
9431            mysql_fsp: None,
9432            mysql_declared_timestamp: false,
9433            mysql_float_md: None,
9434        }
9435    }
9436
9437    /// v7.38.14 — the SAME column, re-described.
9438    ///
9439    /// `ColumnSchema::new` is for SYNTHESISING a column: a catalog row, an
9440    /// admin view, a computed output. It sets twenty-two fields to their
9441    /// defaults, which is right when there is no source column to speak of.
9442    ///
9443    /// It is wrong, and quietly so, when there IS one -- a join's combined
9444    /// schema, an aggregate's synthetic keys, a derived table's output. Those
9445    /// sites re-describe an existing column under a new name or type, and
9446    /// have each been written as `new(..)` followed by hand-picking a few
9447    /// attributes to copy across. They all pick differently and none picks
9448    /// them all.
9449    ///
9450    /// Five fields have been lost through that shape so far -- enum identity,
9451    /// MySQL fsp, the PG collation name, `ProjectedItem::fold_exempt`, and
9452    /// the `collation` enum -- and v7.38.14 alone found four sites dropping
9453    /// the last of those. The failure is never loud: `collation` defaults to
9454    /// `Binary`, which downstream reads as "byte-wise ON PURPOSE" rather than
9455    /// as "unknown", so a dropped declaration presents as a deliberate one.
9456    ///
9457    /// This constructor copies everything by construction. A field added to
9458    /// `ColumnSchema` therefore reaches every re-describe site without anyone
9459    /// having to remember, which is the property the hand-written copy lists
9460    /// never had.
9461    ///
9462    /// The two fields a re-describe legitimately changes -- name and
9463    /// nullability -- are parameters. Callers that also retype the column
9464    /// assign `ty` afterwards.
9465    #[must_use]
9466    pub fn rederive(source: &Self, name: impl Into<String>, nullable: bool) -> Self {
9467        Self {
9468            name: name.into(),
9469            nullable,
9470            ..source.clone()
9471        }
9472    }
9473
9474    /// Builder-style helper to attach a default value to an otherwise
9475    /// plain column schema. Used by the engine when CREATE TABLE
9476    /// specifies `column TYPE DEFAULT <expr>`.
9477    #[must_use]
9478    pub fn with_default(mut self, default: Value<'static>) -> Self {
9479        self.default = Some(default);
9480        self
9481    }
9482
9483    /// v7.9.21 — builder for runtime-evaluated defaults
9484    /// (`DEFAULT now()`, `DEFAULT CURRENT_TIMESTAMP`, …).
9485    /// `expr` is the Expr's `Display` form, re-parsed by the
9486    /// engine at each INSERT.
9487    #[must_use]
9488    pub fn with_runtime_default(mut self, expr: impl Into<String>) -> Self {
9489        self.runtime_default = Some(expr.into());
9490        self
9491    }
9492
9493    /// Builder-style helper to mark a column as `AUTO_INCREMENT`.
9494    #[must_use]
9495    pub const fn with_auto_increment(mut self) -> Self {
9496        self.auto_increment = true;
9497        self
9498    }
9499}
9500
9501impl TableSchema {
9502    pub fn new(name: impl Into<String>, columns: Vec<ColumnSchema>) -> Self {
9503        Self {
9504            name: name.into(),
9505            columns,
9506            hot_tier_bytes: None,
9507            foreign_keys: Vec::new(),
9508            uniqueness_constraints: Vec::new(),
9509            exclusion_constraints: Vec::new(),
9510            checks: Vec::new(),
9511            partition_role: None,
9512            policies: Vec::new(),
9513            row_security: false,
9514            force_row_security: false,
9515            owner: None,
9516            acl: Vec::new(),
9517        }
9518    }
9519}
9520
9521// =========================================================================
9522// Persistent binary format for the catalog.
9523//
9524// Layout (little-endian throughout):
9525//
9526//   [magic "SPGDB001" 8 bytes][version u8]
9527//   [table_count u32]
9528//   for each table:
9529//       [name_len u16][name bytes]
9530//       [col_count u16]
9531//       for each col:
9532//           [name_len u16][name bytes]
9533//           [type_tag u8 + optional payload]
9534//               1=Int 2=BigInt 3=Float 4=Text 5=Bool
9535//               6=Vector(u32 dim)
9536//               7=SmallInt
9537//               8=Varchar(u32 max)
9538//               9=Char(u32 size)
9539//               10=Numeric(u8 precision, u8 scale)
9540//               11=Date
9541//               12=Timestamp
9542//           [nullable u8]   0/1
9543//           [default_tag u8] 0=none 1=value (followed by [value_tag u8] + bytes)
9544//       [row_count u32]
9545//       for each row, for each col, one [value_tag u8] + value bytes:
9546//           tag 0 (Null)     → no body
9547//           tag 1 (Int)      → i32 LE
9548//           tag 2 (BigInt)   → i64 LE
9549//           tag 3 (Float)    → f64 LE
9550//           tag 4 (Text)     → u16 LE len + UTF-8 bytes
9551//           tag 5 (Bool)     → u8 0/1
9552//           tag 6 (Vector)   → u32 LE dim + dim×f32 LE
9553//           tag 7 (SmallInt) → i16 LE
9554//           tag 8 (Numeric)  → i128 LE (16 bytes) + u8 scale
9555//           tag 9 (Date)     → i32 LE (days since Unix epoch)
9556//           tag 10 (Timestamp) → i64 LE (microseconds since Unix epoch)
9557//
9558// Bumped to version 3 when NUMERIC was added; to version 4 when
9559// AUTO_INCREMENT (per-column flag) + NSW index `kind` byte landed;
9560// to version 5 when DATE / TIMESTAMP were added; to version 6 when
9561// NSW graph topology started travelling on disk (v2.7); to version 7
9562// when the NSW topology became multi-layer HNSW (v2.13); to version 8
9563// when row encoding switched to schema-driven dense layout (v3.0.2 —
9564// per-row NULL bitmap + per-column fixed-width body, no per-cell type
9565// tag).
9566// =========================================================================
9567
9568const FILE_MAGIC: &[u8; 8] = b"SPGDB001";
9569/// Current catalog snapshot format version emitted by [`Catalog::serialize`].
9570///
9571/// v9 (v5.2) extends v8 by serialising `BTree` index entries directly — every
9572/// `(IndexKey, Vec<RowLocator>)` pair travels on disk with the v5.1
9573/// `RowLocator::write_le` tag-prefixed codec. v8 `BTree` indices stored no
9574/// entries at all (the map was rebuilt from `Table::rows` on load); v9
9575/// preserves on-disk Cold locators so freezer-produced cold-tier index
9576/// entries survive a catalog snapshot round-trip. v8 readers are accepted
9577/// by version dispatch in [`Catalog::deserialize`] — every entry decodes
9578/// as `RowLocator::Hot(_)` via `add_index` rebuild, identical to v5.1
9579/// behaviour.
9580/// v6.7.2 — bumped from 10 to 11 to append per-table
9581/// `hot_tier_bytes: Option<u64>` after the per-table indices
9582/// section. v10 catalogs (v6.7.1) load with `hot_tier_bytes =
9583/// None` for every table (the deserialiser short-circuits when
9584/// version < 11). v11 snapshots written by a pre-v6.7.2 binary
9585/// fail loudly at the version check, matching the v6.1.2 /
9586/// v6.1.4 / v6.2.0 / v6.7.1 envelope-bump upgrade fences.
9587///
9588/// v6.8.0 — bumped from 11 to 12: per-index
9589/// `included_columns: Vec<u16>` appended at the tail of each
9590/// index payload. v11 (= v6.7.2) catalogs load with
9591/// `included_columns = Vec::new()` for every index — same
9592/// "older readers, append-only extension" pattern as the v6.7.2
9593/// hot_tier_bytes byte.
9594/// v7.13.0 — bumped from 22 to 23. mailrs round-5 G3 / G10.
9595/// Per-table appendix gains two new sections:
9596///   * `checks: Vec<String>` — CHECK predicate sources (Display
9597///     form of the AST Expr); re-parsed on INSERT/UPDATE to
9598///     enforce against candidate rows. Same persistence pattern
9599///     as `Index::partial_predicate`.
9600///   * Per `UniquenessConstraint`: trailing `nulls_not_distinct:
9601///     u8` flag for PG 15+ `UNIQUE NULLS NOT DISTINCT (cols)`
9602///     semantics.
9603/// v22 catalogs deserialise with empty `checks` and every UC
9604/// at `nulls_not_distinct = false`.
9605/// v24 introduces:
9606///   * Index kind tag 4 = trigram-GIN (`gin_trgm_ops`-flavoured
9607///     `USING gin` over a TEXT/VARCHAR column). Payload shape is
9608///     identical to tag-3 GIN (String → Vec<RowLocator>); the
9609///     keys are PG-compatible 3-byte trigram shingles instead of
9610///     tsvector lexemes. v23 catalogs deserialise unchanged — no
9611///     v23 writer ever emitted tag 4.
9612/// v25 introduces:
9613///   * Per `TriggerDef`: trailing `enabled: u8` flag (mailrs
9614///     round-9 A.2.b — `ALTER TABLE … { ENABLE | DISABLE }
9615///     TRIGGER …`). v24 catalogs deserialise with every trigger
9616///     `enabled = true`, matching pre-v7.16.1 behaviour.
9617/// v26 introduces (v7.17.0 Phase 1.1):
9618///   * Trailing SEQUENCE catalog block after triggers. Encoded
9619///     as `u32 count` followed by per-sequence:
9620///     `name`, `data_type: u8` (0=SmallInt,1=Int,2=BigInt),
9621///     `start i64`, `increment i64`, `min_value i64`,
9622///     `max_value i64`, `cache i64`, `cycle u8`,
9623///     `owned_by_tag u8` (0=NONE, 1=Column → `table`,`column`),
9624///     `last_value i64`, `is_called u8`. v25-and-below catalogs
9625///     deserialise with an empty sequences map.
9626/// v27 introduces (v7.17.0 Phase 1.2):
9627///   * Trailing VIEW catalog block after sequences. Encoded as
9628///     `u32 count` followed by per-view:
9629///     `name`, `column_count u16`, then column names, then
9630///     `body` long-string. v26-and-below catalogs deserialise
9631///     with an empty views map.
9632/// v28 introduces (v7.17.0 Phase 1.3):
9633///   * Trailing MATERIALIZED VIEW source registry block after
9634///     views. Encoded as `u32 count` followed by per-entry:
9635///     `name`, `body` long-string. The materialised rows live
9636///     as a regular Table of the same name (already covered by
9637///     the pre-existing tables block). v27-and-below catalogs
9638///     deserialise with an empty map.
9639/// v29 introduces (v7.17.0 Phase 1.4):
9640///   * Per-table user_enum_type appendix (after the CHECK
9641///     appendix). Layout: `u16 count` followed by per-binding
9642///     `[u16 col_pos][str enum_name]`. Only columns whose
9643///     `user_enum_type` is Some land here; the catalog stays
9644///     compact for the common no-enum case.
9645///   * Trailing ENUM types catalog block after materialized
9646///     views. Encoded as `u32 count` followed by per-entry:
9647///     `name`, `u16 label_count`, then `label_count` short
9648///     strings. v28-and-below catalogs deserialise with an
9649///     empty enum_types map and every column's
9650///     `user_enum_type = None`.
9651/// v30 introduces (v7.17.0 Phase 1.5):
9652///   * Per-table user_domain_type appendix (after the
9653///     user_enum_type appendix). Same shape as the enum one.
9654///   * Trailing DOMAIN types catalog block after the enum
9655///     block. Encoded as `u32 count` followed by per-entry:
9656///     `name`, `data_type` byte, `nullable u8`,
9657///     `default_present u8` + optional default string,
9658///     `u16 check_count` then `check_count` Display-form
9659///     CHECK strings. v29-and-below catalogs deserialise with
9660///     an empty domain_types map and `user_domain_type = None`.
9661/// v31 introduces (v7.17.0 Phase 1.6):
9662///   * Trailing user-schemas block after the DOMAIN block.
9663///     Encoded as `u32 count` followed by `count` schema-name
9664///     short strings. Built-in schemas (`public`, `pg_catalog`,
9665///     `information_schema`) are NOT serialised — they're
9666///     hardcoded in `is_builtin_schema`. v30-and-below catalogs
9667///     deserialise with an empty user-schemas set.
9668/// v32 introduces (v7.17.0 Phase 2.1):
9669///   * Per-table on_update_runtime appendix (after the
9670///     user_domain_type appendix). Layout: `u16 count` followed
9671///     by per-binding `[u16 col_pos][str expr_src]`. Only
9672///     columns whose `on_update_runtime` is Some land here;
9673///     the catalog stays compact when no MySQL-shaped table
9674///     uses the attribute. v31-and-below catalogs deserialise
9675///     with every column's `on_update_runtime = None`.
9676/// v33 introduces (v7.17.0 Phase 2.2):
9677///   * Index kind tag 5 = fulltext-GIN (MySQL `FULLTEXT KEY`
9678///     surface over a TEXT / VARCHAR column). Payload shape is
9679///     identical to tag-3 / tag-4 GIN (`String → Vec<RowLocator>`);
9680///     the keys are lower-cased word lexemes (same rule as
9681///     `to_tsvector('simple', text)`). v32 catalogs deserialise
9682///     unchanged — no v32 writer ever emitted tag 5, and FULLTEXT
9683///     KEY was silently dropped pre-v7.17 so no rebuild shim is
9684///     needed for round-tripped catalogs.
9685/// v34 introduces (v7.17.0 Phase 2.5):
9686///   * Per-table collation appendix (after the on_update_runtime
9687///     appendix). Sparse layout: only columns whose `collation`
9688///     is non-Binary land here. `u16 count` then per-binding
9689///     `[u16 col_pos][u8 collation_tag]` where the tag matches
9690///     `Collation::TAG_*`. Snapshots written by v33-and-below
9691///     readers deserialise every column with `collation =
9692///     Binary`, preserving the prior byte-wise compare
9693///     semantics. Unknown tags read back as Binary too — keeps
9694///     a forward-compat path if a future v35 adds variants
9695///     and someone rolls back to a v34 reader.
9696/// v35 introduces (v7.17.0 Phase 4.4):
9697///   * Per-table is_unsigned appendix (after the collation
9698///     appendix). Sparse layout: only `is_unsigned = true`
9699///     columns land. `u16 count` then per-binding `[u16 col_pos]`.
9700///     v34-and-below catalogs deserialise every column as
9701///     `is_unsigned = false`, preserving the prior silent-
9702///     accept behaviour for negative inserts on UNSIGNED columns.
9703/// v46 introduces (v7.23, mailrs round-14):
9704///   * Escaped short-string codec — `write_str` lengths >= 0xFFFF
9705///     emit `[u16 0xFFFF][u32 real_len]` so TEXT cells (mail bodies,
9706///     document text) above 64 KiB encode instead of panicking.
9707///     One-way upgrade: v45-and-below readers reject v46 catalogs
9708///     loudly via the version gate; v46 readers decode v45 catalogs
9709///     with the plain-u16 rules (0xFFFF is a legitimate length
9710///     there).
9711/// v47 introduces (v7.27, mailrs round-21):
9712///   * Escaped lengths for the REMAINING u16-length cell payloads —
9713///     BYTEA cells, TEXT[] elements, tsvector lexemes and tsquery
9714///     terms — the same `[u16 0xFFFF][u32 real_len]` escape v46
9715///     gave short strings. Round-14 fixed TEXT and missed these;
9716///     round-21 fired the BYTEA twin during a production migration.
9717///     One-way upgrade, same posture as v46.
9718/// v48 introduces (v7.37.5 β-P2, sentori cutover window):
9719///   * `INTERVAL` becomes a real column type. Catalog tag 34 in
9720///     `write_data_type`; per-row body is a fixed 16 bytes
9721///     (i64 micros + i32 days + i32 months, LE, PG-byte-equal
9722///     field order). The runtime-only days collapse is gone —
9723///     `'1 day'` and `'24 hours'` are stored distinctly. One-way
9724///     upgrade: v47 catalogs without INTERVAL columns deserialise
9725///     identically; v47 readers fed a v48 catalog that contains
9726///     INTERVAL hit the explicit "unknown data type tag: 34"
9727///     fence in `read_data_type`.
9728/// v49 introduces (v7.37.6-B, sentori Epic 2 P0):
9729///   * Per-table partition role appendix(declarative
9730///     `PARTITION BY RANGE` parent / range child / DEFAULT
9731///     child)。Layout, written **after** the inline_set_variants
9732///     appendix and **before** the per-table block close:
9733///       `[u8 role_tag]`
9734///         0 = `None`(普通表,后向兼容默认)
9735///         1 = `Parent`:  `[u8 kind_tag (0=Range)]`
9736///                        `[u16 key_col_count]` `(× u16 col_pos)`
9737///                        `[u16 tmpl_count]` `(× str source)`
9738///         2 = `Range`:   `[str parent_name]` `[Bound]` `[Bound]`
9739///         3 = `Default`: `[str parent_name]`
9740///     `PartitionBound` codec:
9741///       `[u8 bound_tag]` 0=MinValue 1=MaxValue 2=TimestampTz(`[i64 LE micros]`)
9742///     v48-and-below readers stop after the inline_set_variants
9743///     block — they don't see this appendix and deserialise every
9744///     table with `partition_role = None`. v49 writers always emit
9745///     `[0]` for plain tables, so the encoding stays one-byte-cheap.
9746/// v50 introduces (v7.37.7, sentori Epic 3 P1):
9747///   * Per-table `generated_stored_expr` appendix(stored generated
9748///     columns — `GENERATED ALWAYS AS (<expr>) STORED`)。Layout,
9749///     written **after** the partition_role appendix and before
9750///     the per-table block close:
9751///       `[u16 binding_count]`
9752///       `binding_count × { [u16 col_pos][str expr_source] }`
9753///     Sparse — only generated columns land here, so plain-shape
9754///     catalogs stay byte-for-byte identical save for the new
9755///     u16 zero count. v49-and-below readers stop after the
9756///     partition_role appendix; v50 readers default every column
9757///     to `generated_stored_expr = None` when this block is absent.
9758/// v51 introduces (v7.37.8, sentori Epic 5 P2):
9759///   * Per-index tag byte 6 = `GinJsonb`(real posting-list GIN
9760///     over a JSONB column). Payload shape mirrors tag-3 / 4 / 5:
9761///     `[u32 posting_list_count]` then `(str token, u32 locator_count,
9762///     locators …)` per posting list. Same `write_str` /
9763///     `RowLocator::write_le` codec as the rest of the GIN family.
9764///     v50 catalogs never wrote tag 6(the same DDL loaded as a
9765///     BTree fallback); v51 readers see tag 6 explicitly and dispatch
9766///     into `IndexKind::GinJsonb`.
9767/// v52 introduces (v7.37.42-T2 ζ-B composite + domain metasystem):
9768///   * Trailing COMPOSITE-types catalog block after the
9769///     user-schemas block. Encoded as `u32 count` followed by
9770///     per-entry: `name`, `u16 field_count`, then `field_count`
9771///     `[str field_name][data_type]` pairs (`write_data_type` is
9772///     reused). v51-and-below catalogs deserialise with an empty
9773///     composite_types map; v52 readers tolerate v51 catalogs by
9774///     stopping at the schema block (no composite block present
9775///     ⇒ empty map). Composite types are referenced by columns
9776///     via `ColumnSchema.user_composite_type`, mirroring the
9777///     `user_enum_type` / `user_domain_type` pattern. The block
9778///     lands here (not as a per-table appendix) so dropping the
9779///     composite type registers globally and DROP TYPE can find it
9780///     without a table scan.
9781/// v53 introduces (v7.37.16 Epic W — cross-checkpoint tombstone
9782///   durability):
9783///   * Trailing per-table MVCC appendix carrying, for every row,
9784///     its `RowHeader` (`xmin:u64`, `xmax:u64`, `flags:u8`) and its
9785///     stable `RowId` (`u64`), followed by the relation's
9786///     `next_rowid:u64`. Layout per table (after the v50
9787///     generated_stored_expr block, before the table loop closes):
9788///       `[u32 row_count]` (== `Table::rows().len()`, cross-check)
9789///       per row in physical order:
9790///         `[u64 xmin][u64 xmax][u8 flags][u64 rowid]`
9791///       `[u64 next_rowid]`
9792///     v52-and-below catalogs never wrote this block; their reader
9793///     stops after the last per-table appendix and
9794///     `deserialize_rows` leaves every row `RowHeader::frozen()`
9795///     with dense 1..=N ids — the exact pre-v53 contract. A v53
9796///     reader instead reconstructs headers + ids VERBATIM, so a
9797///     tombstone-redo naming a row inserted before the last
9798///     checkpoint resolves by `RowId` across the base-snapshot
9799///     boundary (closing the coupling the Epic W WAL slices deferred
9800///     to this format bump). Because the reader routes on `version`,
9801///     the block is strictly backward-compatible: old images load
9802///     byte-for-byte as before. `SPG_MVCC_INPLACE` is unaffected —
9803///     a gate-off database's rows are all frozen/alive, so
9804///     persisting + restoring their headers is observationally a
9805///     no-op.
9806/// v7.38 (read01 P5.05) — v54 appends a CRC32C over the whole preceding
9807/// image so a corrupted `base.spg` is caught on load instead of silently
9808/// deserialising garbage. Older images (v8..=53) carry no trailer and load
9809/// unchanged.
9810/// v7.39 (round 210) — v72 appends a per-table EXCLUDE-constraint appendix
9811/// (sparse: only tables carrying an EXCLUDE write it) at the very end of the
9812/// per-table block, after the column-ACL appendix. A v71 reader stops before
9813/// it and its tables read back with no exclusion constraints, which is what
9814/// they were.
9815/// v7.39 (round 220) — v73 appends a per-table identity-RESTART appendix
9816/// (sparse: [u16 count] then per entry [u16 col_pos][i64 LE floor]) after
9817/// the EXCLUDE appendix. A v72 reader stops before it; its columns read
9818/// back with no RESTART floor, losing only an un-consumed
9819/// `ALTER … RESTART WITH` across a restart.
9820/// r1039 — v90 adds index-key tags 4 (bytea) and 5 (the canonical
9821/// numeric key), so BYTEA and NUMERIC columns carry a real B-tree
9822/// instead of falling back to a scan. A v89 reader meeting either tag
9823/// reports a corrupt catalog rather than mis-reading it, which is the
9824/// same forward-compatibility story tag 3 (uuid) had at v36.
9825/// v7.39.13 — v97 changes what a TIMETZ key CONTAINS. It held the UTC
9826/// instant alone, which files values PostgreSQL calls distinct under
9827/// one key; it now holds the instant and the offset, in the pair order
9828/// [`timetz_sort_key`] defines. Nothing before v97 could observe the
9829/// old form — `timetz` had no comparison operator, so no probe was ever
9830/// built — but a v96 file's entries are in it, so a v96 catalog has its
9831/// timetz indexes rebuilt on load.
9832/// v7.40.0 — v98 adds DataType tags 79..=83 for `real[]`, `time[]`,
9833/// `timetz[]`, `inet[]` and `xml[]`. Purely additive: no v97 file can
9834/// contain one, because none of those five was a column type. The
9835/// version moves so that a v97 binary meeting tag 79 reports a version
9836/// it does not know rather than a corrupt catalog — the same story tag
9837/// 3 (uuid) had at v36 and tags 4/5 had at v90.
9838/// v7.40.0 — v99 appends the MySQL index prefix (`KEY k (b(4))`) to the
9839/// per-index block, after the two constraint flags v96 added. A v98
9840/// reader stops before it and reads every index as un-prefixed, which
9841/// is what a v98 snapshot recorded: the parser dropped the length.
9842const FILE_VERSION: u8 = 99;
9843
9844/// v7.37 (round 833) — the codec version to decode a row that
9845/// [`encode_row_body_dense`] has just produced.
9846///
9847/// That encoder always writes the newest form, and every decoder gate is
9848/// a `codec_version >= N` feature test, so a freshly encoded row must be
9849/// read at the current version. Cold segments carry their own version in
9850/// their header and keep passing that; this is for in-process round
9851/// trips — sort runs on temp storage — where the bytes never outlive the
9852/// build that wrote them.
9853pub const CURRENT_ROW_CODEC_VERSION: u8 = FILE_VERSION;
9854/// First version that appends the trailing CRC32C integrity trailer.
9855const FILE_VERSION_CRC_TRAILER: u8 = 54;
9856/// Oldest format version [`Catalog::deserialize`] still accepts. v8 is the
9857/// v3.0.2 dense-row layout; pre-v8 catalogs require an offline migration.
9858const MIN_SUPPORTED_FILE_VERSION: u8 = 8;
9859
9860// IndexKey wire format (v9):
9861//   tag 0 = Int  → [i64 LE]
9862//   tag 1 = Text → [u16 LE len + UTF-8 bytes] (via write_str / read_str)
9863//   tag 2 = Bool → [u8 0/1]
9864const INDEX_KEY_TAG_INT: u8 = 0;
9865const INDEX_KEY_TAG_TEXT: u8 = 1;
9866const INDEX_KEY_TAG_BOOL: u8 = 2;
9867/// v7.17.0 — `IndexKey::Uuid([u8; 16])`. Body = raw 16 bytes
9868/// (RFC 4122 byte order). Persisted only in FILE_VERSION 36+
9869/// catalogs.
9870const INDEX_KEY_TAG_UUID: u8 = 3;
9871/// r1039 — `IndexKey::Bytes`. Body = [u32 LE len][raw bytes].
9872/// Persisted only in FILE_VERSION 90+ catalogs.
9873const INDEX_KEY_TAG_BYTES: u8 = 4;
9874/// r1039 — `IndexKey::Numeric`. Body = [u8 class][u8 neg][i32 LE exp]
9875/// [u32 LE digit count][one byte per decimal digit, 0..=9, MSD first].
9876/// Persisted only in FILE_VERSION 90+ catalogs.
9877const INDEX_KEY_TAG_NUMERIC: u8 = 5;
9878/// v7.38.1 (L12) — `IndexKey::Null`, a NULL component inside a
9879/// composite key. No body. Persisted only inside tag-7 multi-index
9880/// payloads, FILE_VERSION 91+.
9881const INDEX_KEY_TAG_NULL: u8 = 6;
9882
9883impl Catalog {
9884    /// Serialize the whole catalog (schema + every row) into a self-contained
9885    /// byte buffer. Format is documented above the impl block.
9886    pub fn serialize(&self) -> Vec<u8> {
9887        self.serialize_at(FILE_VERSION)
9888    }
9889
9890    /// v7.40.0 — the same image as an OLDER writer would have produced.
9891    ///
9892    /// A test that wants a pre-vN file has until now stamped the version
9893    /// byte on a current image and repaired the CRC. That works only
9894    /// while the newer versions add nothing to any block: v97 changed
9895    /// what an index KEY contains and v98 added DataType tags, so both
9896    /// left the byte layout alone — and v99, which appends the MySQL
9897    /// index prefix to every index, broke the trick with a one-byte
9898    /// desynchronisation reported as `MVCC header appendix row count
9899    /// 768 != decoded rows 3`.
9900    ///
9901    /// So the writer takes the version. Only the fields a version ADDED
9902    /// are conditional; everything older is unconditional, which is what
9903    /// makes this readable.
9904    pub(crate) fn serialize_at(&self, version: u8) -> Vec<u8> {
9905        let mut out = Vec::with_capacity(64);
9906        out.extend_from_slice(FILE_MAGIC);
9907        out.push(version);
9908        write_u32(
9909            &mut out,
9910            u32::try_from(self.tables.len()).expect("≤ 4G tables"),
9911        );
9912        for t in &self.tables {
9913            write_str(&mut out, &t.schema.name);
9914            write_u16(
9915                &mut out,
9916                u16::try_from(t.schema.columns.len()).expect("≤ 65k columns/table"),
9917            );
9918            for c in &t.schema.columns {
9919                write_str(&mut out, &c.name);
9920                write_data_type(&mut out, c.ty);
9921                out.push(u8::from(c.nullable));
9922                match &c.default {
9923                    None => out.push(0),
9924                    Some(v) => {
9925                        out.push(1);
9926                        write_value(&mut out, v);
9927                    }
9928                }
9929                out.push(u8::from(c.auto_increment));
9930            }
9931            write_u32(
9932                &mut out,
9933                u32::try_from(t.rows.len()).expect("≤ 4G rows/table"),
9934            );
9935            // v3.0.2 dense row encoding (FILE_VERSION 8): per-row NULL
9936            // bitmap, then tightly-packed bodies. Identical wire format
9937            // as before — extracted into `encode_row_body_dense` so cold-
9938            // tier segments (v5.1+) can share the encoding.
9939            for row in &t.rows {
9940                out.extend_from_slice(&encode_row_body_dense(row, &t.schema));
9941            }
9942            // Index definitions. Per-index payload:
9943            //   [name][col_pos u16][kind u8]
9944            //     kind 0 = B-tree           (no params — rebuilt on load)
9945            //     kind 1 = NSW graph        (u16 M + serialized graph)
9946            // For NSW the graph topology travels on disk so startup
9947            // doesn't re-run the O(n²M) rebuild — see v2.7 notes.
9948            write_u16(
9949                &mut out,
9950                u16::try_from(t.indices.len()).expect("≤ 65k indices/table"),
9951            );
9952            for idx in &t.indices {
9953                write_str(&mut out, &idx.name);
9954                write_u16(
9955                    &mut out,
9956                    u16::try_from(idx.column_position).expect("≤ 65k columns/table"),
9957                );
9958                match &idx.kind {
9959                    IndexKind::BTree(map) => {
9960                        out.push(0);
9961                        // v9: serialise the full PB map. Each entry's
9962                        // RowLocator list travels with the tag-prefixed
9963                        // codec from `row_locator::write_le`, so freezer-
9964                        // produced Cold locators survive a snapshot
9965                        // round-trip. v8 BTree wrote nothing here and
9966                        // rebuilt from rows — v9 readers tolerate v8 by
9967                        // version dispatch in `Catalog::deserialize`.
9968                        write_u32(
9969                            &mut out,
9970                            u32::try_from(map.len()).expect("≤ 4G index entries/index"),
9971                        );
9972                        for (key, locators) in map {
9973                            write_index_key(&mut out, key);
9974                            write_u32(
9975                                &mut out,
9976                                u32::try_from(locators.len()).expect("≤ 4G locators/key"),
9977                            );
9978                            for loc in locators {
9979                                loc.write_le(&mut out);
9980                            }
9981                        }
9982                    }
9983                    // v7.38.1 (L12) — tag byte 7 = BTreeMulti. Payload
9984                    // mirrors the tag-0 BTree encoding, with each key
9985                    // written as `[u16 arity]` followed by that many
9986                    // `write_index_key` components. FILE_VERSION 91+;
9987                    // older catalogs never carried a multi index, so no
9988                    // migration shim is needed.
9989                    IndexKind::BTreeMulti(map) => {
9990                        out.push(7);
9991                        write_u32(
9992                            &mut out,
9993                            u32::try_from(map.len()).expect("≤ 4G index entries/index"),
9994                        );
9995                        for (key, locators) in map {
9996                            write_u16(
9997                                &mut out,
9998                                u16::try_from(key.len()).expect("≤ 65k key components"),
9999                            );
10000                            for component in key.iter() {
10001                                write_index_key(&mut out, component);
10002                            }
10003                            write_u32(
10004                                &mut out,
10005                                u32::try_from(locators.len()).expect("≤ 4G locators/key"),
10006                            );
10007                            for loc in locators {
10008                                loc.write_le(&mut out);
10009                            }
10010                        }
10011                    }
10012                    IndexKind::Nsw(g) => {
10013                        out.push(1);
10014                        write_u16(&mut out, u16::try_from(g.m).expect("≤ 65k NSW neighbours"));
10015                        write_nsw_graph(&mut out, g);
10016                    }
10017                    IndexKind::Brin { column_type, .. } => {
10018                        // v6.7.1 — tag byte 2 = BRIN. Payload is the
10019                        // column type code (1 byte mapping to the
10020                        // shared DataType numeric encoding); no
10021                        // further data — BRIN summaries live in
10022                        // cold segments, not the catalog.
10023                        out.push(2);
10024                        write_data_type(&mut out, *column_type);
10025                    }
10026                    IndexKind::Gin(map) => {
10027                        // v7.12.3 — tag byte 3 = GIN. Payload mirrors
10028                        // the BTree encoding but with String (lexeme
10029                        // word) keys instead of IndexKey. Tag-prefixed
10030                        // RowLocator codec so freezer-produced Cold
10031                        // locators survive snapshot round-trip.
10032                        // FILE_VERSION 21+; v20 catalogs never wrote a
10033                        // GIN index (the AM degraded to BTree fallback
10034                        // pre-v7.12.3), so no migration shim is needed.
10035                        out.push(3);
10036                        write_u32(
10037                            &mut out,
10038                            u32::try_from(map.len()).expect("≤ 4G GIN posting lists"),
10039                        );
10040                        for (word, locators) in map {
10041                            write_str(&mut out, word);
10042                            write_u32(
10043                                &mut out,
10044                                u32::try_from(locators.len()).expect("≤ 4G locators/posting list"),
10045                            );
10046                            for loc in locators {
10047                                loc.write_le(&mut out);
10048                            }
10049                        }
10050                    }
10051                    IndexKind::GinTrgm(map) => {
10052                        // v7.15.0 — tag byte 4 = GinTrgm
10053                        // (`gin_trgm_ops` GIN over a TEXT column).
10054                        // Payload shape is identical to tag-3 GIN —
10055                        // `String → Vec<RowLocator>` posting lists.
10056                        // The String keys are 3-byte trigrams instead
10057                        // of tsvector lexemes; the deserializer
10058                        // dispatches on the tag, not the key shape.
10059                        // FILE_VERSION 24+; v23 catalogs never wrote
10060                        // a trigram-GIN.
10061                        out.push(4);
10062                        write_u32(
10063                            &mut out,
10064                            u32::try_from(map.len()).expect("≤ 4G trigram-GIN posting lists"),
10065                        );
10066                        for (tri, locators) in map {
10067                            write_str(&mut out, tri);
10068                            write_u32(
10069                                &mut out,
10070                                u32::try_from(locators.len()).expect("≤ 4G locators/posting list"),
10071                            );
10072                            for loc in locators {
10073                                loc.write_le(&mut out);
10074                            }
10075                        }
10076                    }
10077                    IndexKind::GinFulltext(map) => {
10078                        // v7.17.0 Phase 2.2 — tag byte 5 =
10079                        // GinFulltext (MySQL `FULLTEXT KEY` GIN
10080                        // over a TEXT/VARCHAR column). Payload
10081                        // shape mirrors tag-3 / tag-4 GIN —
10082                        // `String → Vec<RowLocator>` posting
10083                        // lists keyed by lower-cased word
10084                        // lexemes. FILE_VERSION 33+; v32 catalogs
10085                        // never wrote a fulltext-GIN (FULLTEXT
10086                        // KEY was silently dropped pre-v7.17).
10087                        out.push(5);
10088                        write_u32(
10089                            &mut out,
10090                            u32::try_from(map.len()).expect("≤ 4G fulltext-GIN posting lists"),
10091                        );
10092                        for (lex, locators) in map {
10093                            write_str(&mut out, lex);
10094                            write_u32(
10095                                &mut out,
10096                                u32::try_from(locators.len()).expect("≤ 4G locators/posting list"),
10097                            );
10098                            for loc in locators {
10099                                loc.write_le(&mut out);
10100                            }
10101                        }
10102                    }
10103                    IndexKind::GinJsonb(map) => {
10104                        // v7.37.8 — tag byte 6 = GinJsonb
10105                        // (real posting-list GIN over a JSONB
10106                        // column; sentori Epic 5 P2). Payload
10107                        // shape mirrors tag-3 / 4 / 5 — keys are
10108                        // the canonical `(path, leaf)` tokens
10109                        // from `jsonb_gin::extract_tokens`.
10110                        // FILE_VERSION 51+; v50 catalogs never
10111                        // wrote a JSONB-GIN (the same DDL loaded
10112                        // as a BTree fallback).
10113                        out.push(6);
10114                        write_u32(
10115                            &mut out,
10116                            u32::try_from(map.len()).expect("≤ 4G JSONB-GIN posting lists"),
10117                        );
10118                        for (token, locators) in map {
10119                            write_str(&mut out, token);
10120                            write_u32(
10121                                &mut out,
10122                                u32::try_from(locators.len()).expect("≤ 4G locators/posting list"),
10123                            );
10124                            for loc in locators {
10125                                loc.write_le(&mut out);
10126                            }
10127                        }
10128                    }
10129                }
10130                // v6.8.0 — included_columns appendix per index.
10131                // Layout: [u16 num_included][num × u16 column_position].
10132                // v11 readers stop before this u16 (deserialise loop
10133                // gated on version >= 12); v12+ readers always
10134                // consume it. Empty Vec serialises as a bare 0u16.
10135                write_u16(
10136                    &mut out,
10137                    u16::try_from(idx.included_columns.len()).expect("≤ 65k INCLUDE columns/index"),
10138                );
10139                for col_pos in &idx.included_columns {
10140                    write_u16(
10141                        &mut out,
10142                        u16::try_from(*col_pos).expect("≤ 65k columns/table"),
10143                    );
10144                }
10145                // v6.8.1 — partial_predicate appendix per index.
10146                // Layout: [u8 has_pred][u16 LE len][bytes (if has_pred)].
10147                // Same v12 gate as included_columns.
10148                match &idx.partial_predicate {
10149                    None => out.push(0),
10150                    Some(pred) => {
10151                        out.push(1);
10152                        write_str(&mut out, pred);
10153                    }
10154                }
10155                // v6.8.2 — expression appendix. Same shape as
10156                // partial_predicate.
10157                match &idx.expression {
10158                    None => out.push(0),
10159                    Some(expr) => {
10160                        out.push(1);
10161                        write_str(&mut out, expr);
10162                    }
10163                }
10164                // v7.9.29 — is_unique appendix (FILE_VERSION 16+).
10165                // Single byte 0/1. v15-and-below readers stop before
10166                // this byte; v16 readers always consume it. mailrs K1.
10167                out.push(u8::from(idx.is_unique));
10168                // v7.9.29 — extra_column_positions appendix.
10169                // Layout: [u16 count][count × u16 column_position].
10170                write_u16(
10171                    &mut out,
10172                    u16::try_from(idx.extra_column_positions.len())
10173                        .expect("≤ 65k extra cols / index"),
10174                );
10175                for cp in &idx.extra_column_positions {
10176                    write_u16(&mut out, u16::try_from(*cp).expect("≤ 65k columns/table"));
10177                }
10178                // v7.39 (read01 round 52) — nulls_not_distinct (FILE_VERSION
10179                // 62+). Appended at the end of the per-index block so the v16
10180                // layout above is untouched; v61-and-below readers stop before
10181                // this byte and default the flag to false (NULLS DISTINCT).
10182                out.push(u8::from(idx.nulls_not_distinct));
10183                // v7.39 (round 537) — the key column's ordering clause
10184                // (FILE_VERSION 83+).
10185                out.push(u8::from(idx.descending));
10186                out.push(match idx.nulls_first {
10187                    None => 0,
10188                    Some(true) => 1,
10189                    Some(false) => 2,
10190                });
10191                // v7.39 (round 538) — the key's explicit collation
10192                // (FILE_VERSION 84+).
10193                match &idx.collation {
10194                    Some(c) => {
10195                        out.push(1);
10196                        write_str(&mut out, c);
10197                    }
10198                    None => out.push(0),
10199                }
10200                // v7.39.11 — the EXTRA key columns' ordering clauses
10201                // (FILE_VERSION 95+). Appended after the collation so a
10202                // v94 reader stops before it and defaults every extra
10203                // to ascending / nulls last, which is what those
10204                // snapshots recorded.
10205                write_u16(
10206                    &mut out,
10207                    u16::try_from(idx.extra_orders.len()).expect("\u{2264} 65k extra cols / index"),
10208                );
10209                for o in &idx.extra_orders {
10210                    out.push(u8::from(o.descending));
10211                    out.push(match o.nulls_first {
10212                        None => 0,
10213                        Some(true) => 1,
10214                        Some(false) => 2,
10215                    });
10216                }
10217                // v7.39.13 — whether SPG built this index for a
10218                // constraint's non-leading columns (FILE_VERSION 96+).
10219                // A v95 reader stops before this byte and reads every
10220                // index as user-created, which is what those snapshots
10221                // recorded and what the catalog said about them.
10222                out.push(u8::from(idx.constraint_internal));
10223                out.push(u8::from(idx.constraint_backing));
10224                // v7.40.0 — the MySQL index prefix `KEY k (b(4))`
10225                // (FILE_VERSION 99+). A v98 reader stops before these
10226                // bytes and reads the index as un-prefixed, which is
10227                // what those snapshots recorded.
10228                if version >= 99 {
10229                    match idx.prefix_len {
10230                        None => out.push(0),
10231                        Some(n) => {
10232                            out.push(1);
10233                            out.extend_from_slice(&n.to_le_bytes());
10234                        }
10235                    }
10236                }
10237            }
10238            // v6.7.2 — per-table hot_tier_bytes Option<u64>.
10239            // Layout: [u8 has_value][u64 LE value (if has_value)].
10240            // v10 readers stop before this byte (deserialise loop
10241            // gated on version >= 11); v11+ readers always
10242            // consume it.
10243            match t.schema.hot_tier_bytes {
10244                None => out.push(0),
10245                Some(n) => {
10246                    out.push(1);
10247                    out.extend_from_slice(&n.to_le_bytes());
10248                }
10249            }
10250            // v7.6.1 — FOREIGN KEY appendix (catalog FILE_VERSION 13+).
10251            // Layout: [u16 LE fk_count]
10252            //   per fk:
10253            //     [u8 has_name] [str name (if has_name)]
10254            //     [u16 LE local_arity] [u16 LE local_pos]*arity
10255            //     [str parent_table]
10256            //     [u16 LE parent_arity] [u16 LE parent_pos]*arity
10257            //     [u8 on_delete_tag] [u8 on_update_tag]
10258            // Older catalogs (v12 and below) skip this block entirely;
10259            // their reader stops before this byte.
10260            write_u16(
10261                &mut out,
10262                u16::try_from(t.schema.foreign_keys.len()).expect("≤ 65k FKs/table"),
10263            );
10264            for fk in &t.schema.foreign_keys {
10265                match &fk.name {
10266                    None => out.push(0),
10267                    Some(n) => {
10268                        out.push(1);
10269                        write_str(&mut out, n);
10270                    }
10271                }
10272                write_u16(
10273                    &mut out,
10274                    u16::try_from(fk.local_columns.len()).expect("≤ 65k FK columns"),
10275                );
10276                for &p in &fk.local_columns {
10277                    write_u16(&mut out, u16::try_from(p).expect("≤ 65k columns/table"));
10278                }
10279                write_str(&mut out, &fk.parent_table);
10280                write_u16(
10281                    &mut out,
10282                    u16::try_from(fk.parent_columns.len()).expect("≤ 65k FK parent columns"),
10283                );
10284                for &p in &fk.parent_columns {
10285                    write_u16(&mut out, u16::try_from(p).expect("≤ 65k columns/table"));
10286                }
10287                out.push(fk.on_delete.tag());
10288                out.push(fk.on_update.tag());
10289                // v7.38 (read01, T29) — MATCH type tag (FILE_VERSION 55+).
10290                out.push(fk.match_type.tag());
10291                // v7.39 (round 288) — constraint timing (FILE_VERSION 79+).
10292                // One byte, bit 0 = DEFERRABLE, bit 1 = INITIALLY DEFERRED.
10293                out.push(u8::from(fk.deferrable) | (u8::from(fk.initially_deferred) << 1));
10294            }
10295            // v7.9.19 — UniquenessConstraint appendix (catalog
10296            // FILE_VERSION 15+). Layout per table after the FK
10297            // block:
10298            //   [u16 count]
10299            //     per constraint:
10300            //       [u8 is_primary_key]
10301            //       [u16 arity][u16 col_pos]*arity
10302            // Older catalogs (v14 and below) skip this block.
10303            write_u16(
10304                &mut out,
10305                u16::try_from(t.schema.uniqueness_constraints.len())
10306                    .expect("≤ 65k uniqueness constraints/table"),
10307            );
10308            for uc in &t.schema.uniqueness_constraints {
10309                out.push(u8::from(uc.is_primary_key));
10310                write_u16(
10311                    &mut out,
10312                    u16::try_from(uc.columns.len()).expect("≤ 65k cols in uniqueness constraint"),
10313                );
10314                for &p in &uc.columns {
10315                    write_u16(&mut out, u16::try_from(p).expect("≤ 65k columns/table"));
10316                }
10317                // v7.13.0 — `nulls_not_distinct` flag
10318                // (FILE_VERSION 23+). Always written by writers at
10319                // version 23+; deserialise gates on `version >= 23`
10320                // so v22-and-below catalogs round-trip cleanly.
10321                out.push(u8::from(uc.nulls_not_distinct));
10322            }
10323            // v7.9.21 — runtime_default appendix per table.
10324            // Layout: [u16 count] then for each:
10325            //   [u16 col_pos][str expr]
10326            // Only columns whose runtime_default is Some land here;
10327            // catalog stays compact for the common literal-default
10328            // case.
10329            let mut rt_defaults: Vec<(usize, &str)> = Vec::new();
10330            for (i, c) in t.schema.columns.iter().enumerate() {
10331                if let Some(e) = &c.runtime_default {
10332                    rt_defaults.push((i, e.as_str()));
10333                }
10334            }
10335            write_u16(
10336                &mut out,
10337                u16::try_from(rt_defaults.len()).expect("≤ 65k runtime defaults/table"),
10338            );
10339            for (pos, expr) in rt_defaults {
10340                write_u16(&mut out, u16::try_from(pos).expect("≤ 65k columns/table"));
10341                write_str(&mut out, expr);
10342            }
10343            // v7.13.0 — CHECK constraint appendix per table.
10344            // Layout: [u16 count] then `count` Display-form
10345            // expression strings. Re-parsed on every INSERT/UPDATE
10346            // by the engine. FILE_VERSION 23+ only; v22 readers
10347            // never reach this block because the writer also moves
10348            // to v23 in lock-step.
10349            write_u16(
10350                &mut out,
10351                u16::try_from(t.schema.checks.len()).expect("≤ 65k CHECK constraints/table"),
10352            );
10353            for c in &t.schema.checks {
10354                // v7.39 (read01 round 48) — the expr stays in this v23
10355                // appendix (byte layout unchanged for old readers); the
10356                // name rides the v60 constraint-name appendix at the tail.
10357                write_str(&mut out, c.expr.as_str());
10358            }
10359            // v7.17.0 Phase 1.4 — per-table user_enum_type
10360            // appendix. Layout: [u16 count] then
10361            // [u16 col_pos][str enum_name] per binding. Only
10362            // columns whose user_enum_type is Some land here.
10363            let mut enum_bindings: Vec<(usize, &str)> = Vec::new();
10364            for (i, c) in t.schema.columns.iter().enumerate() {
10365                if let Some(e) = &c.user_enum_type {
10366                    enum_bindings.push((i, e.as_str()));
10367                }
10368            }
10369            write_u16(
10370                &mut out,
10371                u16::try_from(enum_bindings.len()).expect("≤ 65k enum-typed columns/table"),
10372            );
10373            for (pos, ename) in enum_bindings {
10374                write_u16(&mut out, u16::try_from(pos).expect("≤ 65k columns/table"));
10375                write_str(&mut out, ename);
10376            }
10377            // v7.17.0 Phase 1.5 — per-table user_domain_type
10378            // appendix. Same layout as the enum one. v29-and-
10379            // below readers stop after the enum appendix.
10380            let mut domain_bindings: Vec<(usize, &str)> = Vec::new();
10381            for (i, c) in t.schema.columns.iter().enumerate() {
10382                if let Some(d) = &c.user_domain_type {
10383                    domain_bindings.push((i, d.as_str()));
10384                }
10385            }
10386            write_u16(
10387                &mut out,
10388                u16::try_from(domain_bindings.len()).expect("≤ 65k domain-typed columns/table"),
10389            );
10390            for (pos, dname) in domain_bindings {
10391                write_u16(&mut out, u16::try_from(pos).expect("≤ 65k columns/table"));
10392                write_str(&mut out, dname);
10393            }
10394            // v7.17.0 Phase 2.1 — per-table on_update_runtime
10395            // appendix. Sparse: only ON UPDATE-bound columns.
10396            let mut on_update_bindings: Vec<(usize, &str)> = Vec::new();
10397            for (i, c) in t.schema.columns.iter().enumerate() {
10398                if let Some(e) = &c.on_update_runtime {
10399                    on_update_bindings.push((i, e.as_str()));
10400                }
10401            }
10402            write_u16(
10403                &mut out,
10404                u16::try_from(on_update_bindings.len()).expect("≤ 65k ON UPDATE columns/table"),
10405            );
10406            for (pos, expr_src) in on_update_bindings {
10407                write_u16(&mut out, u16::try_from(pos).expect("≤ 65k columns/table"));
10408                write_str(&mut out, expr_src);
10409            }
10410            // v7.17.0 Phase 2.5 — per-table collation appendix.
10411            // Sparse: only non-Binary columns land. Layout:
10412            // `[u16 count][u16 col_pos][u8 tag] × count`.
10413            let mut coll_bindings: Vec<(usize, u8)> = Vec::new();
10414            for (i, c) in t.schema.columns.iter().enumerate() {
10415                let tag = match c.collation {
10416                    Collation::Binary => continue,
10417                    Collation::CaseInsensitive => Collation::TAG_CASE_INSENSITIVE,
10418                };
10419                coll_bindings.push((i, tag));
10420            }
10421            write_u16(
10422                &mut out,
10423                u16::try_from(coll_bindings.len()).expect("≤ 65k collation bindings/table"),
10424            );
10425            for (pos, tag) in coll_bindings {
10426                write_u16(&mut out, u16::try_from(pos).expect("≤ 65k columns/table"));
10427                out.push(tag);
10428            }
10429            // v7.17.0 Phase 4.4 — per-table is_unsigned appendix.
10430            // Sparse: only UNSIGNED columns land. Layout:
10431            // `[u16 count][u16 col_pos] × count`.
10432            let mut unsigned_bindings: Vec<usize> = Vec::new();
10433            for (i, c) in t.schema.columns.iter().enumerate() {
10434                if c.is_unsigned {
10435                    unsigned_bindings.push(i);
10436                }
10437            }
10438            write_u16(
10439                &mut out,
10440                u16::try_from(unsigned_bindings.len()).expect("≤ 65k UNSIGNED columns/table"),
10441            );
10442            for pos in unsigned_bindings {
10443                write_u16(&mut out, u16::try_from(pos).expect("≤ 65k columns/table"));
10444            }
10445            // v7.17.0 Phase 3.P0-36 — per-table inline_enum_variants
10446            // appendix. Sparse: only ENUM columns land. Layout:
10447            // `[u16 count] then per binding [u16 col_pos]
10448            // [u16 variant_count] then variant strings`.
10449            // FILE_VERSION 41+; v40 readers never reach this block.
10450            let mut enum_inline_bindings: Vec<(usize, &[String])> = Vec::new();
10451            for (i, c) in t.schema.columns.iter().enumerate() {
10452                if let Some(vs) = &c.inline_enum_variants {
10453                    enum_inline_bindings.push((i, vs.as_slice()));
10454                }
10455            }
10456            write_u16(
10457                &mut out,
10458                u16::try_from(enum_inline_bindings.len()).expect("≤ 65k inline-ENUM columns/table"),
10459            );
10460            for (pos, variants) in enum_inline_bindings {
10461                write_u16(&mut out, u16::try_from(pos).expect("≤ 65k columns/table"));
10462                write_u16(
10463                    &mut out,
10464                    u16::try_from(variants.len()).expect("≤ 65k variants/ENUM"),
10465                );
10466                for v in variants {
10467                    write_str(&mut out, v.as_str());
10468                }
10469            }
10470            // v7.17.0 Phase 3.P0-37 — per-table inline_set_variants
10471            // appendix. Same layout as the inline ENUM block.
10472            // FILE_VERSION 42+; v41 readers never reach this block.
10473            let mut set_inline_bindings: Vec<(usize, &[String])> = Vec::new();
10474            for (i, c) in t.schema.columns.iter().enumerate() {
10475                if let Some(vs) = &c.inline_set_variants {
10476                    set_inline_bindings.push((i, vs.as_slice()));
10477                }
10478            }
10479            write_u16(
10480                &mut out,
10481                u16::try_from(set_inline_bindings.len()).expect("≤ 65k inline-SET columns/table"),
10482            );
10483            for (pos, variants) in set_inline_bindings {
10484                write_u16(&mut out, u16::try_from(pos).expect("≤ 65k columns/table"));
10485                write_u16(
10486                    &mut out,
10487                    u16::try_from(variants.len()).expect("≤ 65k variants/SET"),
10488                );
10489                for v in variants {
10490                    write_str(&mut out, v.as_str());
10491                }
10492            }
10493            // v7.37.6-B — partition role appendix(FILE_VERSION 49+)。
10494            // Layout 详见 FILE_VERSION 49 docstring。普通表 = 单字节 0。
10495            write_partition_role(&mut out, t.schema.partition_role.as_ref());
10496            // v7.37.7 — per-table generated_stored_expr appendix
10497            // (FILE_VERSION 50+). Sparse: only columns whose
10498            // generated_stored_expr is Some land here.
10499            let mut gen_bindings: Vec<(usize, &str)> = Vec::new();
10500            for (i, c) in t.schema.columns.iter().enumerate() {
10501                if let Some(src) = &c.generated_stored_expr {
10502                    gen_bindings.push((i, src.as_str()));
10503                }
10504            }
10505            write_u16(
10506                &mut out,
10507                u16::try_from(gen_bindings.len()).expect("≤ 65k GENERATED STORED columns/table"),
10508            );
10509            for (pos, src) in gen_bindings {
10510                write_u16(&mut out, u16::try_from(pos).expect("≤ 65k columns/table"));
10511                write_str(&mut out, src);
10512            }
10513            // v7.38 (read01) — per-table default_text appendix
10514            // (FILE_VERSION 58+). Sparse: only columns whose default_text
10515            // is Some land here. Mirrors the generated_stored_expr shape.
10516            let mut default_texts: Vec<(usize, &str)> = Vec::new();
10517            for (i, c) in t.schema.columns.iter().enumerate() {
10518                if let Some(src) = &c.default_text {
10519                    default_texts.push((i, src.as_str()));
10520                }
10521            }
10522            write_u16(
10523                &mut out,
10524                u16::try_from(default_texts.len()).expect("≤ 65k defaulted columns/table"),
10525            );
10526            for (pos, src) in default_texts {
10527                write_u16(&mut out, u16::try_from(pos).expect("≤ 65k columns/table"));
10528                write_str(&mut out, src);
10529            }
10530            // v7.39 (RLS) — per-table policy appendix + the two RLS flags
10531            // (FILE_VERSION 59+). Written after the default_text block and
10532            // before the MVCC row appendix, so a v58 reader stops before it.
10533            // Layout: [u8 row_security][u8 force] [u16 policy_count] then per
10534            // policy: [str name][u8 cmd][u8 permissive][u16 role_count]
10535            // (role_count × str) [u8 has_using](+str)[u8 has_check](+str).
10536            out.push(u8::from(t.schema.row_security));
10537            out.push(u8::from(t.schema.force_row_security));
10538            write_u16(
10539                &mut out,
10540                u16::try_from(t.schema.policies.len()).expect("≤ 65k policies/table"),
10541            );
10542            for p in &t.schema.policies {
10543                write_str(&mut out, &p.name);
10544                out.push(p.cmd.to_wire_byte());
10545                out.push(u8::from(p.permissive));
10546                write_u16(
10547                    &mut out,
10548                    u16::try_from(p.roles.len()).expect("≤ 65k roles/policy"),
10549                );
10550                for r in &p.roles {
10551                    write_str(&mut out, r);
10552                }
10553                match &p.using_expr {
10554                    Some(s) => {
10555                        out.push(1);
10556                        write_str(&mut out, s);
10557                    }
10558                    None => out.push(0),
10559                }
10560                match &p.with_check_expr {
10561                    Some(s) => {
10562                        out.push(1);
10563                        write_str(&mut out, s);
10564                    }
10565                    None => out.push(0),
10566                }
10567            }
10568            // v7.37.16 (Epic W) — per-row MVCC header + stable RowId
10569            // appendix (FILE_VERSION 53+). Persists xmin/xmax/flags +
10570            // RowId for every row so a tombstone naming a pre-checkpoint
10571            // row survives a serialize→deserialize base restore
10572            // (cross-checkpoint tombstone durability). `headers` /
10573            // `rowids` are lock-step parallel to `rows` (invariant held
10574            // at every mutation boundary), so the count is `rows.len()`
10575            // and the zipped walk visits them in physical row order —
10576            // the same order the rows block above was written in. v52
10577            // readers never reach this block (the writer also moves to
10578            // v53 in lock-step); a v53 reader restores headers + ids
10579            // verbatim instead of freezing + dense-assigning.
10580            debug_assert_eq!(
10581                t.rows.len(),
10582                t.headers.len(),
10583                "headers must be lock-step with rows at serialize"
10584            );
10585            debug_assert_eq!(
10586                t.rows.len(),
10587                t.rowids.len(),
10588                "rowids must be lock-step with rows at serialize"
10589            );
10590            write_u32(
10591                &mut out,
10592                u32::try_from(t.rows.len()).expect("≤ 4G rows/table"),
10593            );
10594            for (h, rid) in t.headers.iter().zip(t.rowids.iter()) {
10595                out.extend_from_slice(&h.xmin.to_le_bytes());
10596                out.extend_from_slice(&h.xmax.to_le_bytes());
10597                out.push(h.flags);
10598                out.extend_from_slice(&rid.0.to_le_bytes());
10599            }
10600            out.extend_from_slice(
10601                &t.next_rowid
10602                    .load(core::sync::atomic::Ordering::Relaxed)
10603                    .to_le_bytes(),
10604            );
10605            // v7.39 (read01 round 48) — constraint-name appendix
10606            // (FILE_VERSION 60+). Index-aligned to the CHECK and
10607            // uniqueness-constraint appendices written above, so the
10608            // existing byte layouts stay untouched and a v59 catalog still
10609            // decodes (its constraints just come back unnamed).
10610            // Layout: [u16 check_count] then per check
10611            //         [u8 has_name] ([str name] when has_name)
10612            //         [u16 uc_count] then per uc the same pair.
10613            write_u16(
10614                &mut out,
10615                u16::try_from(t.schema.checks.len()).expect("≤ 65k CHECK constraints/table"),
10616            );
10617            for c in &t.schema.checks {
10618                match &c.name {
10619                    Some(n) => {
10620                        out.push(1);
10621                        write_str(&mut out, n);
10622                    }
10623                    None => out.push(0),
10624                }
10625            }
10626            write_u16(
10627                &mut out,
10628                u16::try_from(t.schema.uniqueness_constraints.len())
10629                    .expect("≤ 65k uniqueness constraints/table"),
10630            );
10631            for uc in &t.schema.uniqueness_constraints {
10632                match &uc.name {
10633                    Some(n) => {
10634                        out.push(1);
10635                        write_str(&mut out, n);
10636                    }
10637                    None => out.push(0),
10638                }
10639            }
10640            // v7.39 (read01 round 56) — user_composite_type appendix
10641            // (FILE_VERSION 63+). Sparse, at the very end of the per-table
10642            // block: only composite-typed columns land here, so a v62 reader
10643            // stops before it and its composite columns stay plain JSON.
10644            let mut comp_bindings: Vec<(usize, &str)> = Vec::new();
10645            for (i, c) in t.schema.columns.iter().enumerate() {
10646                if let Some(n) = &c.user_composite_type {
10647                    comp_bindings.push((i, n.as_str()));
10648                }
10649            }
10650            write_u16(
10651                &mut out,
10652                u16::try_from(comp_bindings.len()).expect("≤ 65k composite-typed columns/table"),
10653            );
10654            for (pos, n) in comp_bindings {
10655                write_u16(&mut out, u16::try_from(pos).expect("≤ 65k columns/table"));
10656                write_str(&mut out, n);
10657            }
10658            // v7.39 (read01 round 57) — owner + ACL appendix (FILE_VERSION
10659            // 64+), at the very end of the per-table block so a v63 reader
10660            // stops before it (its tables then read back owner-less, i.e.
10661            // owned by the login role, with no grants — which is exactly what
10662            // they were).
10663            match &t.schema.owner {
10664                Some(o) => {
10665                    out.push(1);
10666                    write_str(&mut out, o);
10667                }
10668                None => out.push(0),
10669            }
10670            write_u16(
10671                &mut out,
10672                u16::try_from(t.schema.acl.len()).expect("≤ 65k aclitems/table"),
10673            );
10674            for a in &t.schema.acl {
10675                write_str(&mut out, &a.grantee);
10676                write_u16(&mut out, a.privs);
10677                write_u16(&mut out, a.grantable);
10678                write_str(&mut out, &a.grantor);
10679            }
10680            // v7.39 (read01 round 59) — COLUMN acl appendix (FILE_VERSION 65+),
10681            // sparse: only columns that carry a grant land here, so a v64 reader
10682            // stops before it and its columns read back un-granted, which is
10683            // what they were.
10684            let granted: Vec<(usize, &ColumnSchema)> = t
10685                .schema
10686                .columns
10687                .iter()
10688                .enumerate()
10689                .filter(|(_, c)| !c.acl.is_empty())
10690                .collect();
10691            write_u16(
10692                &mut out,
10693                u16::try_from(granted.len()).expect("≤ 65k granted columns/table"),
10694            );
10695            for (pos, c) in granted {
10696                write_u16(&mut out, u16::try_from(pos).expect("≤ 65k columns/table"));
10697                write_u16(
10698                    &mut out,
10699                    u16::try_from(c.acl.len()).expect("≤ 65k aclitems/column"),
10700                );
10701                for a in &c.acl {
10702                    write_str(&mut out, &a.grantee);
10703                    write_u16(&mut out, a.privs);
10704                    write_u16(&mut out, a.grantable);
10705                    write_str(&mut out, &a.grantor);
10706                }
10707            }
10708            // v7.39 (round 210) — EXCLUDE-constraint appendix (FILE_VERSION
10709            // 72+), at the very end of the per-table block so a v71 reader
10710            // stops before it and its tables read back with no exclusion
10711            // constraints. Layout: [u16 excl_count] then per constraint
10712            // [str name] [u8 has_method](+str) [u16 elem_count] then per
10713            // element [u16 col_pos][str op].
10714            write_u16(
10715                &mut out,
10716                u16::try_from(t.schema.exclusion_constraints.len())
10717                    .expect("≤ 65k exclusion constraints/table"),
10718            );
10719            for ex in &t.schema.exclusion_constraints {
10720                write_str(&mut out, &ex.name);
10721                match &ex.method {
10722                    Some(m) => {
10723                        out.push(1);
10724                        write_str(&mut out, m);
10725                    }
10726                    None => out.push(0),
10727                }
10728                write_u16(
10729                    &mut out,
10730                    u16::try_from(ex.elements.len()).expect("≤ 65k elements/exclusion"),
10731                );
10732                for (pos, op) in &ex.elements {
10733                    write_u16(&mut out, u16::try_from(*pos).expect("≤ 65k columns/table"));
10734                    write_str(&mut out, op);
10735                }
10736            }
10737            // v7.39 (round 220) — identity-RESTART appendix (FILE_VERSION
10738            // 73+), sparse: only columns carrying a RESTART floor land here.
10739            let restarts: Vec<(usize, i64)> = t
10740                .schema
10741                .columns
10742                .iter()
10743                .enumerate()
10744                .filter_map(|(i, c)| c.auto_restart.map(|n| (i, n)))
10745                .collect();
10746            write_u16(
10747                &mut out,
10748                u16::try_from(restarts.len()).expect("≤ 65k restart columns/table"),
10749            );
10750            for (pos, n) in restarts {
10751                write_u16(&mut out, u16::try_from(pos).expect("≤ 65k columns/table"));
10752                out.extend_from_slice(&n.to_le_bytes());
10753            }
10754            // v7.39 (round 386, type-fidelity epic P1) — per-table
10755            // mysql_int_width appendix (FILE_VERSION 81+). Sparse: only
10756            // TINYINT / MEDIUMINT columns land. Layout:
10757            // `[u16 count]([u16 col_pos][u8 width_tag]) × count`
10758            // (tag 0 = Tiny, 1 = Medium). v80-and-below readers stop after
10759            // the identity-RESTART appendix, leaving every column at None.
10760            let int_widths: Vec<(usize, u8)> = t
10761                .schema
10762                .columns
10763                .iter()
10764                .enumerate()
10765                .filter_map(|(i, c)| {
10766                    c.mysql_int_width.map(|w| {
10767                        let tag = match w {
10768                            MysqlIntWidth::Tiny => 0u8,
10769                            MysqlIntWidth::Medium => 1u8,
10770                            MysqlIntWidth::Small => 2u8,
10771                            MysqlIntWidth::Int => 3u8,
10772                            MysqlIntWidth::Big => 4u8,
10773                        };
10774                        (i, tag)
10775                    })
10776                })
10777                .collect();
10778            write_u16(
10779                &mut out,
10780                u16::try_from(int_widths.len()).expect("≤ 65k narrow-int columns/table"),
10781            );
10782            for (pos, tag) in int_widths {
10783                write_u16(&mut out, u16::try_from(pos).expect("≤ 65k columns/table"));
10784                out.push(tag);
10785            }
10786            // v7.39 (round 424, type-fidelity epic) — per-table mysql_fsp
10787            // appendix (FILE_VERSION 82+). Sparse: only MySQL-declared
10788            // temporal columns land. Layout:
10789            // `[u16 count]([u16 col_pos][u8 fsp]) × count`, fsp in 0..=6.
10790            // v81-and-below readers stop after the int-width appendix,
10791            // leaving every column at None (PG microsecond behaviour).
10792            let fsps: Vec<(usize, u8)> = t
10793                .schema
10794                .columns
10795                .iter()
10796                .enumerate()
10797                .filter_map(|(i, c)| c.mysql_fsp.map(|p| (i, p)))
10798                .collect();
10799            write_u16(
10800                &mut out,
10801                u16::try_from(fsps.len()).expect("≤ 65k temporal columns/table"),
10802            );
10803            for (pos, fsp) in fsps {
10804                write_u16(&mut out, u16::try_from(pos).expect("≤ 65k columns/table"));
10805                out.push(fsp);
10806            }
10807            // v7.39.2 — the declared-TIMESTAMP appendix (FILE_VERSION
10808            // 93+). Sparse: only the columns written as `TIMESTAMP` in a
10809            // MySQL session. Layout: `[u16 count]([u16 col_pos]) × count`.
10810            // v92-and-below readers stop after the CHECK appendix below,
10811            // leaving every column at `false` — which is what they meant.
10812            let declared_ts: Vec<usize> = t
10813                .schema
10814                .columns
10815                .iter()
10816                .enumerate()
10817                .filter_map(|(i, c)| c.mysql_declared_timestamp.then_some(i))
10818                .collect();
10819            write_u16(
10820                &mut out,
10821                u16::try_from(declared_ts.len()).expect("≤ 65k timestamp columns/table"),
10822            );
10823            for pos in declared_ts {
10824                write_u16(&mut out, u16::try_from(pos).expect("≤ 65k columns/table"));
10825            }
10826            // v7.39.3 — the FLOAT/DOUBLE (m,d) appendix (FILE_VERSION
10827            // 94+). Sparse: only columns declared with the pair.
10828            // Layout: `[u16 count]([u16 col_pos][u8 m][u8 d]) × count`.
10829            let float_mds: Vec<(usize, u8, u8)> = t
10830                .schema
10831                .columns
10832                .iter()
10833                .enumerate()
10834                .filter_map(|(i, c)| c.mysql_float_md.map(|(m, d)| (i, m, d)))
10835                .collect();
10836            write_u16(
10837                &mut out,
10838                u16::try_from(float_mds.len()).expect("≤ 65k (m,d) columns/table"),
10839            );
10840            for (pos, m, d) in float_mds {
10841                write_u16(&mut out, u16::try_from(pos).expect("≤ 65k columns/table"));
10842                out.push(m);
10843                out.push(d);
10844            }
10845            // v7.39 (round 652) — CHECK-validated appendix (FILE_VERSION
10846            // 87+). Sparse the other way round from the ones above: the
10847            // common case is every constraint validated, so only the
10848            // NOT VALID ones are written, by their index into the CHECK
10849            // appendix. Layout: `[u16 count]([u16 check_idx]) × count`.
10850            let unvalidated: Vec<usize> = t
10851                .schema
10852                .checks
10853                .iter()
10854                .enumerate()
10855                .filter_map(|(i, c)| (!c.validated).then_some(i))
10856                .collect();
10857            write_u16(
10858                &mut out,
10859                u16::try_from(unvalidated.len()).expect("≤ 65k CHECK constraints/table"),
10860            );
10861            for idx in unvalidated {
10862                write_u16(&mut out, u16::try_from(idx).expect("≤ 65k CHECK/table"));
10863            }
10864            // v7.39 (round 677) — per-column collation names (FILE_VERSION
10865            // 88+). Sparse: only the columns that were written with an
10866            // explicit `COLLATE` appear, so a table that declares none pays
10867            // two bytes. Layout: `[u16 count]([u16 col_idx][str]) × count`.
10868            //
10869            // Without this the declaration survives CREATE TABLE and dies
10870            // at the next restart — measured: a column declared
10871            // `COLLATE "C"` reported attcollation 950 in the session that
10872            // created it and 100 after a reload.
10873            let collated: Vec<(usize, &str)> = t
10874                .schema
10875                .columns
10876                .iter()
10877                .enumerate()
10878                .filter_map(|(i, c)| c.collation_name.as_deref().map(|n| (i, n)))
10879                .collect();
10880            write_u16(
10881                &mut out,
10882                u16::try_from(collated.len()).expect("≤ 65k columns/table"),
10883            );
10884            for (idx, name) in collated {
10885                write_u16(&mut out, u16::try_from(idx).expect("≤ 65k columns/table"));
10886                write_str(&mut out, name);
10887            }
10888            // v7.39 (round 711) — PK/UNIQUE constraint timing (FILE_VERSION
10889            // 89+). Dense, one byte per uniqueness constraint in
10890            // declaration order, the same bit layout the FK block has
10891            // carried since round 288: bit 0 = DEFERRABLE, bit 1 =
10892            // INITIALLY DEFERRED. A v88 reader stops before it.
10893            write_u16(
10894                &mut out,
10895                u16::try_from(t.schema.uniqueness_constraints.len())
10896                    .expect("≤ 65k uniqueness constraints/table"),
10897            );
10898            for uc in &t.schema.uniqueness_constraints {
10899                out.push(u8::from(uc.deferrable) | (u8::from(uc.initially_deferred) << 1));
10900            }
10901        }
10902        // v7.12.4 — catalog-wide appendix: user-defined functions
10903        // then triggers. FILE_VERSION 22+ only. v21 and earlier
10904        // readers stop after the last table; v22 readers always
10905        // consume two `u32` counts (possibly zero).
10906        //
10907        // Function entry layout:
10908        //   [str name] [str args_repr] [str returns]
10909        //   [str language] [str body]
10910        // Trigger entry layout:
10911        //   [str name] [str table] [str timing]
10912        //   [u16 event_count] (event_count × str)
10913        //   [str for_each] [str function]
10914        write_u32(
10915            &mut out,
10916            u32::try_from(self.functions.len()).expect("≤ 4G functions"),
10917        );
10918        for fd in self.functions.values() {
10919            write_str(&mut out, &fd.name);
10920            write_str(&mut out, &fd.args_repr);
10921            write_str(&mut out, &fd.returns);
10922            write_str(&mut out, &fd.language);
10923            write_str_long(&mut out, &fd.body);
10924        }
10925        write_u32(
10926            &mut out,
10927            u32::try_from(self.triggers.len()).expect("≤ 4G triggers"),
10928        );
10929        for td in &self.triggers {
10930            write_str(&mut out, &td.name);
10931            write_str(&mut out, &td.table);
10932            write_str(&mut out, &td.timing);
10933            write_u16(
10934                &mut out,
10935                u16::try_from(td.events.len()).expect("≤ 65k events / trigger"),
10936            );
10937            for ev in &td.events {
10938                write_str(&mut out, ev);
10939            }
10940            write_str(&mut out, &td.for_each);
10941            write_str(&mut out, &td.function);
10942            // v7.13.0 — `UPDATE OF cols` filter
10943            // (FILE_VERSION 23+). v22 readers omit; v23 writers
10944            // always emit (possibly zero).
10945            write_u16(
10946                &mut out,
10947                u16::try_from(td.update_columns.len()).expect("≤ 65k cols / trigger"),
10948            );
10949            for c in &td.update_columns {
10950                write_str(&mut out, c);
10951            }
10952            // v7.16.1 — TriggerDef.enabled (FILE_VERSION 25+).
10953            out.push(u8::from(td.enabled));
10954            // v7.39 (round 138) — WHEN condition text (FILE_VERSION 70+).
10955            write_str(&mut out, &td.when_condition);
10956        }
10957        // v7.17.0 Phase 1.1 — SEQUENCE catalog block (FILE_VERSION 26+).
10958        write_u32(
10959            &mut out,
10960            u32::try_from(self.sequences.len()).expect("≤ 4G sequences"),
10961        );
10962        for seq in self.sequences.values() {
10963            write_str(&mut out, &seq.name);
10964            out.push(match seq.data_type {
10965                SequenceDataType::SmallInt => 0,
10966                SequenceDataType::Int => 1,
10967                SequenceDataType::BigInt => 2,
10968            });
10969            out.extend_from_slice(&seq.start.to_le_bytes());
10970            out.extend_from_slice(&seq.increment.to_le_bytes());
10971            out.extend_from_slice(&seq.min_value.to_le_bytes());
10972            out.extend_from_slice(&seq.max_value.to_le_bytes());
10973            out.extend_from_slice(&seq.cache.to_le_bytes());
10974            out.push(u8::from(seq.cycle));
10975            match &seq.owned_by {
10976                None => out.push(0),
10977                Some((table, column)) => {
10978                    out.push(1);
10979                    write_str(&mut out, table);
10980                    write_str(&mut out, column);
10981                }
10982            }
10983            out.extend_from_slice(&seq.last_value.to_le_bytes());
10984            out.push(u8::from(seq.is_called));
10985        }
10986        // v7.17.0 Phase 1.2 — VIEW catalog block (FILE_VERSION 27+).
10987        write_u32(
10988            &mut out,
10989            u32::try_from(self.views.len()).expect("≤ 4G views"),
10990        );
10991        for view in self.views.values() {
10992            write_str(&mut out, &view.name);
10993            write_u16(
10994                &mut out,
10995                u16::try_from(view.columns.len()).expect("≤ 65k cols / view"),
10996            );
10997            for c in &view.columns {
10998                write_str(&mut out, c);
10999            }
11000            write_str_long(&mut out, &view.body);
11001            // v7.39 (round 132, FILE_VERSION 69+) — WITH CHECK OPTION marker.
11002            out.push(view.check_option);
11003        }
11004        // v7.17.0 Phase 1.3 — MATERIALIZED VIEW source registry
11005        // (FILE_VERSION 28+). The backing rows live as a regular
11006        // table of the same name already in the tables block.
11007        write_u32(
11008            &mut out,
11009            u32::try_from(self.materialized_views.len()).expect("≤ 4G materialized views"),
11010        );
11011        for (name, body) in &self.materialized_views {
11012            write_str(&mut out, name);
11013            write_str_long(&mut out, body);
11014        }
11015        // v7.17.0 Phase 1.4 — ENUM types catalog block
11016        // (FILE_VERSION 29+).
11017        write_u32(
11018            &mut out,
11019            u32::try_from(self.enum_types.len()).expect("≤ 4G enum types"),
11020        );
11021        for e in self.enum_types.values() {
11022            write_str(&mut out, &e.name);
11023            write_u16(
11024                &mut out,
11025                u16::try_from(e.labels.len()).expect("≤ 65k labels / enum"),
11026            );
11027            for l in &e.labels {
11028                write_str(&mut out, l);
11029            }
11030        }
11031        // v7.17.0 Phase 1.5 — DOMAIN types catalog block
11032        // (FILE_VERSION 30+).
11033        write_u32(
11034            &mut out,
11035            u32::try_from(self.domain_types.len()).expect("≤ 4G domain types"),
11036        );
11037        for d in self.domain_types.values() {
11038            write_str(&mut out, &d.name);
11039            write_data_type(&mut out, d.base_type);
11040            out.push(u8::from(d.nullable));
11041            match &d.default {
11042                None => out.push(0),
11043                Some(s) => {
11044                    out.push(1);
11045                    write_str(&mut out, s);
11046                }
11047            }
11048            write_u16(
11049                &mut out,
11050                u16::try_from(d.checks.len()).expect("≤ 65k CHECKs / domain"),
11051            );
11052            for c in &d.checks {
11053                write_str(&mut out, &c.expr);
11054                // v7.39 (round 260) — the constraint name (FILE_VERSION 75+).
11055                write_str(&mut out, &c.name);
11056            }
11057            // v7.39 (round 259) — the parent domain (FILE_VERSION 74+).
11058            match &d.base_domain {
11059                None => out.push(0),
11060                Some(s) => {
11061                    out.push(1);
11062                    write_str(&mut out, s);
11063                }
11064            }
11065        }
11066        // v7.17.0 Phase 1.6 — user-schemas registry
11067        // (FILE_VERSION 31+). Built-ins are hardcoded in
11068        // `is_builtin_schema` and not persisted.
11069        write_u32(
11070            &mut out,
11071            u32::try_from(self.schemas.len()).expect("≤ 4G schemas"),
11072        );
11073        for name in &self.schemas {
11074            write_str(&mut out, name);
11075        }
11076        // v7.37.42-T2 ζ-B — COMPOSITE types catalog block
11077        // (FILE_VERSION 52+). Each entry: name, u16 field_count,
11078        // then field_count `[str field_name][data_type]` pairs.
11079        write_u32(
11080            &mut out,
11081            u32::try_from(self.composite_types.len()).expect("≤ 4G composite types"),
11082        );
11083        for c in self.composite_types.values() {
11084            write_str(&mut out, &c.name);
11085            write_u16(
11086                &mut out,
11087                u16::try_from(c.fields.len()).expect("≤ 65k fields / composite"),
11088            );
11089            for (i, (fname, fty)) in c.fields.iter().enumerate() {
11090                write_str(&mut out, fname);
11091                write_data_type(&mut out, *fty);
11092                // v7.39 (round 264) — the field's user type (v76+).
11093                match c.field_user_types.get(i).and_then(Option::as_ref) {
11094                    None => out.push(0),
11095                    Some(n) => {
11096                        out.push(1);
11097                        write_str(&mut out, n);
11098                    }
11099                }
11100            }
11101        }
11102        // v7.39 (read01 round 50) — COMMENT store (FILE_VERSION 61+).
11103        // Catalog-wide, written last (before the CRC trailer) so every older
11104        // reader stops before it. Layout: [u32 count] then [str key][str text].
11105        write_u32(
11106            &mut out,
11107            u32::try_from(self.comments.len()).expect("≤ 4G comments"),
11108        );
11109        for (k, v) in &self.comments {
11110            write_str(&mut out, k);
11111            write_str_long(&mut out, v);
11112        }
11113        // v7.39 (read01 round 60) — non-table ACLs (FILE_VERSION 66+), catalog-
11114        // wide and written last so a v65 reader stops before them. The sequence
11115        // block itself sits mid-image and cannot grow without breaking older
11116        // readers, so a sequence's owner + ACL rides here, keyed by name.
11117        let acl_out = |out: &mut Vec<u8>, acl: &[AclItem]| {
11118            write_u16(out, u16::try_from(acl.len()).expect("≤ 65k aclitems"));
11119            for a in acl {
11120                write_str(out, &a.grantee);
11121                write_u16(out, a.privs);
11122                write_u16(out, a.grantable);
11123                write_str(out, &a.grantor);
11124            }
11125        };
11126        let owned: Vec<&SequenceDef> = self
11127            .sequences
11128            .values()
11129            .filter(|s| s.owner.is_some() || !s.acl.is_empty())
11130            .collect();
11131        write_u32(
11132            &mut out,
11133            u32::try_from(owned.len()).expect("≤ 4G sequences"),
11134        );
11135        for seq in owned {
11136            write_str(&mut out, &seq.name);
11137            match &seq.owner {
11138                Some(o) => {
11139                    out.push(1);
11140                    write_str(&mut out, o);
11141                }
11142                None => out.push(0),
11143            }
11144            acl_out(&mut out, &seq.acl);
11145        }
11146        acl_out(&mut out, &self.schema_acl);
11147        acl_out(&mut out, &self.database_acl);
11148        // v7.39 (read01 round 61) — FUNCTION owner + ACL (FILE_VERSION 67+).
11149        // The function block sits mid-image like the sequence one, so this
11150        // rides the catalog-wide tail too, keyed by name.
11151        let fns: Vec<&FunctionDef> = self
11152            .functions
11153            .values()
11154            .filter(|f| f.owner.is_some() || !f.acl.is_empty())
11155            .collect();
11156        write_u32(&mut out, u32::try_from(fns.len()).expect("≤ 4G functions"));
11157        for f in fns {
11158            // v7.39 (read01 round 62) — keyed by SIGNATURE now: two overloads
11159            // have two ACLs.
11160            write_str(&mut out, &function_signature_key(&f.name, &f.args_repr));
11161            match &f.owner {
11162                Some(o) => {
11163                    out.push(1);
11164                    write_str(&mut out, o);
11165                }
11166                None => out.push(0),
11167            }
11168            acl_out(&mut out, &f.acl);
11169        }
11170        // v7.39 (round 139) — RULE catalog block (FILE_VERSION 71+), catalog-
11171        // wide and written last (right before the CRC trailer) so every older
11172        // reader stops cleanly before it. Layout: [u32 count] then per rule
11173        // [str name][str table][str event][u8 instead][str when]
11174        // [u16 cmd_count]([str cmd] × cmd_count).
11175        write_u32(
11176            &mut out,
11177            u32::try_from(self.rules.len()).expect("≤ 4G rules"),
11178        );
11179        for r in &self.rules {
11180            write_str(&mut out, &r.name);
11181            write_str(&mut out, &r.table);
11182            write_str(&mut out, &r.event);
11183            out.push(u8::from(r.instead));
11184            write_str(&mut out, &r.when_condition);
11185            write_u16(
11186                &mut out,
11187                u16::try_from(r.commands.len()).expect("≤ 65k commands / rule"),
11188            );
11189            for c in &r.commands {
11190                write_str(&mut out, c);
11191            }
11192        }
11193        // v7.39 (round 280) — extended-statistics block (FILE_VERSION
11194        // 77+), appended after the RULE block for the same reason: an
11195        // older reader stops cleanly before it. Layout: [u32 count]
11196        // then per object [str name][str table][u16 n]([str kind] × n)
11197        // [u16 m]([str column] × m).
11198        write_u32(
11199            &mut out,
11200            u32::try_from(self.statistics_ext.len()).expect("≤ 4G statistics objects"),
11201        );
11202        for st in &self.statistics_ext {
11203            write_str(&mut out, &st.name);
11204            write_str(&mut out, &st.table);
11205            write_u16(
11206                &mut out,
11207                u16::try_from(st.kinds.len()).expect("≤ 65k kinds"),
11208            );
11209            for k in &st.kinds {
11210                write_str(&mut out, k);
11211            }
11212            write_u16(
11213                &mut out,
11214                u16::try_from(st.columns.len()).expect("≤ 65k columns"),
11215            );
11216            for c in &st.columns {
11217                write_str(&mut out, c);
11218            }
11219        }
11220        // v7.39 (round 287) — large-object block (FILE_VERSION 78+),
11221        // appended after the statistics block for the same reason: an
11222        // older reader stops cleanly before it. Layout: [u32 count]
11223        // then per object [u32 oid][u32 len][len bytes].
11224        write_u32(
11225            &mut out,
11226            u32::try_from(self.large_objects.len()).expect("≤ 4G large objects"),
11227        );
11228        for (oid, bytes) in &self.large_objects {
11229            write_u32(&mut out, *oid);
11230            write_u32(
11231                &mut out,
11232                u32::try_from(bytes.len()).expect("≤ 4G per object"),
11233            );
11234            out.extend_from_slice(bytes);
11235        }
11236        // v7.39 (round 322, V46) — function-attribute block (FILE_VERSION
11237        // 80+), appended last for the same reason as every block before
11238        // it: an older reader stops cleanly ahead of it and simply sees
11239        // functions with PG's default attributes. Only functions that
11240        // declared something non-default are written. Layout: [u32 count]
11241        // then per function [str signature_key][u8 volatility][u8 flags]
11242        // [u8 parallel][f64 cost or NaN][f64 rows or NaN], where flags bit
11243        // 0 = strict, 1 = security definer, 2 = leakproof.
11244        let attr_fns: Vec<(&String, &FunctionDef)> = self
11245            .functions
11246            .iter()
11247            .filter(|(_, f)| {
11248                f.volatility != FN_VOLATILE
11249                    || f.strict
11250                    || f.security_definer
11251                    || f.leakproof
11252                    || f.parallel != FN_PARALLEL_UNSAFE
11253                    || f.cost.is_some()
11254                    || f.rows.is_some()
11255            })
11256            .collect();
11257        write_u32(
11258            &mut out,
11259            u32::try_from(attr_fns.len()).expect("≤ 4G functions"),
11260        );
11261        for (key, f) in attr_fns {
11262            write_str(&mut out, key);
11263            out.push(f.volatility);
11264            let flags = u8::from(f.strict)
11265                | (u8::from(f.security_definer) << 1)
11266                | (u8::from(f.leakproof) << 2);
11267            out.push(flags);
11268            out.push(f.parallel);
11269            out.extend_from_slice(&f.cost.unwrap_or(f64::NAN).to_le_bytes());
11270            out.extend_from_slice(&f.rows.unwrap_or(f64::NAN).to_le_bytes());
11271        }
11272        // v7.38 (read01 P5.05) — CRC32C trailer over the whole image so a
11273        // corrupted snapshot is rejected on load. FILE_VERSION is >= the
11274        // trailer version, so this always runs for freshly-written images.
11275        // v7.39 (round 547) — pg_db_role_setting (FILE_VERSION 85+),
11276        // catalog-wide and written LAST so a v84 reader stops before it.
11277        // Layout: [u32 scopes] then [str database][str role][u32 params]
11278        // then [str name][str value] per param.
11279        write_u32(
11280            &mut out,
11281            u32::try_from(self.db_role_settings.len()).expect("≤ 4G scopes"),
11282        );
11283        for ((db, role), params) in &self.db_role_settings {
11284            write_str(&mut out, db);
11285            write_str(&mut out, role);
11286            write_u32(&mut out, u32::try_from(params.len()).expect("≤ 4G params"));
11287            for (name, value) in params {
11288                write_str(&mut out, name);
11289                write_str(&mut out, value);
11290            }
11291        }
11292        // v7.39 (round 550) — replication slots (FILE_VERSION 86+),
11293        // written LAST so a v85 reader stops before them.
11294        write_u32(
11295            &mut out,
11296            u32::try_from(self.replication_slots.len()).expect("≤ 4G slots"),
11297        );
11298        for (name, (plugin, slot_type)) in &self.replication_slots {
11299            write_str(&mut out, name);
11300            write_str(&mut out, plugin);
11301            write_str(&mut out, slot_type);
11302        }
11303        // v7.38.18 (S1) — the database collation (FILE_VERSION 92+).
11304        // Absent on an older image, which reads back as `C`.
11305        match &self.db_collation {
11306            None => out.push(0),
11307            Some(c) => {
11308                out.push(1);
11309                write_str(&mut out, c);
11310            }
11311        }
11312        let crc = spg_crypto::crc32c::crc32c(&out);
11313        write_u32(&mut out, crc);
11314        out
11315    }
11316
11317    /// Deserialize a previously-serialized catalog. Rejects bad magic, version
11318    /// mismatch, unknown tags, truncation, and trailing bytes.
11319    pub fn deserialize(buf: &[u8]) -> Result<Self, StorageError> {
11320        let mut cur = Cursor::new(buf);
11321        let magic = cur.take(8)?;
11322        if magic != FILE_MAGIC {
11323            return Err(StorageError::Corrupt(format!(
11324                "bad magic: expected SPGDB001, got {magic:?}"
11325            )));
11326        }
11327        let version = cur.read_u8()?;
11328        if !(MIN_SUPPORTED_FILE_VERSION..=FILE_VERSION).contains(&version) {
11329            return Err(StorageError::Corrupt(format!(
11330                "unsupported file version: {version} (supported: {MIN_SUPPORTED_FILE_VERSION}..={FILE_VERSION})"
11331            )));
11332        }
11333        // v7.23/v7.27 — escape decoding is version-gated (see
11334        // STR_LEN_ESCAPE / Cursor::codec_version).
11335        cur.codec_version = version;
11336        let table_count = cur.read_u32()? as usize;
11337        let mut cat = Self::new();
11338        for _ in 0..table_count {
11339            deserialize_table(&mut cur, &mut cat, version)?;
11340        }
11341        // v7.37.15 (Phase C.1) — stamp dense stable RelIds on load.
11342        // Pre-V6 envelopes carry no ids; a dense 1..=N assignment is
11343        // sufficient while RelId is process-local bookkeeping (the V6
11344        // envelope, Phase C.6, will round-trip real ids). Sets the
11345        // allocator above the loaded ids so a post-load CREATE TABLE
11346        // never collides.
11347        for (i, t) in cat.tables.iter_mut().enumerate() {
11348            t.set_rel_id(row_header::RelId((i as u64) + 1));
11349        }
11350        // v7.39.13 — a pre-v97 catalog's TIMETZ index entries are keyed
11351        // by the UTC instant alone (see `timetz_sort_key`), and a v97
11352        // probe is keyed by the pair. Reading one with the other finds
11353        // nothing, which is the failure this whole layer exists to
11354        // prevent, so the entries are rebuilt from the rows.
11355        //
11356        // Only timetz, and only from below v97: `rebuild_indices_pub`
11357        // rebuilds every index on the table, so this asks first.
11358        if version < 97 {
11359            for t in cat.tables.iter_mut() {
11360                let cols = &t.schema().columns;
11361                let touched = t.indices().iter().any(|idx| {
11362                    core::iter::once(idx.column_position)
11363                        .chain(idx.extra_column_positions.iter().copied())
11364                        .any(|p| {
11365                            cols.get(p)
11366                                .is_some_and(|c| matches!(c.ty, DataType::TimeTz))
11367                        })
11368                });
11369                if touched {
11370                    t.rebuild_indices_pub();
11371                }
11372            }
11373        }
11374        cat.next_rel_id = cat.tables.len() as u64;
11375        // v7.12.4 — catalog-wide function + trigger appendix.
11376        // FILE_VERSION 22+ only; v21 and earlier catalogs stop
11377        // after the last table.
11378        if version >= 22 {
11379            let fn_count = cur.read_u32()? as usize;
11380            for _ in 0..fn_count {
11381                let name = cur.read_str()?;
11382                let args_repr = cur.read_str()?;
11383                let returns = cur.read_str()?;
11384                let language = cur.read_str()?;
11385                let body = cur.read_str_long()?;
11386                let key = function_signature_key(&name, &args_repr);
11387                cat.functions.insert(
11388                    key,
11389                    FunctionDef {
11390                        name,
11391                        args_repr,
11392                        returns,
11393                        language,
11394                        body,
11395                        owner: None,
11396                        acl: Vec::new(),
11397                        volatility: FN_VOLATILE,
11398                        strict: false,
11399                        security_definer: false,
11400                        leakproof: false,
11401                        parallel: FN_PARALLEL_UNSAFE,
11402                        cost: None,
11403                        rows: None,
11404                    },
11405                );
11406            }
11407            let trg_count = cur.read_u32()? as usize;
11408            for _ in 0..trg_count {
11409                let name = cur.read_str()?;
11410                let table = cur.read_str()?;
11411                let timing = cur.read_str()?;
11412                let ev_count = cur.read_u16()? as usize;
11413                let mut events = Vec::with_capacity(ev_count);
11414                for _ in 0..ev_count {
11415                    events.push(cur.read_str()?);
11416                }
11417                let for_each = cur.read_str()?;
11418                let function = cur.read_str()?;
11419                // v7.13.0 — trailing `UPDATE OF cols` filter
11420                // (FILE_VERSION 23+ only; v22 catalogs omit and
11421                // deserialise with an empty vec).
11422                let update_columns = if version >= 23 {
11423                    let n = cur.read_u16()? as usize;
11424                    let mut cols = Vec::with_capacity(n);
11425                    for _ in 0..n {
11426                        cols.push(cur.read_str()?);
11427                    }
11428                    cols
11429                } else {
11430                    Vec::new()
11431                };
11432                // v7.16.1 — TriggerDef.enabled (FILE_VERSION 25+).
11433                // v24-and-below catalogs deserialise with `true`
11434                // — pre-v7.16.1 every trigger always fired.
11435                let enabled = if version >= 25 {
11436                    cur.read_u8()? != 0
11437                } else {
11438                    true
11439                };
11440                // v7.39 (round 138) — WHEN condition text added at FILE_VERSION
11441                // 70; older catalogs read back empty (no WHEN filter).
11442                let when_condition = if version >= 70 {
11443                    cur.read_str()?
11444                } else {
11445                    String::new()
11446                };
11447                cat.triggers.push(TriggerDef {
11448                    name,
11449                    table,
11450                    timing,
11451                    events,
11452                    for_each,
11453                    function,
11454                    update_columns,
11455                    enabled,
11456                    when_condition,
11457                });
11458            }
11459        }
11460        // v7.17.0 Phase 1.1 — SEQUENCE block (FILE_VERSION 26+).
11461        // v25-and-below catalogs omit; we leave the map empty.
11462        if version >= 26 {
11463            let seq_count = cur.read_u32()? as usize;
11464            for _ in 0..seq_count {
11465                let name = cur.read_str()?;
11466                let data_type = match cur.read_u8()? {
11467                    0 => SequenceDataType::SmallInt,
11468                    1 => SequenceDataType::Int,
11469                    2 => SequenceDataType::BigInt,
11470                    other => {
11471                        return Err(StorageError::Corrupt(format!(
11472                            "unknown SEQUENCE data-type tag {other}"
11473                        )));
11474                    }
11475                };
11476                let start = cur.read_i64()?;
11477                let increment = cur.read_i64()?;
11478                let min_value = cur.read_i64()?;
11479                let max_value = cur.read_i64()?;
11480                let cache = cur.read_i64()?;
11481                let cycle = cur.read_u8()? != 0;
11482                let owned_by = match cur.read_u8()? {
11483                    0 => None,
11484                    1 => {
11485                        let t = cur.read_str()?;
11486                        let c = cur.read_str()?;
11487                        Some((t, c))
11488                    }
11489                    other => {
11490                        return Err(StorageError::Corrupt(format!(
11491                            "unknown SEQUENCE owned-by tag {other}"
11492                        )));
11493                    }
11494                };
11495                let last_value = cur.read_i64()?;
11496                let is_called = cur.read_u8()? != 0;
11497                cat.sequences.insert(
11498                    name.clone(),
11499                    SequenceDef {
11500                        name,
11501                        data_type,
11502                        start,
11503                        increment,
11504                        min_value,
11505                        max_value,
11506                        cache,
11507                        cycle,
11508                        owned_by,
11509                        last_value,
11510                        is_called,
11511                        owner: None,
11512                        acl: Vec::new(),
11513                    },
11514                );
11515            }
11516        }
11517        // v7.17.0 Phase 1.2 — VIEW block (FILE_VERSION 27+).
11518        // v26-and-below catalogs omit; we leave the map empty.
11519        if version >= 27 {
11520            let view_count = cur.read_u32()? as usize;
11521            for _ in 0..view_count {
11522                let name = cur.read_str()?;
11523                let col_count = cur.read_u16()? as usize;
11524                let mut columns = Vec::with_capacity(col_count);
11525                for _ in 0..col_count {
11526                    columns.push(cur.read_str()?);
11527                }
11528                let body = cur.read_str_long()?;
11529                // v7.39 (round 132) — check-option marker added at FILE_VERSION
11530                // 69; older catalogs default to 0 (no check option).
11531                let check_option = if version >= 69 { cur.read_u8()? } else { 0 };
11532                cat.views.insert(
11533                    name.clone(),
11534                    ViewDef {
11535                        name,
11536                        columns,
11537                        body,
11538                        check_option,
11539                    },
11540                );
11541            }
11542        }
11543        // v7.17.0 Phase 1.3 — MATERIALIZED VIEW source registry
11544        // (FILE_VERSION 28+). v27-and-below catalogs omit.
11545        if version >= 28 {
11546            let mv_count = cur.read_u32()? as usize;
11547            for _ in 0..mv_count {
11548                let name = cur.read_str()?;
11549                let body = cur.read_str_long()?;
11550                cat.materialized_views.insert(name, body);
11551            }
11552        }
11553        // v7.17.0 Phase 1.4 — ENUM types catalog block
11554        // (FILE_VERSION 29+).
11555        if version >= 29 {
11556            let etype_count = cur.read_u32()? as usize;
11557            for _ in 0..etype_count {
11558                let name = cur.read_str()?;
11559                let label_count = cur.read_u16()? as usize;
11560                let mut labels = Vec::with_capacity(label_count);
11561                for _ in 0..label_count {
11562                    labels.push(cur.read_str()?);
11563                }
11564                cat.enum_types
11565                    .insert(name.clone(), EnumDef { name, labels });
11566            }
11567        }
11568        // v7.17.0 Phase 1.5 — DOMAIN types catalog block
11569        // (FILE_VERSION 30+).
11570        if version >= 30 {
11571            let dtype_count = cur.read_u32()? as usize;
11572            for _ in 0..dtype_count {
11573                let name = cur.read_str()?;
11574                let base_type = cur.read_data_type()?;
11575                let nullable = cur.read_u8()? != 0;
11576                let default = match cur.read_u8()? {
11577                    0 => None,
11578                    1 => Some(cur.read_str()?),
11579                    other => {
11580                        return Err(StorageError::Corrupt(format!(
11581                            "unknown DOMAIN default tag {other}"
11582                        )));
11583                    }
11584                };
11585                let check_count = cur.read_u16()? as usize;
11586                let mut checks: Vec<DomainCheck> = Vec::with_capacity(check_count);
11587                for i in 0..check_count {
11588                    let expr = cur.read_str()?;
11589                    // v7.39 (round 260) — names arrived in FILE_VERSION 75.
11590                    // An older catalog gets PG's auto-naming applied to the
11591                    // checks it stored, which is what they would have been.
11592                    let cname = if version >= 75 {
11593                        cur.read_str()?
11594                    } else if i == 0 {
11595                        alloc::format!("{name}_check")
11596                    } else {
11597                        alloc::format!("{name}_check{i}")
11598                    };
11599                    checks.push(DomainCheck { name: cname, expr });
11600                }
11601                // v7.39 (round 259) — the parent domain. Absent before
11602                // FILE_VERSION 74; an older catalog reads as a domain over
11603                // a scalar, which is what it was.
11604                let base_domain = if version >= 74 {
11605                    match cur.read_u8()? {
11606                        0 => None,
11607                        1 => Some(cur.read_str()?),
11608                        other => {
11609                            return Err(StorageError::Corrupt(alloc::format!(
11610                                "domain base_domain tag {other}"
11611                            )));
11612                        }
11613                    }
11614                } else {
11615                    None
11616                };
11617                cat.domain_types.insert(
11618                    name.clone(),
11619                    DomainDef {
11620                        name,
11621                        base_type,
11622                        nullable,
11623                        default,
11624                        checks,
11625                        base_domain,
11626                    },
11627                );
11628            }
11629        }
11630        // v7.17.0 Phase 1.6 — user-schemas registry
11631        // (FILE_VERSION 31+).
11632        if version >= 31 {
11633            let sch_count = cur.read_u32()? as usize;
11634            for _ in 0..sch_count {
11635                let name = cur.read_str()?;
11636                cat.schemas.insert(name);
11637            }
11638        }
11639        // v7.37.42-T2 ζ-B — COMPOSITE types catalog block
11640        // (FILE_VERSION 52+). v51-and-below readers stop at the
11641        // user-schemas block; v52 readers fed a v51 catalog see no
11642        // composite block and default to an empty map.
11643        if version >= 52 {
11644            let ctype_count = cur.read_u32()? as usize;
11645            for _ in 0..ctype_count {
11646                let name = cur.read_str()?;
11647                let field_count = cur.read_u16()? as usize;
11648                let mut fields = Vec::with_capacity(field_count);
11649                let mut field_user_types: Vec<Option<String>> = Vec::with_capacity(field_count);
11650                for _ in 0..field_count {
11651                    let fname = cur.read_str()?;
11652                    let fty = cur.read_data_type()?;
11653                    // v7.39 (round 264) — present from FILE_VERSION 76.
11654                    let ut = if version >= 76 {
11655                        match cur.read_u8()? {
11656                            0 => None,
11657                            1 => Some(cur.read_str()?),
11658                            other => {
11659                                return Err(StorageError::Corrupt(alloc::format!(
11660                                    "composite field user-type tag {other}"
11661                                )));
11662                            }
11663                        }
11664                    } else {
11665                        None
11666                    };
11667                    fields.push((fname, fty));
11668                    field_user_types.push(ut);
11669                }
11670                cat.composite_types.insert(
11671                    name.clone(),
11672                    CompositeDef {
11673                        name,
11674                        fields,
11675                        field_user_types,
11676                    },
11677                );
11678            }
11679        }
11680        // v7.39 (read01 round 50) — COMMENT store (FILE_VERSION 61+).
11681        if version >= 61 {
11682            let comment_count = cur.read_u32()? as usize;
11683            for _ in 0..comment_count {
11684                let key = cur.read_str()?;
11685                let text = cur.read_str_long()?;
11686                cat.comments.insert(key, text);
11687            }
11688        }
11689        // v7.39 (read01 round 60) — non-table ACLs (FILE_VERSION 66+).
11690        if version >= 66 {
11691            let read_acl = |cur: &mut Cursor| -> Result<Vec<AclItem>, StorageError> {
11692                let n = cur.read_u16()? as usize;
11693                let mut acl = Vec::with_capacity(n);
11694                for _ in 0..n {
11695                    let grantee = cur.read_str()?;
11696                    let privs = cur.read_u16()?;
11697                    let grantable = cur.read_u16()?;
11698                    let grantor = cur.read_str()?;
11699                    acl.push(AclItem {
11700                        grantee,
11701                        privs,
11702                        grantable,
11703                        grantor,
11704                    });
11705                }
11706                Ok(acl)
11707            };
11708            let seq_count = cur.read_u32()? as usize;
11709            for _ in 0..seq_count {
11710                let name = cur.read_str()?;
11711                let owner = if cur.read_u8()? == 1 {
11712                    Some(cur.read_str()?)
11713                } else {
11714                    None
11715                };
11716                let acl = read_acl(&mut cur)?;
11717                if let Some(seq) = cat.sequences.get_mut(&name) {
11718                    seq.owner = owner;
11719                    seq.acl = acl;
11720                }
11721            }
11722            cat.schema_acl = read_acl(&mut cur)?;
11723            cat.database_acl = read_acl(&mut cur)?;
11724            // v7.39 (read01 round 61) — FUNCTION owner + ACL (v67+; keyed by
11725            // signature from v68, when overloads became possible).
11726            if version >= 67 {
11727                let fn_count = cur.read_u32()? as usize;
11728                for _ in 0..fn_count {
11729                    let name = cur.read_str()?;
11730                    let owner = if cur.read_u8()? == 1 {
11731                        Some(cur.read_str()?)
11732                    } else {
11733                        None
11734                    };
11735                    let acl = read_acl(&mut cur)?;
11736                    // v7.39 (round 315, V19) — the stored key was computed
11737                    // by whichever formula was current when the image was
11738                    // written. A miss is not "no such function": before the
11739                    // multi-word fix, `f(double precision)` keyed as
11740                    // `f(precision)`, so an older image's grants would land
11741                    // nowhere and vanish silently. Fall back to matching by
11742                    // the old formula, which re-attaches them.
11743                    let target = resolve_stored_function_key(&cat.functions, &name);
11744                    if let Some(k) = target
11745                        && let Some(f) = cat.functions.get_mut(&k)
11746                    {
11747                        f.owner = owner;
11748                        f.acl = acl;
11749                    }
11750                }
11751            }
11752        }
11753        // v7.39 (round 139) — RULE catalog block (FILE_VERSION 71+), read from
11754        // the tail right before the CRC trailer. Pre-71 images stop before it.
11755        if version >= 71 {
11756            let rule_count = cur.read_u32()? as usize;
11757            for _ in 0..rule_count {
11758                let name = cur.read_str()?;
11759                let table = cur.read_str()?;
11760                let event = cur.read_str()?;
11761                let instead = cur.read_u8()? != 0;
11762                let when_condition = cur.read_str()?;
11763                let cmd_count = cur.read_u16()? as usize;
11764                let mut commands = Vec::with_capacity(cmd_count);
11765                for _ in 0..cmd_count {
11766                    commands.push(cur.read_str()?);
11767                }
11768                cat.rules.push(RuleDef {
11769                    name,
11770                    table,
11771                    event,
11772                    instead,
11773                    when_condition,
11774                    commands,
11775                });
11776            }
11777        }
11778        // v7.39 (round 280) — extended-statistics block (FILE_VERSION
11779        // 77+). Pre-77 images stop before it.
11780        if version >= 77 {
11781            let count = cur.read_u32()? as usize;
11782            for _ in 0..count {
11783                let name = cur.read_str()?;
11784                let table = cur.read_str()?;
11785                let nk = cur.read_u16()? as usize;
11786                let mut kinds = Vec::with_capacity(nk);
11787                for _ in 0..nk {
11788                    kinds.push(cur.read_str()?);
11789                }
11790                let nc = cur.read_u16()? as usize;
11791                let mut columns = Vec::with_capacity(nc);
11792                for _ in 0..nc {
11793                    columns.push(cur.read_str()?);
11794                }
11795                cat.statistics_ext.push(StatisticsExtDef {
11796                    name,
11797                    table,
11798                    kinds,
11799                    columns,
11800                });
11801            }
11802        }
11803        // v7.39 (round 287) — large-object block (FILE_VERSION 78+).
11804        // Pre-78 images stop before it.
11805        if version >= 78 {
11806            let count = cur.read_u32()? as usize;
11807            for _ in 0..count {
11808                let oid = cur.read_u32()?;
11809                let len = cur.read_u32()? as usize;
11810                let bytes = cur.read_bytes(len)?;
11811                cat.large_objects.insert(oid, bytes);
11812            }
11813        }
11814        // v7.39 (round 322, V46) — function-attribute block (FILE_VERSION
11815        // 80+). Pre-80 images stop before it and keep PG's defaults.
11816        if version >= 80 {
11817            let count = cur.read_u32()? as usize;
11818            for _ in 0..count {
11819                let key = cur.read_str()?;
11820                let volatility = cur.read_u8()?;
11821                let flags = cur.read_u8()?;
11822                let parallel = cur.read_u8()?;
11823                let cost = f64::from_le_bytes(cur.read_bytes(8)?.try_into().unwrap_or([0; 8]));
11824                let rows = f64::from_le_bytes(cur.read_bytes(8)?.try_into().unwrap_or([0; 8]));
11825                if let Some(f) = cat.functions.get_mut(&key) {
11826                    f.volatility = volatility;
11827                    f.strict = flags & 1 != 0;
11828                    f.security_definer = flags & 2 != 0;
11829                    f.leakproof = flags & 4 != 0;
11830                    f.parallel = parallel;
11831                    f.cost = (!cost.is_nan()).then_some(cost);
11832                    f.rows = (!rows.is_nan()).then_some(rows);
11833                }
11834            }
11835        }
11836        // v7.39 (round 547) — pg_db_role_setting (FILE_VERSION 85+).
11837        // Pre-85 images stop before it and carry no GUC defaults.
11838        if version >= 85 {
11839            let scopes = cur.read_u32()? as usize;
11840            for _ in 0..scopes {
11841                let db = cur.read_str()?;
11842                let role = cur.read_str()?;
11843                let params = cur.read_u32()? as usize;
11844                let mut m: BTreeMap<String, String> = BTreeMap::new();
11845                for _ in 0..params {
11846                    let name = cur.read_str()?;
11847                    let value = cur.read_str()?;
11848                    m.insert(name, value);
11849                }
11850                if !m.is_empty() {
11851                    cat.db_role_settings.insert((db, role), m);
11852                }
11853            }
11854        }
11855        // v7.39 (round 550) — replication slots (FILE_VERSION 86+).
11856        if version >= 86 {
11857            let count = cur.read_u32()? as usize;
11858            for _ in 0..count {
11859                let name = cur.read_str()?;
11860                let plugin = cur.read_str()?;
11861                let slot_type = cur.read_str()?;
11862                cat.replication_slots.insert(name, (plugin, slot_type));
11863            }
11864        }
11865        // v7.38.18 (S1) — the database collation (FILE_VERSION 92+).
11866        if version >= 92 {
11867            match cur.read_u8()? {
11868                0 => {}
11869                1 => cat.db_collation = Some(cur.read_str()?),
11870                other => {
11871                    return Err(StorageError::Corrupt(format!(
11872                        "db_collation tag: unknown byte {other}"
11873                    )));
11874                }
11875            }
11876        }
11877        // v7.38.18 (S3) — a database created under a collation this
11878        // build cannot perform does not open.
11879        //
11880        // Falling back to bytes would answer with a different comparator
11881        // than every index key in it was built under, which is the one
11882        // failure this whole layer exists to prevent — and it would do
11883        // it silently, since a byte-ordered answer looks exactly like a
11884        // correct one. The check is a NAME classification here; the
11885        // engine, which owns the collator, verifies it can actually
11886        // perform the name before recording it.
11887        if let Some(c) = &cat.db_collation
11888            && c.trim().is_empty()
11889        {
11890            return Err(StorageError::Corrupt(format!(
11891                "database collation is recorded as {c:?}, which names nothing"
11892            )));
11893        }
11894        // v7.38.18 (S2) — and every table read back learns it, because a
11895        // table decides for itself which of its indexes key under a
11896        // collation. Done here rather than per-table in the loop above
11897        // because the byte that says so is written after the tables.
11898        let db_coll = cat.db_collation().to_string();
11899        for t in &mut cat.tables {
11900            t.set_db_collation(&db_coll);
11901        }
11902        // v7.38 (read01 P5.05) — v54+ images end with a CRC32C over every
11903        // preceding byte; verify it before accepting the snapshot. Older
11904        // images have no trailer and fall through to the trailing-byte check.
11905        if version >= FILE_VERSION_CRC_TRAILER {
11906            let crc_start = cur.pos;
11907            let stored = cur.read_u32()?;
11908            let computed = spg_crypto::crc32c::crc32c(&buf[..crc_start]);
11909            if computed != stored {
11910                return Err(StorageError::Corrupt(format!(
11911                    "base snapshot CRC mismatch: computed {computed:#010x}, stored {stored:#010x}"
11912                )));
11913            }
11914        }
11915        if cur.pos < buf.len() {
11916            return Err(StorageError::Corrupt(format!(
11917                "trailing bytes: {} unread",
11918                buf.len() - cur.pos
11919            )));
11920        }
11921        Ok(cat)
11922    }
11923}
11924
11925#[cfg(test)]
11926mod tests;