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 quantize;
24pub mod row_header;
25pub mod row_locator;
26pub mod segment;
27pub mod snapshot;
28mod table;
29pub mod trgm;
30pub mod vacuum;
31
32pub use self::bloom::{BloomError, BloomFilter};
33// v7.31 monster tier-3 cut 3 — on-disk codec moved to `codec`; the
34// public dense-row surface keeps its `spg_storage::*` paths, and the
35// low-level write/read primitives stay crate-visible for the
36// `Catalog::serialize`/`deserialize` methods that remain in this file.
37pub(crate) use self::codec::*;
38pub use self::codec::{
39    decode_row_body_dense, decode_row_body_dense_pruned, encode_row_body_dense,
40    encode_row_body_dense_into, encode_row_body_dense_masked_into, row_body_encoded_len,
41};
42// v7.31 monster tier-3 cut 2 — HNSW algorithms moved to `nsw`; the
43// public vector-search surface keeps its `spg_storage::*` paths via
44// these re-exports, and `nsw_insert_at` stays crate-visible for the
45// `Table` insert paths in the `table` module.
46pub(crate) use self::nsw::nsw_insert_at;
47pub use self::nsw::{NswMetric, cosine_dot_norms_f32, inner_product_f32, nsw_index_on, nsw_query};
48pub use self::row_locator::{RowLocator, RowLocatorError};
49pub use self::segment::{
50    BRIN_SIDECAR_MAGIC, BrinSummary, OwnedSegment, SEGMENT_COMPRESS_ALGO_LZSS,
51    SEGMENT_COMPRESS_ALGO_NONE, SEGMENT_MAGIC, SEGMENT_MAGIC_V2, SEGMENT_PAGE_BYTES, SegmentError,
52    SegmentMeta, SegmentReader, derive_brin_summaries, encode_segment, wrap_v2_envelope,
53    wrap_v2_envelope_with_brin,
54};
55
56use alloc::borrow::Cow;
57use alloc::boxed::Box;
58use alloc::collections::{BTreeMap, BTreeSet};
59use alloc::format;
60use alloc::string::{String, ToString};
61use alloc::sync::Arc;
62use alloc::vec::Vec;
63use core::fmt;
64
65use self::persistent::PersistentVec;
66use self::persistent_btree::PersistentBTreeMap;
67
68/// In-cell encoding for `DataType::Vector`. Mirrors
69/// `spg_sql::ast::VecEncoding` — kept here so storage stays
70/// dep-free of `spg-sql`. The engine bridges between the two
71/// at DDL-execution time.
72///
73/// `F32` is the pre-v6 default: each cell holds a raw `Vec<f32>`.
74/// `Sq8` (v6.0.1) stores `Sq8Vector { min, max, bytes: Vec<u8> }`
75/// per cell; 4× compression vs `F32` with recall@10 ≥ 0.95 on
76/// natural embeddings (Gaussian / unit-sphere corpora).
77/// `F16` (v6.0.3, DDL keyword `HALF`) stores each element as
78/// IEEE-754 binary16; 2× compression and bit-exact dequantise.
79#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
80pub enum VecEncoding {
81    #[default]
82    F32,
83    Sq8,
84    F16,
85}
86
87impl fmt::Display for VecEncoding {
88    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
89        match self {
90            Self::F32 => f.write_str("F32"),
91            Self::Sq8 => f.write_str("SQ8"),
92            Self::F16 => f.write_str("HALF"),
93        }
94    }
95}
96
97/// Runtime type tags. `Vector { dim, encoding }` / `Varchar(max)` /
98/// `Char(size)` are parameterised; the parameter travels with both
99/// the column schema and the on-wire serialised representation.
100#[derive(Debug, Clone, Copy, PartialEq, Eq)]
101pub enum DataType {
102    /// 16-bit signed. Backed by `Value::SmallInt(i16)`; arithmetic that
103    /// would overflow surfaces as a type error at INSERT time.
104    SmallInt,
105    Int,    // 32-bit signed
106    BigInt, // 64-bit signed
107    Float,  // f64 (PG double precision)
108    /// v7.38 (read01, T-float4) — `real` / `float4`: 32-bit IEEE float (PG
109    /// `real`). Backed by `Value::Real(f32)`; behaves like `Float` for most
110    /// dispatch but renders / stores at f32 precision.
111    Real,
112    Text,
113    /// `VARCHAR(n)` — same byte representation as `Text`, but INSERT
114    /// rejects values longer than `n` Unicode characters.
115    Varchar(u32),
116    /// `CHAR(n)` — same representation as `Text`, but INSERT right-pads
117    /// with U+0020 to exactly `n` Unicode characters (or rejects when
118    /// the input is already longer).
119    Char(u32),
120    Bool,
121    /// pgvector-style fixed-dimension vector. `encoding` selects
122    /// the in-cell representation (`F32` = pre-v6 raw f32 buffer;
123    /// `Sq8` = v6.0.1 8-bit scalar-quantised). The DDL grammar
124    /// surfaces encoding via the optional `USING <encoding>`
125    /// clause: `VECTOR(128) USING SQ8`.
126    Vector {
127        dim: u32,
128        encoding: VecEncoding,
129    },
130    /// `NUMERIC(precision, scale)` — exact fixed-point decimal stored as
131    /// a scaled `i128`. `precision` caps total decimal digits, `scale`
132    /// fixes digits after the decimal point. v1.12 supports up to
133    /// precision 38 (the i128-safe ceiling). `NUMERIC` and `NUMERIC(p)`
134    /// surface as `Numeric { precision: p, scale: 0 }`.
135    Numeric {
136        /// v7.39 (round 272) — widened from u8. PG's declared precision
137        /// runs to 1000; at u8 it could not even be spelled, and the
138        /// parser rejected anything past 38 (i128's width) outright.
139        precision: u16,
140        /// v7.39 (round 271) — widened alongside the value's scale.
141        /// v7.39 (round 273) — and signed: PG's DECLARED scale runs
142        /// -1000..=1000, where a negative one rounds to tens / hundreds.
143        /// A VALUE's display scale is always non-negative.
144        scale: i16,
145    },
146    /// `DATE` — calendar date with day precision, stored as `i32` days
147    /// since the Unix epoch (1970-01-01).
148    Date,
149    /// `TIMESTAMP` (a.k.a. `MySQL` `DATETIME`) — instant with microsecond
150    /// precision, stored as `i64` microseconds since the Unix epoch.
151    Timestamp,
152    /// v7.9.2 `TIMESTAMPTZ` — bit-identical to `Timestamp` on disk
153    /// (i64 microseconds, UTC by convention). Carried as a distinct
154    /// type tag so the PG-wire layer can advertise OID 1184 (PG's
155    /// `timestamp with time zone`) and `sqlx`/`pgx`/JDBC clients
156    /// decode into their TZ-aware datetime types. The internal
157    /// semantics are unchanged: SPG never stored per-row offsets,
158    /// and neither did PG — `TIMESTAMPTZ` in PG is also UTC i64.
159    Timestamptz,
160    /// v7.39 (round 291) — PG's `name`: the type its catalogs use for
161    /// identifiers. Text truncated to NAMEDATALEN-1 (63) bytes, with
162    /// its own type identity — `pg_typeof('abc'::name)` is `name`, and
163    /// `CREATE TABLE t (a name)` is legal SQL that SPG rejected.
164    Name,
165    /// v7.39 (round 640) — PG's `xid`: a transaction id. [`Value::Xid`]
166    /// has existed since round 512, so a `'5'::xid` literal already knew
167    /// what it was; this is the DECLARED half, which nothing had. Without
168    /// it `pg_typeof(NULL::xid)` answered `bigint`, `pg_type` could not
169    /// list oid 28 — leaving the 48 `pg_attribute` rows that describe
170    /// `xmin` / `xmax` pointing at a type no catalog carried — and
171    /// `CREATE TABLE t (a xid)` was refused as an unknown type.
172    ///
173    /// On disk it is the 8-byte body its BIGINT sibling writes, and it
174    /// reads back as a `Value::Xid`, so a stored column and a literal are
175    /// the same thing to everything downstream.
176    ///
177    /// What is NOT yet true of the identity: PG gives `xid` equality and
178    /// hashing and no ordering operator at all, so `min` / `max` /
179    /// `count(DISTINCT …)` / `<=` all error there and all answer here.
180    /// Measured, not assumed — and left for the operator surface rather
181    /// than claimed by this comment.
182    Xid,
183    /// v7.39 (round 640) — PG's `xid8`: the same transaction id, 64 bits
184    /// wide and monotonic. Unlike [`DataType::Xid`] it has no value of
185    /// its own; a cell is a `Value::BigInt` and only the declared type
186    /// witnesses it. That is enough for `pg_typeof`, the catalogs and
187    /// the wire OID, and not enough to refuse a bigint where PG refuses
188    /// one. `pg_current_xact_id()` returns this type on PG.
189    Xid8,
190    /// v7.39 (round 667) — PG's `oid`: an unsigned 32-bit object
191    /// identifier. Modelled exactly like [`DataType::Xid8`] above: it has
192    /// no value of its own, a cell is a `Value::BigInt`, and only the
193    /// declared type witnesses it.
194    ///
195    /// That deliberately buys less than a full value type. What it buys:
196    /// `CREATE TABLE t(o OID)` is accepted (it was rejected outright with
197    /// `type "oid" does not exist`, while the neighbouring `XID` worked),
198    /// `pg_typeof` answers `oid` rather than `bigint`, and the catalogs
199    /// report their own key columns honestly. What it does NOT buy is
200    /// refusing a bigint where PG refuses an oid — `sum(oid)` and
201    /// `avg(oid)` still answer here and error on PG, because at runtime
202    /// the cell is indistinguishable from a bigint. Round 664 tried to
203    /// close those two by name and withdrew: a guard keyed on the name
204    /// would have caught `sum(bigint)` with it.
205    ///
206    /// The cast itself was already right before this — `4294967296::oid`
207    /// and `'abc'::oid` produce PG's errors word for word, and `(-1)::oid`
208    /// wraps to 4294967295 as PG does. Only the resulting type was lost,
209    /// because `conversions.rs` mapped the target to `BigInt`.
210    Oid,
211    /// `INTERVAL` — calendar-aware span (months + microseconds). v2.11
212    /// supports INTERVAL only as a runtime intermediate (literals,
213    /// arithmetic results); on-disk encoding is rejected so this branch
214    /// can't appear in a `ColumnSchema`.
215    Interval,
216    /// v4.9: `JSON` — text-backed JSON document. We don't parse
217    /// the content (no path operators or jsonb functions yet) —
218    /// the column accepts any TEXT-compatible value and round-trips
219    /// it verbatim. PG OID 114 on the wire.
220    Json,
221    /// v7.9.0: `JSONB` — semantically identical to `Json` on
222    /// the storage side (same `Value::Json` cells, same
223    /// row codec), but advertised as PG OID 3802 on the wire
224    /// so `sqlx`-style clients that bind `jsonb` columns
225    /// decode correctly. mailrs migration blocker #3.
226    Jsonb,
227    /// v7.10.4: `BYTES` / `BYTEA` — variable-length raw binary.
228    /// Backed by `Value::Bytes(Vec<u8>)`. PG wire OID 17. Literal
229    /// forms accepted by parser/engine: PG hex form `'\xDEADBEEF'`
230    /// (case-insensitive hex pairs) and escape form
231    /// `'foo\\000bar'` (the latter decoded at coercion time when
232    /// the target column is BYTEA — TEXT columns leave the
233    /// backslash sequence verbatim).
234    Bytes,
235    /// v7.10.9: `TEXT[]` — single-dimension TEXT array. Elements
236    /// may be NULL (PG semantics). PG wire OID 1009. Literal
237    /// forms: `ARRAY['a', 'b', NULL]` and the PG external form
238    /// `'{a,b,NULL}'::TEXT[]`. Engine implements `= ANY(arr)`,
239    /// `<> ALL(arr)`, and 1-based indexing `arr[i]`. Catalog
240    /// FILE_VERSION 18+; older snapshots reject this DataType
241    /// (forward-only by design — TEXT[] columns aren't readable
242    /// on a pre-v7.10 binary).
243    TextArray,
244    /// v7.11.12: `INT[]` — single-dimension i32 array. PG wire
245    /// OID 1007 (_int4). Same `ARRAY[...]` / `'{1,2,3}'::INT[]`
246    /// literal surface as TEXT[]. Catalog FILE_VERSION 19+.
247    IntArray,
248    /// v7.11.12: `BIGINT[]` — single-dimension i64 array. PG
249    /// wire OID 1016 (_int8). Catalog FILE_VERSION 19+.
250    BigIntArray,
251    /// v7.39 (round 694) — `oid[]`. It exists for the reason
252    /// [`DataType::Oid`] does: mapping it onto `BigIntArray` answers
253    /// `pg_typeof('{1,2}'::oid[])` with `bigint[]`, which is the defect
254    /// round 667 closed for the scalar.
255    OidArray,
256    /// v7.37.5 β-P4 — `INTERVAL[]` — single-dimension array of
257    /// `IntervalSpan { months, days, micros }`. PG wire OID 1187
258    /// (`_interval`). Catalog tag 35 + per-cell body
259    /// `[u16 count][per elem: u8 null + (if non-null) 16-byte
260    /// interval body in LE PG-byte-equal field order]`.
261    /// FILE_VERSION 48+.
262    IntervalArray,
263    /// v7.37.5 γ — full PG array-of-scalar family. Catalog tags
264    /// 36..48; wire OIDs from PG `pg_type.dat`. Per-element body
265    /// uses the scalar's existing `write_value_body` shape.
266    /// FILE_VERSION 48+ (same window as β; no separate bump).
267    BoolArray, // PG `_bool`        OID 1000, tag 36
268    SmallIntArray,    // PG `_int2`        OID 1005, tag 37
269    FloatArray,       // PG `_float8`      OID 1022, tag 38
270    NumericArray,     // PG `_numeric`     OID 1231, tag 39
271    DateArray,        // PG `_date`        OID 1182, tag 40
272    TimestampArray,   // PG `_timestamp`   OID 1115, tag 41
273    TimestamptzArray, // PG `_timestamptz` OID 1185, tag 42
274    UuidArray,        // PG `_uuid`        OID 2951, tag 43
275    JsonArray,        // PG `_json`        OID 199,  tag 44
276    JsonbArray,       // PG `_jsonb`       OID 3807, tag 45
277    BytesArray,       // PG `_bytea`       OID 1001, tag 46
278    VarcharArray,     // PG `_varchar`     OID 1015, tag 47
279    CharArray,        // PG `_bpchar`      OID 1014, tag 48
280    /// v7.37.5 δ — PG 14+ multirange types. A multirange is an
281    /// ordered collection of non-overlapping ranges of the same
282    /// element kind (e.g. `int4multirange(int4range(1,5),
283    /// int4range(10,15))` → `{[1,5),[10,15)}`). The same DataType
284    /// variant covers all six builtin multiranges; `RangeKind`
285    /// pins the element type so encode/decode/display can route
286    /// off one switch (parallel to `Range(RangeKind)`).
287    /// Wire OIDs: int4multirange=4451, int8multirange=4537,
288    /// nummultirange=4536, tsmultirange=4533, tstzmultirange=4534,
289    /// datemultirange=4535. Catalog tag 49 + 1-byte RangeKind on
290    /// the dense type-tag side. FILE_VERSION 48+ (same window as
291    /// β/γ, no separate bump).
292    Multirange(RangeKind),
293    /// v7.37.5 ε — PG geometry scalar family. Mirrors PG's seven
294    /// builtin geometric types one-for-one. Body shapes (LE):
295    ///   Point   = 16 B fixed (f64 x + f64 y)            OID 600
296    ///   Lseg    = 32 B fixed (Point p1 + Point p2)      OID 601
297    ///   Path    = varlena ([u8 closed][u32 n][Point*n]) OID 602
298    ///   Box     = 32 B fixed (Point ur + Point ll)      OID 603
299    ///   Polygon = varlena ([u32 n][Point*n])            OID 604
300    ///   Line    = 24 B fixed (f64 a + f64 b + f64 c)    OID 628
301    ///   Circle  = 24 B fixed (Point center + f64 r)     OID 718
302    /// Catalog tags 50..56. FILE_VERSION 48+ (same window as β/γ/δ;
303    /// no separate bump). Geometric operators (`<->` / `@>` / `&&`
304    /// / `<<` / `>>` / `~=`) are a planner-integration follow-up,
305    /// parallel to the Range operator defer in e2e_pg_range.rs.
306    Point,
307    Lseg,
308    Path,
309    PgBox,
310    Polygon,
311    Line,
312    Circle,
313    /// v7.37.5 ζ-A — PG network address family. Body shapes (LE):
314    ///   Inet     = 18 B fixed (u8 family + u8 bits + 16 B addr)  OID 869
315    ///   Cidr     = 18 B fixed (same shape as Inet; CIDR rejects
316    ///                          host bits at parse / coerce)       OID 650
317    ///   Macaddr  = 6 B fixed                                      OID 829
318    ///   Macaddr8 = 8 B fixed (EUI-64)                             OID 774
319    /// Catalog tags 57-60. FILE_VERSION 48+. `family = 4` is IPv4
320    /// (uses the first 4 bytes of the 16-B addr slot, rest 0);
321    /// `family = 6` is IPv6 (full 16 B).
322    Inet,
323    Cidr,
324    Macaddr,
325    Macaddr8,
326    /// v7.39 (read01 pg_lsn.c) — PG `pg_lsn` (WAL location). 8 bytes,
327    /// rendered `%X/%X`. Catalog tag 66. OID 3220.
328    PgLsn,
329    /// v7.37.5 ζ-A — PG bit string. Body = `[u32 nbits][ceil(nbits/8) bytes]`,
330    /// big-endian within each byte (matches PG binary).
331    ///   Bit         OID 1560 (fixed-length, but SPG carries the
332    ///                         length per cell — column declaration
333    ///                         `BIT(n)` constrains at coerce time)
334    ///   BitVarying  OID 1562 (variable-length, declared as `VARBIT`)
335    /// Catalog tags 61-62.
336    /// v7.39 (round 281) — `BIT(n)`: a FIXED-length bit string. `0`
337    /// means the type was written without a typmod, which PG treats as
338    /// `bit(1)`. Column assignment requires the length to match
339    /// exactly; an explicit cast pads or truncates instead.
340    Bit(u32),
341    /// v7.39 (round 281) — `BIT VARYING(n)`: `n` is a MAXIMUM, and `0`
342    /// means unbounded (`varbit` with no typmod).
343    BitVarying(u32),
344    /// v7.37.5 ζ-A — PG `xml`. Body identical to TEXT (storage is
345    /// the verbatim XML string; no parse-time validation). Only
346    /// the wire OID (142) differs. Catalog tag 63.
347    Xml,
348    /// v7.37.5 ζ-A — PG `"char"` (the internal single-byte type,
349    /// distinct from `CHAR(n)` / `BPCHAR`). Body = 1 byte raw.
350    /// OID 18. Catalog tag 64.
351    Char1,
352    /// v7.37.5 ζ-A — `MONEY[]`. Body = `[u16 count][per elem: u8 null
353    /// + (non-null) i64 LE cents]`. OID 791. Catalog tag 65.
354    MoneyArray,
355    /// v7.12.0: PG `tsvector` — ordered, deduplicated set of
356    /// `(lexeme, positions, weight)` tuples. PG wire OID 3614.
357    /// Catalog FILE_VERSION 20+. Storage shape is row-codec
358    /// tag 22; the schema-agnostic `write_value` path emits tag
359    /// 18. Literal: `'foo:1 bar:2,3'::tsvector` (PG external
360    /// form). G-CRIT-3 entry — v7.12.0 only ships the type +
361    /// codec; matching `@@` lands in v7.12.2.
362    TsVector,
363    /// v7.12.0: PG `tsquery` — parse tree of lexemes joined by
364    /// `&` `|` `!` and phrase operators. PG wire OID 3615.
365    /// Catalog FILE_VERSION 20+.
366    TsQuery,
367    /// v7.17.0: PG `uuid` — 128-bit identifier stored as
368    /// `Value::Uuid([u8; 16])`. PG wire OID 2950. Canonical
369    /// text form is lowercase 8-4-4-4-12 hyphenated; input
370    /// also accepts uppercase, unhyphenated, and brace-wrapped
371    /// forms (`{xxxx…}`). Catalog FILE_VERSION 36+; tag 24 on
372    /// the dense type-tag side, tag 20 on the schema-agnostic
373    /// value side. The drop-in PG/MySQL surface for Django /
374    /// Rails / Hibernate "id UUID PRIMARY KEY DEFAULT
375    /// gen_random_uuid()" default-PK pattern.
376    Uuid,
377    /// v7.17.0 Phase 3.P0-32: PG `time` (without time zone) — i64
378    /// microseconds since 00:00:00. PG wire OID 1083. Display:
379    /// canonical zero-padded `HH:MM:SS` when fractional is zero,
380    /// `HH:MM:SS.ffffff` otherwise. Catalog FILE_VERSION 37+;
381    /// tag 25 on the dense type-tag side, tag 21 on the schema-
382    /// agnostic value side. The wall-clock-of-day half of PG's
383    /// date/time triplet (date / time / timestamp).
384    Time,
385    /// v7.17.0 Phase 3.P0-33: MySQL `YEAR` — u16 in range
386    /// 1901..=2155 plus the special zero-year sentinel 0. No
387    /// dedicated PG OID (advertised as INT4 / OID 23 on the wire
388    /// — psql renders integers, MySQL CLI renders 4-digit
389    /// zero-padded text). Display always 4 digits: `0000` for the
390    /// zero-year, `1985` / `2007` / etc otherwise. Catalog
391    /// FILE_VERSION 38+; tag 26 on the dense type-tag side, tag
392    /// 22 on the schema-agnostic value side.
393    Year,
394    /// v7.17.0 Phase 3.P0-34: PG `time with time zone` (TIMETZ) —
395    /// i64 microseconds since 00:00:00 in the local wall clock
396    /// PLUS i32 offset-from-UTC in seconds. PG wire OID 1266.
397    /// Display: `HH:MM:SS[.ffffff]±HH[:MM]` (PG `timetz_out`).
398    /// Range: offset in ±50400 seconds (±14 hours). Catalog
399    /// FILE_VERSION 39+; tag 27 on the dense type-tag side, tag
400    /// 23 on the schema-agnostic value side.
401    TimeTz,
402    /// v7.17.0 Phase 3.P0-35: PG `money` — i64 cents (locale-
403    /// independent storage). PG wire OID 790. Display: en_US
404    /// locale (`$N,NNN.CC`, negative → `-$1.23`). Input accepts
405    /// `$N.NN`, `$N,NNN.NN`, bare integer (treated as major
406    /// units), optional leading `-`. Range: full i64. Catalog
407    /// FILE_VERSION 40+; tag 28 on the dense type-tag side, tag
408    /// 24 on the schema-agnostic value side.
409    Money,
410    /// v7.17.0 Phase 3.P0-38: PG range type. The same DataType
411    /// variant covers all six builtin ranges (int4range,
412    /// int8range, numrange, tsrange, tstzrange, daterange) —
413    /// `RangeKind` pins the element type so encode / decode /
414    /// display can route off one switch. Catalog FILE_VERSION
415    /// 43+; tag 29 + a 1-byte RangeKind on the dense type-tag
416    /// side, tag 25 on the schema-agnostic value side.
417    Range(RangeKind),
418    /// v7.17.0 Phase 3.P0-39: PG `hstore` extension type — flat
419    /// `text => text` map with NULL value support. Catalog
420    /// FILE_VERSION 44+; tag 30 on the dense type-tag side, tag
421    /// 26 on the schema-agnostic value side. The contrib OID is
422    /// installation-dependent in real PG; SPG advertises it via
423    /// dynamic lookup, falling back to TEXT (OID 25) on the wire
424    /// when the installed `hstore` extension hasn't claimed an
425    /// OID yet.
426    Hstore,
427    /// v7.17.0 Phase 3.P0-40: PG `int[][]` — 2-dimensional INT
428    /// matrix. Storage: row-major Vec<Vec<Option<i32>>>. All
429    /// rows must share the same column count. Wire OID 1007
430    /// (same as INT[]; the dimension count travels in the data
431    /// header, not the OID). Catalog FILE_VERSION 45+; tag 31
432    /// on the dense type-tag side, tag 27 on the schema-agnostic
433    /// value side.
434    IntArray2D,
435    /// v7.17.0 Phase 3.P0-40: PG `bigint[][]` — 2-dimensional
436    /// BIGINT matrix. Storage / OID / tags mirror IntArray2D.
437    /// Tag 32 dense, tag 28 schema-agnostic.
438    BigIntArray2D,
439    /// v7.17.0 Phase 3.P0-40: PG `text[][]` — 2-dimensional TEXT
440    /// matrix. Storage: row-major Vec<Vec<Option<String>>>.
441    /// Tag 33 dense, tag 29 schema-agnostic.
442    TextArray2D,
443    /// v7.39 (read01 round 75) — `bool[][]`. BOOL is the ONE element type whose
444    /// ARRAY rendering differs from its scalar one (`t` vs `true`), so a
445    /// text-backed 2-D cannot be PG-faithful for it: rendering the whole array
446    /// wants `t`, and subscripting a cell to text wants `false`. Every other
447    /// element type renders the same either way, which is why this is the only
448    /// typed 2-D variant SPG needs.
449    BoolArray2D,
450}
451
452/// v7.17.0 Phase 3.P0-38 — pins the element type of a range value
453/// or column. Wire OIDs: Int4=3904, Int8=3926, Num=3906,
454/// Ts=3908, TsTz=3910, Date=3912.
455#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
456pub enum RangeKind {
457    Int4,
458    Int8,
459    Num,
460    Ts,
461    TsTz,
462    Date,
463}
464
465impl RangeKind {
466    pub const fn tag(self) -> u8 {
467        match self {
468            Self::Int4 => 0,
469            Self::Int8 => 1,
470            Self::Num => 2,
471            Self::Ts => 3,
472            Self::TsTz => 4,
473            Self::Date => 5,
474        }
475    }
476    pub const fn from_tag(t: u8) -> Option<Self> {
477        Some(match t {
478            0 => Self::Int4,
479            1 => Self::Int8,
480            2 => Self::Num,
481            3 => Self::Ts,
482            4 => Self::TsTz,
483            5 => Self::Date,
484            _ => return None,
485        })
486    }
487    pub const fn keyword(self) -> &'static str {
488        match self {
489            Self::Int4 => "INT4RANGE",
490            Self::Int8 => "INT8RANGE",
491            Self::Num => "NUMRANGE",
492            Self::Ts => "TSRANGE",
493            Self::TsTz => "TSTZRANGE",
494            Self::Date => "DATERANGE",
495        }
496    }
497}
498
499impl fmt::Display for DataType {
500    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
501        match self {
502            Self::SmallInt => f.write_str("SMALLINT"),
503            Self::Int => f.write_str("INT"),
504            Self::BigInt => f.write_str("BIGINT"),
505            Self::Xid => f.write_str("XID"),
506            Self::Xid8 => f.write_str("XID8"),
507            Self::Oid => f.write_str("OID"),
508            Self::OidArray => f.write_str("OID[]"),
509            Self::Float => f.write_str("FLOAT"),
510            Self::Real => f.write_str("REAL"),
511            Self::Text => f.write_str("TEXT"),
512            Self::Varchar(n) => write!(f, "VARCHAR({n})"),
513            Self::Char(n) => write!(f, "CHAR({n})"),
514            Self::Bool => f.write_str("BOOL"),
515            Self::Vector { dim, encoding } => match encoding {
516                VecEncoding::F32 => write!(f, "VECTOR({dim})"),
517                VecEncoding::Sq8 => write!(f, "VECTOR({dim}) USING SQ8"),
518                VecEncoding::F16 => write!(f, "VECTOR({dim}) USING HALF"),
519            },
520            Self::Numeric { precision, scale } => {
521                if *scale == 0 {
522                    write!(f, "NUMERIC({precision})")
523                } else {
524                    write!(f, "NUMERIC({precision}, {scale})")
525                }
526            }
527            Self::Date => f.write_str("DATE"),
528            Self::Timestamp => f.write_str("TIMESTAMP"),
529            Self::Timestamptz => f.write_str("TIMESTAMPTZ"),
530            Self::Name => f.write_str("NAME"),
531            Self::Interval => f.write_str("INTERVAL"),
532            Self::Json => f.write_str("JSON"),
533            Self::Jsonb => f.write_str("JSONB"),
534            Self::Bytes => f.write_str("BYTEA"),
535            Self::TextArray => f.write_str("TEXT[]"),
536            Self::IntArray => f.write_str("INT[]"),
537            Self::BigIntArray => f.write_str("BIGINT[]"),
538            Self::IntervalArray => f.write_str("INTERVAL[]"),
539            Self::BoolArray => f.write_str("BOOL[]"),
540            Self::SmallIntArray => f.write_str("SMALLINT[]"),
541            Self::FloatArray => f.write_str("FLOAT[]"),
542            Self::NumericArray => f.write_str("NUMERIC[]"),
543            Self::DateArray => f.write_str("DATE[]"),
544            Self::TimestampArray => f.write_str("TIMESTAMP[]"),
545            Self::TimestamptzArray => f.write_str("TIMESTAMPTZ[]"),
546            Self::UuidArray => f.write_str("UUID[]"),
547            Self::JsonArray => f.write_str("JSON[]"),
548            Self::JsonbArray => f.write_str("JSONB[]"),
549            Self::BytesArray => f.write_str("BYTEA[]"),
550            Self::VarcharArray => f.write_str("VARCHAR[]"),
551            Self::CharArray => f.write_str("CHAR[]"),
552            Self::Multirange(k) => f.write_str(match k {
553                RangeKind::Int4 => "INT4MULTIRANGE",
554                RangeKind::Int8 => "INT8MULTIRANGE",
555                RangeKind::Num => "NUMMULTIRANGE",
556                RangeKind::Ts => "TSMULTIRANGE",
557                RangeKind::TsTz => "TSTZMULTIRANGE",
558                RangeKind::Date => "DATEMULTIRANGE",
559            }),
560            Self::Point => f.write_str("POINT"),
561            Self::Lseg => f.write_str("LSEG"),
562            Self::Path => f.write_str("PATH"),
563            Self::PgBox => f.write_str("BOX"),
564            Self::Polygon => f.write_str("POLYGON"),
565            Self::Line => f.write_str("LINE"),
566            Self::Circle => f.write_str("CIRCLE"),
567            Self::Inet => f.write_str("INET"),
568            Self::Cidr => f.write_str("CIDR"),
569            Self::Macaddr => f.write_str("MACADDR"),
570            Self::Macaddr8 => f.write_str("MACADDR8"),
571            Self::PgLsn => f.write_str("PG_LSN"),
572            Self::Bit(0) => f.write_str("BIT"),
573            Self::Bit(n) => write!(f, "BIT({n})"),
574            Self::BitVarying(0) => f.write_str("VARBIT"),
575            Self::BitVarying(n) => write!(f, "VARBIT({n})"),
576            Self::Xml => f.write_str("XML"),
577            Self::Char1 => f.write_str("\"char\""),
578            Self::MoneyArray => f.write_str("MONEY[]"),
579            Self::TsVector => f.write_str("TSVECTOR"),
580            Self::TsQuery => f.write_str("TSQUERY"),
581            Self::Uuid => f.write_str("UUID"),
582            Self::Time => f.write_str("TIME"),
583            Self::Year => f.write_str("YEAR"),
584            Self::TimeTz => f.write_str("TIMETZ"),
585            Self::Money => f.write_str("MONEY"),
586            Self::Range(k) => f.write_str(k.keyword()),
587            Self::Hstore => f.write_str("HSTORE"),
588            Self::IntArray2D => f.write_str("INT[][]"),
589            Self::BigIntArray2D => f.write_str("BIGINT[][]"),
590            Self::TextArray2D => f.write_str("TEXT[][]"),
591            Self::BoolArray2D => f.write_str("BOOL[][]"),
592        }
593    }
594}
595
596/// v7.12.0 — one entry in a `Value::TsVector`. The lexeme is the
597/// (already-tokenised + stemmed in v7.12.1+) word; `positions` is
598/// a strictly-ascending list of 1-based positions; `weight` is the
599/// PG weight letter (A=3, B=2, C=1, D=0) — v7.12.0 defaults every
600/// lexeme to D, the v7.12.2 ranking path consumes the weight.
601#[derive(Debug, Clone, PartialEq, Eq)]
602pub struct TsLexeme {
603    pub word: String,
604    pub positions: Vec<u16>,
605    pub weight: u8,
606}
607
608/// v7.12.0 — parse tree for a PG `tsquery`. v7.12.0 ships the
609/// type + codec only; the `to_tsquery` / `plainto_tsquery` lexer
610/// lands in v7.12.1 and the `@@` evaluator in v7.12.2.
611#[derive(Debug, Clone, PartialEq, Eq)]
612pub enum TsQueryAst {
613    /// Single lexeme term. The `weight_mask` is the PG-style
614    /// bitmask of accepted weights (`A=1<<3`, `B=1<<2`, `C=1<<1`,
615    /// `D=1<<0`); `0` = any weight. v7.12.0 always sets it to 0.
616    Term {
617        word: String,
618        weight_mask: u8,
619    },
620    And(Box<TsQueryAst>, Box<TsQueryAst>),
621    Or(Box<TsQueryAst>, Box<TsQueryAst>),
622    Not(Box<TsQueryAst>),
623    /// `phrase <distance> phrase`. v7.12.0 only persists this; the
624    /// match semantics arrive in v7.12.2 alongside `@@`.
625    Phrase {
626        left: Box<TsQueryAst>,
627        right: Box<TsQueryAst>,
628        distance: u16,
629    },
630}
631
632/// A row-cell value, including SQL `NULL`. `Float` uses `f64`; NaN compares
633/// non-equal to itself (PG behaviour) — `PartialEq` is derived so callers
634/// must opt into NaN-aware comparison if they need stronger guarantees.
635///
636/// v7.37.42-arena Phase 1: parameterised on `'arena` so heap-bearing
637/// variants (Text/Json/Xml/Bytes/Vector/BitString.bytes) can borrow from
638/// a per-query bump arena (`Cow::Borrowed(&'arena ...)`). Persistent /
639/// catalog Values use `Value<'static>` (alias `ValueOwned`) with
640/// `Cow::Owned(...)`. Phase 1 keeps Range/Multirange recursive `Box<Value>`
641/// at `'static` (owned) — arena migration deferred to a later phase.
642/// Array-of-Option<String> variants (TextArray etc.) also stay owned in
643/// Phase 1; their nested shape is awkward for the simple Cow lift and the
644/// SCALARSQ hot path doesn't touch them.
645/// v7.38 (read01, T6) — the IEEE-style class of a NUMERIC value. `Finite` is the
646/// ordinary fixed-point case; the specials mirror PG's `'NaN'` / `'Infinity'` /
647/// `'-Infinity'`. Derived `PartialEq` gives `NaN == NaN` — correct for NUMERIC
648/// (unlike float's NaN ≠ NaN); the total order (`-Inf < finite < +Inf < NaN`)
649/// lives in the comparison paths, not in `Ord`.
650#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Hash)]
651pub enum NumericKind {
652    #[default]
653    Finite,
654    NaN,
655    PosInf,
656    NegInf,
657}
658
659#[derive(Debug, Clone, PartialEq)]
660#[non_exhaustive]
661pub enum Value<'arena> {
662    SmallInt(i16),
663    Int(i32),
664    BigInt(i64),
665    Float(f64),
666    /// v7.38 (read01, T-float4) — PG `real` (32-bit IEEE float).
667    Real(f32),
668    Text(Cow<'arena, str>),
669    Bool(bool),
670    Vector(Cow<'arena, [f32]>),
671    /// v6.0.1: 8-bit scalar-quantised vector cell. Lives in
672    /// columns declared `VECTOR(N) USING SQ8`. Layout per cell:
673    /// `Sq8Vector { min: f32, max: f32, bytes: Vec<u8> }` —
674    /// 4× compression vs `Vector(Vec<f32>)`. The wire layer
675    /// dequantises to `f32` on SELECT; INSERT path quantises
676    /// incoming `Vector(Vec<f32>)` cells into this variant.
677    Sq8Vector(crate::quantize::Sq8Vector),
678    /// v6.0.3: IEEE-754 binary16 vector cell. Lives in columns
679    /// declared `VECTOR(N) USING HALF`. Stores raw u16 LE bits
680    /// (2× compression vs `Vector(Vec<f32>)`). Wire / display
681    /// paths dequantise to f32 bit-exactly; INSERT path converts
682    /// incoming f32 vectors at the engine boundary.
683    HalfVector(crate::halfvec::HalfVector),
684    /// Exact fixed-point decimal. `scaled` holds the value as
685    /// `actual * 10^scale` so the storage type is always integral —
686    /// arithmetic never falls back to floating-point. v7.38 (read01, T6) —
687    /// `kind` classifies the value as finite (the common case, using
688    /// `scaled`/`scale`) or one of PG's NUMERIC specials (NaN / ±Infinity),
689    /// which ignore `scaled`/`scale` (canonicalized to 0).
690    Numeric {
691        scaled: i128,
692        /// v7.39 (round 271) — widened from u8. PG's numeric carries a
693        /// display scale up to 16383; at u8 a literal with 256 decimal
694        /// places could not be represented at all, and the conversion
695        /// aborted the query with an internal error.
696        scale: u16,
697        kind: NumericKind,
698    },
699    /// v7.38 (read01, T3) — an exact NUMERIC whose mantissa overflows `i128`
700    /// (PG's NUMERIC is unbounded). Boxed so the common finite case keeps its
701    /// small footprint; specials never take this form (they stay `Numeric`).
702    NumericBig(alloc::boxed::Box<crate::bignum::BigNumeric>),
703    /// Days since the Unix epoch (1970-01-01). Negative for earlier dates.
704    Date(i32),
705    /// Microseconds since the Unix epoch (1970-01-01T00:00:00Z).
706    Timestamp(i64),
707    /// Calendar span: `months` + `days` + `micros`. Three fields are
708    /// required for PG byte-equal: `'1 day'` ≠ `'24 hours'` (DST,
709    /// month-boundary, and the on-wire `pg_type` `interval` are all
710    /// `i64 micros + i32 days + i32 months`). v7.37.5 β widened from
711    /// `{months, micros}`; column storage lands in the same window.
712    Interval {
713        months: i32,
714        days: i32,
715        micros: i64,
716    },
717    /// v4.9 `JSON` — raw JSON text. No structural validation
718    /// happens at the storage layer; whatever the parser hands us
719    /// round-trips verbatim. Equality is byte-wise.
720    Json(Cow<'arena, str>),
721    /// v7.10.4 `BYTEA` — raw binary blob. Equality is byte-wise.
722    /// Layout matches `Text`'s length-prefixed shape (`[u32 LE
723    /// len][bytes]`) under tag 18; the engine accepts PG hex
724    /// literals (`'\xDEADBEEF'`) and escape literals at the
725    /// coercion boundary.
726    Bytes(Cow<'arena, [u8]>),
727    /// v7.10.9 `TEXT[]` — single-dimension TEXT array with
728    /// optional NULL elements. Equality is element-wise. PG's
729    /// NULL-element comparison semantics: NULL ≠ NULL inside
730    /// arrays under `=`, so `[NULL] != [NULL]` (the engine
731    /// honours this).
732    TextArray(Vec<Option<String>>),
733    /// v7.11.12 `INT[]` — single-dimension i32 array with optional
734    /// NULL elements. Codec mirrors TextArray with i32 LE per
735    /// element instead of length-prefixed UTF-8.
736    IntArray(Vec<Option<i32>>),
737    /// v7.11.12 `BIGINT[]` — single-dimension i64 array with optional
738    /// NULL elements.
739    BigIntArray(Vec<Option<i64>>),
740    /// v7.37.5 β-P4 `INTERVAL[]` — single-dimension array of
741    /// `IntervalSpan { months, days, micros }` with optional NULL
742    /// elements. PG external form quotes each non-NULL element
743    /// (`{"1 day","24:00:00",NULL}`) because interval text contains
744    /// spaces and colons. Storage codec follows the BigIntArray
745    /// shape with a 16-byte per-element body.
746    IntervalArray(Vec<Option<IntervalSpan>>),
747    /// v7.37.5 γ — single-dimension arrays of the remaining PG
748    /// scalar types. Each carries `Vec<Option<T>>` with the
749    /// scalar's natural Rust shape; element NULLs are first-class
750    /// (per PG: `{1,NULL,3}` is a 3-element array, not a 2-element
751    /// one). Codec follows the IntervalArray shape — `[u16 count]
752    /// [per elem: u8 null + (non-null) scalar body]`.
753    BoolArray(Vec<Option<bool>>),
754    SmallIntArray(Vec<Option<i16>>),
755    FloatArray(Vec<Option<f64>>),
756    /// PG `NUMERIC[]` — `(scaled: i128, scale: u16)` per element.
757    NumericArray(Vec<Option<(i128, u16)>>),
758    DateArray(Vec<Option<i32>>),
759    TimestampArray(Vec<Option<i64>>),
760    TimestamptzArray(Vec<Option<i64>>),
761    UuidArray(Vec<Option<[u8; 16]>>),
762    JsonArray(Vec<Option<String>>),
763    JsonbArray(Vec<Option<String>>),
764    BytesArray(Vec<Option<Vec<u8>>>),
765    VarcharArray(Vec<Option<String>>),
766    CharArray(Vec<Option<String>>),
767    /// v7.37.5 δ — PG 14+ multirange. `ranges` is a Vec of
768    /// non-overlapping bounds spans of the shared `kind`. PG's
769    /// canonical text form is `{[a,b),[c,d),...}` (comma-separated
770    /// ranges in braces; `{}` for the empty multirange). SPG's
771    /// constructor enforces no overlap/coalescing — for now the
772    /// engine trusts the caller (mirrors PG's `_construct_array`
773    /// pattern). Catalog tag 49 + 1-byte RangeKind on the dense
774    /// type-tag side; schema-less path is unreachable (multirange
775    /// is column-typed only).
776    Multirange {
777        kind: RangeKind,
778        ranges: Vec<RangeSpan>,
779    },
780    /// v7.37.5 ε — PG geometry scalars. Per-type Vec/struct shape;
781    /// codec body shape is described on the matching DataType
782    /// variant. PG canonical text forms:
783    ///   Point   `(x,y)`
784    ///   Lseg    `[(x1,y1),(x2,y2)]`
785    ///   Path    open `[(x,y),(x,y),...]` / closed `((x,y),(x,y),...)`
786    ///   Box     `(ux,uy),(lx,ly)` (PG normalises to upper-right + lower-left)
787    ///   Polygon `((x,y),(x,y),...)` (implicit closed)
788    ///   Line    `{a,b,c}` (Ax + By + C = 0)
789    ///   Circle  `<(x,y),r>`
790    Point(Point2D),
791    Lseg(Point2D, Point2D),
792    /// `closed = true` is `((p,p,...))`; `false` is `[(p,p,...)]`.
793    Path {
794        points: Vec<Point2D>,
795        closed: bool,
796    },
797    /// PG `box` — stored as `(upper_right, lower_left)` (PG's
798    /// normalised order). The engine accepts both endpoint
799    /// orderings at parse time and normalises here.
800    PgBox(Point2D, Point2D),
801    Polygon(Vec<Point2D>),
802    Line {
803        a: f64,
804        b: f64,
805        c: f64,
806    },
807    Circle {
808        center: Point2D,
809        radius: f64,
810    },
811    /// v7.37.5 ζ-A — PG `inet`. `family = 4` (IPv4) or `6` (IPv6).
812    /// `bits` is the netmask bit count (0..=32 for IPv4, 0..=128
813    /// for IPv6). `addr` is right-padded with zeros when family=4
814    /// (first 4 bytes are the address).
815    Inet {
816        family: u8,
817        bits: u8,
818        addr: [u8; 16],
819    },
820    /// v7.37.5 ζ-A — PG `cidr`. Same shape as Inet; CIDR's
821    /// invariant (host bits zero) is enforced at parse / coerce.
822    Cidr {
823        family: u8,
824        bits: u8,
825        addr: [u8; 16],
826    },
827    /// v7.37.5 ζ-A — PG `macaddr`. 6 bytes (XX:XX:XX:XX:XX:XX).
828    Macaddr([u8; 6]),
829    /// v7.37.5 ζ-A — PG `macaddr8`. 8 bytes (EUI-64).
830    Macaddr8([u8; 8]),
831    /// v7.39 (read01 pg_lsn.c) — PG `pg_lsn`, a 64-bit WAL location.
832    PgLsn(u64),
833    /// v7.39 (read01 ruleutils.c) — PG `regclass`: an OID-typed relation
834    /// reference that renders as the relation name. SPG carries BOTH
835    /// (the synthetic oid for catalog joins, the name for display) so
836    /// `conrelid = 't'::regclass` and `'t'::regclass::text` agree.
837    /// Eval-only (no column storage).
838    RegClass(i64, alloc::boxed::Box<str>),
839    /// v7.39 (round 342, V65) — PG `regproc`: an OID-typed FUNCTION
840    /// reference that renders as the function name. Same dual shape
841    /// [`Value::RegClass`] carries, and for the same reason: without the
842    /// oid half, `pg_proc.oid = 'f'::regproc` cannot join, and a callee
843    /// cannot tell `pg_get_functiondef('f'::regproc)` — which PG answers
844    /// — from `pg_get_functiondef('f')` — which PG rejects.
845    /// Eval-only (no column storage).
846    RegProc(i64, alloc::boxed::Box<str>),
847    /// v7.39 (round 648) — PG `regtype`: an OID-typed TYPE reference
848    /// that renders as the type name. The third of the shape
849    /// [`Value::RegClass`] and [`Value::RegProc`] carry, and the one
850    /// that was missing it: `::regtype` produced a plain `Value::Text`
851    /// holding the canonical name, so `'text'::regtype::oid` tried to
852    /// parse the NAME as a number and answered `invalid input syntax
853    /// for type oid: "text"` where PG answers 25. `pg_typeof` on one
854    /// said `text` rather than `regtype` for the same reason.
855    ///
856    /// Eval-only (no column storage).
857    RegType(i64, alloc::boxed::Box<str>),
858    /// v7.39 (round 512) — PG `xid` and `cid`, the transaction and command
859    /// ids the `xmin` / `xmax` / `cmin` / `cmax` system columns carry.
860    ///
861    /// Their own types rather than integers, because PG deliberately gives
862    /// them almost no operators: measured on PG18, `xmin + 1` is "operator
863    /// does not exist: xid + integer", `xmin > 0` likewise, `xmin::bigint`
864    /// is "cannot cast type xid to bigint", and there is no `max(xid)`.
865    /// Carrying them as BigInt would quietly allow all four.
866    ///
867    /// Eval-only (no column storage).
868    Xid(u32),
869    Cid(u32),
870    /// v7.39 (round 511) — PG `tid`, the physical row identity `ctid`
871    /// carries: a block number and a one-based offset inside it, rendered
872    /// `(block,offset)`.
873    ///
874    /// It is a real type rather than a two-field record because the idiom
875    /// that makes `ctid` worth having — `DELETE … WHERE ctid NOT IN (SELECT
876    /// min(ctid) … GROUP BY key)` — needs `min()` over it, and PG has no
877    /// `min(record)`. Ordering is by block then offset, so `(0,2) < (0,9) <
878    /// (0,10)`; a text form would order those `(0,10) < (0,2) < (0,9)` and
879    /// the dedup would keep the wrong row.
880    ///
881    /// Eval-only (no column storage).
882    Tid(u32, u32),
883    /// v7.37.5 ζ-A — PG `bit` / `bit varying`. `nbits` is the
884    /// actual bit count; `bytes` is the packed representation
885    /// (big-endian within each byte; final byte right-padded
886    /// with 0s if `nbits % 8 != 0`).
887    BitString {
888        nbits: u32,
889        bytes: Cow<'arena, [u8]>,
890    },
891    /// v7.37.5 ζ-A — PG `xml`. Stored verbatim as a string; no
892    /// parse-time validation (matches the SPG JSON convention).
893    Xml(Cow<'arena, str>),
894    /// v7.37.5 ζ-A — PG `"char"` (internal single-byte type,
895    /// distinct from CHAR(n)).
896    Char1(u8),
897    /// v7.38 (read01, T11) — PG `bpchar` / CHAR(n): blank-padded fixed-length
898    /// string. Stored space-padded to the declared width (as PG does + for wire
899    /// display); length / comparison / ::text / concat all ignore the trailing
900    /// blanks (handled at those sites).
901    BpChar(Cow<'arena, str>),
902    /// v7.37.5 ζ-A — PG `money[]`.
903    MoneyArray(Vec<Option<i64>>),
904    /// v7.12.0 `tsvector` — sorted-by-word, deduped lexeme set with
905    /// positions + weights. The engine enforces sort/dedup on
906    /// construction; consumers can rely on `lexemes.windows(2)`
907    /// being strictly ascending by `word`.
908    TsVector(Vec<TsLexeme>),
909    /// v7.12.0 `tsquery` — boolean / phrase parse tree over
910    /// lexemes. Engine builds via `to_tsquery` family.
911    TsQuery(TsQueryAst),
912    /// v7.17.0 `uuid` — 128-bit identifier. Stored as 16 bytes
913    /// (big-endian / network-byte order, same as RFC 4122).
914    /// Display normalises to canonical lowercase 8-4-4-4-12
915    /// hyphenated form. Equality is byte-wise.
916    Uuid([u8; 16]),
917    /// v7.17.0 Phase 3.P0-32 — PG `time` (without time zone) —
918    /// i64 microseconds since 00:00:00. Range 0..86_400_000_000.
919    /// Display: `HH:MM:SS` zero-padded, with optional `.ffffff`
920    /// suffix when fractional is non-zero.
921    Time(i64),
922    /// v7.17.0 Phase 3.P0-33 — MySQL `YEAR` — u16 in range
923    /// 1901..=2155 plus the special zero-year sentinel 0.
924    /// Display always 4 digits zero-padded (`0000` for the
925    /// sentinel; `1985`/`2007` otherwise).
926    Year(u16),
927    /// v7.17.0 Phase 3.P0-34 — PG `time with time zone` — i64
928    /// microseconds since 00:00:00 in the LOCAL wall clock PLUS
929    /// an i32 offset-from-UTC in seconds. PG preserves the
930    /// offset on output, so the wall-clock value is NOT shifted
931    /// to UTC at storage time. Offset range: ±50400 seconds
932    /// (±14 hours).
933    TimeTz {
934        us: i64,
935        offset_secs: i32,
936    },
937    /// v7.17.0 Phase 3.P0-35 — PG `money` — i64 cents
938    /// (locale-independent storage; the en_US locale renders on
939    /// display via `$N,NNN.CC`).
940    Money(i64),
941    /// v7.17.0 Phase 3.P0-39 — PG `hstore` value: flat
942    /// `text => text` map with NULL value support. Insertion
943    /// order preserved on input; duplicate keys take last-write-
944    /// wins at parse time.
945    Hstore(Vec<(String, Option<String>)>),
946    /// v7.17.0 Phase 3.P0-40 — 2D INT matrix (row-major).
947    IntArray2D(Vec<Vec<Option<i32>>>),
948    /// v7.17.0 Phase 3.P0-40 — 2D BIGINT matrix (row-major).
949    BigIntArray2D(Vec<Vec<Option<i64>>>),
950    /// v7.17.0 Phase 3.P0-40 — 2D TEXT matrix (row-major).
951    TextArray2D(Vec<Vec<Option<String>>>),
952    /// v7.39 (read01 round 75) — see `DataType::BoolArray2D`.
953    BoolArray2D(Vec<Vec<Option<bool>>>),
954    /// v7.17.0 Phase 3.P0-38 — PG range value. One shape covers
955    /// all six builtin range types; `kind` pins the element type
956    /// (must match the column's `DataType::Range(kind)`).
957    /// `lower` / `upper` are `None` for the unbounded sides;
958    /// `lower_inc` / `upper_inc` mirror the canonical PG
959    /// `[` / `(` / `]` / `)` bracket inclusivity. `empty=true`
960    /// supersedes all other fields (the empty range has no
961    /// bounds).
962    Range {
963        kind: RangeKind,
964        // v7.37.42-arena Phase 1: Range bounds stay owned ('static).
965        // Recursive arena lifetimes are awkward to migrate at this
966        // phase and the SCALARSQ hot path doesn't construct ranges.
967        lower: Option<alloc::boxed::Box<Value<'static>>>,
968        upper: Option<alloc::boxed::Box<Value<'static>>>,
969        lower_inc: bool,
970        upper_inc: bool,
971        empty: bool,
972    },
973    /// v7.38 (read01, T9) — a composite / record value (a `row(...)`
974    /// constructor or a whole-row reference). Fields are `(name, value)`; the
975    /// names are `f1..fN` for an anonymous `row(...)` or the source column
976    /// names for a table row. Transient — flows through row_to_json / to_json
977    /// and the composite text form `(a,b)`; not a storable column type here.
978    Composite(alloc::vec::Vec<(alloc::string::String, Value<'static>)>),
979    Null,
980}
981
982/// Owned `Value` — heap-bearing variants are `Cow::Owned`. Used everywhere
983/// a Value must outlive a query-scoped arena (catalog defaults, persistent
984/// storage, public APIs).
985pub type ValueOwned = Value<'static>;
986
987/// v7.37.5 ε — PG `point` building block. Shared by every other
988/// geometric type (lseg / path / box / polygon / circle all
989/// reduce to compositions of `Point2D`). Packed `{x: f64, y: f64}`,
990/// 16 B, on-disk LE field order matches the PG binary point
991/// format byte-for-byte (so a future binary BIND path lands
992/// without rearrangement).
993#[derive(Debug, Clone, Copy, PartialEq)]
994pub struct Point2D {
995    pub x: f64,
996    pub y: f64,
997}
998
999/// v7.37.5 δ — single-range bounds without the kind tag. Used as
1000/// the element type of `Value::Multirange { kind, ranges }` so a
1001/// multirange carries one shared `RangeKind` plus N bounds-only
1002/// spans (saves 1 byte/elem vs duplicating the kind). The five
1003/// other fields mirror `Value::Range` exactly.
1004#[derive(Debug, Clone, PartialEq)]
1005pub struct RangeSpan {
1006    // v7.37.42-arena Phase 1: stays owned ('static) — same rationale as
1007    // Range bounds above.
1008    pub lower: Option<alloc::boxed::Box<Value<'static>>>,
1009    pub upper: Option<alloc::boxed::Box<Value<'static>>>,
1010    pub lower_inc: bool,
1011    pub upper_inc: bool,
1012    pub empty: bool,
1013}
1014
1015/// v7.37.5 β-P4 — element type for `Value::IntervalArray`. Mirrors
1016/// the `{months, days, micros}` shape of scalar `Value::Interval`,
1017/// broken out as a named struct so `IntervalArray`'s element type
1018/// is concrete (24 bytes, packed) instead of an enum-boxed Value.
1019/// All three dimensions are independent — `IntervalSpan { days: 1,
1020/// .. }` is distinct from `IntervalSpan { micros: 86_400_000_000,
1021/// .. }` per PG byte-equal.
1022#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1023pub struct IntervalSpan {
1024    pub months: i32,
1025    pub days: i32,
1026    pub micros: i64,
1027}
1028
1029impl<'arena> Value<'arena> {
1030    /// Type tag, or `None` for `NULL` (unknown at value level).
1031    pub fn data_type(&self) -> Option<DataType> {
1032        match self {
1033            Self::SmallInt(_) => Some(DataType::SmallInt),
1034            Self::Int(_) => Some(DataType::Int),
1035            Self::BigInt(_) => Some(DataType::BigInt),
1036            Self::Float(_) => Some(DataType::Float),
1037            Self::Real(_) => Some(DataType::Real),
1038            // `Text` covers both unbounded TEXT and bounded VARCHAR/CHAR
1039            // — the constraint lives on the column schema, not the value.
1040            Self::Text(_) => Some(DataType::Text),
1041            Self::Bool(_) => Some(DataType::Bool),
1042            Self::Vector(v) => Some(DataType::Vector {
1043                dim: u32::try_from(v.len()).expect("vector dim ≤ u32"),
1044                encoding: VecEncoding::F32,
1045            }),
1046            Self::Sq8Vector(q) => Some(DataType::Vector {
1047                dim: u32::try_from(q.bytes.len()).expect("vector dim ≤ u32"),
1048                encoding: VecEncoding::Sq8,
1049            }),
1050            Self::HalfVector(h) => Some(DataType::Vector {
1051                dim: u32::try_from(h.dim()).expect("vector dim ≤ u32"),
1052                encoding: VecEncoding::F16,
1053            }),
1054            // `Value::Numeric` doesn't carry its precision (the column
1055            // schema does); we surface precision=0 as "unknown" and let
1056            // the engine reconcile against the column type at coercion
1057            // time.
1058            // v7.39 (round 273) — a VALUE's display scale is unsigned and
1059            // never exceeds PG's 16383 ceiling, so it always fits the
1060            // signed declared-scale field this describes itself with.
1061            Self::Numeric { scale, .. } => Some(DataType::Numeric {
1062                precision: 0,
1063                scale: i16::try_from(*scale).unwrap_or(i16::MAX),
1064            }),
1065            Self::NumericBig(b) => Some(DataType::Numeric {
1066                precision: 0,
1067                scale: i16::try_from(b.scale()).unwrap_or(i16::MAX),
1068            }),
1069            Self::Date(_) => Some(DataType::Date),
1070            Self::Timestamp(_) => Some(DataType::Timestamp),
1071            Self::Interval { .. } => Some(DataType::Interval),
1072            Self::Json(_) => Some(DataType::Json),
1073            Self::Bytes(_) => Some(DataType::Bytes),
1074            Self::TextArray(_) => Some(DataType::TextArray),
1075            Self::IntArray(_) => Some(DataType::IntArray),
1076            Self::BigIntArray(_) => Some(DataType::BigIntArray),
1077            Self::IntervalArray(_) => Some(DataType::IntervalArray),
1078            Self::BoolArray(_) => Some(DataType::BoolArray),
1079            Self::SmallIntArray(_) => Some(DataType::SmallIntArray),
1080            Self::FloatArray(_) => Some(DataType::FloatArray),
1081            Self::NumericArray(_) => Some(DataType::NumericArray),
1082            Self::DateArray(_) => Some(DataType::DateArray),
1083            Self::TimestampArray(_) => Some(DataType::TimestampArray),
1084            Self::TimestamptzArray(_) => Some(DataType::TimestamptzArray),
1085            Self::UuidArray(_) => Some(DataType::UuidArray),
1086            Self::JsonArray(_) => Some(DataType::JsonArray),
1087            Self::JsonbArray(_) => Some(DataType::JsonbArray),
1088            Self::BytesArray(_) => Some(DataType::BytesArray),
1089            Self::VarcharArray(_) => Some(DataType::VarcharArray),
1090            Self::CharArray(_) => Some(DataType::CharArray),
1091            Self::Multirange { kind, .. } => Some(DataType::Multirange(*kind)),
1092            Self::Point(_) => Some(DataType::Point),
1093            Self::Lseg(_, _) => Some(DataType::Lseg),
1094            Self::Path { .. } => Some(DataType::Path),
1095            Self::PgBox(_, _) => Some(DataType::PgBox),
1096            Self::Polygon(_) => Some(DataType::Polygon),
1097            Self::Line { .. } => Some(DataType::Line),
1098            Self::Circle { .. } => Some(DataType::Circle),
1099            Self::Inet { .. } => Some(DataType::Inet),
1100            Self::Cidr { .. } => Some(DataType::Cidr),
1101            Self::Macaddr(_) => Some(DataType::Macaddr),
1102            Self::Macaddr8(_) => Some(DataType::Macaddr8),
1103            Self::PgLsn(_) => Some(DataType::PgLsn),
1104            // BitString could be either Bit or BitVarying; column
1105            // schema decides. Default to BitVarying when called
1106            // schema-less (rare; storage path is always
1107            // schema-aware so this only matters for diagnostics).
1108            Self::BitString { .. } => Some(DataType::BitVarying(0)),
1109            Self::Xml(_) => Some(DataType::Xml),
1110            Self::Char1(_) => Some(DataType::Char1),
1111            // BpChar reports its declared width from the padded length.
1112            Self::BpChar(s) => Some(DataType::Char(
1113                u32::try_from(s.chars().count()).unwrap_or(0),
1114            )),
1115            Self::MoneyArray(_) => Some(DataType::MoneyArray),
1116            Self::TsVector(_) => Some(DataType::TsVector),
1117            Self::TsQuery(_) => Some(DataType::TsQuery),
1118            Self::Uuid(_) => Some(DataType::Uuid),
1119            Self::Time(_) => Some(DataType::Time),
1120            Self::Year(_) => Some(DataType::Year),
1121            Self::TimeTz { .. } => Some(DataType::TimeTz),
1122            Self::Money(_) => Some(DataType::Money),
1123            Self::Range { kind, .. } => Some(DataType::Range(*kind)),
1124            Self::Hstore(_) => Some(DataType::Hstore),
1125            Self::IntArray2D(_) => Some(DataType::IntArray2D),
1126            Self::BigIntArray2D(_) => Some(DataType::BigIntArray2D),
1127            Self::TextArray2D(_) => Some(DataType::TextArray2D),
1128            Self::BoolArray2D(_) => Some(DataType::BoolArray2D),
1129            // v7.38 (read01, T9) — a transient composite/record has no storable
1130            // column DataType (it flows through row_to_json / to_json).
1131            Self::Composite(_) => None,
1132            // v7.39 (read01 ruleutils.c) — regclass is eval-only (dual
1133            // oid+name shape); no column storage type.
1134            // v7.39 (round 640) — `xid` became a column type, so its value
1135            // has a DataType to answer with. `cid` and `tid` are equally
1136            // legal column types on PG (measured: `CREATE TABLE t (a cid,
1137            // b tid)` is accepted), but SPG's grammar has no keyword for
1138            // them yet; they stay eval-only rather than half-declared.
1139            Self::Xid(_) => Some(DataType::Xid),
1140            Self::RegClass(..)
1141            | Self::RegProc(..)
1142            | Self::RegType(..)
1143            | Self::Tid(..)
1144            | Self::Cid(_) => None,
1145            Self::Null => None,
1146        }
1147    }
1148
1149    pub const fn is_null(&self) -> bool {
1150        matches!(self, Self::Null)
1151    }
1152
1153    /// v7.37.42-arena Phase 1: lift any `Value<'arena>` (possibly
1154    /// borrowing from a bump arena) into a fully-owned `Value<'static>`.
1155    /// Used at boundaries that must outlive the per-query arena
1156    /// (catalog write, public QueryResult emit, sqlx materialise).
1157    ///
1158    /// For the recursive Range/Multirange variants — bounds are already
1159    /// `Box<Value<'static>>` per Phase 1 design, so we just rebuild the
1160    /// outer enum at `'static`.
1161    pub fn into_owned(self) -> Value<'static> {
1162        match self {
1163            Value::SmallInt(n) => Value::SmallInt(n),
1164            Value::Int(n) => Value::Int(n),
1165            Value::BigInt(n) => Value::BigInt(n),
1166            Value::Float(f) => Value::Float(f),
1167            Value::Real(f) => Value::Real(f),
1168            Value::Text(s) => Value::Text(Cow::Owned(s.into_owned())),
1169            Value::Bool(b) => Value::Bool(b),
1170            Value::Vector(v) => Value::Vector(Cow::Owned(v.into_owned())),
1171            Value::Sq8Vector(q) => Value::Sq8Vector(q),
1172            Value::HalfVector(h) => Value::HalfVector(h),
1173            Value::Numeric {
1174                scaled,
1175                scale,
1176                kind,
1177            } => Value::Numeric {
1178                scaled,
1179                scale,
1180                kind,
1181            },
1182            Value::NumericBig(b) => Value::NumericBig(b),
1183            Value::Date(d) => Value::Date(d),
1184            Value::Timestamp(t) => Value::Timestamp(t),
1185            Value::Interval {
1186                months,
1187                days,
1188                micros,
1189            } => Value::Interval {
1190                months,
1191                days,
1192                micros,
1193            },
1194            Value::Json(s) => Value::Json(Cow::Owned(s.into_owned())),
1195            Value::Bytes(b) => Value::Bytes(Cow::Owned(b.into_owned())),
1196            Value::TextArray(v) => Value::TextArray(v),
1197            Value::IntArray(v) => Value::IntArray(v),
1198            Value::BigIntArray(v) => Value::BigIntArray(v),
1199            Value::IntervalArray(v) => Value::IntervalArray(v),
1200            Value::BoolArray(v) => Value::BoolArray(v),
1201            Value::SmallIntArray(v) => Value::SmallIntArray(v),
1202            Value::FloatArray(v) => Value::FloatArray(v),
1203            Value::NumericArray(v) => Value::NumericArray(v),
1204            Value::DateArray(v) => Value::DateArray(v),
1205            Value::TimestampArray(v) => Value::TimestampArray(v),
1206            Value::TimestamptzArray(v) => Value::TimestamptzArray(v),
1207            Value::UuidArray(v) => Value::UuidArray(v),
1208            Value::JsonArray(v) => Value::JsonArray(v),
1209            Value::JsonbArray(v) => Value::JsonbArray(v),
1210            Value::BytesArray(v) => Value::BytesArray(v),
1211            Value::VarcharArray(v) => Value::VarcharArray(v),
1212            Value::CharArray(v) => Value::CharArray(v),
1213            Value::Multirange { kind, ranges } => Value::Multirange { kind, ranges },
1214            // v7.38 (read01, T9) — Composite fields are already `Value<'static>`.
1215            Value::Composite(fields) => Value::Composite(fields),
1216            Value::RegClass(oid, name) => Value::RegClass(oid, name),
1217            Value::Tid(b, o) => Value::Tid(b, o),
1218            Value::Xid(x) => Value::Xid(x),
1219            Value::Cid(c) => Value::Cid(c),
1220            Value::RegProc(oid, name) => Value::RegProc(oid, name),
1221            Value::RegType(oid, name) => Value::RegType(oid, name),
1222            Value::Point(p) => Value::Point(p),
1223            Value::Lseg(a, b) => Value::Lseg(a, b),
1224            Value::Path { points, closed } => Value::Path { points, closed },
1225            Value::PgBox(a, b) => Value::PgBox(a, b),
1226            Value::Polygon(p) => Value::Polygon(p),
1227            Value::Line { a, b, c } => Value::Line { a, b, c },
1228            Value::Circle { center, radius } => Value::Circle { center, radius },
1229            Value::Inet { family, bits, addr } => Value::Inet { family, bits, addr },
1230            Value::Cidr { family, bits, addr } => Value::Cidr { family, bits, addr },
1231            Value::Macaddr(m) => Value::Macaddr(m),
1232            Value::Macaddr8(m) => Value::Macaddr8(m),
1233            Value::PgLsn(l) => Value::PgLsn(l),
1234            Value::BitString { nbits, bytes } => Value::BitString {
1235                nbits,
1236                bytes: Cow::Owned(bytes.into_owned()),
1237            },
1238            Value::Xml(s) => Value::Xml(Cow::Owned(s.into_owned())),
1239            Value::Char1(c) => Value::Char1(c),
1240            Value::BpChar(s) => Value::BpChar(Cow::Owned(s.into_owned())),
1241            Value::MoneyArray(v) => Value::MoneyArray(v),
1242            Value::TsVector(v) => Value::TsVector(v),
1243            Value::TsQuery(q) => Value::TsQuery(q),
1244            Value::Uuid(u) => Value::Uuid(u),
1245            Value::Time(t) => Value::Time(t),
1246            Value::Year(y) => Value::Year(y),
1247            Value::TimeTz { us, offset_secs } => Value::TimeTz { us, offset_secs },
1248            Value::Money(m) => Value::Money(m),
1249            Value::Range {
1250                kind,
1251                lower,
1252                upper,
1253                lower_inc,
1254                upper_inc,
1255                empty,
1256            } => Value::Range {
1257                kind,
1258                lower,
1259                upper,
1260                lower_inc,
1261                upper_inc,
1262                empty,
1263            },
1264            Value::Hstore(h) => Value::Hstore(h),
1265            Value::IntArray2D(a) => Value::IntArray2D(a),
1266            Value::BigIntArray2D(a) => Value::BigIntArray2D(a),
1267            Value::TextArray2D(a) => Value::TextArray2D(a),
1268            Value::BoolArray2D(a) => Value::BoolArray2D(a),
1269            Value::Null => Value::Null,
1270        }
1271    }
1272
1273    /// v7.37.42-arena Phase 4 — copy heap payloads into the supplied
1274    /// bump arena, yielding a `Value<'a>` whose Cow-variant payloads
1275    /// are arena-borrowed (or stay as small owned scalars for the
1276    /// `Copy`-able variants).
1277    ///
1278    /// Used at the catalog ↔ ephemeral boundary: a `ColumnSchema.default`
1279    /// is `Value<'static>` but INSERT-time eval may want it stamped into
1280    /// the per-statement arena alongside other arena-built scalars.
1281    ///
1282    /// Allocates only into the supplied arena; the input `&self` keeps
1283    /// its own storage. For `Copy`-able / nested-owned variants the
1284    /// implementation falls back to `clone()` (the nested heap blocks
1285    /// stay on the global allocator, which is fine — the boundary
1286    /// requirement is just "no aliasing of caller-owned strings").
1287    pub fn clone_into<'a>(&self, arena: &'a bumpalo::Bump) -> Value<'a> {
1288        match self {
1289            Value::Text(s) => Value::Text(Cow::Borrowed(arena.alloc_str(s))),
1290            Value::Json(s) => Value::Json(Cow::Borrowed(arena.alloc_str(s))),
1291            Value::Xml(s) => Value::Xml(Cow::Borrowed(arena.alloc_str(s))),
1292            Value::BpChar(s) => Value::BpChar(Cow::Borrowed(arena.alloc_str(s))),
1293            Value::Bytes(b) => {
1294                let slot = arena.alloc_slice_copy::<u8>(b);
1295                Value::Bytes(Cow::Borrowed(slot))
1296            }
1297            Value::Vector(v) => {
1298                let slot = arena.alloc_slice_copy::<f32>(v);
1299                Value::Vector(Cow::Borrowed(slot))
1300            }
1301            Value::BitString { nbits, bytes } => {
1302                let slot = arena.alloc_slice_copy::<u8>(bytes);
1303                Value::BitString {
1304                    nbits: *nbits,
1305                    bytes: Cow::Borrowed(slot),
1306                }
1307            }
1308            // Copy-able scalars + variants whose nested heap blocks are
1309            // `'static` regardless of `'arena` (TextArray, JsonArray,
1310            // Hstore, TsVector, Range bounds, …). Clone the heap block
1311            // via the standard `into_owned()` path then lift the
1312            // resulting `Value<'static>` to `Value<'a>` via the Cow
1313            // variance — `'static` covers any lifetime.
1314            other => other.clone().into_owned(),
1315        }
1316    }
1317}
1318
1319impl Value<'static> {
1320    /// v7.37.42-arena Phase 1 — owned-Text constructor. The variant now
1321    /// holds `Cow<'arena, str>`, so the previous `Value::Text(String)`
1322    /// shape no longer compiles directly. This helper preserves the
1323    /// historical ergonomics: `Value::text("foo")` or
1324    /// `Value::text(String::from("foo"))`.
1325    pub fn text<S: Into<String>>(s: S) -> Self {
1326        Value::Text(Cow::Owned(s.into()))
1327    }
1328
1329    /// v7.38 (read01, T6) — a finite NUMERIC from its fixed-point parts.
1330    pub const fn numeric(scaled: i128, scale: u16) -> Self {
1331        Value::Numeric {
1332            scaled,
1333            scale,
1334            kind: NumericKind::Finite,
1335        }
1336    }
1337
1338    /// v7.38 (read01, T6) — a special NUMERIC (NaN / ±Infinity). The fixed-point
1339    /// fields are canonicalized to 0 so equal specials compare byte-identical.
1340    pub const fn numeric_special(kind: NumericKind) -> Self {
1341        Value::Numeric {
1342            scaled: 0,
1343            scale: 0,
1344            kind,
1345        }
1346    }
1347
1348    /// v7.37.42-arena Phase 1 — owned-Json constructor (mirrors `text`).
1349    pub fn json<S: Into<String>>(s: S) -> Self {
1350        Value::Json(Cow::Owned(s.into()))
1351    }
1352
1353    /// v7.37.42-arena Phase 1 — owned-Xml constructor.
1354    pub fn xml<S: Into<String>>(s: S) -> Self {
1355        Value::Xml(Cow::Owned(s.into()))
1356    }
1357
1358    /// v7.37.42-arena Phase 1 — owned-Bytes constructor.
1359    pub fn bytes<B: Into<Vec<u8>>>(b: B) -> Self {
1360        Value::Bytes(Cow::Owned(b.into()))
1361    }
1362
1363    /// v7.37.42-arena Phase 1 — owned-Vector constructor.
1364    pub fn vector<V: Into<Vec<f32>>>(v: V) -> Self {
1365        Value::Vector(Cow::Owned(v.into()))
1366    }
1367
1368    /// v7.37.42-arena Phase 1 — owned-BitString constructor.
1369    pub fn bit_string<B: Into<Vec<u8>>>(nbits: u32, bytes: B) -> Self {
1370        Value::BitString {
1371            nbits,
1372            bytes: Cow::Owned(bytes.into()),
1373        }
1374    }
1375}
1376
1377/// One table row — values are positional and must match
1378/// `TableSchema.columns` in length and (modulo NULL) in `DataType`.
1379///
1380/// v7.37.42-arena Phase 1: parameterised on `'arena` so per-query rows
1381/// can borrow from a bump arena. The owned shape (`Row<'static>`, alias
1382/// `RowOwned`) is what catalog storage, public APIs, and tests use.
1383#[derive(Debug, Clone, PartialEq)]
1384pub struct Row<'arena> {
1385    pub values: Vec<Value<'arena>>,
1386}
1387
1388/// Owned `Row` — values are `Value<'static>`. Used everywhere a row must
1389/// outlive a query-scoped arena.
1390pub type RowOwned = Row<'static>;
1391
1392impl<'arena> Row<'arena> {
1393    pub const fn new(values: Vec<Value<'arena>>) -> Self {
1394        Self { values }
1395    }
1396
1397    pub fn len(&self) -> usize {
1398        self.values.len()
1399    }
1400
1401    pub fn is_empty(&self) -> bool {
1402        self.values.is_empty()
1403    }
1404}
1405
1406impl<'arena> Row<'arena> {
1407    /// v7.37.42-arena Phase 4 — copy every cell into the supplied bump
1408    /// arena, yielding a `Row<'a>` whose Cow-payloads are arena-borrowed.
1409    /// Boundary helper for catalog defaults → DML eval handoff and
1410    /// arena-local row scratch.
1411    pub fn clone_into<'a>(&self, arena: &'a bumpalo::Bump) -> Row<'a> {
1412        Row {
1413            values: self.values.iter().map(|v| v.clone_into(arena)).collect(),
1414        }
1415    }
1416
1417    /// v7.37.42-arena Phase 4 — lift this `Row<'arena>` to a fully-owned
1418    /// `Row<'static>` for catalog write / WAL serialisation. Equivalent
1419    /// to `Row::from_arena(self)` but consumes by value at any lifetime
1420    /// (callers can write `row.into_owned()` mirroring `Value::into_owned`).
1421    pub fn into_owned(self) -> Row<'static> {
1422        Row {
1423            values: self.values.into_iter().map(Value::into_owned).collect(),
1424        }
1425    }
1426}
1427
1428impl Row<'static> {
1429    /// v7.37.42-arena Phase 1 — lift any `Row<'arena>` (possibly arena-
1430    /// borrowed) into a fully-owned `Row<'static>`. Mirrors
1431    /// `Value::into_owned`.
1432    pub fn from_arena(row: Row<'_>) -> Self {
1433        Self {
1434            values: row.values.into_iter().map(Value::into_owned).collect(),
1435        }
1436    }
1437}
1438
1439/// Each bool is an independent, separately-persisted column attribute
1440/// (`nullable`, `auto_increment`, `is_unsigned`, `identity_always`) that the
1441/// catalog appendix reads and writes by name. Packing them into a bitflags
1442/// word would buy nothing and would put a decoding step between the on-disk
1443/// format and every reader of the schema.
1444#[allow(clippy::struct_excessive_bools)]
1445#[derive(Debug, Clone, PartialEq)]
1446pub struct ColumnSchema {
1447    pub name: String,
1448    pub ty: DataType,
1449    pub nullable: bool,
1450    /// Optional `DEFAULT` value, frozen at CREATE TABLE time. `None`
1451    /// means "no default" (so omitted columns become NULL, or error
1452    /// out when the column is NOT NULL). Literal defaults take this
1453    /// path.
1454    ///
1455    /// v7.37.42-arena Phase 1: explicitly `Value<'static>` — catalog
1456    /// defaults must outlive any per-query arena.
1457    pub default: Option<Value<'static>>,
1458    /// v7.9.21 — for DEFAULT expressions that need INSERT-time
1459    /// evaluation (e.g. `DEFAULT now()`, `DEFAULT CURRENT_TIMESTAMP`),
1460    /// the Display form of the expression. The engine re-parses
1461    /// it on each INSERT default-fill, evaluates against an empty
1462    /// row context, and coerces to the column type. mailrs G4.
1463    /// Persisted in catalog FILE_VERSION 15+; older catalogs
1464    /// deserialise with None.
1465    pub runtime_default: Option<String>,
1466    /// MySQL-style `AUTO_INCREMENT`. When set, an INSERT that leaves
1467    /// this column unbound (or sets it to NULL) gets the next integer
1468    /// computed from the column's current max + 1.
1469    /// v7.39 (round 676) — the collation NAME as written, when the column
1470    /// carried an explicit `COLLATE`.
1471    ///
1472    /// `spg_sql::Collation` cannot carry it: it is a two-variant MySQL enum
1473    /// and `from_collation_name` folds `C`, `POSIX`, `en_US` and `default`
1474    /// all into `Binary`. Without the name `pg_attribute.attcollation` can
1475    /// only ever report the type's default, which is what F36 records as
1476    /// "the declaration is taken and ignored".
1477    ///
1478    /// None means the column was written without a `COLLATE` clause and
1479    /// takes its type's collation. Persisted through the v88 appendix,
1480    /// which costs two bytes for a table that declares none.
1481    pub collation_name: Option<String>,
1482    pub auto_increment: bool,
1483    /// v7.17.0 Phase 1.4 — when the column is bound to a user-
1484    /// defined ENUM type (the parser saw an unknown type ident
1485    /// and the engine resolved it against `catalog.enum_types`),
1486    /// this carries the enum name so INSERT/UPDATE can validate
1487    /// the cell value against the enum's labels. `ty` is
1488    /// `DataType::Text` in that case. Persisted in catalog
1489    /// FILE_VERSION 29+; older catalogs deserialise with None.
1490    pub user_enum_type: Option<String>,
1491    /// v7.17.0 Phase 1.5 — when the column is bound to a user-
1492    /// defined DOMAIN (the parser saw an unknown type ident and
1493    /// the engine resolved it against `catalog.domain_types`),
1494    /// this carries the domain name. `ty` is the domain's base
1495    /// type; INSERT/UPDATE re-evaluates the domain's CHECK list
1496    /// + NOT NULL against the cell value. Persisted in catalog
1497    /// FILE_VERSION 30+; older catalogs deserialise with None.
1498    pub user_domain_type: Option<String>,
1499    /// v7.39 (read01 round 56) — when the column is bound to a user-defined
1500    /// COMPOSITE type. `ty` stays `DataType::Jsonb` (the on-disk form), but the
1501    /// engine REHYDRATES the stored JSON into a `Value::Composite` on read, so
1502    /// field access `(p).x`, `= ROW(…)`, ordering and the canonical `(2,b)`
1503    /// text form all work — they were already implemented on Value::Composite;
1504    /// what was missing was that the column never recorded WHICH composite type
1505    /// it holds (this field's doc comment existed for two releases, the field
1506    /// itself did not). Persisted in the composite-column appendix
1507    /// (FILE_VERSION 63+); older catalogs deserialise with None.
1508    pub user_composite_type: Option<String>,
1509    /// v7.39 (read01 round 59) — column-level privileges (PG
1510    /// `pg_attribute.attacl`). `GRANT SELECT (pub) ON t TO dan` lands here and
1511    /// does NOT touch the table's `relacl`. Empty = no column grant, which is
1512    /// every column until one is made.
1513    pub acl: Vec<AclItem>,
1514    /// v7.17.0 Phase 2.1 — MySQL `ON UPDATE CURRENT_TIMESTAMP`
1515    /// column attribute. When `Some(expr_src)`, an UPDATE that
1516    /// does NOT bind this column overrides the new value with
1517    /// the engine-evaluated expression (always `now()` in
1518    /// v7.17.0). Stored as Display-form source so storage
1519    /// stays free of spg-sql; the engine re-parses at UPDATE
1520    /// time. Persisted in catalog FILE_VERSION 32+; older
1521    /// catalogs deserialise with None — preserves the existing
1522    /// "silent ignore" behaviour for snapshots written before
1523    /// the upgrade.
1524    pub on_update_runtime: Option<String>,
1525    /// v7.17.0 Phase 2.5 — text collation. Pre-2.5 SPG accepted
1526    /// `COLLATE <name>` clauses but discarded the name, so a
1527    /// column declared `COLLATE "case_insensitive"` (or any
1528    /// MySQL `_ci` collation) still compared byte-wise — a
1529    /// Tier-S silent failure where `WHERE name = 'foo'` never
1530    /// matched stored `'Foo'`. This carries the parser-derived
1531    /// classification so the engine's WHERE evaluator can route
1532    /// text equality through a case-aware compare. `Binary` (the
1533    /// default) preserves the prior byte-wise behaviour. Only
1534    /// CaseInsensitive lands in the catalog appendix — Binary
1535    /// columns stay implicit, keeping snapshots compact.
1536    /// Persisted in catalog FILE_VERSION 34+; older catalogs
1537    /// deserialise every column as `Binary`.
1538    pub collation: Collation,
1539    /// v7.17.0 Phase 4.4 — MySQL `UNSIGNED` modifier flag. Drives
1540    /// engine-side INSERT / UPDATE range enforcement (rejects
1541    /// negative values on UNSIGNED int columns). Pre-4.4 the
1542    /// parser consumed and discarded the keyword silently, so
1543    /// every UNSIGNED column quietly accepted negatives — a
1544    /// Tier-A correctness drift. Sparse: only UNSIGNED columns
1545    /// land in the catalog appendix; the default `false` keeps
1546    /// snapshots compact for the common signed-int path.
1547    /// Persisted in catalog FILE_VERSION 35+; older catalogs
1548    /// deserialise every column as `is_unsigned = false`.
1549    pub is_unsigned: bool,
1550    /// v7.17.0 Phase 3.P0-36 — MySQL inline `ENUM('a','b','c')`
1551    /// value list. Distinct from `user_enum_type` (which points
1552    /// to a separately CREATE TYPE'd PG enum); this carries the
1553    /// column-local list MySQL DDL declares inline. When `Some`,
1554    /// `ty` is `DataType::Text` and INSERT/UPDATE validates the
1555    /// cell value against this list. Variant ORDER is preserved
1556    /// (MySQL uses it for `ORDER BY col`). Sparse: only ENUM
1557    /// columns land in the catalog appendix.
1558    /// Persisted in catalog FILE_VERSION 41+; older catalogs
1559    /// deserialise with None — preserves silent-drop behaviour
1560    /// for snapshots written before P0-36.
1561    pub inline_enum_variants: Option<Vec<String>>,
1562    /// v7.17.0 Phase 3.P0-37 — MySQL inline `SET('a','b','c')`
1563    /// variant list. Storage is TEXT (canonical comma-joined in
1564    /// definition order, de-duplicated). INSERT/UPDATE validates
1565    /// every comma-separated token against this list. Sparse:
1566    /// only SET columns land in the catalog appendix.
1567    /// Persisted in catalog FILE_VERSION 42+; older catalogs
1568    /// deserialise with None.
1569    pub inline_set_variants: Option<Vec<String>>,
1570    /// v7.37.7(sentori Epic 3 P1)— `GENERATED ALWAYS AS (<expr>)
1571    /// STORED` computed-column source. When `Some`, INSERT / UPDATE
1572    /// recompute the cell against the candidate row(re-parse the
1573    /// stored Display form and evaluate)and overwrite any
1574    /// user-supplied value, matching PG's stored-generated-column
1575    /// semantics. `None` (the default) preserves the regular
1576    /// "column value is whatever the caller passed" path.
1577    /// Persisted in catalog FILE_VERSION 50+; older catalogs
1578    /// deserialise with None.
1579    pub generated_stored_expr: Option<String>,
1580    /// v7.38 (read01) — `GENERATED ALWAYS AS IDENTITY`. Both identity
1581    /// flavours set `auto_increment`; this additionally marks the ALWAYS
1582    /// flavour, whose explicit INSERT value PG rejects ("cannot insert a
1583    /// non-DEFAULT value into column …") unless `OVERRIDING SYSTEM VALUE`.
1584    /// `false` (serial / `BY DEFAULT`) keeps the permissive path. In-memory
1585    /// only for now — not yet in the catalog appendix, so a reloaded table
1586    /// deserialises as `false` (the pre-existing permissive behaviour).
1587    pub identity_always: bool,
1588    /// v7.38 (read01) — the DEFAULT expression's source text, deparsed to
1589    /// PG-compatible form at CREATE TABLE time (e.g. `0`, `(3 + 4)`,
1590    /// `'hi'::text`, `now()`, `CURRENT_DATE`). Distinct from `default`
1591    /// (the coerced value the INSERT path fills) and `runtime_default`
1592    /// (the recompute-per-row Display form): those lose the source
1593    /// spelling, so `information_schema.columns.column_default` /
1594    /// `pg_attrdef` / `pg_get_expr` reported the coerced render
1595    /// (`0.00` for `numeric(10,2) DEFAULT 0`) instead of PG's `0`.
1596    /// `None` for a column with no explicit default. Persisted in catalog
1597    /// FILE_VERSION 58+; older catalogs deserialise with None.
1598    pub default_text: Option<String>,
1599    /// v7.39 (round 220) — `ALTER TABLE … ALTER COLUMN … RESTART [WITH n]`
1600    /// on an identity column. SPG's identity allocation is a max+1 scan;
1601    /// this floor lifts the next allocated value to at least `n`
1602    /// (`max(max+1, n)`) — exactly what a dump-restore RESTART needs, and
1603    /// safer than PG for a backward RESTART (no duplicate-key landmine).
1604    /// Persisted in the FILE_VERSION 73+ sparse appendix; older catalogs
1605    /// deserialise with None.
1606    pub auto_restart: Option<i64>,
1607    /// v7.39 (read01 round 78) — this column is the ONLY column of a FROM item
1608    /// that calls a function returning a BASE type, so the item's row type IS
1609    /// this column: a whole-row reference collapses to the value
1610    /// (`SELECT j FROM jsonb_array_elements('[1]') AS j` → `1`, PG). Runtime
1611    /// only — a catalogued table column is never one, and it is not persisted.
1612    pub scalar_row_source: bool,
1613    /// v7.39 (round 386, type-fidelity epic P1) — the declared MySQL narrow
1614    /// integer width (TINYINT / MEDIUMINT) whose range the storage `ty`
1615    /// (SmallInt / Int) is too wide to enforce. `None` for every other
1616    /// column. Drives the epic-P2 write-path range check. Persisted in the
1617    /// FILE_VERSION 81+ sparse appendix; older catalogs deserialise as None.
1618    pub mysql_int_width: Option<MysqlIntWidth>,
1619    /// v7.39 (round 424, type-fidelity epic) — the declared MySQL
1620    /// fractional-seconds precision of a temporal column: `DATETIME(3)` is
1621    /// `Some(3)`, a BARE `DATETIME` / `TIME` / `TIMESTAMP` is `Some(0)`
1622    /// (MySQL's default is zero — the fraction is dropped on write), and
1623    /// `None` means "not a MySQL-declared temporal column", which is every
1624    /// PG column and leaves microsecond behaviour untouched.
1625    ///
1626    /// Drives write-path truncation (toward zero) and render padding
1627    /// (exactly this many digits, `.000` when the fraction is zero).
1628    /// Persisted in the FILE_VERSION 82+ sparse appendix; older catalogs
1629    /// deserialise as None.
1630    pub mysql_fsp: Option<u8>,
1631}
1632
1633/// v7.17.0 Phase 2.5 — column-level text collation. Drives the
1634/// engine's WHERE / GROUP BY equality routing for `Value::Text`.
1635/// Only two variants are modelled in v7.17:
1636///   * `Binary`  — byte-wise comparison (the SPG default;
1637///                 matches PG `COLLATE "C"` / `pg_catalog.default`
1638///                 and MySQL `*_bin`).
1639///   * `CaseInsensitive` — ASCII case-folded comparison (like
1640///                 MySQL `*_ci` collations; PG has NO built-in
1641///                 collation of this name — round-761 audit: a
1642///                 nondeterministic ICU collation must be CREATEd
1643///                 there first). Non-ASCII bytes
1644///                 still compare byte-wise; full ICU folding is
1645///                 out of v7.17 scope.
1646/// New variants append at the end — older catalogs read missing
1647/// columns as `Binary`.
1648#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1649pub enum Collation {
1650    Binary,
1651    CaseInsensitive,
1652}
1653
1654/// v7.39 (round 386, type-fidelity epic P1) — the declared MySQL narrow
1655/// integer type for a column whose storage `DataType` cannot express it.
1656/// MySQL `TINYINT` (i8, -128..127) collapses to `DataType::SmallInt` (i16)
1657/// and `MEDIUMINT` (24-bit) to `DataType::Int` (i32) — both wider than the
1658/// declared type, so a range check against `ty` alone accepts out-of-range
1659/// values (`INSERT 128 INTO TINYINT` is stored silently where MariaDB
1660/// strict raises ERROR 1264). This annotation records the lost width so the
1661/// write path (epic P2) can enforce the real bounds. `SMALLINT` / `INT` /
1662/// `BIGINT` need no marker — their storage `DataType` is already faithful.
1663/// Sparse: only TINYINT / MEDIUMINT columns carry it; persisted in the
1664/// FILE_VERSION 81+ appendix, older catalogs deserialise as None.
1665#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1666pub enum MysqlIntWidth {
1667    /// MySQL `TINYINT` — signed -128..127, unsigned 0..255. Storage i16.
1668    Tiny,
1669    /// MySQL `SMALLINT UNSIGNED` — 0..65535. Storage widened to i32 (a
1670    /// signed SMALLINT keeps `DataType::SmallInt` and carries no marker).
1671    Small,
1672    /// MySQL `MEDIUMINT` — signed -8388608..8388607, unsigned 0..16777215.
1673    /// Storage i32.
1674    Medium,
1675    /// MySQL `INT UNSIGNED` — 0..4294967295. Storage widened to i64 (a
1676    /// signed INT keeps `DataType::Int` and carries no marker).
1677    Int,
1678    /// v7.39 (round 471, epic P4b) — MySQL `BIGINT UNSIGNED` —
1679    /// 0..18446744073709551615. i64 stops at 2^63-1, so the storage tag is
1680    /// widened to `Numeric` (i128-backed, scale 0), which already compares,
1681    /// orders, indexes and renders as an exact integer. A signed BIGINT
1682    /// keeps `DataType::BigInt` and carries no marker.
1683    Big,
1684}
1685
1686/// v7.39 (round 363, M4 P1) — MySQL's default accent- and
1687/// case-insensitive fold (`utf8mb4_uca1400_ai_ci`).
1688///
1689/// This is the primitive M4 rests on: a session on the MySQL dialect
1690/// compares, groups, sorts and de-duplicates text by its FOLDED form, so
1691/// `Foo` = `foo` = `FOO` and, because the default collation is accent-
1692/// insensitive too, `Bär` = `bar`. The later stages (read path, then the
1693/// UNIQUE / index write path) all route through here so they cannot fold
1694/// differently from one another.
1695///
1696/// The fold is more than case + strip-combining: MariaDB EXPANDS some
1697/// letters — `ß` → `ss`, `æ` → `ae`, `œ` → `oe` — which is why the result
1698/// is built as a `String` rather than mapped char-for-char. Every mapping
1699/// below was measured on MariaDB 11 (`'Bär'='bar'` is 1, `'straße'=
1700/// 'strasse'` is 1, `'a'='æ'` is 0, `'s'='ß'` is 0). Characters with no
1701/// entry keep their lower-cased self, so ASCII and unknown scripts pass
1702/// through unchanged.
1703#[must_use]
1704pub fn mysql_ci_fold(s: &str) -> String {
1705    let mut out = String::with_capacity(s.len());
1706    for ch in s.chars() {
1707        // Lower-case first (`À` → `à`, `Æ` → `æ`), then fold the base.
1708        for lc in ch.to_lowercase() {
1709            match fold_latin_base(lc) {
1710                Some(base) => out.push_str(base),
1711                None => out.push(lc),
1712            }
1713        }
1714    }
1715    out
1716}
1717
1718/// v7.39 (round 375) — the fold used to COMPARE / GROUP / de-dup text on
1719/// the MySQL dialect. Its default collation is PAD SPACE: trailing spaces
1720/// do not affect a comparison (`'a' = 'a '`, `'' = ' '`, measured on
1721/// MariaDB 11), so they are stripped before the case/accent fold. Only
1722/// literal spaces pad — a tab or other whitespace is significant — and
1723/// this is NOT used by `LIKE`, whose pattern treats a trailing space
1724/// literally.
1725pub fn mysql_compare_fold(s: &str) -> String {
1726    mysql_ci_fold(s.trim_end_matches(' '))
1727}
1728
1729/// The base letter(s) a lower-cased Latin character folds to, or `None`
1730/// when it is already a base / has no fold. Expansions (`ß` → `ss`) are
1731/// why this returns a string.
1732fn fold_latin_base(c: char) -> Option<&'static str> {
1733    Some(match c {
1734        'à' | 'á' | 'â' | 'ã' | 'ä' | 'å' | 'ā' | 'ă' | 'ą' => "a",
1735        'æ' => "ae",
1736        'ç' | 'ć' | 'č' | 'ĉ' | 'ċ' => "c",
1737        'ð' | 'ď' | 'đ' => "d",
1738        'è' | 'é' | 'ê' | 'ë' | 'ē' | 'ĕ' | 'ė' | 'ę' | 'ě' => "e",
1739        'ĝ' | 'ğ' | 'ġ' | 'ģ' => "g",
1740        'ì' | 'í' | 'î' | 'ï' | 'ĩ' | 'ī' | 'ĭ' | 'į' => "i",
1741        'ĵ' => "j",
1742        'ķ' => "k",
1743        'ł' | 'ĺ' | 'ļ' | 'ľ' => "l",
1744        'ñ' | 'ń' | 'ņ' | 'ň' => "n",
1745        'ò' | 'ó' | 'ô' | 'õ' | 'ö' | 'ø' | 'ō' | 'ŏ' | 'ő' => "o",
1746        'œ' => "oe",
1747        'ŕ' | 'ŗ' | 'ř' => "r",
1748        'ś' | 'š' | 'ŝ' | 'ş' => "s",
1749        'ß' => "ss",
1750        'ţ' | 'ť' | 'ŧ' => "t",
1751        'ù' | 'ú' | 'û' | 'ü' | 'ũ' | 'ū' | 'ŭ' | 'ů' | 'ű' | 'ų' => "u",
1752        'ý' | 'ÿ' => "y",
1753        'ź' | 'ž' | 'ż' => "z",
1754        _ => return None,
1755    })
1756}
1757
1758#[allow(clippy::derivable_impls)]
1759impl Default for Collation {
1760    fn default() -> Self {
1761        Self::Binary
1762    }
1763}
1764
1765impl Collation {
1766    /// Wire tag persisted in the FILE_VERSION 34+ catalog appendix.
1767    /// Stable: future variants append above the recognised range
1768    /// and unknown tags read back as `Binary` for forward-compat
1769    /// on rollback.
1770    pub const TAG_BINARY: u8 = 0;
1771    pub const TAG_CASE_INSENSITIVE: u8 = 1;
1772}
1773
1774/// v7.39 (RLS) — the command a policy applies to. `ALL` is the default and
1775/// covers every command; the others scope the policy to one statement kind.
1776/// Persisted as a single byte in the policy appendix (FILE_VERSION 59+).
1777#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1778pub enum PolicyCmd {
1779    All,
1780    Select,
1781    Insert,
1782    Update,
1783    Delete,
1784}
1785
1786impl PolicyCmd {
1787    /// PG `pg_policy.polcmd` single-char encoding.
1788    #[must_use]
1789    pub const fn as_pg_char(self) -> char {
1790        match self {
1791            Self::All => '*',
1792            Self::Select => 'r',
1793            Self::Insert => 'a',
1794            Self::Update => 'w',
1795            Self::Delete => 'd',
1796        }
1797    }
1798
1799    /// PG `pg_policies.cmd` word form.
1800    #[must_use]
1801    pub const fn as_pg_word(self) -> &'static str {
1802        match self {
1803            Self::All => "ALL",
1804            Self::Select => "SELECT",
1805            Self::Insert => "INSERT",
1806            Self::Update => "UPDATE",
1807            Self::Delete => "DELETE",
1808        }
1809    }
1810
1811    #[must_use]
1812    pub const fn to_wire_byte(self) -> u8 {
1813        match self {
1814            Self::All => 0,
1815            Self::Select => 1,
1816            Self::Insert => 2,
1817            Self::Update => 3,
1818            Self::Delete => 4,
1819        }
1820    }
1821
1822    #[must_use]
1823    pub const fn from_wire_byte(b: u8) -> Option<Self> {
1824        match b {
1825            0 => Some(Self::All),
1826            1 => Some(Self::Select),
1827            2 => Some(Self::Insert),
1828            3 => Some(Self::Update),
1829            4 => Some(Self::Delete),
1830            _ => None,
1831        }
1832    }
1833}
1834
1835/// v7.39 (RLS) — one `CREATE POLICY` object, stored per table. The `using_expr`
1836/// / `with_check_expr` hold the qualifying expression's `Display` form
1837/// (re-parsed and evaluated per row at enforcement time, exactly like
1838/// `TableSchema.checks`); `None` means the clause was absent. `roles` empty =
1839/// PUBLIC. Persisted in the policy appendix (FILE_VERSION 59+).
1840#[derive(Debug, Clone, PartialEq)]
1841pub struct PolicyDef {
1842    pub name: String,
1843    pub cmd: PolicyCmd,
1844    /// `true` = PERMISSIVE (default, OR-combined), `false` = RESTRICTIVE
1845    /// (AND-combined).
1846    pub permissive: bool,
1847    pub roles: Vec<String>,
1848    pub using_expr: Option<String>,
1849    pub with_check_expr: Option<String>,
1850}
1851
1852#[derive(Debug, Clone, PartialEq)]
1853pub struct TableSchema {
1854    pub name: String,
1855    pub columns: Vec<ColumnSchema>,
1856    /// v6.7.2 — per-table hot-tier byte budget override. `None`
1857    /// falls through to the global `SPG_HOT_TIER_BYTES` setting;
1858    /// `Some(n)` overrides it for this specific table. Set via
1859    /// `ALTER TABLE t SET hot_tier_bytes = X`. Persisted in
1860    /// catalog FILE_VERSION 11+.
1861    pub hot_tier_bytes: Option<u64>,
1862    /// v7.6.1 — FOREIGN KEY constraints declared on this table.
1863    /// Engine maintains this in lock-step with `spg-sql`'s parser
1864    /// AST; the storage layer carries the on-disk shape so a
1865    /// catalog snapshot round-trips without external mapping.
1866    /// Persisted in catalog FILE_VERSION 13+. Older catalogs
1867    /// deserialise with an empty vec.
1868    pub foreign_keys: Vec<ForeignKeyConstraint>,
1869    /// v7.9.19 — composite UNIQUE / PRIMARY KEY constraints
1870    /// declared at the table level. Each entry's leading column
1871    /// has a BTree index (created via the constraint), and INSERT
1872    /// path enforces the full-tuple uniqueness via a scan keyed
1873    /// by the leading column. Persisted in catalog FILE_VERSION
1874    /// 15+. Older catalogs (≤ 14) deserialise with an empty vec.
1875    pub uniqueness_constraints: Vec<UniquenessConstraint>,
1876    /// v7.39 (round 210) — `EXCLUDE` constraints declared at the table level.
1877    /// Enforced on INSERT/UPDATE by a full live-row scan re-checking each
1878    /// element's operator (no equality index can answer overlap). Persisted
1879    /// in catalog FILE_VERSION 72+; older catalogs deserialise with an empty
1880    /// vec.
1881    pub exclusion_constraints: Vec<ExclusionConstraint>,
1882    /// v7.13.0 — `CHECK (<expr>)` predicates declared on this
1883    /// table. Both column-level inline `CHECK (…)` and
1884    /// table-level `CHECK (…)` fold into this list. Each entry
1885    /// is the AST Expr's `Display` form, re-parsed on every
1886    /// INSERT/UPDATE and evaluated against the candidate row.
1887    /// A false / NULL result rejects the mutation (PG semantics).
1888    /// Persisted in catalog FILE_VERSION 23+. Older catalogs
1889    /// deserialise with an empty vec. v7.39 (read01 round 48) — each entry
1890    /// now carries the user's constraint name too (FILE_VERSION 60+).
1891    pub checks: Vec<CheckConstraint>,
1892    /// v7.37.6-B — declarative partition role(sentori Epic 2 P0).
1893    /// `None` = 普通表(后向兼容,< v49 catalog 默认 None)。
1894    /// `Some(Parent { … })` = `CREATE TABLE p (...) PARTITION BY RANGE (key_col)` 父表 —
1895    /// 父表自己 `rows` 永远空,INSERT 在引擎层路由到命中的 child。
1896    /// `Some(Range { … })` = `CREATE TABLE c PARTITION OF p FOR VALUES FROM (a) TO (b)` 范围子表。
1897    /// `Some(Default { … })` = `CREATE TABLE c PARTITION OF p DEFAULT` 兜底子表。
1898    /// 持久化于 FILE_VERSION 49+。
1899    pub partition_role: Option<PartitionRole>,
1900    /// v7.39 (RLS) — `CREATE POLICY` objects on this table, independent of the
1901    /// `row_security` flag (PG stores policies even on non-RLS tables; they
1902    /// only take effect once RLS is enabled). Persisted in the policy appendix
1903    /// (FILE_VERSION 59+). Older catalogs deserialise with an empty vec.
1904    pub policies: Vec<PolicyDef>,
1905    /// v7.39 (RLS) — `ALTER TABLE … ENABLE ROW LEVEL SECURITY`
1906    /// (PG `pg_class.relrowsecurity`). Fresh table = `false`.
1907    pub row_security: bool,
1908    /// v7.39 (RLS) — `ALTER TABLE … FORCE ROW LEVEL SECURITY`
1909    /// (PG `pg_class.relforcerowsecurity`); subjects the table owner to RLS
1910    /// too. Fresh table = `false`.
1911    pub force_row_security: bool,
1912    /// v7.39 (read01 round 57, ACL) — the role that owns this table: whoever
1913    /// ran CREATE TABLE (PG `pg_class.relowner`). The owner holds every
1914    /// privilege implicitly and is the only role that may ALTER / DROP it.
1915    /// `None` = an image written before FILE_VERSION 64, which predates roles
1916    /// entirely; those tables read back as owned by the login role.
1917    pub owner: Option<String>,
1918    /// v7.39 (read01 round 57, ACL) — explicit GRANTs on this table
1919    /// (PG `pg_class.relacl`). EMPTY means "never granted": PG leaves relacl
1920    /// NULL while only the owner's implicit privileges apply, and materialises
1921    /// the whole list — owner's default entry included — on the first GRANT.
1922    /// Once materialised it stays, even after every grant is revoked.
1923    pub acl: Vec<AclItem>,
1924}
1925
1926/// v7.39 (read01 round 57) — one PG `aclitem`: what `grantee` may do to a
1927/// table, and who granted it. Renders as `grantee=privs/grantor`, with an
1928/// EMPTY grantee meaning PUBLIC (`=r/owner`).
1929#[derive(Debug, Clone, PartialEq, Eq)]
1930pub struct AclItem {
1931    /// The role the privileges are held by. Empty string = PUBLIC.
1932    pub grantee: String,
1933    /// Bitmask over `priv_bits`: which privileges are held.
1934    pub privs: u16,
1935    /// Bitmask over `priv_bits`: which of them carry WITH GRANT OPTION
1936    /// (PG renders those with a trailing `*` — `r*`).
1937    pub grantable: u16,
1938    /// The role that ran the GRANT.
1939    pub grantor: String,
1940}
1941
1942/// v7.39 (read01 round 57) — the table-privilege bits, in PG's `aclitem`
1943/// rendering order (`arwdDxtm`). The order matters: `relacl` output is
1944/// byte-compared against PG.
1945pub mod priv_bits {
1946    pub const INSERT: u16 = 1 << 0; // a
1947    pub const SELECT: u16 = 1 << 1; // r
1948    pub const UPDATE: u16 = 1 << 2; // w
1949    pub const DELETE: u16 = 1 << 3; // d
1950    pub const TRUNCATE: u16 = 1 << 4; // D
1951    pub const REFERENCES: u16 = 1 << 5; // x
1952    pub const TRIGGER: u16 = 1 << 6; // t
1953    pub const MAINTAIN: u16 = 1 << 7; // m
1954    /// v7.39 (read01 round 60) — the non-table privileges. They share the
1955    /// bitmask because an aclitem is an aclitem whatever it hangs off; which
1956    /// bits are MEANINGFUL depends on the object (a sequence has r / w / U, a
1957    /// schema has U / C, a database has C / c / T).
1958    pub const USAGE: u16 = 1 << 8; // U
1959    pub const CREATE: u16 = 1 << 9; // C
1960    pub const CONNECT: u16 = 1 << 10; // c
1961    pub const TEMPORARY: u16 = 1 << 11; // T
1962    pub const EXECUTE: u16 = 1 << 12; // X
1963    /// Every TABLE privilege — what `GRANT ALL ON <table>` grants and what a
1964    /// table's owner holds.
1965    pub const ALL: u16 =
1966        INSERT | SELECT | UPDATE | DELETE | TRUNCATE | REFERENCES | TRIGGER | MAINTAIN;
1967    /// `GRANT ALL ON SEQUENCE` — PG renders a sequence owner's default as `rwU`.
1968    pub const ALL_SEQUENCE: u16 = SELECT | UPDATE | USAGE;
1969    /// `GRANT ALL ON SCHEMA` — `UC`.
1970    pub const ALL_SCHEMA: u16 = USAGE | CREATE;
1971    /// `GRANT ALL ON DATABASE` — `CTc`.
1972    pub const ALL_DATABASE: u16 = CREATE | CONNECT | TEMPORARY;
1973    /// `GRANT ALL ON FUNCTION` — just `X`.
1974    pub const ALL_FUNCTION: u16 = EXECUTE;
1975}
1976
1977/// v7.37.6-B — partition 三态(parent / range child / default child)。
1978#[derive(Debug, Clone, PartialEq, Eq)]
1979pub enum PartitionRole {
1980    Parent {
1981        kind: PartitionKind,
1982        /// 父表 columns 中 key 列的下标(单列 v7.37.6-B,
1983        /// `Vec` 为将来扩多列预留)。
1984        key_column_positions: Vec<usize>,
1985        /// `CREATE INDEX ON parent (…)` 的 Display-form 源串。
1986        /// child 创建时再 parse + 在 child 上 execute,这样 future
1987        /// child 也自动继承父表索引。fan-out 实施在引擎层。
1988        index_template_sources: Vec<String>,
1989    },
1990    Range {
1991        parent_name: String,
1992        /// 半开区间下界(`>=`,SQL `FROM (lower)`).
1993        lower: PartitionBound,
1994        /// 半开区间上界(`<`,SQL `TO (upper)`).
1995        upper: PartitionBound,
1996    },
1997    /// v7.37.16 (16.1) — LIST child:行属于本 child iff key ∈ values。
1998    /// `values` 在 child 创建时从 SQL `FOR VALUES IN (lit, …)` 求值;
1999    /// 跟 PG 一样,显式 NULL ∈ values 由 caller 单独处理(不在
2000    /// PartitionBound 内表达 NULL)。
2001    List {
2002        parent_name: String,
2003        values: Vec<PartitionBound>,
2004    },
2005    /// v7.39 (round 645) — PG 表继承的 CHILD:`CREATE TABLE c (…)
2006    /// INHERITS (p1, p2)`。跟分区 child 的三个本质区别(实测 PG18):
2007    ///   * 父表**自己有行**(分区父表永远空),所以父表的联合体要含自身;
2008    ///   * `INSERT INTO 父表` **不路由**到 child(分区会路由);
2009    ///   * `DROP TABLE 父表` 不带 CASCADE **报错**(分区父表连子表一起删)。
2010    /// 多父继承合法,故 `parent_names` 是 Vec;`pg_inherits.inhseqno`
2011    /// 正是父表在这个列表里的位置(1-based)。
2012    Inherits {
2013        parent_names: Vec<String>,
2014    },
2015    /// v7.37.16 (16.2) — HASH child:行属于本 child iff
2016    /// `pg_compatible_hash(key) mod modulus == remainder`。
2017    /// PG 强制 `0 ≤ remainder < modulus`;parser/DDL 层先 gate。
2018    Hash {
2019        parent_name: String,
2020        modulus: u32,
2021        remainder: u32,
2022    },
2023    Default {
2024        parent_name: String,
2025    },
2026}
2027
2028/// v7.37.6-B — 分区策略。
2029///
2030/// - `Range`:半开区间 `[lower, upper)`(v7.37.6-B 初始)
2031/// - `List` (v7.37.16):枚举集合 — 行属于 partition iff key ∈ children list
2032/// - `Hash` (v7.37.16):`hash(key) mod modulus == remainder`
2033#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2034pub enum PartitionKind {
2035    Range,
2036    List,
2037    Hash,
2038}
2039
2040/// v7.37.6-B — partition 边界 literal。
2041///
2042/// v7.37.6-B 仅 `TimestampTz`(i64 microseconds since epoch);
2043/// v7.37.16 (16.6) 加全 PG 内建可比类型,匹配 `Value` 的对应 variant
2044/// 以避免 LIST membership 比较时的类型转换。
2045///
2046/// `MinValue` / `MaxValue` 对应 SQL `MINVALUE` / `MAXVALUE`,仅
2047/// Range 策略有意义(LIST 无 minvalue/maxvalue 概念,HASH 不
2048/// 使用 PartitionBound)。
2049#[derive(Debug, Clone, PartialEq, Eq)]
2050pub enum PartitionBound {
2051    MinValue,
2052    MaxValue,
2053    TimestampTz(i64),
2054    /// v7.37.16 (16.6) — BIGINT partition key.
2055    BigInt(i64),
2056    /// v7.37.16 (16.6) — INTEGER partition key (also covers
2057    /// `SERIAL` since SPG decomposes it to INTEGER + sequence).
2058    Int(i32),
2059    /// v7.37.16 (16.6) — SMALLINT partition key.
2060    SmallInt(i16),
2061    /// v7.37.16 (16.6) — DATE partition key. Stored as days
2062    /// since the Unix epoch (matches `Value::Date`).
2063    Date(i32),
2064    /// v7.37.16 (16.6) — TEXT / VARCHAR partition key.
2065    Text(alloc::string::String),
2066}
2067
2068impl PartitionBound {
2069    /// v7.37.16 (16.6) — true iff this bound's underlying value
2070    /// equals `other`'s. Used for LIST partition membership
2071    /// checks. Returns false for `MinValue` / `MaxValue`
2072    /// (sentinels — never literal equality).
2073    #[must_use]
2074    pub fn equals_value(&self, other: &Value<'_>) -> bool {
2075        match (self, other) {
2076            (PartitionBound::TimestampTz(a), Value::Timestamp(b)) => a == b,
2077            (PartitionBound::BigInt(a), Value::BigInt(b)) => a == b,
2078            (PartitionBound::Int(a), Value::Int(b)) => a == b,
2079            (PartitionBound::SmallInt(a), Value::SmallInt(b)) => a == b,
2080            (PartitionBound::Date(a), Value::Date(b)) => a == b,
2081            (PartitionBound::Text(a), Value::Text(b)) => a.as_str() == b.as_ref(),
2082            _ => false,
2083        }
2084    }
2085}
2086
2087/// v7.9.19 — composite UNIQUE / PRIMARY KEY constraint persisted
2088/// on the table schema. The leading column always has a BTree
2089/// index (created at CREATE TABLE time); INSERT enforcement
2090/// scans that index for collisions on the full column tuple.
2091/// v7.39 (read01 round 48) — a `CHECK` constraint: the SQL name the user
2092/// gave it (via `ADD CONSTRAINT <name> CHECK (...)` or the inline
2093/// `CONSTRAINT <name> CHECK (...)` form) plus the predicate source. `None`
2094/// name = unnamed, in which case `pg_constraint` synthesises PG's
2095/// `<table>_<col>_check` form. Names are persisted in the constraint-name
2096/// appendix (FILE_VERSION 60+); older catalogs deserialise with `None`.
2097#[derive(Debug, Clone, PartialEq, Eq)]
2098pub struct CheckConstraint {
2099    pub name: Option<String>,
2100    /// The AST Expr's `Display` form, re-parsed on every INSERT/UPDATE.
2101    pub expr: String,
2102    /// v7.39 (round 652) — `false` for a constraint added `NOT VALID`: the
2103    /// rows already in the table were never scanned against it, and
2104    /// `pg_constraint.convalidated` says so. It does NOT weaken the check on
2105    /// new rows — INSERT and UPDATE enforce it either way, as in PG.
2106    /// `VALIDATE CONSTRAINT` does the deferred scan and flips it. Persisted
2107    /// by the FILE_VERSION 87 appendix; older catalogs deserialise as `true`,
2108    /// which is what every constraint they could hold actually was.
2109    pub validated: bool,
2110}
2111
2112#[derive(Debug, Clone, PartialEq, Eq)]
2113pub struct UniquenessConstraint {
2114    /// `true` when this constraint was declared as `PRIMARY KEY`
2115    /// (vs `UNIQUE`). Semantically PK implies NOT NULL on all
2116    /// referenced columns; the engine enforces that at CREATE
2117    /// TABLE time.
2118    pub is_primary_key: bool,
2119    /// Column positions on the parent table. ≥ 1 element. For
2120    /// single-column UNIQUE this is exactly one position; the
2121    /// BTree index alone enforces it.
2122    pub columns: Vec<usize>,
2123    /// v7.13.0 — `UNIQUE NULLS NOT DISTINCT` modifier
2124    /// (mailrs round-5 G10; PG 15+ surface). When `true`, two
2125    /// rows whose constrained columns are all NULL collide on
2126    /// the constraint. Default (`false`) is the SQL-standard
2127    /// `NULLS DISTINCT` behaviour where any NULL passes.
2128    /// Persisted in catalog FILE_VERSION 23+.
2129    pub nulls_not_distinct: bool,
2130    /// v7.39 (read01 round 48) — the constraint's SQL name when the user
2131    /// supplied one (`ADD CONSTRAINT <name> PRIMARY KEY/UNIQUE (...)`, or
2132    /// the inline `CONSTRAINT <name>` form). `None` = unnamed, in which
2133    /// case `pg_constraint` synthesises PG's `<table>_pkey` /
2134    /// `<table>_<col>_key` form. DROP CONSTRAINT resolves the stored name
2135    /// first and falls back to the synthesised one, so catalogs written
2136    /// before this field (< FILE_VERSION 60) keep working unchanged.
2137    pub name: Option<String>,
2138    /// v7.39 (round 711) — `[NOT] DEFERRABLE`. Round 621 taught the parser
2139    /// to CONSUME the clause on PK/UNIQUE (the FK path had stored it since
2140    /// round 288); this is the storing half. Persisted in the v89 timing
2141    /// appendix.
2142    pub deferrable: bool,
2143    /// `INITIALLY DEFERRED`: the check belongs to COMMIT, not the
2144    /// statement, unless `SET CONSTRAINTS … IMMEDIATE` pulls it in.
2145    pub initially_deferred: bool,
2146}
2147
2148/// v7.39 (round 210) — an `EXCLUDE` constraint. Forbids two distinct live
2149/// rows from satisfying, for EVERY element, `new.col <op> existing.col`
2150/// (e.g. `EXCLUDE USING gist (during WITH &&)` = no two `during` ranges
2151/// overlap). Unlike a uniqueness constraint the operator is not equality,
2152/// so enforcement is a full live-row scan re-checking the operator (a real
2153/// GiST index that answers overlap in O(log n) is a later perf phase). A
2154/// NULL in any element column exempts the row (matching PG / UNIQUE NULL
2155/// semantics). Persisted in catalog FILE_VERSION 72+.
2156#[derive(Debug, Clone, PartialEq, Eq)]
2157pub struct ExclusionConstraint {
2158    /// The constraint's SQL name. PG auto-names an unnamed EXCLUDE
2159    /// `<table>_<leading-col>_excl`; the engine synthesises that at CREATE
2160    /// TABLE time so this is always populated.
2161    pub name: String,
2162    /// Access method spelled after `USING` (`gist`, `spgist`, …), lower-cased.
2163    /// `None` = no `USING` clause. Purely cosmetic for enforcement; it round-
2164    /// trips into `pg_get_constraintdef`.
2165    pub method: Option<String>,
2166    /// One `(column-position, operator-spelling)` pair per element, in
2167    /// declaration order. The operator spelling is the wire token (`&&`,
2168    /// `=`, `@>`, `<@`, `&<`, `&>`) evaluated against each existing row.
2169    pub elements: Vec<(usize, String)>,
2170}
2171
2172/// v7.6.1 — Storage-layer mirror of `spg_sql::ast::ForeignKeyConstraint`.
2173/// The engine's CREATE TABLE path translates between the two; keeping
2174/// them separate preserves the no-deps boundary between
2175/// `spg-storage` and `spg-sql`.
2176#[derive(Debug, Clone, PartialEq, Eq)]
2177pub struct ForeignKeyConstraint {
2178    /// Optional user-supplied constraint name (`CONSTRAINT <name>`
2179    /// prefix). Used by `ALTER TABLE DROP CONSTRAINT <name>` in
2180    /// v7.6.8; ignored by enforcement.
2181    pub name: Option<String>,
2182    /// Positions of local columns in this table's column list.
2183    /// Same arity as `parent_columns`.
2184    pub local_columns: Vec<usize>,
2185    /// Referenced parent table name.
2186    pub parent_table: String,
2187    /// Positions of parent columns in the parent's column list.
2188    /// Engine resolves these at CREATE TABLE time (after the parent
2189    /// schema is known) so enforcement paths can skip the name
2190    /// lookup on every row.
2191    pub parent_columns: Vec<usize>,
2192    /// Referential action when a parent row is deleted.
2193    pub on_delete: FkAction,
2194    /// Referential action when a parent row's referenced columns
2195    /// are updated.
2196    pub on_update: FkAction,
2197    /// v7.38 (read01, T29) — `MATCH SIMPLE | FULL`. Defaults to `Simple`.
2198    pub match_type: MatchType,
2199    /// v7.39 (round 288) — `[NOT] DEFERRABLE`.
2200    pub deferrable: bool,
2201    /// `INITIALLY DEFERRED`: the check runs at COMMIT rather than at
2202    /// the statement, unless `SET CONSTRAINTS … IMMEDIATE` pulls it in.
2203    pub initially_deferred: bool,
2204}
2205
2206/// v7.38 (read01, T29) — FK MATCH type. Mirrors `spg_sql::ast::MatchType`.
2207#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
2208pub enum MatchType {
2209    #[default]
2210    Simple,
2211    Full,
2212}
2213
2214impl MatchType {
2215    /// On-disk tag byte (catalog appendix, `FILE_VERSION` 55+).
2216    pub const fn tag(self) -> u8 {
2217        match self {
2218            Self::Simple => 0,
2219            Self::Full => 1,
2220        }
2221    }
2222    pub const fn from_tag(b: u8) -> Option<Self> {
2223        Some(match b {
2224            0 => Self::Simple,
2225            1 => Self::Full,
2226            _ => return None,
2227        })
2228    }
2229}
2230
2231/// v7.6.1 — referential action tag. Mirrors `spg_sql::ast::FkAction`.
2232#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2233pub enum FkAction {
2234    Restrict,
2235    Cascade,
2236    SetNull,
2237    SetDefault,
2238    NoAction,
2239}
2240
2241impl FkAction {
2242    /// On-disk tag byte (v13 catalog appendix).
2243    pub const fn tag(self) -> u8 {
2244        match self {
2245            Self::Restrict => 0,
2246            Self::Cascade => 1,
2247            Self::SetNull => 2,
2248            Self::SetDefault => 3,
2249            Self::NoAction => 4,
2250        }
2251    }
2252    pub const fn from_tag(b: u8) -> Option<Self> {
2253        Some(match b {
2254            0 => Self::Restrict,
2255            1 => Self::Cascade,
2256            2 => Self::SetNull,
2257            3 => Self::SetDefault,
2258            4 => Self::NoAction,
2259            _ => return None,
2260        })
2261    }
2262}
2263
2264impl TableSchema {
2265    pub fn column_position(&self, name: &str) -> Option<usize> {
2266        self.columns.iter().position(|c| c.name == name)
2267    }
2268}
2269
2270/// Key type accepted by secondary indices. Float / NULL / Vector values
2271/// can't participate in a B-tree index — `f64` is only `PartialOrd`, NULL
2272/// has SQL-three-valued semantics, and Vector belongs to the (future) HNSW
2273/// path. Index lookups on those columns fall back to full scan.
2274#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
2275pub enum IndexKey {
2276    Int(i64),
2277    Text(String),
2278    Bool(bool),
2279    /// v7.17.0 — `Value::Uuid` index key. Comparison is byte-wise
2280    /// (RFC 4122 byte order) so PRIMARY KEY UUID lookups land on
2281    /// the same fast-path as Int / Text.
2282    Uuid([u8; 16]),
2283}
2284
2285impl IndexKey {
2286    /// v7.37.43 (INSUBQ B-4) — inline-friendly BigInt fast path.
2287    /// `try_count_star_pk_in_subquery_fast` (and any other hot loop
2288    /// probing an integer PK) already holds an `i64`; this builds the
2289    /// `IndexKey` without going through the generic `from_value`
2290    /// dispatch tree.
2291    #[inline]
2292    pub fn from_i64(n: i64) -> Self {
2293        Self::Int(n)
2294    }
2295
2296    pub fn from_value(v: &Value<'_>) -> Option<Self> {
2297        match v {
2298            // v7.37.43 (INSUBQ B-4) — BigInt hits first (the dominant
2299            // INSUBQ shape probes PK as BigInt). Tiny micro-win.
2300            Value::BigInt(n) => Some(Self::Int(*n)),
2301            Value::SmallInt(n) => Some(Self::Int(i64::from(*n))),
2302            Value::Int(n) => Some(Self::Int(i64::from(*n))),
2303            Value::Text(s) => Some(Self::Text(s.clone().into_owned())),
2304            // v7.38 (read01, T11) — bpchar keys compare blank-insensitively.
2305            Value::BpChar(s) => Some(Self::Text(s.trim_end_matches(' ').to_string())),
2306            Value::Bool(b) => Some(Self::Bool(*b)),
2307            // Date/Timestamp use their integer storage repr as the
2308            // index key — same order semantics, same comparison.
2309            Value::Date(d) => Some(Self::Int(i64::from(*d))),
2310            Value::Timestamp(t) => Some(Self::Int(*t)),
2311            // v7.17.0: UUID indexable via byte-wise ordering. Lookup
2312            // on `id = '...'::uuid` resolves through the secondary
2313            // index rather than full-scan.
2314            Value::Uuid(b) => Some(Self::Uuid(*b)),
2315            // v7.17.0 Phase 3.P0-32: TIME indexable via i64 — same
2316            // order semantics as Date/Timestamp.
2317            Value::Time(us) => Some(Self::Int(*us)),
2318            // v7.17.0 Phase 3.P0-33: YEAR indexable as i64 — u16
2319            // widens losslessly and gives the natural calendar
2320            // ordering.
2321            Value::Year(y) => Some(Self::Int(i64::from(*y))),
2322            // v7.17.0 Phase 3.P0-34: TIMETZ indexable by its
2323            // UTC-equivalent microseconds (local wall - offset).
2324            // Without normalising, two values for the same
2325            // physical instant in different zones would sort
2326            // wrong. Matches PG's TIMETZ index behaviour.
2327            Value::TimeTz { us, offset_secs } => {
2328                Some(Self::Int(us - i64::from(*offset_secs) * 1_000_000))
2329            }
2330            // v7.17.0 Phase 3.P0-35: MONEY indexable as i64 cents
2331            // (no scaling needed — natural numeric ordering).
2332            Value::Money(c) => Some(Self::Int(*c)),
2333            // v7.17.0 Phase 3.P0-38: ranges are NOT indexable in
2334            // v7.17.0 — they'd need a custom comparator (PG uses
2335            // SP-GiST for this). Skip.
2336            Value::Range { .. } => None,
2337            // v7.17.0 Phase 3.P0-39: hstore is NOT indexable in
2338            // v7.17.0 — map columns need GIN with bespoke ops.
2339            Value::Hstore(_) => None,
2340            Value::NumericBig(_) => None,
2341            // v7.17.0 Phase 3.P0-40: 2D arrays aren't indexable.
2342            Value::IntArray2D(_)
2343            | Value::BigIntArray2D(_)
2344            | Value::TextArray2D(_)
2345            | Value::BoolArray2D(_) => None,
2346            // v7.37.5 β-P4: INTERVAL[] isn't indexable (PG uses
2347            // GIN/intarray for array-contains queries; SPG plans
2348            // that as a separate axis under v7.37.8 GIN-on-jsonb).
2349            Value::IntervalArray(_) => None,
2350            // v7.37.5 γ — none of the array-of-scalar family is
2351            // B-tree indexable. Same reason as IntervalArray: PG
2352            // serves array-contains / array-overlap queries via
2353            // GIN, and SPG's GIN axis lands in v7.37.8.
2354            Value::BoolArray(_)
2355            | Value::SmallIntArray(_)
2356            | Value::FloatArray(_)
2357            | Value::NumericArray(_)
2358            | Value::DateArray(_)
2359            | Value::TimestampArray(_)
2360            | Value::TimestamptzArray(_)
2361            | Value::UuidArray(_)
2362            | Value::JsonArray(_)
2363            | Value::JsonbArray(_)
2364            | Value::BytesArray(_)
2365            | Value::VarcharArray(_)
2366            | Value::CharArray(_)
2367            // v7.37.5 δ — multirange not indexable (PG uses GiST/
2368            // SP-GiST + a custom operator class; SPG plans the same
2369            // axis under v7.37.8 with ranges).
2370            | Value::Multirange { .. }
2371            // v7.37.5 ε — geometric scalars not B-tree indexable
2372            // (PG uses GiST/SP-GiST for these too; SPG plans the
2373            // same axis under v7.37.8).
2374            | Value::Point(_)
2375            | Value::Lseg(_, _)
2376            | Value::Path { .. }
2377            | Value::PgBox(_, _)
2378            | Value::Polygon(_)
2379            | Value::Line { .. }
2380            | Value::Circle { .. }
2381            // v7.37.5 ζ-A — network / bit / xml / "char" / money[].
2382            // INET / CIDR / MACADDR / MACADDR8 could be B-tree
2383            // indexable (PG does this), but the byte-wise compare
2384            // family-blind would mis-order IPv4 vs IPv6; left as
2385            // a follow-up under v7.37.8 GIN window.
2386            | Value::Inet { .. }
2387            | Value::Cidr { .. }
2388            | Value::Macaddr(_)
2389            | Value::Macaddr8(_)
2390            | Value::PgLsn(_)
2391            | Value::BitString { .. }
2392            | Value::Xml(_)
2393            | Value::Char1(_)
2394            | Value::MoneyArray(_)
2395            | Value::Composite(_)
2396            | Value::Tid(..)
2397            | Value::Xid(_)
2398            | Value::Cid(_)
2399            | Value::RegClass(..)
2400            | Value::RegProc(..)
2401            | Value::RegType(..) => None,
2402            // Numeric isn't (yet) indexable — exact-decimal index keys
2403            // would need a stable scale-normalised representation.
2404            // Interval isn't index-eligible either (and can't reach this
2405            // path through column storage anyway).
2406            Value::Null
2407            | Value::Float(_)
2408            | Value::Vector(_)
2409            | Value::Sq8Vector(_)
2410            | Value::HalfVector(_)
2411            | Value::Numeric { .. }
2412            | Value::Interval { .. }
2413            | Value::Json(_)
2414            | Value::Bytes(_)
2415            | Value::TextArray(_)
2416            | Value::IntArray(_)
2417            | Value::BigIntArray(_)
2418            | Value::TsVector(_)
2419            | Value::TsQuery(_)
2420            | Value::Real(_) => None,
2421        }
2422    }
2423}
2424
2425/// A single-column secondary index. v2.0 carries either a B-tree map
2426/// (the default — used for equality / range lookups on scalar columns)
2427/// or a navigable-small-world graph (used for kNN over vector
2428/// columns).
2429#[derive(Debug, Clone)]
2430pub struct Index {
2431    pub name: String,
2432    pub column_position: usize,
2433    pub kind: IndexKind,
2434    /// v6.8.0 — column positions of `INCLUDE (col1, col2, …)`
2435    /// non-key columns. Carries the planner's "this query is
2436    /// covered by the index" signal; lookup paths still resolve
2437    /// via the `RowLocator` to fetch the row body, but EXPLAIN
2438    /// surfaces the covered-scan annotation so operators can
2439    /// confirm the planner sees the coverage.
2440    ///
2441    /// Empty `Vec` = no `INCLUDE` clause (the legacy shape). v12
2442    /// catalog snapshots deserialise with an empty vec.
2443    pub included_columns: Vec<usize>,
2444    /// v6.8.1 — partial-index predicate stored as its canonical
2445    /// Display form (the engine re-parses it on the maintenance
2446    /// path). `None` = unconditional index (the legacy shape).
2447    /// Persisted as `[u8 has_pred][u16 LE len][bytes]` on the
2448    /// catalog snapshot (FILE_VERSION 12, appended after
2449    /// `included_columns`).
2450    pub partial_predicate: Option<String>,
2451    /// v6.8.2 — expression-index key, stored as the expression's
2452    /// canonical Display form. `None` = bare column-reference
2453    /// index (the legacy shape). Persisted alongside
2454    /// `partial_predicate` on the v12 catalog snapshot.
2455    pub expression: Option<String>,
2456    /// v7.39 (read01 round 52) — `CREATE UNIQUE INDEX … NULLS NOT DISTINCT`
2457    /// (PG 15+): a NULL in the key no longer exempts the row, so two
2458    /// all-NULL keys collide. Default `false` = SQL-standard NULLS DISTINCT.
2459    /// Persisted in the index appendix (FILE_VERSION 62+); older catalogs
2460    /// deserialise with `false`.
2461    pub nulls_not_distinct: bool,
2462    /// v7.39 (round 537) — the key column's ordering clause, as written.
2463    ///
2464    /// SPG's index does not scan in a direction, so this changes no
2465    /// lookup; `pg_indexes.indexdef` is a reproduction of the DDL and
2466    /// dropping the clause made `CREATE INDEX i ON t (a DESC NULLS
2467    /// LAST)` read back as `(a)` — a dump lost it and a schema diff saw
2468    /// drift every run. `nulls_first` is `None` when the statement did
2469    /// not say, in which case PG's default applies and neither word is
2470    /// rendered.
2471    pub descending: bool,
2472    pub nulls_first: Option<bool>,
2473    /// v7.39 (round 538) — an explicit `COLLATE` on the key, as written.
2474    /// SPG orders text by bytes, so it changes no comparison; PG prints
2475    /// it because a named collation and an inherited one are different
2476    /// objects even where they sort identically.
2477    pub collation: Option<String>,
2478    /// v7.9.29 — `CREATE UNIQUE INDEX …`. When true the engine
2479    /// rejects INSERTs whose key already appears in this index
2480    /// (combined with `partial_predicate` when present — only
2481    /// rows matching the predicate enter the uniqueness check).
2482    /// Catalog FILE_VERSION 16+; older snapshots deserialise
2483    /// with `false`. mailrs K1.
2484    pub is_unique: bool,
2485    /// v7.9.29 — extra (non-leading) column positions for
2486    /// multi-column indexes (`CREATE INDEX … (a, b, c)`). The
2487    /// planner today still only uses the leading
2488    /// `column_position` for index seeks, but UNIQUE INDEX
2489    /// enforcement walks the full tuple so partial-unique
2490    /// invariants like CalDAV `(calendar_id, uid,
2491    /// recurrence_id)` are enforced correctly. Catalog
2492    /// FILE_VERSION 16+; older snapshots deserialise empty.
2493    pub extra_column_positions: Vec<usize>,
2494}
2495
2496/// Default neighbor degree (M) for the NSW graph. Picked at construction
2497/// time and persisted with the index.
2498pub const NSW_DEFAULT_M: usize = 16;
2499
2500/// v5.2.2: outcome of a successful [`Catalog::freeze_oldest_to_cold`]
2501/// call. The catalog state has already been mutated by the time this
2502/// is returned (hot rows dropped + segment registered + Cold locators
2503/// flipped). The caller's only remaining concern is `segment_bytes` —
2504/// persist them to disk under `<db>.spg/segments/seg_<id>.spg` so a
2505/// future restart can reload via the v5.1 `SPG_PRELOAD_COLD_SEGMENT`
2506/// path. (v5.3's manifest will subsume this manual step.)
2507#[derive(Debug, Clone)]
2508pub struct FreezeReport {
2509    /// Id allocated by [`Catalog::load_segment_bytes`] for the new
2510    /// cold-tier segment. Stable across the call's success path.
2511    pub segment_id: u32,
2512    /// Number of rows that moved hot → cold. Equals the `max_rows`
2513    /// the caller asked for (the API is strict on the count).
2514    pub frozen_rows: usize,
2515    /// Hot-tier bytes reclaimed by the freeze — the
2516    /// [`Table::hot_bytes`] delta before vs after. Useful to feed
2517    /// back into the freezer's budget check on the next tick.
2518    pub bytes_freed: u64,
2519    /// Encoded segment bytes, byte-identical to what
2520    /// [`encode_segment`] produced. The catalog already owns a
2521    /// copy inside `cold_segments`; this hand-off lets the caller
2522    /// persist them without re-encoding.
2523    pub segment_bytes: Vec<u8>,
2524}
2525
2526/// v6.7.4 — read-only output of [`Catalog::prepare_freeze_slice`].
2527/// Carries every row body + key in a contiguous hot-row range,
2528/// already encoded and sorted by PK so the coordinator's merge
2529/// step is a k-way merge over already-sorted streams.
2530///
2531/// `Vec<FreezeSlice>` from N independent workers feeds
2532/// [`Catalog::commit_freeze_slices`], which concats + encodes the
2533/// merged segment + atomically swaps the catalog state.
2534#[derive(Debug, Clone)]
2535pub struct FreezeSlice {
2536    /// Hot-row index range this slice covered (half-open, in the
2537    /// table's `rows: PersistentVec` ordering at call time). The
2538    /// commit step uses this to compute the union range that
2539    /// gets passed to [`Table::delete_rows`].
2540    pub row_range: core::ops::Range<usize>,
2541    /// `(pk_u64, encoded_row_body, IndexKey)` triples, sorted
2542    /// ascending by `pk_u64`. Per-slice sort happens inside
2543    /// `prepare_freeze_slice`; the coordinator does only a
2544    /// k-way merge to reach the global PK ordering
2545    /// [`encode_segment`] requires.
2546    pub rows: Vec<(u64, Vec<u8>, IndexKey)>,
2547}
2548
2549/// v6.7.3 — outcome of a [`Catalog::compact_cold_segments`] call.
2550/// The catalog state has already been mutated when this is returned:
2551/// the merged segment is loaded into `cold_segments`, the source
2552/// segment slots are tombstoned (`None`), and every BTree-index
2553/// `RowLocator::Cold` that previously pointed at a source now
2554/// points at the merged segment. The caller's remaining job is to
2555/// persist `merged_segment_bytes` under
2556/// `<db>.spg/segments/seg_<merged_segment_id>.spg` and update the
2557/// in-memory `segment_id → path` map (remove the source ids, add
2558/// the merged id) so the next CHECKPOINT writes a manifest that
2559/// no longer lists the retired sources.
2560///
2561/// On a no-op (fewer than 2 candidate segments under the threshold),
2562/// `merged_segment_id` is `None` and `sources` is empty; the
2563/// catalog was not mutated.
2564#[derive(Debug, Clone)]
2565pub struct CompactReport {
2566    /// Source segment ids that were merged + tombstoned.
2567    pub sources: Vec<u32>,
2568    /// Id allocated for the merged segment. `None` on no-op.
2569    pub merged_segment_id: Option<u32>,
2570    /// Encoded merged-segment bytes (empty on no-op).
2571    pub merged_segment_bytes: Vec<u8>,
2572    /// Number of rows that landed in the merged segment.
2573    pub merged_rows: usize,
2574    /// `Σ source.num_rows − merged_rows`. Rows present in source
2575    /// segment payloads but unreferenced by any live BTree
2576    /// `Cold` locator — DELETE'd-but-still-frozen rows that
2577    /// compaction GC'd during the merge.
2578    pub deleted_rows_pruned: usize,
2579    /// `Σ source.bytes() − merged.bytes()`. Estimate of on-disk
2580    /// space the merge will reclaim once the source segment files
2581    /// are GC'd. Saturating subtract — never negative.
2582    pub bytes_reclaimed_estimate: u64,
2583}
2584
2585#[derive(Debug, Clone)]
2586pub enum IndexKind {
2587    /// v4.40: structural-sharing B-tree over `IndexKey`. Replaces the v0.8
2588    /// `BTreeMap<IndexKey, Vec<usize>>` — `Index::clone` is now an `Arc`
2589    /// bump regardless of index size, so `Catalog::clone` inside the
2590    /// v4.34 auto-commit wrap stays O(1) even for tables with secondary
2591    /// indices (the case that bottlenecked v4.39 at 1M rows in the
2592    /// sweep).
2593    ///
2594    /// v5.1: value type widened from `Vec<usize>` to `Vec<RowLocator>` so
2595    /// a single key can point to a mix of hot-tier rows (`RowLocator::Hot`,
2596    /// equivalent to the pre-v5 `usize` row index) and cold-tier rows
2597    /// (`RowLocator::Cold { segment_id, page_offset }`) once the v5.2
2598    /// freezer starts producing them. Pre-v5.2 only `Hot` entries appear
2599    /// — the on-disk encoding stays at `FILE_VERSION` 8 (raw u64 row index)
2600    /// because every locator round-trips through `RowLocator::from_legacy_v8_u64`
2601    /// without information loss. `FILE_VERSION` 9 with tagged encoding lands
2602    /// alongside the first freezer commit (v5.1 step 2b / v5.2).
2603    BTree(PersistentBTreeMap<IndexKey, Vec<RowLocator>>),
2604    /// Navigable-small-world graph for vector kNN search.
2605    Nsw(NswGraph),
2606    /// v6.7.1 — BRIN (Block Range INdex). Pure metadata: BRIN
2607    /// indexes carry NO in-memory key→locator map. The (min,
2608    /// max) summaries live in each cold-tier segment's v2
2609    /// envelope sidecar; the BRIN entry in `Table.indices` only
2610    /// records THAT a BRIN index exists on this column so the
2611    /// segment encoder + planner can opt into the summary path.
2612    Brin {
2613        /// The cell type at `column_position` at CREATE INDEX time.
2614        /// Used by the planner to type-check WHERE-clause range
2615        /// predicates against the BRIN-indexed column.
2616        column_type: DataType,
2617    },
2618    /// v7.12.3 — GIN inverted index over a `tsvector` column.
2619    ///
2620    /// Storage shape: `lexeme word → Vec<RowLocator>`. The posting
2621    /// list per word is appended in row-order, so range scans are
2622    /// O(matching rows) once the per-word lookup is done. Multi-
2623    /// term queries intersect / union posting lists.
2624    ///
2625    /// `IndexKey::from_value(TsVector)` returns `None` — GIN doesn't
2626    /// participate in `try_index_seek` (which is BTree-equality-keyed).
2627    /// The engine consults this index through `try_gin_lookup` on
2628    /// `WHERE col @@ tsquery` predicates instead.
2629    ///
2630    /// Backed by a `PersistentBTreeMap` so `Catalog::clone` (the
2631    /// per-write snapshot) stays O(1) — same structural-sharing
2632    /// invariant as BTree.
2633    Gin(PersistentBTreeMap<alloc::string::String, Vec<RowLocator>>),
2634    /// v7.15.0 — `USING gin (col gin_trgm_ops)` over a `TEXT`
2635    /// column. Posting lists map `trigram` (PG-compatible 3-byte
2636    /// shingle on the lower-cased + space-padded input) to row
2637    /// locators. The planner uses this index to accelerate
2638    /// `WHERE col LIKE '…'` / `ILIKE '…'` / `similarity(col, q) >
2639    /// t` — every literal run of length ≥ 1 in the pattern
2640    /// produces a trigram set, the engine intersects the posting
2641    /// lists, and the LIKE / similarity predicate is re-evaluated
2642    /// per candidate row to filter the over-approximation.
2643    /// Persisted via tag-4 index payload in `FILE_VERSION` 24+.
2644    GinTrgm(PersistentBTreeMap<alloc::string::String, Vec<RowLocator>>),
2645    /// v7.17.0 Phase 2.2 — MySQL `FULLTEXT KEY (col)` over a
2646    /// `TEXT` / `VARCHAR` column. Posting lists map
2647    /// `tsvector('simple') lexeme` to row locators. At insert /
2648    /// build time the engine derives the lexemes from the cell
2649    /// via the same lower-case tokenisation rule as
2650    /// `to_tsvector('simple', ...)` — the column itself stays a
2651    /// plain text type on disk (mysqldump round-trips would be
2652    /// broken otherwise). The planner uses this index to
2653    /// accelerate MySQL-shape `MATCH(col) AGAINST('term')`
2654    /// queries by mapping them onto the existing tsquery `@@`
2655    /// walker. Persisted via tag-5 index payload in
2656    /// `FILE_VERSION` 33+.
2657    GinFulltext(PersistentBTreeMap<alloc::string::String, Vec<RowLocator>>),
2658    /// v7.37.8(sentori Epic 5 P2)— `USING gin (col)` over a
2659    /// `JSON` / `JSONB` column. Posting lists map a canonical
2660    /// `(path, leaf)` token(see [`crate::jsonb_gin::extract_tokens`])
2661    /// to row locators so the planner can resolve
2662    /// `<col> @> <jsonb_literal>` to a candidate row set via
2663    /// posting-list intersection + per-row `json::contains`
2664    /// re-verification. Pre-7.37.8 the same DDL loaded as a
2665    /// BTree fallback so `pg_dump` JSONB-GIN scripts kept loading
2666    /// without query-time acceleration. Persisted via tag-6 index
2667    /// payload in `FILE_VERSION` 51+.
2668    GinJsonb(PersistentBTreeMap<alloc::string::String, Vec<RowLocator>>),
2669}
2670
2671impl IndexKind {
2672    /// v7.31 (memory campaign, C2) — bytes this index variant holds
2673    /// resident in RAM, computed by walking its OWN structure rather
2674    /// than a parametric guess made by the engine. Replaces the old
2675    /// `spg_admin::memory_stats` inline match, which charged NSW with
2676    /// a stale `m_max_0 * 8` per node (neighbour slots are `u32` = 4 B
2677    /// since v6.1.x, and most nodes never fill `m_max_0`) and lumped
2678    /// every GIN family index into a flat 1 KiB token — a gross
2679    /// undercount for the text-heavy posting lists that dominate
2680    /// mailrs' footprint. Per-entry container overhead uses the
2681    /// 3-word (24 B on 64-bit) `Vec`/`String` header as the charge.
2682    ///
2683    /// O(index entries): operator/monitoring surface (`memory_stats` /
2684    /// `spg_memory_stats`), not a query path.
2685    #[must_use]
2686    pub fn approx_resident_bytes(&self) -> u64 {
2687        const HEADER: usize = 24; // Vec/String 3-word header on 64-bit.
2688        let loc = core::mem::size_of::<RowLocator>();
2689        match self {
2690            IndexKind::BTree(map) => {
2691                let key = core::mem::size_of::<IndexKey>();
2692                map.iter()
2693                    .map(|(_, locs)| (key + HEADER + locs.len() * loc) as u64)
2694                    .sum()
2695            }
2696            IndexKind::Nsw(g) => {
2697                // `levels` is one byte per node; each layer's adjacency
2698                // is a `Vec<u32>` per node whose actual length we walk
2699                // (the dense layer-0 list dominates, but upper layers
2700                // are sparse — the old estimate ignored that).
2701                let mut b = g.levels.len() as u64;
2702                for layer in &g.layers {
2703                    for nbrs in layer.iter() {
2704                        b += (HEADER + nbrs.len() * core::mem::size_of::<u32>()) as u64;
2705                    }
2706                }
2707                b
2708            }
2709            // BRIN carries NO in-memory key→locator map (the (min,max)
2710            // summaries live in cold-segment sidecars on disk); the
2711            // resident footprint is just the column-type token.
2712            IndexKind::Brin { .. } => core::mem::size_of::<DataType>() as u64,
2713            IndexKind::Gin(map)
2714            | IndexKind::GinTrgm(map)
2715            | IndexKind::GinFulltext(map)
2716            | IndexKind::GinJsonb(map) => map
2717                .iter()
2718                .map(|(word, postings)| {
2719                    (word.len() + HEADER + HEADER + postings.len() * loc) as u64
2720                })
2721                .sum(),
2722        }
2723    }
2724}
2725
2726/// Multi-layer HNSW graph (v2.13). Each node is assigned a `top_level`;
2727/// it appears in layers `0..=top_level`. Higher layers are sparser, so
2728/// search starts from the entry at the top layer, greedy-descends to
2729/// layer 0, and beam-searches there. Layer 0 keeps a larger neighbour
2730/// budget (`m_max_0 = 2 * m` per the HNSW paper); upper layers cap at
2731/// `m`. The struct name stays `NswGraph` so external users / on-disk
2732/// callers don't have to track a rename — the algorithm changed, the
2733/// data slot didn't.
2734#[derive(Debug, Clone)]
2735pub struct NswGraph {
2736    /// Max neighbours per node on layers ≥ 1.
2737    pub m: usize,
2738    /// Max neighbours on layer 0 (the dense bottom layer). HNSW
2739    /// convention: `m_max_0 = 2 * m`.
2740    pub m_max_0: usize,
2741    /// Entry point — the node that sits on the topmost layer. Search
2742    /// always starts here.
2743    pub entry: Option<usize>,
2744    /// Top layer of the entry node (== `layers.len() - 1` when populated).
2745    pub entry_level: u8,
2746    /// `levels[i]` = top layer of node `i`. Nodes whose vector cell is
2747    /// NULL / non-Vector have `levels[i] = 0` and no neighbour entries.
2748    ///
2749    /// v5.5.0: backed by `PersistentVec` so `NswGraph::clone` (and the
2750    /// `Catalog::clone` on every group-commit write that contains it) is O(1)
2751    /// structural-sharing instead of an O(N) element copy.
2752    pub levels: PersistentVec<u8>,
2753    /// `layers[l][i]` = neighbours of node `i` at layer `l`. Inner vec
2754    /// is empty when node `i` doesn't reach layer `l`.
2755    ///
2756    /// v5.5.0: the per-node middle dimension (the O(N) one) is a
2757    /// `PersistentVec`; the outer layer dimension stays a plain `Vec`
2758    /// (layer count ≤ 8, so its clone is O(1) in practice) and the inner
2759    /// neighbour list stays a `Vec` (bounded by `m_max_0`).
2760    ///
2761    /// v6.1.x: neighbour slot widened from `usize` (8 B on 64-bit) to
2762    /// `u32` (4 B). Row indices are catalog-bounded by `u32::MAX` (4G
2763    /// rows per table); the cast at the NSW boundary asserts this. At
2764    /// 1M dim-128 SQ8, layer 0 adjacency alone shrinks by ~128 MiB
2765    /// — the largest single contribution to the v6.0.5-measured
2766    /// 624 MiB ambition gap. On-disk format already used u32 LE, so
2767    /// this is a pure in-memory layout change; no `FILE_VERSION` bump.
2768    pub layers: Vec<PersistentVec<Vec<u32>>>,
2769}
2770
2771impl NswGraph {
2772    fn new(m: usize) -> Self {
2773        Self {
2774            m,
2775            m_max_0: m.saturating_mul(2),
2776            entry: None,
2777            entry_level: 0,
2778            levels: PersistentVec::new(),
2779            layers: alloc::vec![PersistentVec::new()],
2780        }
2781    }
2782
2783    /// Max-neighbour budget for layer `l`.
2784    pub const fn cap_for_layer(&self, layer: u8) -> usize {
2785        if layer == 0 { self.m_max_0 } else { self.m }
2786    }
2787}
2788
2789/// Deterministic level assignment, seeded on the row index so the same
2790/// insert order reproduces the same topology. Distribution is roughly
2791/// HNSW-flavoured with `mL ≈ 1/ln(M) ≈ 0.36` for M=16: each 4-bit
2792/// chunk that comes up zero promotes the node one layer (so P(level ≥
2793/// L) ≈ (1/16)^L).
2794#[allow(clippy::verbose_bit_mask)] // clippy suggests trailing_zeros(); we need an explicit MAX cap and a stable distribution shape.
2795pub fn nsw_assign_level(row_idx: usize) -> u8 {
2796    const MAX_LEVEL: u8 = 7; // 7 ⇒ ~16^7 ≈ 2.7e8 expected nodes between promotions; ample.
2797    // SplitMix-style mixer — cheap and seedable.
2798    let mut x = (row_idx as u64).wrapping_mul(0x9E37_79B9_7F4A_7C15);
2799    x ^= x >> 30;
2800    x = x.wrapping_mul(0xBF58_476D_1CE4_E5B9);
2801    x ^= x >> 27;
2802    x = x.wrapping_mul(0x94D0_49BB_1331_11EB);
2803    x ^= x >> 31;
2804    // Count contiguous low-end zero nibbles (4-bit chunks). Each zero
2805    // nibble has probability 1/16, mirroring HNSW's `mL ≈ 1/ln(M)` for
2806    // M=16. `trailing_zeros / 4` would lose the ordering when x = 0, so
2807    // a plain loop with a cap is clearer.
2808    let mut level: u8 = 0;
2809    while x & 0xF == 0 && level < MAX_LEVEL {
2810        level += 1;
2811        x >>= 4;
2812    }
2813    level
2814}
2815
2816impl Index {
2817    fn new_btree(name: String, column_position: usize) -> Self {
2818        Self {
2819            name,
2820            column_position,
2821            kind: IndexKind::BTree(PersistentBTreeMap::new()),
2822            included_columns: Vec::new(),
2823            partial_predicate: None,
2824            expression: None,
2825            is_unique: false,
2826            nulls_not_distinct: false,
2827            descending: false,
2828            nulls_first: None,
2829            collation: None,
2830            extra_column_positions: Vec::new(),
2831        }
2832    }
2833
2834    fn new_nsw(name: String, column_position: usize, m: usize) -> Self {
2835        Self {
2836            name,
2837            column_position,
2838            kind: IndexKind::Nsw(NswGraph::new(m)),
2839            included_columns: Vec::new(),
2840            partial_predicate: None,
2841            expression: None,
2842            is_unique: false,
2843            nulls_not_distinct: false,
2844            descending: false,
2845            nulls_first: None,
2846            collation: None,
2847            extra_column_positions: Vec::new(),
2848        }
2849    }
2850
2851    /// v6.7.1 — BRIN index constructor. BRIN carries no in-memory
2852    /// data; the `column_type` snapshot is used by the segment
2853    /// encoder + planner for type-checking range predicates.
2854    fn new_brin(name: String, column_position: usize, column_type: DataType) -> Self {
2855        Self {
2856            name,
2857            column_position,
2858            kind: IndexKind::Brin { column_type },
2859            included_columns: Vec::new(),
2860            partial_predicate: None,
2861            expression: None,
2862            is_unique: false,
2863            nulls_not_distinct: false,
2864            descending: false,
2865            nulls_first: None,
2866            collation: None,
2867            extra_column_positions: Vec::new(),
2868        }
2869    }
2870
2871    /// v7.12.3 — GIN inverted-index constructor. Empty posting-list
2872    /// map; caller (typically [`Table::add_gin_index`] or
2873    /// [`Table::restore_gin_index`]) populates it from existing rows
2874    /// or from a deserialised snapshot.
2875    fn new_gin(name: String, column_position: usize) -> Self {
2876        Self {
2877            name,
2878            column_position,
2879            kind: IndexKind::Gin(PersistentBTreeMap::new()),
2880            included_columns: Vec::new(),
2881            partial_predicate: None,
2882            expression: None,
2883            is_unique: false,
2884            nulls_not_distinct: false,
2885            descending: false,
2886            nulls_first: None,
2887            collation: None,
2888            extra_column_positions: Vec::new(),
2889        }
2890    }
2891
2892    /// v7.15.0 — `gin_trgm_ops`-flavoured GIN constructor. Same
2893    /// shape as `new_gin` but the posting-list keys are 3-byte
2894    /// trigram shingles (`pg_trgm`-compatible) and the column
2895    /// type is `TEXT` / `VARCHAR` (not `TSVECTOR`).
2896    fn new_gin_trgm(name: String, column_position: usize) -> Self {
2897        Self {
2898            name,
2899            column_position,
2900            kind: IndexKind::GinTrgm(PersistentBTreeMap::new()),
2901            included_columns: Vec::new(),
2902            partial_predicate: None,
2903            expression: None,
2904            is_unique: false,
2905            nulls_not_distinct: false,
2906            descending: false,
2907            nulls_first: None,
2908            collation: None,
2909            extra_column_positions: Vec::new(),
2910        }
2911    }
2912
2913    /// v7.17.0 Phase 2.2 — MySQL `FULLTEXT KEY` GIN constructor.
2914    /// Same shape as `new_gin_trgm` but the posting-list keys
2915    /// are lower-cased word lexemes (`to_tsvector('simple', col)`
2916    /// equivalent) instead of trigrams, and the column type is
2917    /// `TEXT` / `VARCHAR` (not `TSVECTOR`).
2918    fn new_gin_fulltext(name: String, column_position: usize) -> Self {
2919        Self {
2920            name,
2921            column_position,
2922            kind: IndexKind::GinFulltext(PersistentBTreeMap::new()),
2923            included_columns: Vec::new(),
2924            partial_predicate: None,
2925            expression: None,
2926            is_unique: false,
2927            nulls_not_distinct: false,
2928            descending: false,
2929            nulls_first: None,
2930            collation: None,
2931            extra_column_positions: Vec::new(),
2932        }
2933    }
2934
2935    /// v7.37.8(sentori Epic 5 P2)— JSONB-GIN constructor. Same
2936    /// shape as the other GIN-family indexes; posting-list keys
2937    /// are the canonical `(path, leaf)` tokens emitted by
2938    /// `crate::jsonb_gin::extract_tokens`. Maintains posting
2939    /// lists from `Value::Json` cells(JSONB is a synonym for the
2940    /// same in-memory string-backed Value).
2941    fn new_gin_jsonb(name: String, column_position: usize) -> Self {
2942        Self {
2943            name,
2944            column_position,
2945            kind: IndexKind::GinJsonb(PersistentBTreeMap::new()),
2946            included_columns: Vec::new(),
2947            partial_predicate: None,
2948            expression: None,
2949            is_unique: false,
2950            nulls_not_distinct: false,
2951            descending: false,
2952            nulls_first: None,
2953            collation: None,
2954            extra_column_positions: Vec::new(),
2955        }
2956    }
2957
2958    /// v7.34.4 — descending-order iterator over `(IndexKey, locators)`
2959    /// pairs for a BTree index, with O(log N) descent to the rightmost
2960    /// leaf and lazy emission thereafter. Returns an empty iterator
2961    /// for non-BTree index kinds — callers handle both uniformly.
2962    /// Used by the ORDER BY `<indexed col>` DESC + LIMIT N executor
2963    /// path: walking only the first N matches off the rightmost leaf
2964    /// avoids the per-row materialisation + partial-sort cost on
2965    /// large tables (mailrs `content_worker` at 250 k rows).
2966    pub fn iter_desc(
2967        &self,
2968    ) -> alloc::boxed::Box<dyn Iterator<Item = (&IndexKey, &alloc::vec::Vec<RowLocator>)> + '_>
2969    {
2970        match &self.kind {
2971            IndexKind::BTree(m) => alloc::boxed::Box::new(m.iter_rev()),
2972            IndexKind::Nsw(_)
2973            | IndexKind::Brin { .. }
2974            | IndexKind::Gin(_)
2975            | IndexKind::GinTrgm(_)
2976            | IndexKind::GinFulltext(_)
2977            | IndexKind::GinJsonb(_) => alloc::boxed::Box::new(core::iter::empty()),
2978        }
2979    }
2980
2981    /// v7.34.4 — ascending-order iterator over `(IndexKey, locators)`
2982    /// pairs. Mirror of `iter_desc` for ORDER BY ... ASC + LIMIT N.
2983    pub fn iter_asc(
2984        &self,
2985    ) -> alloc::boxed::Box<dyn Iterator<Item = (&IndexKey, &alloc::vec::Vec<RowLocator>)> + '_>
2986    {
2987        match &self.kind {
2988            IndexKind::BTree(m) => alloc::boxed::Box::new(m.iter()),
2989            IndexKind::Nsw(_)
2990            | IndexKind::Brin { .. }
2991            | IndexKind::Gin(_)
2992            | IndexKind::GinTrgm(_)
2993            | IndexKind::GinFulltext(_)
2994            | IndexKind::GinJsonb(_) => alloc::boxed::Box::new(core::iter::empty()),
2995        }
2996    }
2997
2998    /// Look up the locators stored under `key` (B-tree only). Returns
2999    /// an empty slice when the key is absent or the index isn't a
3000    /// BTree — callers can treat both cases uniformly.
3001    ///
3002    /// v5.1: return type widened from `&[usize]` to `&[RowLocator]`.
3003    /// Pre-v5.2 callers can read the slice and `.as_hot().unwrap()`
3004    /// each entry (no `Cold` variants exist until the freezer lands);
3005    /// post-v5.2 callers dispatch hot vs. cold per locator.
3006    pub fn lookup_eq(&self, key: &IndexKey) -> &[RowLocator] {
3007        match &self.kind {
3008            IndexKind::BTree(m) => m.get(key).map_or(&[][..], Vec::as_slice),
3009            // BRIN / NSW / GIN / trigram-GIN / fulltext-GIN have
3010            // no IndexKey-keyed map; lookup is a no-op. GIN uses
3011            // [`Index::gin_lookup_word`] instead.
3012            IndexKind::Nsw(_)
3013            | IndexKind::Brin { .. }
3014            | IndexKind::Gin(_)
3015            | IndexKind::GinTrgm(_)
3016            | IndexKind::GinFulltext(_)
3017            | IndexKind::GinJsonb(_) => &[][..],
3018        }
3019    }
3020
3021    /// v7.37.43 (INSUBQ B-2) — specialised lookup for integer-PK probes.
3022    /// `try_count_star_pk_in_subquery_fast` already holds an `i64` (the
3023    /// inner survivor key); skip the `IndexKey::from_value` enum-dispatch
3024    /// trip and build the key inline. ~20 ns × N_survivors saved on
3025    /// the INSUBQ hot loop.
3026    #[inline]
3027    pub fn lookup_eq_i64(&self, n: i64) -> &[RowLocator] {
3028        match &self.kind {
3029            IndexKind::BTree(m) => m.get(&IndexKey::Int(n)).map_or(&[][..], Vec::as_slice),
3030            IndexKind::Nsw(_)
3031            | IndexKind::Brin { .. }
3032            | IndexKind::Gin(_)
3033            | IndexKind::GinTrgm(_)
3034            | IndexKind::GinFulltext(_)
3035            | IndexKind::GinJsonb(_) => &[][..],
3036        }
3037    }
3038
3039    /// v7.38 (perf, index range scan) — flatten the row locators for every key
3040    /// in `[lo, hi]` (bounds per `core::ops::Bound`) via the BTree's `O(log N +
3041    /// k)` range walk. Returns `None` once more than `cap` locators accumulate
3042    /// — a "this range isn't selective enough, seq-scan instead" signal that
3043    /// stops a wide range from materialising a near-full table's worth of rows
3044    /// through the index. BTree only (other kinds → None).
3045    pub fn lookup_range_capped(
3046        &self,
3047        lo: core::ops::Bound<&IndexKey>,
3048        hi: core::ops::Bound<&IndexKey>,
3049        cap: usize,
3050    ) -> Option<Vec<RowLocator>> {
3051        self.lookup_range_capped_by(lo, hi, cap, |_| true)
3052    }
3053
3054    /// v7.39 (round 490) — the same range walk, but the caller decides
3055    /// which locators are worth carrying, and the cap counts only those.
3056    ///
3057    /// A BTree index holds one locator per row VERSION. On a churned table
3058    /// the dead versions are still in there: round 490 measured a
3059    /// 1000-row range handing back 61 000 locators after 60
3060    /// delete-and-reinsert cycles with the background vacuum switched off.
3061    /// Every caller then dropped the dead ones — the mutation paths and the
3062    /// SELECT range path all test `is_row_visible` and `continue` — but only
3063    /// after they had been collected into a `Vec`, sorted, and walked.
3064    ///
3065    /// Handing the predicate down means the walk keeps ~1000, and the cap
3066    /// (which exists so an index walk never costs more than the scan it
3067    /// replaces) is once again measured in rows a caller will actually look
3068    /// at. Round 461 had to add the dead count to the budget to stop the
3069    /// seek being refused outright; with the filter here that compensation
3070    /// is no longer needed.
3071    pub fn lookup_range_capped_by(
3072        &self,
3073        lo: core::ops::Bound<&IndexKey>,
3074        hi: core::ops::Bound<&IndexKey>,
3075        cap: usize,
3076        keep: impl Fn(RowLocator) -> bool,
3077    ) -> Option<Vec<RowLocator>> {
3078        match &self.kind {
3079            IndexKind::BTree(m) => {
3080                let mut out: Vec<RowLocator> = Vec::new();
3081                for (_, locs) in m.range(lo, hi) {
3082                    out.extend(locs.iter().copied().filter(|l| keep(*l)));
3083                    if out.len() > cap {
3084                        return None;
3085                    }
3086                }
3087                Some(out)
3088            }
3089            IndexKind::Nsw(_)
3090            | IndexKind::Brin { .. }
3091            | IndexKind::Gin(_)
3092            | IndexKind::GinTrgm(_)
3093            | IndexKind::GinFulltext(_)
3094            | IndexKind::GinJsonb(_) => None,
3095        }
3096    }
3097
3098    /// v7.39 (round 560) — the index range as (key, locator) pairs.
3099    ///
3100    /// `lookup_range_capped_by` throws the KEY away and returns only
3101    /// locators, so a query whose projection is exactly the indexed
3102    /// column still goes to the row store for a value the walk already
3103    /// had in hand — paying per row for something the index knows.
3104    ///
3105    /// Uncapped on purpose: an index-only walk touches no row, so the
3106    /// selectivity ceiling that keeps a seek from being worse than the
3107    /// scan it replaces does not apply to it.
3108    ///
3109    /// v7.39 (round 562) — and it does not collect, either. This
3110    /// returned a `Vec<(IndexKey, RowLocator)>`: for a 100k-row range,
3111    /// 100k key clones into a `Vec::new()` that doubles its way up to
3112    /// several MB, all to be walked once and dropped. A profile of the
3113    /// server serving that query put 20% of the connection thread's CPU
3114    /// on the collect alone, with another 18% in the allocator beside
3115    /// it. The caller consumes the pairs in order and needs the key
3116    /// only by reference, so it can have the walk itself.
3117    pub fn range_keyed(
3118        &self,
3119        lo: core::ops::Bound<&IndexKey>,
3120        hi: core::ops::Bound<&IndexKey>,
3121    ) -> Option<impl Iterator<Item = (&IndexKey, RowLocator)> + '_> {
3122        match &self.kind {
3123            IndexKind::BTree(m) => Some(
3124                m.range(lo, hi)
3125                    .flat_map(|(k, locs)| locs.iter().map(move |l| (k, *l))),
3126            ),
3127            IndexKind::Nsw(_)
3128            | IndexKind::Brin { .. }
3129            | IndexKind::Gin(_)
3130            | IndexKind::GinTrgm(_)
3131            | IndexKind::GinFulltext(_)
3132            | IndexKind::GinJsonb(_) => None,
3133        }
3134    }
3135
3136    /// v7.12.3 — GIN posting-list lookup. Returns the row locators
3137    /// whose `tsvector` cell contains `word`. Empty when the word is
3138    /// absent from the index or this isn't a GIN index.
3139    pub fn gin_lookup_word(&self, word: &str) -> &[RowLocator] {
3140        match &self.kind {
3141            // v7.17.0 Phase 2.2 — fulltext-GIN shares the same
3142            // lexeme-keyed posting list shape as the
3143            // tsvector-typed GIN, so the same lookup applies.
3144            IndexKind::Gin(m) | IndexKind::GinFulltext(m) => {
3145                m.get(&String::from(word)).map_or(&[][..], Vec::as_slice)
3146            }
3147            IndexKind::BTree(_)
3148            | IndexKind::Nsw(_)
3149            | IndexKind::Brin { .. }
3150            | IndexKind::GinTrgm(_)
3151            | IndexKind::GinJsonb(_) => &[][..],
3152        }
3153    }
3154
3155    /// v7.15.0 — trigram-GIN posting-list lookup. Returns the row
3156    /// locators whose indexed `TEXT` cell contains the trigram
3157    /// `tri`. Empty when the trigram is absent or this isn't a
3158    /// trigram-GIN index.
3159    pub fn gin_trgm_lookup(&self, tri: &str) -> &[RowLocator] {
3160        match &self.kind {
3161            IndexKind::GinTrgm(m) => m.get(&String::from(tri)).map_or(&[][..], Vec::as_slice),
3162            IndexKind::BTree(_)
3163            | IndexKind::Nsw(_)
3164            | IndexKind::Brin { .. }
3165            | IndexKind::Gin(_)
3166            | IndexKind::GinFulltext(_)
3167            | IndexKind::GinJsonb(_) => &[][..],
3168        }
3169    }
3170
3171    /// v7.37.8(sentori Epic 5 P2)— JSONB-GIN posting-list lookup.
3172    /// Returns the row locators whose indexed JSONB cell carries
3173    /// the canonical `token`(see [`crate::jsonb_gin::extract_tokens`]).
3174    /// Empty when the token is absent or this isn't a JSONB-GIN
3175    /// index. Planners drive `<col> @> <jsonb_literal>` through here.
3176    pub fn gin_jsonb_lookup(&self, token: &str) -> &[RowLocator] {
3177        match &self.kind {
3178            IndexKind::GinJsonb(m) => m.get(&String::from(token)).map_or(&[][..], Vec::as_slice),
3179            IndexKind::BTree(_)
3180            | IndexKind::Nsw(_)
3181            | IndexKind::Brin { .. }
3182            | IndexKind::Gin(_)
3183            | IndexKind::GinTrgm(_)
3184            | IndexKind::GinFulltext(_) => &[][..],
3185        }
3186    }
3187
3188    /// Borrow the NSW graph (if this is an NSW index). Callers that need
3189    /// the graph for a kNN search go through here.
3190    pub const fn nsw(&self) -> Option<&NswGraph> {
3191        match &self.kind {
3192            IndexKind::Nsw(g) => Some(g),
3193            IndexKind::BTree(_)
3194            | IndexKind::Brin { .. }
3195            | IndexKind::Gin(_)
3196            | IndexKind::GinTrgm(_)
3197            | IndexKind::GinFulltext(_)
3198            | IndexKind::GinJsonb(_) => None,
3199        }
3200    }
3201
3202    /// v6.7.1 — true when this index is a BRIN (block range) index.
3203    /// Used by the segment encoder to opt into BRIN sidecar emission
3204    /// at freeze time, and by the planner to opt into page-skipping
3205    /// on range predicates.
3206    pub const fn is_brin(&self) -> bool {
3207        matches!(self.kind, IndexKind::Brin { .. })
3208    }
3209
3210    /// v7.15.0 — true when this index is a trigram GIN
3211    /// (`gin_trgm_ops`-flavoured). Used by the LIKE planner to
3212    /// opt into trigram acceleration.
3213    pub const fn is_gin_trgm(&self) -> bool {
3214        matches!(self.kind, IndexKind::GinTrgm(_))
3215    }
3216
3217    /// v7.12.3 — true when this index is a GIN inverted index.
3218    /// Used by the planner to opt into posting-list acceleration on
3219    /// `WHERE col @@ tsquery` predicates.
3220    pub const fn is_gin(&self) -> bool {
3221        matches!(self.kind, IndexKind::Gin(_))
3222    }
3223
3224    /// v7.17.0 Phase 2.2 — true when this index is a fulltext
3225    /// GIN over a TEXT / VARCHAR column (MySQL `FULLTEXT KEY`
3226    /// surface). Used by the planner to opt the FULLTEXT-indexed
3227    /// column into MATCH AGAINST acceleration.
3228    pub const fn is_gin_fulltext(&self) -> bool {
3229        matches!(self.kind, IndexKind::GinFulltext(_))
3230    }
3231
3232    /// v7.37.8(sentori Epic 5 P2)— true when this index is a
3233    /// real JSONB-GIN(posting-list backed). Used by the planner
3234    /// to opt `<col> @> <jsonb_literal>` into posting-list seek.
3235    pub const fn is_gin_jsonb(&self) -> bool {
3236        matches!(self.kind, IndexKind::GinJsonb(_))
3237    }
3238}
3239
3240/// In-memory table: schema + a persistent row vector + secondary indices.
3241///
3242/// v4.39: `rows` is a [`PersistentVec`] (Bitmapped Vector Trie, 32-way) so
3243/// `Table::clone()` is `O(1)` — the whole reason for v4.39's existence is
3244/// to make `Catalog::clone()` cheap inside the v4.34 auto-commit wrap.
3245///
3246/// v5.2.1: `hot_bytes` tracks the encoded byte size of every row currently
3247/// in [`Self::rows`], summed over rows. Updated incrementally by `insert`
3248/// (+= encoded row size), `delete_rows` (-= removed rows' encoded sizes),
3249/// and `update_row` (-= old size, += new size). The value is what the
3250/// v5.2 freezer reads to decide when to demote cold rows — when the
3251/// catalog-wide sum crosses `SPG_HOT_TIER_BYTES` (default 4 GiB) the
3252/// freezer thread wakes. v5.2.1 ships measurement only; the freezer
3253/// itself lands in v5.2.2. Stored as `u64` so a single field clone in
3254/// `Catalog::clone` stays at the O(1) invariant v4.39 built.
3255/// v7.34 (crash-recovery P0 #2) — one row-level physical redo record.
3256/// Row-level redo replaces statement-based WAL replay (which re-executes
3257/// each SQL through the full engine — O(records × catalog_rows), the
3258/// superlinear recovery hang root-caused on the mailrs crash-recovery
3259/// P0). A `RowChange` is the exact storage mutation the engine applied
3260/// (`Table::insert` / `update_row` / `delete_rows`); replaying it on a
3261/// catalog restored from the matching checkpoint reproduces the state
3262/// WITHOUT re-validating uniqueness/FK/parse/plan — O(changed rows).
3263///
3264/// Positions are physical, not key-based: `serialize`/`deserialize`
3265/// preserve row order exactly (rows written + read back in `self.rows`
3266/// order) and the mutation ops are deterministic, so the same op sequence
3267/// replayed from the same checkpoint reproduces the same positions. This
3268/// matches PostgreSQL's physical redo and supports tables with no primary
3269/// key. (Caveat handled at replay integration: a post-checkpoint cold-tier
3270/// freeze shifts hot positions and must itself be logged or fenced by a
3271/// checkpoint — see `row-level-redo-design`.)
3272/// ## v7.37.15 (Epic W slice 1) — additive MVCC identity metadata
3273///
3274/// Each variant now also carries, additively, the stable
3275/// [`RowId`](row_header::RowId) of the affected row(s) and the
3276/// **writer version** (`xmin` for an insert, `xmax` for a
3277/// delete/update). This is the codec foundation for making
3278/// in-place MVCC tombstones durable across crash/upgrade recovery.
3279///
3280/// Two important properties for the durability path:
3281///
3282/// 1. **Replay resolution is UNCHANGED.** `apply_redo_run_on_table`
3283///    still resolves every change by physical `pos`/`positions`
3284///    exactly as before. The new metadata is *carried but unused*
3285///    by replay in this slice; resolving-by-`RowId` and
3286///    header-preserving replay are later slices.
3287/// 2. **Backward compatibility.** A redo payload written by
3288///    pre-Epic-W code carries no metadata; [`decode_redo_log`]
3289///    fills `rowid`/`rowids` with [`RowId::UNASSIGNED`](row_header::RowId::UNASSIGNED)
3290///    (empty for `Delete`) and `writer_version` with `0`. See the
3291///    codec version gate in [`encode_redo_log`]/[`decode_redo_log`].
3292///
3293/// The `writer_version` is captured as `0` at the storage layer
3294/// (`Table::insert`/`delete_rows`/`update_row` don't have the
3295/// committing `TxId`), then **stamped with the real committing
3296/// version by the engine** after it drains the statement's changes
3297/// (Epic W slice 2 — [`RowChange::set_writer_version`], driven from
3298/// `Engine::writer_version_for_current_stmt`). All changes from one
3299/// statement share the one version. Replay still resolves by
3300/// physical position and does not read `writer_version` — that is a
3301/// later slice (header-preserving replay).
3302#[derive(Debug, Clone, PartialEq)]
3303pub enum RowChange {
3304    /// Append `row` to `table`.
3305    Insert {
3306        table: String,
3307        row: Row<'static>,
3308        /// Epic W: stable id the appended row will receive.
3309        /// [`RowId::UNASSIGNED`](row_header::RowId::UNASSIGNED) when
3310        /// decoded from a pre-Epic-W redo payload.
3311        rowid: row_header::RowId,
3312        /// Epic W: writer version (`xmin`). `0` until the writing
3313        /// `TxId` is threaded to the storage layer (later slice).
3314        writer_version: u64,
3315    },
3316    /// Replace the row at physical `pos` in `table` with `new_row`.
3317    Update {
3318        table: String,
3319        pos: usize,
3320        new_row: Vec<Value<'static>>,
3321        /// Epic W: stable id of the row at `pos`.
3322        /// [`RowId::UNASSIGNED`](row_header::RowId::UNASSIGNED) when
3323        /// decoded from a pre-Epic-W redo payload.
3324        rowid: row_header::RowId,
3325        /// Epic W: writer version (`xmax` of the superseded tuple).
3326        /// `0` until the writing `TxId` is threaded (later slice).
3327        writer_version: u64,
3328    },
3329    /// Remove the rows at the given physical `positions` from `table`.
3330    Delete {
3331        table: String,
3332        positions: Vec<usize>,
3333        /// Epic W: stable ids parallel to `positions` (same length,
3334        /// [`RowId::UNASSIGNED`](row_header::RowId::UNASSIGNED) for an
3335        /// out-of-bounds input position). **Empty** when decoded from
3336        /// a pre-Epic-W redo payload (no metadata was recorded).
3337        rowids: Vec<row_header::RowId>,
3338        /// Epic W: writer version (`xmax`). `0` until the writing
3339        /// `TxId` is threaded to the storage layer (later slice).
3340        writer_version: u64,
3341    },
3342    /// v7.37.15 (Epic W durable-tombstone slice) — an **in-place MVCC
3343    /// delete**: the row(s) named by `rowids` are NOT physically
3344    /// removed; their header `xmax` is stamped so newer snapshots stop
3345    /// seeing them (vacuum reclaims later). This is the redo shape of
3346    /// the gate-on (`SPG_MVCC_INPLACE`) DELETE / UPDATE-old-version /
3347    /// ON-CONFLICT paths, which call [`Table::mark_row_deleted`]
3348    /// instead of `delete_rows`.
3349    ///
3350    /// Unlike `Delete`, the target is named by **stable `RowId`**, not
3351    /// physical position: a tombstone keeps the slot, so position would
3352    /// be ambiguous after later compaction, and the header-preserving
3353    /// replay must re-find the exact row the writer tombstoned. On
3354    /// replay the id is matched against the ids the same redo run
3355    /// produced (an `Insert`'s `rowid`, or the table's ids snapshotted
3356    /// at run start); an id that cannot be resolved is skipped and
3357    /// counted (see `apply_redo_run_on_table`) — this is the documented
3358    /// cross-checkpoint limitation until the V6 envelope persists ids.
3359    Tombstone {
3360        table: String,
3361        /// Stable ids of the tombstoned rows (from `self.rowids()[pos]`
3362        /// at capture). Never empty for a recorded tombstone.
3363        rowids: Vec<row_header::RowId>,
3364        /// The version stamped into each target row's header `xmax`
3365        /// (the deleting statement's writer version).
3366        xmax: u64,
3367    },
3368}
3369
3370impl RowChange {
3371    /// v7.39 (round 736) — which table this change applies to.
3372    #[must_use]
3373    pub fn table_name(&self) -> &str {
3374        match self {
3375            Self::Insert { table, .. }
3376            | Self::Update { table, .. }
3377            | Self::Delete { table, .. }
3378            | Self::Tombstone { table, .. } => table,
3379        }
3380    }
3381
3382    /// v7.37.15 (Epic W slice 2) — stamp the committing writer
3383    /// version onto this change. Every change drained from a single
3384    /// statement shares one version (the statement's `xmin`/`xmax`),
3385    /// so the engine calls this on each drained change with the value
3386    /// from [`Engine::writer_version_for_current_stmt`]. Additive
3387    /// metadata only: replay still resolves by physical position and
3388    /// does not read `writer_version` (that is a later slice).
3389    pub fn set_writer_version(&mut self, v: u64) {
3390        match self {
3391            RowChange::Insert { writer_version, .. }
3392            | RowChange::Update { writer_version, .. }
3393            | RowChange::Delete { writer_version, .. } => *writer_version = v,
3394            // A tombstone captures `xmax` directly from the deleting
3395            // statement's version at record time (via
3396            // `mark_row_deleted`), so it already equals `v`. Keep the
3397            // "one statement, one version" invariant mechanical by
3398            // asserting agreement in debug builds rather than silently
3399            // overwriting a possibly-different value.
3400            RowChange::Tombstone { xmax, .. } => {
3401                debug_assert_eq!(
3402                    *xmax, v,
3403                    "tombstone xmax must match the statement writer version"
3404                );
3405                *xmax = v;
3406            }
3407        }
3408    }
3409}
3410
3411/// v7.37.15 (Epic W slice 1) — leading marker byte of the
3412/// metadata-carrying redo layout. A **pre-Epic-W** redo payload leads
3413/// with `FILE_VERSION` (8..=52 today, rising ~1 per release); this
3414/// marker is `0xFF` and can therefore never collide with a real
3415/// `FILE_VERSION`, so [`decode_redo_log`] tells the two layouts apart
3416/// by inspecting the first byte alone. The compile-time assertion
3417/// below makes the "never collide" invariant a hard build gate: if
3418/// `FILE_VERSION` ever climbs toward `0xFF` the build breaks and forces
3419/// a redesign long before an ambiguity could ship.
3420const REDO_META_MARKER: u8 = 0xFF;
3421/// v7.37.15 (Epic W slice 1) — version of the metadata-carrying redo
3422/// layout that follows [`REDO_META_MARKER`]. Bumped when the per-change
3423/// metadata shape changes; an unknown value is a hard decode error.
3424const REDO_META_VERSION: u8 = 1;
3425
3426/// v7.37.15 (Epic W durable-tombstone slice) — process-wide count of
3427/// [`RowChange::Tombstone`] targets that `apply_redo` could NOT resolve
3428/// to a row by `RowId`. A non-zero value is expected only across a
3429/// checkpoint boundary (the table's ids are reassigned on deserialize
3430/// and the V6 envelope does not yet persist them), where a tombstone
3431/// naming a pre-checkpoint row is left visible rather than mis-applied.
3432/// Surfaced for observability; never affects correctness of the resolved
3433/// tombstones. Read via [`unresolved_tombstone_count`].
3434static UNRESOLVED_TOMBSTONES: core::sync::atomic::AtomicU64 = core::sync::atomic::AtomicU64::new(0);
3435
3436/// v7.39 (flip crash-replay P0) — observability read for the replay
3437/// tombstones that could not be resolved to a row (each one is a
3438/// resurrected delete).
3439#[must_use]
3440pub fn unresolved_tombstones() -> u64 {
3441    UNRESOLVED_TOMBSTONES.load(core::sync::atomic::Ordering::Relaxed)
3442}
3443
3444/// v7.37.15 (Epic W durable-tombstone slice) — read the process-wide
3445/// count of redo tombstones that could not be resolved to a row by
3446/// `RowId` during `apply_redo`. See [`UNRESOLVED_TOMBSTONES`].
3447#[must_use]
3448pub fn unresolved_tombstone_count() -> u64 {
3449    UNRESOLVED_TOMBSTONES.load(core::sync::atomic::Ordering::Relaxed)
3450}
3451// Provably-unambiguous old/new distinction: the pre-Epic-W layout's
3452// first byte is `FILE_VERSION`, which must stay strictly below the
3453// marker forever.
3454const _: () = assert!(FILE_VERSION < REDO_META_MARKER);
3455
3456/// v7.34 (crash-recovery P0 #2), extended v7.37.15 (Epic W slice 1) —
3457/// encode a row-level redo log to bytes for a WAL record.
3458///
3459/// ## Layout (Epic W metadata-carrying form, always emitted now)
3460///
3461/// `[u8 REDO_META_MARKER=0xFF][u8 REDO_META_VERSION][u8 FILE_VERSION]
3462/// [u32 count]` then per change `[u8 op][str table]` and, per op:
3463/// - `Insert [u32 n][value×n][u64 rowid][u64 writer_version]`
3464/// - `Update [u32 pos][u32 n][value×n][u64 rowid][u64 writer_version]`
3465/// - `Delete [u32 n][u32 pos×n][u64 rowid×n][u64 writer_version]`
3466/// - `Tombstone [u32 n][u64 rowid×n][u64 xmax]` (op byte 3; only ever
3467///   emitted under the metadata-carrying layout — the pre-Epic-W layout
3468///   had no in-place tombstone, so a legacy stream can never carry it)
3469///
3470/// Positions are physical (u32 ≤ 4 G rows). The `FILE_VERSION` byte
3471/// still rides along (now the 3rd byte) so the value codec decodes
3472/// string / BYTEA escapes exactly as before.
3473///
3474/// ## Backward compatibility
3475///
3476/// The **pre-Epic-W** layout was `[u8 FILE_VERSION][u32 count]…` with
3477/// no per-change metadata. [`decode_redo_log`] still decodes that form
3478/// (first byte < `0xFF`) byte-for-byte identically — every WAL file
3479/// written by released code replays unchanged.
3480#[must_use]
3481pub fn encode_redo_log(changes: &[RowChange]) -> Vec<u8> {
3482    let mut out = Vec::new();
3483    out.push(REDO_META_MARKER);
3484    out.push(REDO_META_VERSION);
3485    out.push(FILE_VERSION);
3486    codec::write_u32(&mut out, changes.len() as u32);
3487    let write_values = |out: &mut Vec<u8>, vals: &[Value<'static>]| {
3488        codec::write_u32(out, vals.len() as u32);
3489        for v in vals {
3490            codec::write_value(out, v);
3491        }
3492    };
3493    for change in changes {
3494        match change {
3495            RowChange::Insert {
3496                table,
3497                row,
3498                rowid,
3499                writer_version,
3500            } => {
3501                out.push(0);
3502                codec::write_str(&mut out, table);
3503                write_values(&mut out, &row.values);
3504                codec::write_u64(&mut out, rowid.0);
3505                codec::write_u64(&mut out, *writer_version);
3506            }
3507            RowChange::Update {
3508                table,
3509                pos,
3510                new_row,
3511                rowid,
3512                writer_version,
3513            } => {
3514                out.push(1);
3515                codec::write_str(&mut out, table);
3516                codec::write_u32(&mut out, *pos as u32);
3517                write_values(&mut out, new_row);
3518                codec::write_u64(&mut out, rowid.0);
3519                codec::write_u64(&mut out, *writer_version);
3520            }
3521            RowChange::Delete {
3522                table,
3523                positions,
3524                rowids,
3525                writer_version,
3526            } => {
3527                out.push(2);
3528                codec::write_str(&mut out, table);
3529                codec::write_u32(&mut out, positions.len() as u32);
3530                for p in positions {
3531                    codec::write_u32(&mut out, *p as u32);
3532                }
3533                // Epic W: one RowId per position (parallel). Capture
3534                // sites always produce `rowids.len() == positions.len()`;
3535                // this assertion pins that invariant at encode time so a
3536                // mismatch is a loud bug, not a silently short payload.
3537                debug_assert_eq!(
3538                    rowids.len(),
3539                    positions.len(),
3540                    "redo Delete: rowids must be parallel to positions"
3541                );
3542                for rid in rowids {
3543                    codec::write_u64(&mut out, rid.0);
3544                }
3545                codec::write_u64(&mut out, *writer_version);
3546            }
3547            RowChange::Tombstone {
3548                table,
3549                rowids,
3550                xmax,
3551            } => {
3552                out.push(3);
3553                codec::write_str(&mut out, table);
3554                codec::write_u32(&mut out, rowids.len() as u32);
3555                for rid in rowids {
3556                    codec::write_u64(&mut out, rid.0);
3557                }
3558                codec::write_u64(&mut out, *xmax);
3559            }
3560        }
3561    }
3562    out
3563}
3564
3565/// v7.34, extended v7.37.15 (Epic W slice 1) — decode a row-level redo
3566/// log written by [`encode_redo_log`].
3567///
3568/// Decodes **both** the Epic W metadata-carrying layout (first byte
3569/// `REDO_META_MARKER = 0xFF`) and the pre-Epic-W layout (first byte is
3570/// `FILE_VERSION`, always `< 0xFF`). For the old layout the per-change
3571/// metadata is absent, so `rowid`/`rowids` come back
3572/// [`RowId::UNASSIGNED`](row_header::RowId::UNASSIGNED) (empty for
3573/// `Delete`) and `writer_version` comes back `0`.
3574///
3575/// A truncated / corrupt buffer is a hard error — never a panic — the
3576/// embedding layer frames each record with its own length + CRC, so a
3577/// frame that decodes short is corruption, not a torn tail.
3578pub fn decode_redo_log(bytes: &[u8]) -> Result<Vec<RowChange>, StorageError> {
3579    let first = *bytes
3580        .first()
3581        .ok_or_else(|| StorageError::Corrupt("redo log: empty".into()))?;
3582    // Epic W: `0xFF` marker ⇒ metadata-carrying layout; anything else
3583    // is a pre-Epic-W `FILE_VERSION` byte (old layout, no metadata).
3584    let has_meta = first == REDO_META_MARKER;
3585    let (codec_version, header_len) = if has_meta {
3586        let meta_version = *bytes
3587            .get(1)
3588            .ok_or_else(|| StorageError::Corrupt("redo log: short header".into()))?;
3589        if meta_version != REDO_META_VERSION {
3590            return Err(StorageError::Corrupt(alloc::format!(
3591                "redo log: unknown metadata version {meta_version}"
3592            )));
3593        }
3594        let file_version = *bytes
3595            .get(2)
3596            .ok_or_else(|| StorageError::Corrupt("redo log: short header".into()))?;
3597        // header = [marker][meta_version][file_version]
3598        (file_version, 3usize)
3599    } else {
3600        // Old layout: the first byte IS the FILE_VERSION.
3601        (first, 1usize)
3602    };
3603    let mut cur = codec::Cursor::new(bytes).with_codec_version(codec_version);
3604    for _ in 0..header_len {
3605        cur.read_u8()?;
3606    }
3607    let count = cur.read_u32()? as usize;
3608    let mut read_values =
3609        |cur: &mut codec::Cursor<'_>| -> Result<Vec<Value<'static>>, StorageError> {
3610            let n = cur.read_u32()? as usize;
3611            let mut vals = Vec::with_capacity(n);
3612            for _ in 0..n {
3613                vals.push(cur.read_value()?);
3614            }
3615            Ok(vals)
3616        };
3617    let mut changes = Vec::with_capacity(count);
3618    for _ in 0..count {
3619        let op = cur.read_u8()?;
3620        let table = cur.read_str()?;
3621        let change = match op {
3622            0 => {
3623                let row = Row::new(read_values(&mut cur)?);
3624                let (rowid, writer_version) = if has_meta {
3625                    (row_header::RowId(cur.read_u64()?), cur.read_u64()?)
3626                } else {
3627                    (row_header::RowId::UNASSIGNED, 0)
3628                };
3629                RowChange::Insert {
3630                    table,
3631                    row,
3632                    rowid,
3633                    writer_version,
3634                }
3635            }
3636            1 => {
3637                let pos = cur.read_u32()? as usize;
3638                let new_row = read_values(&mut cur)?;
3639                let (rowid, writer_version) = if has_meta {
3640                    (row_header::RowId(cur.read_u64()?), cur.read_u64()?)
3641                } else {
3642                    (row_header::RowId::UNASSIGNED, 0)
3643                };
3644                RowChange::Update {
3645                    table,
3646                    pos,
3647                    new_row,
3648                    rowid,
3649                    writer_version,
3650                }
3651            }
3652            2 => {
3653                let n = cur.read_u32()? as usize;
3654                let mut positions = Vec::with_capacity(n);
3655                for _ in 0..n {
3656                    positions.push(cur.read_u32()? as usize);
3657                }
3658                let (rowids, writer_version) = if has_meta {
3659                    let mut rowids = Vec::with_capacity(n);
3660                    for _ in 0..n {
3661                        rowids.push(row_header::RowId(cur.read_u64()?));
3662                    }
3663                    (rowids, cur.read_u64()?)
3664                } else {
3665                    // Old layout carried no RowId metadata.
3666                    (Vec::new(), 0)
3667                };
3668                RowChange::Delete {
3669                    table,
3670                    positions,
3671                    rowids,
3672                    writer_version,
3673                }
3674            }
3675            // Op 3 is the Epic W in-place tombstone — it only exists in
3676            // the metadata-carrying layout. Guarding on `has_meta` means
3677            // a legacy stream that happens to contain a `3` byte here is
3678            // reported as an unknown op (corruption), never mis-decoded.
3679            3 if has_meta => {
3680                let n = cur.read_u32()? as usize;
3681                let mut rowids = Vec::with_capacity(n);
3682                for _ in 0..n {
3683                    rowids.push(row_header::RowId(cur.read_u64()?));
3684                }
3685                let xmax = cur.read_u64()?;
3686                RowChange::Tombstone {
3687                    table,
3688                    rowids,
3689                    xmax,
3690                }
3691            }
3692            other => {
3693                return Err(StorageError::Corrupt(alloc::format!(
3694                    "redo log: unknown op {other}"
3695                )));
3696            }
3697        };
3698        changes.push(change);
3699    }
3700    Ok(changes)
3701}
3702
3703/// v7.39 (pg_stat knife B) — per-table scan counters, bumped from
3704/// `&self` read paths. Clone (tx shadow catalogs clone tables) copies
3705/// the current values; the counters are volatile like PG's cumulative
3706/// stats.
3707#[derive(Debug, Default)]
3708pub struct ScanStats {
3709    pub seq_scan: core::sync::atomic::AtomicU64,
3710    pub seq_tup_read: core::sync::atomic::AtomicU64,
3711    pub idx_scan: core::sync::atomic::AtomicU64,
3712    pub idx_tup_fetch: core::sync::atomic::AtomicU64,
3713}
3714
3715impl Clone for ScanStats {
3716    fn clone(&self) -> Self {
3717        use core::sync::atomic::{AtomicU64, Ordering};
3718        Self {
3719            seq_scan: AtomicU64::new(self.seq_scan.load(Ordering::Relaxed)),
3720            seq_tup_read: AtomicU64::new(self.seq_tup_read.load(Ordering::Relaxed)),
3721            idx_scan: AtomicU64::new(self.idx_scan.load(Ordering::Relaxed)),
3722            idx_tup_fetch: AtomicU64::new(self.idx_tup_fetch.load(Ordering::Relaxed)),
3723        }
3724    }
3725}
3726
3727/// v7.39 (round 215) — the lower-bound sort key for a range value, used by
3728/// the range-exclusion index. The bound as an `i128` (unbounded lower =
3729/// `i128::MIN`, sorting first) plus an inclusivity rank (inclusive lower
3730/// sorts before exclusive at the same value, `[3` before `(3`). Returns
3731/// `None` for range kinds whose bound isn't an integer scalar (numrange's
3732/// numeric/bignum), for empty ranges, and for non-range values — the caller
3733/// then keeps the O(n) scan rather than risk an unsound order. Int4/Int8/
3734/// Date/Ts/TsTz all reduce here (tstzrange bounds are `Value::Timestamp`).
3735/// Maintenance (index build) and query (overlap probe) MUST agree on this
3736/// key, so both sides call exactly this function.
3737#[must_use]
3738pub fn range_excl_index_key(v: &Value<'_>) -> Option<(i128, u8)> {
3739    let Value::Range {
3740        lower,
3741        lower_inc,
3742        empty,
3743        ..
3744    } = v
3745    else {
3746        return None;
3747    };
3748    if *empty {
3749        return None;
3750    }
3751    let key = match lower {
3752        None => i128::MIN,
3753        Some(b) => match b.as_ref() {
3754            Value::SmallInt(n) => i128::from(*n),
3755            Value::Int(n) => i128::from(*n),
3756            Value::BigInt(n) => i128::from(*n),
3757            Value::Date(n) => i128::from(*n),
3758            Value::Timestamp(n) => i128::from(*n),
3759            _ => return None,
3760        },
3761    };
3762    Some((key, u8::from(!*lower_inc)))
3763}
3764
3765/// v7.39 (round 215) — a per-table range-exclusion index: an incrementally
3766/// maintained map from a range column's lower-bound key
3767/// ([`range_excl_index_key`]) to the physical row locators carrying that
3768/// bound. Lets EXCLUDE enforcement find the few candidate rows a new range
3769/// might overlap in O(log n) instead of scanning every row (measured O(N²),
3770/// r213). Because the stored ranges under a valid `EXCLUDE (col WITH &&)`
3771/// are pairwise disjoint, a candidate overlaps only its predecessor or the
3772/// successors whose lower bound precedes its upper — a handful of probes.
3773///
3774/// NOT persisted: rebuilt from the (persisted) exclusion constraints + rows
3775/// on catalog load, exactly like BRIN re-derives. Backed by a
3776/// `PersistentBTreeMap` so `Table::clone` (the per-write snapshot) stays
3777/// O(1). Locators to tombstoned rows are left in place and filtered by the
3778/// consumer via `is_deleted()` at query time — the established index pattern.
3779#[derive(Debug, Clone)]
3780pub struct ExclRangeIndex {
3781    /// The constrained range column's position in the table.
3782    pub column_position: usize,
3783    /// Lower-bound key → row locators. A key maps to a `Vec` because a
3784    /// tombstoned-then-reinserted bound can transiently collide; live rows
3785    /// under the constraint are disjoint so each key has one live locator.
3786    pub map: PersistentBTreeMap<(i128, u8), Vec<RowLocator>>,
3787}
3788
3789#[derive(Debug, Clone)]
3790pub struct Table {
3791    schema: TableSchema,
3792    /// v7.37.15 (Phase C.1) — stable per-catalog relation identity.
3793    /// [`RelId::UNASSIGNED`](row_header::RelId::UNASSIGNED) until
3794    /// `Catalog::create_table` (or the deserialize dense-assign pass)
3795    /// stamps a real id. Keys the Phase C.4 row-lock table and the
3796    /// Phase C.5 `RelationStore`; survives `DROP TABLE` slot shifts.
3797    rel_id: row_header::RelId,
3798    rows: PersistentVec<Row<'static>>,
3799    /// v7.37.15 (Phase A.2) — per-row MVCC visibility headers
3800    /// parallel to `rows`. `headers.len() == rows.len()` is the
3801    /// load-bearing invariant; debug builds assert it on every
3802    /// scan boundary, release builds rely on it from
3803    /// disciplined insert / delete / update paths.
3804    ///
3805    /// Pre-v7.37.15-loaded tables (every row currently in the
3806    /// fleet) start as `RowHeader::frozen()` — `is_all_visible_fast()`
3807    /// returns `true`, so the per-row visibility gate Phase B
3808    /// adds is a no-op against any snapshot.
3809    ///
3810    /// Headers are NOT yet serialised into the envelope at this
3811    /// commit — on snapshot deserialize every row gets a fresh
3812    /// `RowHeader::frozen()`. Phase D adds the visibility-map
3813    /// + segment-freeze story which makes serialisation
3814    /// meaningful; until then the on-disk story is "the catalog
3815    /// is the set of visible rows."
3816    headers: PersistentVec<row_header::RowHeader>,
3817    /// v7.37.15 (Phase C.1) — stable per-relation row identity
3818    /// parallel to `rows` / `headers`. `rowids[i]` is the never-
3819    /// reused [`RowId`](row_header::RowId) of the row physically at
3820    /// slot `i`; `rowids.len() == rows.len()` joins the same load-
3821    /// bearing lock-step invariant as `headers`. Compaction (delete
3822    /// / vacuum) rebuilds all three vecs together so the id travels
3823    /// with the row while the slot shifts.
3824    ///
3825    /// Introduced additively: allocated + kept lock-step, but index
3826    /// locators still address rows by physical slot at this commit.
3827    /// Later phases migrate the lock table (C.4), HOT chains (D),
3828    /// and the WAL (Epic W) to address by `RowId`.
3829    ///
3830    /// Not yet serialised into the envelope — on load every row is
3831    /// assigned a fresh dense id `1..=len` (see `next_rowid`), which
3832    /// is sufficient while the id is process-local bookkeeping. The
3833    /// V6 envelope (Phase C.6) will persist ids so a WAL redo can
3834    /// name a row across restart.
3835    rowids: PersistentVec<row_header::RowId>,
3836    /// v7.37.15 (Phase C.1) — per-relation monotonic allocator for
3837    /// `rowids`. Starts at 1 (0 is the `RowId::UNASSIGNED` sentinel);
3838    /// every append takes `next_rowid` then increments. Never reused
3839    /// even after the row is deleted / vacuumed, so a stale lock /
3840    /// redo reference can be detected rather than silently aliasing a
3841    /// later row that reused the slot.
3842    next_rowid: u64,
3843    /// v7.37.16 (autovacuum) — live count of tombstoned-but-present hot
3844    /// rows (`headers[i].xmax != XMAX_ALIVE`). Maintained incrementally:
3845    /// `mark_row_deleted` / `mark_rows_deleted` increment (the only
3846    /// tombstone producers), `delete_rows_no_index` recomputes over the
3847    /// survivors (it is the compaction hub every physical removal —
3848    /// including vacuum — flows through), and the v53 snapshot loader
3849    /// recounts verbatim-restored headers. Drives the engine's
3850    /// autovacuum threshold; not persisted (recomputed on load).
3851    dead_rows: u64,
3852    /// v7.39 (pg_stat knife A) — volatile per-table write counters
3853    /// backing `pg_stat_user_tables.n_tup_ins/upd/del`. Not persisted
3854    /// (PG's cumulative stats are shared-memory-volatile too — a
3855    /// restart zeroes them).
3856    stat_tup_ins: u64,
3857    stat_tup_upd: u64,
3858    stat_tup_del: u64,
3859    /// v7.39 (pg_stat knife B) — volatile scan counters
3860    /// (`seq_scan/seq_tup_read/idx_scan/idx_tup_fetch`). Atomics: the
3861    /// read paths that bump them hold only `&Table`.
3862    scan_stats: ScanStats,
3863    /// v7.39 (pg_stat knife C) — wall-clock stamps (unix µs, from the
3864    /// host ClockFn) for pg_stat_user_tables' last_autovacuum /
3865    /// last_analyze. Volatile, like PG's cumulative stats. SPG has no
3866    /// manual-VACUUM statement semantics, so last_vacuum stays NULL.
3867    last_autovacuum_us: Option<i64>,
3868    last_analyze_us: Option<i64>,
3869    indices: Vec<Index>,
3870    hot_bytes: u64,
3871    /// v6.7.0 — cached count of rows currently materialised in the
3872    /// cold tier via `RowLocator::Cold` entries across THIS table's
3873    /// indices. Populated by `ANALYZE` (walks every BTree index and
3874    /// counts Cold locators); the count survives until the next
3875    /// ANALYZE recomputes it. Surfaced via `spg_statistic.cold_row_count`
3876    /// and `spg_stat_segment.table_name`.
3877    ///
3878    /// Honest scope: this is a CACHED count, not a live one.
3879    /// Freezer / promote / DELETE don't currently update the cache
3880    /// incrementally — they invalidate it by setting the
3881    /// `cold_row_count_stale` flag, and the next ANALYZE re-walks.
3882    /// Incremental maintenance is a v6.7.x candidate if observation
3883    /// shows the ANALYZE walk cost dominates.
3884    cold_row_count: u64,
3885    /// v6.7.0 — set when the cached `cold_row_count` may be wrong
3886    /// because rows moved into / out of the cold tier since the last
3887    /// ANALYZE. The virtual-table surface reports the cached value
3888    /// regardless (operators run ANALYZE to refresh).
3889    cold_row_count_stale: bool,
3890    /// v7.34 (crash-recovery P0 #2) — row-level redo capture buffer.
3891    /// `None` (default, in-memory mode) captures nothing — zero overhead.
3892    /// `Some` (set by the engine when persistence is on, before a
3893    /// mutating call) makes `insert` / `update_row` / `delete_rows`
3894    /// record the physical [`RowChange`] they applied, which the engine
3895    /// drains after the statement and writes to the WAL in place of the
3896    /// SQL text. Transient: never serialized; a `Catalog::clone` between
3897    /// enable and drain copies it (cheap — empty in the steady state).
3898    redo_log: Option<Vec<RowChange>>,
3899    /// v7.39 (round 215) — per-`EXCLUDE`-constraint range-overlap indexes,
3900    /// one per single-`&&` constraint on an integer-keyable range column.
3901    /// Maintained incrementally on insert / update / rebuild (mirroring the
3902    /// BTree secondary indexes); NOT serialized — rebuilt from the schema's
3903    /// exclusion constraints on load. Empty for tables with no EXCLUDE
3904    /// constraint (the common case), so `Table::clone` pays nothing.
3905    excl_indexes: Vec<ExclRangeIndex>,
3906    /// v7.39 (round 493) — the snapshot floor below which a deleted row
3907    /// version is invisible to everyone, as of the statement now running.
3908    ///
3909    /// Runtime only: never serialised, and `0` (the default) prunes
3910    /// nothing, so any path that forgets to set it is merely slower, not
3911    /// wrong. The engine sets it from `vacuum_oldest_active()` — the same
3912    /// floor `vacuum` itself takes — before the statement's inserts.
3913    prune_horizon: u64,
3914}
3915
3916/// Catalog: insertion-ordered `Vec<Table>` for stable iter / serialize,
3917/// plus a `BTreeMap<String, usize>` sidecar index so `get` / `get_mut`
3918/// run in O(log n) instead of the old linear scan with per-element
3919/// string compares.
3920///
3921/// A pure `BTreeMap<String, Table>` was tried in an interim version
3922/// of v3.1.2 and regressed the single-table catalog benches by ~10%
3923/// (the per-element `BTreeMap` overhead outweighs the lookup win
3924/// when n is small). The sidecar shape preserves the insertion-order
3925/// iteration the on-disk encoding relies on and keeps `last_mut`
3926/// (used by the deserialize hot path) cheap.
3927/// v7.39 (pg_stat blks knife) — catalog-wide cold-tier read counter
3928/// backing pg_stat_database.blks_read. Row-granular (SPG has no 8 KB
3929/// page notion): one cold-segment row resolution = one "block read",
3930/// one hot row access = one "block hit" — the hit RATIO monitoring
3931/// dashboards compute keeps its meaning. Volatile like PG's stats.
3932#[derive(Debug, Default)]
3933pub struct ColdReadStats {
3934    pub cold_reads: core::sync::atomic::AtomicU64,
3935}
3936
3937impl Clone for ColdReadStats {
3938    fn clone(&self) -> Self {
3939        Self {
3940            cold_reads: core::sync::atomic::AtomicU64::new(
3941                self.cold_reads.load(core::sync::atomic::Ordering::Relaxed),
3942            ),
3943        }
3944    }
3945}
3946
3947#[derive(Debug, Clone, Default)]
3948pub struct Catalog {
3949    /// v7.39 (pg_stat blks knife) — see [`ColdReadStats`].
3950    pub cold_read_stats: ColdReadStats,
3951    tables: Vec<Table>,
3952    /// `name → tables[index]`. Kept in lock-step with `tables`.
3953    /// `create_table` is the only write path.
3954    by_name: BTreeMap<String, usize>,
3955    /// v7.39 (round 436) — the current session's temporary-table namespace.
3956    /// A temp table is stored under `<prefix><name>`, and every lookup tries
3957    /// that first: exactly PG's `pg_temp` search-path rule, and MySQL's
3958    /// "a TEMPORARY table shadows a permanent one of the same name".
3959    ///
3960    /// Process-local, never serialised: the engine sets it per session, and
3961    /// a catalog read back from disk starts with none. Kept here rather than
3962    /// at each of the ~170 engine call sites because `by_name` is private —
3963    /// this is the ONE place a table name becomes an index.
3964    temp_prefix: Option<String>,
3965    /// v7.39 (round 496) — the names of tables this catalog handle has had
3966    /// changed since the set was last cleared.
3967    ///
3968    /// Runtime only, never serialised. A transaction's shadow catalog
3969    /// clears it at BEGIN, so at COMMIT the set is exactly the tables the
3970    /// transaction changed — which is what lets a commit that cannot use
3971    /// the row-level merge install only those tables instead of the whole
3972    /// catalog, leaving another session's concurrent work in place.
3973    ///
3974    /// Recorded where the change actually happens (`get_mut`,
3975    /// `create_table`, `drop_table`) rather than from the statement
3976    /// classifier: round 494 tried classification for a correctness gate
3977    /// and it was wrong, because `SELECT lo_write(…)` reads as read-only.
3978    dirty_tables: alloc::collections::BTreeSet<String>,
3979    /// v7.37.15 (Phase C.1) — monotonic allocator for stable
3980    /// [`RelId`](row_header::RelId)s. Pre-incremented on each
3981    /// `create_table` so real ids start at 1 (0 is `UNASSIGNED`);
3982    /// never reused even after `DROP TABLE`, so a stale lock / redo
3983    /// reference is detectable. Process-local bookkeeping — not yet
3984    /// serialised; `deserialize` re-assigns dense ids on load (the
3985    /// V6 envelope, Phase C.6, will round-trip real ids).
3986    next_rel_id: u64,
3987    /// v5.1: in-memory cold-tier segments. Side-loaded via
3988    /// [`Catalog::load_segment_bytes`] — they live outside the
3989    /// catalog snapshot (caller persists them as separate files
3990    /// and re-loads on boot, until v5.3's `CatalogManifest` makes
3991    /// that wiring automatic). `RowLocator::Cold { segment_id, .. }`
3992    /// indexes this `Vec`. Cleared on `Catalog::new` / fresh
3993    /// `deserialize`.
3994    ///
3995    /// `Arc` wrap keeps `Catalog::clone` at O(N segments) bumps
3996    /// (rather than O(total segment bytes) memcpy) so the v4.42
3997    /// group-commit pre-image rollback invariant — clone is
3998    /// effectively free — survives the cold-tier addition.
3999    ///
4000    /// v6.7.3 — slots became `Option<…>` so cold-segment compaction
4001    /// can tombstone merged sources without breaking the
4002    /// `segment_id = index_into_vec` contract that on-disk
4003    /// `RowLocator::Cold { segment_id }` already serialized.
4004    /// `None` slot = the segment was retired by compaction; the
4005    /// physical file may still be on disk (next CHECKPOINT writes
4006    /// a manifest that no longer lists it, and the file becomes
4007    /// an orphan eligible for offline cleanup).
4008    cold_segments: Vec<Option<Arc<OwnedSegment>>>,
4009    /// v7.12.4 — user-defined functions (PL/pgSQL + SQL).
4010    /// Keyed by function name (PG overloading is out of scope).
4011    /// Bodies are stored as the raw source text the parser saw
4012    /// between `$$ ... $$`; the engine re-parses on each
4013    /// invocation. This keeps `spg-storage` free of `spg-sql`
4014    /// dependency — same pattern as partial-index predicates.
4015    functions: BTreeMap<String, FunctionDef>,
4016    /// v7.12.4 — triggers in insertion order. PG18-measured (round
4017    /// 753): PG fires same-event triggers in NAME order (a_trig
4018    /// before z_trig regardless of creation order); SPG fires in
4019    /// insertion order — a real divergence, ledgered as F31-B2.
4020    triggers: Vec<TriggerDef>,
4021    /// v7.39 (round 139) — query-rewrite RULEs, flat like triggers.
4022    rules: Vec<RuleDef>,
4023    /// v7.39 (round 280) — extended-statistics objects. Recorded so a
4024    /// pg_dump restores them and reflection reports them; the planner
4025    /// does not consult them yet.
4026    statistics_ext: Vec<StatisticsExtDef>,
4027    /// v7.39 (round 287) — server-side large objects, keyed by OID.
4028    /// PG stores them as 2 KB pages in `pg_largeobject`; the page split
4029    /// is a storage detail of ITS heap, so SPG holds the whole byte
4030    /// string and renders the pages on read. What must match is the
4031    /// observable surface: the OIDs, the bytes, and the page rows.
4032    large_objects: alloc::collections::BTreeMap<u32, Vec<u8>>,
4033    /// v7.17.0 — catalogued SEQUENCE objects (Phase 1.1). Each
4034    /// `nextval(name)` reaches in here, atomically increments
4035    /// `last_value` / flips `is_called`, returns the new value.
4036    /// Persisted in catalog FILE_VERSION 26+; older catalogs
4037    /// deserialise with an empty map.
4038    sequences: BTreeMap<String, SequenceDef>,
4039    /// v7.39 (read01 round 60) — the `public` schema's ACL (PG
4040    /// `pg_namespace.nspacl`). EMPTY = PG's default, which is not "nothing":
4041    /// PUBLIC holds USAGE and the owner holds USAGE + CREATE. Materialised on
4042    /// the first GRANT / REVOKE, exactly like a table's relacl.
4043    schema_acl: Vec<AclItem>,
4044    /// v7.39 (read01 round 60) — the database's ACL. EMPTY = PG's default:
4045    /// PUBLIC holds CONNECT + TEMPORARY, the owner holds all three.
4046    database_acl: Vec<AclItem>,
4047    /// v7.17.0 — catalogued VIEW objects (Phase 1.2). Each
4048    /// `SELECT FROM v` at engine exec-time looks up `v` here and
4049    /// prepends the view body as a synthetic CTE. Persisted in
4050    /// catalog FILE_VERSION 27+; older catalogs deserialise with
4051    /// an empty map.
4052    views: BTreeMap<String, ViewDef>,
4053    /// v7.17.0 — catalogued MATERIALIZED VIEW source registry
4054    /// (Phase 1.3). Maps name → SELECT source. The materialised
4055    /// rows themselves live as a regular `Table` with the same
4056    /// name; REFRESH re-parses + re-executes the source against
4057    /// the table. Persisted in catalog FILE_VERSION 28+;
4058    /// older catalogs deserialise with an empty map.
4059    materialized_views: BTreeMap<String, String>,
4060    /// v7.17.0 — catalogued user-defined ENUM types (Phase 1.4).
4061    /// Maps name → label list. Columns reference these by name
4062    /// via `ColumnSchema.user_enum_type`. Persisted in catalog
4063    /// FILE_VERSION 29+; older catalogs deserialise with an empty
4064    /// map.
4065    enum_types: BTreeMap<String, EnumDef>,
4066    /// v7.17.0 — catalogued user-defined DOMAIN types (Phase 1.5).
4067    /// Maps name → base + CHECK constraints. Columns reference
4068    /// these by name via `ColumnSchema.user_domain_type`.
4069    /// Persisted in catalog FILE_VERSION 30+; older catalogs
4070    /// deserialise with an empty map.
4071    domain_types: BTreeMap<String, DomainDef>,
4072    /// v7.39 (read01 round 50) — `COMMENT ON <kind> <obj> IS '…'` store.
4073    /// Keyed by a canonical `"<kind>:<name>"` string (`"table:t"`,
4074    /// `"column:t.c"`, `"index:i"`, `"view:v"`, …) so a new commentable
4075    /// object kind needs no schema change. `COMMENT … IS NULL` removes the
4076    /// entry. Persisted in catalog FILE_VERSION 61+; older catalogs
4077    /// deserialise with an empty map. Read back by obj_description /
4078    /// col_description and the pg_description view.
4079    comments: BTreeMap<String, String>,
4080    /// v7.39 (round 547) — PG's `pg_db_role_setting`: the GUC defaults
4081    /// `ALTER ROLE … SET` / `ALTER DATABASE … SET` record, applied when
4082    /// a session starts.
4083    ///
4084    /// Keyed exactly as PG keys it — `(database, role)` where an empty
4085    /// name is PG's oid 0, meaning "all". So `ALTER ROLE ALL SET` is
4086    /// `("", "")`, `ALTER DATABASE d SET` is `(d, "")`, `ALTER ROLE r
4087    /// SET` is `("", r)` and `ALTER ROLE r IN DATABASE d SET` is
4088    /// `(d, r)`. The value is that scope's parameter list.
4089    db_role_settings: BTreeMap<(String, String), BTreeMap<String, String>>,
4090    /// v7.39 (round 550) — replication slots, by name.
4091    ///
4092    /// A slot in PG is two things: a named record, and a reservation
4093    /// that holds WAL back. SPG keeps the record — which is what every
4094    /// setup script and monitoring query reads — and reports
4095    /// `wal_status = 'unreserved'`, PG's own word for a slot that no
4096    /// longer holds WAL. The whole family used to answer NULL and
4097    /// report success, so `pg_drop_replication_slot('nosuchslot')` said
4098    /// it worked and a setup script created nothing.
4099    ///
4100    /// Value: (plugin, slot_type). `plugin` is empty for a physical slot.
4101    replication_slots: BTreeMap<String, (String, String)>,
4102    /// v7.37.42-T2 ζ-B — catalogued user-defined COMPOSITE types
4103    /// (`CREATE TYPE name AS (field_name field_type, …)`). Columns
4104    /// reference these by name via
4105    /// `ColumnSchema.user_composite_type` (parallel to
4106    /// `user_enum_type` / `user_domain_type`). Persisted in catalog
4107    /// FILE_VERSION 52+; older catalogs deserialise with an empty
4108    /// map.
4109    composite_types: BTreeMap<String, CompositeDef>,
4110    /// v7.17.0 — schema-namespace registry (Phase 1.6). Tracks
4111    /// which schemas exist. `public`, `pg_catalog`, and
4112    /// `information_schema` are built-in and always present.
4113    /// Schema-qualified table references still strip the prefix
4114    /// at lookup time per v7.16-and-earlier — full
4115    /// schema-as-isolation is v7.18+ scope. Persisted in catalog
4116    /// FILE_VERSION 31+; older catalogs deserialise with just
4117    /// the built-ins.
4118    schemas: alloc::collections::BTreeSet<String>,
4119}
4120
4121/// v7.12.4 — catalogued user-defined function. `body` is the raw
4122/// source text between `$$ ... $$`; the engine re-parses it on
4123/// invocation. This keeps the storage codec stable when the
4124/// PL/pgSQL surface grows (no breaking-change risk on the disk
4125/// format).
4126// v7.39 (round 322, V46) — no longer `Eq`: COST / ROWS are f64, as in PG.
4127#[derive(Debug, Clone, PartialEq)]
4128pub struct FunctionDef {
4129    pub name: String,
4130    /// Display form of the argument list, e.g.
4131    /// `"(name TEXT, ts TIMESTAMP)"`. Empty `"()"` for the trigger
4132    /// function shape. Parser-side canonicalised before storage.
4133    pub args_repr: String,
4134    /// Display form of the return type, e.g. `"TRIGGER"` /
4135    /// `"INT"` / `"SETOF text"`. The engine special-cases
4136    /// `"TRIGGER"` (case-insensitive) to gate trigger-only
4137    /// semantics (NEW/OLD).
4138    pub returns: String,
4139    /// `LANGUAGE` clause, lowercased. `"plpgsql"` / `"sql"`.
4140    pub language: String,
4141    /// Source body of the function. PL/pgSQL: includes the
4142    /// surrounding `BEGIN ... END;`. SQL: includes the
4143    /// statement(s). The engine re-parses on invocation; bad
4144    /// bodies surface as a parse error at CALL time, not CREATE.
4145    pub body: String,
4146    /// v7.39 (read01 round 61) — the role that ran CREATE FUNCTION.
4147    pub owner: Option<String>,
4148    /// v7.39 (read01 round 61) — explicit GRANTs (PG `pg_proc.proacl`). EMPTY
4149    /// is NOT "nobody may call it": PG grants EXECUTE to PUBLIC by default, and
4150    /// leaves proacl NULL to say so. The list materialises on the first
4151    /// GRANT / REVOKE.
4152    pub acl: Vec<AclItem>,
4153    /// v7.39 (round 322, V46) — `IMMUTABLE` / `STRICT` / `PARALLEL SAFE` /
4154    /// `SECURITY DEFINER` / `LEAKPROOF` / `COST` / `ROWS`. `strict` is the
4155    /// only one with execution semantics today (a NULL argument yields a
4156    /// NULL result without running the body); the rest are recorded so
4157    /// `pg_get_functiondef` and `pg_proc` report what was declared.
4158    pub volatility: u8,
4159    pub strict: bool,
4160    pub security_definer: bool,
4161    pub leakproof: bool,
4162    pub parallel: u8,
4163    pub cost: Option<f64>,
4164    pub rows: Option<f64>,
4165}
4166
4167/// v7.39 (round 322, V46) — `FunctionDef.volatility` codes: PG's
4168/// `pg_proc.provolatile` letters.
4169pub const FN_VOLATILE: u8 = b'v';
4170pub const FN_IMMUTABLE: u8 = b'i';
4171pub const FN_STABLE: u8 = b's';
4172
4173/// v7.39 (round 322, V46) — `FunctionDef.parallel` codes: PG's
4174/// `pg_proc.proparallel` letters.
4175pub const FN_PARALLEL_UNSAFE: u8 = b'u';
4176pub const FN_PARALLEL_RESTRICTED: u8 = b'r';
4177pub const FN_PARALLEL_SAFE: u8 = b's';
4178
4179/// v7.39 (round 315, V19) — which catalogued function does a persisted
4180/// ACL key refer to?
4181///
4182/// The key was computed by whichever formula was current when the image
4183/// was written, and the multi-word fix changed that formula for bare
4184/// types like `double precision`. A miss therefore does NOT mean "no
4185/// such function": an older image's key would land nowhere and its owner
4186/// and grants would be dropped in silence. Exact match first, then the
4187/// pre-fix formula.
4188#[must_use]
4189pub fn resolve_stored_function_key(
4190    functions: &BTreeMap<String, FunctionDef>,
4191    stored: &str,
4192) -> Option<String> {
4193    if functions.contains_key(stored) {
4194        return Some(stored.to_string());
4195    }
4196    functions
4197        .values()
4198        .find(|f| function_signature_key_legacy(&f.name, &f.args_repr) == stored)
4199        .map(|f| function_signature_key(&f.name, &f.args_repr))
4200}
4201
4202/// v7.39 (round 344, V49) — re-exported from [`spg_sql`], which owns the
4203/// SQL type spellings. This crate carried a byte-identical copy because
4204/// the two were siblings that did not depend on each other; spg-sql is a
4205/// dependency-free leaf, so the dependency is acyclic and the publish
4206/// order already puts it first. One list, one place to keep it right.
4207pub use spg_sql::parser::is_multiword_type_phrase;
4208
4209/// v7.39 (round 315, V19) — the signature key as computed BEFORE the
4210/// multi-word fix, used only to recognise what an older image wrote.
4211///
4212/// The function catalogue recomputes its keys from the stored name and
4213/// argument text on load, so it needs no migration. The ACL block does
4214/// not: it persists the computed key as a string and matches on it. A
4215/// key that changed shape would simply fail to match, and the owner and
4216/// grants would be dropped without a word — so the loader falls back to
4217/// this when the stored key finds nothing.
4218#[must_use]
4219pub fn function_signature_key_legacy(name: &str, args_repr: &str) -> String {
4220    let inner = args_repr
4221        .trim()
4222        .trim_start_matches('(')
4223        .trim_end_matches(')');
4224    let types: Vec<String> = if inner.trim().is_empty() {
4225        Vec::new()
4226    } else {
4227        inner
4228            .split(',')
4229            .map(|part| {
4230                let mut words: Vec<&str> = part.split_whitespace().collect();
4231                if !words.is_empty()
4232                    && (words[0].eq_ignore_ascii_case("OUT")
4233                        || words[0].eq_ignore_ascii_case("INOUT"))
4234                {
4235                    words.remove(0);
4236                }
4237                let ty = if words.len() >= 2 {
4238                    words[1..].join(" ")
4239                } else {
4240                    words.first().map_or(String::new(), |w| (*w).to_string())
4241                };
4242                normalize_type_name(&ty)
4243            })
4244            .collect()
4245    };
4246    format!("{}({})", name.to_ascii_lowercase(), types.join(","))
4247}
4248
4249pub fn function_signature_key(name: &str, args_repr: &str) -> String {
4250    let types = function_arg_types(args_repr);
4251    format!("{}({})", name.to_ascii_lowercase(), types.join(","))
4252}
4253
4254/// The declared argument TYPES of a function, out of its `args_repr`
4255/// (`"(x INT, y DOUBLE PRECISION)"` → `["int", "float"]`). An entry may be a
4256/// bare type with no name (`"(INT)"`).
4257#[must_use]
4258pub fn function_arg_types(args_repr: &str) -> Vec<String> {
4259    let inner = args_repr
4260        .trim()
4261        .trim_start_matches('(')
4262        .trim_end_matches(')');
4263    if inner.trim().is_empty() {
4264        return Vec::new();
4265    }
4266    inner
4267        .split(',')
4268        .map(|part| {
4269            let mut words: Vec<&str> = part.split_whitespace().collect();
4270            // `OUT x INT` / `INOUT x INT` — the mode is not part of the type.
4271            if !words.is_empty()
4272                && (words[0].eq_ignore_ascii_case("OUT") || words[0].eq_ignore_ascii_case("INOUT"))
4273            {
4274                words.remove(0);
4275            }
4276            // v7.39 (round 315, V19) — two or more words is USUALLY
4277            // `name TYPE`, but not when the type itself is spelled in
4278            // several words. `double precision` was read as a parameter
4279            // named "double" of type "precision", so it keyed differently
4280            // from `x double precision` — the same signature written two
4281            // ways did not resolve to the same function. Decide by asking
4282            // whether the whole phrase names a type first; only then is
4283            // the leading word a parameter name.
4284            let whole = words.join(" ");
4285            let ty = if words.len() >= 2 && !is_multiword_type_phrase(&whole) {
4286                words[1..].join(" ")
4287            } else {
4288                whole
4289            };
4290            normalize_type_name(&ty)
4291        })
4292        .collect()
4293}
4294
4295/// v7.39 (read01 round 65) — the declared argument NAMES of a function (`""` for
4296/// a bare type with no name).
4297#[must_use]
4298pub fn function_arg_names(args_repr: &str) -> Vec<String> {
4299    let inner = args_repr
4300        .trim()
4301        .trim_start_matches('(')
4302        .trim_end_matches(')');
4303    if inner.trim().is_empty() {
4304        return Vec::new();
4305    }
4306    inner
4307        .split(',')
4308        .map(|part| {
4309            let mut words: Vec<&str> = part.split_whitespace().collect();
4310            if !words.is_empty()
4311                && (words[0].eq_ignore_ascii_case("OUT") || words[0].eq_ignore_ascii_case("INOUT"))
4312            {
4313                words.remove(0);
4314            }
4315            if words.len() >= 2 {
4316                words[0].to_string()
4317            } else {
4318                String::new()
4319            }
4320        })
4321        .collect()
4322}
4323
4324/// Fold PG's type aliases so a signature key is stable across spellings.
4325/// Unknown names pass through lower-cased — consistency is what the key needs.
4326#[must_use]
4327pub fn normalize_type_name(ty: &str) -> String {
4328    let t = ty.trim().to_ascii_lowercase();
4329    // Peel a precision/length modifier: `numeric(10,2)`, `varchar(64)`.
4330    let base = t.split_once('(').map_or(t.as_str(), |(h, _)| h).trim();
4331    match base {
4332        "int" | "int4" | "integer" => "int",
4333        "bigint" | "int8" => "bigint",
4334        "smallint" | "int2" => "smallint",
4335        "text" | "varchar" | "character varying" | "char" | "character" | "bpchar" => "text",
4336        "bool" | "boolean" => "bool",
4337        "float" | "float8" | "double precision" => "float",
4338        "real" | "float4" => "real",
4339        "numeric" | "decimal" => "numeric",
4340        "timestamptz" | "timestamp with time zone" => "timestamptz",
4341        "timestamp" | "timestamp without time zone" => "timestamp",
4342        other => other,
4343    }
4344    .to_string()
4345}
4346
4347/// v7.12.4 — catalogued trigger. References its function by
4348/// name; the function must exist at TRIGGER creation time
4349/// (forward references are deferred to v7.12.5+).
4350#[derive(Debug, Clone, PartialEq, Eq)]
4351pub struct TriggerDef {
4352    pub name: String,
4353    /// Watched table. Trigger is dropped when the table drops.
4354    pub table: String,
4355    /// `"BEFORE"` / `"AFTER"` / `"INSTEAD OF"`. Stored as the
4356    /// uppercased keyword so deserialised catalogs round-trip
4357    /// without canonicalisation surprises.
4358    pub timing: String,
4359    /// Each entry is one of `"INSERT"` / `"UPDATE"` / `"DELETE"`
4360    /// / `"TRUNCATE"`. `INSERT OR UPDATE` parses to two entries.
4361    pub events: Vec<String>,
4362    /// `"ROW"` / `"STATEMENT"`. v7.12.4 ships `"ROW"` only;
4363    /// `"STATEMENT"` parses and persists but the executor
4364    /// refuses it at trigger fire time.
4365    pub for_each: String,
4366    /// Name of the PL/pgSQL function to invoke.
4367    pub function: String,
4368    /// v7.13.0 — `UPDATE OF col, col, …` column-list filter
4369    /// (mailrs round-5 G7). Non-empty means the trigger fires
4370    /// only when at least one of these columns appears in the
4371    /// UPDATE's SET list. Empty = no column filter. Stored in
4372    /// catalog FILE_VERSION 23+; older catalogs deserialise with
4373    /// an empty vec.
4374    pub update_columns: Vec<String>,
4375    /// v7.16.1 — whether the trigger fires when its watched
4376    /// event occurs. Toggled by `ALTER TABLE … { ENABLE |
4377    /// DISABLE } TRIGGER …`; pg_dump --disable-triggers wraps
4378    /// every data block with a DISABLE/ENABLE pair so the
4379    /// rows already-computed in prod don't get re-rewritten.
4380    /// Defaults to `true` at CREATE TRIGGER time. Stored in
4381    /// catalog FILE_VERSION 25+; older catalogs deserialise
4382    /// with `enabled = true`.
4383    pub enabled: bool,
4384    /// v7.39 (round 138) — the deparsed `WHEN ( condition )` predicate text
4385    /// (re-parsed at fire time to filter row triggers). Empty = no WHEN.
4386    /// Persisted from FILE_VERSION 70; older catalogs read back empty.
4387    pub when_condition: String,
4388}
4389
4390/// v7.39 (round 280) — one `CREATE STATISTICS` object.
4391#[derive(Debug, Clone, PartialEq, Eq)]
4392pub struct StatisticsExtDef {
4393    pub name: String,
4394    pub table: String,
4395    /// PG's single-letter kinds: `d` ndistinct, `f` dependencies,
4396    /// `m` mcv. PG's default set is all three.
4397    pub kinds: Vec<String>,
4398    pub columns: Vec<String>,
4399}
4400
4401/// v7.39 (round 139) — a catalogued query-rewrite RULE. Stored flat like
4402/// `TriggerDef`, keyed by `(name, table)`. Command / WHEN text is deparsed SQL
4403/// re-parsed at rewrite time (the same round-trip trick as
4404/// `TriggerDef.when_condition`). Persisted from FILE_VERSION 71.
4405#[derive(Debug, Clone, PartialEq, Eq)]
4406pub struct RuleDef {
4407    pub name: String,
4408    pub table: String,
4409    /// Event keyword, uppercased: `INSERT` / `UPDATE` / `DELETE` / `SELECT`.
4410    pub event: String,
4411    /// `true` = `DO INSTEAD`, `false` = `DO ALSO`.
4412    pub instead: bool,
4413    /// Deparsed `WHERE` predicate text; empty = unconditional.
4414    pub when_condition: String,
4415    /// Deparsed DO command statements; empty = `NOTHING`.
4416    pub commands: Vec<String>,
4417}
4418
4419/// v7.17.0 — catalogued SEQUENCE. PG semantics: a counter object
4420/// returning monotonically increasing values via `nextval(name)`.
4421/// `last_value` is the most recent value handed out; `is_called`
4422/// is false until the first `nextval`/`setval`. Stored separately
4423/// from tables in the catalog.
4424#[derive(Debug, Clone, PartialEq, Eq)]
4425pub struct SequenceDef {
4426    pub name: String,
4427    /// Data type — narrows the i64 range. PG default BIGINT.
4428    pub data_type: SequenceDataType,
4429    pub start: i64,
4430    pub increment: i64,
4431    pub min_value: i64,
4432    pub max_value: i64,
4433    pub cache: i64,
4434    pub cycle: bool,
4435    /// `OWNED BY` target — `(table, column)` or NONE.
4436    pub owned_by: Option<(String, String)>,
4437    /// Most recently handed-out value. Meaningless when
4438    /// `is_called == false`; in that case the NEXT `nextval`
4439    /// will return `start`.
4440    pub last_value: i64,
4441    pub is_called: bool,
4442    /// v7.39 (read01 round 60) — the role that ran CREATE SEQUENCE. `None` = an
4443    /// image written before FILE_VERSION 66, which predates sequence owners.
4444    pub owner: Option<String>,
4445    /// v7.39 (read01 round 60) — explicit GRANTs on this sequence. A sequence's
4446    /// meaningful privileges are SELECT (`currval`), UPDATE (`setval`) and
4447    /// USAGE (`nextval`).
4448    pub acl: Vec<AclItem>,
4449}
4450
4451/// v7.17.0 — sequence integer width.
4452#[derive(Debug, Clone, Copy, PartialEq, Eq)]
4453pub enum SequenceDataType {
4454    SmallInt,
4455    Int,
4456    BigInt,
4457}
4458
4459/// v7.17.0 Phase 1.6 — built-in schema names that every Catalog
4460/// understands without an explicit CREATE SCHEMA. Used by
4461/// [`Catalog::schema_exists`] and the engine's schema-qualified
4462/// lookup path.
4463#[must_use]
4464pub fn is_builtin_schema(name: &str) -> bool {
4465    name.eq_ignore_ascii_case("public")
4466        || name.eq_ignore_ascii_case("pg_catalog")
4467        || name.eq_ignore_ascii_case("information_schema")
4468}
4469
4470/// v7.17.0 — parse a PG-canonical UUID text representation into the
4471/// 16-byte network-order layout used by `Value::Uuid`. Accepted input
4472/// shapes (all case-insensitive):
4473///   * Canonical hyphenated 8-4-4-4-12 (`550e8400-e29b-41d4-a716-446655440000`)
4474///   * Unhyphenated 32-char hex (`550e8400e29b41d4a716446655440000`)
4475///   * Either form wrapped in `{ ... }`
4476///
4477/// Returns `None` for any malformed input (wrong length, non-hex
4478/// characters, misplaced hyphens). The caller surfaces a SQL error
4479/// at coercion time — silent acceptance of garbage would mask
4480/// application bugs and is exactly the divergence from PG that
4481/// breaks the 0-change cutover promise.
4482#[must_use]
4483pub fn parse_uuid_str(input: &str) -> Option<[u8; 16]> {
4484    let s = input.trim();
4485    // Strip surrounding braces if present.
4486    let s = if let Some(inner) = s.strip_prefix('{').and_then(|x| x.strip_suffix('}')) {
4487        inner
4488    } else {
4489        s
4490    };
4491    // Two valid shapes after braces are stripped: 32 hex chars or
4492    // the canonical 36-char hyphenated form.
4493    let hex: String = match s.len() {
4494        32 => s.to_ascii_lowercase(),
4495        36 => {
4496            // Hyphens must be exactly at positions 8, 13, 18, 23.
4497            let b = s.as_bytes();
4498            if b[8] != b'-' || b[13] != b'-' || b[18] != b'-' || b[23] != b'-' {
4499                return None;
4500            }
4501            let mut out = String::with_capacity(32);
4502            out.push_str(&s[0..8]);
4503            out.push_str(&s[9..13]);
4504            out.push_str(&s[14..18]);
4505            out.push_str(&s[19..23]);
4506            out.push_str(&s[24..36]);
4507            out.make_ascii_lowercase();
4508            out
4509        }
4510        _ => return None,
4511    };
4512    let bytes = hex.as_bytes();
4513    let mut out = [0u8; 16];
4514    for i in 0..16 {
4515        let hi = hex_nibble(bytes[i * 2])?;
4516        let lo = hex_nibble(bytes[i * 2 + 1])?;
4517        out[i] = (hi << 4) | lo;
4518    }
4519    Some(out)
4520}
4521
4522fn hex_nibble(b: u8) -> Option<u8> {
4523    match b {
4524        b'0'..=b'9' => Some(b - b'0'),
4525        b'a'..=b'f' => Some(10 + b - b'a'),
4526        b'A'..=b'F' => Some(10 + b - b'A'),
4527        _ => None,
4528    }
4529}
4530
4531/// v7.17.0 — render a `Value::Uuid` payload as the canonical
4532/// lowercase 8-4-4-4-12 hyphenated form PG `text` cast surfaces.
4533#[must_use]
4534pub fn format_uuid(b: &[u8; 16]) -> String {
4535    const HEX: &[u8; 16] = b"0123456789abcdef";
4536    let mut out = String::with_capacity(36);
4537    for (i, byte) in b.iter().enumerate() {
4538        if matches!(i, 4 | 6 | 8 | 10) {
4539            out.push('-');
4540        }
4541        out.push(HEX[(byte >> 4) as usize] as char);
4542        out.push(HEX[(byte & 0x0f) as usize] as char);
4543    }
4544    out
4545}
4546
4547/// v7.17.0 Phase 1.5 — catalogued user-defined DOMAIN. A domain
4548/// is a named CHECK-constrained alias over a built-in type;
4549/// columns bound to it inherit the base type plus the CHECK
4550/// predicates + NOT NULL + DEFAULT at INSERT/UPDATE time.
4551/// v7.37.17 (Phase E RC rebase) — the write-set one writer version left
4552/// on a table, addressed by stable [`row_header::RowId`]s so it can be
4553/// replayed onto a fresher clone of the relation whose physical slots
4554/// differ. Produced by [`Table::extract_tx_writeset`], consumed by
4555/// [`Table::replay_tx_writeset`].
4556#[derive(Debug, Clone, Default)]
4557pub struct TxWriteSet {
4558    /// INSERTs and UPDATE-new-versions (`header.xmin == v`).
4559    pub inserted: Vec<(row_header::RowId, Row<'static>)>,
4560    /// DELETE / UPDATE-old-version targets (`header.xmax == v`).
4561    pub tombstoned: Vec<row_header::RowId>,
4562}
4563
4564impl TxWriteSet {
4565    #[must_use]
4566    pub fn is_empty(&self) -> bool {
4567        self.inserted.is_empty() && self.tombstoned.is_empty()
4568    }
4569}
4570
4571/// v7.39 (round 260) — one named CHECK on a domain. PG auto-names an
4572/// unnamed one `<domain>_check`, then `_check1`, `_check2`, … (probed).
4573#[derive(Debug, Clone, PartialEq, Eq)]
4574pub struct DomainCheck {
4575    pub name: String,
4576    /// The predicate source, referencing the pseudo-column `VALUE`.
4577    pub expr: String,
4578}
4579
4580/// `default` / `checks` are stored as Display-form source so
4581/// `spg-storage` stays free of `spg-sql` dependency — same
4582/// pattern as FunctionDef / ViewDef.
4583#[derive(Debug, Clone, PartialEq, Eq)]
4584pub struct DomainDef {
4585    pub name: String,
4586    pub base_type: DataType,
4587    pub nullable: bool,
4588    pub default: Option<String>,
4589    /// v7.39 (round 260) — each CHECK carries its constraint NAME, so
4590    /// `ALTER DOMAIN … DROP CONSTRAINT <name>` can find it and the
4591    /// violation message can report the constraint that actually failed.
4592    /// PG's auto-naming for an unnamed check is `<domain>_check`, then
4593    /// `_check1`, `_check2`, … (probed).
4594    pub checks: Vec<DomainCheck>,
4595    /// v7.39 (round 258/259) — when this domain was declared over ANOTHER
4596    /// domain (`CREATE DOMAIN child AS parent CHECK (…)`), the parent's
4597    /// name. `base_type` is the ultimate scalar type either way, so
4598    /// without this the parent's constraints were invisible and a value
4599    /// violating them was silently accepted. PG checks the whole chain,
4600    /// base-first, and an `ALTER DOMAIN` on the parent takes effect for
4601    /// the child immediately (probed) — so the chain is walked at check
4602    /// time rather than copied at CREATE time. Catalog FILE_VERSION 74+.
4603    pub base_domain: Option<String>,
4604}
4605
4606/// v7.17.0 Phase 1.4 — catalogued user-defined ENUM type. The
4607/// label vector is order-preserving (PG enum ordering follows the
4608/// declared order). At INSERT/UPDATE on a column bound to this
4609/// enum, the engine looks up the value against `labels` and
4610/// rejects non-members.
4611#[derive(Debug, Clone, PartialEq, Eq)]
4612pub struct EnumDef {
4613    pub name: String,
4614    pub labels: Vec<String>,
4615}
4616
4617/// v7.37.42-T2 ζ-B — catalogued user-defined COMPOSITE type
4618/// (`CREATE TYPE name AS (field_name field_type, ...)`). Order
4619/// matters: PG composite literals are positional, and SPG mirrors
4620/// that. Stored as ordered `(name, DataType)` pairs to keep the
4621/// codec straightforward and to allow eventual `Value::Composite`
4622/// bodies to encode positionally. Persisted in catalog FILE_VERSION
4623/// 52+; older catalogs deserialise with an empty composite_types
4624/// map. Composite types can be used as a column type by spelling
4625/// the composite's name; the resolution from
4626/// `ColumnSchema.user_composite_type = Some(name)` happens at the
4627/// engine boundary (parallel to `user_enum_type` /
4628/// `user_domain_type`). The dense storage shape — JSON-text body
4629/// keyed by the composite's field list — keeps the codec free of
4630/// recursive `Value` bodies until the full Value::Composite arena
4631/// migration in a later phase.
4632#[derive(Debug, Clone, PartialEq, Eq)]
4633pub struct CompositeDef {
4634    pub name: String,
4635    /// Ordered `(field_name, field_type)` pairs. PG composite
4636    /// literals are positional, so order is part of the type's
4637    /// identity.
4638    pub fields: Vec<(String, DataType)>,
4639    /// v7.39 (round 264) — parallel to `fields`: the USER type name of
4640    /// each field when it is itself a composite (or another named user
4641    /// type). `DataType` has no room for one, so a nested composite
4642    /// field resolved to the parser's Text placeholder and the inner
4643    /// record stayed TEXT — `(x).inner.street` errored, `pg_typeof`
4644    /// said text, and `row_to_json` nested a string instead of an
4645    /// object. Same shape as `ColumnSchema.user_composite_type` and
4646    /// `DomainDef.base_domain`. Catalog FILE_VERSION 76+; an older
4647    /// catalog reads all-None, which is what it meant.
4648    pub field_user_types: Vec<Option<String>>,
4649}
4650
4651/// v7.17.0 Phase 1.2 — catalogued VIEW. The body is stored as the
4652/// raw source text the parser saw between `AS` and the statement
4653/// terminator; the engine re-parses on each invocation. Same
4654/// pattern as `FunctionDef` — keeps `spg-storage` free of
4655/// `spg-sql` dependency.
4656#[derive(Debug, Clone, PartialEq, Eq)]
4657pub struct ViewDef {
4658    pub name: String,
4659    /// Optional `(col, col, …)` rename list. Empty when the body's
4660    /// projected names are used directly.
4661    pub columns: Vec<String>,
4662    /// Raw SELECT source. Display-rendered at storage time so the
4663    /// catalog round-trips a deterministic form regardless of
4664    /// whitespace / comments in the original input. Re-parsed at
4665    /// SELECT-from-view time to materialise as a synthetic CTE.
4666    pub body: String,
4667    /// v7.39 (round 132) — `WITH CHECK OPTION`: 0 = none, 1 = LOCAL,
4668    /// 2 = CASCADED. A storage-local u8 (no dependency on the SQL AST).
4669    /// Persisted from FILE_VERSION 69; older catalogs read back as 0.
4670    pub check_option: u8,
4671}
4672
4673impl SequenceDataType {
4674    /// PG default min/max per AS clause.
4675    pub fn default_bounds(self, increment_positive: bool) -> (i64, i64) {
4676        match self {
4677            Self::SmallInt => {
4678                if increment_positive {
4679                    (1, i64::from(i16::MAX))
4680                } else {
4681                    (i64::from(i16::MIN), -1)
4682                }
4683            }
4684            Self::Int => {
4685                if increment_positive {
4686                    (1, i64::from(i32::MAX))
4687                } else {
4688                    (i64::from(i32::MIN), -1)
4689                }
4690            }
4691            Self::BigInt => {
4692                if increment_positive {
4693                    (1, i64::MAX)
4694                } else {
4695                    (i64::MIN, -1)
4696                }
4697            }
4698        }
4699    }
4700}
4701
4702impl Catalog {
4703    /// v7.37.15 (Phase D) — fleet-wide vacuum pass. Walks every
4704    /// user table and reclaims rows whose delete-commit version is
4705    /// older than `oldest_active_snapshot`. Returns an aggregated
4706    /// report with per-table breakdown so hosts can emit metrics.
4707    ///
4708    /// `dry_run = true` reports the work without doing it. Use it
4709    /// to estimate the cost before scheduling a real pass.
4710    pub fn vacuum_all(
4711        &mut self,
4712        oldest_active_snapshot: u64,
4713        dry_run: bool,
4714    ) -> vacuum::VacuumReport {
4715        let mut total = vacuum::VacuumReport::default();
4716        // Snapshot the table names so we don't hold an immutable
4717        // borrow during the get_mut loop.
4718        let names: Vec<String> = self
4719            .tables
4720            .iter()
4721            .map(|t| t.schema().name.clone())
4722            .collect();
4723        for name in names {
4724            let Some(t) = self.get_mut(&name) else {
4725                continue;
4726            };
4727            let r = t.vacuum(oldest_active_snapshot, dry_run);
4728            if r.rows_reclaimed > 0 {
4729                total.per_table.push((name, r.rows_reclaimed));
4730            }
4731            total.rows_reclaimed += r.rows_reclaimed;
4732            total.rows_examined += r.rows_examined;
4733        }
4734        total
4735    }
4736
4737    pub const fn new() -> Self {
4738        Self {
4739            cold_read_stats: ColdReadStats {
4740                cold_reads: core::sync::atomic::AtomicU64::new(0),
4741            },
4742            tables: Vec::new(),
4743            by_name: BTreeMap::new(),
4744            temp_prefix: None,
4745            dirty_tables: alloc::collections::BTreeSet::new(),
4746            next_rel_id: 0,
4747            cold_segments: Vec::new(),
4748            functions: BTreeMap::new(),
4749            triggers: Vec::new(),
4750            rules: Vec::new(),
4751            statistics_ext: Vec::new(),
4752            large_objects: alloc::collections::BTreeMap::new(),
4753            sequences: BTreeMap::new(),
4754            schema_acl: Vec::new(),
4755            database_acl: Vec::new(),
4756            views: BTreeMap::new(),
4757            materialized_views: BTreeMap::new(),
4758            enum_types: BTreeMap::new(),
4759            domain_types: BTreeMap::new(),
4760            comments: BTreeMap::new(),
4761            db_role_settings: BTreeMap::new(),
4762            replication_slots: BTreeMap::new(),
4763            composite_types: BTreeMap::new(),
4764            schemas: alloc::collections::BTreeSet::new(),
4765        }
4766    }
4767
4768    /// v7.12.4 — read-only view of catalogued user-defined
4769    /// functions. Engine callers go through here to look up the
4770    /// function body before re-parsing it for invocation.
4771    pub const fn functions(&self) -> &BTreeMap<String, FunctionDef> {
4772        &self.functions
4773    }
4774
4775    /// v7.12.4 — register a new user-defined function. With
4776    /// `or_replace = false`, errors if the name is taken. The
4777    /// engine validates the body before passing it here.
4778    pub fn create_function(
4779        &mut self,
4780        def: FunctionDef,
4781        or_replace: bool,
4782    ) -> Result<(), StorageError> {
4783        // v7.39 (read01 round 62) — functions are keyed by SIGNATURE, not by
4784        // name: `f(int)` and `f(text)` are two functions, as in PG. Keying by
4785        // name alone made a second overload an "already exists" error — so a
4786        // pg_dump carrying an overload set could not restore — and, worse, a
4787        // call to one overload silently ran the other.
4788        let key = function_signature_key(&def.name, &def.args_repr);
4789        if !or_replace && self.functions.contains_key(&key) {
4790            return Err(StorageError::Corrupt(format!(
4791                "function {:?} already exists (drop or use CREATE OR REPLACE)",
4792                def.name
4793            )));
4794        }
4795        self.functions.insert(key, def);
4796        Ok(())
4797    }
4798
4799    /// v7.39 (read01 round 62) — every overload of `name`.
4800    #[must_use]
4801    pub fn functions_named(&self, name: &str) -> Vec<&FunctionDef> {
4802        self.functions
4803            .values()
4804            .filter(|f| f.name.eq_ignore_ascii_case(name))
4805            .collect()
4806    }
4807
4808    /// v7.39 (read01 round 62) — one overload, by its signature key.
4809    #[must_use]
4810    pub fn function_by_key(&self, key: &str) -> Option<&FunctionDef> {
4811        self.functions.get(key)
4812    }
4813
4814    /// v7.39 (read01 round 62) — drop ONE overload. `true` if it was there.
4815    pub fn drop_function_by_key(&mut self, key: &str) -> bool {
4816        self.functions.remove(key).is_some()
4817    }
4818
4819    /// v7.12.4 — remove a user-defined function by name. Returns
4820    /// `true` if a function was removed, `false` if none matched.
4821    /// Caller decides whether to surface `if_exists` semantics.
4822    /// v7.39 (read01 round 62) — with no signature, PG drops the function only
4823    /// when the name is unambiguous. SPG mirrors that: this removes EVERY
4824    /// overload of `name`, and the caller (ddl.rs) refuses the ambiguous case
4825    /// before getting here.
4826    pub fn drop_function(&mut self, name: &str) -> bool {
4827        let keys: Vec<String> = self
4828            .functions
4829            .iter()
4830            .filter(|(_, f)| f.name.eq_ignore_ascii_case(name))
4831            .map(|(k, _)| k.clone())
4832            .collect();
4833        let hit = !keys.is_empty();
4834        for k in keys {
4835            self.functions.remove(&k);
4836        }
4837        hit
4838    }
4839
4840    /// v7.17.0 — read-only handle to catalogued sequences.
4841    /// v7.39 (read01 round 60) — the `public` schema's ACL (PG nspacl).
4842    #[must_use]
4843    pub fn schema_acl(&self) -> &[AclItem] {
4844        &self.schema_acl
4845    }
4846
4847    pub fn schema_acl_mut(&mut self) -> &mut Vec<AclItem> {
4848        &mut self.schema_acl
4849    }
4850
4851    /// v7.39 (read01 round 60) — the database's ACL.
4852    #[must_use]
4853    pub fn database_acl(&self) -> &[AclItem] {
4854        &self.database_acl
4855    }
4856
4857    pub fn database_acl_mut(&mut self) -> &mut Vec<AclItem> {
4858        &mut self.database_acl
4859    }
4860
4861    /// v7.39 (read01 round 60) — mutable sequence access, for GRANT.
4862    /// v7.39 (round 469) — resolves the session's temporary sequence
4863    /// first, like its read-only twin. `nextval` and `setval` reach the
4864    /// map through here, so a temporary sequence shadowing a permanent one
4865    /// advances the temporary one — measured against PG18, where the
4866    /// permanent sequence's counter is untouched while the temp exists.
4867    pub fn sequence_mut(&mut self, name: &str) -> Option<&mut SequenceDef> {
4868        let key = self.sequence_key(name);
4869        self.sequences.get_mut(&key)
4870    }
4871
4872    /// v7.39 (read01 round 61) — mutable function access, for GRANT.
4873    pub fn function_mut(&mut self, name: &str) -> Option<&mut FunctionDef> {
4874        self.functions.get_mut(name)
4875    }
4876
4877    /// Every catalogued sequence, temp ones included under their mangled
4878    /// storage names. Listing code filters these through
4879    /// [`Self::listed_name`]; anything resolving ONE name by its logical
4880    /// spelling wants [`Self::sequence`] instead.
4881    pub const fn sequences_all(&self) -> &BTreeMap<String, SequenceDef> {
4882        &self.sequences
4883    }
4884
4885    /// v7.39 (round 469) — resolve one sequence by its logical name, the
4886    /// session's temporary one winning over a permanent one of the same
4887    /// name. The same rule [`Self::resolve_index`] applies to tables.
4888    #[must_use]
4889    pub fn sequence(&self, name: &str) -> Option<&SequenceDef> {
4890        if let Some(mangled) = self.temp_name_for(name)
4891            && let Some(def) = self.sequences.get(&mangled)
4892        {
4893            return Some(def);
4894        }
4895        self.sequences.get(name)
4896    }
4897
4898    /// Does a sequence of this logical name exist for this session?
4899    #[must_use]
4900    pub fn has_sequence(&self, name: &str) -> bool {
4901        self.sequence(name).is_some()
4902    }
4903
4904    /// The storage key a sequence of this logical name resolves to — the
4905    /// session's temp mangling when it has one, else the name itself.
4906    #[must_use]
4907    pub fn sequence_key(&self, name: &str) -> String {
4908        if let Some(mangled) = self.temp_name_for(name)
4909            && self.sequences.contains_key(&mangled)
4910        {
4911            return mangled;
4912        }
4913        name.into()
4914    }
4915
4916    /// v7.17.0 — register a new SEQUENCE. Errors if `name`
4917    /// collides with an existing sequence and `if_not_exists`
4918    /// is false.
4919    pub fn create_sequence(
4920        &mut self,
4921        def: SequenceDef,
4922        if_not_exists: bool,
4923    ) -> Result<(), StorageError> {
4924        if self.sequences.contains_key(&def.name) {
4925            if if_not_exists {
4926                return Ok(());
4927            }
4928            // v7.39 (read01 round 47) — a sequence is a relation to PG (42P07).
4929            return Err(StorageError::Corrupt(format!(
4930                "relation {:?} already exists",
4931                def.name
4932            )));
4933        }
4934        self.sequences.insert(def.name.clone(), def);
4935        Ok(())
4936    }
4937
4938    /// v7.17.0 — remove a SEQUENCE by name. Returns `true` if a
4939    /// sequence was removed, `false` if none matched. Caller
4940    /// surfaces IF EXISTS semantics.
4941    /// v7.39 (read01 round 49) — `ALTER SEQUENCE old RENAME TO new`.
4942    /// Errors when `old` is missing or `new` is taken; the SequenceDef's own
4943    /// `name` field is rewritten so it stays self-describing.
4944    pub fn rename_sequence(&mut self, old: &str, new: &str) -> Result<(), StorageError> {
4945        if !self.sequences.contains_key(old) {
4946            return Err(StorageError::Corrupt(format!(
4947                "relation {old:?} does not exist"
4948            )));
4949        }
4950        if self.sequences.contains_key(new) {
4951            return Err(StorageError::Corrupt(format!(
4952                "relation {new:?} already exists"
4953            )));
4954        }
4955        if let Some(mut def) = self.sequences.remove(old) {
4956            def.name = new.to_string();
4957            self.sequences.insert(new.to_string(), def);
4958        }
4959        Ok(())
4960    }
4961
4962    pub fn drop_sequence(&mut self, name: &str) -> bool {
4963        self.sequences.remove(name).is_some()
4964    }
4965
4966    /// v7.17.0 — atomic nextval. Increments `last_value` per
4967    /// `increment`, returns the new value, sets `is_called`.
4968    /// Returns an error on CYCLE-less overflow.
4969    /// v7.39 (round 497) — the counter state of every sequence, for
4970    /// carrying across a commit install.
4971    ///
4972    /// A sequence's VALUE is not transactional in PG: `nextval` advances
4973    /// shared state that a rollback does not give back, because two
4974    /// sessions must never receive the same number. SPG keeps sequences in
4975    /// the catalog, and a transaction works on a catalog CLONE, so
4976    /// installing that clone at COMMIT would restore whatever the counter
4977    /// was at BEGIN. These two let the install put the live counters back.
4978    #[must_use]
4979    pub fn sequence_counters(&self) -> Vec<(String, i64, bool)> {
4980        self.sequences
4981            .iter()
4982            .map(|(k, d)| (k.clone(), d.last_value, d.is_called))
4983            .collect()
4984    }
4985
4986    /// Restore counters saved by [`Self::sequence_counters`], for the
4987    /// sequences that still exist. A sequence the transaction CREATED is
4988    /// absent from the saved set and keeps the value it was given.
4989    pub fn restore_sequence_counters(&mut self, saved: &[(String, i64, bool)]) {
4990        for (k, last, called) in saved {
4991            if let Some(d) = self.sequences.get_mut(k) {
4992                d.last_value = *last;
4993                d.is_called = *called;
4994            }
4995        }
4996    }
4997
4998    pub fn sequence_next_value(&mut self, name: &str) -> Result<i64, StorageError> {
4999        let key = self.sequence_key(name);
5000        let Some(seq) = self.sequences.get_mut(&key) else {
5001            return Err(StorageError::TableNotFound { name: name.into() });
5002        };
5003        // PG semantics: when !is_called (fresh sequence or
5004        // setval(_, false)), the next nextval returns the stored
5005        // `last_value`. When is_called, it advances by `increment`
5006        // and CYCLE-wraps on overflow.
5007        let candidate = if seq.is_called {
5008            let next = seq.last_value.checked_add(seq.increment).ok_or_else(|| {
5009                StorageError::Corrupt(format!("sequence {name:?} arithmetic overflow"))
5010            })?;
5011            if seq.increment > 0 {
5012                if next > seq.max_value {
5013                    if seq.cycle {
5014                        seq.min_value
5015                    } else {
5016                        // v7.39 (round 220) — PG's 2200H wording, not a
5017                        // Corrupt-classed error.
5018                        return Err(StorageError::SequenceExhausted {
5019                            name: name.into(),
5020                            limit: seq.max_value,
5021                            is_max: true,
5022                        });
5023                    }
5024                } else {
5025                    next
5026                }
5027            } else if next < seq.min_value {
5028                if seq.cycle {
5029                    seq.max_value
5030                } else {
5031                    return Err(StorageError::SequenceExhausted {
5032                        name: name.into(),
5033                        limit: seq.min_value,
5034                        is_max: false,
5035                    });
5036                }
5037            } else {
5038                next
5039            }
5040        } else {
5041            seq.last_value
5042        };
5043        seq.last_value = candidate;
5044        seq.is_called = true;
5045        Ok(candidate)
5046    }
5047
5048    /// v7.17.0 — currval. Errors if the session has never called
5049    /// nextval on this sequence (PG semantics). At the catalog
5050    /// level we approximate "session" with "is_called persisted";
5051    /// the engine session-tracking layer can wrap this for the
5052    /// strict per-session semantics later.
5053    pub fn sequence_current_value(&self, name: &str) -> Result<i64, StorageError> {
5054        let Some(seq) = self.sequences.get(name) else {
5055            return Err(StorageError::TableNotFound { name: name.into() });
5056        };
5057        if !seq.is_called {
5058            return Err(StorageError::Corrupt(format!(
5059                "currval of sequence {name:?} is not yet defined in this session"
5060            )));
5061        }
5062        Ok(seq.last_value)
5063    }
5064
5065    /// v7.17.0 — setval(name, value [, is_called]). PG returns
5066    /// `value` regardless. `is_called=true` means the NEXT
5067    /// nextval will return `value + increment`; `is_called=false`
5068    /// means the next nextval will return `value`.
5069    pub fn sequence_set_value(
5070        &mut self,
5071        name: &str,
5072        value: i64,
5073        is_called: bool,
5074    ) -> Result<i64, StorageError> {
5075        let key = self.sequence_key(name);
5076        let Some(seq) = self.sequences.get_mut(&key) else {
5077            return Err(StorageError::TableNotFound { name: name.into() });
5078        };
5079        // v7.39 (round 244) — PG refuses a value outside the sequence's
5080        // range (22003); SPG accepted it silently, leaving last_value out
5081        // of bounds.
5082        if value < seq.min_value || value > seq.max_value {
5083            return Err(StorageError::Unsupported(format!(
5084                "setval: value {value} is out of bounds for sequence \"{name}\" ({}..{})",
5085                seq.min_value, seq.max_value
5086            )));
5087        }
5088        seq.last_value = value;
5089        seq.is_called = is_called;
5090        Ok(value)
5091    }
5092
5093    /// v7.17.0 Phase 1.2 — read-only handle to catalogued views. Temp ones
5094    /// are in here under their mangled storage names; listing code filters
5095    /// through [`Self::listed_name`], and anything resolving ONE name by
5096    /// its logical spelling wants [`Self::view`].
5097    pub const fn views_all(&self) -> &BTreeMap<String, ViewDef> {
5098        &self.views
5099    }
5100
5101    /// v7.39 (round 469) — resolve one view by its logical name, the
5102    /// session's temporary one winning over a permanent one of the same
5103    /// name.
5104    #[must_use]
5105    pub fn view(&self, name: &str) -> Option<&ViewDef> {
5106        if let Some(mangled) = self.temp_name_for(name)
5107            && let Some(def) = self.views.get(&mangled)
5108        {
5109            return Some(def);
5110        }
5111        self.views.get(name)
5112    }
5113
5114    /// Does a view of this logical name exist for this session?
5115    #[must_use]
5116    pub fn has_view(&self, name: &str) -> bool {
5117        self.view(name).is_some()
5118    }
5119
5120    /// The storage key a view of this logical name resolves to.
5121    #[must_use]
5122    pub fn view_key(&self, name: &str) -> String {
5123        if let Some(mangled) = self.temp_name_for(name)
5124            && self.views.contains_key(&mangled)
5125        {
5126            return mangled;
5127        }
5128        name.into()
5129    }
5130
5131    /// v7.17.0 Phase 1.2 — install a VIEW. `or_replace=true`
5132    /// overwrites an existing entry; `if_not_exists=true` is a
5133    /// silent no-op when the name is taken. Errors if both flags
5134    /// are off and the name collides.
5135    pub fn create_view(
5136        &mut self,
5137        def: ViewDef,
5138        or_replace: bool,
5139        if_not_exists: bool,
5140    ) -> Result<(), StorageError> {
5141        if self.views.contains_key(&def.name) {
5142            if or_replace {
5143                self.views.insert(def.name.clone(), def);
5144                return Ok(());
5145            }
5146            if if_not_exists {
5147                return Ok(());
5148            }
5149            // v7.39 (read01 round 47) — a view is a relation to PG (42P07).
5150            return Err(StorageError::Corrupt(format!(
5151                "relation {:?} already exists",
5152                def.name
5153            )));
5154        }
5155        // Reject name collision with tables / sequences — same
5156        // namespace per PG.
5157        if self.by_name.contains_key(&def.name) {
5158            return Err(StorageError::Corrupt(format!(
5159                "view {:?} would shadow an existing table",
5160                def.name
5161            )));
5162        }
5163        if self.sequences.contains_key(&def.name) {
5164            return Err(StorageError::Corrupt(format!(
5165                "view {:?} would shadow an existing sequence",
5166                def.name
5167            )));
5168        }
5169        self.views.insert(def.name.clone(), def);
5170        Ok(())
5171    }
5172
5173    /// v7.17.0 Phase 1.2 — remove a view by name. Returns true if
5174    /// a view was removed.
5175    pub fn drop_view(&mut self, name: &str) -> bool {
5176        self.views.remove(name).is_some()
5177    }
5178
5179    /// v7.17.0 Phase 1.3 — read-only handle to the materialised-
5180    /// view source registry. Each entry pairs with a regular
5181    /// table of the same name that holds the cached rows.
5182    pub const fn materialized_views(&self) -> &BTreeMap<String, String> {
5183        &self.materialized_views
5184    }
5185
5186    /// v7.17.0 Phase 1.3 — register a source for a materialised
5187    /// view. Caller has already created the backing table.
5188    pub fn register_materialized_view(&mut self, name: String, body: String) {
5189        self.materialized_views.insert(name, body);
5190    }
5191
5192    /// v7.17.0 Phase 1.3 — drop the source registry entry. Returns
5193    /// true if a source was unregistered. Caller separately drops
5194    /// the backing table.
5195    pub fn drop_materialized_view_source(&mut self, name: &str) -> bool {
5196        self.materialized_views.remove(name).is_some()
5197    }
5198
5199    /// v7.17.0 Phase 1.4 — read-only handle to user-defined ENUM
5200    /// catalog.
5201    pub const fn enum_types(&self) -> &BTreeMap<String, EnumDef> {
5202        &self.enum_types
5203    }
5204
5205    /// v7.17.0 Phase 1.4 — install a new ENUM type. Errors if
5206    /// `name` collides with an existing enum (no IF NOT EXISTS
5207    /// per PG semantics for CREATE TYPE).
5208    pub fn create_enum_type(&mut self, def: EnumDef) -> Result<(), StorageError> {
5209        if self.enum_types.contains_key(&def.name) {
5210            return Err(StorageError::Corrupt(format!(
5211                "type {:?} already exists",
5212                def.name
5213            )));
5214        }
5215        self.enum_types.insert(def.name.clone(), def);
5216        Ok(())
5217    }
5218
5219    /// v7.17.0 Phase 1.4 — drop an ENUM type by name. Returns
5220    /// true if a type was removed.
5221    /// v7.37 D.55 — `ALTER TYPE … ADD VALUE`. Appends `label` to an existing
5222    /// enum's ordered label list, or inserts it before/after an existing label.
5223    /// `if_not_exists` makes a duplicate a no-op; otherwise a duplicate errors.
5224    /// Returns `Ok(true)` if a label was added, `Ok(false)` if it already existed
5225    /// (only possible under `if_not_exists`).
5226    /// v7.39 (read01 round 49) — `ALTER TYPE t RENAME VALUE 'old' TO 'new'`.
5227    /// The parser used to swallow this form as a no-op, so the rename was
5228    /// accepted and silently ignored. Renaming in place keeps the label's
5229    /// sort position, which is what PG does (enumsortorder is untouched).
5230    pub fn rename_enum_value(
5231        &mut self,
5232        type_name: &str,
5233        old: &str,
5234        new: &str,
5235    ) -> Result<(), StorageError> {
5236        let def = self
5237            .enum_types
5238            .get_mut(type_name)
5239            .ok_or_else(|| StorageError::Corrupt(format!("type {type_name:?} does not exist")))?;
5240        if def.labels.iter().any(|l| l == new) {
5241            return Err(StorageError::Corrupt(format!(
5242                "enum label {new:?} already exists"
5243            )));
5244        }
5245        let at = def.labels.iter().position(|l| l == old).ok_or_else(|| {
5246            StorageError::Corrupt(format!("{old:?} is not an existing enum label"))
5247        })?;
5248        def.labels[at] = new.to_string();
5249        Ok(())
5250    }
5251
5252    /// v7.39 (read01 round 50) — set (or, with `None`, remove) the comment on
5253    /// an object. `key` is the canonical `"<kind>:<name>"` form.
5254    pub fn set_comment(&mut self, key: &str, text: Option<&str>) {
5255        match text {
5256            Some(t) => {
5257                self.comments.insert(key.to_string(), t.to_string());
5258            }
5259            None => {
5260                self.comments.remove(key);
5261            }
5262        }
5263    }
5264
5265    /// v7.39 (read01 round 50) — the comment on an object, if any.
5266    #[must_use]
5267    pub fn comment(&self, key: &str) -> Option<&str> {
5268        self.comments.get(key).map(String::as_str)
5269    }
5270
5271    /// v7.39 (round 547) — record a GUC default for a scope. An empty
5272    /// database or role name is PG's oid 0 ("all"). `None` value
5273    /// removes just that parameter, as PG's RESET does.
5274    pub fn set_db_role_setting(
5275        &mut self,
5276        database: &str,
5277        role: &str,
5278        param: &str,
5279        value: Option<&str>,
5280    ) {
5281        let key = (database.to_string(), role.to_string());
5282        match value {
5283            Some(v) => {
5284                self.db_role_settings
5285                    .entry(key)
5286                    .or_default()
5287                    .insert(param.to_ascii_lowercase(), v.to_string());
5288            }
5289            None => {
5290                if let Some(m) = self.db_role_settings.get_mut(&key) {
5291                    m.remove(&param.to_ascii_lowercase());
5292                    if m.is_empty() {
5293                        self.db_role_settings.remove(&key);
5294                    }
5295                }
5296            }
5297        }
5298    }
5299
5300    /// v7.39 (round 550) — create a replication slot. `Err` carries
5301    /// PG's own message for a duplicate.
5302    ///
5303    /// # Errors
5304    /// When a slot of that name already exists.
5305    pub fn create_replication_slot(
5306        &mut self,
5307        name: &str,
5308        plugin: &str,
5309        slot_type: &str,
5310    ) -> Result<(), String> {
5311        if self.replication_slots.contains_key(name) {
5312            return Err(alloc::format!("replication slot \"{name}\" already exists"));
5313        }
5314        self.replication_slots.insert(
5315            name.to_string(),
5316            (plugin.to_string(), slot_type.to_string()),
5317        );
5318        Ok(())
5319    }
5320
5321    /// # Errors
5322    /// When no slot of that name exists — PG's message, and the case
5323    /// that used to report success.
5324    pub fn drop_replication_slot(&mut self, name: &str) -> Result<(), String> {
5325        if self.replication_slots.remove(name).is_none() {
5326            return Err(alloc::format!("replication slot \"{name}\" does not exist"));
5327        }
5328        Ok(())
5329    }
5330
5331    #[must_use]
5332    pub const fn replication_slots(&self) -> &BTreeMap<String, (String, String)> {
5333        &self.replication_slots
5334    }
5335
5336    /// PG's RESET ALL: drops this scope's whole entry, leaving the
5337    /// other scopes alone — measured on PG18, where `ALTER ROLE r RESET
5338    /// ALL` left the ALL, the database and the role-in-database rows.
5339    pub fn reset_db_role_settings(&mut self, database: &str, role: &str) {
5340        self.db_role_settings
5341            .remove(&(database.to_string(), role.to_string()));
5342    }
5343
5344    #[must_use]
5345    pub const fn db_role_settings(&self) -> &BTreeMap<(String, String), BTreeMap<String, String>> {
5346        &self.db_role_settings
5347    }
5348
5349    /// v7.39 (read01 round 50) — every `(key, text)` pair, for the
5350    /// pg_description view.
5351    #[must_use]
5352    pub const fn comments(&self) -> &BTreeMap<String, String> {
5353        &self.comments
5354    }
5355
5356    /// v7.39 (read01 round 50) — drop every comment whose key names `obj`
5357    /// (the object itself and, for a table, its columns). Called when the
5358    /// object is dropped so a later object of the same name doesn't inherit
5359    /// a stale comment.
5360    pub fn drop_comments_for(&mut self, kind: &str, name: &str) {
5361        let exact = alloc::format!("{kind}:{name}");
5362        let col_prefix = alloc::format!("column:{name}.");
5363        self.comments
5364            .retain(|k, _| *k != exact && !k.starts_with(&col_prefix));
5365    }
5366
5367    pub fn add_enum_value(
5368        &mut self,
5369        type_name: &str,
5370        label: &str,
5371        if_not_exists: bool,
5372        position: Option<(bool, String)>,
5373    ) -> Result<bool, StorageError> {
5374        let def = self
5375            .enum_types
5376            .get_mut(type_name)
5377            .ok_or_else(|| StorageError::Corrupt(format!("type {type_name:?} does not exist")))?;
5378        if def.labels.iter().any(|l| l == label) {
5379            if if_not_exists {
5380                return Ok(false);
5381            }
5382            // v7.39 (read01 round 49) — PG wording (42710 at the wire).
5383            return Err(StorageError::Corrupt(format!(
5384                "enum label {label:?} already exists"
5385            )));
5386        }
5387        match position {
5388            None => def.labels.push(label.to_string()),
5389            Some((is_before, anchor)) => {
5390                let at = def
5391                    .labels
5392                    .iter()
5393                    .position(|l| l == &anchor)
5394                    .ok_or_else(|| {
5395                        StorageError::Corrupt(format!(
5396                            "enum label {anchor:?} does not exist in type {type_name:?}"
5397                        ))
5398                    })?;
5399                let idx = if is_before { at } else { at + 1 };
5400                def.labels.insert(idx, label.to_string());
5401            }
5402        }
5403        Ok(true)
5404    }
5405
5406    pub fn drop_enum_type(&mut self, name: &str) -> bool {
5407        self.enum_types.remove(name).is_some()
5408    }
5409
5410    /// v7.17.0 Phase 1.5 — read-only handle to DOMAIN catalog.
5411    pub const fn domain_types(&self) -> &BTreeMap<String, DomainDef> {
5412        &self.domain_types
5413    }
5414
5415    /// v7.17.0 Phase 1.5 — install a DOMAIN. Errors on collision
5416    /// with an existing domain.
5417    pub fn create_domain_type(&mut self, def: DomainDef) -> Result<(), StorageError> {
5418        if self.domain_types.contains_key(&def.name) {
5419            return Err(StorageError::Corrupt(format!(
5420                "domain {:?} already exists",
5421                def.name
5422            )));
5423        }
5424        self.domain_types.insert(def.name.clone(), def);
5425        Ok(())
5426    }
5427
5428    /// v7.17.0 Phase 1.5 — drop a DOMAIN by name.
5429    pub fn drop_domain_type(&mut self, name: &str) -> bool {
5430        self.domain_types.remove(name).is_some()
5431    }
5432
5433    /// v7.37.42-T2 ζ-B — read-only handle to user-defined COMPOSITE
5434    /// catalog. Used by the engine to resolve
5435    /// `ColumnSchema.user_composite_type` lookups + by
5436    /// information_schema-style introspection.
5437    pub const fn composite_types(&self) -> &BTreeMap<String, CompositeDef> {
5438        &self.composite_types
5439    }
5440
5441    /// v7.37.42-T2 ζ-B — install a new COMPOSITE type. Errors if
5442    /// `name` already exists in the composite registry (PG forbids
5443    /// IF NOT EXISTS on CREATE TYPE composite; the engine surfaces
5444    /// the collision with the existing name).
5445    pub fn create_composite_type(&mut self, def: CompositeDef) -> Result<(), StorageError> {
5446        if self.composite_types.contains_key(&def.name) {
5447            return Err(StorageError::Corrupt(format!(
5448                "type {:?} already exists",
5449                def.name
5450            )));
5451        }
5452        self.composite_types.insert(def.name.clone(), def);
5453        Ok(())
5454    }
5455
5456    /// v7.37.42-T2 ζ-B — drop a COMPOSITE type by name. Returns
5457    /// true if a type was removed.
5458    pub fn drop_composite_type(&mut self, name: &str) -> bool {
5459        self.composite_types.remove(name).is_some()
5460    }
5461
5462    /// v7.17.0 Phase 1.6 — read-only handle to the user-created
5463    /// schema registry. Built-in schemas (`public`, `pg_catalog`,
5464    /// `information_schema`) are NOT included here; use
5465    /// [`schema_exists`](Self::schema_exists) for the full
5466    /// check.
5467    pub const fn user_schemas(&self) -> &alloc::collections::BTreeSet<String> {
5468        &self.schemas
5469    }
5470
5471    /// v7.17.0 Phase 1.6 — schema-name resolver. Returns true
5472    /// for built-in schemas + every user-CREATEd one. Used by
5473    /// CREATE SCHEMA collision checks and (future) by
5474    /// information_schema.schemata.
5475    pub fn schema_exists(&self, name: &str) -> bool {
5476        is_builtin_schema(name) || self.schemas.contains(name)
5477    }
5478
5479    /// v7.17.0 Phase 1.6 — register a new schema. Errors if the
5480    /// name already exists and `if_not_exists=false`. Built-in
5481    /// names cannot be redeclared.
5482    pub fn create_schema(&mut self, name: String, if_not_exists: bool) -> Result<(), StorageError> {
5483        if is_builtin_schema(&name) {
5484            if if_not_exists {
5485                return Ok(());
5486            }
5487            return Err(StorageError::Corrupt(format!(
5488                "schema {name:?} is built-in and cannot be redeclared"
5489            )));
5490        }
5491        if self.schemas.contains(&name) {
5492            if if_not_exists {
5493                return Ok(());
5494            }
5495            return Err(StorageError::Corrupt(format!(
5496                "schema {name:?} already exists"
5497            )));
5498        }
5499        self.schemas.insert(name);
5500        Ok(())
5501    }
5502
5503    /// v7.17.0 Phase 1.6 — drop a user-created schema. Returns
5504    /// true if a schema was removed. Built-in names always
5505    /// return false (cannot be dropped). Tables that previously
5506    /// used the schema as a prefix keep their bare name and stay
5507    /// queryable — this is the "prefix routing, not isolation"
5508    /// posture documented in v7.17 Phase 1.6.
5509    pub fn drop_schema(&mut self, name: &str) -> Result<bool, StorageError> {
5510        if is_builtin_schema(name) {
5511            return Err(StorageError::Corrupt(format!(
5512                "schema {name:?} is built-in and cannot be dropped"
5513            )));
5514        }
5515        Ok(self.schemas.remove(name))
5516    }
5517
5518    /// v7.17.0 — ALTER SEQUENCE option merge. Caller-provided
5519    /// updates overwrite the matching fields; unset fields keep
5520    /// their stored values. RESTART variants update last_value
5521    /// directly per PG: `RESTART` resets to current `start`;
5522    /// `RESTART WITH n` resets to `n`.
5523    #[allow(clippy::too_many_arguments)]
5524    pub fn alter_sequence(
5525        &mut self,
5526        name: &str,
5527        increment: Option<i64>,
5528        min_value: Option<i64>,
5529        max_value: Option<i64>,
5530        start: Option<i64>,
5531        restart: Option<Option<i64>>,
5532        cache: Option<i64>,
5533        cycle: Option<bool>,
5534        owned_by: Option<Option<(String, String)>>,
5535    ) -> Result<(), StorageError> {
5536        let Some(seq) = self.sequences.get_mut(name) else {
5537            return Err(StorageError::TableNotFound { name: name.into() });
5538        };
5539        if let Some(v) = increment {
5540            seq.increment = v;
5541        }
5542        if let Some(v) = min_value {
5543            seq.min_value = v;
5544        }
5545        if let Some(v) = max_value {
5546            seq.max_value = v;
5547        }
5548        if let Some(v) = start {
5549            seq.start = v;
5550        }
5551        if let Some(restart_value) = restart {
5552            seq.last_value = restart_value.unwrap_or(seq.start);
5553            seq.is_called = false;
5554        }
5555        if let Some(v) = cache {
5556            seq.cache = v;
5557        }
5558        if let Some(v) = cycle {
5559            seq.cycle = v;
5560        }
5561        if let Some(v) = owned_by {
5562            seq.owned_by = v;
5563        }
5564        Ok(())
5565    }
5566
5567    /// v7.12.4 — read-only slice of all catalogued triggers.
5568    /// Engine row-write paths filter this by (table, event,
5569    /// timing) and fire matches in slice order.
5570    pub fn triggers(&self) -> &[TriggerDef] {
5571        &self.triggers
5572    }
5573
5574    /// v7.15.0 — mutable handle to the trigger slice for
5575    /// `ALTER TABLE … RENAME COLUMN`, which rewrites every
5576    /// `update_columns` entry that referenced the renamed
5577    /// column.
5578    pub fn triggers_mut(&mut self) -> &mut Vec<TriggerDef> {
5579        &mut self.triggers
5580    }
5581
5582    /// v7.12.4 — register a new trigger. With `or_replace = false`,
5583    /// errors when a trigger with the same name already exists on
5584    /// the same table (PG scoping rule — trigger names are
5585    /// per-table, not global). Trigger function must already
5586    /// exist in the catalog at registration time.
5587    pub fn create_trigger(
5588        &mut self,
5589        def: TriggerDef,
5590        or_replace: bool,
5591    ) -> Result<(), StorageError> {
5592        // v7.39 (round 137) — a trigger may target a base table (BEFORE / AFTER)
5593        // or a view (INSTEAD OF). The engine enforces the timing↔target rule;
5594        // storage only requires the relation to exist as one or the other.
5595        if !self.by_name.contains_key(&def.table) && !self.views.contains_key(&def.table) {
5596            return Err(StorageError::TableNotFound {
5597                name: def.table.clone(),
5598            });
5599        }
5600        // v7.39 (read01 round 62) — functions are keyed by SIGNATURE now. A
5601        // trigger names its function by NAME (a trigger function takes no
5602        // arguments), so the existence check goes through the name index.
5603        if self.functions_named(&def.function).is_empty() {
5604            // v7.39 (round 710) — PG's wording: the FUNCTION is what does
5605            // not exist (`function nosuch_fn() does not exist`), and the
5606            // old message rode `Corrupt`'s on-disk banner besides.
5607            return Err(StorageError::Corrupt(format!(
5608                "function {}() does not exist",
5609                def.function
5610            )));
5611        }
5612        let dup = self
5613            .triggers
5614            .iter()
5615            .position(|t| t.name == def.name && t.table == def.table);
5616        match (dup, or_replace) {
5617            (Some(_), false) => Err(StorageError::Corrupt(format!(
5618                "trigger {:?} already exists on table {:?}",
5619                def.name, def.table
5620            ))),
5621            (Some(i), true) => {
5622                self.triggers[i] = def;
5623                Ok(())
5624            }
5625            (None, _) => {
5626                self.triggers.push(def);
5627                Ok(())
5628            }
5629        }
5630    }
5631
5632    /// v7.12.4 — remove a trigger by `(name, table)`. Returns
5633    /// `true` if one was removed.
5634    pub fn drop_trigger(&mut self, name: &str, table: &str) -> bool {
5635        let before = self.triggers.len();
5636        self.triggers
5637            .retain(|t| !(t.name == name && t.table == table));
5638        before != self.triggers.len()
5639    }
5640
5641    /// v7.39 (round 139) — the catalogued query-rewrite RULEs.
5642    pub fn rules(&self) -> &[RuleDef] {
5643        &self.rules
5644    }
5645
5646    /// v7.39 (round 280) — the catalogued extended-statistics objects.
5647    #[must_use]
5648    pub fn statistics_ext(&self) -> &[StatisticsExtDef] {
5649        &self.statistics_ext
5650    }
5651
5652    /// v7.39 (round 287) — every large object, ascending by OID.
5653    #[must_use]
5654    pub fn large_objects(&self) -> &alloc::collections::BTreeMap<u32, Vec<u8>> {
5655        &self.large_objects
5656    }
5657
5658    /// The bytes of one large object, or `None` when no such OID exists.
5659    #[must_use]
5660    pub fn large_object(&self, oid: u32) -> Option<&[u8]> {
5661        self.large_objects.get(&oid).map(Vec::as_slice)
5662    }
5663
5664    /// Create a large object. `oid` of 0 means "pick one" — PG's
5665    /// `lo_create(0)` / `lo_creat(-1)` spelling. Errors when the
5666    /// requested OID is taken.
5667    pub fn create_large_object(&mut self, oid: u32, bytes: Vec<u8>) -> Result<u32, String> {
5668        let id = if oid == 0 {
5669            self.next_large_object_oid()
5670        } else {
5671            oid
5672        };
5673        if self.large_objects.contains_key(&id) {
5674            return Err(format!("large object {id} already exists"));
5675        }
5676        self.large_objects.insert(id, bytes);
5677        Ok(id)
5678    }
5679
5680    /// Overwrite `len` bytes at `offset` (0-based), growing the object
5681    /// with zero bytes if the write starts past the end — PG's
5682    /// `lo_put` semantics.
5683    pub fn put_large_object(&mut self, oid: u32, offset: usize, data: &[u8]) -> Result<(), String> {
5684        let Some(buf) = self.large_objects.get_mut(&oid) else {
5685            return Err(format!("large object {oid} does not exist"));
5686        };
5687        let end = offset.saturating_add(data.len());
5688        if buf.len() < end {
5689            buf.resize(end, 0);
5690        }
5691        buf[offset..end].copy_from_slice(data);
5692        Ok(())
5693    }
5694
5695    /// v7.39 (round 306) — `lo_truncate`. PG's truncate sets the object
5696    /// to exactly `len` bytes in BOTH directions: it shortens, and it
5697    /// GROWS with zero fill when `len` exceeds the current size
5698    /// (measured — `lo_truncate(fd, 8)` over a 4-byte object leaves
5699    /// eight bytes, the last four zero).
5700    pub fn truncate_large_object(&mut self, oid: u32, len: usize) -> Result<(), String> {
5701        let Some(buf) = self.large_objects.get_mut(&oid) else {
5702            return Err(format!("large object {oid} does not exist"));
5703        };
5704        buf.resize(len, 0);
5705        Ok(())
5706    }
5707
5708    /// Remove a large object. `false` when the OID was not there.
5709    pub fn unlink_large_object(&mut self, oid: u32) -> bool {
5710        self.large_objects.remove(&oid).is_some()
5711    }
5712
5713    /// The next free OID in PG's user band.
5714    /// v7.39 (round 343, V40) — large objects have their own oid band.
5715    /// It used to start at 16_384, which is where user TABLES start, so
5716    /// the first large object and the first table shared an oid — and
5717    /// `pg_largeobject_metadata.oid` is joinable against `pg_class.oid`,
5718    /// so a join across them matched a row that has nothing to do with
5719    /// it. (PG cannot collide: every oid there comes off one counter.)
5720    /// An object already stored keeps the oid it was given; only new
5721    /// ones land in the band.
5722    fn next_large_object_oid(&self) -> u32 {
5723        self.large_objects
5724            .keys()
5725            .next_back()
5726            .map_or(500_000, |m| m.saturating_add(1))
5727    }
5728
5729    /// Register one. `Err(name)` when the name is taken.
5730    pub fn create_statistics_ext(&mut self, def: StatisticsExtDef) -> Result<(), String> {
5731        if self.statistics_ext.iter().any(|s| s.name == def.name) {
5732            return Err(def.name);
5733        }
5734        self.statistics_ext.push(def);
5735        Ok(())
5736    }
5737
5738    /// Drop one by name; false when absent.
5739    pub fn drop_statistics_ext(&mut self, name: &str) -> bool {
5740        let before = self.statistics_ext.len();
5741        self.statistics_ext.retain(|s| s.name != name);
5742        before != self.statistics_ext.len()
5743    }
5744
5745    /// v7.39 (round 139) — register a RULE. Its target relation (table or view)
5746    /// must exist; `or_replace` overwrites a same-(name,table) rule.
5747    pub fn create_rule(&mut self, def: RuleDef, or_replace: bool) -> Result<(), StorageError> {
5748        if !self.by_name.contains_key(&def.table) && !self.views.contains_key(&def.table) {
5749            return Err(StorageError::TableNotFound {
5750                name: def.table.clone(),
5751            });
5752        }
5753        let dup = self
5754            .rules
5755            .iter()
5756            .position(|r| r.name == def.name && r.table == def.table);
5757        match (dup, or_replace) {
5758            (Some(_), false) => Err(StorageError::Corrupt(format!(
5759                "rule {:?} for relation {:?} already exists",
5760                def.name, def.table
5761            ))),
5762            (Some(i), true) => {
5763                self.rules[i] = def;
5764                Ok(())
5765            }
5766            (None, _) => {
5767                self.rules.push(def);
5768                Ok(())
5769            }
5770        }
5771    }
5772
5773    /// v7.39 (round 139) — drop a RULE by `(name, table)`.
5774    pub fn drop_rule(&mut self, name: &str, table: &str) -> bool {
5775        let before = self.rules.len();
5776        self.rules.retain(|r| !(r.name == name && r.table == table));
5777        before != self.rules.len()
5778    }
5779
5780    pub fn create_table(&mut self, schema: TableSchema) -> Result<(), StorageError> {
5781        if self.by_name.contains_key(&schema.name) {
5782            return Err(StorageError::DuplicateTable {
5783                name: schema.name.clone(),
5784            });
5785        }
5786        let idx = self.tables.len();
5787        let name = schema.name.clone();
5788        self.tables.push(Table::new(schema));
5789        self.by_name.insert(name.clone(), idx);
5790        // v7.39 (round 496) — see `dirty_tables`.
5791        self.dirty_tables.insert(name);
5792        // v7.37.15 (Phase C.1) — stamp the new relation with a stable,
5793        // monotonic, never-reused RelId. Pre-increment so ids start at
5794        // 1 (0 = UNASSIGNED); a later DROP TABLE frees the slot but not
5795        // the id.
5796        self.next_rel_id += 1;
5797        let rid = row_header::RelId(self.next_rel_id);
5798        self.tables[idx].set_rel_id(rid);
5799        Ok(())
5800    }
5801
5802    /// v7.39 (round 436) — the session's temporary table of this name wins
5803    /// over a permanent one, as `pg_temp` does in PG's search path and as
5804    /// MySQL's TEMPORARY shadowing does. Every name → index resolution in
5805    /// this catalog goes through here.
5806    fn resolve_index(&self, name: &str) -> Option<usize> {
5807        if let Some(prefix) = &self.temp_prefix {
5808            let mut mangled = String::with_capacity(prefix.len() + name.len());
5809            mangled.push_str(prefix);
5810            mangled.push_str(name);
5811            if let Some(idx) = self.by_name.get(&mangled) {
5812                return Some(*idx);
5813            }
5814        }
5815        self.by_name.get(name).copied()
5816    }
5817
5818    /// v7.39 (round 436) — install the calling session's temp namespace.
5819    /// `None` disables temp resolution entirely (a session that never made
5820    /// one pays a single `Option` check per lookup).
5821    pub fn set_temp_prefix(&mut self, prefix: Option<String>) {
5822        self.temp_prefix = prefix;
5823    }
5824
5825    /// The mangled storage name a temp table of `name` takes in this
5826    /// session, or `None` when the session has no temp namespace.
5827    #[must_use]
5828    pub fn temp_name_for(&self, name: &str) -> Option<String> {
5829        self.temp_prefix
5830            .as_ref()
5831            .map(|p| alloc::format!("{p}{name}"))
5832    }
5833
5834    pub fn get(&self, name: &str) -> Option<&Table> {
5835        let idx = self.resolve_index(name)?;
5836        self.tables.get(idx)
5837    }
5838
5839    pub fn get_mut(&mut self, name: &str) -> Option<&mut Table> {
5840        let idx = self.resolve_index(name)?;
5841        // v7.39 (round 496) — the choke point for changing a table, so the
5842        // record is taken here. Over-approximate on purpose: a caller that
5843        // takes the handle and writes nothing merely carries that table
5844        // through a commit, which is the old behaviour.
5845        let recorded = self.tables.get(idx).map(|t| t.schema().name.clone());
5846        if let Some(n) = recorded {
5847            self.dirty_tables.insert(n);
5848        }
5849        self.tables.get_mut(idx)
5850    }
5851
5852    /// v7.39 (round 496) — the tables changed through this handle since
5853    /// [`Self::clear_dirty_tables`]. See `dirty_tables`.
5854    #[must_use]
5855    pub fn dirty_tables(&self) -> &alloc::collections::BTreeSet<String> {
5856        &self.dirty_tables
5857    }
5858
5859    /// v7.39 (round 496) — start a fresh recording window. A transaction's
5860    /// shadow calls this at BEGIN so the set means "changed by this tx".
5861    pub fn clear_dirty_tables(&mut self) {
5862        self.dirty_tables.clear();
5863    }
5864
5865    /// v7.39 (round 496) — put `table` in at `name`, replacing any table
5866    /// already there and keeping the rest of the catalog untouched.
5867    ///
5868    /// The commit-time table-granularity merge needs exactly this: take
5869    /// the latest committed catalog, then overwrite only the tables the
5870    /// transaction changed.
5871    pub fn install_table(&mut self, name: &str, table: Table) {
5872        match self.by_name.get(name).copied() {
5873            Some(idx) => self.tables[idx] = table,
5874            None => {
5875                let idx = self.tables.len();
5876                self.tables.push(table);
5877                self.by_name.insert(name.into(), idx);
5878            }
5879        }
5880        self.dirty_tables.insert(name.into());
5881    }
5882
5883    /// v7.37.42 (docker-fair SCALARSQ attack) — resolve a table name to
5884    /// its insertion-order index ONCE, so callers that need to fetch the
5885    /// same table many times (per-row PK probes in correlated scalar
5886    /// subqueries) can avoid the per-call `BTreeMap<String, usize>` string
5887    /// descent. The returned index is stable for the lifetime of the
5888    /// catalog snapshot the caller holds (same engine read guard).
5889    pub fn tables_position_of(&self, name: &str) -> Option<usize> {
5890        self.resolve_index(name)
5891    }
5892
5893    /// Direct positional fetch counterpart to [`tables_position_of`].
5894    /// `idx` must come from `tables_position_of` against the same catalog
5895    /// snapshot — out-of-range returns `None`.
5896    pub fn tables_at(&self, idx: usize) -> Option<&Table> {
5897        self.tables.get(idx)
5898    }
5899
5900    /// v7.34 (crash-recovery P0 #2) — replay a row-level redo log onto
5901    /// this catalog (the [`RowChange`] physical-redo apply primitive that
5902    /// row-level WAL recovery will use in place of statement re-execution).
5903    /// Applies each change in order via the same `Table` mutators the
5904    /// engine used — no uniqueness/FK/parse/plan: the original execution
5905    /// already validated, replay trusts and applies. Positions are
5906    /// physical and only valid when replayed from the matching checkpoint
5907    /// baseline in original order (see [`RowChange`] docs).
5908    ///
5909    /// A change naming an absent table, or whose position is out of range,
5910    /// is a corrupt/misaligned log and surfaces as an error rather than a
5911    /// silent skip.
5912    pub fn apply_redo(&mut self, changes: &[RowChange]) -> Result<(), StorageError> {
5913        // v7.37.5 (mailrs crash-recovery Ask 3) — true batched replay.
5914        // Pre-v7.37.5 each `RowChange::Delete` record ran a fresh
5915        // O(N) PersistentVec rebuild + O(N × indices × log N)
5916        // `rebuild_indices()` — 5000 records × 100k rows × 13 indices
5917        // ≈ 27 min on the mailrs prod-shape WAL.
5918        //
5919        // The strategy: group consecutive changes by table, and for
5920        // each run, compose all the row-level mutations through a
5921        // single "live" tracking vector + a per-table operation log,
5922        // then apply rows + indices ONCE at the end. The result:
5923        //  - DELETE blow-up: O(records × rows × indices × log rows)
5924        //    → O(rows × indices × log rows) — one rebuild per run.
5925        //  - Row-position semantics preserved: positions in a later
5926        //    `Delete` / `Update` record reference the layout produced
5927        //    by every earlier change; we walk the live-vector
5928        //    forward as each change is processed so positions
5929        //    translate correctly to the ORIGINAL row index space.
5930        //
5931        // For correctness, even with this batching `apply_redo`
5932        // remains in-order: a single per-table run only batches
5933        // a contiguous slice of changes targeting that table; a
5934        // mid-run change targeting a DIFFERENT table forces a
5935        // flush of the current run.
5936        let mut runs: alloc::vec::Vec<(String, alloc::vec::Vec<&RowChange>)> =
5937            alloc::vec::Vec::new();
5938        for change in changes {
5939            // v7.39 (flip crash-replay P0) — a replayed tombstone carries
5940            // the xmax the CRASHED process allocated, but this process's
5941            // version cursor restarted; without advancing it past every
5942            // replayed version, `Snapshot::visible`'s "deletion is in the
5943            // future" branch (xmax > snapshot.version) resurrects every
5944            // replayed delete. Same recovery contract as the snapshot
5945            // loader (`observe_persisted_version`, the pg_control-style
5946            // nextXid recovery).
5947            if let RowChange::Tombstone { xmax, .. } = change {
5948                row_header::observe_persisted_version(*xmax);
5949            }
5950            let table = match change {
5951                RowChange::Insert { table, .. }
5952                | RowChange::Update { table, .. }
5953                | RowChange::Delete { table, .. }
5954                | RowChange::Tombstone { table, .. } => table.clone(),
5955            };
5956            if runs.last().map(|(t, _)| t.as_str()) != Some(table.as_str()) {
5957                runs.push((table, alloc::vec::Vec::new()));
5958            }
5959            runs.last_mut().unwrap().1.push(change);
5960        }
5961        for (table_name, run) in runs {
5962            self.apply_redo_run_on_table(&table_name, &run)?;
5963        }
5964        Ok(())
5965    }
5966
5967    /// v7.37.5 — apply a contiguous slice of `RowChange`s all
5968    /// targeting the same `table_name`. Composes row mutations
5969    /// through a single live-tracking vector + a single tail
5970    /// for appended `Insert`s + a single in-place edit set for
5971    /// `Update`s, then writes the final row layout to
5972    /// `self.rows` and rebuilds indices ONCE.
5973    fn apply_redo_run_on_table(
5974        &mut self,
5975        table_name: &str,
5976        run: &[&RowChange],
5977    ) -> Result<(), StorageError> {
5978        // Look up the table once; the unchecked unwrap is safe
5979        // because the caller just resolved `table_name` for each
5980        // change.
5981        let table = self.get_mut(table_name).ok_or_else(|| {
5982            StorageError::Corrupt(alloc::format!("redo: unknown table {table_name:?}"))
5983        })?;
5984        // Live-tracking over both pre-existing rows and tail-
5985        // appended Insert rows. `live[i] = true` initially for
5986        // every existing row. Appended Inserts extend with `true`.
5987        // A `Delete` flips entries to `false` (using the position
5988        // mapping that walks live indices in order). An `Update`
5989        // edits in place — collected into an overlay map keyed by
5990        // ORIGINAL row position so later Updates win.
5991        let original_rows: alloc::vec::Vec<Row<'static>> = table.rows().iter().cloned().collect();
5992        let mut live: alloc::vec::Vec<bool> = alloc::vec![true; original_rows.len()];
5993        let mut tail: alloc::vec::Vec<Row<'static>> = alloc::vec::Vec::new();
5994        // Overlay: index into ORIGINAL row space (existing rows
5995        // 0..original_rows.len()) or into tail (offset
5996        // original_rows.len()). Map -> new values.
5997        let mut overlay: alloc::collections::BTreeMap<usize, alloc::vec::Vec<Value<'static>>> =
5998            alloc::collections::BTreeMap::new();
5999        // v7.37.15 (Epic W durable-tombstone slice) — extra bookkeeping
6000        // ONLY when this run actually carries an in-place `Tombstone`.
6001        // A tombstone keeps its row physically present but stamps `xmax`
6002        // on the header; the run finalizer `set_rows_and_rebuild_indices`
6003        // freezes every header (and reassigns ids), so we must re-stamp
6004        // in a post-pass keyed by RowId. When the run has no tombstone
6005        // (every default gate-off replay) this is all skipped and the
6006        // path below stays byte-for-byte the legacy one.
6007        let has_tomb = run.iter().any(|c| matches!(c, RowChange::Tombstone { .. }));
6008        // Ids of the pre-existing rows, snapshotted parallel to
6009        // `original_rows`, and ids of the tail rows filled from each
6010        // `Insert`'s carried `rowid`. Together they let a tombstone name
6011        // the exact row the writer stamped, independent of the ids the
6012        // finalizer will hand out. (When `!has_tomb`, both stay empty.)
6013        // v7.39 (flip crash-replay P0) — ids are tracked UNCONDITIONALLY
6014        // now: the finalizer preserves them so a later WAL record's
6015        // tombstone can still name rows this record produced.
6016        let orig_rowids: alloc::vec::Vec<row_header::RowId> =
6017            table.rowids().iter().copied().collect();
6018        // Headers snapshotted in lock-step: the finalizer preserves
6019        // them so earlier records' tombstone stamps survive.
6020        let orig_headers: alloc::vec::Vec<row_header::RowHeader> =
6021            table.headers().iter().copied().collect();
6022        let mut tail_rowids: alloc::vec::Vec<row_header::RowId> = alloc::vec::Vec::new();
6023        // (RowId, xmax) of every row this run tombstones.
6024        let mut tomb_targets: alloc::vec::Vec<(row_header::RowId, u64)> = alloc::vec::Vec::new();
6025        // Helper: given a "current" position (i.e. position in
6026        // the post-prior-deletes layout), translate to the
6027        // ABSOLUTE position in the unified live + tail space
6028        // by walking the live vector + tail. Returns None when
6029        // the position is out of range.
6030        fn translate(live: &[bool], tail_len: usize, current_pos: usize) -> Option<usize> {
6031            // Walk live[..] counting live entries until we hit
6032            // current_pos. Then if not yet matched, dip into tail.
6033            let mut seen = 0usize;
6034            for (i, &alive) in live.iter().enumerate() {
6035                if alive {
6036                    if seen == current_pos {
6037                        return Some(i);
6038                    }
6039                    seen += 1;
6040                }
6041            }
6042            // Position lives in tail. tail_len rows in the tail
6043            // are all live (we haven't deleted any tail rows in
6044            // this simplification; if we did, we'd extend `live`).
6045            let off = current_pos - seen;
6046            if off < tail_len {
6047                Some(live.len() + off)
6048            } else {
6049                None
6050            }
6051        }
6052        for change in run {
6053            match *change {
6054                RowChange::Insert { row, rowid, .. } => {
6055                    // Validate against schema before recording the
6056                    // change so a corrupt log surfaces as an error
6057                    // rather than silently mis-applying.
6058                    if row.len() != table.schema().columns.len() {
6059                        return Err(StorageError::ArityMismatch {
6060                            expected: table.schema().columns.len(),
6061                            actual: row.len(),
6062                        });
6063                    }
6064                    tail.push(row.clone());
6065                    // Keep the id lock-step with `tail` so a later
6066                    // tombstone (this run or a later WAL record) can
6067                    // find the row by the id the writer captured.
6068                    tail_rowids.push(*rowid);
6069                }
6070                RowChange::Update { pos, new_row, .. } => {
6071                    if new_row.len() != table.schema().columns.len() {
6072                        return Err(StorageError::ArityMismatch {
6073                            expected: table.schema().columns.len(),
6074                            actual: new_row.len(),
6075                        });
6076                    }
6077                    let abs = translate(&live, tail.len(), *pos).ok_or_else(|| {
6078                        StorageError::Corrupt(alloc::format!(
6079                            "redo: update_row position {pos} out of bounds in table {table_name:?}",
6080                        ))
6081                    })?;
6082                    // Tail edits are applied directly to `tail`
6083                    // (we own it); existing-row edits land in
6084                    // the overlay map keyed by original index.
6085                    if abs < live.len() {
6086                        overlay.insert(abs, new_row.clone());
6087                    } else {
6088                        tail[abs - live.len()] = Row::new(new_row.clone());
6089                    }
6090                }
6091                RowChange::Delete { positions, .. } => {
6092                    // De-dup + sort so the translate walk stays
6093                    // monotone (the second translate doesn't have
6094                    // to redo work the first one did, in principle;
6095                    // we keep it simple here and re-walk per
6096                    // position). Bounds-filter silently mirrors
6097                    // `Table::delete_rows`.
6098                    let mut sorted: alloc::vec::Vec<usize> = positions.clone();
6099                    sorted.sort_unstable();
6100                    sorted.dedup();
6101                    // Walk live[] once per Delete record to
6102                    // translate all positions in this record's
6103                    // post-prior-deletes layout to absolute
6104                    // indices. We MUST defer the live[] flip
6105                    // until after all positions are translated
6106                    // so two positions in the same record
6107                    // (e.g. [3, 7]) reference the same layout.
6108                    let mut to_flip_live: alloc::vec::Vec<usize> = alloc::vec::Vec::new();
6109                    let mut to_flip_tail: alloc::vec::Vec<usize> = alloc::vec::Vec::new();
6110                    // Two-pointer walk: live[i] scanned monotonically,
6111                    // sorted positions consumed in order.
6112                    let mut seen = 0usize;
6113                    let mut sp = sorted.iter().peekable();
6114                    for (i, &alive) in live.iter().enumerate() {
6115                        if !alive {
6116                            continue;
6117                        }
6118                        while let Some(&&p) = sp.peek() {
6119                            if seen == p {
6120                                to_flip_live.push(i);
6121                                sp.next();
6122                            } else {
6123                                break;
6124                            }
6125                        }
6126                        if sp.peek().is_none() {
6127                            break;
6128                        }
6129                        seen += 1;
6130                    }
6131                    // Remaining positions fall into the tail.
6132                    for &p in sp {
6133                        // p >= seen and refers to the (p - seen)-th
6134                        // entry in tail. Filter out-of-bounds.
6135                        let off = p - seen;
6136                        if off < tail.len() {
6137                            to_flip_tail.push(off);
6138                        }
6139                    }
6140                    for i in to_flip_live {
6141                        live[i] = false;
6142                        // Any pending overlay edit for this
6143                        // index is moot — the row is gone.
6144                        overlay.remove(&i);
6145                    }
6146                    // Tail deletes: remove in REVERSE order so
6147                    // shifting indices stay valid.
6148                    to_flip_tail.sort_unstable();
6149                    to_flip_tail.dedup();
6150                    for off in to_flip_tail.into_iter().rev() {
6151                        tail.remove(off);
6152                        {
6153                            // Keep the id vector lock-step with `tail`.
6154                            tail_rowids.remove(off);
6155                        }
6156                        // Re-key tail-relative overlay entries that
6157                        // were past `off` — in practice tail edits
6158                        // are applied directly so the overlay map
6159                        // only holds existing-row keys; nothing to
6160                        // do here.
6161                    }
6162                }
6163                RowChange::Tombstone { rowids, xmax, .. } => {
6164                    // An in-place tombstone leaves the row physically
6165                    // present — it does not touch `live` / `tail` /
6166                    // `overlay`. Record the (id, xmax) targets; the
6167                    // post-finalizer pass re-stamps `xmax` onto the
6168                    // matching row's (otherwise-frozen) header.
6169                    for rid in rowids {
6170                        tomb_targets.push((*rid, *xmax));
6171                    }
6172                }
6173            }
6174        }
6175        // Compose the final row layout: keep existing rows where
6176        // live[i] = true, applying overlay edits in place; then
6177        // append the surviving tail.
6178        let mut new_rows: PersistentVec<Row> = PersistentVec::new();
6179        let mut new_hot_bytes: u64 = 0;
6180        let schema_snapshot = table.schema().clone();
6181        // Parallel to `new_rows` (only built when `has_tomb`): the RowId
6182        // of each row in its FINAL slot, so the post-pass can map a
6183        // tombstone target id → the slot to re-stamp `xmax` on.
6184        let mut final_rowids: alloc::vec::Vec<row_header::RowId> = alloc::vec::Vec::new();
6185        let mut final_headers: alloc::vec::Vec<row_header::RowHeader> = alloc::vec::Vec::new();
6186        for (i, row) in original_rows.into_iter().enumerate() {
6187            if !live[i] {
6188                continue;
6189            }
6190            let final_row = if let Some(new_values) = overlay.remove(&i) {
6191                Row::new(new_values)
6192            } else {
6193                row
6194            };
6195            new_hot_bytes = new_hot_bytes
6196                .saturating_add(row_body_encoded_len(&final_row, &schema_snapshot) as u64);
6197            new_rows.push_mut(final_row);
6198            final_rowids.push(
6199                orig_rowids
6200                    .get(i)
6201                    .copied()
6202                    .unwrap_or(row_header::RowId::UNASSIGNED),
6203            );
6204            final_headers.push(
6205                orig_headers
6206                    .get(i)
6207                    .copied()
6208                    .unwrap_or_else(row_header::RowHeader::frozen),
6209            );
6210        }
6211        for (off, row) in tail.into_iter().enumerate() {
6212            new_hot_bytes =
6213                new_hot_bytes.saturating_add(row_body_encoded_len(&row, &schema_snapshot) as u64);
6214            new_rows.push_mut(row);
6215            final_rowids.push(
6216                tail_rowids
6217                    .get(off)
6218                    .copied()
6219                    .unwrap_or(row_header::RowId::UNASSIGNED),
6220            );
6221            final_headers.push(row_header::RowHeader::frozen());
6222        }
6223        // v7.39 (flip crash-replay P0) — id-preserving finalizer, so a
6224        // LATER WAL record's tombstone still resolves rows this record
6225        // produced (per-statement replay used to reassign ids between
6226        // records, orphaning every cross-record tombstone target).
6227        table.set_rows_and_rebuild_indices_with_rowids(
6228            new_rows,
6229            new_hot_bytes,
6230            &final_rowids,
6231            &final_headers,
6232        );
6233        // v7.37.15 (Epic W durable-tombstone slice) — header-preserving
6234        // re-stamp. `set_rows_and_rebuild_indices` above froze every
6235        // header, so any row this run tombstoned is currently all-
6236        // visible again. Re-apply the `xmax` stamp by matching the
6237        // tombstone's target RowId against the final-slot id map. This
6238        // is what makes a gate-on DELETE durable across replay without
6239        // changing the on-disk snapshot format (headers/ids are still
6240        // NOT serialised — that is the deferred V6 coupling; see below).
6241        if has_tomb && !tomb_targets.is_empty() {
6242            let mut id_to_slot: alloc::collections::BTreeMap<row_header::RowId, usize> =
6243                alloc::collections::BTreeMap::new();
6244            for (slot, rid) in final_rowids.iter().enumerate() {
6245                if *rid != row_header::RowId::UNASSIGNED {
6246                    id_to_slot.insert(*rid, slot);
6247                }
6248            }
6249            let table = self.get_mut(table_name).ok_or_else(|| {
6250                StorageError::Corrupt(alloc::format!("redo: unknown table {table_name:?}"))
6251            })?;
6252            for (rid, xmax) in &tomb_targets {
6253                match id_to_slot.get(rid) {
6254                    Some(&slot) => {
6255                        // First-deleter-wins + bounds handled inside.
6256                        let _ = table.mark_row_deleted(slot, *xmax);
6257                    }
6258                    None => {
6259                        // The target row was not produced by THIS redo
6260                        // run and its id was not in the run-start
6261                        // snapshot — the documented cross-checkpoint
6262                        // limitation: after a checkpoint restore the
6263                        // table's ids are reassigned (not yet persisted
6264                        // in the envelope), so a tombstone naming a
6265                        // pre-checkpoint row cannot be resolved by id.
6266                        // Skipping leaves the row visible (identical to
6267                        // the pre-Epic-W non-durable behaviour); it is
6268                        // never a correctness regression, only an
6269                        // unclosed durability gap the V6 envelope slice
6270                        // closes. Counted for observability.
6271                        UNRESOLVED_TOMBSTONES.fetch_add(1, core::sync::atomic::Ordering::Relaxed);
6272                    }
6273                }
6274            }
6275        }
6276        Ok(())
6277    }
6278
6279    fn table_for_redo(&mut self, name: &str) -> Result<&mut Table, StorageError> {
6280        self.get_mut(name)
6281            .ok_or_else(|| StorageError::Corrupt(alloc::format!("redo: unknown table {name:?}")))
6282    }
6283
6284    /// v7.34 (crash-recovery P0 #2) — enable row-level redo capture on
6285    /// every table (the engine calls this before a mutating statement
6286    /// when persistence is on; idempotent, keeps any in-flight capture).
6287    pub fn enable_redo_all(&mut self) {
6288        for t in &mut self.tables {
6289            t.enable_redo();
6290        }
6291    }
6292
6293    /// v7.34 — drain the row-level redo captured across all tables, in
6294    /// table order then per-table apply order, and stop capturing. The
6295    /// engine calls this after a successful mutating statement and writes
6296    /// the returned [`RowChange`]s to the WAL in place of the SQL text.
6297    pub fn drain_redo(&mut self) -> Vec<RowChange> {
6298        let mut all = Vec::new();
6299        for t in &mut self.tables {
6300            all.extend(t.take_redo());
6301        }
6302        all
6303    }
6304
6305    pub fn table_count(&self) -> usize {
6306        self.tables.len()
6307    }
6308
6309    /// v7.14.0 — remove a table by name. Returns `true` when the
6310    /// table existed (and is now gone), `false` when it didn't.
6311    /// Used by `DROP TABLE` from pg_dump / mysqldump preambles
6312    /// where the dump re-creates schema and starts with
6313    /// `DROP TABLE IF EXISTS`.
6314    pub fn drop_table(&mut self, name: &str) -> bool {
6315        // v7.39 (round 436) — resolve through the session's temp namespace
6316        // first, exactly as a read would: MariaDB's plain `DROP TABLE tmp`
6317        // drops the TEMPORARY one and leaves a permanent namesake standing
6318        // (measured). Removing by the raw name would have dropped the
6319        // permanent table out from under every other session.
6320        let key = match self.temp_prefix.as_ref() {
6321            Some(p) => {
6322                let mangled = alloc::format!("{p}{name}");
6323                if self.by_name.contains_key(&mangled) {
6324                    mangled
6325                } else {
6326                    name.into()
6327                }
6328            }
6329            None => name.into(),
6330        };
6331        let Some(idx) = self.by_name.remove(&key) else {
6332            return false;
6333        };
6334        // v7.39 (round 496) — see `dirty_tables`. Recorded under the
6335        // RESOLVED key, which is what a commit-time merge looks up.
6336        self.dirty_tables.insert(key.clone());
6337        // swap_remove invalidates the trailing index → rebuild
6338        // by_name for affected entries.
6339        self.tables.swap_remove(idx);
6340        // Re-stamp moved table's index slot in by_name.
6341        if idx < self.tables.len() {
6342            let moved_name = self.tables[idx].schema.name.clone();
6343            self.by_name.insert(moved_name, idx);
6344        }
6345        true
6346    }
6347
6348    /// v7.16.2 — rename a table (mailrs round-10 A.5). Updates
6349    /// the schema name, the catalog name → index map, and
6350    /// rewrites every reference dangling at the table name:
6351    ///   * every FK on every OTHER table whose `parent_table`
6352    ///     pointed at the old name now points at the new
6353    ///     name, so FK enforcement keeps working
6354    ///   * every trigger watching the table updates its `table`
6355    ///     field
6356    /// Returns `Ok` on success; `Err(StorageError::TableNotFound)`
6357    /// when the old name isn't in the catalog and
6358    /// `Err(StorageError::DuplicateTable)` when the new name is
6359    /// already taken.
6360    pub fn rename_table(&mut self, old: &str, new: &str) -> Result<(), StorageError> {
6361        if old == new {
6362            return Ok(());
6363        }
6364        if self.by_name.contains_key(new) {
6365            return Err(StorageError::Corrupt(format!(
6366                "rename_table: target name {new:?} already exists"
6367            )));
6368        }
6369        let idx = self
6370            .by_name
6371            .remove(old)
6372            .ok_or_else(|| StorageError::TableNotFound { name: old.into() })?;
6373        self.tables[idx].schema.name = new.to_string();
6374        self.by_name.insert(new.to_string(), idx);
6375        for t in &mut self.tables {
6376            for fk in &mut t.schema.foreign_keys {
6377                if fk.parent_table == old {
6378                    fk.parent_table = new.to_string();
6379                }
6380            }
6381        }
6382        for trig in &mut self.triggers {
6383            if trig.table == old {
6384                trig.table = new.to_string();
6385            }
6386        }
6387        Ok(())
6388    }
6389
6390    /// v7.16.2 — rename an index by name. Walks every table
6391    /// since the index lives on its owning table; updates the
6392    /// name in place. Errors with `IndexNotFound` when no
6393    /// index matches. mailrs round-10 A.5.
6394    pub fn rename_index(&mut self, old: &str, new: &str) -> Result<(), StorageError> {
6395        if old == new {
6396            return Ok(());
6397        }
6398        // Reject the new name if it already exists anywhere.
6399        for t in &self.tables {
6400            if t.indices.iter().any(|i| i.name == new) {
6401                return Err(StorageError::Corrupt(format!(
6402                    "rename_index: target name {new:?} already exists"
6403                )));
6404            }
6405        }
6406        for t in &mut self.tables {
6407            for i in &mut t.indices {
6408                if i.name == old {
6409                    i.name = new.to_string();
6410                    return Ok(());
6411                }
6412            }
6413        }
6414        Err(StorageError::IndexNotFound { name: old.into() })
6415    }
6416
6417    /// v7.14.0 — remove a named index across the catalog.
6418    /// Returns `true` when found + dropped.
6419    pub fn drop_named_index(&mut self, name: &str) -> bool {
6420        for t in &mut self.tables {
6421            let before = t.indices.len();
6422            t.indices.retain(|i| i.name != name);
6423            if t.indices.len() != before {
6424                return true;
6425            }
6426        }
6427        false
6428    }
6429
6430    /// Borrow-free copy of every table's name in catalog order
6431    /// (= insertion order, matching the on-disk encoding).
6432    pub fn table_names(&self) -> Vec<String> {
6433        self.tables.iter().map(|t| t.schema.name.clone()).collect()
6434    }
6435
6436    /// v7.39 (round 436) — the marker every session's temporary-table
6437    /// namespace starts with. Public so the catalog synths can tell a
6438    /// temp table from an ordinary one without knowing the session id.
6439    pub const TEMP_NAME_MARKER: &'static str = "__spg_temp_";
6440
6441    /// v7.39 (round 437) — how a stored table name should appear to the
6442    /// CALLING session in a catalog listing (SHOW TABLES, pg_class,
6443    /// information_schema, …):
6444    ///   * an ordinary table → its own name
6445    ///   * this session's temporary table → its logical name, prefix stripped
6446    ///   * another session's temporary table → `None`, i.e. not listed
6447    ///
6448    /// Measured on both oracles: MariaDB 11 and PG 18 each list the calling
6449    /// session's own temporary tables and neither lists anybody else's.
6450    /// Round 436 stored temp tables under a prefix without teaching the
6451    /// listings about it, so the mangled names leaked to every client.
6452    #[must_use]
6453    pub fn listed_name<'a>(&self, stored: &'a str) -> Option<&'a str> {
6454        if !stored.starts_with(Self::TEMP_NAME_MARKER) {
6455            return Some(stored);
6456        }
6457        let prefix = self.temp_prefix.as_ref()?;
6458        stored.strip_prefix(prefix.as_str())
6459    }
6460
6461    /// The listing names of every table this session may see, in catalog
6462    /// order. See [`Catalog::listed_name`].
6463    #[must_use]
6464    pub fn visible_table_names(&self) -> Vec<String> {
6465        self.tables
6466            .iter()
6467            .filter_map(|t| self.listed_name(&t.schema.name).map(String::from))
6468            .collect()
6469    }
6470
6471    /// v5.1: register a cold-tier segment that already lives in
6472    /// memory (caller did the file read). Returns the
6473    /// `segment_id` that `RowLocator::Cold { segment_id, .. }`
6474    /// will reference — currently this is just the index into
6475    /// `cold_segments`, but treat it as an opaque token.
6476    ///
6477    /// Storage is `no_std`, so file I/O is the caller's
6478    /// responsibility — `spg-server` reads the file and forwards
6479    /// the bytes here. The bytes stay resident in the catalog
6480    /// for the life of the `Catalog`, parsed only once.
6481    pub fn load_segment_bytes(&mut self, bytes: Vec<u8>) -> Result<u32, StorageError> {
6482        let id = u32::try_from(self.cold_segments.len()).map_err(|_| {
6483            StorageError::Corrupt("cold segment count would exceed u32::MAX".into())
6484        })?;
6485        let seg = OwnedSegment::from_bytes(bytes)
6486            .map_err(|e| StorageError::Corrupt(format!("cold segment parse failed: {e}")))?;
6487        self.cold_segments.push(Some(Arc::new(seg)));
6488        Ok(id)
6489    }
6490
6491    /// v6.7.3 — register a cold-tier segment at a specific id. Used
6492    /// by the spg-server manifest-boot path so segments whose
6493    /// neighbouring ids were retired by compaction still get back
6494    /// the same `segment_id` they had pre-restart (the
6495    /// `RowLocator::Cold { segment_id }` baked into the BTree-index
6496    /// snapshot persists across restart and must continue to
6497    /// resolve).
6498    ///
6499    /// Pads the Vec with `None` slots up to `target_id` if needed.
6500    /// Errors when the target slot is already occupied (would
6501    /// stomp another segment), the parse fails, or `target_id`
6502    /// exceeds `u32::MAX`.
6503    pub fn load_segment_bytes_at(
6504        &mut self,
6505        target_id: u32,
6506        bytes: Vec<u8>,
6507    ) -> Result<(), StorageError> {
6508        let seg = OwnedSegment::from_bytes(bytes)
6509            .map_err(|e| StorageError::Corrupt(format!("cold segment parse failed: {e}")))?;
6510        let idx = target_id as usize;
6511        while self.cold_segments.len() <= idx {
6512            self.cold_segments.push(None);
6513        }
6514        if self.cold_segments[idx].is_some() {
6515            return Err(StorageError::Corrupt(format!(
6516                "load_segment_bytes_at: segment_id {target_id} already occupied"
6517            )));
6518        }
6519        self.cold_segments[idx] = Some(Arc::new(seg));
6520        Ok(())
6521    }
6522
6523    /// v6.7.3 — retire a cold-tier segment slot (compaction-driven).
6524    /// The physical file is the caller's concern (typically kept
6525    /// on disk until the next CHECKPOINT writes a manifest that
6526    /// no longer lists it); this just flips the in-memory slot
6527    /// to `None` so later cold lookups for `segment_id` resolve
6528    /// as "unknown" instead of returning a stale row.
6529    ///
6530    /// No-op when the slot is already `None`. Errors only when
6531    /// `segment_id` is out of bounds.
6532    pub fn tombstone_segment(&mut self, segment_id: u32) -> Result<(), StorageError> {
6533        let idx = segment_id as usize;
6534        if idx >= self.cold_segments.len() {
6535            return Err(StorageError::Corrupt(format!(
6536                "tombstone_segment: segment_id {segment_id} out of bounds (len={})",
6537                self.cold_segments.len()
6538            )));
6539        }
6540        self.cold_segments[idx] = None;
6541        Ok(())
6542    }
6543
6544    /// Number of *active* (non-tombstoned) cold segments.
6545    #[must_use]
6546    pub fn cold_segment_count(&self) -> usize {
6547        self.cold_segments.iter().filter(|s| s.is_some()).count()
6548    }
6549
6550    /// v7.37.42 (docker-fair SCALARSQ attack 3) — short-circuit guard
6551    /// for scan loops that conditionally walk the cold tier. Returns
6552    /// `false` when the catalog has never loaded a cold segment (or all
6553    /// segments are tombstoned), so callers can skip the per-table cold
6554    /// PK-index walk entirely on hot-only databases. O(N segments);
6555    /// typical N is small (single-digit) so the check is sub-µs.
6556    #[must_use]
6557    pub fn has_any_cold_segments(&self) -> bool {
6558        self.cold_segments.iter().any(Option::is_some)
6559    }
6560
6561    /// Slot count including tombstones (= the next id the
6562    /// no-arg `load_segment_bytes` would allocate).
6563    #[must_use]
6564    pub fn cold_segment_slot_count(&self) -> usize {
6565        self.cold_segments.len()
6566    }
6567
6568    /// v6.2.7 — list every *active* cold-tier segment id known to
6569    /// this catalog (skips compaction tombstones since v6.7.3).
6570    /// Used by EXPLAIN ANALYZE to annotate scan nodes with the
6571    /// segments they could have walked.
6572    #[must_use]
6573    pub fn cold_segment_ids_global(&self) -> Vec<u32> {
6574        self.cold_segments
6575            .iter()
6576            .enumerate()
6577            .filter_map(|(i, s)| s.as_ref().map(|_| i as u32))
6578            .collect()
6579    }
6580
6581    /// v5.2.1: sum of `Table::hot_bytes` across every table. The v5.2
6582    /// freezer compares this against `SPG_HOT_TIER_BYTES` (parsed at
6583    /// server startup; default 4 GiB) and wakes when the budget is
6584    /// crossed. Pre-freezer (v5.2.1) this is measurement-only — the
6585    /// counter exposes whether the budget is being approached without
6586    /// triggering any demotion.
6587    #[must_use]
6588    pub fn hot_tier_bytes(&self) -> u64 {
6589        self.tables
6590            .iter()
6591            .map(Table::hot_bytes)
6592            .fold(0u64, u64::saturating_add)
6593    }
6594
6595    /// v5.2.2: freeze the **first** `max_rows` rows of `table_name`'s
6596    /// hot tier into a brand-new cold-tier segment. The named `BTree`
6597    /// index supplies the per-row PK (its column must be an integer
6598    /// type — v5.2.2 only supports `IndexKey::Int` PKs, matching the
6599    /// `index_key_as_u64` constraint used by the cold-tier lookup
6600    /// path). On success returns a [`FreezeReport`] with the
6601    /// freshly-allocated segment id, the count of rows that moved,
6602    /// the encoded segment bytes (so the caller can persist them to
6603    /// disk for later reload via `SPG_PRELOAD_COLD_SEGMENT`), and the
6604    /// hot-tier byte delta that was reclaimed.
6605    ///
6606    /// **Semantics**:
6607    /// 1. The first `max_rows` rows (by hot-tier position — same as
6608    ///    insertion order under v4.39 `PersistentVec`) are read.
6609    /// 2. Rows are sorted ascending by PK and serialised into a new
6610    ///    segment via [`encode_segment`].
6611    /// 3. The hot rows are dropped via [`Table::delete_rows`]; the
6612    ///    `rebuild_indices` it triggers regenerates `Hot` locators
6613    ///    for every remaining row (their positions shift down by
6614    ///    `max_rows`). Existing `Cold` locators in this index — from
6615    ///    a previous freeze — are also rebuilt **but with empty
6616    ///    payload** since rebuild reads only `self.rows`; this
6617    ///    routine re-registers them at the end of the call so the
6618    ///    user-visible state preserves all prior cold locators.
6619    /// 4. The new segment is loaded into `self.cold_segments` via
6620    ///    [`Catalog::load_segment_bytes`] (allocating a fresh
6621    ///    `segment_id`). New `Cold` locators are registered on the
6622    ///    named index — one per frozen row.
6623    ///
6624    /// **v5.2.2 limits** (relaxed in later sub-versions):
6625    /// - INSERT-only flow: subsequent UPDATE/DELETE on a frozen row
6626    ///   returns a stale-locator error (no promote-on-write until
6627    ///   v5.2.3).
6628    /// - Single-table scope: callers iterate tables themselves.
6629    /// - All-or-nothing: returns `Err` and leaves catalog unchanged
6630    ///   if any step fails before the atomic swap point.
6631    ///
6632    /// Errors:
6633    /// - [`StorageError::Corrupt`] for missing table/index, non-`BTree`
6634    ///   index, non-integer PK column, `max_rows == 0`, or
6635    ///   `max_rows > row_count`.
6636    /// - The encoder's [`SegmentError`] surfaces as `Corrupt` (the
6637    ///   only realistic source is "a single row is larger than the
6638    ///   page size"; SPG schemas don't hit it in practice).
6639    pub fn freeze_oldest_to_cold(
6640        &mut self,
6641        table_name: &str,
6642        index_name: &str,
6643        max_rows: usize,
6644    ) -> Result<FreezeReport, StorageError> {
6645        // --- validation phase: never mutates ---------------------
6646        if max_rows == 0 {
6647            return Err(StorageError::Corrupt(
6648                "freeze_oldest_to_cold: max_rows must be > 0".into(),
6649            ));
6650        }
6651        let table = self.get(table_name).ok_or_else(|| {
6652            StorageError::Corrupt(format!(
6653                "freeze_oldest_to_cold: table {table_name:?} not found"
6654            ))
6655        })?;
6656        if max_rows > table.rows.len() {
6657            return Err(StorageError::Corrupt(format!(
6658                "freeze_oldest_to_cold: max_rows {max_rows} > row_count {}",
6659                table.rows.len()
6660            )));
6661        }
6662        let idx = table
6663            .indices
6664            .iter()
6665            .find(|i| i.name == index_name)
6666            .ok_or_else(|| {
6667                StorageError::Corrupt(format!(
6668                    "freeze_oldest_to_cold: index {index_name:?} not found on {table_name:?}"
6669                ))
6670            })?;
6671        if !matches!(idx.kind, IndexKind::BTree(_)) {
6672            return Err(StorageError::Corrupt(format!(
6673                "freeze_oldest_to_cold: index {index_name:?} is NSW; only BTree indices may freeze"
6674            )));
6675        }
6676        let column_position = idx.column_position;
6677
6678        // --- segment build phase: reads only --------------------
6679        let schema = table.schema.clone();
6680        let mut to_freeze: Vec<(u64, Vec<u8>, IndexKey)> = Vec::with_capacity(max_rows);
6681        for row_idx in 0..max_rows {
6682            let row = table.rows.get(row_idx).expect("bounds-checked above");
6683            let key = IndexKey::from_value(&row.values[column_position]).ok_or_else(|| {
6684                StorageError::Corrupt(format!(
6685                    "freeze_oldest_to_cold: row {row_idx} has NULL / non-key value in index column"
6686                ))
6687            })?;
6688            let pk_u64 = index_key_as_u64(&key).ok_or_else(|| {
6689                StorageError::Corrupt(format!(
6690                    "freeze_oldest_to_cold: index {index_name:?} column type is non-integer; \
6691                     v5.2.2 cold tier requires IndexKey::Int (Text PK lands in v5.5+)"
6692                ))
6693            })?;
6694            to_freeze.push((pk_u64, encode_row_body_dense(row, &schema), key));
6695        }
6696        // encode_segment requires ascending u64 keys. Sort by PK
6697        // before encoding; the caller's row-position order is not
6698        // necessarily PK order (e.g. workloads that insert random
6699        // PKs).
6700        to_freeze.sort_by_key(|(k, _, _)| *k);
6701        // Reject duplicate PKs — encode_segment also rejects them
6702        // (`SegmentError::UnsortedKey`), but the resulting error
6703        // message there is misleading. Surface a clearer one.
6704        for w in to_freeze.windows(2) {
6705            if w[0].0 == w[1].0 {
6706                return Err(StorageError::Corrupt(format!(
6707                    "freeze_oldest_to_cold: duplicate PK {} in freeze batch",
6708                    w[0].0
6709                )));
6710            }
6711        }
6712        // Snapshot the (key, locator) pairs that will be registered
6713        // post-swap. Cloning the IndexKey out before the move makes
6714        // the registration loop borrow-free.
6715        let post_swap_keys: Vec<IndexKey> = to_freeze.iter().map(|(_, _, k)| k.clone()).collect();
6716        // Segment encode is now infallible w.r.t. ordering. Map the
6717        // `SegmentError` into a `StorageError::Corrupt` so the
6718        // public surface stays one error type.
6719        let seg_rows: Vec<(u64, Vec<u8>)> = to_freeze
6720            .into_iter()
6721            .map(|(k, body, _)| (k, body))
6722            .collect();
6723        let frozen_rows = seg_rows.len();
6724        let (seg_bytes, _meta) = encode_segment(seg_rows.into_iter(), 0.01, SEGMENT_PAGE_BYTES)
6725            .map_err(|e| StorageError::Corrupt(format!("freeze_oldest_to_cold: encode: {e}")))?;
6726
6727        // --- atomic swap phase: mutations only past this point ---
6728        // v5.2.3 made `Table::rebuild_indices` preserve every Cold
6729        // locator across the per-table rebuild, so `delete_rows`
6730        // below no longer wipes prior-freeze cold entries. The pre-
6731        // v5.2.3 capture-then-re-register that used to live here
6732        // was removed in v5.3.1 — keeping it would double-count
6733        // every prior-frozen key's Cold locator on each subsequent
6734        // freeze.
6735        let bytes_before = self.get(table_name).expect("just validated").hot_bytes();
6736        let positions: Vec<usize> = (0..max_rows).collect();
6737        let t_mut = self
6738            .get_mut(table_name)
6739            .expect("just validated; still present");
6740        let removed = t_mut.delete_rows(&positions);
6741        debug_assert_eq!(removed, max_rows, "delete_rows count matches request");
6742        let bytes_after = t_mut.hot_bytes();
6743        let bytes_freed = bytes_before.saturating_sub(bytes_after);
6744
6745        let segment_id = self
6746            .load_segment_bytes(seg_bytes.clone())
6747            .map_err(|e| StorageError::Corrupt(format!("freeze_oldest_to_cold: load: {e}")))?;
6748        let new_cold = post_swap_keys.into_iter().map(|k| {
6749            (
6750                k,
6751                RowLocator::Cold {
6752                    segment_id,
6753                    page_offset: 0,
6754                },
6755            )
6756        });
6757        let t_mut = self.get_mut(table_name).expect("still present");
6758        t_mut.register_cold_locators(index_name, new_cold)?;
6759        // r944 — a freeze has to say that it froze something.
6760        //
6761        // `has_cold_rows_fast()` reads the cached count, and neither
6762        // freeze path touched it, so afterwards it answered "no cold
6763        // rows" while cold rows existed. That predicate gates four join
6764        // paths, and a gate that wrongly declines the cold-aware path
6765        // drops the frozen rows from the answer.
6766        //
6767        // Marking it stale rather than adding to it: stale reads as
6768        // true, which is the safe direction, and this function cannot
6769        // know the exact total (rows may already have been cold). ANALYZE
6770        // recomputes the number.
6771        t_mut.mark_cold_row_count_stale();
6772
6773        Ok(FreezeReport {
6774            segment_id,
6775            frozen_rows,
6776            bytes_freed,
6777            segment_bytes: seg_bytes,
6778        })
6779    }
6780
6781    /// v5.1: borrow the cold segment at `segment_id`. Used by the
6782    /// spg-server preload path to enumerate (key, locator) pairs
6783    /// after loading a segment, so it can call
6784    /// [`Table::register_cold_locators`] without re-parsing the
6785    /// bytes.
6786    #[must_use]
6787    pub fn cold_segment(&self, segment_id: u32) -> Option<&OwnedSegment> {
6788        self.cold_segments
6789            .get(segment_id as usize)
6790            .and_then(|s| s.as_deref())
6791    }
6792
6793    /// v5.1: resolve a single `RowLocator::Cold` to its underlying
6794    /// `Row`. Decoupled from [`Catalog::lookup_by_pk`] so callers
6795    /// iterating a multi-locator slice (e.g. the engine's index
6796    /// seek path) can dispatch per locator instead of getting back
6797    /// only the first row for a key. Returns `None` when the
6798    /// segment isn't registered, the key isn't `u64`-coercible, or
6799    /// the segment doesn't actually carry the key (bloom or page-
6800    /// index reject).
6801    pub fn resolve_cold_locator(
6802        &self,
6803        table_name: &str,
6804        segment_id: u32,
6805        key: &IndexKey,
6806    ) -> Option<Row<'static>> {
6807        let t = self.get(table_name)?;
6808        let u64_key = index_key_as_u64(key)?;
6809        let seg = self.cold_segments.get(segment_id as usize)?.as_ref()?;
6810        let payload = seg.lookup(u64_key)?;
6811        let (row, _) = decode_row_body_dense(&payload, &t.schema, seg.codec_version()).ok()?;
6812        // v7.39 (pg_stat blks knife) — one cold-tier "block read".
6813        self.cold_read_stats
6814            .cold_reads
6815            .fetch_add(1, core::sync::atomic::Ordering::Relaxed);
6816        Some(row)
6817    }
6818
6819    /// v5.1: indexed PK lookup that dispatches per locator,
6820    /// returning the first matching row from either the hot tier
6821    /// (`Table::rows`) or a registered cold segment.
6822    ///
6823    /// The cold path requires the index column to be coercible to
6824    /// a `u64` (the segment's PK type) and the segment payload to
6825    /// be a [`encode_row_body_dense`]-encoded row body for the
6826    /// same schema. v5.1 ships this for BIGINT / INT / SMALLINT
6827    /// PKs; other types fall through to hot-only behavior.
6828    ///
6829    /// Returns `None` if (a) the table or index doesn't exist,
6830    /// (b) the key isn't in the index at all, or (c) the key was
6831    /// resolved to a stale locator (Hot index out of range, Cold
6832    /// segment id unknown, segment lookup miss). Does not surface
6833    /// segment-decode errors — those would indicate corrupted
6834    /// cold-tier files and should be caught at
6835    /// [`Catalog::load_segment_bytes`] time.
6836    pub fn lookup_by_pk(&self, table: &str, index_name: &str, key: &IndexKey) -> Option<Row<'_>> {
6837        let t = self.get(table)?;
6838        let idx = t.indices.iter().find(|i| i.name == index_name)?;
6839        let locators = idx.lookup_eq(key);
6840        let cold_u64_key = index_key_as_u64(key);
6841        for loc in locators {
6842            match *loc {
6843                RowLocator::Hot(i) => {
6844                    if let Some(row) = t.rows.get(i) {
6845                        return Some(row.clone());
6846                    }
6847                }
6848                RowLocator::Cold {
6849                    segment_id,
6850                    page_offset: _,
6851                } => {
6852                    let Some(u64_key) = cold_u64_key else {
6853                        // Key type not coercible to u64 — cold tier
6854                        // only handles BIGINT/INT/SMALLINT in v5.1.
6855                        continue;
6856                    };
6857                    let Some(seg) = self
6858                        .cold_segments
6859                        .get(segment_id as usize)
6860                        .and_then(|s| s.as_deref())
6861                    else {
6862                        // v6.7.3 — `None` slot = compaction
6863                        // retired this segment; the live locator
6864                        // on a freshly-compacted index points to
6865                        // the merged segment_id, so a Cold hit
6866                        // here against a tombstone means the BTree
6867                        // entry hasn't been swapped yet (mid-
6868                        // compaction reader race) or the caller is
6869                        // looking up a stale snapshot. Skip — the
6870                        // next locator in the list, if any, is
6871                        // typically the merged segment.
6872                        continue;
6873                    };
6874                    let Some(payload) = seg.lookup(u64_key) else {
6875                        continue;
6876                    };
6877                    let (row, _) =
6878                        decode_row_body_dense(&payload, &t.schema, seg.codec_version()).ok()?;
6879                    return Some(row);
6880                }
6881            }
6882        }
6883        None
6884    }
6885
6886    /// v5.2.3: promote a frozen row back to the hot tier so an
6887    /// UPDATE / DELETE can mutate it. Reads the cold-tier row body
6888    /// (decoded from its registered segment), pushes it into
6889    /// `table.rows` via [`Table::insert`] (which also adds a fresh
6890    /// `Hot(new_idx)` locator on `index_name`), then retires the
6891    /// shadowed `Cold` locator via
6892    /// [`Table::remove_cold_locators_for_key`]. The cold-tier row
6893    /// in the segment file becomes garbage — recoverable when a
6894    /// future cold-segment compaction job lands.
6895    ///
6896    /// Returns:
6897    /// - `Ok(Some(new_hot_idx))` when the key resolved through a
6898    ///   cold locator and the promote completed. `new_hot_idx` is
6899    ///   the position the row now occupies in `table.rows`.
6900    /// - `Ok(None)` when the key has no Cold locator on the index
6901    ///   (already hot, or wasn't present at all). Callers treat this
6902    ///   as "nothing to do here, fall back to the hot-only path".
6903    ///
6904    /// Errors when the table / index doesn't exist, the index isn't
6905    /// `BTree`, the cold segment is missing / can't decode the row,
6906    /// or the inferred row body fails `Table::insert` validation.
6907    pub fn promote_cold_row(
6908        &mut self,
6909        table_name: &str,
6910        index_name: &str,
6911        key: &IndexKey,
6912    ) -> Result<Option<usize>, StorageError> {
6913        let cold_loc = self.find_cold_locator(table_name, index_name, key)?;
6914        let Some((segment_id, _page_offset)) = cold_loc else {
6915            return Ok(None);
6916        };
6917        let u64_key = index_key_as_u64(key).ok_or_else(|| {
6918            StorageError::Corrupt(
6919                "promote_cold_row: key type not coercible to u64 (cold tier requires integer PK)"
6920                    .into(),
6921            )
6922        })?;
6923        // Read the row body from the segment. Borrow the segment +
6924        // schema short-term so we can then take `&mut self` for the
6925        // hot-side insert.
6926        let schema = self
6927            .get(table_name)
6928            .ok_or_else(|| {
6929                StorageError::Corrupt(format!("promote_cold_row: table {table_name:?} not found"))
6930            })?
6931            .schema
6932            .clone();
6933        let seg = self
6934            .cold_segments
6935            .get(segment_id as usize)
6936            .and_then(|s| s.as_ref())
6937            .ok_or_else(|| {
6938                StorageError::Corrupt(format!(
6939                    "promote_cold_row: segment {segment_id} not registered on catalog"
6940                ))
6941            })?;
6942        let payload = seg.lookup(u64_key).ok_or_else(|| {
6943            StorageError::Corrupt(format!(
6944                "promote_cold_row: key {u64_key} resolves to segment {segment_id} \
6945                 but the segment's bloom/page lookup didn't return a row"
6946            ))
6947        })?;
6948        let (row, _consumed) = decode_row_body_dense(&payload, &schema, seg.codec_version())?;
6949        // Insert the promoted row into the hot tier. `Table::insert`
6950        // appends to `self.rows`, adds a `Hot(new_idx)` locator to
6951        // every BTree index covering the row's keyed columns, and
6952        // increments `hot_bytes`.
6953        let t = self
6954            .get_mut(table_name)
6955            .expect("table existed at lookup time");
6956        t.insert(row)?;
6957        let new_hot_idx =
6958            t.rows.len().checked_sub(1).ok_or_else(|| {
6959                StorageError::Corrupt("promote_cold_row: empty after insert".into())
6960            })?;
6961        // The hot insert added Hot(new_idx) alongside the still-
6962        // present Cold locator. Drop the Cold entry so future
6963        // lookups return only the fresh hot row.
6964        t.remove_cold_locators_for_key(index_name, key)?;
6965        Ok(Some(new_hot_idx))
6966    }
6967
6968    /// v5.2.3: shadow a frozen row's index entry. Used by DELETE
6969    /// when the row to remove lives in a cold-tier segment — the
6970    /// row body stays in the segment file (becoming garbage) but
6971    /// every `Cold` locator for `key` on `index_name` is removed
6972    /// so PK lookups stop returning it.
6973    ///
6974    /// Returns the number of cold locators retired (0 when the key
6975    /// has no cold entries — the DELETE fell on a hot row or a
6976    /// key that was already absent). Errors when the table /
6977    /// index doesn't exist or the index isn't `BTree`.
6978    ///
6979    /// Cold-segment compaction (which merges shadowed-heavy
6980    /// segments and reclaims their disk footprint) lands in a
6981    /// later v5.x sub-version; until then, repeated UPDATE/DELETE
6982    /// of cold rows can amplify cold-segment disk usage by up to
6983    /// 1-2× — still well under typical LSM-tree shadowing because
6984    /// SPG segments are bulk-baked, not write-merged.
6985    pub fn shadow_cold_row(
6986        &mut self,
6987        table_name: &str,
6988        index_name: &str,
6989        key: &IndexKey,
6990    ) -> Result<usize, StorageError> {
6991        let t = self.get_mut(table_name).ok_or_else(|| {
6992            StorageError::Corrupt(format!("shadow_cold_row: table {table_name:?} not found"))
6993        })?;
6994        t.remove_cold_locators_for_key(index_name, key)
6995    }
6996
6997    /// v6.7.4 — read-only slice preparation for the parallel
6998    /// freezer. Walks rows in `row_range`, builds the
6999    /// `(pk_u64, encoded_body, IndexKey)` triples that the
7000    /// coordinator's k-way merge consumes, sorts the slice by
7001    /// `pk_u64`, and returns a [`FreezeSlice`].
7002    ///
7003    /// Caller invariants:
7004    /// - `row_range.end <= table.rows.len()` (caller's job to
7005    ///   compute the partition).
7006    /// - All slices passed to `commit_freeze_slices` must cover a
7007    ///   contiguous half-open range `[0, total_max_rows)` with no
7008    ///   gaps and no overlaps. The coordinator validates this
7009    ///   invariant before committing.
7010    ///
7011    /// `&self`-only — multiple workers can run this concurrently
7012    /// against the same `Catalog` reference under the engine's
7013    /// write lock (workers don't mutate; the coordinator does).
7014    pub fn prepare_freeze_slice(
7015        &self,
7016        table_name: &str,
7017        index_name: &str,
7018        row_range: core::ops::Range<usize>,
7019    ) -> Result<FreezeSlice, StorageError> {
7020        let table = self.get(table_name).ok_or_else(|| {
7021            StorageError::Corrupt(format!(
7022                "prepare_freeze_slice: table {table_name:?} not found"
7023            ))
7024        })?;
7025        let idx = table
7026            .indices
7027            .iter()
7028            .find(|i| i.name == index_name)
7029            .ok_or_else(|| {
7030                StorageError::Corrupt(format!(
7031                    "prepare_freeze_slice: index {index_name:?} not found on {table_name:?}"
7032                ))
7033            })?;
7034        if !matches!(idx.kind, IndexKind::BTree(_)) {
7035            return Err(StorageError::Corrupt(format!(
7036                "prepare_freeze_slice: index {index_name:?} is NSW; only BTree indices may freeze"
7037            )));
7038        }
7039        if row_range.end > table.rows.len() {
7040            return Err(StorageError::Corrupt(format!(
7041                "prepare_freeze_slice: row_range end {} > row_count {}",
7042                row_range.end,
7043                table.rows.len()
7044            )));
7045        }
7046        let column_position = idx.column_position;
7047        let schema = table.schema.clone();
7048        let mut rows: Vec<(u64, Vec<u8>, IndexKey)> = Vec::with_capacity(row_range.len());
7049        for row_idx in row_range.clone() {
7050            let row = table.rows.get(row_idx).expect("bounds-checked above");
7051            let key = IndexKey::from_value(&row.values[column_position]).ok_or_else(|| {
7052                StorageError::Corrupt(format!(
7053                    "prepare_freeze_slice: row {row_idx} has NULL / non-key value in index column"
7054                ))
7055            })?;
7056            let pk_u64 = index_key_as_u64(&key).ok_or_else(|| {
7057                StorageError::Corrupt(format!(
7058                    "prepare_freeze_slice: index {index_name:?} column type is non-integer; \
7059                     v5.2.2 cold tier requires IndexKey::Int (Text PK lands in v5.5+)"
7060                ))
7061            })?;
7062            rows.push((pk_u64, encode_row_body_dense(row, &schema), key));
7063        }
7064        rows.sort_by_key(|(k, _, _)| *k);
7065        Ok(FreezeSlice { row_range, rows })
7066    }
7067
7068    /// v6.7.4 — coordinator commit step. Merges N
7069    /// [`FreezeSlice`]s into one segment via the standard
7070    /// [`encode_segment`] path, atomically swaps the catalog
7071    /// state (delete the union row range + register Cold
7072    /// locators + load the segment).
7073    ///
7074    /// Validates that the slices cover a contiguous, gap-free,
7075    /// overlap-free half-open range starting at index 0 (the
7076    /// freezer always freezes "oldest first" — same semantics as
7077    /// the single-threaded [`Catalog::freeze_oldest_to_cold`]).
7078    ///
7079    /// Empty `slices` → no-op success (returns a zero-row report
7080    /// without mutating). Total row count = `Σ slice.rows.len()`.
7081    pub fn commit_freeze_slices(
7082        &mut self,
7083        table_name: &str,
7084        index_name: &str,
7085        slices: Vec<FreezeSlice>,
7086    ) -> Result<FreezeReport, StorageError> {
7087        // --- validation phase: never mutates ---------------------
7088        let table = self.get(table_name).ok_or_else(|| {
7089            StorageError::Corrupt(format!(
7090                "commit_freeze_slices: table {table_name:?} not found"
7091            ))
7092        })?;
7093        let idx = table
7094            .indices
7095            .iter()
7096            .find(|i| i.name == index_name)
7097            .ok_or_else(|| {
7098                StorageError::Corrupt(format!(
7099                    "commit_freeze_slices: index {index_name:?} not found on {table_name:?}"
7100                ))
7101            })?;
7102        if !matches!(idx.kind, IndexKind::BTree(_)) {
7103            return Err(StorageError::Corrupt(format!(
7104                "commit_freeze_slices: index {index_name:?} is NSW; only BTree indices may freeze"
7105            )));
7106        }
7107        // Validate slice coverage: contiguous from 0, no gaps, no
7108        // overlaps. Allow the caller to pass slices in any order —
7109        // sort by row_range.start first.
7110        let mut ordered = slices;
7111        ordered.sort_by_key(|s| s.row_range.start);
7112        // Drop fully-empty slices that fell out of an uneven
7113        // partition; they carry no data but contribute to the
7114        // contiguity check, so keep them in line.
7115        let mut expected_start = 0usize;
7116        for s in &ordered {
7117            if s.row_range.start != expected_start {
7118                return Err(StorageError::Corrupt(format!(
7119                    "commit_freeze_slices: gap/overlap at row {}; expected start {}",
7120                    s.row_range.start, expected_start
7121                )));
7122            }
7123            expected_start = s.row_range.end;
7124        }
7125        let max_rows = expected_start;
7126        if max_rows > table.rows.len() {
7127            return Err(StorageError::Corrupt(format!(
7128                "commit_freeze_slices: total row range {} exceeds row_count {}",
7129                max_rows,
7130                table.rows.len()
7131            )));
7132        }
7133        if max_rows == 0 {
7134            return Ok(FreezeReport {
7135                segment_id: u32::MAX,
7136                frozen_rows: 0,
7137                bytes_freed: 0,
7138                segment_bytes: Vec::new(),
7139            });
7140        }
7141
7142        // --- segment build phase: reads only --------------------
7143        // K-way merge of already-sorted slices. Each slice's rows
7144        // are ascending by pk_u64; we keep a per-slice cursor and
7145        // pull the next-smallest head until every cursor drains.
7146        let total_rows: usize = ordered.iter().map(|s| s.rows.len()).sum();
7147        if total_rows != max_rows {
7148            return Err(StorageError::Corrupt(format!(
7149                "commit_freeze_slices: total slice rows {total_rows} ≠ row_range coverage {max_rows}"
7150            )));
7151        }
7152        let mut cursors: Vec<usize> = alloc::vec![0; ordered.len()];
7153        let mut merged: Vec<(u64, Vec<u8>, IndexKey)> = Vec::with_capacity(total_rows);
7154        loop {
7155            // Pick the slice whose head row has the smallest key
7156            // and isn't yet exhausted.
7157            let mut pick: Option<usize> = None;
7158            for (i, c) in cursors.iter().enumerate() {
7159                let slice = &ordered[i];
7160                if *c >= slice.rows.len() {
7161                    continue;
7162                }
7163                match pick {
7164                    None => pick = Some(i),
7165                    Some(j) => {
7166                        if slice.rows[*c].0 < ordered[j].rows[cursors[j]].0 {
7167                            pick = Some(i);
7168                        }
7169                    }
7170                }
7171            }
7172            let Some(i) = pick else { break };
7173            let row = ordered[i].rows[cursors[i]].clone();
7174            cursors[i] += 1;
7175            merged.push(row);
7176        }
7177        // Reject duplicate PKs — same error as the single-threaded
7178        // path so callers get a uniform surface.
7179        for w in merged.windows(2) {
7180            if w[0].0 == w[1].0 {
7181                return Err(StorageError::Corrupt(format!(
7182                    "commit_freeze_slices: duplicate PK {} across slices",
7183                    w[0].0
7184                )));
7185            }
7186        }
7187        let post_swap_keys: Vec<IndexKey> = merged.iter().map(|(_, _, k)| k.clone()).collect();
7188        let seg_rows: Vec<(u64, Vec<u8>)> =
7189            merged.into_iter().map(|(k, body, _)| (k, body)).collect();
7190        let frozen_rows = seg_rows.len();
7191        let (seg_bytes, _meta) = encode_segment(seg_rows.into_iter(), 0.01, SEGMENT_PAGE_BYTES)
7192            .map_err(|e| StorageError::Corrupt(format!("commit_freeze_slices: encode: {e}")))?;
7193
7194        // --- atomic swap phase: mutations only past this point ---
7195        let bytes_before = self.get(table_name).expect("just validated").hot_bytes();
7196        let positions: Vec<usize> = (0..max_rows).collect();
7197        let t_mut = self
7198            .get_mut(table_name)
7199            .expect("just validated; still present");
7200        let removed = t_mut.delete_rows(&positions);
7201        debug_assert_eq!(removed, max_rows, "delete_rows count matches request");
7202        let bytes_after = t_mut.hot_bytes();
7203        let bytes_freed = bytes_before.saturating_sub(bytes_after);
7204
7205        let segment_id = self
7206            .load_segment_bytes(seg_bytes.clone())
7207            .map_err(|e| StorageError::Corrupt(format!("commit_freeze_slices: load: {e}")))?;
7208        let new_cold = post_swap_keys.into_iter().map(|k| {
7209            (
7210                k,
7211                RowLocator::Cold {
7212                    segment_id,
7213                    page_offset: 0,
7214                },
7215            )
7216        });
7217        let t_mut = self.get_mut(table_name).expect("still present");
7218        t_mut.register_cold_locators(index_name, new_cold)?;
7219        // r944 — a freeze has to say that it froze something.
7220        //
7221        // `has_cold_rows_fast()` reads the cached count, and neither
7222        // freeze path touched it, so afterwards it answered "no cold
7223        // rows" while cold rows existed. That predicate gates four join
7224        // paths, and a gate that wrongly declines the cold-aware path
7225        // drops the frozen rows from the answer.
7226        //
7227        // Marking it stale rather than adding to it: stale reads as
7228        // true, which is the safe direction, and this function cannot
7229        // know the exact total (rows may already have been cold). ANALYZE
7230        // recomputes the number.
7231        t_mut.mark_cold_row_count_stale();
7232
7233        Ok(FreezeReport {
7234            segment_id,
7235            frozen_rows,
7236            bytes_freed,
7237            segment_bytes: seg_bytes,
7238        })
7239    }
7240
7241    /// v6.7.3 — compact every cold segment on `(table, index)` whose
7242    /// `OwnedSegment::bytes().len()` is below `target_segment_bytes`
7243    /// into a single larger merged segment. Rows present in source
7244    /// segment payloads but no longer referenced by any
7245    /// `RowLocator::Cold` on the index (DELETE'd + frozen rows
7246    /// retired via [`Catalog::shadow_cold_row`]) are GC'd in the
7247    /// merge.
7248    ///
7249    /// **Semantics**:
7250    /// 1. Walk the BTree index to collect every Cold locator that
7251    ///    targets a small (< threshold) segment. Each such
7252    ///    `(key, segment_id)` becomes a row in the merged segment;
7253    ///    payload is looked up from the source segment in-place.
7254    /// 2. Encode the collected rows into one new segment via
7255    ///    [`encode_segment`]; register it via
7256    ///    [`Catalog::load_segment_bytes`] (allocating a fresh
7257    ///    `merged_segment_id` at the end of `cold_segments`).
7258    /// 3. Rewrite the BTree index in one pass: every
7259    ///    `RowLocator::Cold { segment_id ∈ sources }` becomes
7260    ///    `RowLocator::Cold { segment_id = merged_id, page_offset = 0 }`.
7261    ///    Hot locators are untouched.
7262    /// 4. Tombstone every source slot via
7263    ///    [`Catalog::tombstone_segment`]. Source segment payloads
7264    ///    are no longer reachable through the catalog; the on-disk
7265    ///    files are the caller's concern.
7266    ///
7267    /// On fewer than 2 candidate segments the catalog is **not**
7268    /// mutated and a no-op report (`merged_segment_id: None`,
7269    /// `sources: []`) is returned. This is the routine case — a
7270    /// freshly-frozen table has at most 1 small segment, no merge
7271    /// possible.
7272    ///
7273    /// Atomicity: every mutating step runs after the read-only
7274    /// gather phase, so a panic before the merge encode leaves the
7275    /// catalog unchanged. The mutation block itself (load + rewrite +
7276    /// tombstone) takes only `&mut self` — callers serialise the
7277    /// engine write lock outside this function.
7278    ///
7279    /// Errors when the table / index doesn't exist, the index isn't
7280    /// `BTree`, the index column type isn't u64-coercible (cold-tier
7281    /// pre-condition), or a source segment fails its in-place
7282    /// row-body lookup (would indicate prior catalog corruption).
7283    pub fn compact_cold_segments(
7284        &mut self,
7285        table_name: &str,
7286        index_name: &str,
7287        target_segment_bytes: u64,
7288    ) -> Result<CompactReport, StorageError> {
7289        // --- validation phase ----------------------------------
7290        let t = self.get(table_name).ok_or_else(|| {
7291            StorageError::Corrupt(format!(
7292                "compact_cold_segments: table {table_name:?} not found"
7293            ))
7294        })?;
7295        let idx = t
7296            .indices
7297            .iter()
7298            .find(|i| i.name == index_name)
7299            .ok_or_else(|| {
7300                StorageError::Corrupt(format!(
7301                    "compact_cold_segments: index {index_name:?} not found on {table_name:?}"
7302                ))
7303            })?;
7304        let map = match &idx.kind {
7305            IndexKind::BTree(m) => m,
7306            IndexKind::Nsw(_)
7307            | IndexKind::Brin { .. }
7308            | IndexKind::Gin(_)
7309            | IndexKind::GinTrgm(_)
7310            | IndexKind::GinFulltext(_)
7311            | IndexKind::GinJsonb(_) => {
7312                return Err(StorageError::Corrupt(format!(
7313                    "compact_cold_segments: index {index_name:?} is not BTree; \
7314                     compaction applies only to BTree cold-tier indices"
7315                )));
7316            }
7317        };
7318
7319        // --- gather phase --------------------------------------
7320        // Step A: every segment_id this BTree index Cold-references.
7321        let mut referenced_ids: BTreeSet<u32> = BTreeSet::new();
7322        for (_key, locators) in map.iter() {
7323            for loc in locators {
7324                if let RowLocator::Cold { segment_id, .. } = loc {
7325                    referenced_ids.insert(*segment_id);
7326                }
7327            }
7328        }
7329        // Step B: keep only the small + still-active ones.
7330        let candidate_set: BTreeSet<u32> = referenced_ids
7331            .into_iter()
7332            .filter(|id| {
7333                self.cold_segments
7334                    .get(*id as usize)
7335                    .and_then(|s| s.as_deref())
7336                    .is_some_and(|s| (s.bytes().len() as u64) < target_segment_bytes)
7337            })
7338            .collect();
7339        if candidate_set.len() < 2 {
7340            return Ok(CompactReport {
7341                sources: Vec::new(),
7342                merged_segment_id: None,
7343                merged_segment_bytes: Vec::new(),
7344                merged_rows: 0,
7345                deleted_rows_pruned: 0,
7346                bytes_reclaimed_estimate: 0,
7347            });
7348        }
7349        // Step C: pre-count source rows for the deleted-pruned metric.
7350        let mut source_row_count: usize = 0;
7351        let mut source_byte_total: u64 = 0;
7352        for &id in &candidate_set {
7353            let seg = self.cold_segments[id as usize]
7354                .as_ref()
7355                .expect("candidate selected only when slot is Some");
7356            source_row_count = source_row_count.saturating_add(seg.meta().num_rows as usize);
7357            source_byte_total = source_byte_total.saturating_add(seg.bytes().len() as u64);
7358        }
7359        // Step D: collect (key, body) pairs from every live Cold
7360        // locator pointing at a candidate. dedupe by key — one
7361        // BTree key resolves to at most one cold payload (the
7362        // freezer + promote/shadow flow keeps Cold locators
7363        // unique per key).
7364        let mut collected: BTreeMap<u64, (Vec<u8>, IndexKey)> = BTreeMap::new();
7365        for (key, locators) in map.iter() {
7366            for loc in locators {
7367                let RowLocator::Cold { segment_id, .. } = loc else {
7368                    continue;
7369                };
7370                if !candidate_set.contains(segment_id) {
7371                    continue;
7372                }
7373                let u64_key = index_key_as_u64(key).ok_or_else(|| {
7374                    StorageError::Corrupt(format!(
7375                        "compact_cold_segments: index {index_name:?} has non-integer Cold key; \
7376                         cold tier requires IndexKey::Int (Text PK lands in v5.5+)"
7377                    ))
7378                })?;
7379                let seg = self.cold_segments[*segment_id as usize]
7380                    .as_ref()
7381                    .expect("candidate slot guaranteed Some above");
7382                let payload = seg.lookup(u64_key).ok_or_else(|| {
7383                    StorageError::Corrupt(format!(
7384                        "compact_cold_segments: BTree {index_name:?} points key={u64_key} \
7385                         at segment {segment_id} but the segment lookup missed"
7386                    ))
7387                })?;
7388                collected.insert(u64_key, (payload, key.clone()));
7389                break;
7390            }
7391        }
7392        let merged_rows = collected.len();
7393        let deleted_rows_pruned = source_row_count.saturating_sub(merged_rows);
7394
7395        // Step E: encode the merged segment. `BTreeMap<u64, _>`
7396        // iteration is ascending by key, which is what
7397        // `encode_segment` requires.
7398        let seg_rows: Vec<(u64, Vec<u8>)> = collected
7399            .iter()
7400            .map(|(k, (body, _))| (*k, body.clone()))
7401            .collect();
7402        let (seg_bytes, _meta) = encode_segment(seg_rows.into_iter(), 0.01, SEGMENT_PAGE_BYTES)
7403            .map_err(|e| StorageError::Corrupt(format!("compact_cold_segments: encode: {e}")))?;
7404        let merged_bytes_len = seg_bytes.len() as u64;
7405
7406        // --- atomic mutation phase ------------------------------
7407        let merged_segment_id = self
7408            .load_segment_bytes(seg_bytes.clone())
7409            .map_err(|e| StorageError::Corrupt(format!("compact_cold_segments: load: {e}")))?;
7410
7411        // Rewrite the BTree index: every Cold locator pointing at
7412        // a candidate source becomes a Cold locator pointing at
7413        // the merged segment. Use a flat collect-then-replace
7414        // pattern so we never hold a `&self` borrow across the
7415        // `&mut self` write.
7416        let entries: Vec<(IndexKey, Vec<RowLocator>)> = {
7417            let t = self
7418                .get(table_name)
7419                .expect("table existed at the start of this fn");
7420            let idx = t
7421                .indices
7422                .iter()
7423                .find(|i| i.name == index_name)
7424                .expect("index existed at the start of this fn");
7425            let IndexKind::BTree(map) = &idx.kind else {
7426                unreachable!("validated above");
7427            };
7428            map.iter().map(|(k, v)| (k.clone(), v.clone())).collect()
7429        };
7430        let t_mut = self
7431            .get_mut(table_name)
7432            .expect("table existed at the start of this fn");
7433        let idx_mut = t_mut
7434            .indices
7435            .iter_mut()
7436            .find(|i| i.name == index_name)
7437            .expect("index existed at the start of this fn");
7438        let IndexKind::BTree(map_mut) = &mut idx_mut.kind else {
7439            unreachable!("validated above");
7440        };
7441        for (key, locators) in entries {
7442            let mut new_locs: Vec<RowLocator> = Vec::with_capacity(locators.len());
7443            let mut changed = false;
7444            for loc in &locators {
7445                match *loc {
7446                    RowLocator::Cold {
7447                        segment_id,
7448                        page_offset: _,
7449                    } if candidate_set.contains(&segment_id) => {
7450                        let replacement = RowLocator::Cold {
7451                            segment_id: merged_segment_id,
7452                            page_offset: 0,
7453                        };
7454                        if !new_locs.contains(&replacement) {
7455                            new_locs.push(replacement);
7456                        }
7457                        changed = true;
7458                    }
7459                    other => new_locs.push(other),
7460                }
7461            }
7462            if changed {
7463                map_mut.insert_mut(key, new_locs);
7464            }
7465        }
7466
7467        // Tombstone every source slot. Last step — failures here
7468        // would leave the segment double-referenced in both
7469        // memory + manifest, but `tombstone_segment` only errors
7470        // on out-of-bounds, which we've already validated.
7471        for &id in &candidate_set {
7472            self.tombstone_segment(id)?;
7473        }
7474
7475        let bytes_reclaimed_estimate = source_byte_total.saturating_sub(merged_bytes_len);
7476        Ok(CompactReport {
7477            sources: candidate_set.into_iter().collect(),
7478            merged_segment_id: Some(merged_segment_id),
7479            merged_segment_bytes: seg_bytes,
7480            merged_rows,
7481            deleted_rows_pruned,
7482            bytes_reclaimed_estimate,
7483        })
7484    }
7485
7486    /// Internal helper: scan `(table, index)` for a `Cold` locator
7487    /// keyed by `key`. Returns `Ok(Some((segment_id, page_offset)))`
7488    /// when found, `Ok(None)` when the key has only hot entries
7489    /// or no entries at all, `Err` on the same input-validation
7490    /// errors as the public `promote_cold_row` / `shadow_cold_row`.
7491    fn find_cold_locator(
7492        &self,
7493        table_name: &str,
7494        index_name: &str,
7495        key: &IndexKey,
7496    ) -> Result<Option<(u32, u32)>, StorageError> {
7497        let t = self.get(table_name).ok_or_else(|| {
7498            StorageError::Corrupt(format!("find_cold_locator: table {table_name:?} not found"))
7499        })?;
7500        let idx = t
7501            .indices
7502            .iter()
7503            .find(|i| i.name == index_name)
7504            .ok_or_else(|| {
7505                StorageError::Corrupt(format!(
7506                    "find_cold_locator: index {index_name:?} not found on {table_name:?}"
7507                ))
7508            })?;
7509        if !matches!(idx.kind, IndexKind::BTree(_)) {
7510            return Err(StorageError::Corrupt(format!(
7511                "find_cold_locator: index {index_name:?} is NSW; promote-on-write only applies to BTree indices"
7512            )));
7513        }
7514        for loc in idx.lookup_eq(key) {
7515            if let RowLocator::Cold {
7516                segment_id,
7517                page_offset,
7518            } = *loc
7519            {
7520                return Ok(Some((segment_id, page_offset)));
7521            }
7522        }
7523        Ok(None)
7524    }
7525}
7526
7527/// Coerce an [`IndexKey`] to the `u64` that v5.1 cold-tier
7528/// segments use as their on-disk PK. Returns `None` for keys that
7529/// aren't representable as `u64` — Text PKs need a hash mapping
7530/// the segment writer baked in (deferred to v5.2+), Bool PKs are
7531/// almost never wide enough to be sharded into a cold tier.
7532fn index_key_as_u64(key: &IndexKey) -> Option<u64> {
7533    match key {
7534        // Reinterpret the i64 bit pattern as u64. Cold-tier segments
7535        // are sorted by this u64 view, so the chosen interpretation
7536        // only has to match between insert (bake_segment / freezer)
7537        // and lookup — using cast_unsigned keeps both sides honest
7538        // and silences clippy::cast_sign_loss.
7539        IndexKey::Int(n) => Some(n.cast_unsigned()),
7540        // Text / Bool / Uuid PKs aren't representable as u64 and so
7541        // can't participate in the u64-sorted cold-tier segment
7542        // PK layout. Same deferral story as Text — lookup falls
7543        // through the in-memory btree.
7544        IndexKey::Text(_) | IndexKey::Bool(_) | IndexKey::Uuid(_) => None,
7545    }
7546}
7547
7548#[derive(Debug, Clone, PartialEq, Eq)]
7549#[non_exhaustive]
7550pub enum StorageError {
7551    DuplicateTable {
7552        name: String,
7553    },
7554    TableNotFound {
7555        name: String,
7556    },
7557    ArityMismatch {
7558        expected: usize,
7559        actual: usize,
7560    },
7561    TypeMismatch {
7562        column: String,
7563        expected: DataType,
7564        actual: DataType,
7565        position: usize,
7566    },
7567    NullInNotNull {
7568        column: String,
7569    },
7570    /// Index with this name already exists on the table.
7571    DuplicateIndex {
7572        name: String,
7573    },
7574    /// Column referenced by an index doesn't exist on the table.
7575    ColumnNotFound {
7576        column: String,
7577    },
7578    /// On-disk format failed to parse — corrupted file, wrong magic, truncated
7579    /// payload, or unknown tag bytes.
7580    Corrupt(String),
7581    /// v6.0.4 — ALTER INDEX targeted an index name that doesn't
7582    /// exist on any table in this catalog.
7583    IndexNotFound {
7584        name: String,
7585    },
7586    /// v6.0.4 — operation requested isn't supported on this index
7587    /// kind / column type (e.g. ALTER INDEX REBUILD on a `BTree`
7588    /// index, or REBUILD WITH (encoding=…) on a non-vector column).
7589    Unsupported(String),
7590    /// v7.39 (round 220) — a CYCLE-less sequence ran past its bound.
7591    /// PG's 2200H phrasing: `nextval: reached maximum value of
7592    /// sequence "s" (n)` (`is_max: false` = the MINVALUE direction).
7593    SequenceExhausted {
7594        name: String,
7595        limit: i64,
7596        is_max: bool,
7597    },
7598}
7599
7600impl fmt::Display for StorageError {
7601    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
7602        match self {
7603            // v7.39 (read01 round 47) — PG's 42P07 wording.
7604            Self::DuplicateTable { name } => write!(f, "relation \"{name}\" already exists"),
7605            // v7.39 (read01 round 47) — PG's wording for a missing relation
7606            // (42P01). DROP TABLE says "table" and raises its own error at
7607            // the engine; every other path (SELECT / ALTER / …) says
7608            // "relation", which is what this carries.
7609            Self::TableNotFound { name } => write!(f, "relation \"{name}\" does not exist"),
7610            Self::ArityMismatch { expected, actual } => write!(
7611                f,
7612                "row arity mismatch: expected {expected} columns, got {actual}"
7613            ),
7614            Self::TypeMismatch {
7615                column,
7616                expected,
7617                actual,
7618                position,
7619            } => write!(
7620                f,
7621                "type mismatch in column {column:?} (position {position}): expected {expected}, got {actual}"
7622            ),
7623            Self::NullInNotNull { column } => {
7624                // v7.39 (SQLSTATE fidelity) — PG's 23502 phrasing (the
7625                // relation-qualified long form is added by engine call
7626                // sites that know the table name).
7627                write!(
7628                    f,
7629                    "null value in column \"{column}\" violates not-null constraint"
7630                )
7631            }
7632            // v7.39 (read01 round 47) — an index is a relation to PG (42P07).
7633            Self::DuplicateIndex { name } => write!(f, "relation \"{name}\" already exists"),
7634            // v7.39 (round 701) — PG's wording, and the same fix `EvalError::
7635            // ColumnNotFound` took in read01 round 81 with the same reason:
7636            // "column not found: x" matches none of the wire layer's `does
7637            // not exist` patterns, so a missing column reached the client as
7638            // the generic error class. The eval-side variant was changed and
7639            // the storage-side one was not, so which sentence you got
7640            // depended on which layer noticed — `CREATE INDEX ix ON t(nope)`
7641            // came out of storage and kept the old spelling.
7642            Self::ColumnNotFound { column } => write!(f, "column \"{column}\" does not exist"),
7643            Self::Corrupt(detail) => write!(f, "corrupt on-disk format: {detail}"),
7644            Self::IndexNotFound { name } => write!(f, "index \"{name}\" does not exist"),
7645            Self::Unsupported(detail) => write!(f, "unsupported: {detail}"),
7646            // v7.39 (round 220) — PG's exact 2200H wording.
7647            Self::SequenceExhausted {
7648                name,
7649                limit,
7650                is_max,
7651            } => write!(
7652                f,
7653                "nextval: reached {} value of sequence \"{name}\" ({limit})",
7654                if *is_max { "maximum" } else { "minimum" }
7655            ),
7656        }
7657    }
7658}
7659
7660impl ColumnSchema {
7661    pub fn new(name: impl Into<String>, ty: DataType, nullable: bool) -> Self {
7662        Self {
7663            name: name.into(),
7664            ty,
7665            nullable,
7666            collation_name: None,
7667            default: None,
7668            runtime_default: None,
7669            auto_increment: false,
7670            user_enum_type: None,
7671            user_domain_type: None,
7672            user_composite_type: None,
7673            acl: Vec::new(),
7674            on_update_runtime: None,
7675            collation: Collation::Binary,
7676            is_unsigned: false,
7677            inline_enum_variants: None,
7678            inline_set_variants: None,
7679            generated_stored_expr: None,
7680            identity_always: false,
7681            default_text: None,
7682            auto_restart: None,
7683            scalar_row_source: false,
7684            mysql_int_width: None,
7685            mysql_fsp: None,
7686        }
7687    }
7688
7689    /// Builder-style helper to attach a default value to an otherwise
7690    /// plain column schema. Used by the engine when CREATE TABLE
7691    /// specifies `column TYPE DEFAULT <expr>`.
7692    #[must_use]
7693    pub fn with_default(mut self, default: Value<'static>) -> Self {
7694        self.default = Some(default);
7695        self
7696    }
7697
7698    /// v7.9.21 — builder for runtime-evaluated defaults
7699    /// (`DEFAULT now()`, `DEFAULT CURRENT_TIMESTAMP`, …).
7700    /// `expr` is the Expr's `Display` form, re-parsed by the
7701    /// engine at each INSERT.
7702    #[must_use]
7703    pub fn with_runtime_default(mut self, expr: impl Into<String>) -> Self {
7704        self.runtime_default = Some(expr.into());
7705        self
7706    }
7707
7708    /// Builder-style helper to mark a column as `AUTO_INCREMENT`.
7709    #[must_use]
7710    pub const fn with_auto_increment(mut self) -> Self {
7711        self.auto_increment = true;
7712        self
7713    }
7714}
7715
7716impl TableSchema {
7717    pub fn new(name: impl Into<String>, columns: Vec<ColumnSchema>) -> Self {
7718        Self {
7719            name: name.into(),
7720            columns,
7721            hot_tier_bytes: None,
7722            foreign_keys: Vec::new(),
7723            uniqueness_constraints: Vec::new(),
7724            exclusion_constraints: Vec::new(),
7725            checks: Vec::new(),
7726            partition_role: None,
7727            policies: Vec::new(),
7728            row_security: false,
7729            force_row_security: false,
7730            owner: None,
7731            acl: Vec::new(),
7732        }
7733    }
7734}
7735
7736// =========================================================================
7737// Persistent binary format for the catalog.
7738//
7739// Layout (little-endian throughout):
7740//
7741//   [magic "SPGDB001" 8 bytes][version u8]
7742//   [table_count u32]
7743//   for each table:
7744//       [name_len u16][name bytes]
7745//       [col_count u16]
7746//       for each col:
7747//           [name_len u16][name bytes]
7748//           [type_tag u8 + optional payload]
7749//               1=Int 2=BigInt 3=Float 4=Text 5=Bool
7750//               6=Vector(u32 dim)
7751//               7=SmallInt
7752//               8=Varchar(u32 max)
7753//               9=Char(u32 size)
7754//               10=Numeric(u8 precision, u8 scale)
7755//               11=Date
7756//               12=Timestamp
7757//           [nullable u8]   0/1
7758//           [default_tag u8] 0=none 1=value (followed by [value_tag u8] + bytes)
7759//       [row_count u32]
7760//       for each row, for each col, one [value_tag u8] + value bytes:
7761//           tag 0 (Null)     → no body
7762//           tag 1 (Int)      → i32 LE
7763//           tag 2 (BigInt)   → i64 LE
7764//           tag 3 (Float)    → f64 LE
7765//           tag 4 (Text)     → u16 LE len + UTF-8 bytes
7766//           tag 5 (Bool)     → u8 0/1
7767//           tag 6 (Vector)   → u32 LE dim + dim×f32 LE
7768//           tag 7 (SmallInt) → i16 LE
7769//           tag 8 (Numeric)  → i128 LE (16 bytes) + u8 scale
7770//           tag 9 (Date)     → i32 LE (days since Unix epoch)
7771//           tag 10 (Timestamp) → i64 LE (microseconds since Unix epoch)
7772//
7773// Bumped to version 3 when NUMERIC was added; to version 4 when
7774// AUTO_INCREMENT (per-column flag) + NSW index `kind` byte landed;
7775// to version 5 when DATE / TIMESTAMP were added; to version 6 when
7776// NSW graph topology started travelling on disk (v2.7); to version 7
7777// when the NSW topology became multi-layer HNSW (v2.13); to version 8
7778// when row encoding switched to schema-driven dense layout (v3.0.2 —
7779// per-row NULL bitmap + per-column fixed-width body, no per-cell type
7780// tag).
7781// =========================================================================
7782
7783const FILE_MAGIC: &[u8; 8] = b"SPGDB001";
7784/// Current catalog snapshot format version emitted by [`Catalog::serialize`].
7785///
7786/// v9 (v5.2) extends v8 by serialising `BTree` index entries directly — every
7787/// `(IndexKey, Vec<RowLocator>)` pair travels on disk with the v5.1
7788/// `RowLocator::write_le` tag-prefixed codec. v8 `BTree` indices stored no
7789/// entries at all (the map was rebuilt from `Table::rows` on load); v9
7790/// preserves on-disk Cold locators so freezer-produced cold-tier index
7791/// entries survive a catalog snapshot round-trip. v8 readers are accepted
7792/// by version dispatch in [`Catalog::deserialize`] — every entry decodes
7793/// as `RowLocator::Hot(_)` via `add_index` rebuild, identical to v5.1
7794/// behaviour.
7795/// v6.7.2 — bumped from 10 to 11 to append per-table
7796/// `hot_tier_bytes: Option<u64>` after the per-table indices
7797/// section. v10 catalogs (v6.7.1) load with `hot_tier_bytes =
7798/// None` for every table (the deserialiser short-circuits when
7799/// version < 11). v11 snapshots written by a pre-v6.7.2 binary
7800/// fail loudly at the version check, matching the v6.1.2 /
7801/// v6.1.4 / v6.2.0 / v6.7.1 envelope-bump upgrade fences.
7802///
7803/// v6.8.0 — bumped from 11 to 12: per-index
7804/// `included_columns: Vec<u16>` appended at the tail of each
7805/// index payload. v11 (= v6.7.2) catalogs load with
7806/// `included_columns = Vec::new()` for every index — same
7807/// "older readers, append-only extension" pattern as the v6.7.2
7808/// hot_tier_bytes byte.
7809/// v7.13.0 — bumped from 22 to 23. mailrs round-5 G3 / G10.
7810/// Per-table appendix gains two new sections:
7811///   * `checks: Vec<String>` — CHECK predicate sources (Display
7812///     form of the AST Expr); re-parsed on INSERT/UPDATE to
7813///     enforce against candidate rows. Same persistence pattern
7814///     as `Index::partial_predicate`.
7815///   * Per `UniquenessConstraint`: trailing `nulls_not_distinct:
7816///     u8` flag for PG 15+ `UNIQUE NULLS NOT DISTINCT (cols)`
7817///     semantics.
7818/// v22 catalogs deserialise with empty `checks` and every UC
7819/// at `nulls_not_distinct = false`.
7820/// v24 introduces:
7821///   * Index kind tag 4 = trigram-GIN (`gin_trgm_ops`-flavoured
7822///     `USING gin` over a TEXT/VARCHAR column). Payload shape is
7823///     identical to tag-3 GIN (String → Vec<RowLocator>); the
7824///     keys are PG-compatible 3-byte trigram shingles instead of
7825///     tsvector lexemes. v23 catalogs deserialise unchanged — no
7826///     v23 writer ever emitted tag 4.
7827/// v25 introduces:
7828///   * Per `TriggerDef`: trailing `enabled: u8` flag (mailrs
7829///     round-9 A.2.b — `ALTER TABLE … { ENABLE | DISABLE }
7830///     TRIGGER …`). v24 catalogs deserialise with every trigger
7831///     `enabled = true`, matching pre-v7.16.1 behaviour.
7832/// v26 introduces (v7.17.0 Phase 1.1):
7833///   * Trailing SEQUENCE catalog block after triggers. Encoded
7834///     as `u32 count` followed by per-sequence:
7835///     `name`, `data_type: u8` (0=SmallInt,1=Int,2=BigInt),
7836///     `start i64`, `increment i64`, `min_value i64`,
7837///     `max_value i64`, `cache i64`, `cycle u8`,
7838///     `owned_by_tag u8` (0=NONE, 1=Column → `table`,`column`),
7839///     `last_value i64`, `is_called u8`. v25-and-below catalogs
7840///     deserialise with an empty sequences map.
7841/// v27 introduces (v7.17.0 Phase 1.2):
7842///   * Trailing VIEW catalog block after sequences. Encoded as
7843///     `u32 count` followed by per-view:
7844///     `name`, `column_count u16`, then column names, then
7845///     `body` long-string. v26-and-below catalogs deserialise
7846///     with an empty views map.
7847/// v28 introduces (v7.17.0 Phase 1.3):
7848///   * Trailing MATERIALIZED VIEW source registry block after
7849///     views. Encoded as `u32 count` followed by per-entry:
7850///     `name`, `body` long-string. The materialised rows live
7851///     as a regular Table of the same name (already covered by
7852///     the pre-existing tables block). v27-and-below catalogs
7853///     deserialise with an empty map.
7854/// v29 introduces (v7.17.0 Phase 1.4):
7855///   * Per-table user_enum_type appendix (after the CHECK
7856///     appendix). Layout: `u16 count` followed by per-binding
7857///     `[u16 col_pos][str enum_name]`. Only columns whose
7858///     `user_enum_type` is Some land here; the catalog stays
7859///     compact for the common no-enum case.
7860///   * Trailing ENUM types catalog block after materialized
7861///     views. Encoded as `u32 count` followed by per-entry:
7862///     `name`, `u16 label_count`, then `label_count` short
7863///     strings. v28-and-below catalogs deserialise with an
7864///     empty enum_types map and every column's
7865///     `user_enum_type = None`.
7866/// v30 introduces (v7.17.0 Phase 1.5):
7867///   * Per-table user_domain_type appendix (after the
7868///     user_enum_type appendix). Same shape as the enum one.
7869///   * Trailing DOMAIN types catalog block after the enum
7870///     block. Encoded as `u32 count` followed by per-entry:
7871///     `name`, `data_type` byte, `nullable u8`,
7872///     `default_present u8` + optional default string,
7873///     `u16 check_count` then `check_count` Display-form
7874///     CHECK strings. v29-and-below catalogs deserialise with
7875///     an empty domain_types map and `user_domain_type = None`.
7876/// v31 introduces (v7.17.0 Phase 1.6):
7877///   * Trailing user-schemas block after the DOMAIN block.
7878///     Encoded as `u32 count` followed by `count` schema-name
7879///     short strings. Built-in schemas (`public`, `pg_catalog`,
7880///     `information_schema`) are NOT serialised — they're
7881///     hardcoded in `is_builtin_schema`. v30-and-below catalogs
7882///     deserialise with an empty user-schemas set.
7883/// v32 introduces (v7.17.0 Phase 2.1):
7884///   * Per-table on_update_runtime appendix (after the
7885///     user_domain_type appendix). Layout: `u16 count` followed
7886///     by per-binding `[u16 col_pos][str expr_src]`. Only
7887///     columns whose `on_update_runtime` is Some land here;
7888///     the catalog stays compact when no MySQL-shaped table
7889///     uses the attribute. v31-and-below catalogs deserialise
7890///     with every column's `on_update_runtime = None`.
7891/// v33 introduces (v7.17.0 Phase 2.2):
7892///   * Index kind tag 5 = fulltext-GIN (MySQL `FULLTEXT KEY`
7893///     surface over a TEXT / VARCHAR column). Payload shape is
7894///     identical to tag-3 / tag-4 GIN (`String → Vec<RowLocator>`);
7895///     the keys are lower-cased word lexemes (same rule as
7896///     `to_tsvector('simple', text)`). v32 catalogs deserialise
7897///     unchanged — no v32 writer ever emitted tag 5, and FULLTEXT
7898///     KEY was silently dropped pre-v7.17 so no rebuild shim is
7899///     needed for round-tripped catalogs.
7900/// v34 introduces (v7.17.0 Phase 2.5):
7901///   * Per-table collation appendix (after the on_update_runtime
7902///     appendix). Sparse layout: only columns whose `collation`
7903///     is non-Binary land here. `u16 count` then per-binding
7904///     `[u16 col_pos][u8 collation_tag]` where the tag matches
7905///     `Collation::TAG_*`. Snapshots written by v33-and-below
7906///     readers deserialise every column with `collation =
7907///     Binary`, preserving the prior byte-wise compare
7908///     semantics. Unknown tags read back as Binary too — keeps
7909///     a forward-compat path if a future v35 adds variants
7910///     and someone rolls back to a v34 reader.
7911/// v35 introduces (v7.17.0 Phase 4.4):
7912///   * Per-table is_unsigned appendix (after the collation
7913///     appendix). Sparse layout: only `is_unsigned = true`
7914///     columns land. `u16 count` then per-binding `[u16 col_pos]`.
7915///     v34-and-below catalogs deserialise every column as
7916///     `is_unsigned = false`, preserving the prior silent-
7917///     accept behaviour for negative inserts on UNSIGNED columns.
7918/// v46 introduces (v7.23, mailrs round-14):
7919///   * Escaped short-string codec — `write_str` lengths >= 0xFFFF
7920///     emit `[u16 0xFFFF][u32 real_len]` so TEXT cells (mail bodies,
7921///     document text) above 64 KiB encode instead of panicking.
7922///     One-way upgrade: v45-and-below readers reject v46 catalogs
7923///     loudly via the version gate; v46 readers decode v45 catalogs
7924///     with the plain-u16 rules (0xFFFF is a legitimate length
7925///     there).
7926/// v47 introduces (v7.27, mailrs round-21):
7927///   * Escaped lengths for the REMAINING u16-length cell payloads —
7928///     BYTEA cells, TEXT[] elements, tsvector lexemes and tsquery
7929///     terms — the same `[u16 0xFFFF][u32 real_len]` escape v46
7930///     gave short strings. Round-14 fixed TEXT and missed these;
7931///     round-21 fired the BYTEA twin during a production migration.
7932///     One-way upgrade, same posture as v46.
7933/// v48 introduces (v7.37.5 β-P2, sentori cutover window):
7934///   * `INTERVAL` becomes a real column type. Catalog tag 34 in
7935///     `write_data_type`; per-row body is a fixed 16 bytes
7936///     (i64 micros + i32 days + i32 months, LE, PG-byte-equal
7937///     field order). The runtime-only days collapse is gone —
7938///     `'1 day'` and `'24 hours'` are stored distinctly. One-way
7939///     upgrade: v47 catalogs without INTERVAL columns deserialise
7940///     identically; v47 readers fed a v48 catalog that contains
7941///     INTERVAL hit the explicit "unknown data type tag: 34"
7942///     fence in `read_data_type`.
7943/// v49 introduces (v7.37.6-B, sentori Epic 2 P0):
7944///   * Per-table partition role appendix(declarative
7945///     `PARTITION BY RANGE` parent / range child / DEFAULT
7946///     child)。Layout, written **after** the inline_set_variants
7947///     appendix and **before** the per-table block close:
7948///       `[u8 role_tag]`
7949///         0 = `None`(普通表,后向兼容默认)
7950///         1 = `Parent`:  `[u8 kind_tag (0=Range)]`
7951///                        `[u16 key_col_count]` `(× u16 col_pos)`
7952///                        `[u16 tmpl_count]` `(× str source)`
7953///         2 = `Range`:   `[str parent_name]` `[Bound]` `[Bound]`
7954///         3 = `Default`: `[str parent_name]`
7955///     `PartitionBound` codec:
7956///       `[u8 bound_tag]` 0=MinValue 1=MaxValue 2=TimestampTz(`[i64 LE micros]`)
7957///     v48-and-below readers stop after the inline_set_variants
7958///     block — they don't see this appendix and deserialise every
7959///     table with `partition_role = None`. v49 writers always emit
7960///     `[0]` for plain tables, so the encoding stays one-byte-cheap.
7961/// v50 introduces (v7.37.7, sentori Epic 3 P1):
7962///   * Per-table `generated_stored_expr` appendix(stored generated
7963///     columns — `GENERATED ALWAYS AS (<expr>) STORED`)。Layout,
7964///     written **after** the partition_role appendix and before
7965///     the per-table block close:
7966///       `[u16 binding_count]`
7967///       `binding_count × { [u16 col_pos][str expr_source] }`
7968///     Sparse — only generated columns land here, so plain-shape
7969///     catalogs stay byte-for-byte identical save for the new
7970///     u16 zero count. v49-and-below readers stop after the
7971///     partition_role appendix; v50 readers default every column
7972///     to `generated_stored_expr = None` when this block is absent.
7973/// v51 introduces (v7.37.8, sentori Epic 5 P2):
7974///   * Per-index tag byte 6 = `GinJsonb`(real posting-list GIN
7975///     over a JSONB column). Payload shape mirrors tag-3 / 4 / 5:
7976///     `[u32 posting_list_count]` then `(str token, u32 locator_count,
7977///     locators …)` per posting list. Same `write_str` /
7978///     `RowLocator::write_le` codec as the rest of the GIN family.
7979///     v50 catalogs never wrote tag 6(the same DDL loaded as a
7980///     BTree fallback); v51 readers see tag 6 explicitly and dispatch
7981///     into `IndexKind::GinJsonb`.
7982/// v52 introduces (v7.37.42-T2 ζ-B composite + domain metasystem):
7983///   * Trailing COMPOSITE-types catalog block after the
7984///     user-schemas block. Encoded as `u32 count` followed by
7985///     per-entry: `name`, `u16 field_count`, then `field_count`
7986///     `[str field_name][data_type]` pairs (`write_data_type` is
7987///     reused). v51-and-below catalogs deserialise with an empty
7988///     composite_types map; v52 readers tolerate v51 catalogs by
7989///     stopping at the schema block (no composite block present
7990///     ⇒ empty map). Composite types are referenced by columns
7991///     via `ColumnSchema.user_composite_type`, mirroring the
7992///     `user_enum_type` / `user_domain_type` pattern. The block
7993///     lands here (not as a per-table appendix) so dropping the
7994///     composite type registers globally and DROP TYPE can find it
7995///     without a table scan.
7996/// v53 introduces (v7.37.16 Epic W — cross-checkpoint tombstone
7997///   durability):
7998///   * Trailing per-table MVCC appendix carrying, for every row,
7999///     its `RowHeader` (`xmin:u64`, `xmax:u64`, `flags:u8`) and its
8000///     stable `RowId` (`u64`), followed by the relation's
8001///     `next_rowid:u64`. Layout per table (after the v50
8002///     generated_stored_expr block, before the table loop closes):
8003///       `[u32 row_count]` (== `Table::rows().len()`, cross-check)
8004///       per row in physical order:
8005///         `[u64 xmin][u64 xmax][u8 flags][u64 rowid]`
8006///       `[u64 next_rowid]`
8007///     v52-and-below catalogs never wrote this block; their reader
8008///     stops after the last per-table appendix and
8009///     `deserialize_rows` leaves every row `RowHeader::frozen()`
8010///     with dense 1..=N ids — the exact pre-v53 contract. A v53
8011///     reader instead reconstructs headers + ids VERBATIM, so a
8012///     tombstone-redo naming a row inserted before the last
8013///     checkpoint resolves by `RowId` across the base-snapshot
8014///     boundary (closing the coupling the Epic W WAL slices deferred
8015///     to this format bump). Because the reader routes on `version`,
8016///     the block is strictly backward-compatible: old images load
8017///     byte-for-byte as before. `SPG_MVCC_INPLACE` is unaffected —
8018///     a gate-off database's rows are all frozen/alive, so
8019///     persisting + restoring their headers is observationally a
8020///     no-op.
8021/// v7.38 (read01 P5.05) — v54 appends a CRC32C over the whole preceding
8022/// image so a corrupted `base.spg` is caught on load instead of silently
8023/// deserialising garbage. Older images (v8..=53) carry no trailer and load
8024/// unchanged.
8025/// v7.39 (round 210) — v72 appends a per-table EXCLUDE-constraint appendix
8026/// (sparse: only tables carrying an EXCLUDE write it) at the very end of the
8027/// per-table block, after the column-ACL appendix. A v71 reader stops before
8028/// it and its tables read back with no exclusion constraints, which is what
8029/// they were.
8030/// v7.39 (round 220) — v73 appends a per-table identity-RESTART appendix
8031/// (sparse: [u16 count] then per entry [u16 col_pos][i64 LE floor]) after
8032/// the EXCLUDE appendix. A v72 reader stops before it; its columns read
8033/// back with no RESTART floor, losing only an un-consumed
8034/// `ALTER … RESTART WITH` across a restart.
8035const FILE_VERSION: u8 = 89;
8036
8037/// v7.37 (round 833) — the codec version to decode a row that
8038/// [`encode_row_body_dense`] has just produced.
8039///
8040/// That encoder always writes the newest form, and every decoder gate is
8041/// a `codec_version >= N` feature test, so a freshly encoded row must be
8042/// read at the current version. Cold segments carry their own version in
8043/// their header and keep passing that; this is for in-process round
8044/// trips — sort runs on temp storage — where the bytes never outlive the
8045/// build that wrote them.
8046pub const CURRENT_ROW_CODEC_VERSION: u8 = FILE_VERSION;
8047/// First version that appends the trailing CRC32C integrity trailer.
8048const FILE_VERSION_CRC_TRAILER: u8 = 54;
8049/// Oldest format version [`Catalog::deserialize`] still accepts. v8 is the
8050/// v3.0.2 dense-row layout; pre-v8 catalogs require an offline migration.
8051const MIN_SUPPORTED_FILE_VERSION: u8 = 8;
8052
8053// IndexKey wire format (v9):
8054//   tag 0 = Int  → [i64 LE]
8055//   tag 1 = Text → [u16 LE len + UTF-8 bytes] (via write_str / read_str)
8056//   tag 2 = Bool → [u8 0/1]
8057const INDEX_KEY_TAG_INT: u8 = 0;
8058const INDEX_KEY_TAG_TEXT: u8 = 1;
8059const INDEX_KEY_TAG_BOOL: u8 = 2;
8060/// v7.17.0 — `IndexKey::Uuid([u8; 16])`. Body = raw 16 bytes
8061/// (RFC 4122 byte order). Persisted only in FILE_VERSION 36+
8062/// catalogs.
8063const INDEX_KEY_TAG_UUID: u8 = 3;
8064
8065impl Catalog {
8066    /// Serialize the whole catalog (schema + every row) into a self-contained
8067    /// byte buffer. Format is documented above the impl block.
8068    pub fn serialize(&self) -> Vec<u8> {
8069        let mut out = Vec::with_capacity(64);
8070        out.extend_from_slice(FILE_MAGIC);
8071        out.push(FILE_VERSION);
8072        write_u32(
8073            &mut out,
8074            u32::try_from(self.tables.len()).expect("≤ 4G tables"),
8075        );
8076        for t in &self.tables {
8077            write_str(&mut out, &t.schema.name);
8078            write_u16(
8079                &mut out,
8080                u16::try_from(t.schema.columns.len()).expect("≤ 65k columns/table"),
8081            );
8082            for c in &t.schema.columns {
8083                write_str(&mut out, &c.name);
8084                write_data_type(&mut out, c.ty);
8085                out.push(u8::from(c.nullable));
8086                match &c.default {
8087                    None => out.push(0),
8088                    Some(v) => {
8089                        out.push(1);
8090                        write_value(&mut out, v);
8091                    }
8092                }
8093                out.push(u8::from(c.auto_increment));
8094            }
8095            write_u32(
8096                &mut out,
8097                u32::try_from(t.rows.len()).expect("≤ 4G rows/table"),
8098            );
8099            // v3.0.2 dense row encoding (FILE_VERSION 8): per-row NULL
8100            // bitmap, then tightly-packed bodies. Identical wire format
8101            // as before — extracted into `encode_row_body_dense` so cold-
8102            // tier segments (v5.1+) can share the encoding.
8103            for row in &t.rows {
8104                out.extend_from_slice(&encode_row_body_dense(row, &t.schema));
8105            }
8106            // Index definitions. Per-index payload:
8107            //   [name][col_pos u16][kind u8]
8108            //     kind 0 = B-tree           (no params — rebuilt on load)
8109            //     kind 1 = NSW graph        (u16 M + serialized graph)
8110            // For NSW the graph topology travels on disk so startup
8111            // doesn't re-run the O(n²M) rebuild — see v2.7 notes.
8112            write_u16(
8113                &mut out,
8114                u16::try_from(t.indices.len()).expect("≤ 65k indices/table"),
8115            );
8116            for idx in &t.indices {
8117                write_str(&mut out, &idx.name);
8118                write_u16(
8119                    &mut out,
8120                    u16::try_from(idx.column_position).expect("≤ 65k columns/table"),
8121                );
8122                match &idx.kind {
8123                    IndexKind::BTree(map) => {
8124                        out.push(0);
8125                        // v9: serialise the full PB map. Each entry's
8126                        // RowLocator list travels with the tag-prefixed
8127                        // codec from `row_locator::write_le`, so freezer-
8128                        // produced Cold locators survive a snapshot
8129                        // round-trip. v8 BTree wrote nothing here and
8130                        // rebuilt from rows — v9 readers tolerate v8 by
8131                        // version dispatch in `Catalog::deserialize`.
8132                        write_u32(
8133                            &mut out,
8134                            u32::try_from(map.len()).expect("≤ 4G index entries/index"),
8135                        );
8136                        for (key, locators) in map {
8137                            write_index_key(&mut out, key);
8138                            write_u32(
8139                                &mut out,
8140                                u32::try_from(locators.len()).expect("≤ 4G locators/key"),
8141                            );
8142                            for loc in locators {
8143                                loc.write_le(&mut out);
8144                            }
8145                        }
8146                    }
8147                    IndexKind::Nsw(g) => {
8148                        out.push(1);
8149                        write_u16(&mut out, u16::try_from(g.m).expect("≤ 65k NSW neighbours"));
8150                        write_nsw_graph(&mut out, g);
8151                    }
8152                    IndexKind::Brin { column_type } => {
8153                        // v6.7.1 — tag byte 2 = BRIN. Payload is the
8154                        // column type code (1 byte mapping to the
8155                        // shared DataType numeric encoding); no
8156                        // further data — BRIN summaries live in
8157                        // cold segments, not the catalog.
8158                        out.push(2);
8159                        write_data_type(&mut out, *column_type);
8160                    }
8161                    IndexKind::Gin(map) => {
8162                        // v7.12.3 — tag byte 3 = GIN. Payload mirrors
8163                        // the BTree encoding but with String (lexeme
8164                        // word) keys instead of IndexKey. Tag-prefixed
8165                        // RowLocator codec so freezer-produced Cold
8166                        // locators survive snapshot round-trip.
8167                        // FILE_VERSION 21+; v20 catalogs never wrote a
8168                        // GIN index (the AM degraded to BTree fallback
8169                        // pre-v7.12.3), so no migration shim is needed.
8170                        out.push(3);
8171                        write_u32(
8172                            &mut out,
8173                            u32::try_from(map.len()).expect("≤ 4G GIN posting lists"),
8174                        );
8175                        for (word, locators) in map {
8176                            write_str(&mut out, word);
8177                            write_u32(
8178                                &mut out,
8179                                u32::try_from(locators.len()).expect("≤ 4G locators/posting list"),
8180                            );
8181                            for loc in locators {
8182                                loc.write_le(&mut out);
8183                            }
8184                        }
8185                    }
8186                    IndexKind::GinTrgm(map) => {
8187                        // v7.15.0 — tag byte 4 = GinTrgm
8188                        // (`gin_trgm_ops` GIN over a TEXT column).
8189                        // Payload shape is identical to tag-3 GIN —
8190                        // `String → Vec<RowLocator>` posting lists.
8191                        // The String keys are 3-byte trigrams instead
8192                        // of tsvector lexemes; the deserializer
8193                        // dispatches on the tag, not the key shape.
8194                        // FILE_VERSION 24+; v23 catalogs never wrote
8195                        // a trigram-GIN.
8196                        out.push(4);
8197                        write_u32(
8198                            &mut out,
8199                            u32::try_from(map.len()).expect("≤ 4G trigram-GIN posting lists"),
8200                        );
8201                        for (tri, locators) in map {
8202                            write_str(&mut out, tri);
8203                            write_u32(
8204                                &mut out,
8205                                u32::try_from(locators.len()).expect("≤ 4G locators/posting list"),
8206                            );
8207                            for loc in locators {
8208                                loc.write_le(&mut out);
8209                            }
8210                        }
8211                    }
8212                    IndexKind::GinFulltext(map) => {
8213                        // v7.17.0 Phase 2.2 — tag byte 5 =
8214                        // GinFulltext (MySQL `FULLTEXT KEY` GIN
8215                        // over a TEXT/VARCHAR column). Payload
8216                        // shape mirrors tag-3 / tag-4 GIN —
8217                        // `String → Vec<RowLocator>` posting
8218                        // lists keyed by lower-cased word
8219                        // lexemes. FILE_VERSION 33+; v32 catalogs
8220                        // never wrote a fulltext-GIN (FULLTEXT
8221                        // KEY was silently dropped pre-v7.17).
8222                        out.push(5);
8223                        write_u32(
8224                            &mut out,
8225                            u32::try_from(map.len()).expect("≤ 4G fulltext-GIN posting lists"),
8226                        );
8227                        for (lex, locators) in map {
8228                            write_str(&mut out, lex);
8229                            write_u32(
8230                                &mut out,
8231                                u32::try_from(locators.len()).expect("≤ 4G locators/posting list"),
8232                            );
8233                            for loc in locators {
8234                                loc.write_le(&mut out);
8235                            }
8236                        }
8237                    }
8238                    IndexKind::GinJsonb(map) => {
8239                        // v7.37.8 — tag byte 6 = GinJsonb
8240                        // (real posting-list GIN over a JSONB
8241                        // column; sentori Epic 5 P2). Payload
8242                        // shape mirrors tag-3 / 4 / 5 — keys are
8243                        // the canonical `(path, leaf)` tokens
8244                        // from `jsonb_gin::extract_tokens`.
8245                        // FILE_VERSION 51+; v50 catalogs never
8246                        // wrote a JSONB-GIN (the same DDL loaded
8247                        // as a BTree fallback).
8248                        out.push(6);
8249                        write_u32(
8250                            &mut out,
8251                            u32::try_from(map.len()).expect("≤ 4G JSONB-GIN posting lists"),
8252                        );
8253                        for (token, locators) in map {
8254                            write_str(&mut out, token);
8255                            write_u32(
8256                                &mut out,
8257                                u32::try_from(locators.len()).expect("≤ 4G locators/posting list"),
8258                            );
8259                            for loc in locators {
8260                                loc.write_le(&mut out);
8261                            }
8262                        }
8263                    }
8264                }
8265                // v6.8.0 — included_columns appendix per index.
8266                // Layout: [u16 num_included][num × u16 column_position].
8267                // v11 readers stop before this u16 (deserialise loop
8268                // gated on version >= 12); v12+ readers always
8269                // consume it. Empty Vec serialises as a bare 0u16.
8270                write_u16(
8271                    &mut out,
8272                    u16::try_from(idx.included_columns.len()).expect("≤ 65k INCLUDE columns/index"),
8273                );
8274                for col_pos in &idx.included_columns {
8275                    write_u16(
8276                        &mut out,
8277                        u16::try_from(*col_pos).expect("≤ 65k columns/table"),
8278                    );
8279                }
8280                // v6.8.1 — partial_predicate appendix per index.
8281                // Layout: [u8 has_pred][u16 LE len][bytes (if has_pred)].
8282                // Same v12 gate as included_columns.
8283                match &idx.partial_predicate {
8284                    None => out.push(0),
8285                    Some(pred) => {
8286                        out.push(1);
8287                        write_str(&mut out, pred);
8288                    }
8289                }
8290                // v6.8.2 — expression appendix. Same shape as
8291                // partial_predicate.
8292                match &idx.expression {
8293                    None => out.push(0),
8294                    Some(expr) => {
8295                        out.push(1);
8296                        write_str(&mut out, expr);
8297                    }
8298                }
8299                // v7.9.29 — is_unique appendix (FILE_VERSION 16+).
8300                // Single byte 0/1. v15-and-below readers stop before
8301                // this byte; v16 readers always consume it. mailrs K1.
8302                out.push(u8::from(idx.is_unique));
8303                // v7.9.29 — extra_column_positions appendix.
8304                // Layout: [u16 count][count × u16 column_position].
8305                write_u16(
8306                    &mut out,
8307                    u16::try_from(idx.extra_column_positions.len())
8308                        .expect("≤ 65k extra cols / index"),
8309                );
8310                for cp in &idx.extra_column_positions {
8311                    write_u16(&mut out, u16::try_from(*cp).expect("≤ 65k columns/table"));
8312                }
8313                // v7.39 (read01 round 52) — nulls_not_distinct (FILE_VERSION
8314                // 62+). Appended at the end of the per-index block so the v16
8315                // layout above is untouched; v61-and-below readers stop before
8316                // this byte and default the flag to false (NULLS DISTINCT).
8317                out.push(u8::from(idx.nulls_not_distinct));
8318                // v7.39 (round 537) — the key column's ordering clause
8319                // (FILE_VERSION 83+).
8320                out.push(u8::from(idx.descending));
8321                out.push(match idx.nulls_first {
8322                    None => 0,
8323                    Some(true) => 1,
8324                    Some(false) => 2,
8325                });
8326                // v7.39 (round 538) — the key's explicit collation
8327                // (FILE_VERSION 84+).
8328                match &idx.collation {
8329                    Some(c) => {
8330                        out.push(1);
8331                        write_str(&mut out, c);
8332                    }
8333                    None => out.push(0),
8334                }
8335            }
8336            // v6.7.2 — per-table hot_tier_bytes Option<u64>.
8337            // Layout: [u8 has_value][u64 LE value (if has_value)].
8338            // v10 readers stop before this byte (deserialise loop
8339            // gated on version >= 11); v11+ readers always
8340            // consume it.
8341            match t.schema.hot_tier_bytes {
8342                None => out.push(0),
8343                Some(n) => {
8344                    out.push(1);
8345                    out.extend_from_slice(&n.to_le_bytes());
8346                }
8347            }
8348            // v7.6.1 — FOREIGN KEY appendix (catalog FILE_VERSION 13+).
8349            // Layout: [u16 LE fk_count]
8350            //   per fk:
8351            //     [u8 has_name] [str name (if has_name)]
8352            //     [u16 LE local_arity] [u16 LE local_pos]*arity
8353            //     [str parent_table]
8354            //     [u16 LE parent_arity] [u16 LE parent_pos]*arity
8355            //     [u8 on_delete_tag] [u8 on_update_tag]
8356            // Older catalogs (v12 and below) skip this block entirely;
8357            // their reader stops before this byte.
8358            write_u16(
8359                &mut out,
8360                u16::try_from(t.schema.foreign_keys.len()).expect("≤ 65k FKs/table"),
8361            );
8362            for fk in &t.schema.foreign_keys {
8363                match &fk.name {
8364                    None => out.push(0),
8365                    Some(n) => {
8366                        out.push(1);
8367                        write_str(&mut out, n);
8368                    }
8369                }
8370                write_u16(
8371                    &mut out,
8372                    u16::try_from(fk.local_columns.len()).expect("≤ 65k FK columns"),
8373                );
8374                for &p in &fk.local_columns {
8375                    write_u16(&mut out, u16::try_from(p).expect("≤ 65k columns/table"));
8376                }
8377                write_str(&mut out, &fk.parent_table);
8378                write_u16(
8379                    &mut out,
8380                    u16::try_from(fk.parent_columns.len()).expect("≤ 65k FK parent columns"),
8381                );
8382                for &p in &fk.parent_columns {
8383                    write_u16(&mut out, u16::try_from(p).expect("≤ 65k columns/table"));
8384                }
8385                out.push(fk.on_delete.tag());
8386                out.push(fk.on_update.tag());
8387                // v7.38 (read01, T29) — MATCH type tag (FILE_VERSION 55+).
8388                out.push(fk.match_type.tag());
8389                // v7.39 (round 288) — constraint timing (FILE_VERSION 79+).
8390                // One byte, bit 0 = DEFERRABLE, bit 1 = INITIALLY DEFERRED.
8391                out.push(u8::from(fk.deferrable) | (u8::from(fk.initially_deferred) << 1));
8392            }
8393            // v7.9.19 — UniquenessConstraint appendix (catalog
8394            // FILE_VERSION 15+). Layout per table after the FK
8395            // block:
8396            //   [u16 count]
8397            //     per constraint:
8398            //       [u8 is_primary_key]
8399            //       [u16 arity][u16 col_pos]*arity
8400            // Older catalogs (v14 and below) skip this block.
8401            write_u16(
8402                &mut out,
8403                u16::try_from(t.schema.uniqueness_constraints.len())
8404                    .expect("≤ 65k uniqueness constraints/table"),
8405            );
8406            for uc in &t.schema.uniqueness_constraints {
8407                out.push(u8::from(uc.is_primary_key));
8408                write_u16(
8409                    &mut out,
8410                    u16::try_from(uc.columns.len()).expect("≤ 65k cols in uniqueness constraint"),
8411                );
8412                for &p in &uc.columns {
8413                    write_u16(&mut out, u16::try_from(p).expect("≤ 65k columns/table"));
8414                }
8415                // v7.13.0 — `nulls_not_distinct` flag
8416                // (FILE_VERSION 23+). Always written by writers at
8417                // version 23+; deserialise gates on `version >= 23`
8418                // so v22-and-below catalogs round-trip cleanly.
8419                out.push(u8::from(uc.nulls_not_distinct));
8420            }
8421            // v7.9.21 — runtime_default appendix per table.
8422            // Layout: [u16 count] then for each:
8423            //   [u16 col_pos][str expr]
8424            // Only columns whose runtime_default is Some land here;
8425            // catalog stays compact for the common literal-default
8426            // case.
8427            let mut rt_defaults: Vec<(usize, &str)> = Vec::new();
8428            for (i, c) in t.schema.columns.iter().enumerate() {
8429                if let Some(e) = &c.runtime_default {
8430                    rt_defaults.push((i, e.as_str()));
8431                }
8432            }
8433            write_u16(
8434                &mut out,
8435                u16::try_from(rt_defaults.len()).expect("≤ 65k runtime defaults/table"),
8436            );
8437            for (pos, expr) in rt_defaults {
8438                write_u16(&mut out, u16::try_from(pos).expect("≤ 65k columns/table"));
8439                write_str(&mut out, expr);
8440            }
8441            // v7.13.0 — CHECK constraint appendix per table.
8442            // Layout: [u16 count] then `count` Display-form
8443            // expression strings. Re-parsed on every INSERT/UPDATE
8444            // by the engine. FILE_VERSION 23+ only; v22 readers
8445            // never reach this block because the writer also moves
8446            // to v23 in lock-step.
8447            write_u16(
8448                &mut out,
8449                u16::try_from(t.schema.checks.len()).expect("≤ 65k CHECK constraints/table"),
8450            );
8451            for c in &t.schema.checks {
8452                // v7.39 (read01 round 48) — the expr stays in this v23
8453                // appendix (byte layout unchanged for old readers); the
8454                // name rides the v60 constraint-name appendix at the tail.
8455                write_str(&mut out, c.expr.as_str());
8456            }
8457            // v7.17.0 Phase 1.4 — per-table user_enum_type
8458            // appendix. Layout: [u16 count] then
8459            // [u16 col_pos][str enum_name] per binding. Only
8460            // columns whose user_enum_type is Some land here.
8461            let mut enum_bindings: Vec<(usize, &str)> = Vec::new();
8462            for (i, c) in t.schema.columns.iter().enumerate() {
8463                if let Some(e) = &c.user_enum_type {
8464                    enum_bindings.push((i, e.as_str()));
8465                }
8466            }
8467            write_u16(
8468                &mut out,
8469                u16::try_from(enum_bindings.len()).expect("≤ 65k enum-typed columns/table"),
8470            );
8471            for (pos, ename) in enum_bindings {
8472                write_u16(&mut out, u16::try_from(pos).expect("≤ 65k columns/table"));
8473                write_str(&mut out, ename);
8474            }
8475            // v7.17.0 Phase 1.5 — per-table user_domain_type
8476            // appendix. Same layout as the enum one. v29-and-
8477            // below readers stop after the enum appendix.
8478            let mut domain_bindings: Vec<(usize, &str)> = Vec::new();
8479            for (i, c) in t.schema.columns.iter().enumerate() {
8480                if let Some(d) = &c.user_domain_type {
8481                    domain_bindings.push((i, d.as_str()));
8482                }
8483            }
8484            write_u16(
8485                &mut out,
8486                u16::try_from(domain_bindings.len()).expect("≤ 65k domain-typed columns/table"),
8487            );
8488            for (pos, dname) in domain_bindings {
8489                write_u16(&mut out, u16::try_from(pos).expect("≤ 65k columns/table"));
8490                write_str(&mut out, dname);
8491            }
8492            // v7.17.0 Phase 2.1 — per-table on_update_runtime
8493            // appendix. Sparse: only ON UPDATE-bound columns.
8494            let mut on_update_bindings: Vec<(usize, &str)> = Vec::new();
8495            for (i, c) in t.schema.columns.iter().enumerate() {
8496                if let Some(e) = &c.on_update_runtime {
8497                    on_update_bindings.push((i, e.as_str()));
8498                }
8499            }
8500            write_u16(
8501                &mut out,
8502                u16::try_from(on_update_bindings.len()).expect("≤ 65k ON UPDATE columns/table"),
8503            );
8504            for (pos, expr_src) in on_update_bindings {
8505                write_u16(&mut out, u16::try_from(pos).expect("≤ 65k columns/table"));
8506                write_str(&mut out, expr_src);
8507            }
8508            // v7.17.0 Phase 2.5 — per-table collation appendix.
8509            // Sparse: only non-Binary columns land. Layout:
8510            // `[u16 count][u16 col_pos][u8 tag] × count`.
8511            let mut coll_bindings: Vec<(usize, u8)> = Vec::new();
8512            for (i, c) in t.schema.columns.iter().enumerate() {
8513                let tag = match c.collation {
8514                    Collation::Binary => continue,
8515                    Collation::CaseInsensitive => Collation::TAG_CASE_INSENSITIVE,
8516                };
8517                coll_bindings.push((i, tag));
8518            }
8519            write_u16(
8520                &mut out,
8521                u16::try_from(coll_bindings.len()).expect("≤ 65k collation bindings/table"),
8522            );
8523            for (pos, tag) in coll_bindings {
8524                write_u16(&mut out, u16::try_from(pos).expect("≤ 65k columns/table"));
8525                out.push(tag);
8526            }
8527            // v7.17.0 Phase 4.4 — per-table is_unsigned appendix.
8528            // Sparse: only UNSIGNED columns land. Layout:
8529            // `[u16 count][u16 col_pos] × count`.
8530            let mut unsigned_bindings: Vec<usize> = Vec::new();
8531            for (i, c) in t.schema.columns.iter().enumerate() {
8532                if c.is_unsigned {
8533                    unsigned_bindings.push(i);
8534                }
8535            }
8536            write_u16(
8537                &mut out,
8538                u16::try_from(unsigned_bindings.len()).expect("≤ 65k UNSIGNED columns/table"),
8539            );
8540            for pos in unsigned_bindings {
8541                write_u16(&mut out, u16::try_from(pos).expect("≤ 65k columns/table"));
8542            }
8543            // v7.17.0 Phase 3.P0-36 — per-table inline_enum_variants
8544            // appendix. Sparse: only ENUM columns land. Layout:
8545            // `[u16 count] then per binding [u16 col_pos]
8546            // [u16 variant_count] then variant strings`.
8547            // FILE_VERSION 41+; v40 readers never reach this block.
8548            let mut enum_inline_bindings: Vec<(usize, &[String])> = Vec::new();
8549            for (i, c) in t.schema.columns.iter().enumerate() {
8550                if let Some(vs) = &c.inline_enum_variants {
8551                    enum_inline_bindings.push((i, vs.as_slice()));
8552                }
8553            }
8554            write_u16(
8555                &mut out,
8556                u16::try_from(enum_inline_bindings.len()).expect("≤ 65k inline-ENUM columns/table"),
8557            );
8558            for (pos, variants) in enum_inline_bindings {
8559                write_u16(&mut out, u16::try_from(pos).expect("≤ 65k columns/table"));
8560                write_u16(
8561                    &mut out,
8562                    u16::try_from(variants.len()).expect("≤ 65k variants/ENUM"),
8563                );
8564                for v in variants {
8565                    write_str(&mut out, v.as_str());
8566                }
8567            }
8568            // v7.17.0 Phase 3.P0-37 — per-table inline_set_variants
8569            // appendix. Same layout as the inline ENUM block.
8570            // FILE_VERSION 42+; v41 readers never reach this block.
8571            let mut set_inline_bindings: Vec<(usize, &[String])> = Vec::new();
8572            for (i, c) in t.schema.columns.iter().enumerate() {
8573                if let Some(vs) = &c.inline_set_variants {
8574                    set_inline_bindings.push((i, vs.as_slice()));
8575                }
8576            }
8577            write_u16(
8578                &mut out,
8579                u16::try_from(set_inline_bindings.len()).expect("≤ 65k inline-SET columns/table"),
8580            );
8581            for (pos, variants) in set_inline_bindings {
8582                write_u16(&mut out, u16::try_from(pos).expect("≤ 65k columns/table"));
8583                write_u16(
8584                    &mut out,
8585                    u16::try_from(variants.len()).expect("≤ 65k variants/SET"),
8586                );
8587                for v in variants {
8588                    write_str(&mut out, v.as_str());
8589                }
8590            }
8591            // v7.37.6-B — partition role appendix(FILE_VERSION 49+)。
8592            // Layout 详见 FILE_VERSION 49 docstring。普通表 = 单字节 0。
8593            write_partition_role(&mut out, t.schema.partition_role.as_ref());
8594            // v7.37.7 — per-table generated_stored_expr appendix
8595            // (FILE_VERSION 50+). Sparse: only columns whose
8596            // generated_stored_expr is Some land here.
8597            let mut gen_bindings: Vec<(usize, &str)> = Vec::new();
8598            for (i, c) in t.schema.columns.iter().enumerate() {
8599                if let Some(src) = &c.generated_stored_expr {
8600                    gen_bindings.push((i, src.as_str()));
8601                }
8602            }
8603            write_u16(
8604                &mut out,
8605                u16::try_from(gen_bindings.len()).expect("≤ 65k GENERATED STORED columns/table"),
8606            );
8607            for (pos, src) in gen_bindings {
8608                write_u16(&mut out, u16::try_from(pos).expect("≤ 65k columns/table"));
8609                write_str(&mut out, src);
8610            }
8611            // v7.38 (read01) — per-table default_text appendix
8612            // (FILE_VERSION 58+). Sparse: only columns whose default_text
8613            // is Some land here. Mirrors the generated_stored_expr shape.
8614            let mut default_texts: Vec<(usize, &str)> = Vec::new();
8615            for (i, c) in t.schema.columns.iter().enumerate() {
8616                if let Some(src) = &c.default_text {
8617                    default_texts.push((i, src.as_str()));
8618                }
8619            }
8620            write_u16(
8621                &mut out,
8622                u16::try_from(default_texts.len()).expect("≤ 65k defaulted columns/table"),
8623            );
8624            for (pos, src) in default_texts {
8625                write_u16(&mut out, u16::try_from(pos).expect("≤ 65k columns/table"));
8626                write_str(&mut out, src);
8627            }
8628            // v7.39 (RLS) — per-table policy appendix + the two RLS flags
8629            // (FILE_VERSION 59+). Written after the default_text block and
8630            // before the MVCC row appendix, so a v58 reader stops before it.
8631            // Layout: [u8 row_security][u8 force] [u16 policy_count] then per
8632            // policy: [str name][u8 cmd][u8 permissive][u16 role_count]
8633            // (role_count × str) [u8 has_using](+str)[u8 has_check](+str).
8634            out.push(u8::from(t.schema.row_security));
8635            out.push(u8::from(t.schema.force_row_security));
8636            write_u16(
8637                &mut out,
8638                u16::try_from(t.schema.policies.len()).expect("≤ 65k policies/table"),
8639            );
8640            for p in &t.schema.policies {
8641                write_str(&mut out, &p.name);
8642                out.push(p.cmd.to_wire_byte());
8643                out.push(u8::from(p.permissive));
8644                write_u16(
8645                    &mut out,
8646                    u16::try_from(p.roles.len()).expect("≤ 65k roles/policy"),
8647                );
8648                for r in &p.roles {
8649                    write_str(&mut out, r);
8650                }
8651                match &p.using_expr {
8652                    Some(s) => {
8653                        out.push(1);
8654                        write_str(&mut out, s);
8655                    }
8656                    None => out.push(0),
8657                }
8658                match &p.with_check_expr {
8659                    Some(s) => {
8660                        out.push(1);
8661                        write_str(&mut out, s);
8662                    }
8663                    None => out.push(0),
8664                }
8665            }
8666            // v7.37.16 (Epic W) — per-row MVCC header + stable RowId
8667            // appendix (FILE_VERSION 53+). Persists xmin/xmax/flags +
8668            // RowId for every row so a tombstone naming a pre-checkpoint
8669            // row survives a serialize→deserialize base restore
8670            // (cross-checkpoint tombstone durability). `headers` /
8671            // `rowids` are lock-step parallel to `rows` (invariant held
8672            // at every mutation boundary), so the count is `rows.len()`
8673            // and the zipped walk visits them in physical row order —
8674            // the same order the rows block above was written in. v52
8675            // readers never reach this block (the writer also moves to
8676            // v53 in lock-step); a v53 reader restores headers + ids
8677            // verbatim instead of freezing + dense-assigning.
8678            debug_assert_eq!(
8679                t.rows.len(),
8680                t.headers.len(),
8681                "headers must be lock-step with rows at serialize"
8682            );
8683            debug_assert_eq!(
8684                t.rows.len(),
8685                t.rowids.len(),
8686                "rowids must be lock-step with rows at serialize"
8687            );
8688            write_u32(
8689                &mut out,
8690                u32::try_from(t.rows.len()).expect("≤ 4G rows/table"),
8691            );
8692            for (h, rid) in t.headers.iter().zip(t.rowids.iter()) {
8693                out.extend_from_slice(&h.xmin.to_le_bytes());
8694                out.extend_from_slice(&h.xmax.to_le_bytes());
8695                out.push(h.flags);
8696                out.extend_from_slice(&rid.0.to_le_bytes());
8697            }
8698            out.extend_from_slice(&t.next_rowid.to_le_bytes());
8699            // v7.39 (read01 round 48) — constraint-name appendix
8700            // (FILE_VERSION 60+). Index-aligned to the CHECK and
8701            // uniqueness-constraint appendices written above, so the
8702            // existing byte layouts stay untouched and a v59 catalog still
8703            // decodes (its constraints just come back unnamed).
8704            // Layout: [u16 check_count] then per check
8705            //         [u8 has_name] ([str name] when has_name)
8706            //         [u16 uc_count] then per uc the same pair.
8707            write_u16(
8708                &mut out,
8709                u16::try_from(t.schema.checks.len()).expect("≤ 65k CHECK constraints/table"),
8710            );
8711            for c in &t.schema.checks {
8712                match &c.name {
8713                    Some(n) => {
8714                        out.push(1);
8715                        write_str(&mut out, n);
8716                    }
8717                    None => out.push(0),
8718                }
8719            }
8720            write_u16(
8721                &mut out,
8722                u16::try_from(t.schema.uniqueness_constraints.len())
8723                    .expect("≤ 65k uniqueness constraints/table"),
8724            );
8725            for uc in &t.schema.uniqueness_constraints {
8726                match &uc.name {
8727                    Some(n) => {
8728                        out.push(1);
8729                        write_str(&mut out, n);
8730                    }
8731                    None => out.push(0),
8732                }
8733            }
8734            // v7.39 (read01 round 56) — user_composite_type appendix
8735            // (FILE_VERSION 63+). Sparse, at the very end of the per-table
8736            // block: only composite-typed columns land here, so a v62 reader
8737            // stops before it and its composite columns stay plain JSON.
8738            let mut comp_bindings: Vec<(usize, &str)> = Vec::new();
8739            for (i, c) in t.schema.columns.iter().enumerate() {
8740                if let Some(n) = &c.user_composite_type {
8741                    comp_bindings.push((i, n.as_str()));
8742                }
8743            }
8744            write_u16(
8745                &mut out,
8746                u16::try_from(comp_bindings.len()).expect("≤ 65k composite-typed columns/table"),
8747            );
8748            for (pos, n) in comp_bindings {
8749                write_u16(&mut out, u16::try_from(pos).expect("≤ 65k columns/table"));
8750                write_str(&mut out, n);
8751            }
8752            // v7.39 (read01 round 57) — owner + ACL appendix (FILE_VERSION
8753            // 64+), at the very end of the per-table block so a v63 reader
8754            // stops before it (its tables then read back owner-less, i.e.
8755            // owned by the login role, with no grants — which is exactly what
8756            // they were).
8757            match &t.schema.owner {
8758                Some(o) => {
8759                    out.push(1);
8760                    write_str(&mut out, o);
8761                }
8762                None => out.push(0),
8763            }
8764            write_u16(
8765                &mut out,
8766                u16::try_from(t.schema.acl.len()).expect("≤ 65k aclitems/table"),
8767            );
8768            for a in &t.schema.acl {
8769                write_str(&mut out, &a.grantee);
8770                write_u16(&mut out, a.privs);
8771                write_u16(&mut out, a.grantable);
8772                write_str(&mut out, &a.grantor);
8773            }
8774            // v7.39 (read01 round 59) — COLUMN acl appendix (FILE_VERSION 65+),
8775            // sparse: only columns that carry a grant land here, so a v64 reader
8776            // stops before it and its columns read back un-granted, which is
8777            // what they were.
8778            let granted: Vec<(usize, &ColumnSchema)> = t
8779                .schema
8780                .columns
8781                .iter()
8782                .enumerate()
8783                .filter(|(_, c)| !c.acl.is_empty())
8784                .collect();
8785            write_u16(
8786                &mut out,
8787                u16::try_from(granted.len()).expect("≤ 65k granted columns/table"),
8788            );
8789            for (pos, c) in granted {
8790                write_u16(&mut out, u16::try_from(pos).expect("≤ 65k columns/table"));
8791                write_u16(
8792                    &mut out,
8793                    u16::try_from(c.acl.len()).expect("≤ 65k aclitems/column"),
8794                );
8795                for a in &c.acl {
8796                    write_str(&mut out, &a.grantee);
8797                    write_u16(&mut out, a.privs);
8798                    write_u16(&mut out, a.grantable);
8799                    write_str(&mut out, &a.grantor);
8800                }
8801            }
8802            // v7.39 (round 210) — EXCLUDE-constraint appendix (FILE_VERSION
8803            // 72+), at the very end of the per-table block so a v71 reader
8804            // stops before it and its tables read back with no exclusion
8805            // constraints. Layout: [u16 excl_count] then per constraint
8806            // [str name] [u8 has_method](+str) [u16 elem_count] then per
8807            // element [u16 col_pos][str op].
8808            write_u16(
8809                &mut out,
8810                u16::try_from(t.schema.exclusion_constraints.len())
8811                    .expect("≤ 65k exclusion constraints/table"),
8812            );
8813            for ex in &t.schema.exclusion_constraints {
8814                write_str(&mut out, &ex.name);
8815                match &ex.method {
8816                    Some(m) => {
8817                        out.push(1);
8818                        write_str(&mut out, m);
8819                    }
8820                    None => out.push(0),
8821                }
8822                write_u16(
8823                    &mut out,
8824                    u16::try_from(ex.elements.len()).expect("≤ 65k elements/exclusion"),
8825                );
8826                for (pos, op) in &ex.elements {
8827                    write_u16(&mut out, u16::try_from(*pos).expect("≤ 65k columns/table"));
8828                    write_str(&mut out, op);
8829                }
8830            }
8831            // v7.39 (round 220) — identity-RESTART appendix (FILE_VERSION
8832            // 73+), sparse: only columns carrying a RESTART floor land here.
8833            let restarts: Vec<(usize, i64)> = t
8834                .schema
8835                .columns
8836                .iter()
8837                .enumerate()
8838                .filter_map(|(i, c)| c.auto_restart.map(|n| (i, n)))
8839                .collect();
8840            write_u16(
8841                &mut out,
8842                u16::try_from(restarts.len()).expect("≤ 65k restart columns/table"),
8843            );
8844            for (pos, n) in restarts {
8845                write_u16(&mut out, u16::try_from(pos).expect("≤ 65k columns/table"));
8846                out.extend_from_slice(&n.to_le_bytes());
8847            }
8848            // v7.39 (round 386, type-fidelity epic P1) — per-table
8849            // mysql_int_width appendix (FILE_VERSION 81+). Sparse: only
8850            // TINYINT / MEDIUMINT columns land. Layout:
8851            // `[u16 count]([u16 col_pos][u8 width_tag]) × count`
8852            // (tag 0 = Tiny, 1 = Medium). v80-and-below readers stop after
8853            // the identity-RESTART appendix, leaving every column at None.
8854            let int_widths: Vec<(usize, u8)> = t
8855                .schema
8856                .columns
8857                .iter()
8858                .enumerate()
8859                .filter_map(|(i, c)| {
8860                    c.mysql_int_width.map(|w| {
8861                        let tag = match w {
8862                            MysqlIntWidth::Tiny => 0u8,
8863                            MysqlIntWidth::Medium => 1u8,
8864                            MysqlIntWidth::Small => 2u8,
8865                            MysqlIntWidth::Int => 3u8,
8866                            MysqlIntWidth::Big => 4u8,
8867                        };
8868                        (i, tag)
8869                    })
8870                })
8871                .collect();
8872            write_u16(
8873                &mut out,
8874                u16::try_from(int_widths.len()).expect("≤ 65k narrow-int columns/table"),
8875            );
8876            for (pos, tag) in int_widths {
8877                write_u16(&mut out, u16::try_from(pos).expect("≤ 65k columns/table"));
8878                out.push(tag);
8879            }
8880            // v7.39 (round 424, type-fidelity epic) — per-table mysql_fsp
8881            // appendix (FILE_VERSION 82+). Sparse: only MySQL-declared
8882            // temporal columns land. Layout:
8883            // `[u16 count]([u16 col_pos][u8 fsp]) × count`, fsp in 0..=6.
8884            // v81-and-below readers stop after the int-width appendix,
8885            // leaving every column at None (PG microsecond behaviour).
8886            let fsps: Vec<(usize, u8)> = t
8887                .schema
8888                .columns
8889                .iter()
8890                .enumerate()
8891                .filter_map(|(i, c)| c.mysql_fsp.map(|p| (i, p)))
8892                .collect();
8893            write_u16(
8894                &mut out,
8895                u16::try_from(fsps.len()).expect("≤ 65k temporal columns/table"),
8896            );
8897            for (pos, fsp) in fsps {
8898                write_u16(&mut out, u16::try_from(pos).expect("≤ 65k columns/table"));
8899                out.push(fsp);
8900            }
8901            // v7.39 (round 652) — CHECK-validated appendix (FILE_VERSION
8902            // 87+). Sparse the other way round from the ones above: the
8903            // common case is every constraint validated, so only the
8904            // NOT VALID ones are written, by their index into the CHECK
8905            // appendix. Layout: `[u16 count]([u16 check_idx]) × count`.
8906            let unvalidated: Vec<usize> = t
8907                .schema
8908                .checks
8909                .iter()
8910                .enumerate()
8911                .filter_map(|(i, c)| (!c.validated).then_some(i))
8912                .collect();
8913            write_u16(
8914                &mut out,
8915                u16::try_from(unvalidated.len()).expect("≤ 65k CHECK constraints/table"),
8916            );
8917            for idx in unvalidated {
8918                write_u16(&mut out, u16::try_from(idx).expect("≤ 65k CHECK/table"));
8919            }
8920            // v7.39 (round 677) — per-column collation names (FILE_VERSION
8921            // 88+). Sparse: only the columns that were written with an
8922            // explicit `COLLATE` appear, so a table that declares none pays
8923            // two bytes. Layout: `[u16 count]([u16 col_idx][str]) × count`.
8924            //
8925            // Without this the declaration survives CREATE TABLE and dies
8926            // at the next restart — measured: a column declared
8927            // `COLLATE "C"` reported attcollation 950 in the session that
8928            // created it and 100 after a reload.
8929            let collated: Vec<(usize, &str)> = t
8930                .schema
8931                .columns
8932                .iter()
8933                .enumerate()
8934                .filter_map(|(i, c)| c.collation_name.as_deref().map(|n| (i, n)))
8935                .collect();
8936            write_u16(
8937                &mut out,
8938                u16::try_from(collated.len()).expect("≤ 65k columns/table"),
8939            );
8940            for (idx, name) in collated {
8941                write_u16(&mut out, u16::try_from(idx).expect("≤ 65k columns/table"));
8942                write_str(&mut out, name);
8943            }
8944            // v7.39 (round 711) — PK/UNIQUE constraint timing (FILE_VERSION
8945            // 89+). Dense, one byte per uniqueness constraint in
8946            // declaration order, the same bit layout the FK block has
8947            // carried since round 288: bit 0 = DEFERRABLE, bit 1 =
8948            // INITIALLY DEFERRED. A v88 reader stops before it.
8949            write_u16(
8950                &mut out,
8951                u16::try_from(t.schema.uniqueness_constraints.len())
8952                    .expect("≤ 65k uniqueness constraints/table"),
8953            );
8954            for uc in &t.schema.uniqueness_constraints {
8955                out.push(u8::from(uc.deferrable) | (u8::from(uc.initially_deferred) << 1));
8956            }
8957        }
8958        // v7.12.4 — catalog-wide appendix: user-defined functions
8959        // then triggers. FILE_VERSION 22+ only. v21 and earlier
8960        // readers stop after the last table; v22 readers always
8961        // consume two `u32` counts (possibly zero).
8962        //
8963        // Function entry layout:
8964        //   [str name] [str args_repr] [str returns]
8965        //   [str language] [str body]
8966        // Trigger entry layout:
8967        //   [str name] [str table] [str timing]
8968        //   [u16 event_count] (event_count × str)
8969        //   [str for_each] [str function]
8970        write_u32(
8971            &mut out,
8972            u32::try_from(self.functions.len()).expect("≤ 4G functions"),
8973        );
8974        for fd in self.functions.values() {
8975            write_str(&mut out, &fd.name);
8976            write_str(&mut out, &fd.args_repr);
8977            write_str(&mut out, &fd.returns);
8978            write_str(&mut out, &fd.language);
8979            write_str_long(&mut out, &fd.body);
8980        }
8981        write_u32(
8982            &mut out,
8983            u32::try_from(self.triggers.len()).expect("≤ 4G triggers"),
8984        );
8985        for td in &self.triggers {
8986            write_str(&mut out, &td.name);
8987            write_str(&mut out, &td.table);
8988            write_str(&mut out, &td.timing);
8989            write_u16(
8990                &mut out,
8991                u16::try_from(td.events.len()).expect("≤ 65k events / trigger"),
8992            );
8993            for ev in &td.events {
8994                write_str(&mut out, ev);
8995            }
8996            write_str(&mut out, &td.for_each);
8997            write_str(&mut out, &td.function);
8998            // v7.13.0 — `UPDATE OF cols` filter
8999            // (FILE_VERSION 23+). v22 readers omit; v23 writers
9000            // always emit (possibly zero).
9001            write_u16(
9002                &mut out,
9003                u16::try_from(td.update_columns.len()).expect("≤ 65k cols / trigger"),
9004            );
9005            for c in &td.update_columns {
9006                write_str(&mut out, c);
9007            }
9008            // v7.16.1 — TriggerDef.enabled (FILE_VERSION 25+).
9009            out.push(u8::from(td.enabled));
9010            // v7.39 (round 138) — WHEN condition text (FILE_VERSION 70+).
9011            write_str(&mut out, &td.when_condition);
9012        }
9013        // v7.17.0 Phase 1.1 — SEQUENCE catalog block (FILE_VERSION 26+).
9014        write_u32(
9015            &mut out,
9016            u32::try_from(self.sequences.len()).expect("≤ 4G sequences"),
9017        );
9018        for seq in self.sequences.values() {
9019            write_str(&mut out, &seq.name);
9020            out.push(match seq.data_type {
9021                SequenceDataType::SmallInt => 0,
9022                SequenceDataType::Int => 1,
9023                SequenceDataType::BigInt => 2,
9024            });
9025            out.extend_from_slice(&seq.start.to_le_bytes());
9026            out.extend_from_slice(&seq.increment.to_le_bytes());
9027            out.extend_from_slice(&seq.min_value.to_le_bytes());
9028            out.extend_from_slice(&seq.max_value.to_le_bytes());
9029            out.extend_from_slice(&seq.cache.to_le_bytes());
9030            out.push(u8::from(seq.cycle));
9031            match &seq.owned_by {
9032                None => out.push(0),
9033                Some((table, column)) => {
9034                    out.push(1);
9035                    write_str(&mut out, table);
9036                    write_str(&mut out, column);
9037                }
9038            }
9039            out.extend_from_slice(&seq.last_value.to_le_bytes());
9040            out.push(u8::from(seq.is_called));
9041        }
9042        // v7.17.0 Phase 1.2 — VIEW catalog block (FILE_VERSION 27+).
9043        write_u32(
9044            &mut out,
9045            u32::try_from(self.views.len()).expect("≤ 4G views"),
9046        );
9047        for view in self.views.values() {
9048            write_str(&mut out, &view.name);
9049            write_u16(
9050                &mut out,
9051                u16::try_from(view.columns.len()).expect("≤ 65k cols / view"),
9052            );
9053            for c in &view.columns {
9054                write_str(&mut out, c);
9055            }
9056            write_str_long(&mut out, &view.body);
9057            // v7.39 (round 132, FILE_VERSION 69+) — WITH CHECK OPTION marker.
9058            out.push(view.check_option);
9059        }
9060        // v7.17.0 Phase 1.3 — MATERIALIZED VIEW source registry
9061        // (FILE_VERSION 28+). The backing rows live as a regular
9062        // table of the same name already in the tables block.
9063        write_u32(
9064            &mut out,
9065            u32::try_from(self.materialized_views.len()).expect("≤ 4G materialized views"),
9066        );
9067        for (name, body) in &self.materialized_views {
9068            write_str(&mut out, name);
9069            write_str_long(&mut out, body);
9070        }
9071        // v7.17.0 Phase 1.4 — ENUM types catalog block
9072        // (FILE_VERSION 29+).
9073        write_u32(
9074            &mut out,
9075            u32::try_from(self.enum_types.len()).expect("≤ 4G enum types"),
9076        );
9077        for e in self.enum_types.values() {
9078            write_str(&mut out, &e.name);
9079            write_u16(
9080                &mut out,
9081                u16::try_from(e.labels.len()).expect("≤ 65k labels / enum"),
9082            );
9083            for l in &e.labels {
9084                write_str(&mut out, l);
9085            }
9086        }
9087        // v7.17.0 Phase 1.5 — DOMAIN types catalog block
9088        // (FILE_VERSION 30+).
9089        write_u32(
9090            &mut out,
9091            u32::try_from(self.domain_types.len()).expect("≤ 4G domain types"),
9092        );
9093        for d in self.domain_types.values() {
9094            write_str(&mut out, &d.name);
9095            write_data_type(&mut out, d.base_type);
9096            out.push(u8::from(d.nullable));
9097            match &d.default {
9098                None => out.push(0),
9099                Some(s) => {
9100                    out.push(1);
9101                    write_str(&mut out, s);
9102                }
9103            }
9104            write_u16(
9105                &mut out,
9106                u16::try_from(d.checks.len()).expect("≤ 65k CHECKs / domain"),
9107            );
9108            for c in &d.checks {
9109                write_str(&mut out, &c.expr);
9110                // v7.39 (round 260) — the constraint name (FILE_VERSION 75+).
9111                write_str(&mut out, &c.name);
9112            }
9113            // v7.39 (round 259) — the parent domain (FILE_VERSION 74+).
9114            match &d.base_domain {
9115                None => out.push(0),
9116                Some(s) => {
9117                    out.push(1);
9118                    write_str(&mut out, s);
9119                }
9120            }
9121        }
9122        // v7.17.0 Phase 1.6 — user-schemas registry
9123        // (FILE_VERSION 31+). Built-ins are hardcoded in
9124        // `is_builtin_schema` and not persisted.
9125        write_u32(
9126            &mut out,
9127            u32::try_from(self.schemas.len()).expect("≤ 4G schemas"),
9128        );
9129        for name in &self.schemas {
9130            write_str(&mut out, name);
9131        }
9132        // v7.37.42-T2 ζ-B — COMPOSITE types catalog block
9133        // (FILE_VERSION 52+). Each entry: name, u16 field_count,
9134        // then field_count `[str field_name][data_type]` pairs.
9135        write_u32(
9136            &mut out,
9137            u32::try_from(self.composite_types.len()).expect("≤ 4G composite types"),
9138        );
9139        for c in self.composite_types.values() {
9140            write_str(&mut out, &c.name);
9141            write_u16(
9142                &mut out,
9143                u16::try_from(c.fields.len()).expect("≤ 65k fields / composite"),
9144            );
9145            for (i, (fname, fty)) in c.fields.iter().enumerate() {
9146                write_str(&mut out, fname);
9147                write_data_type(&mut out, *fty);
9148                // v7.39 (round 264) — the field's user type (v76+).
9149                match c.field_user_types.get(i).and_then(Option::as_ref) {
9150                    None => out.push(0),
9151                    Some(n) => {
9152                        out.push(1);
9153                        write_str(&mut out, n);
9154                    }
9155                }
9156            }
9157        }
9158        // v7.39 (read01 round 50) — COMMENT store (FILE_VERSION 61+).
9159        // Catalog-wide, written last (before the CRC trailer) so every older
9160        // reader stops before it. Layout: [u32 count] then [str key][str text].
9161        write_u32(
9162            &mut out,
9163            u32::try_from(self.comments.len()).expect("≤ 4G comments"),
9164        );
9165        for (k, v) in &self.comments {
9166            write_str(&mut out, k);
9167            write_str_long(&mut out, v);
9168        }
9169        // v7.39 (read01 round 60) — non-table ACLs (FILE_VERSION 66+), catalog-
9170        // wide and written last so a v65 reader stops before them. The sequence
9171        // block itself sits mid-image and cannot grow without breaking older
9172        // readers, so a sequence's owner + ACL rides here, keyed by name.
9173        let acl_out = |out: &mut Vec<u8>, acl: &[AclItem]| {
9174            write_u16(out, u16::try_from(acl.len()).expect("≤ 65k aclitems"));
9175            for a in acl {
9176                write_str(out, &a.grantee);
9177                write_u16(out, a.privs);
9178                write_u16(out, a.grantable);
9179                write_str(out, &a.grantor);
9180            }
9181        };
9182        let owned: Vec<&SequenceDef> = self
9183            .sequences
9184            .values()
9185            .filter(|s| s.owner.is_some() || !s.acl.is_empty())
9186            .collect();
9187        write_u32(
9188            &mut out,
9189            u32::try_from(owned.len()).expect("≤ 4G sequences"),
9190        );
9191        for seq in owned {
9192            write_str(&mut out, &seq.name);
9193            match &seq.owner {
9194                Some(o) => {
9195                    out.push(1);
9196                    write_str(&mut out, o);
9197                }
9198                None => out.push(0),
9199            }
9200            acl_out(&mut out, &seq.acl);
9201        }
9202        acl_out(&mut out, &self.schema_acl);
9203        acl_out(&mut out, &self.database_acl);
9204        // v7.39 (read01 round 61) — FUNCTION owner + ACL (FILE_VERSION 67+).
9205        // The function block sits mid-image like the sequence one, so this
9206        // rides the catalog-wide tail too, keyed by name.
9207        let fns: Vec<&FunctionDef> = self
9208            .functions
9209            .values()
9210            .filter(|f| f.owner.is_some() || !f.acl.is_empty())
9211            .collect();
9212        write_u32(&mut out, u32::try_from(fns.len()).expect("≤ 4G functions"));
9213        for f in fns {
9214            // v7.39 (read01 round 62) — keyed by SIGNATURE now: two overloads
9215            // have two ACLs.
9216            write_str(&mut out, &function_signature_key(&f.name, &f.args_repr));
9217            match &f.owner {
9218                Some(o) => {
9219                    out.push(1);
9220                    write_str(&mut out, o);
9221                }
9222                None => out.push(0),
9223            }
9224            acl_out(&mut out, &f.acl);
9225        }
9226        // v7.39 (round 139) — RULE catalog block (FILE_VERSION 71+), catalog-
9227        // wide and written last (right before the CRC trailer) so every older
9228        // reader stops cleanly before it. Layout: [u32 count] then per rule
9229        // [str name][str table][str event][u8 instead][str when]
9230        // [u16 cmd_count]([str cmd] × cmd_count).
9231        write_u32(
9232            &mut out,
9233            u32::try_from(self.rules.len()).expect("≤ 4G rules"),
9234        );
9235        for r in &self.rules {
9236            write_str(&mut out, &r.name);
9237            write_str(&mut out, &r.table);
9238            write_str(&mut out, &r.event);
9239            out.push(u8::from(r.instead));
9240            write_str(&mut out, &r.when_condition);
9241            write_u16(
9242                &mut out,
9243                u16::try_from(r.commands.len()).expect("≤ 65k commands / rule"),
9244            );
9245            for c in &r.commands {
9246                write_str(&mut out, c);
9247            }
9248        }
9249        // v7.39 (round 280) — extended-statistics block (FILE_VERSION
9250        // 77+), appended after the RULE block for the same reason: an
9251        // older reader stops cleanly before it. Layout: [u32 count]
9252        // then per object [str name][str table][u16 n]([str kind] × n)
9253        // [u16 m]([str column] × m).
9254        write_u32(
9255            &mut out,
9256            u32::try_from(self.statistics_ext.len()).expect("≤ 4G statistics objects"),
9257        );
9258        for st in &self.statistics_ext {
9259            write_str(&mut out, &st.name);
9260            write_str(&mut out, &st.table);
9261            write_u16(
9262                &mut out,
9263                u16::try_from(st.kinds.len()).expect("≤ 65k kinds"),
9264            );
9265            for k in &st.kinds {
9266                write_str(&mut out, k);
9267            }
9268            write_u16(
9269                &mut out,
9270                u16::try_from(st.columns.len()).expect("≤ 65k columns"),
9271            );
9272            for c in &st.columns {
9273                write_str(&mut out, c);
9274            }
9275        }
9276        // v7.39 (round 287) — large-object block (FILE_VERSION 78+),
9277        // appended after the statistics block for the same reason: an
9278        // older reader stops cleanly before it. Layout: [u32 count]
9279        // then per object [u32 oid][u32 len][len bytes].
9280        write_u32(
9281            &mut out,
9282            u32::try_from(self.large_objects.len()).expect("≤ 4G large objects"),
9283        );
9284        for (oid, bytes) in &self.large_objects {
9285            write_u32(&mut out, *oid);
9286            write_u32(
9287                &mut out,
9288                u32::try_from(bytes.len()).expect("≤ 4G per object"),
9289            );
9290            out.extend_from_slice(bytes);
9291        }
9292        // v7.39 (round 322, V46) — function-attribute block (FILE_VERSION
9293        // 80+), appended last for the same reason as every block before
9294        // it: an older reader stops cleanly ahead of it and simply sees
9295        // functions with PG's default attributes. Only functions that
9296        // declared something non-default are written. Layout: [u32 count]
9297        // then per function [str signature_key][u8 volatility][u8 flags]
9298        // [u8 parallel][f64 cost or NaN][f64 rows or NaN], where flags bit
9299        // 0 = strict, 1 = security definer, 2 = leakproof.
9300        let attr_fns: Vec<(&String, &FunctionDef)> = self
9301            .functions
9302            .iter()
9303            .filter(|(_, f)| {
9304                f.volatility != FN_VOLATILE
9305                    || f.strict
9306                    || f.security_definer
9307                    || f.leakproof
9308                    || f.parallel != FN_PARALLEL_UNSAFE
9309                    || f.cost.is_some()
9310                    || f.rows.is_some()
9311            })
9312            .collect();
9313        write_u32(
9314            &mut out,
9315            u32::try_from(attr_fns.len()).expect("≤ 4G functions"),
9316        );
9317        for (key, f) in attr_fns {
9318            write_str(&mut out, key);
9319            out.push(f.volatility);
9320            let flags = u8::from(f.strict)
9321                | (u8::from(f.security_definer) << 1)
9322                | (u8::from(f.leakproof) << 2);
9323            out.push(flags);
9324            out.push(f.parallel);
9325            out.extend_from_slice(&f.cost.unwrap_or(f64::NAN).to_le_bytes());
9326            out.extend_from_slice(&f.rows.unwrap_or(f64::NAN).to_le_bytes());
9327        }
9328        // v7.38 (read01 P5.05) — CRC32C trailer over the whole image so a
9329        // corrupted snapshot is rejected on load. FILE_VERSION is >= the
9330        // trailer version, so this always runs for freshly-written images.
9331        // v7.39 (round 547) — pg_db_role_setting (FILE_VERSION 85+),
9332        // catalog-wide and written LAST so a v84 reader stops before it.
9333        // Layout: [u32 scopes] then [str database][str role][u32 params]
9334        // then [str name][str value] per param.
9335        write_u32(
9336            &mut out,
9337            u32::try_from(self.db_role_settings.len()).expect("≤ 4G scopes"),
9338        );
9339        for ((db, role), params) in &self.db_role_settings {
9340            write_str(&mut out, db);
9341            write_str(&mut out, role);
9342            write_u32(&mut out, u32::try_from(params.len()).expect("≤ 4G params"));
9343            for (name, value) in params {
9344                write_str(&mut out, name);
9345                write_str(&mut out, value);
9346            }
9347        }
9348        // v7.39 (round 550) — replication slots (FILE_VERSION 86+),
9349        // written LAST so a v85 reader stops before them.
9350        write_u32(
9351            &mut out,
9352            u32::try_from(self.replication_slots.len()).expect("≤ 4G slots"),
9353        );
9354        for (name, (plugin, slot_type)) in &self.replication_slots {
9355            write_str(&mut out, name);
9356            write_str(&mut out, plugin);
9357            write_str(&mut out, slot_type);
9358        }
9359        let crc = spg_crypto::crc32c::crc32c(&out);
9360        write_u32(&mut out, crc);
9361        out
9362    }
9363
9364    /// Deserialize a previously-serialized catalog. Rejects bad magic, version
9365    /// mismatch, unknown tags, truncation, and trailing bytes.
9366    pub fn deserialize(buf: &[u8]) -> Result<Self, StorageError> {
9367        let mut cur = Cursor::new(buf);
9368        let magic = cur.take(8)?;
9369        if magic != FILE_MAGIC {
9370            return Err(StorageError::Corrupt(format!(
9371                "bad magic: expected SPGDB001, got {magic:?}"
9372            )));
9373        }
9374        let version = cur.read_u8()?;
9375        if !(MIN_SUPPORTED_FILE_VERSION..=FILE_VERSION).contains(&version) {
9376            return Err(StorageError::Corrupt(format!(
9377                "unsupported file version: {version} (supported: {MIN_SUPPORTED_FILE_VERSION}..={FILE_VERSION})"
9378            )));
9379        }
9380        // v7.23/v7.27 — escape decoding is version-gated (see
9381        // STR_LEN_ESCAPE / Cursor::codec_version).
9382        cur.codec_version = version;
9383        let table_count = cur.read_u32()? as usize;
9384        let mut cat = Self::new();
9385        for _ in 0..table_count {
9386            deserialize_table(&mut cur, &mut cat, version)?;
9387        }
9388        // v7.37.15 (Phase C.1) — stamp dense stable RelIds on load.
9389        // Pre-V6 envelopes carry no ids; a dense 1..=N assignment is
9390        // sufficient while RelId is process-local bookkeeping (the V6
9391        // envelope, Phase C.6, will round-trip real ids). Sets the
9392        // allocator above the loaded ids so a post-load CREATE TABLE
9393        // never collides.
9394        for (i, t) in cat.tables.iter_mut().enumerate() {
9395            t.set_rel_id(row_header::RelId((i as u64) + 1));
9396        }
9397        cat.next_rel_id = cat.tables.len() as u64;
9398        // v7.12.4 — catalog-wide function + trigger appendix.
9399        // FILE_VERSION 22+ only; v21 and earlier catalogs stop
9400        // after the last table.
9401        if version >= 22 {
9402            let fn_count = cur.read_u32()? as usize;
9403            for _ in 0..fn_count {
9404                let name = cur.read_str()?;
9405                let args_repr = cur.read_str()?;
9406                let returns = cur.read_str()?;
9407                let language = cur.read_str()?;
9408                let body = cur.read_str_long()?;
9409                let key = function_signature_key(&name, &args_repr);
9410                cat.functions.insert(
9411                    key,
9412                    FunctionDef {
9413                        name,
9414                        args_repr,
9415                        returns,
9416                        language,
9417                        body,
9418                        owner: None,
9419                        acl: Vec::new(),
9420                        volatility: FN_VOLATILE,
9421                        strict: false,
9422                        security_definer: false,
9423                        leakproof: false,
9424                        parallel: FN_PARALLEL_UNSAFE,
9425                        cost: None,
9426                        rows: None,
9427                    },
9428                );
9429            }
9430            let trg_count = cur.read_u32()? as usize;
9431            for _ in 0..trg_count {
9432                let name = cur.read_str()?;
9433                let table = cur.read_str()?;
9434                let timing = cur.read_str()?;
9435                let ev_count = cur.read_u16()? as usize;
9436                let mut events = Vec::with_capacity(ev_count);
9437                for _ in 0..ev_count {
9438                    events.push(cur.read_str()?);
9439                }
9440                let for_each = cur.read_str()?;
9441                let function = cur.read_str()?;
9442                // v7.13.0 — trailing `UPDATE OF cols` filter
9443                // (FILE_VERSION 23+ only; v22 catalogs omit and
9444                // deserialise with an empty vec).
9445                let update_columns = if version >= 23 {
9446                    let n = cur.read_u16()? as usize;
9447                    let mut cols = Vec::with_capacity(n);
9448                    for _ in 0..n {
9449                        cols.push(cur.read_str()?);
9450                    }
9451                    cols
9452                } else {
9453                    Vec::new()
9454                };
9455                // v7.16.1 — TriggerDef.enabled (FILE_VERSION 25+).
9456                // v24-and-below catalogs deserialise with `true`
9457                // — pre-v7.16.1 every trigger always fired.
9458                let enabled = if version >= 25 {
9459                    cur.read_u8()? != 0
9460                } else {
9461                    true
9462                };
9463                // v7.39 (round 138) — WHEN condition text added at FILE_VERSION
9464                // 70; older catalogs read back empty (no WHEN filter).
9465                let when_condition = if version >= 70 {
9466                    cur.read_str()?
9467                } else {
9468                    String::new()
9469                };
9470                cat.triggers.push(TriggerDef {
9471                    name,
9472                    table,
9473                    timing,
9474                    events,
9475                    for_each,
9476                    function,
9477                    update_columns,
9478                    enabled,
9479                    when_condition,
9480                });
9481            }
9482        }
9483        // v7.17.0 Phase 1.1 — SEQUENCE block (FILE_VERSION 26+).
9484        // v25-and-below catalogs omit; we leave the map empty.
9485        if version >= 26 {
9486            let seq_count = cur.read_u32()? as usize;
9487            for _ in 0..seq_count {
9488                let name = cur.read_str()?;
9489                let data_type = match cur.read_u8()? {
9490                    0 => SequenceDataType::SmallInt,
9491                    1 => SequenceDataType::Int,
9492                    2 => SequenceDataType::BigInt,
9493                    other => {
9494                        return Err(StorageError::Corrupt(format!(
9495                            "unknown SEQUENCE data-type tag {other}"
9496                        )));
9497                    }
9498                };
9499                let start = cur.read_i64()?;
9500                let increment = cur.read_i64()?;
9501                let min_value = cur.read_i64()?;
9502                let max_value = cur.read_i64()?;
9503                let cache = cur.read_i64()?;
9504                let cycle = cur.read_u8()? != 0;
9505                let owned_by = match cur.read_u8()? {
9506                    0 => None,
9507                    1 => {
9508                        let t = cur.read_str()?;
9509                        let c = cur.read_str()?;
9510                        Some((t, c))
9511                    }
9512                    other => {
9513                        return Err(StorageError::Corrupt(format!(
9514                            "unknown SEQUENCE owned-by tag {other}"
9515                        )));
9516                    }
9517                };
9518                let last_value = cur.read_i64()?;
9519                let is_called = cur.read_u8()? != 0;
9520                cat.sequences.insert(
9521                    name.clone(),
9522                    SequenceDef {
9523                        name,
9524                        data_type,
9525                        start,
9526                        increment,
9527                        min_value,
9528                        max_value,
9529                        cache,
9530                        cycle,
9531                        owned_by,
9532                        last_value,
9533                        is_called,
9534                        owner: None,
9535                        acl: Vec::new(),
9536                    },
9537                );
9538            }
9539        }
9540        // v7.17.0 Phase 1.2 — VIEW block (FILE_VERSION 27+).
9541        // v26-and-below catalogs omit; we leave the map empty.
9542        if version >= 27 {
9543            let view_count = cur.read_u32()? as usize;
9544            for _ in 0..view_count {
9545                let name = cur.read_str()?;
9546                let col_count = cur.read_u16()? as usize;
9547                let mut columns = Vec::with_capacity(col_count);
9548                for _ in 0..col_count {
9549                    columns.push(cur.read_str()?);
9550                }
9551                let body = cur.read_str_long()?;
9552                // v7.39 (round 132) — check-option marker added at FILE_VERSION
9553                // 69; older catalogs default to 0 (no check option).
9554                let check_option = if version >= 69 { cur.read_u8()? } else { 0 };
9555                cat.views.insert(
9556                    name.clone(),
9557                    ViewDef {
9558                        name,
9559                        columns,
9560                        body,
9561                        check_option,
9562                    },
9563                );
9564            }
9565        }
9566        // v7.17.0 Phase 1.3 — MATERIALIZED VIEW source registry
9567        // (FILE_VERSION 28+). v27-and-below catalogs omit.
9568        if version >= 28 {
9569            let mv_count = cur.read_u32()? as usize;
9570            for _ in 0..mv_count {
9571                let name = cur.read_str()?;
9572                let body = cur.read_str_long()?;
9573                cat.materialized_views.insert(name, body);
9574            }
9575        }
9576        // v7.17.0 Phase 1.4 — ENUM types catalog block
9577        // (FILE_VERSION 29+).
9578        if version >= 29 {
9579            let etype_count = cur.read_u32()? as usize;
9580            for _ in 0..etype_count {
9581                let name = cur.read_str()?;
9582                let label_count = cur.read_u16()? as usize;
9583                let mut labels = Vec::with_capacity(label_count);
9584                for _ in 0..label_count {
9585                    labels.push(cur.read_str()?);
9586                }
9587                cat.enum_types
9588                    .insert(name.clone(), EnumDef { name, labels });
9589            }
9590        }
9591        // v7.17.0 Phase 1.5 — DOMAIN types catalog block
9592        // (FILE_VERSION 30+).
9593        if version >= 30 {
9594            let dtype_count = cur.read_u32()? as usize;
9595            for _ in 0..dtype_count {
9596                let name = cur.read_str()?;
9597                let base_type = cur.read_data_type()?;
9598                let nullable = cur.read_u8()? != 0;
9599                let default = match cur.read_u8()? {
9600                    0 => None,
9601                    1 => Some(cur.read_str()?),
9602                    other => {
9603                        return Err(StorageError::Corrupt(format!(
9604                            "unknown DOMAIN default tag {other}"
9605                        )));
9606                    }
9607                };
9608                let check_count = cur.read_u16()? as usize;
9609                let mut checks: Vec<DomainCheck> = Vec::with_capacity(check_count);
9610                for i in 0..check_count {
9611                    let expr = cur.read_str()?;
9612                    // v7.39 (round 260) — names arrived in FILE_VERSION 75.
9613                    // An older catalog gets PG's auto-naming applied to the
9614                    // checks it stored, which is what they would have been.
9615                    let cname = if version >= 75 {
9616                        cur.read_str()?
9617                    } else if i == 0 {
9618                        alloc::format!("{name}_check")
9619                    } else {
9620                        alloc::format!("{name}_check{i}")
9621                    };
9622                    checks.push(DomainCheck { name: cname, expr });
9623                }
9624                // v7.39 (round 259) — the parent domain. Absent before
9625                // FILE_VERSION 74; an older catalog reads as a domain over
9626                // a scalar, which is what it was.
9627                let base_domain = if version >= 74 {
9628                    match cur.read_u8()? {
9629                        0 => None,
9630                        1 => Some(cur.read_str()?),
9631                        other => {
9632                            return Err(StorageError::Corrupt(alloc::format!(
9633                                "domain base_domain tag {other}"
9634                            )));
9635                        }
9636                    }
9637                } else {
9638                    None
9639                };
9640                cat.domain_types.insert(
9641                    name.clone(),
9642                    DomainDef {
9643                        name,
9644                        base_type,
9645                        nullable,
9646                        default,
9647                        checks,
9648                        base_domain,
9649                    },
9650                );
9651            }
9652        }
9653        // v7.17.0 Phase 1.6 — user-schemas registry
9654        // (FILE_VERSION 31+).
9655        if version >= 31 {
9656            let sch_count = cur.read_u32()? as usize;
9657            for _ in 0..sch_count {
9658                let name = cur.read_str()?;
9659                cat.schemas.insert(name);
9660            }
9661        }
9662        // v7.37.42-T2 ζ-B — COMPOSITE types catalog block
9663        // (FILE_VERSION 52+). v51-and-below readers stop at the
9664        // user-schemas block; v52 readers fed a v51 catalog see no
9665        // composite block and default to an empty map.
9666        if version >= 52 {
9667            let ctype_count = cur.read_u32()? as usize;
9668            for _ in 0..ctype_count {
9669                let name = cur.read_str()?;
9670                let field_count = cur.read_u16()? as usize;
9671                let mut fields = Vec::with_capacity(field_count);
9672                let mut field_user_types: Vec<Option<String>> = Vec::with_capacity(field_count);
9673                for _ in 0..field_count {
9674                    let fname = cur.read_str()?;
9675                    let fty = cur.read_data_type()?;
9676                    // v7.39 (round 264) — present from FILE_VERSION 76.
9677                    let ut = if version >= 76 {
9678                        match cur.read_u8()? {
9679                            0 => None,
9680                            1 => Some(cur.read_str()?),
9681                            other => {
9682                                return Err(StorageError::Corrupt(alloc::format!(
9683                                    "composite field user-type tag {other}"
9684                                )));
9685                            }
9686                        }
9687                    } else {
9688                        None
9689                    };
9690                    fields.push((fname, fty));
9691                    field_user_types.push(ut);
9692                }
9693                cat.composite_types.insert(
9694                    name.clone(),
9695                    CompositeDef {
9696                        name,
9697                        fields,
9698                        field_user_types,
9699                    },
9700                );
9701            }
9702        }
9703        // v7.39 (read01 round 50) — COMMENT store (FILE_VERSION 61+).
9704        if version >= 61 {
9705            let comment_count = cur.read_u32()? as usize;
9706            for _ in 0..comment_count {
9707                let key = cur.read_str()?;
9708                let text = cur.read_str_long()?;
9709                cat.comments.insert(key, text);
9710            }
9711        }
9712        // v7.39 (read01 round 60) — non-table ACLs (FILE_VERSION 66+).
9713        if version >= 66 {
9714            let read_acl = |cur: &mut Cursor| -> Result<Vec<AclItem>, StorageError> {
9715                let n = cur.read_u16()? as usize;
9716                let mut acl = Vec::with_capacity(n);
9717                for _ in 0..n {
9718                    let grantee = cur.read_str()?;
9719                    let privs = cur.read_u16()?;
9720                    let grantable = cur.read_u16()?;
9721                    let grantor = cur.read_str()?;
9722                    acl.push(AclItem {
9723                        grantee,
9724                        privs,
9725                        grantable,
9726                        grantor,
9727                    });
9728                }
9729                Ok(acl)
9730            };
9731            let seq_count = cur.read_u32()? as usize;
9732            for _ in 0..seq_count {
9733                let name = cur.read_str()?;
9734                let owner = if cur.read_u8()? == 1 {
9735                    Some(cur.read_str()?)
9736                } else {
9737                    None
9738                };
9739                let acl = read_acl(&mut cur)?;
9740                if let Some(seq) = cat.sequences.get_mut(&name) {
9741                    seq.owner = owner;
9742                    seq.acl = acl;
9743                }
9744            }
9745            cat.schema_acl = read_acl(&mut cur)?;
9746            cat.database_acl = read_acl(&mut cur)?;
9747            // v7.39 (read01 round 61) — FUNCTION owner + ACL (v67+; keyed by
9748            // signature from v68, when overloads became possible).
9749            if version >= 67 {
9750                let fn_count = cur.read_u32()? as usize;
9751                for _ in 0..fn_count {
9752                    let name = cur.read_str()?;
9753                    let owner = if cur.read_u8()? == 1 {
9754                        Some(cur.read_str()?)
9755                    } else {
9756                        None
9757                    };
9758                    let acl = read_acl(&mut cur)?;
9759                    // v7.39 (round 315, V19) — the stored key was computed
9760                    // by whichever formula was current when the image was
9761                    // written. A miss is not "no such function": before the
9762                    // multi-word fix, `f(double precision)` keyed as
9763                    // `f(precision)`, so an older image's grants would land
9764                    // nowhere and vanish silently. Fall back to matching by
9765                    // the old formula, which re-attaches them.
9766                    let target = resolve_stored_function_key(&cat.functions, &name);
9767                    if let Some(k) = target
9768                        && let Some(f) = cat.functions.get_mut(&k)
9769                    {
9770                        f.owner = owner;
9771                        f.acl = acl;
9772                    }
9773                }
9774            }
9775        }
9776        // v7.39 (round 139) — RULE catalog block (FILE_VERSION 71+), read from
9777        // the tail right before the CRC trailer. Pre-71 images stop before it.
9778        if version >= 71 {
9779            let rule_count = cur.read_u32()? as usize;
9780            for _ in 0..rule_count {
9781                let name = cur.read_str()?;
9782                let table = cur.read_str()?;
9783                let event = cur.read_str()?;
9784                let instead = cur.read_u8()? != 0;
9785                let when_condition = cur.read_str()?;
9786                let cmd_count = cur.read_u16()? as usize;
9787                let mut commands = Vec::with_capacity(cmd_count);
9788                for _ in 0..cmd_count {
9789                    commands.push(cur.read_str()?);
9790                }
9791                cat.rules.push(RuleDef {
9792                    name,
9793                    table,
9794                    event,
9795                    instead,
9796                    when_condition,
9797                    commands,
9798                });
9799            }
9800        }
9801        // v7.39 (round 280) — extended-statistics block (FILE_VERSION
9802        // 77+). Pre-77 images stop before it.
9803        if version >= 77 {
9804            let count = cur.read_u32()? as usize;
9805            for _ in 0..count {
9806                let name = cur.read_str()?;
9807                let table = cur.read_str()?;
9808                let nk = cur.read_u16()? as usize;
9809                let mut kinds = Vec::with_capacity(nk);
9810                for _ in 0..nk {
9811                    kinds.push(cur.read_str()?);
9812                }
9813                let nc = cur.read_u16()? as usize;
9814                let mut columns = Vec::with_capacity(nc);
9815                for _ in 0..nc {
9816                    columns.push(cur.read_str()?);
9817                }
9818                cat.statistics_ext.push(StatisticsExtDef {
9819                    name,
9820                    table,
9821                    kinds,
9822                    columns,
9823                });
9824            }
9825        }
9826        // v7.39 (round 287) — large-object block (FILE_VERSION 78+).
9827        // Pre-78 images stop before it.
9828        if version >= 78 {
9829            let count = cur.read_u32()? as usize;
9830            for _ in 0..count {
9831                let oid = cur.read_u32()?;
9832                let len = cur.read_u32()? as usize;
9833                let bytes = cur.read_bytes(len)?;
9834                cat.large_objects.insert(oid, bytes);
9835            }
9836        }
9837        // v7.39 (round 322, V46) — function-attribute block (FILE_VERSION
9838        // 80+). Pre-80 images stop before it and keep PG's defaults.
9839        if version >= 80 {
9840            let count = cur.read_u32()? as usize;
9841            for _ in 0..count {
9842                let key = cur.read_str()?;
9843                let volatility = cur.read_u8()?;
9844                let flags = cur.read_u8()?;
9845                let parallel = cur.read_u8()?;
9846                let cost = f64::from_le_bytes(cur.read_bytes(8)?.try_into().unwrap_or([0; 8]));
9847                let rows = f64::from_le_bytes(cur.read_bytes(8)?.try_into().unwrap_or([0; 8]));
9848                if let Some(f) = cat.functions.get_mut(&key) {
9849                    f.volatility = volatility;
9850                    f.strict = flags & 1 != 0;
9851                    f.security_definer = flags & 2 != 0;
9852                    f.leakproof = flags & 4 != 0;
9853                    f.parallel = parallel;
9854                    f.cost = (!cost.is_nan()).then_some(cost);
9855                    f.rows = (!rows.is_nan()).then_some(rows);
9856                }
9857            }
9858        }
9859        // v7.39 (round 547) — pg_db_role_setting (FILE_VERSION 85+).
9860        // Pre-85 images stop before it and carry no GUC defaults.
9861        if version >= 85 {
9862            let scopes = cur.read_u32()? as usize;
9863            for _ in 0..scopes {
9864                let db = cur.read_str()?;
9865                let role = cur.read_str()?;
9866                let params = cur.read_u32()? as usize;
9867                let mut m: BTreeMap<String, String> = BTreeMap::new();
9868                for _ in 0..params {
9869                    let name = cur.read_str()?;
9870                    let value = cur.read_str()?;
9871                    m.insert(name, value);
9872                }
9873                if !m.is_empty() {
9874                    cat.db_role_settings.insert((db, role), m);
9875                }
9876            }
9877        }
9878        // v7.39 (round 550) — replication slots (FILE_VERSION 86+).
9879        if version >= 86 {
9880            let count = cur.read_u32()? as usize;
9881            for _ in 0..count {
9882                let name = cur.read_str()?;
9883                let plugin = cur.read_str()?;
9884                let slot_type = cur.read_str()?;
9885                cat.replication_slots.insert(name, (plugin, slot_type));
9886            }
9887        }
9888        // v7.38 (read01 P5.05) — v54+ images end with a CRC32C over every
9889        // preceding byte; verify it before accepting the snapshot. Older
9890        // images have no trailer and fall through to the trailing-byte check.
9891        if version >= FILE_VERSION_CRC_TRAILER {
9892            let crc_start = cur.pos;
9893            let stored = cur.read_u32()?;
9894            let computed = spg_crypto::crc32c::crc32c(&buf[..crc_start]);
9895            if computed != stored {
9896                return Err(StorageError::Corrupt(format!(
9897                    "base snapshot CRC mismatch: computed {computed:#010x}, stored {stored:#010x}"
9898                )));
9899            }
9900        }
9901        if cur.pos < buf.len() {
9902            return Err(StorageError::Corrupt(format!(
9903                "trailing bytes: {} unread",
9904                buf.len() - cur.pos
9905            )));
9906        }
9907        Ok(cat)
9908    }
9909}
9910
9911#[cfg(test)]
9912mod tests;