spg_storage/lib.rs
1//! In-memory storage primitives.
2//!
3//! v0.3 is intentionally simple: a flat catalog of tables, each holding rows
4//! as `Vec<Value>` (positional, matching the table's `TableSchema`). No MVCC,
5//! no on-disk format — those land in later milestones.
6#![no_std]
7// v3.3.2 NEON path for l2_distance_sq (aarch64 only). Scoped allow:
8// `unsafe_code = "deny"` at workspace level stays in force for every
9// other crate.
10#![cfg_attr(target_arch = "aarch64", allow(unsafe_code))]
11
12extern crate alloc;
13
14pub mod bignum;
15pub mod bloom;
16mod codec;
17pub mod fts_simple;
18pub mod halfvec;
19pub mod jsonb_gin;
20mod nsw;
21pub mod persistent;
22pub mod persistent_btree;
23pub mod posting;
24pub mod quantize;
25pub mod row_header;
26pub mod row_locator;
27pub mod segment;
28pub mod snapshot;
29mod table;
30pub mod trgm;
31pub mod vacuum;
32
33pub use self::bloom::{BloomError, BloomFilter};
34// v7.31 monster tier-3 cut 3 — on-disk codec moved to `codec`; the
35// public dense-row surface keeps its `spg_storage::*` paths, and the
36// low-level write/read primitives stay crate-visible for the
37// `Catalog::serialize`/`deserialize` methods that remain in this file.
38pub(crate) use self::codec::*;
39pub use self::codec::{
40 decode_row_body_dense, decode_row_body_dense_pruned, encode_row_body_dense,
41 encode_row_body_dense_into, encode_row_body_dense_masked_into, row_body_encoded_len,
42};
43// v7.31 monster tier-3 cut 2 — HNSW algorithms moved to `nsw`; the
44// public vector-search surface keeps its `spg_storage::*` paths via
45// these re-exports, and `nsw_insert_at` stays crate-visible for the
46// `Table` insert paths in the `table` module.
47pub(crate) use self::nsw::nsw_insert_at;
48pub use self::nsw::{NswMetric, cosine_dot_norms_f32, inner_product_f32, nsw_index_on, nsw_query};
49pub use self::posting::PostingList;
50
51/// The list handed back for an absent key, so callers cannot tell an
52/// absent key from an empty posting list — the property the old
53/// `&[][..]` return had, kept.
54static EMPTY_POSTINGS: crate::posting::PostingList = crate::posting::PostingList::new();
55pub use self::row_locator::{RowLocator, RowLocatorError};
56pub use self::segment::{
57 BRIN_SIDECAR_MAGIC, BrinSummary, OwnedSegment, SEGMENT_COMPRESS_ALGO_LZSS,
58 SEGMENT_COMPRESS_ALGO_NONE, SEGMENT_MAGIC, SEGMENT_MAGIC_V2, SEGMENT_PAGE_BYTES, SegmentError,
59 SegmentMeta, SegmentReader, derive_brin_summaries, encode_segment, wrap_v2_envelope,
60 wrap_v2_envelope_with_brin,
61};
62
63use alloc::borrow::Cow;
64use alloc::boxed::Box;
65use alloc::collections::{BTreeMap, BTreeSet};
66use alloc::format;
67use alloc::string::{String, ToString};
68use alloc::sync::Arc;
69use alloc::vec::Vec;
70use core::fmt;
71
72use self::persistent::PersistentVec;
73use self::persistent_btree::PersistentBTreeMap;
74
75/// In-cell encoding for `DataType::Vector`. Mirrors
76/// `spg_sql::ast::VecEncoding` — kept here so storage stays
77/// dep-free of `spg-sql`. The engine bridges between the two
78/// at DDL-execution time.
79///
80/// `F32` is the pre-v6 default: each cell holds a raw `Vec<f32>`.
81/// `Sq8` (v6.0.1) stores `Sq8Vector { min, max, bytes: Vec<u8> }`
82/// per cell; 4× compression vs `F32` with recall@10 ≥ 0.95 on
83/// natural embeddings (Gaussian / unit-sphere corpora).
84/// `F16` (v6.0.3, DDL keyword `HALF`) stores each element as
85/// IEEE-754 binary16; 2× compression and bit-exact dequantise.
86#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
87pub enum VecEncoding {
88 #[default]
89 F32,
90 Sq8,
91 F16,
92}
93
94impl fmt::Display for VecEncoding {
95 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
96 match self {
97 Self::F32 => f.write_str("F32"),
98 Self::Sq8 => f.write_str("SQ8"),
99 Self::F16 => f.write_str("HALF"),
100 }
101 }
102}
103
104/// Runtime type tags. `Vector { dim, encoding }` / `Varchar(max)` /
105/// `Char(size)` are parameterised; the parameter travels with both
106/// the column schema and the on-wire serialised representation.
107#[derive(Debug, Clone, Copy, PartialEq, Eq)]
108pub enum DataType {
109 /// 16-bit signed. Backed by `Value::SmallInt(i16)`; arithmetic that
110 /// would overflow surfaces as a type error at INSERT time.
111 SmallInt,
112 Int, // 32-bit signed
113 BigInt, // 64-bit signed
114 Float, // f64 (PG double precision)
115 /// v7.38 (read01, T-float4) — `real` / `float4`: 32-bit IEEE float (PG
116 /// `real`). Backed by `Value::Real(f32)`; behaves like `Float` for most
117 /// dispatch but renders / stores at f32 precision.
118 Real,
119 Text,
120 /// `VARCHAR(n)` — same byte representation as `Text`, but INSERT
121 /// rejects values longer than `n` Unicode characters.
122 Varchar(u32),
123 /// `CHAR(n)` — same representation as `Text`, but INSERT right-pads
124 /// with U+0020 to exactly `n` Unicode characters (or rejects when
125 /// the input is already longer).
126 Char(u32),
127 Bool,
128 /// pgvector-style fixed-dimension vector. `encoding` selects
129 /// the in-cell representation (`F32` = pre-v6 raw f32 buffer;
130 /// `Sq8` = v6.0.1 8-bit scalar-quantised). The DDL grammar
131 /// surfaces encoding via the optional `USING <encoding>`
132 /// clause: `VECTOR(128) USING SQ8`.
133 Vector {
134 dim: u32,
135 encoding: VecEncoding,
136 },
137 /// `NUMERIC(precision, scale)` — exact fixed-point decimal stored as
138 /// a scaled `i128`. `precision` caps total decimal digits, `scale`
139 /// fixes digits after the decimal point. v1.12 supports up to
140 /// precision 38 (the i128-safe ceiling). `NUMERIC` and `NUMERIC(p)`
141 /// surface as `Numeric { precision: p, scale: 0 }`.
142 Numeric {
143 /// v7.39 (round 272) — widened from u8. PG's declared precision
144 /// runs to 1000; at u8 it could not even be spelled, and the
145 /// parser rejected anything past 38 (i128's width) outright.
146 precision: u16,
147 /// v7.39 (round 271) — widened alongside the value's scale.
148 /// v7.39 (round 273) — and signed: PG's DECLARED scale runs
149 /// -1000..=1000, where a negative one rounds to tens / hundreds.
150 /// A VALUE's display scale is always non-negative.
151 scale: i16,
152 },
153 /// `DATE` — calendar date with day precision, stored as `i32` days
154 /// since the Unix epoch (1970-01-01).
155 Date,
156 /// `TIMESTAMP` (a.k.a. `MySQL` `DATETIME`) — instant with microsecond
157 /// precision, stored as `i64` microseconds since the Unix epoch.
158 Timestamp,
159 /// v7.9.2 `TIMESTAMPTZ` — bit-identical to `Timestamp` on disk
160 /// (i64 microseconds, UTC by convention). Carried as a distinct
161 /// type tag so the PG-wire layer can advertise OID 1184 (PG's
162 /// `timestamp with time zone`) and `sqlx`/`pgx`/JDBC clients
163 /// decode into their TZ-aware datetime types. The internal
164 /// semantics are unchanged: SPG never stored per-row offsets,
165 /// and neither did PG — `TIMESTAMPTZ` in PG is also UTC i64.
166 Timestamptz,
167 /// v7.39 (round 291) — PG's `name`: the type its catalogs use for
168 /// identifiers. Text truncated to NAMEDATALEN-1 (63) bytes, with
169 /// its own type identity — `pg_typeof('abc'::name)` is `name`, and
170 /// `CREATE TABLE t (a name)` is legal SQL that SPG rejected.
171 Name,
172 /// v7.39 (round 640) — PG's `xid`: a transaction id. [`Value::Xid`]
173 /// has existed since round 512, so a `'5'::xid` literal already knew
174 /// what it was; this is the DECLARED half, which nothing had. Without
175 /// it `pg_typeof(NULL::xid)` answered `bigint`, `pg_type` could not
176 /// list oid 28 — leaving the 48 `pg_attribute` rows that describe
177 /// `xmin` / `xmax` pointing at a type no catalog carried — and
178 /// `CREATE TABLE t (a xid)` was refused as an unknown type.
179 ///
180 /// On disk it is the 8-byte body its BIGINT sibling writes, and it
181 /// reads back as a `Value::Xid`, so a stored column and a literal are
182 /// the same thing to everything downstream.
183 ///
184 /// What is NOT yet true of the identity: PG gives `xid` equality and
185 /// hashing and no ordering operator at all, so `min` / `max` /
186 /// `count(DISTINCT …)` / `<=` all error there and all answer here.
187 /// Measured, not assumed — and left for the operator surface rather
188 /// than claimed by this comment.
189 Xid,
190 /// v7.39 (round 640) — PG's `xid8`: the same transaction id, 64 bits
191 /// wide and monotonic. Unlike [`DataType::Xid`] it has no value of
192 /// its own; a cell is a `Value::BigInt` and only the declared type
193 /// witnesses it. That is enough for `pg_typeof`, the catalogs and
194 /// the wire OID, and not enough to refuse a bigint where PG refuses
195 /// one. `pg_current_xact_id()` returns this type on PG.
196 Xid8,
197 /// v7.39 (round 667) — PG's `oid`: an unsigned 32-bit object
198 /// identifier. Modelled exactly like [`DataType::Xid8`] above: it has
199 /// no value of its own, a cell is a `Value::BigInt`, and only the
200 /// declared type witnesses it.
201 ///
202 /// That deliberately buys less than a full value type. What it buys:
203 /// `CREATE TABLE t(o OID)` is accepted (it was rejected outright with
204 /// `type "oid" does not exist`, while the neighbouring `XID` worked),
205 /// `pg_typeof` answers `oid` rather than `bigint`, and the catalogs
206 /// report their own key columns honestly. What it does NOT buy is
207 /// refusing a bigint where PG refuses an oid — `sum(oid)` and
208 /// `avg(oid)` still answer here and error on PG, because at runtime
209 /// the cell is indistinguishable from a bigint. Round 664 tried to
210 /// close those two by name and withdrew: a guard keyed on the name
211 /// would have caught `sum(bigint)` with it.
212 ///
213 /// The cast itself was already right before this — `4294967296::oid`
214 /// and `'abc'::oid` produce PG's errors word for word, and `(-1)::oid`
215 /// wraps to 4294967295 as PG does. Only the resulting type was lost,
216 /// because `conversions.rs` mapped the target to `BigInt`.
217 Oid,
218 /// `INTERVAL` — calendar-aware span (months + microseconds). v2.11
219 /// supports INTERVAL only as a runtime intermediate (literals,
220 /// arithmetic results); on-disk encoding is rejected so this branch
221 /// can't appear in a `ColumnSchema`.
222 Interval,
223 /// v4.9: `JSON` — text-backed JSON document. We don't parse
224 /// the content (no path operators or jsonb functions yet) —
225 /// the column accepts any TEXT-compatible value and round-trips
226 /// it verbatim. PG OID 114 on the wire.
227 Json,
228 /// v7.9.0: `JSONB` — semantically identical to `Json` on
229 /// the storage side (same `Value::Json` cells, same
230 /// row codec), but advertised as PG OID 3802 on the wire
231 /// so `sqlx`-style clients that bind `jsonb` columns
232 /// decode correctly. mailrs migration blocker #3.
233 Jsonb,
234 /// v7.10.4: `BYTES` / `BYTEA` — variable-length raw binary.
235 /// Backed by `Value::Bytes(Vec<u8>)`. PG wire OID 17. Literal
236 /// forms accepted by parser/engine: PG hex form `'\xDEADBEEF'`
237 /// (case-insensitive hex pairs) and escape form
238 /// `'foo\\000bar'` (the latter decoded at coercion time when
239 /// the target column is BYTEA — TEXT columns leave the
240 /// backslash sequence verbatim).
241 Bytes,
242 /// v7.10.9: `TEXT[]` — single-dimension TEXT array. Elements
243 /// may be NULL (PG semantics). PG wire OID 1009. Literal
244 /// forms: `ARRAY['a', 'b', NULL]` and the PG external form
245 /// `'{a,b,NULL}'::TEXT[]`. Engine implements `= ANY(arr)`,
246 /// `<> ALL(arr)`, and 1-based indexing `arr[i]`. Catalog
247 /// FILE_VERSION 18+; older snapshots reject this DataType
248 /// (forward-only by design — TEXT[] columns aren't readable
249 /// on a pre-v7.10 binary).
250 TextArray,
251 /// v7.11.12: `INT[]` — single-dimension i32 array. PG wire
252 /// OID 1007 (_int4). Same `ARRAY[...]` / `'{1,2,3}'::INT[]`
253 /// literal surface as TEXT[]. Catalog FILE_VERSION 19+.
254 IntArray,
255 /// v7.11.12: `BIGINT[]` — single-dimension i64 array. PG
256 /// wire OID 1016 (_int8). Catalog FILE_VERSION 19+.
257 BigIntArray,
258 /// v7.39 (round 694) — `oid[]`. It exists for the reason
259 /// [`DataType::Oid`] does: mapping it onto `BigIntArray` answers
260 /// `pg_typeof('{1,2}'::oid[])` with `bigint[]`, which is the defect
261 /// round 667 closed for the scalar.
262 OidArray,
263 /// v7.37.5 β-P4 — `INTERVAL[]` — single-dimension array of
264 /// `IntervalSpan { months, days, micros }`. PG wire OID 1187
265 /// (`_interval`). Catalog tag 35 + per-cell body
266 /// `[u16 count][per elem: u8 null + (if non-null) 16-byte
267 /// interval body in LE PG-byte-equal field order]`.
268 /// FILE_VERSION 48+.
269 IntervalArray,
270 /// v7.37.5 γ — full PG array-of-scalar family. Catalog tags
271 /// 36..48; wire OIDs from PG `pg_type.dat`. Per-element body
272 /// uses the scalar's existing `write_value_body` shape.
273 /// FILE_VERSION 48+ (same window as β; no separate bump).
274 BoolArray, // PG `_bool` OID 1000, tag 36
275 SmallIntArray, // PG `_int2` OID 1005, tag 37
276 FloatArray, // PG `_float8` OID 1022, tag 38
277 NumericArray, // PG `_numeric` OID 1231, tag 39
278 DateArray, // PG `_date` OID 1182, tag 40
279 TimestampArray, // PG `_timestamp` OID 1115, tag 41
280 TimestamptzArray, // PG `_timestamptz` OID 1185, tag 42
281 UuidArray, // PG `_uuid` OID 2951, tag 43
282 JsonArray, // PG `_json` OID 199, tag 44
283 JsonbArray, // PG `_jsonb` OID 3807, tag 45
284 BytesArray, // PG `_bytea` OID 1001, tag 46
285 VarcharArray, // PG `_varchar` OID 1015, tag 47
286 CharArray, // PG `_bpchar` OID 1014, tag 48
287 /// v7.37.5 δ — PG 14+ multirange types. A multirange is an
288 /// ordered collection of non-overlapping ranges of the same
289 /// element kind (e.g. `int4multirange(int4range(1,5),
290 /// int4range(10,15))` → `{[1,5),[10,15)}`). The same DataType
291 /// variant covers all six builtin multiranges; `RangeKind`
292 /// pins the element type so encode/decode/display can route
293 /// off one switch (parallel to `Range(RangeKind)`).
294 /// Wire OIDs: int4multirange=4451, int8multirange=4537,
295 /// nummultirange=4536, tsmultirange=4533, tstzmultirange=4534,
296 /// datemultirange=4535. Catalog tag 49 + 1-byte RangeKind on
297 /// the dense type-tag side. FILE_VERSION 48+ (same window as
298 /// β/γ, no separate bump).
299 Multirange(RangeKind),
300 /// v7.37.5 ε — PG geometry scalar family. Mirrors PG's seven
301 /// builtin geometric types one-for-one. Body shapes (LE):
302 /// Point = 16 B fixed (f64 x + f64 y) OID 600
303 /// Lseg = 32 B fixed (Point p1 + Point p2) OID 601
304 /// Path = varlena ([u8 closed][u32 n][Point*n]) OID 602
305 /// Box = 32 B fixed (Point ur + Point ll) OID 603
306 /// Polygon = varlena ([u32 n][Point*n]) OID 604
307 /// Line = 24 B fixed (f64 a + f64 b + f64 c) OID 628
308 /// Circle = 24 B fixed (Point center + f64 r) OID 718
309 /// Catalog tags 50..56. FILE_VERSION 48+ (same window as β/γ/δ;
310 /// no separate bump). Geometric operators (`<->` / `@>` / `&&`
311 /// / `<<` / `>>` / `~=`) are a planner-integration follow-up,
312 /// parallel to the Range operator defer in e2e_pg_range.rs.
313 Point,
314 Lseg,
315 Path,
316 PgBox,
317 Polygon,
318 Line,
319 Circle,
320 /// v7.37.5 ζ-A — PG network address family. Body shapes (LE):
321 /// Inet = 18 B fixed (u8 family + u8 bits + 16 B addr) OID 869
322 /// Cidr = 18 B fixed (same shape as Inet; CIDR rejects
323 /// host bits at parse / coerce) OID 650
324 /// Macaddr = 6 B fixed OID 829
325 /// Macaddr8 = 8 B fixed (EUI-64) OID 774
326 /// Catalog tags 57-60. FILE_VERSION 48+. `family = 4` is IPv4
327 /// (uses the first 4 bytes of the 16-B addr slot, rest 0);
328 /// `family = 6` is IPv6 (full 16 B).
329 Inet,
330 Cidr,
331 Macaddr,
332 Macaddr8,
333 /// v7.39 (read01 pg_lsn.c) — PG `pg_lsn` (WAL location). 8 bytes,
334 /// rendered `%X/%X`. Catalog tag 66. OID 3220.
335 PgLsn,
336 /// v7.37.5 ζ-A — PG bit string. Body = `[u32 nbits][ceil(nbits/8) bytes]`,
337 /// big-endian within each byte (matches PG binary).
338 /// Bit OID 1560 (fixed-length, but SPG carries the
339 /// length per cell — column declaration
340 /// `BIT(n)` constrains at coerce time)
341 /// BitVarying OID 1562 (variable-length, declared as `VARBIT`)
342 /// Catalog tags 61-62.
343 /// v7.39 (round 281) — `BIT(n)`: a FIXED-length bit string. `0`
344 /// means the type was written without a typmod, which PG treats as
345 /// `bit(1)`. Column assignment requires the length to match
346 /// exactly; an explicit cast pads or truncates instead.
347 Bit(u32),
348 /// v7.39 (round 281) — `BIT VARYING(n)`: `n` is a MAXIMUM, and `0`
349 /// means unbounded (`varbit` with no typmod).
350 BitVarying(u32),
351 /// v7.37.5 ζ-A — PG `xml`. Body identical to TEXT (storage is
352 /// the verbatim XML string; no parse-time validation). Only
353 /// the wire OID (142) differs. Catalog tag 63.
354 Xml,
355 /// v7.37.5 ζ-A — PG `"char"` (the internal single-byte type,
356 /// distinct from `CHAR(n)` / `BPCHAR`). Body = 1 byte raw.
357 /// OID 18. Catalog tag 64.
358 Char1,
359 /// v7.37.5 ζ-A — `MONEY[]`. Body = `[u16 count][per elem: u8 null
360 /// + (non-null) i64 LE cents]`. OID 791. Catalog tag 65.
361 MoneyArray,
362 /// v7.12.0: PG `tsvector` — ordered, deduplicated set of
363 /// `(lexeme, positions, weight)` tuples. PG wire OID 3614.
364 /// Catalog FILE_VERSION 20+. Storage shape is row-codec
365 /// tag 22; the schema-agnostic `write_value` path emits tag
366 /// 18. Literal: `'foo:1 bar:2,3'::tsvector` (PG external
367 /// form). G-CRIT-3 entry — v7.12.0 only ships the type +
368 /// codec; matching `@@` lands in v7.12.2.
369 TsVector,
370 /// v7.12.0: PG `tsquery` — parse tree of lexemes joined by
371 /// `&` `|` `!` and phrase operators. PG wire OID 3615.
372 /// Catalog FILE_VERSION 20+.
373 TsQuery,
374 /// v7.17.0: PG `uuid` — 128-bit identifier stored as
375 /// `Value::Uuid([u8; 16])`. PG wire OID 2950. Canonical
376 /// text form is lowercase 8-4-4-4-12 hyphenated; input
377 /// also accepts uppercase, unhyphenated, and brace-wrapped
378 /// forms (`{xxxx…}`). Catalog FILE_VERSION 36+; tag 24 on
379 /// the dense type-tag side, tag 20 on the schema-agnostic
380 /// value side. The drop-in PG/MySQL surface for Django /
381 /// Rails / Hibernate "id UUID PRIMARY KEY DEFAULT
382 /// gen_random_uuid()" default-PK pattern.
383 Uuid,
384 /// v7.17.0 Phase 3.P0-32: PG `time` (without time zone) — i64
385 /// microseconds since 00:00:00. PG wire OID 1083. Display:
386 /// canonical zero-padded `HH:MM:SS` when fractional is zero,
387 /// `HH:MM:SS.ffffff` otherwise. Catalog FILE_VERSION 37+;
388 /// tag 25 on the dense type-tag side, tag 21 on the schema-
389 /// agnostic value side. The wall-clock-of-day half of PG's
390 /// date/time triplet (date / time / timestamp).
391 Time,
392 /// v7.17.0 Phase 3.P0-33: MySQL `YEAR` — u16 in range
393 /// 1901..=2155 plus the special zero-year sentinel 0. No
394 /// dedicated PG OID (advertised as INT4 / OID 23 on the wire
395 /// — psql renders integers, MySQL CLI renders 4-digit
396 /// zero-padded text). Display always 4 digits: `0000` for the
397 /// zero-year, `1985` / `2007` / etc otherwise. Catalog
398 /// FILE_VERSION 38+; tag 26 on the dense type-tag side, tag
399 /// 22 on the schema-agnostic value side.
400 Year,
401 /// v7.17.0 Phase 3.P0-34: PG `time with time zone` (TIMETZ) —
402 /// i64 microseconds since 00:00:00 in the local wall clock
403 /// PLUS i32 offset-from-UTC in seconds. PG wire OID 1266.
404 /// Display: `HH:MM:SS[.ffffff]±HH[:MM]` (PG `timetz_out`).
405 /// Range: offset in ±50400 seconds (±14 hours). Catalog
406 /// FILE_VERSION 39+; tag 27 on the dense type-tag side, tag
407 /// 23 on the schema-agnostic value side.
408 TimeTz,
409 /// v7.17.0 Phase 3.P0-35: PG `money` — i64 cents (locale-
410 /// independent storage). PG wire OID 790. Display: en_US
411 /// locale (`$N,NNN.CC`, negative → `-$1.23`). Input accepts
412 /// `$N.NN`, `$N,NNN.NN`, bare integer (treated as major
413 /// units), optional leading `-`. Range: full i64. Catalog
414 /// FILE_VERSION 40+; tag 28 on the dense type-tag side, tag
415 /// 24 on the schema-agnostic value side.
416 Money,
417 /// v7.17.0 Phase 3.P0-38: PG range type. The same DataType
418 /// variant covers all six builtin ranges (int4range,
419 /// int8range, numrange, tsrange, tstzrange, daterange) —
420 /// `RangeKind` pins the element type so encode / decode /
421 /// display can route off one switch. Catalog FILE_VERSION
422 /// 43+; tag 29 + a 1-byte RangeKind on the dense type-tag
423 /// side, tag 25 on the schema-agnostic value side.
424 Range(RangeKind),
425 /// v7.17.0 Phase 3.P0-39: PG `hstore` extension type — flat
426 /// `text => text` map with NULL value support. Catalog
427 /// FILE_VERSION 44+; tag 30 on the dense type-tag side, tag
428 /// 26 on the schema-agnostic value side. The contrib OID is
429 /// installation-dependent in real PG; SPG advertises it via
430 /// dynamic lookup, falling back to TEXT (OID 25) on the wire
431 /// when the installed `hstore` extension hasn't claimed an
432 /// OID yet.
433 Hstore,
434 /// v7.17.0 Phase 3.P0-40: PG `int[][]` — 2-dimensional INT
435 /// matrix. Storage: row-major Vec<Vec<Option<i32>>>. All
436 /// rows must share the same column count. Wire OID 1007
437 /// (same as INT[]; the dimension count travels in the data
438 /// header, not the OID). Catalog FILE_VERSION 45+; tag 31
439 /// on the dense type-tag side, tag 27 on the schema-agnostic
440 /// value side.
441 IntArray2D,
442 /// v7.17.0 Phase 3.P0-40: PG `bigint[][]` — 2-dimensional
443 /// BIGINT matrix. Storage / OID / tags mirror IntArray2D.
444 /// Tag 32 dense, tag 28 schema-agnostic.
445 BigIntArray2D,
446 /// v7.17.0 Phase 3.P0-40: PG `text[][]` — 2-dimensional TEXT
447 /// matrix. Storage: row-major Vec<Vec<Option<String>>>.
448 /// Tag 33 dense, tag 29 schema-agnostic.
449 TextArray2D,
450 /// v7.39 (read01 round 75) — `bool[][]`. BOOL is the ONE element type whose
451 /// ARRAY rendering differs from its scalar one (`t` vs `true`), so a
452 /// text-backed 2-D cannot be PG-faithful for it: rendering the whole array
453 /// wants `t`, and subscripting a cell to text wants `false`. Every other
454 /// element type renders the same either way, which is why this is the only
455 /// typed 2-D variant SPG needs.
456 BoolArray2D,
457}
458
459/// v7.17.0 Phase 3.P0-38 — pins the element type of a range value
460/// or column. Wire OIDs: Int4=3904, Int8=3926, Num=3906,
461/// Ts=3908, TsTz=3910, Date=3912.
462#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
463pub enum RangeKind {
464 Int4,
465 Int8,
466 Num,
467 Ts,
468 TsTz,
469 Date,
470}
471
472impl RangeKind {
473 pub const fn tag(self) -> u8 {
474 match self {
475 Self::Int4 => 0,
476 Self::Int8 => 1,
477 Self::Num => 2,
478 Self::Ts => 3,
479 Self::TsTz => 4,
480 Self::Date => 5,
481 }
482 }
483 pub const fn from_tag(t: u8) -> Option<Self> {
484 Some(match t {
485 0 => Self::Int4,
486 1 => Self::Int8,
487 2 => Self::Num,
488 3 => Self::Ts,
489 4 => Self::TsTz,
490 5 => Self::Date,
491 _ => return None,
492 })
493 }
494 pub const fn keyword(self) -> &'static str {
495 match self {
496 Self::Int4 => "INT4RANGE",
497 Self::Int8 => "INT8RANGE",
498 Self::Num => "NUMRANGE",
499 Self::Ts => "TSRANGE",
500 Self::TsTz => "TSTZRANGE",
501 Self::Date => "DATERANGE",
502 }
503 }
504}
505
506impl fmt::Display for DataType {
507 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
508 match self {
509 Self::SmallInt => f.write_str("SMALLINT"),
510 Self::Int => f.write_str("INT"),
511 Self::BigInt => f.write_str("BIGINT"),
512 Self::Xid => f.write_str("XID"),
513 Self::Xid8 => f.write_str("XID8"),
514 Self::Oid => f.write_str("OID"),
515 Self::OidArray => f.write_str("OID[]"),
516 Self::Float => f.write_str("FLOAT"),
517 Self::Real => f.write_str("REAL"),
518 Self::Text => f.write_str("TEXT"),
519 Self::Varchar(n) => write!(f, "VARCHAR({n})"),
520 Self::Char(n) => write!(f, "CHAR({n})"),
521 Self::Bool => f.write_str("BOOL"),
522 Self::Vector { dim, encoding } => match encoding {
523 VecEncoding::F32 => write!(f, "VECTOR({dim})"),
524 VecEncoding::Sq8 => write!(f, "VECTOR({dim}) USING SQ8"),
525 VecEncoding::F16 => write!(f, "VECTOR({dim}) USING HALF"),
526 },
527 Self::Numeric { precision, scale } => {
528 if *scale == 0 {
529 write!(f, "NUMERIC({precision})")
530 } else {
531 write!(f, "NUMERIC({precision}, {scale})")
532 }
533 }
534 Self::Date => f.write_str("DATE"),
535 Self::Timestamp => f.write_str("TIMESTAMP"),
536 Self::Timestamptz => f.write_str("TIMESTAMPTZ"),
537 Self::Name => f.write_str("NAME"),
538 Self::Interval => f.write_str("INTERVAL"),
539 Self::Json => f.write_str("JSON"),
540 Self::Jsonb => f.write_str("JSONB"),
541 Self::Bytes => f.write_str("BYTEA"),
542 Self::TextArray => f.write_str("TEXT[]"),
543 Self::IntArray => f.write_str("INT[]"),
544 Self::BigIntArray => f.write_str("BIGINT[]"),
545 Self::IntervalArray => f.write_str("INTERVAL[]"),
546 Self::BoolArray => f.write_str("BOOL[]"),
547 Self::SmallIntArray => f.write_str("SMALLINT[]"),
548 Self::FloatArray => f.write_str("FLOAT[]"),
549 Self::NumericArray => f.write_str("NUMERIC[]"),
550 Self::DateArray => f.write_str("DATE[]"),
551 Self::TimestampArray => f.write_str("TIMESTAMP[]"),
552 Self::TimestamptzArray => f.write_str("TIMESTAMPTZ[]"),
553 Self::UuidArray => f.write_str("UUID[]"),
554 Self::JsonArray => f.write_str("JSON[]"),
555 Self::JsonbArray => f.write_str("JSONB[]"),
556 Self::BytesArray => f.write_str("BYTEA[]"),
557 Self::VarcharArray => f.write_str("VARCHAR[]"),
558 Self::CharArray => f.write_str("CHAR[]"),
559 Self::Multirange(k) => f.write_str(match k {
560 RangeKind::Int4 => "INT4MULTIRANGE",
561 RangeKind::Int8 => "INT8MULTIRANGE",
562 RangeKind::Num => "NUMMULTIRANGE",
563 RangeKind::Ts => "TSMULTIRANGE",
564 RangeKind::TsTz => "TSTZMULTIRANGE",
565 RangeKind::Date => "DATEMULTIRANGE",
566 }),
567 Self::Point => f.write_str("POINT"),
568 Self::Lseg => f.write_str("LSEG"),
569 Self::Path => f.write_str("PATH"),
570 Self::PgBox => f.write_str("BOX"),
571 Self::Polygon => f.write_str("POLYGON"),
572 Self::Line => f.write_str("LINE"),
573 Self::Circle => f.write_str("CIRCLE"),
574 Self::Inet => f.write_str("INET"),
575 Self::Cidr => f.write_str("CIDR"),
576 Self::Macaddr => f.write_str("MACADDR"),
577 Self::Macaddr8 => f.write_str("MACADDR8"),
578 Self::PgLsn => f.write_str("PG_LSN"),
579 Self::Bit(0) => f.write_str("BIT"),
580 Self::Bit(n) => write!(f, "BIT({n})"),
581 Self::BitVarying(0) => f.write_str("VARBIT"),
582 Self::BitVarying(n) => write!(f, "VARBIT({n})"),
583 Self::Xml => f.write_str("XML"),
584 Self::Char1 => f.write_str("\"char\""),
585 Self::MoneyArray => f.write_str("MONEY[]"),
586 Self::TsVector => f.write_str("TSVECTOR"),
587 Self::TsQuery => f.write_str("TSQUERY"),
588 Self::Uuid => f.write_str("UUID"),
589 Self::Time => f.write_str("TIME"),
590 Self::Year => f.write_str("YEAR"),
591 Self::TimeTz => f.write_str("TIMETZ"),
592 Self::Money => f.write_str("MONEY"),
593 Self::Range(k) => f.write_str(k.keyword()),
594 Self::Hstore => f.write_str("HSTORE"),
595 Self::IntArray2D => f.write_str("INT[][]"),
596 Self::BigIntArray2D => f.write_str("BIGINT[][]"),
597 Self::TextArray2D => f.write_str("TEXT[][]"),
598 Self::BoolArray2D => f.write_str("BOOL[][]"),
599 }
600 }
601}
602
603/// v7.12.0 — one entry in a `Value::TsVector`. The lexeme is the
604/// (already-tokenised + stemmed in v7.12.1+) word; `positions` is
605/// a strictly-ascending list of 1-based positions; `weight` is the
606/// PG weight letter (A=3, B=2, C=1, D=0) — v7.12.0 defaults every
607/// lexeme to D, the v7.12.2 ranking path consumes the weight.
608#[derive(Debug, Clone, PartialEq, Eq)]
609pub struct TsLexeme {
610 pub word: String,
611 pub positions: Vec<u16>,
612 pub weight: u8,
613}
614
615/// v7.12.0 — parse tree for a PG `tsquery`. v7.12.0 ships the
616/// type + codec only; the `to_tsquery` / `plainto_tsquery` lexer
617/// lands in v7.12.1 and the `@@` evaluator in v7.12.2.
618#[derive(Debug, Clone, PartialEq, Eq)]
619pub enum TsQueryAst {
620 /// Single lexeme term. The `weight_mask` is the PG-style
621 /// bitmask of accepted weights (`A=1<<3`, `B=1<<2`, `C=1<<1`,
622 /// `D=1<<0`); `0` = any weight. v7.12.0 always sets it to 0.
623 Term {
624 word: String,
625 weight_mask: u8,
626 },
627 And(Box<TsQueryAst>, Box<TsQueryAst>),
628 Or(Box<TsQueryAst>, Box<TsQueryAst>),
629 Not(Box<TsQueryAst>),
630 /// `phrase <distance> phrase`. v7.12.0 only persists this; the
631 /// match semantics arrive in v7.12.2 alongside `@@`.
632 Phrase {
633 left: Box<TsQueryAst>,
634 right: Box<TsQueryAst>,
635 distance: u16,
636 },
637}
638
639/// A row-cell value, including SQL `NULL`. `Float` uses `f64`; NaN compares
640/// non-equal to itself (PG behaviour) — `PartialEq` is derived so callers
641/// must opt into NaN-aware comparison if they need stronger guarantees.
642///
643/// v7.37.42-arena Phase 1: parameterised on `'arena` so heap-bearing
644/// variants (Text/Json/Xml/Bytes/Vector/BitString.bytes) can borrow from
645/// a per-query bump arena (`Cow::Borrowed(&'arena ...)`). Persistent /
646/// catalog Values use `Value<'static>` (alias `ValueOwned`) with
647/// `Cow::Owned(...)`. Phase 1 keeps Range/Multirange recursive `Box<Value>`
648/// at `'static` (owned) — arena migration deferred to a later phase.
649/// Array-of-Option<String> variants (TextArray etc.) also stay owned in
650/// Phase 1; their nested shape is awkward for the simple Cow lift and the
651/// SCALARSQ hot path doesn't touch them.
652/// v7.38 (read01, T6) — the IEEE-style class of a NUMERIC value. `Finite` is the
653/// ordinary fixed-point case; the specials mirror PG's `'NaN'` / `'Infinity'` /
654/// `'-Infinity'`. Derived `PartialEq` gives `NaN == NaN` — correct for NUMERIC
655/// (unlike float's NaN ≠ NaN); the total order (`-Inf < finite < +Inf < NaN`)
656/// lives in the comparison paths, not in `Ord`.
657#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Hash)]
658pub enum NumericKind {
659 #[default]
660 Finite,
661 NaN,
662 PosInf,
663 NegInf,
664}
665
666#[derive(Debug, Clone, PartialEq)]
667#[non_exhaustive]
668pub enum Value<'arena> {
669 SmallInt(i16),
670 Int(i32),
671 BigInt(i64),
672 Float(f64),
673 /// v7.38 (read01, T-float4) — PG `real` (32-bit IEEE float).
674 Real(f32),
675 Text(Cow<'arena, str>),
676 Bool(bool),
677 Vector(Cow<'arena, [f32]>),
678 /// v6.0.1: 8-bit scalar-quantised vector cell. Lives in
679 /// columns declared `VECTOR(N) USING SQ8`. Layout per cell:
680 /// `Sq8Vector { min: f32, max: f32, bytes: Vec<u8> }` —
681 /// 4× compression vs `Vector(Vec<f32>)`. The wire layer
682 /// dequantises to `f32` on SELECT; INSERT path quantises
683 /// incoming `Vector(Vec<f32>)` cells into this variant.
684 Sq8Vector(crate::quantize::Sq8Vector),
685 /// v6.0.3: IEEE-754 binary16 vector cell. Lives in columns
686 /// declared `VECTOR(N) USING HALF`. Stores raw u16 LE bits
687 /// (2× compression vs `Vector(Vec<f32>)`). Wire / display
688 /// paths dequantise to f32 bit-exactly; INSERT path converts
689 /// incoming f32 vectors at the engine boundary.
690 HalfVector(crate::halfvec::HalfVector),
691 /// Exact fixed-point decimal. `scaled` holds the value as
692 /// `actual * 10^scale` so the storage type is always integral —
693 /// arithmetic never falls back to floating-point. v7.38 (read01, T6) —
694 /// `kind` classifies the value as finite (the common case, using
695 /// `scaled`/`scale`) or one of PG's NUMERIC specials (NaN / ±Infinity),
696 /// which ignore `scaled`/`scale` (canonicalized to 0).
697 Numeric {
698 scaled: i128,
699 /// v7.39 (round 271) — widened from u8. PG's numeric carries a
700 /// display scale up to 16383; at u8 a literal with 256 decimal
701 /// places could not be represented at all, and the conversion
702 /// aborted the query with an internal error.
703 scale: u16,
704 kind: NumericKind,
705 },
706 /// v7.38 (read01, T3) — an exact NUMERIC whose mantissa overflows `i128`
707 /// (PG's NUMERIC is unbounded). Boxed so the common finite case keeps its
708 /// small footprint; specials never take this form (they stay `Numeric`).
709 NumericBig(alloc::boxed::Box<crate::bignum::BigNumeric>),
710 /// Days since the Unix epoch (1970-01-01). Negative for earlier dates.
711 Date(i32),
712 /// Microseconds since the Unix epoch (1970-01-01T00:00:00Z).
713 Timestamp(i64),
714 /// Calendar span: `months` + `days` + `micros`. Three fields are
715 /// required for PG byte-equal: `'1 day'` ≠ `'24 hours'` (DST,
716 /// month-boundary, and the on-wire `pg_type` `interval` are all
717 /// `i64 micros + i32 days + i32 months`). v7.37.5 β widened from
718 /// `{months, micros}`; column storage lands in the same window.
719 Interval {
720 months: i32,
721 days: i32,
722 micros: i64,
723 },
724 /// v4.9 `JSON` — raw JSON text. No structural validation
725 /// happens at the storage layer; whatever the parser hands us
726 /// round-trips verbatim. Equality is byte-wise.
727 Json(Cow<'arena, str>),
728 /// v7.10.4 `BYTEA` — raw binary blob. Equality is byte-wise.
729 /// Layout matches `Text`'s length-prefixed shape (`[u32 LE
730 /// len][bytes]`) under tag 18; the engine accepts PG hex
731 /// literals (`'\xDEADBEEF'`) and escape literals at the
732 /// coercion boundary.
733 Bytes(Cow<'arena, [u8]>),
734 /// v7.10.9 `TEXT[]` — single-dimension TEXT array with
735 /// optional NULL elements. Equality is element-wise. PG's
736 /// NULL-element comparison semantics: NULL ≠ NULL inside
737 /// arrays under `=`, so `[NULL] != [NULL]` (the engine
738 /// honours this).
739 TextArray(Vec<Option<String>>),
740 /// v7.11.12 `INT[]` — single-dimension i32 array with optional
741 /// NULL elements. Codec mirrors TextArray with i32 LE per
742 /// element instead of length-prefixed UTF-8.
743 IntArray(Vec<Option<i32>>),
744 /// v7.11.12 `BIGINT[]` — single-dimension i64 array with optional
745 /// NULL elements.
746 BigIntArray(Vec<Option<i64>>),
747 /// v7.37.5 β-P4 `INTERVAL[]` — single-dimension array of
748 /// `IntervalSpan { months, days, micros }` with optional NULL
749 /// elements. PG external form quotes each non-NULL element
750 /// (`{"1 day","24:00:00",NULL}`) because interval text contains
751 /// spaces and colons. Storage codec follows the BigIntArray
752 /// shape with a 16-byte per-element body.
753 IntervalArray(Vec<Option<IntervalSpan>>),
754 /// v7.37.5 γ — single-dimension arrays of the remaining PG
755 /// scalar types. Each carries `Vec<Option<T>>` with the
756 /// scalar's natural Rust shape; element NULLs are first-class
757 /// (per PG: `{1,NULL,3}` is a 3-element array, not a 2-element
758 /// one). Codec follows the IntervalArray shape — `[u16 count]
759 /// [per elem: u8 null + (non-null) scalar body]`.
760 BoolArray(Vec<Option<bool>>),
761 SmallIntArray(Vec<Option<i16>>),
762 FloatArray(Vec<Option<f64>>),
763 /// PG `NUMERIC[]` — `(scaled: i128, scale: u16)` per element.
764 NumericArray(Vec<Option<(i128, u16)>>),
765 DateArray(Vec<Option<i32>>),
766 TimestampArray(Vec<Option<i64>>),
767 TimestamptzArray(Vec<Option<i64>>),
768 UuidArray(Vec<Option<[u8; 16]>>),
769 JsonArray(Vec<Option<String>>),
770 JsonbArray(Vec<Option<String>>),
771 BytesArray(Vec<Option<Vec<u8>>>),
772 VarcharArray(Vec<Option<String>>),
773 CharArray(Vec<Option<String>>),
774 /// v7.37.5 δ — PG 14+ multirange. `ranges` is a Vec of
775 /// non-overlapping bounds spans of the shared `kind`. PG's
776 /// canonical text form is `{[a,b),[c,d),...}` (comma-separated
777 /// ranges in braces; `{}` for the empty multirange). SPG's
778 /// constructor enforces no overlap/coalescing — for now the
779 /// engine trusts the caller (mirrors PG's `_construct_array`
780 /// pattern). Catalog tag 49 + 1-byte RangeKind on the dense
781 /// type-tag side; schema-less path is unreachable (multirange
782 /// is column-typed only).
783 Multirange {
784 kind: RangeKind,
785 ranges: Vec<RangeSpan>,
786 },
787 /// v7.37.5 ε — PG geometry scalars. Per-type Vec/struct shape;
788 /// codec body shape is described on the matching DataType
789 /// variant. PG canonical text forms:
790 /// Point `(x,y)`
791 /// Lseg `[(x1,y1),(x2,y2)]`
792 /// Path open `[(x,y),(x,y),...]` / closed `((x,y),(x,y),...)`
793 /// Box `(ux,uy),(lx,ly)` (PG normalises to upper-right + lower-left)
794 /// Polygon `((x,y),(x,y),...)` (implicit closed)
795 /// Line `{a,b,c}` (Ax + By + C = 0)
796 /// Circle `<(x,y),r>`
797 Point(Point2D),
798 Lseg(Point2D, Point2D),
799 /// `closed = true` is `((p,p,...))`; `false` is `[(p,p,...)]`.
800 Path {
801 points: Vec<Point2D>,
802 closed: bool,
803 },
804 /// PG `box` — stored as `(upper_right, lower_left)` (PG's
805 /// normalised order). The engine accepts both endpoint
806 /// orderings at parse time and normalises here.
807 PgBox(Point2D, Point2D),
808 Polygon(Vec<Point2D>),
809 Line {
810 a: f64,
811 b: f64,
812 c: f64,
813 },
814 Circle {
815 center: Point2D,
816 radius: f64,
817 },
818 /// v7.37.5 ζ-A — PG `inet`. `family = 4` (IPv4) or `6` (IPv6).
819 /// `bits` is the netmask bit count (0..=32 for IPv4, 0..=128
820 /// for IPv6). `addr` is right-padded with zeros when family=4
821 /// (first 4 bytes are the address).
822 Inet {
823 family: u8,
824 bits: u8,
825 addr: [u8; 16],
826 },
827 /// v7.37.5 ζ-A — PG `cidr`. Same shape as Inet; CIDR's
828 /// invariant (host bits zero) is enforced at parse / coerce.
829 Cidr {
830 family: u8,
831 bits: u8,
832 addr: [u8; 16],
833 },
834 /// v7.37.5 ζ-A — PG `macaddr`. 6 bytes (XX:XX:XX:XX:XX:XX).
835 Macaddr([u8; 6]),
836 /// v7.37.5 ζ-A — PG `macaddr8`. 8 bytes (EUI-64).
837 Macaddr8([u8; 8]),
838 /// v7.39 (read01 pg_lsn.c) — PG `pg_lsn`, a 64-bit WAL location.
839 PgLsn(u64),
840 /// v7.39 (read01 ruleutils.c) — PG `regclass`: an OID-typed relation
841 /// reference that renders as the relation name. SPG carries BOTH
842 /// (the synthetic oid for catalog joins, the name for display) so
843 /// `conrelid = 't'::regclass` and `'t'::regclass::text` agree.
844 /// Eval-only (no column storage).
845 RegClass(i64, alloc::boxed::Box<str>),
846 /// v7.39 (round 342, V65) — PG `regproc`: an OID-typed FUNCTION
847 /// reference that renders as the function name. Same dual shape
848 /// [`Value::RegClass`] carries, and for the same reason: without the
849 /// oid half, `pg_proc.oid = 'f'::regproc` cannot join, and a callee
850 /// cannot tell `pg_get_functiondef('f'::regproc)` — which PG answers
851 /// — from `pg_get_functiondef('f')` — which PG rejects.
852 /// Eval-only (no column storage).
853 RegProc(i64, alloc::boxed::Box<str>),
854 /// v7.39 (round 648) — PG `regtype`: an OID-typed TYPE reference
855 /// that renders as the type name. The third of the shape
856 /// [`Value::RegClass`] and [`Value::RegProc`] carry, and the one
857 /// that was missing it: `::regtype` produced a plain `Value::Text`
858 /// holding the canonical name, so `'text'::regtype::oid` tried to
859 /// parse the NAME as a number and answered `invalid input syntax
860 /// for type oid: "text"` where PG answers 25. `pg_typeof` on one
861 /// said `text` rather than `regtype` for the same reason.
862 ///
863 /// Eval-only (no column storage).
864 RegType(i64, alloc::boxed::Box<str>),
865 /// v7.39 (round 512) — PG `xid` and `cid`, the transaction and command
866 /// ids the `xmin` / `xmax` / `cmin` / `cmax` system columns carry.
867 ///
868 /// Their own types rather than integers, because PG deliberately gives
869 /// them almost no operators: measured on PG18, `xmin + 1` is "operator
870 /// does not exist: xid + integer", `xmin > 0` likewise, `xmin::bigint`
871 /// is "cannot cast type xid to bigint", and there is no `max(xid)`.
872 /// Carrying them as BigInt would quietly allow all four.
873 ///
874 /// Eval-only (no column storage).
875 Xid(u32),
876 Cid(u32),
877 /// v7.39 (round 511) — PG `tid`, the physical row identity `ctid`
878 /// carries: a block number and a one-based offset inside it, rendered
879 /// `(block,offset)`.
880 ///
881 /// It is a real type rather than a two-field record because the idiom
882 /// that makes `ctid` worth having — `DELETE … WHERE ctid NOT IN (SELECT
883 /// min(ctid) … GROUP BY key)` — needs `min()` over it, and PG has no
884 /// `min(record)`. Ordering is by block then offset, so `(0,2) < (0,9) <
885 /// (0,10)`; a text form would order those `(0,10) < (0,2) < (0,9)` and
886 /// the dedup would keep the wrong row.
887 ///
888 /// Eval-only (no column storage).
889 Tid(u32, u32),
890 /// v7.37.5 ζ-A — PG `bit` / `bit varying`. `nbits` is the
891 /// actual bit count; `bytes` is the packed representation
892 /// (big-endian within each byte; final byte right-padded
893 /// with 0s if `nbits % 8 != 0`).
894 BitString {
895 nbits: u32,
896 bytes: Cow<'arena, [u8]>,
897 },
898 /// v7.37.5 ζ-A — PG `xml`. Stored verbatim as a string; no
899 /// parse-time validation (matches the SPG JSON convention).
900 Xml(Cow<'arena, str>),
901 /// v7.37.5 ζ-A — PG `"char"` (internal single-byte type,
902 /// distinct from CHAR(n)).
903 Char1(u8),
904 /// v7.38 (read01, T11) — PG `bpchar` / CHAR(n): blank-padded fixed-length
905 /// string. Stored space-padded to the declared width (as PG does + for wire
906 /// display); length / comparison / ::text / concat all ignore the trailing
907 /// blanks (handled at those sites).
908 BpChar(Cow<'arena, str>),
909 /// v7.37.5 ζ-A — PG `money[]`.
910 MoneyArray(Vec<Option<i64>>),
911 /// v7.12.0 `tsvector` — sorted-by-word, deduped lexeme set with
912 /// positions + weights. The engine enforces sort/dedup on
913 /// construction; consumers can rely on `lexemes.windows(2)`
914 /// being strictly ascending by `word`.
915 TsVector(Vec<TsLexeme>),
916 /// v7.12.0 `tsquery` — boolean / phrase parse tree over
917 /// lexemes. Engine builds via `to_tsquery` family.
918 TsQuery(TsQueryAst),
919 /// v7.17.0 `uuid` — 128-bit identifier. Stored as 16 bytes
920 /// (big-endian / network-byte order, same as RFC 4122).
921 /// Display normalises to canonical lowercase 8-4-4-4-12
922 /// hyphenated form. Equality is byte-wise.
923 Uuid([u8; 16]),
924 /// v7.17.0 Phase 3.P0-32 — PG `time` (without time zone) —
925 /// i64 microseconds since 00:00:00. Range 0..86_400_000_000.
926 /// Display: `HH:MM:SS` zero-padded, with optional `.ffffff`
927 /// suffix when fractional is non-zero.
928 Time(i64),
929 /// v7.17.0 Phase 3.P0-33 — MySQL `YEAR` — u16 in range
930 /// 1901..=2155 plus the special zero-year sentinel 0.
931 /// Display always 4 digits zero-padded (`0000` for the
932 /// sentinel; `1985`/`2007` otherwise).
933 Year(u16),
934 /// v7.17.0 Phase 3.P0-34 — PG `time with time zone` — i64
935 /// microseconds since 00:00:00 in the LOCAL wall clock PLUS
936 /// an i32 offset-from-UTC in seconds. PG preserves the
937 /// offset on output, so the wall-clock value is NOT shifted
938 /// to UTC at storage time. Offset range: ±50400 seconds
939 /// (±14 hours).
940 TimeTz {
941 us: i64,
942 offset_secs: i32,
943 },
944 /// v7.17.0 Phase 3.P0-35 — PG `money` — i64 cents
945 /// (locale-independent storage; the en_US locale renders on
946 /// display via `$N,NNN.CC`).
947 Money(i64),
948 /// v7.17.0 Phase 3.P0-39 — PG `hstore` value: flat
949 /// `text => text` map with NULL value support. Insertion
950 /// order preserved on input; duplicate keys take last-write-
951 /// wins at parse time.
952 Hstore(Vec<(String, Option<String>)>),
953 /// v7.17.0 Phase 3.P0-40 — 2D INT matrix (row-major).
954 IntArray2D(Vec<Vec<Option<i32>>>),
955 /// v7.17.0 Phase 3.P0-40 — 2D BIGINT matrix (row-major).
956 BigIntArray2D(Vec<Vec<Option<i64>>>),
957 /// v7.17.0 Phase 3.P0-40 — 2D TEXT matrix (row-major).
958 TextArray2D(Vec<Vec<Option<String>>>),
959 /// v7.39 (read01 round 75) — see `DataType::BoolArray2D`.
960 BoolArray2D(Vec<Vec<Option<bool>>>),
961 /// v7.17.0 Phase 3.P0-38 — PG range value. One shape covers
962 /// all six builtin range types; `kind` pins the element type
963 /// (must match the column's `DataType::Range(kind)`).
964 /// `lower` / `upper` are `None` for the unbounded sides;
965 /// `lower_inc` / `upper_inc` mirror the canonical PG
966 /// `[` / `(` / `]` / `)` bracket inclusivity. `empty=true`
967 /// supersedes all other fields (the empty range has no
968 /// bounds).
969 Range {
970 kind: RangeKind,
971 // v7.37.42-arena Phase 1: Range bounds stay owned ('static).
972 // Recursive arena lifetimes are awkward to migrate at this
973 // phase and the SCALARSQ hot path doesn't construct ranges.
974 lower: Option<alloc::boxed::Box<Value<'static>>>,
975 upper: Option<alloc::boxed::Box<Value<'static>>>,
976 lower_inc: bool,
977 upper_inc: bool,
978 empty: bool,
979 },
980 /// v7.38 (read01, T9) — a composite / record value (a `row(...)`
981 /// constructor or a whole-row reference). Fields are `(name, value)`; the
982 /// names are `f1..fN` for an anonymous `row(...)` or the source column
983 /// names for a table row. Transient — flows through row_to_json / to_json
984 /// and the composite text form `(a,b)`; not a storable column type here.
985 Composite(alloc::vec::Vec<(alloc::string::String, Value<'static>)>),
986 Null,
987}
988
989/// Owned `Value` — heap-bearing variants are `Cow::Owned`. Used everywhere
990/// a Value must outlive a query-scoped arena (catalog defaults, persistent
991/// storage, public APIs).
992pub type ValueOwned = Value<'static>;
993
994/// v7.37.5 ε — PG `point` building block. Shared by every other
995/// geometric type (lseg / path / box / polygon / circle all
996/// reduce to compositions of `Point2D`). Packed `{x: f64, y: f64}`,
997/// 16 B, on-disk LE field order matches the PG binary point
998/// format byte-for-byte (so a future binary BIND path lands
999/// without rearrangement).
1000#[derive(Debug, Clone, Copy, PartialEq)]
1001pub struct Point2D {
1002 pub x: f64,
1003 pub y: f64,
1004}
1005
1006/// v7.37.5 δ — single-range bounds without the kind tag. Used as
1007/// the element type of `Value::Multirange { kind, ranges }` so a
1008/// multirange carries one shared `RangeKind` plus N bounds-only
1009/// spans (saves 1 byte/elem vs duplicating the kind). The five
1010/// other fields mirror `Value::Range` exactly.
1011#[derive(Debug, Clone, PartialEq)]
1012pub struct RangeSpan {
1013 // v7.37.42-arena Phase 1: stays owned ('static) — same rationale as
1014 // Range bounds above.
1015 pub lower: Option<alloc::boxed::Box<Value<'static>>>,
1016 pub upper: Option<alloc::boxed::Box<Value<'static>>>,
1017 pub lower_inc: bool,
1018 pub upper_inc: bool,
1019 pub empty: bool,
1020}
1021
1022/// v7.37.5 β-P4 — element type for `Value::IntervalArray`. Mirrors
1023/// the `{months, days, micros}` shape of scalar `Value::Interval`,
1024/// broken out as a named struct so `IntervalArray`'s element type
1025/// is concrete (24 bytes, packed) instead of an enum-boxed Value.
1026/// All three dimensions are independent — `IntervalSpan { days: 1,
1027/// .. }` is distinct from `IntervalSpan { micros: 86_400_000_000,
1028/// .. }` per PG byte-equal.
1029#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1030pub struct IntervalSpan {
1031 pub months: i32,
1032 pub days: i32,
1033 pub micros: i64,
1034}
1035
1036impl<'arena> Value<'arena> {
1037 /// Type tag, or `None` for `NULL` (unknown at value level).
1038 pub fn data_type(&self) -> Option<DataType> {
1039 match self {
1040 Self::SmallInt(_) => Some(DataType::SmallInt),
1041 Self::Int(_) => Some(DataType::Int),
1042 Self::BigInt(_) => Some(DataType::BigInt),
1043 Self::Float(_) => Some(DataType::Float),
1044 Self::Real(_) => Some(DataType::Real),
1045 // `Text` covers both unbounded TEXT and bounded VARCHAR/CHAR
1046 // — the constraint lives on the column schema, not the value.
1047 Self::Text(_) => Some(DataType::Text),
1048 Self::Bool(_) => Some(DataType::Bool),
1049 Self::Vector(v) => Some(DataType::Vector {
1050 dim: u32::try_from(v.len()).expect("vector dim ≤ u32"),
1051 encoding: VecEncoding::F32,
1052 }),
1053 Self::Sq8Vector(q) => Some(DataType::Vector {
1054 dim: u32::try_from(q.bytes.len()).expect("vector dim ≤ u32"),
1055 encoding: VecEncoding::Sq8,
1056 }),
1057 Self::HalfVector(h) => Some(DataType::Vector {
1058 dim: u32::try_from(h.dim()).expect("vector dim ≤ u32"),
1059 encoding: VecEncoding::F16,
1060 }),
1061 // `Value::Numeric` doesn't carry its precision (the column
1062 // schema does); we surface precision=0 as "unknown" and let
1063 // the engine reconcile against the column type at coercion
1064 // time.
1065 // v7.39 (round 273) — a VALUE's display scale is unsigned and
1066 // never exceeds PG's 16383 ceiling, so it always fits the
1067 // signed declared-scale field this describes itself with.
1068 Self::Numeric { scale, .. } => Some(DataType::Numeric {
1069 precision: 0,
1070 scale: i16::try_from(*scale).unwrap_or(i16::MAX),
1071 }),
1072 Self::NumericBig(b) => Some(DataType::Numeric {
1073 precision: 0,
1074 scale: i16::try_from(b.scale()).unwrap_or(i16::MAX),
1075 }),
1076 Self::Date(_) => Some(DataType::Date),
1077 Self::Timestamp(_) => Some(DataType::Timestamp),
1078 Self::Interval { .. } => Some(DataType::Interval),
1079 Self::Json(_) => Some(DataType::Json),
1080 Self::Bytes(_) => Some(DataType::Bytes),
1081 Self::TextArray(_) => Some(DataType::TextArray),
1082 Self::IntArray(_) => Some(DataType::IntArray),
1083 Self::BigIntArray(_) => Some(DataType::BigIntArray),
1084 Self::IntervalArray(_) => Some(DataType::IntervalArray),
1085 Self::BoolArray(_) => Some(DataType::BoolArray),
1086 Self::SmallIntArray(_) => Some(DataType::SmallIntArray),
1087 Self::FloatArray(_) => Some(DataType::FloatArray),
1088 Self::NumericArray(_) => Some(DataType::NumericArray),
1089 Self::DateArray(_) => Some(DataType::DateArray),
1090 Self::TimestampArray(_) => Some(DataType::TimestampArray),
1091 Self::TimestamptzArray(_) => Some(DataType::TimestamptzArray),
1092 Self::UuidArray(_) => Some(DataType::UuidArray),
1093 Self::JsonArray(_) => Some(DataType::JsonArray),
1094 Self::JsonbArray(_) => Some(DataType::JsonbArray),
1095 Self::BytesArray(_) => Some(DataType::BytesArray),
1096 Self::VarcharArray(_) => Some(DataType::VarcharArray),
1097 Self::CharArray(_) => Some(DataType::CharArray),
1098 Self::Multirange { kind, .. } => Some(DataType::Multirange(*kind)),
1099 Self::Point(_) => Some(DataType::Point),
1100 Self::Lseg(_, _) => Some(DataType::Lseg),
1101 Self::Path { .. } => Some(DataType::Path),
1102 Self::PgBox(_, _) => Some(DataType::PgBox),
1103 Self::Polygon(_) => Some(DataType::Polygon),
1104 Self::Line { .. } => Some(DataType::Line),
1105 Self::Circle { .. } => Some(DataType::Circle),
1106 Self::Inet { .. } => Some(DataType::Inet),
1107 Self::Cidr { .. } => Some(DataType::Cidr),
1108 Self::Macaddr(_) => Some(DataType::Macaddr),
1109 Self::Macaddr8(_) => Some(DataType::Macaddr8),
1110 Self::PgLsn(_) => Some(DataType::PgLsn),
1111 // BitString could be either Bit or BitVarying; column
1112 // schema decides. Default to BitVarying when called
1113 // schema-less (rare; storage path is always
1114 // schema-aware so this only matters for diagnostics).
1115 Self::BitString { .. } => Some(DataType::BitVarying(0)),
1116 Self::Xml(_) => Some(DataType::Xml),
1117 Self::Char1(_) => Some(DataType::Char1),
1118 // BpChar reports its declared width from the padded length.
1119 Self::BpChar(s) => Some(DataType::Char(
1120 u32::try_from(s.chars().count()).unwrap_or(0),
1121 )),
1122 Self::MoneyArray(_) => Some(DataType::MoneyArray),
1123 Self::TsVector(_) => Some(DataType::TsVector),
1124 Self::TsQuery(_) => Some(DataType::TsQuery),
1125 Self::Uuid(_) => Some(DataType::Uuid),
1126 Self::Time(_) => Some(DataType::Time),
1127 Self::Year(_) => Some(DataType::Year),
1128 Self::TimeTz { .. } => Some(DataType::TimeTz),
1129 Self::Money(_) => Some(DataType::Money),
1130 Self::Range { kind, .. } => Some(DataType::Range(*kind)),
1131 Self::Hstore(_) => Some(DataType::Hstore),
1132 Self::IntArray2D(_) => Some(DataType::IntArray2D),
1133 Self::BigIntArray2D(_) => Some(DataType::BigIntArray2D),
1134 Self::TextArray2D(_) => Some(DataType::TextArray2D),
1135 Self::BoolArray2D(_) => Some(DataType::BoolArray2D),
1136 // v7.38 (read01, T9) — a transient composite/record has no storable
1137 // column DataType (it flows through row_to_json / to_json).
1138 Self::Composite(_) => None,
1139 // v7.39 (read01 ruleutils.c) — regclass is eval-only (dual
1140 // oid+name shape); no column storage type.
1141 // v7.39 (round 640) — `xid` became a column type, so its value
1142 // has a DataType to answer with. `cid` and `tid` are equally
1143 // legal column types on PG (measured: `CREATE TABLE t (a cid,
1144 // b tid)` is accepted), but SPG's grammar has no keyword for
1145 // them yet; they stay eval-only rather than half-declared.
1146 Self::Xid(_) => Some(DataType::Xid),
1147 Self::RegClass(..)
1148 | Self::RegProc(..)
1149 | Self::RegType(..)
1150 | Self::Tid(..)
1151 | Self::Cid(_) => None,
1152 Self::Null => None,
1153 }
1154 }
1155
1156 pub const fn is_null(&self) -> bool {
1157 matches!(self, Self::Null)
1158 }
1159
1160 /// v7.37.42-arena Phase 1: lift any `Value<'arena>` (possibly
1161 /// borrowing from a bump arena) into a fully-owned `Value<'static>`.
1162 /// Used at boundaries that must outlive the per-query arena
1163 /// (catalog write, public QueryResult emit, sqlx materialise).
1164 ///
1165 /// For the recursive Range/Multirange variants — bounds are already
1166 /// `Box<Value<'static>>` per Phase 1 design, so we just rebuild the
1167 /// outer enum at `'static`.
1168 pub fn into_owned(self) -> Value<'static> {
1169 match self {
1170 Value::SmallInt(n) => Value::SmallInt(n),
1171 Value::Int(n) => Value::Int(n),
1172 Value::BigInt(n) => Value::BigInt(n),
1173 Value::Float(f) => Value::Float(f),
1174 Value::Real(f) => Value::Real(f),
1175 Value::Text(s) => Value::Text(Cow::Owned(s.into_owned())),
1176 Value::Bool(b) => Value::Bool(b),
1177 Value::Vector(v) => Value::Vector(Cow::Owned(v.into_owned())),
1178 Value::Sq8Vector(q) => Value::Sq8Vector(q),
1179 Value::HalfVector(h) => Value::HalfVector(h),
1180 Value::Numeric {
1181 scaled,
1182 scale,
1183 kind,
1184 } => Value::Numeric {
1185 scaled,
1186 scale,
1187 kind,
1188 },
1189 Value::NumericBig(b) => Value::NumericBig(b),
1190 Value::Date(d) => Value::Date(d),
1191 Value::Timestamp(t) => Value::Timestamp(t),
1192 Value::Interval {
1193 months,
1194 days,
1195 micros,
1196 } => Value::Interval {
1197 months,
1198 days,
1199 micros,
1200 },
1201 Value::Json(s) => Value::Json(Cow::Owned(s.into_owned())),
1202 Value::Bytes(b) => Value::Bytes(Cow::Owned(b.into_owned())),
1203 Value::TextArray(v) => Value::TextArray(v),
1204 Value::IntArray(v) => Value::IntArray(v),
1205 Value::BigIntArray(v) => Value::BigIntArray(v),
1206 Value::IntervalArray(v) => Value::IntervalArray(v),
1207 Value::BoolArray(v) => Value::BoolArray(v),
1208 Value::SmallIntArray(v) => Value::SmallIntArray(v),
1209 Value::FloatArray(v) => Value::FloatArray(v),
1210 Value::NumericArray(v) => Value::NumericArray(v),
1211 Value::DateArray(v) => Value::DateArray(v),
1212 Value::TimestampArray(v) => Value::TimestampArray(v),
1213 Value::TimestamptzArray(v) => Value::TimestamptzArray(v),
1214 Value::UuidArray(v) => Value::UuidArray(v),
1215 Value::JsonArray(v) => Value::JsonArray(v),
1216 Value::JsonbArray(v) => Value::JsonbArray(v),
1217 Value::BytesArray(v) => Value::BytesArray(v),
1218 Value::VarcharArray(v) => Value::VarcharArray(v),
1219 Value::CharArray(v) => Value::CharArray(v),
1220 Value::Multirange { kind, ranges } => Value::Multirange { kind, ranges },
1221 // v7.38 (read01, T9) — Composite fields are already `Value<'static>`.
1222 Value::Composite(fields) => Value::Composite(fields),
1223 Value::RegClass(oid, name) => Value::RegClass(oid, name),
1224 Value::Tid(b, o) => Value::Tid(b, o),
1225 Value::Xid(x) => Value::Xid(x),
1226 Value::Cid(c) => Value::Cid(c),
1227 Value::RegProc(oid, name) => Value::RegProc(oid, name),
1228 Value::RegType(oid, name) => Value::RegType(oid, name),
1229 Value::Point(p) => Value::Point(p),
1230 Value::Lseg(a, b) => Value::Lseg(a, b),
1231 Value::Path { points, closed } => Value::Path { points, closed },
1232 Value::PgBox(a, b) => Value::PgBox(a, b),
1233 Value::Polygon(p) => Value::Polygon(p),
1234 Value::Line { a, b, c } => Value::Line { a, b, c },
1235 Value::Circle { center, radius } => Value::Circle { center, radius },
1236 Value::Inet { family, bits, addr } => Value::Inet { family, bits, addr },
1237 Value::Cidr { family, bits, addr } => Value::Cidr { family, bits, addr },
1238 Value::Macaddr(m) => Value::Macaddr(m),
1239 Value::Macaddr8(m) => Value::Macaddr8(m),
1240 Value::PgLsn(l) => Value::PgLsn(l),
1241 Value::BitString { nbits, bytes } => Value::BitString {
1242 nbits,
1243 bytes: Cow::Owned(bytes.into_owned()),
1244 },
1245 Value::Xml(s) => Value::Xml(Cow::Owned(s.into_owned())),
1246 Value::Char1(c) => Value::Char1(c),
1247 Value::BpChar(s) => Value::BpChar(Cow::Owned(s.into_owned())),
1248 Value::MoneyArray(v) => Value::MoneyArray(v),
1249 Value::TsVector(v) => Value::TsVector(v),
1250 Value::TsQuery(q) => Value::TsQuery(q),
1251 Value::Uuid(u) => Value::Uuid(u),
1252 Value::Time(t) => Value::Time(t),
1253 Value::Year(y) => Value::Year(y),
1254 Value::TimeTz { us, offset_secs } => Value::TimeTz { us, offset_secs },
1255 Value::Money(m) => Value::Money(m),
1256 Value::Range {
1257 kind,
1258 lower,
1259 upper,
1260 lower_inc,
1261 upper_inc,
1262 empty,
1263 } => Value::Range {
1264 kind,
1265 lower,
1266 upper,
1267 lower_inc,
1268 upper_inc,
1269 empty,
1270 },
1271 Value::Hstore(h) => Value::Hstore(h),
1272 Value::IntArray2D(a) => Value::IntArray2D(a),
1273 Value::BigIntArray2D(a) => Value::BigIntArray2D(a),
1274 Value::TextArray2D(a) => Value::TextArray2D(a),
1275 Value::BoolArray2D(a) => Value::BoolArray2D(a),
1276 Value::Null => Value::Null,
1277 }
1278 }
1279
1280 /// v7.37.42-arena Phase 4 — copy heap payloads into the supplied
1281 /// bump arena, yielding a `Value<'a>` whose Cow-variant payloads
1282 /// are arena-borrowed (or stay as small owned scalars for the
1283 /// `Copy`-able variants).
1284 ///
1285 /// Used at the catalog ↔ ephemeral boundary: a `ColumnSchema.default`
1286 /// is `Value<'static>` but INSERT-time eval may want it stamped into
1287 /// the per-statement arena alongside other arena-built scalars.
1288 ///
1289 /// Allocates only into the supplied arena; the input `&self` keeps
1290 /// its own storage. For `Copy`-able / nested-owned variants the
1291 /// implementation falls back to `clone()` (the nested heap blocks
1292 /// stay on the global allocator, which is fine — the boundary
1293 /// requirement is just "no aliasing of caller-owned strings").
1294 pub fn clone_into<'a>(&self, arena: &'a bumpalo::Bump) -> Value<'a> {
1295 match self {
1296 Value::Text(s) => Value::Text(Cow::Borrowed(arena.alloc_str(s))),
1297 Value::Json(s) => Value::Json(Cow::Borrowed(arena.alloc_str(s))),
1298 Value::Xml(s) => Value::Xml(Cow::Borrowed(arena.alloc_str(s))),
1299 Value::BpChar(s) => Value::BpChar(Cow::Borrowed(arena.alloc_str(s))),
1300 Value::Bytes(b) => {
1301 let slot = arena.alloc_slice_copy::<u8>(b);
1302 Value::Bytes(Cow::Borrowed(slot))
1303 }
1304 Value::Vector(v) => {
1305 let slot = arena.alloc_slice_copy::<f32>(v);
1306 Value::Vector(Cow::Borrowed(slot))
1307 }
1308 Value::BitString { nbits, bytes } => {
1309 let slot = arena.alloc_slice_copy::<u8>(bytes);
1310 Value::BitString {
1311 nbits: *nbits,
1312 bytes: Cow::Borrowed(slot),
1313 }
1314 }
1315 // Copy-able scalars + variants whose nested heap blocks are
1316 // `'static` regardless of `'arena` (TextArray, JsonArray,
1317 // Hstore, TsVector, Range bounds, …). Clone the heap block
1318 // via the standard `into_owned()` path then lift the
1319 // resulting `Value<'static>` to `Value<'a>` via the Cow
1320 // variance — `'static` covers any lifetime.
1321 other => other.clone().into_owned(),
1322 }
1323 }
1324}
1325
1326impl Value<'static> {
1327 /// v7.37.42-arena Phase 1 — owned-Text constructor. The variant now
1328 /// holds `Cow<'arena, str>`, so the previous `Value::Text(String)`
1329 /// shape no longer compiles directly. This helper preserves the
1330 /// historical ergonomics: `Value::text("foo")` or
1331 /// `Value::text(String::from("foo"))`.
1332 pub fn text<S: Into<String>>(s: S) -> Self {
1333 Value::Text(Cow::Owned(s.into()))
1334 }
1335
1336 /// v7.38 (read01, T6) — a finite NUMERIC from its fixed-point parts.
1337 pub const fn numeric(scaled: i128, scale: u16) -> Self {
1338 Value::Numeric {
1339 scaled,
1340 scale,
1341 kind: NumericKind::Finite,
1342 }
1343 }
1344
1345 /// v7.38 (read01, T6) — a special NUMERIC (NaN / ±Infinity). The fixed-point
1346 /// fields are canonicalized to 0 so equal specials compare byte-identical.
1347 pub const fn numeric_special(kind: NumericKind) -> Self {
1348 Value::Numeric {
1349 scaled: 0,
1350 scale: 0,
1351 kind,
1352 }
1353 }
1354
1355 /// v7.37.42-arena Phase 1 — owned-Json constructor (mirrors `text`).
1356 pub fn json<S: Into<String>>(s: S) -> Self {
1357 Value::Json(Cow::Owned(s.into()))
1358 }
1359
1360 /// v7.37.42-arena Phase 1 — owned-Xml constructor.
1361 pub fn xml<S: Into<String>>(s: S) -> Self {
1362 Value::Xml(Cow::Owned(s.into()))
1363 }
1364
1365 /// v7.37.42-arena Phase 1 — owned-Bytes constructor.
1366 pub fn bytes<B: Into<Vec<u8>>>(b: B) -> Self {
1367 Value::Bytes(Cow::Owned(b.into()))
1368 }
1369
1370 /// v7.37.42-arena Phase 1 — owned-Vector constructor.
1371 pub fn vector<V: Into<Vec<f32>>>(v: V) -> Self {
1372 Value::Vector(Cow::Owned(v.into()))
1373 }
1374
1375 /// v7.37.42-arena Phase 1 — owned-BitString constructor.
1376 pub fn bit_string<B: Into<Vec<u8>>>(nbits: u32, bytes: B) -> Self {
1377 Value::BitString {
1378 nbits,
1379 bytes: Cow::Owned(bytes.into()),
1380 }
1381 }
1382}
1383
1384/// One table row — values are positional and must match
1385/// `TableSchema.columns` in length and (modulo NULL) in `DataType`.
1386///
1387/// v7.37.42-arena Phase 1: parameterised on `'arena` so per-query rows
1388/// can borrow from a bump arena. The owned shape (`Row<'static>`, alias
1389/// `RowOwned`) is what catalog storage, public APIs, and tests use.
1390#[derive(Debug, Clone, PartialEq)]
1391pub struct Row<'arena> {
1392 pub values: Vec<Value<'arena>>,
1393}
1394
1395/// Owned `Row` — values are `Value<'static>`. Used everywhere a row must
1396/// outlive a query-scoped arena.
1397pub type RowOwned = Row<'static>;
1398
1399impl<'arena> Row<'arena> {
1400 pub const fn new(values: Vec<Value<'arena>>) -> Self {
1401 Self { values }
1402 }
1403
1404 pub fn len(&self) -> usize {
1405 self.values.len()
1406 }
1407
1408 pub fn is_empty(&self) -> bool {
1409 self.values.is_empty()
1410 }
1411}
1412
1413impl<'arena> Row<'arena> {
1414 /// v7.37.42-arena Phase 4 — copy every cell into the supplied bump
1415 /// arena, yielding a `Row<'a>` whose Cow-payloads are arena-borrowed.
1416 /// Boundary helper for catalog defaults → DML eval handoff and
1417 /// arena-local row scratch.
1418 pub fn clone_into<'a>(&self, arena: &'a bumpalo::Bump) -> Row<'a> {
1419 Row {
1420 values: self.values.iter().map(|v| v.clone_into(arena)).collect(),
1421 }
1422 }
1423
1424 /// v7.37.42-arena Phase 4 — lift this `Row<'arena>` to a fully-owned
1425 /// `Row<'static>` for catalog write / WAL serialisation. Equivalent
1426 /// to `Row::from_arena(self)` but consumes by value at any lifetime
1427 /// (callers can write `row.into_owned()` mirroring `Value::into_owned`).
1428 pub fn into_owned(self) -> Row<'static> {
1429 Row {
1430 values: self.values.into_iter().map(Value::into_owned).collect(),
1431 }
1432 }
1433}
1434
1435impl Row<'static> {
1436 /// v7.37.42-arena Phase 1 — lift any `Row<'arena>` (possibly arena-
1437 /// borrowed) into a fully-owned `Row<'static>`. Mirrors
1438 /// `Value::into_owned`.
1439 pub fn from_arena(row: Row<'_>) -> Self {
1440 Self {
1441 values: row.values.into_iter().map(Value::into_owned).collect(),
1442 }
1443 }
1444}
1445
1446/// Each bool is an independent, separately-persisted column attribute
1447/// (`nullable`, `auto_increment`, `is_unsigned`, `identity_always`) that the
1448/// catalog appendix reads and writes by name. Packing them into a bitflags
1449/// word would buy nothing and would put a decoding step between the on-disk
1450/// format and every reader of the schema.
1451#[allow(clippy::struct_excessive_bools)]
1452#[derive(Debug, Clone, PartialEq)]
1453pub struct ColumnSchema {
1454 pub name: String,
1455 pub ty: DataType,
1456 pub nullable: bool,
1457 /// Optional `DEFAULT` value, frozen at CREATE TABLE time. `None`
1458 /// means "no default" (so omitted columns become NULL, or error
1459 /// out when the column is NOT NULL). Literal defaults take this
1460 /// path.
1461 ///
1462 /// v7.37.42-arena Phase 1: explicitly `Value<'static>` — catalog
1463 /// defaults must outlive any per-query arena.
1464 pub default: Option<Value<'static>>,
1465 /// v7.9.21 — for DEFAULT expressions that need INSERT-time
1466 /// evaluation (e.g. `DEFAULT now()`, `DEFAULT CURRENT_TIMESTAMP`),
1467 /// the Display form of the expression. The engine re-parses
1468 /// it on each INSERT default-fill, evaluates against an empty
1469 /// row context, and coerces to the column type. mailrs G4.
1470 /// Persisted in catalog FILE_VERSION 15+; older catalogs
1471 /// deserialise with None.
1472 pub runtime_default: Option<String>,
1473 /// MySQL-style `AUTO_INCREMENT`. When set, an INSERT that leaves
1474 /// this column unbound (or sets it to NULL) gets the next integer
1475 /// computed from the column's current max + 1.
1476 /// v7.39 (round 676) — the collation NAME as written, when the column
1477 /// carried an explicit `COLLATE`.
1478 ///
1479 /// `spg_sql::Collation` cannot carry it: it is a two-variant MySQL enum
1480 /// and `from_collation_name` folds `C`, `POSIX`, `en_US` and `default`
1481 /// all into `Binary`. Without the name `pg_attribute.attcollation` can
1482 /// only ever report the type's default, which is what F36 records as
1483 /// "the declaration is taken and ignored".
1484 ///
1485 /// None means the column was written without a `COLLATE` clause and
1486 /// takes its type's collation. Persisted through the v88 appendix,
1487 /// which costs two bytes for a table that declares none.
1488 pub collation_name: Option<String>,
1489 pub auto_increment: bool,
1490 /// v7.17.0 Phase 1.4 — when the column is bound to a user-
1491 /// defined ENUM type (the parser saw an unknown type ident
1492 /// and the engine resolved it against `catalog.enum_types`),
1493 /// this carries the enum name so INSERT/UPDATE can validate
1494 /// the cell value against the enum's labels. `ty` is
1495 /// `DataType::Text` in that case. Persisted in catalog
1496 /// FILE_VERSION 29+; older catalogs deserialise with None.
1497 pub user_enum_type: Option<String>,
1498 /// v7.17.0 Phase 1.5 — when the column is bound to a user-
1499 /// defined DOMAIN (the parser saw an unknown type ident and
1500 /// the engine resolved it against `catalog.domain_types`),
1501 /// this carries the domain name. `ty` is the domain's base
1502 /// type; INSERT/UPDATE re-evaluates the domain's CHECK list
1503 /// + NOT NULL against the cell value. Persisted in catalog
1504 /// FILE_VERSION 30+; older catalogs deserialise with None.
1505 pub user_domain_type: Option<String>,
1506 /// v7.39 (read01 round 56) — when the column is bound to a user-defined
1507 /// COMPOSITE type. `ty` stays `DataType::Jsonb` (the on-disk form), but the
1508 /// engine REHYDRATES the stored JSON into a `Value::Composite` on read, so
1509 /// field access `(p).x`, `= ROW(…)`, ordering and the canonical `(2,b)`
1510 /// text form all work — they were already implemented on Value::Composite;
1511 /// what was missing was that the column never recorded WHICH composite type
1512 /// it holds (this field's doc comment existed for two releases, the field
1513 /// itself did not). Persisted in the composite-column appendix
1514 /// (FILE_VERSION 63+); older catalogs deserialise with None.
1515 pub user_composite_type: Option<String>,
1516 /// v7.39 (read01 round 59) — column-level privileges (PG
1517 /// `pg_attribute.attacl`). `GRANT SELECT (pub) ON t TO dan` lands here and
1518 /// does NOT touch the table's `relacl`. Empty = no column grant, which is
1519 /// every column until one is made.
1520 pub acl: Vec<AclItem>,
1521 /// v7.17.0 Phase 2.1 — MySQL `ON UPDATE CURRENT_TIMESTAMP`
1522 /// column attribute. When `Some(expr_src)`, an UPDATE that
1523 /// does NOT bind this column overrides the new value with
1524 /// the engine-evaluated expression (always `now()` in
1525 /// v7.17.0). Stored as Display-form source so storage
1526 /// stays free of spg-sql; the engine re-parses at UPDATE
1527 /// time. Persisted in catalog FILE_VERSION 32+; older
1528 /// catalogs deserialise with None — preserves the existing
1529 /// "silent ignore" behaviour for snapshots written before
1530 /// the upgrade.
1531 pub on_update_runtime: Option<String>,
1532 /// v7.17.0 Phase 2.5 — text collation. Pre-2.5 SPG accepted
1533 /// `COLLATE <name>` clauses but discarded the name, so a
1534 /// column declared `COLLATE "case_insensitive"` (or any
1535 /// MySQL `_ci` collation) still compared byte-wise — a
1536 /// Tier-S silent failure where `WHERE name = 'foo'` never
1537 /// matched stored `'Foo'`. This carries the parser-derived
1538 /// classification so the engine's WHERE evaluator can route
1539 /// text equality through a case-aware compare. `Binary` (the
1540 /// default) preserves the prior byte-wise behaviour. Only
1541 /// CaseInsensitive lands in the catalog appendix — Binary
1542 /// columns stay implicit, keeping snapshots compact.
1543 /// Persisted in catalog FILE_VERSION 34+; older catalogs
1544 /// deserialise every column as `Binary`.
1545 pub collation: Collation,
1546 /// v7.17.0 Phase 4.4 — MySQL `UNSIGNED` modifier flag. Drives
1547 /// engine-side INSERT / UPDATE range enforcement (rejects
1548 /// negative values on UNSIGNED int columns). Pre-4.4 the
1549 /// parser consumed and discarded the keyword silently, so
1550 /// every UNSIGNED column quietly accepted negatives — a
1551 /// Tier-A correctness drift. Sparse: only UNSIGNED columns
1552 /// land in the catalog appendix; the default `false` keeps
1553 /// snapshots compact for the common signed-int path.
1554 /// Persisted in catalog FILE_VERSION 35+; older catalogs
1555 /// deserialise every column as `is_unsigned = false`.
1556 pub is_unsigned: bool,
1557 /// v7.17.0 Phase 3.P0-36 — MySQL inline `ENUM('a','b','c')`
1558 /// value list. Distinct from `user_enum_type` (which points
1559 /// to a separately CREATE TYPE'd PG enum); this carries the
1560 /// column-local list MySQL DDL declares inline. When `Some`,
1561 /// `ty` is `DataType::Text` and INSERT/UPDATE validates the
1562 /// cell value against this list. Variant ORDER is preserved
1563 /// (MySQL uses it for `ORDER BY col`). Sparse: only ENUM
1564 /// columns land in the catalog appendix.
1565 /// Persisted in catalog FILE_VERSION 41+; older catalogs
1566 /// deserialise with None — preserves silent-drop behaviour
1567 /// for snapshots written before P0-36.
1568 pub inline_enum_variants: Option<Vec<String>>,
1569 /// v7.17.0 Phase 3.P0-37 — MySQL inline `SET('a','b','c')`
1570 /// variant list. Storage is TEXT (canonical comma-joined in
1571 /// definition order, de-duplicated). INSERT/UPDATE validates
1572 /// every comma-separated token against this list. Sparse:
1573 /// only SET columns land in the catalog appendix.
1574 /// Persisted in catalog FILE_VERSION 42+; older catalogs
1575 /// deserialise with None.
1576 pub inline_set_variants: Option<Vec<String>>,
1577 /// v7.37.7(sentori Epic 3 P1)— `GENERATED ALWAYS AS (<expr>)
1578 /// STORED` computed-column source. When `Some`, INSERT / UPDATE
1579 /// recompute the cell against the candidate row(re-parse the
1580 /// stored Display form and evaluate)and overwrite any
1581 /// user-supplied value, matching PG's stored-generated-column
1582 /// semantics. `None` (the default) preserves the regular
1583 /// "column value is whatever the caller passed" path.
1584 /// Persisted in catalog FILE_VERSION 50+; older catalogs
1585 /// deserialise with None.
1586 pub generated_stored_expr: Option<String>,
1587 /// v7.38 (read01) — `GENERATED ALWAYS AS IDENTITY`. Both identity
1588 /// flavours set `auto_increment`; this additionally marks the ALWAYS
1589 /// flavour, whose explicit INSERT value PG rejects ("cannot insert a
1590 /// non-DEFAULT value into column …") unless `OVERRIDING SYSTEM VALUE`.
1591 /// `false` (serial / `BY DEFAULT`) keeps the permissive path. In-memory
1592 /// only for now — not yet in the catalog appendix, so a reloaded table
1593 /// deserialises as `false` (the pre-existing permissive behaviour).
1594 pub identity_always: bool,
1595 /// v7.38 (read01) — the DEFAULT expression's source text, deparsed to
1596 /// PG-compatible form at CREATE TABLE time (e.g. `0`, `(3 + 4)`,
1597 /// `'hi'::text`, `now()`, `CURRENT_DATE`). Distinct from `default`
1598 /// (the coerced value the INSERT path fills) and `runtime_default`
1599 /// (the recompute-per-row Display form): those lose the source
1600 /// spelling, so `information_schema.columns.column_default` /
1601 /// `pg_attrdef` / `pg_get_expr` reported the coerced render
1602 /// (`0.00` for `numeric(10,2) DEFAULT 0`) instead of PG's `0`.
1603 /// `None` for a column with no explicit default. Persisted in catalog
1604 /// FILE_VERSION 58+; older catalogs deserialise with None.
1605 pub default_text: Option<String>,
1606 /// v7.39 (round 220) — `ALTER TABLE … ALTER COLUMN … RESTART [WITH n]`
1607 /// on an identity column. SPG's identity allocation is a max+1 scan;
1608 /// this floor lifts the next allocated value to at least `n`
1609 /// (`max(max+1, n)`) — exactly what a dump-restore RESTART needs, and
1610 /// safer than PG for a backward RESTART (no duplicate-key landmine).
1611 /// Persisted in the FILE_VERSION 73+ sparse appendix; older catalogs
1612 /// deserialise with None.
1613 pub auto_restart: Option<i64>,
1614 /// v7.39 (read01 round 78) — this column is the ONLY column of a FROM item
1615 /// that calls a function returning a BASE type, so the item's row type IS
1616 /// this column: a whole-row reference collapses to the value
1617 /// (`SELECT j FROM jsonb_array_elements('[1]') AS j` → `1`, PG). Runtime
1618 /// only — a catalogued table column is never one, and it is not persisted.
1619 pub scalar_row_source: bool,
1620 /// v7.39 (round 386, type-fidelity epic P1) — the declared MySQL narrow
1621 /// integer width (TINYINT / MEDIUMINT) whose range the storage `ty`
1622 /// (SmallInt / Int) is too wide to enforce. `None` for every other
1623 /// column. Drives the epic-P2 write-path range check. Persisted in the
1624 /// FILE_VERSION 81+ sparse appendix; older catalogs deserialise as None.
1625 pub mysql_int_width: Option<MysqlIntWidth>,
1626 /// v7.39 (round 424, type-fidelity epic) — the declared MySQL
1627 /// fractional-seconds precision of a temporal column: `DATETIME(3)` is
1628 /// `Some(3)`, a BARE `DATETIME` / `TIME` / `TIMESTAMP` is `Some(0)`
1629 /// (MySQL's default is zero — the fraction is dropped on write), and
1630 /// `None` means "not a MySQL-declared temporal column", which is every
1631 /// PG column and leaves microsecond behaviour untouched.
1632 ///
1633 /// Drives write-path truncation (toward zero) and render padding
1634 /// (exactly this many digits, `.000` when the fraction is zero).
1635 /// Persisted in the FILE_VERSION 82+ sparse appendix; older catalogs
1636 /// deserialise as None.
1637 pub mysql_fsp: Option<u8>,
1638}
1639
1640/// v7.17.0 Phase 2.5 — column-level text collation. Drives the
1641/// engine's WHERE / GROUP BY equality routing for `Value::Text`.
1642/// Only two variants are modelled in v7.17:
1643/// * `Binary` — byte-wise comparison (the SPG default;
1644/// matches PG `COLLATE "C"` / `pg_catalog.default`
1645/// and MySQL `*_bin`).
1646/// * `CaseInsensitive` — ASCII case-folded comparison (like
1647/// MySQL `*_ci` collations; PG has NO built-in
1648/// collation of this name — round-761 audit: a
1649/// nondeterministic ICU collation must be CREATEd
1650/// there first). Non-ASCII bytes
1651/// still compare byte-wise; full ICU folding is
1652/// out of v7.17 scope.
1653/// New variants append at the end — older catalogs read missing
1654/// columns as `Binary`.
1655#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1656pub enum Collation {
1657 Binary,
1658 CaseInsensitive,
1659}
1660
1661/// v7.39 (round 386, type-fidelity epic P1) — the declared MySQL narrow
1662/// integer type for a column whose storage `DataType` cannot express it.
1663/// MySQL `TINYINT` (i8, -128..127) collapses to `DataType::SmallInt` (i16)
1664/// and `MEDIUMINT` (24-bit) to `DataType::Int` (i32) — both wider than the
1665/// declared type, so a range check against `ty` alone accepts out-of-range
1666/// values (`INSERT 128 INTO TINYINT` is stored silently where MariaDB
1667/// strict raises ERROR 1264). This annotation records the lost width so the
1668/// write path (epic P2) can enforce the real bounds. `SMALLINT` / `INT` /
1669/// `BIGINT` need no marker — their storage `DataType` is already faithful.
1670/// Sparse: only TINYINT / MEDIUMINT columns carry it; persisted in the
1671/// FILE_VERSION 81+ appendix, older catalogs deserialise as None.
1672#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1673pub enum MysqlIntWidth {
1674 /// MySQL `TINYINT` — signed -128..127, unsigned 0..255. Storage i16.
1675 Tiny,
1676 /// MySQL `SMALLINT UNSIGNED` — 0..65535. Storage widened to i32 (a
1677 /// signed SMALLINT keeps `DataType::SmallInt` and carries no marker).
1678 Small,
1679 /// MySQL `MEDIUMINT` — signed -8388608..8388607, unsigned 0..16777215.
1680 /// Storage i32.
1681 Medium,
1682 /// MySQL `INT UNSIGNED` — 0..4294967295. Storage widened to i64 (a
1683 /// signed INT keeps `DataType::Int` and carries no marker).
1684 Int,
1685 /// v7.39 (round 471, epic P4b) — MySQL `BIGINT UNSIGNED` —
1686 /// 0..18446744073709551615. i64 stops at 2^63-1, so the storage tag is
1687 /// widened to `Numeric` (i128-backed, scale 0), which already compares,
1688 /// orders, indexes and renders as an exact integer. A signed BIGINT
1689 /// keeps `DataType::BigInt` and carries no marker.
1690 Big,
1691}
1692
1693/// v7.39 (round 363, M4 P1) — MySQL's default accent- and
1694/// case-insensitive fold (`utf8mb4_uca1400_ai_ci`).
1695///
1696/// This is the primitive M4 rests on: a session on the MySQL dialect
1697/// compares, groups, sorts and de-duplicates text by its FOLDED form, so
1698/// `Foo` = `foo` = `FOO` and, because the default collation is accent-
1699/// insensitive too, `Bär` = `bar`. The later stages (read path, then the
1700/// UNIQUE / index write path) all route through here so they cannot fold
1701/// differently from one another.
1702///
1703/// The fold is more than case + strip-combining: MariaDB EXPANDS some
1704/// letters — `ß` → `ss`, `æ` → `ae`, `œ` → `oe` — which is why the result
1705/// is built as a `String` rather than mapped char-for-char. Every mapping
1706/// below was measured on MariaDB 11 (`'Bär'='bar'` is 1, `'straße'=
1707/// 'strasse'` is 1, `'a'='æ'` is 0, `'s'='ß'` is 0). Characters with no
1708/// entry keep their lower-cased self, so ASCII and unknown scripts pass
1709/// through unchanged.
1710#[must_use]
1711pub fn mysql_ci_fold(s: &str) -> String {
1712 let mut out = String::with_capacity(s.len());
1713 for ch in s.chars() {
1714 // Lower-case first (`À` → `à`, `Æ` → `æ`), then fold the base.
1715 for lc in ch.to_lowercase() {
1716 match fold_latin_base(lc) {
1717 Some(base) => out.push_str(base),
1718 None => out.push(lc),
1719 }
1720 }
1721 }
1722 out
1723}
1724
1725/// v7.39 (round 375) — the fold used to COMPARE / GROUP / de-dup text on
1726/// the MySQL dialect. Its default collation is PAD SPACE: trailing spaces
1727/// do not affect a comparison (`'a' = 'a '`, `'' = ' '`, measured on
1728/// MariaDB 11), so they are stripped before the case/accent fold. Only
1729/// literal spaces pad — a tab or other whitespace is significant — and
1730/// this is NOT used by `LIKE`, whose pattern treats a trailing space
1731/// literally.
1732pub fn mysql_compare_fold(s: &str) -> String {
1733 mysql_ci_fold(s.trim_end_matches(' '))
1734}
1735
1736/// The base letter(s) a lower-cased Latin character folds to, or `None`
1737/// when it is already a base / has no fold. Expansions (`ß` → `ss`) are
1738/// why this returns a string.
1739fn fold_latin_base(c: char) -> Option<&'static str> {
1740 Some(match c {
1741 'à' | 'á' | 'â' | 'ã' | 'ä' | 'å' | 'ā' | 'ă' | 'ą' => "a",
1742 'æ' => "ae",
1743 'ç' | 'ć' | 'č' | 'ĉ' | 'ċ' => "c",
1744 'ð' | 'ď' | 'đ' => "d",
1745 'è' | 'é' | 'ê' | 'ë' | 'ē' | 'ĕ' | 'ė' | 'ę' | 'ě' => "e",
1746 'ĝ' | 'ğ' | 'ġ' | 'ģ' => "g",
1747 'ì' | 'í' | 'î' | 'ï' | 'ĩ' | 'ī' | 'ĭ' | 'į' => "i",
1748 'ĵ' => "j",
1749 'ķ' => "k",
1750 'ł' | 'ĺ' | 'ļ' | 'ľ' => "l",
1751 'ñ' | 'ń' | 'ņ' | 'ň' => "n",
1752 'ò' | 'ó' | 'ô' | 'õ' | 'ö' | 'ø' | 'ō' | 'ŏ' | 'ő' => "o",
1753 'œ' => "oe",
1754 'ŕ' | 'ŗ' | 'ř' => "r",
1755 'ś' | 'š' | 'ŝ' | 'ş' => "s",
1756 'ß' => "ss",
1757 'ţ' | 'ť' | 'ŧ' => "t",
1758 'ù' | 'ú' | 'û' | 'ü' | 'ũ' | 'ū' | 'ŭ' | 'ů' | 'ű' | 'ų' => "u",
1759 'ý' | 'ÿ' => "y",
1760 'ź' | 'ž' | 'ż' => "z",
1761 _ => return None,
1762 })
1763}
1764
1765#[allow(clippy::derivable_impls)]
1766impl Default for Collation {
1767 fn default() -> Self {
1768 Self::Binary
1769 }
1770}
1771
1772impl Collation {
1773 /// Wire tag persisted in the FILE_VERSION 34+ catalog appendix.
1774 /// Stable: future variants append above the recognised range
1775 /// and unknown tags read back as `Binary` for forward-compat
1776 /// on rollback.
1777 pub const TAG_BINARY: u8 = 0;
1778 pub const TAG_CASE_INSENSITIVE: u8 = 1;
1779}
1780
1781/// v7.39 (RLS) — the command a policy applies to. `ALL` is the default and
1782/// covers every command; the others scope the policy to one statement kind.
1783/// Persisted as a single byte in the policy appendix (FILE_VERSION 59+).
1784#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1785pub enum PolicyCmd {
1786 All,
1787 Select,
1788 Insert,
1789 Update,
1790 Delete,
1791}
1792
1793impl PolicyCmd {
1794 /// PG `pg_policy.polcmd` single-char encoding.
1795 #[must_use]
1796 pub const fn as_pg_char(self) -> char {
1797 match self {
1798 Self::All => '*',
1799 Self::Select => 'r',
1800 Self::Insert => 'a',
1801 Self::Update => 'w',
1802 Self::Delete => 'd',
1803 }
1804 }
1805
1806 /// PG `pg_policies.cmd` word form.
1807 #[must_use]
1808 pub const fn as_pg_word(self) -> &'static str {
1809 match self {
1810 Self::All => "ALL",
1811 Self::Select => "SELECT",
1812 Self::Insert => "INSERT",
1813 Self::Update => "UPDATE",
1814 Self::Delete => "DELETE",
1815 }
1816 }
1817
1818 #[must_use]
1819 pub const fn to_wire_byte(self) -> u8 {
1820 match self {
1821 Self::All => 0,
1822 Self::Select => 1,
1823 Self::Insert => 2,
1824 Self::Update => 3,
1825 Self::Delete => 4,
1826 }
1827 }
1828
1829 #[must_use]
1830 pub const fn from_wire_byte(b: u8) -> Option<Self> {
1831 match b {
1832 0 => Some(Self::All),
1833 1 => Some(Self::Select),
1834 2 => Some(Self::Insert),
1835 3 => Some(Self::Update),
1836 4 => Some(Self::Delete),
1837 _ => None,
1838 }
1839 }
1840}
1841
1842/// v7.39 (RLS) — one `CREATE POLICY` object, stored per table. The `using_expr`
1843/// / `with_check_expr` hold the qualifying expression's `Display` form
1844/// (re-parsed and evaluated per row at enforcement time, exactly like
1845/// `TableSchema.checks`); `None` means the clause was absent. `roles` empty =
1846/// PUBLIC. Persisted in the policy appendix (FILE_VERSION 59+).
1847#[derive(Debug, Clone, PartialEq)]
1848pub struct PolicyDef {
1849 pub name: String,
1850 pub cmd: PolicyCmd,
1851 /// `true` = PERMISSIVE (default, OR-combined), `false` = RESTRICTIVE
1852 /// (AND-combined).
1853 pub permissive: bool,
1854 pub roles: Vec<String>,
1855 pub using_expr: Option<String>,
1856 pub with_check_expr: Option<String>,
1857}
1858
1859#[derive(Debug, Clone, PartialEq)]
1860pub struct TableSchema {
1861 pub name: String,
1862 pub columns: Vec<ColumnSchema>,
1863 /// v6.7.2 — per-table hot-tier byte budget override. `None`
1864 /// falls through to the global `SPG_HOT_TIER_BYTES` setting;
1865 /// `Some(n)` overrides it for this specific table. Set via
1866 /// `ALTER TABLE t SET hot_tier_bytes = X`. Persisted in
1867 /// catalog FILE_VERSION 11+.
1868 pub hot_tier_bytes: Option<u64>,
1869 /// v7.6.1 — FOREIGN KEY constraints declared on this table.
1870 /// Engine maintains this in lock-step with `spg-sql`'s parser
1871 /// AST; the storage layer carries the on-disk shape so a
1872 /// catalog snapshot round-trips without external mapping.
1873 /// Persisted in catalog FILE_VERSION 13+. Older catalogs
1874 /// deserialise with an empty vec.
1875 pub foreign_keys: Vec<ForeignKeyConstraint>,
1876 /// v7.9.19 — composite UNIQUE / PRIMARY KEY constraints
1877 /// declared at the table level. Each entry's leading column
1878 /// has a BTree index (created via the constraint), and INSERT
1879 /// path enforces the full-tuple uniqueness via a scan keyed
1880 /// by the leading column. Persisted in catalog FILE_VERSION
1881 /// 15+. Older catalogs (≤ 14) deserialise with an empty vec.
1882 pub uniqueness_constraints: Vec<UniquenessConstraint>,
1883 /// v7.39 (round 210) — `EXCLUDE` constraints declared at the table level.
1884 /// Enforced on INSERT/UPDATE by a full live-row scan re-checking each
1885 /// element's operator (no equality index can answer overlap). Persisted
1886 /// in catalog FILE_VERSION 72+; older catalogs deserialise with an empty
1887 /// vec.
1888 pub exclusion_constraints: Vec<ExclusionConstraint>,
1889 /// v7.13.0 — `CHECK (<expr>)` predicates declared on this
1890 /// table. Both column-level inline `CHECK (…)` and
1891 /// table-level `CHECK (…)` fold into this list. Each entry
1892 /// is the AST Expr's `Display` form, re-parsed on every
1893 /// INSERT/UPDATE and evaluated against the candidate row.
1894 /// A false / NULL result rejects the mutation (PG semantics).
1895 /// Persisted in catalog FILE_VERSION 23+. Older catalogs
1896 /// deserialise with an empty vec. v7.39 (read01 round 48) — each entry
1897 /// now carries the user's constraint name too (FILE_VERSION 60+).
1898 pub checks: Vec<CheckConstraint>,
1899 /// v7.37.6-B — declarative partition role(sentori Epic 2 P0).
1900 /// `None` = 普通表(后向兼容,< v49 catalog 默认 None)。
1901 /// `Some(Parent { … })` = `CREATE TABLE p (...) PARTITION BY RANGE (key_col)` 父表 —
1902 /// 父表自己 `rows` 永远空,INSERT 在引擎层路由到命中的 child。
1903 /// `Some(Range { … })` = `CREATE TABLE c PARTITION OF p FOR VALUES FROM (a) TO (b)` 范围子表。
1904 /// `Some(Default { … })` = `CREATE TABLE c PARTITION OF p DEFAULT` 兜底子表。
1905 /// 持久化于 FILE_VERSION 49+。
1906 pub partition_role: Option<PartitionRole>,
1907 /// v7.39 (RLS) — `CREATE POLICY` objects on this table, independent of the
1908 /// `row_security` flag (PG stores policies even on non-RLS tables; they
1909 /// only take effect once RLS is enabled). Persisted in the policy appendix
1910 /// (FILE_VERSION 59+). Older catalogs deserialise with an empty vec.
1911 pub policies: Vec<PolicyDef>,
1912 /// v7.39 (RLS) — `ALTER TABLE … ENABLE ROW LEVEL SECURITY`
1913 /// (PG `pg_class.relrowsecurity`). Fresh table = `false`.
1914 pub row_security: bool,
1915 /// v7.39 (RLS) — `ALTER TABLE … FORCE ROW LEVEL SECURITY`
1916 /// (PG `pg_class.relforcerowsecurity`); subjects the table owner to RLS
1917 /// too. Fresh table = `false`.
1918 pub force_row_security: bool,
1919 /// v7.39 (read01 round 57, ACL) — the role that owns this table: whoever
1920 /// ran CREATE TABLE (PG `pg_class.relowner`). The owner holds every
1921 /// privilege implicitly and is the only role that may ALTER / DROP it.
1922 /// `None` = an image written before FILE_VERSION 64, which predates roles
1923 /// entirely; those tables read back as owned by the login role.
1924 pub owner: Option<String>,
1925 /// v7.39 (read01 round 57, ACL) — explicit GRANTs on this table
1926 /// (PG `pg_class.relacl`). EMPTY means "never granted": PG leaves relacl
1927 /// NULL while only the owner's implicit privileges apply, and materialises
1928 /// the whole list — owner's default entry included — on the first GRANT.
1929 /// Once materialised it stays, even after every grant is revoked.
1930 pub acl: Vec<AclItem>,
1931}
1932
1933/// v7.39 (read01 round 57) — one PG `aclitem`: what `grantee` may do to a
1934/// table, and who granted it. Renders as `grantee=privs/grantor`, with an
1935/// EMPTY grantee meaning PUBLIC (`=r/owner`).
1936#[derive(Debug, Clone, PartialEq, Eq)]
1937pub struct AclItem {
1938 /// The role the privileges are held by. Empty string = PUBLIC.
1939 pub grantee: String,
1940 /// Bitmask over `priv_bits`: which privileges are held.
1941 pub privs: u16,
1942 /// Bitmask over `priv_bits`: which of them carry WITH GRANT OPTION
1943 /// (PG renders those with a trailing `*` — `r*`).
1944 pub grantable: u16,
1945 /// The role that ran the GRANT.
1946 pub grantor: String,
1947}
1948
1949/// v7.39 (read01 round 57) — the table-privilege bits, in PG's `aclitem`
1950/// rendering order (`arwdDxtm`). The order matters: `relacl` output is
1951/// byte-compared against PG.
1952pub mod priv_bits {
1953 pub const INSERT: u16 = 1 << 0; // a
1954 pub const SELECT: u16 = 1 << 1; // r
1955 pub const UPDATE: u16 = 1 << 2; // w
1956 pub const DELETE: u16 = 1 << 3; // d
1957 pub const TRUNCATE: u16 = 1 << 4; // D
1958 pub const REFERENCES: u16 = 1 << 5; // x
1959 pub const TRIGGER: u16 = 1 << 6; // t
1960 pub const MAINTAIN: u16 = 1 << 7; // m
1961 /// v7.39 (read01 round 60) — the non-table privileges. They share the
1962 /// bitmask because an aclitem is an aclitem whatever it hangs off; which
1963 /// bits are MEANINGFUL depends on the object (a sequence has r / w / U, a
1964 /// schema has U / C, a database has C / c / T).
1965 pub const USAGE: u16 = 1 << 8; // U
1966 pub const CREATE: u16 = 1 << 9; // C
1967 pub const CONNECT: u16 = 1 << 10; // c
1968 pub const TEMPORARY: u16 = 1 << 11; // T
1969 pub const EXECUTE: u16 = 1 << 12; // X
1970 /// Every TABLE privilege — what `GRANT ALL ON <table>` grants and what a
1971 /// table's owner holds.
1972 pub const ALL: u16 =
1973 INSERT | SELECT | UPDATE | DELETE | TRUNCATE | REFERENCES | TRIGGER | MAINTAIN;
1974 /// `GRANT ALL ON SEQUENCE` — PG renders a sequence owner's default as `rwU`.
1975 pub const ALL_SEQUENCE: u16 = SELECT | UPDATE | USAGE;
1976 /// `GRANT ALL ON SCHEMA` — `UC`.
1977 pub const ALL_SCHEMA: u16 = USAGE | CREATE;
1978 /// `GRANT ALL ON DATABASE` — `CTc`.
1979 pub const ALL_DATABASE: u16 = CREATE | CONNECT | TEMPORARY;
1980 /// `GRANT ALL ON FUNCTION` — just `X`.
1981 pub const ALL_FUNCTION: u16 = EXECUTE;
1982}
1983
1984/// v7.37.6-B — partition 三态(parent / range child / default child)。
1985#[derive(Debug, Clone, PartialEq, Eq)]
1986pub enum PartitionRole {
1987 Parent {
1988 kind: PartitionKind,
1989 /// 父表 columns 中 key 列的下标(单列 v7.37.6-B,
1990 /// `Vec` 为将来扩多列预留)。
1991 key_column_positions: Vec<usize>,
1992 /// `CREATE INDEX ON parent (…)` 的 Display-form 源串。
1993 /// child 创建时再 parse + 在 child 上 execute,这样 future
1994 /// child 也自动继承父表索引。fan-out 实施在引擎层。
1995 index_template_sources: Vec<String>,
1996 },
1997 Range {
1998 parent_name: String,
1999 /// 半开区间下界(`>=`,SQL `FROM (lower)`).
2000 lower: PartitionBound,
2001 /// 半开区间上界(`<`,SQL `TO (upper)`).
2002 upper: PartitionBound,
2003 },
2004 /// v7.37.16 (16.1) — LIST child:行属于本 child iff key ∈ values。
2005 /// `values` 在 child 创建时从 SQL `FOR VALUES IN (lit, …)` 求值;
2006 /// 跟 PG 一样,显式 NULL ∈ values 由 caller 单独处理(不在
2007 /// PartitionBound 内表达 NULL)。
2008 List {
2009 parent_name: String,
2010 values: Vec<PartitionBound>,
2011 },
2012 /// v7.39 (round 645) — PG 表继承的 CHILD:`CREATE TABLE c (…)
2013 /// INHERITS (p1, p2)`。跟分区 child 的三个本质区别(实测 PG18):
2014 /// * 父表**自己有行**(分区父表永远空),所以父表的联合体要含自身;
2015 /// * `INSERT INTO 父表` **不路由**到 child(分区会路由);
2016 /// * `DROP TABLE 父表` 不带 CASCADE **报错**(分区父表连子表一起删)。
2017 /// 多父继承合法,故 `parent_names` 是 Vec;`pg_inherits.inhseqno`
2018 /// 正是父表在这个列表里的位置(1-based)。
2019 Inherits {
2020 parent_names: Vec<String>,
2021 },
2022 /// v7.37.16 (16.2) — HASH child:行属于本 child iff
2023 /// `pg_compatible_hash(key) mod modulus == remainder`。
2024 /// PG 强制 `0 ≤ remainder < modulus`;parser/DDL 层先 gate。
2025 Hash {
2026 parent_name: String,
2027 modulus: u32,
2028 remainder: u32,
2029 },
2030 Default {
2031 parent_name: String,
2032 },
2033}
2034
2035/// v7.37.6-B — 分区策略。
2036///
2037/// - `Range`:半开区间 `[lower, upper)`(v7.37.6-B 初始)
2038/// - `List` (v7.37.16):枚举集合 — 行属于 partition iff key ∈ children list
2039/// - `Hash` (v7.37.16):`hash(key) mod modulus == remainder`
2040#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2041pub enum PartitionKind {
2042 Range,
2043 List,
2044 Hash,
2045}
2046
2047/// v7.37.6-B — partition 边界 literal。
2048///
2049/// v7.37.6-B 仅 `TimestampTz`(i64 microseconds since epoch);
2050/// v7.37.16 (16.6) 加全 PG 内建可比类型,匹配 `Value` 的对应 variant
2051/// 以避免 LIST membership 比较时的类型转换。
2052///
2053/// `MinValue` / `MaxValue` 对应 SQL `MINVALUE` / `MAXVALUE`,仅
2054/// Range 策略有意义(LIST 无 minvalue/maxvalue 概念,HASH 不
2055/// 使用 PartitionBound)。
2056#[derive(Debug, Clone, PartialEq, Eq)]
2057pub enum PartitionBound {
2058 MinValue,
2059 MaxValue,
2060 TimestampTz(i64),
2061 /// v7.37.16 (16.6) — BIGINT partition key.
2062 BigInt(i64),
2063 /// v7.37.16 (16.6) — INTEGER partition key (also covers
2064 /// `SERIAL` since SPG decomposes it to INTEGER + sequence).
2065 Int(i32),
2066 /// v7.37.16 (16.6) — SMALLINT partition key.
2067 SmallInt(i16),
2068 /// v7.37.16 (16.6) — DATE partition key. Stored as days
2069 /// since the Unix epoch (matches `Value::Date`).
2070 Date(i32),
2071 /// v7.37.16 (16.6) — TEXT / VARCHAR partition key.
2072 Text(alloc::string::String),
2073}
2074
2075impl PartitionBound {
2076 /// v7.37.16 (16.6) — true iff this bound's underlying value
2077 /// equals `other`'s. Used for LIST partition membership
2078 /// checks. Returns false for `MinValue` / `MaxValue`
2079 /// (sentinels — never literal equality).
2080 #[must_use]
2081 pub fn equals_value(&self, other: &Value<'_>) -> bool {
2082 match (self, other) {
2083 (PartitionBound::TimestampTz(a), Value::Timestamp(b)) => a == b,
2084 (PartitionBound::BigInt(a), Value::BigInt(b)) => a == b,
2085 (PartitionBound::Int(a), Value::Int(b)) => a == b,
2086 (PartitionBound::SmallInt(a), Value::SmallInt(b)) => a == b,
2087 (PartitionBound::Date(a), Value::Date(b)) => a == b,
2088 (PartitionBound::Text(a), Value::Text(b)) => a.as_str() == b.as_ref(),
2089 _ => false,
2090 }
2091 }
2092}
2093
2094/// v7.9.19 — composite UNIQUE / PRIMARY KEY constraint persisted
2095/// on the table schema. The leading column always has a BTree
2096/// index (created at CREATE TABLE time); INSERT enforcement
2097/// scans that index for collisions on the full column tuple.
2098/// v7.39 (read01 round 48) — a `CHECK` constraint: the SQL name the user
2099/// gave it (via `ADD CONSTRAINT <name> CHECK (...)` or the inline
2100/// `CONSTRAINT <name> CHECK (...)` form) plus the predicate source. `None`
2101/// name = unnamed, in which case `pg_constraint` synthesises PG's
2102/// `<table>_<col>_check` form. Names are persisted in the constraint-name
2103/// appendix (FILE_VERSION 60+); older catalogs deserialise with `None`.
2104#[derive(Debug, Clone, PartialEq, Eq)]
2105pub struct CheckConstraint {
2106 pub name: Option<String>,
2107 /// The AST Expr's `Display` form, re-parsed on every INSERT/UPDATE.
2108 pub expr: String,
2109 /// v7.39 (round 652) — `false` for a constraint added `NOT VALID`: the
2110 /// rows already in the table were never scanned against it, and
2111 /// `pg_constraint.convalidated` says so. It does NOT weaken the check on
2112 /// new rows — INSERT and UPDATE enforce it either way, as in PG.
2113 /// `VALIDATE CONSTRAINT` does the deferred scan and flips it. Persisted
2114 /// by the FILE_VERSION 87 appendix; older catalogs deserialise as `true`,
2115 /// which is what every constraint they could hold actually was.
2116 pub validated: bool,
2117}
2118
2119#[derive(Debug, Clone, PartialEq, Eq)]
2120pub struct UniquenessConstraint {
2121 /// `true` when this constraint was declared as `PRIMARY KEY`
2122 /// (vs `UNIQUE`). Semantically PK implies NOT NULL on all
2123 /// referenced columns; the engine enforces that at CREATE
2124 /// TABLE time.
2125 pub is_primary_key: bool,
2126 /// Column positions on the parent table. ≥ 1 element. For
2127 /// single-column UNIQUE this is exactly one position; the
2128 /// BTree index alone enforces it.
2129 pub columns: Vec<usize>,
2130 /// v7.13.0 — `UNIQUE NULLS NOT DISTINCT` modifier
2131 /// (mailrs round-5 G10; PG 15+ surface). When `true`, two
2132 /// rows whose constrained columns are all NULL collide on
2133 /// the constraint. Default (`false`) is the SQL-standard
2134 /// `NULLS DISTINCT` behaviour where any NULL passes.
2135 /// Persisted in catalog FILE_VERSION 23+.
2136 pub nulls_not_distinct: bool,
2137 /// v7.39 (read01 round 48) — the constraint's SQL name when the user
2138 /// supplied one (`ADD CONSTRAINT <name> PRIMARY KEY/UNIQUE (...)`, or
2139 /// the inline `CONSTRAINT <name>` form). `None` = unnamed, in which
2140 /// case `pg_constraint` synthesises PG's `<table>_pkey` /
2141 /// `<table>_<col>_key` form. DROP CONSTRAINT resolves the stored name
2142 /// first and falls back to the synthesised one, so catalogs written
2143 /// before this field (< FILE_VERSION 60) keep working unchanged.
2144 pub name: Option<String>,
2145 /// v7.39 (round 711) — `[NOT] DEFERRABLE`. Round 621 taught the parser
2146 /// to CONSUME the clause on PK/UNIQUE (the FK path had stored it since
2147 /// round 288); this is the storing half. Persisted in the v89 timing
2148 /// appendix.
2149 pub deferrable: bool,
2150 /// `INITIALLY DEFERRED`: the check belongs to COMMIT, not the
2151 /// statement, unless `SET CONSTRAINTS … IMMEDIATE` pulls it in.
2152 pub initially_deferred: bool,
2153}
2154
2155/// v7.39 (round 210) — an `EXCLUDE` constraint. Forbids two distinct live
2156/// rows from satisfying, for EVERY element, `new.col <op> existing.col`
2157/// (e.g. `EXCLUDE USING gist (during WITH &&)` = no two `during` ranges
2158/// overlap). Unlike a uniqueness constraint the operator is not equality,
2159/// so enforcement is a full live-row scan re-checking the operator (a real
2160/// GiST index that answers overlap in O(log n) is a later perf phase). A
2161/// NULL in any element column exempts the row (matching PG / UNIQUE NULL
2162/// semantics). Persisted in catalog FILE_VERSION 72+.
2163#[derive(Debug, Clone, PartialEq, Eq)]
2164pub struct ExclusionConstraint {
2165 /// The constraint's SQL name. PG auto-names an unnamed EXCLUDE
2166 /// `<table>_<leading-col>_excl`; the engine synthesises that at CREATE
2167 /// TABLE time so this is always populated.
2168 pub name: String,
2169 /// Access method spelled after `USING` (`gist`, `spgist`, …), lower-cased.
2170 /// `None` = no `USING` clause. Purely cosmetic for enforcement; it round-
2171 /// trips into `pg_get_constraintdef`.
2172 pub method: Option<String>,
2173 /// One `(column-position, operator-spelling)` pair per element, in
2174 /// declaration order. The operator spelling is the wire token (`&&`,
2175 /// `=`, `@>`, `<@`, `&<`, `&>`) evaluated against each existing row.
2176 pub elements: Vec<(usize, String)>,
2177}
2178
2179/// v7.6.1 — Storage-layer mirror of `spg_sql::ast::ForeignKeyConstraint`.
2180/// The engine's CREATE TABLE path translates between the two; keeping
2181/// them separate preserves the no-deps boundary between
2182/// `spg-storage` and `spg-sql`.
2183#[derive(Debug, Clone, PartialEq, Eq)]
2184pub struct ForeignKeyConstraint {
2185 /// Optional user-supplied constraint name (`CONSTRAINT <name>`
2186 /// prefix). Used by `ALTER TABLE DROP CONSTRAINT <name>` in
2187 /// v7.6.8; ignored by enforcement.
2188 pub name: Option<String>,
2189 /// Positions of local columns in this table's column list.
2190 /// Same arity as `parent_columns`.
2191 pub local_columns: Vec<usize>,
2192 /// Referenced parent table name.
2193 pub parent_table: String,
2194 /// Positions of parent columns in the parent's column list.
2195 /// Engine resolves these at CREATE TABLE time (after the parent
2196 /// schema is known) so enforcement paths can skip the name
2197 /// lookup on every row.
2198 pub parent_columns: Vec<usize>,
2199 /// Referential action when a parent row is deleted.
2200 pub on_delete: FkAction,
2201 /// Referential action when a parent row's referenced columns
2202 /// are updated.
2203 pub on_update: FkAction,
2204 /// v7.38 (read01, T29) — `MATCH SIMPLE | FULL`. Defaults to `Simple`.
2205 pub match_type: MatchType,
2206 /// v7.39 (round 288) — `[NOT] DEFERRABLE`.
2207 pub deferrable: bool,
2208 /// `INITIALLY DEFERRED`: the check runs at COMMIT rather than at
2209 /// the statement, unless `SET CONSTRAINTS … IMMEDIATE` pulls it in.
2210 pub initially_deferred: bool,
2211}
2212
2213/// v7.38 (read01, T29) — FK MATCH type. Mirrors `spg_sql::ast::MatchType`.
2214#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
2215pub enum MatchType {
2216 #[default]
2217 Simple,
2218 Full,
2219}
2220
2221impl MatchType {
2222 /// On-disk tag byte (catalog appendix, `FILE_VERSION` 55+).
2223 pub const fn tag(self) -> u8 {
2224 match self {
2225 Self::Simple => 0,
2226 Self::Full => 1,
2227 }
2228 }
2229 pub const fn from_tag(b: u8) -> Option<Self> {
2230 Some(match b {
2231 0 => Self::Simple,
2232 1 => Self::Full,
2233 _ => return None,
2234 })
2235 }
2236}
2237
2238/// v7.6.1 — referential action tag. Mirrors `spg_sql::ast::FkAction`.
2239#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2240pub enum FkAction {
2241 Restrict,
2242 Cascade,
2243 SetNull,
2244 SetDefault,
2245 NoAction,
2246}
2247
2248impl FkAction {
2249 /// On-disk tag byte (v13 catalog appendix).
2250 pub const fn tag(self) -> u8 {
2251 match self {
2252 Self::Restrict => 0,
2253 Self::Cascade => 1,
2254 Self::SetNull => 2,
2255 Self::SetDefault => 3,
2256 Self::NoAction => 4,
2257 }
2258 }
2259 pub const fn from_tag(b: u8) -> Option<Self> {
2260 Some(match b {
2261 0 => Self::Restrict,
2262 1 => Self::Cascade,
2263 2 => Self::SetNull,
2264 3 => Self::SetDefault,
2265 4 => Self::NoAction,
2266 _ => return None,
2267 })
2268 }
2269}
2270
2271impl TableSchema {
2272 pub fn column_position(&self, name: &str) -> Option<usize> {
2273 self.columns.iter().position(|c| c.name == name)
2274 }
2275}
2276
2277/// Key type accepted by secondary indices. Float / NULL / Vector values
2278/// can't participate in a B-tree index — `f64` is only `PartialOrd`, NULL
2279/// has SQL-three-valued semantics, and Vector belongs to the (future) HNSW
2280/// path. Index lookups on those columns fall back to full scan.
2281#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
2282pub enum IndexKey {
2283 Int(i64),
2284 Text(String),
2285 Bool(bool),
2286 /// v7.17.0 — `Value::Uuid` index key. Comparison is byte-wise
2287 /// (RFC 4122 byte order) so PRIMARY KEY UUID lookups land on
2288 /// the same fast-path as Int / Text.
2289 Uuid([u8; 16]),
2290}
2291
2292impl IndexKey {
2293 /// v7.37.43 (INSUBQ B-4) — inline-friendly BigInt fast path.
2294 /// `try_count_star_pk_in_subquery_fast` (and any other hot loop
2295 /// probing an integer PK) already holds an `i64`; this builds the
2296 /// `IndexKey` without going through the generic `from_value`
2297 /// dispatch tree.
2298 #[inline]
2299 pub fn from_i64(n: i64) -> Self {
2300 Self::Int(n)
2301 }
2302
2303 pub fn from_value(v: &Value<'_>) -> Option<Self> {
2304 match v {
2305 // v7.37.43 (INSUBQ B-4) — BigInt hits first (the dominant
2306 // INSUBQ shape probes PK as BigInt). Tiny micro-win.
2307 Value::BigInt(n) => Some(Self::Int(*n)),
2308 Value::SmallInt(n) => Some(Self::Int(i64::from(*n))),
2309 Value::Int(n) => Some(Self::Int(i64::from(*n))),
2310 Value::Text(s) => Some(Self::Text(s.clone().into_owned())),
2311 // v7.38 (read01, T11) — bpchar keys compare blank-insensitively.
2312 Value::BpChar(s) => Some(Self::Text(s.trim_end_matches(' ').to_string())),
2313 Value::Bool(b) => Some(Self::Bool(*b)),
2314 // Date/Timestamp use their integer storage repr as the
2315 // index key — same order semantics, same comparison.
2316 Value::Date(d) => Some(Self::Int(i64::from(*d))),
2317 Value::Timestamp(t) => Some(Self::Int(*t)),
2318 // v7.17.0: UUID indexable via byte-wise ordering. Lookup
2319 // on `id = '...'::uuid` resolves through the secondary
2320 // index rather than full-scan.
2321 Value::Uuid(b) => Some(Self::Uuid(*b)),
2322 // v7.17.0 Phase 3.P0-32: TIME indexable via i64 — same
2323 // order semantics as Date/Timestamp.
2324 Value::Time(us) => Some(Self::Int(*us)),
2325 // v7.17.0 Phase 3.P0-33: YEAR indexable as i64 — u16
2326 // widens losslessly and gives the natural calendar
2327 // ordering.
2328 Value::Year(y) => Some(Self::Int(i64::from(*y))),
2329 // v7.17.0 Phase 3.P0-34: TIMETZ indexable by its
2330 // UTC-equivalent microseconds (local wall - offset).
2331 // Without normalising, two values for the same
2332 // physical instant in different zones would sort
2333 // wrong. Matches PG's TIMETZ index behaviour.
2334 Value::TimeTz { us, offset_secs } => {
2335 Some(Self::Int(us - i64::from(*offset_secs) * 1_000_000))
2336 }
2337 // v7.17.0 Phase 3.P0-35: MONEY indexable as i64 cents
2338 // (no scaling needed — natural numeric ordering).
2339 Value::Money(c) => Some(Self::Int(*c)),
2340 // v7.17.0 Phase 3.P0-38: ranges are NOT indexable in
2341 // v7.17.0 — they'd need a custom comparator (PG uses
2342 // SP-GiST for this). Skip.
2343 Value::Range { .. } => None,
2344 // v7.17.0 Phase 3.P0-39: hstore is NOT indexable in
2345 // v7.17.0 — map columns need GIN with bespoke ops.
2346 Value::Hstore(_) => None,
2347 Value::NumericBig(_) => None,
2348 // v7.17.0 Phase 3.P0-40: 2D arrays aren't indexable.
2349 Value::IntArray2D(_)
2350 | Value::BigIntArray2D(_)
2351 | Value::TextArray2D(_)
2352 | Value::BoolArray2D(_) => None,
2353 // v7.37.5 β-P4: INTERVAL[] isn't indexable (PG uses
2354 // GIN/intarray for array-contains queries; SPG plans
2355 // that as a separate axis under v7.37.8 GIN-on-jsonb).
2356 Value::IntervalArray(_) => None,
2357 // v7.37.5 γ — none of the array-of-scalar family is
2358 // B-tree indexable. Same reason as IntervalArray: PG
2359 // serves array-contains / array-overlap queries via
2360 // GIN, and SPG's GIN axis lands in v7.37.8.
2361 Value::BoolArray(_)
2362 | Value::SmallIntArray(_)
2363 | Value::FloatArray(_)
2364 | Value::NumericArray(_)
2365 | Value::DateArray(_)
2366 | Value::TimestampArray(_)
2367 | Value::TimestamptzArray(_)
2368 | Value::UuidArray(_)
2369 | Value::JsonArray(_)
2370 | Value::JsonbArray(_)
2371 | Value::BytesArray(_)
2372 | Value::VarcharArray(_)
2373 | Value::CharArray(_)
2374 // v7.37.5 δ — multirange not indexable (PG uses GiST/
2375 // SP-GiST + a custom operator class; SPG plans the same
2376 // axis under v7.37.8 with ranges).
2377 | Value::Multirange { .. }
2378 // v7.37.5 ε — geometric scalars not B-tree indexable
2379 // (PG uses GiST/SP-GiST for these too; SPG plans the
2380 // same axis under v7.37.8).
2381 | Value::Point(_)
2382 | Value::Lseg(_, _)
2383 | Value::Path { .. }
2384 | Value::PgBox(_, _)
2385 | Value::Polygon(_)
2386 | Value::Line { .. }
2387 | Value::Circle { .. }
2388 // v7.37.5 ζ-A — network / bit / xml / "char" / money[].
2389 // INET / CIDR / MACADDR / MACADDR8 could be B-tree
2390 // indexable (PG does this), but the byte-wise compare
2391 // family-blind would mis-order IPv4 vs IPv6; left as
2392 // a follow-up under v7.37.8 GIN window.
2393 | Value::Inet { .. }
2394 | Value::Cidr { .. }
2395 | Value::Macaddr(_)
2396 | Value::Macaddr8(_)
2397 | Value::PgLsn(_)
2398 | Value::BitString { .. }
2399 | Value::Xml(_)
2400 | Value::Char1(_)
2401 | Value::MoneyArray(_)
2402 | Value::Composite(_)
2403 | Value::Tid(..)
2404 | Value::Xid(_)
2405 | Value::Cid(_)
2406 | Value::RegClass(..)
2407 | Value::RegProc(..)
2408 | Value::RegType(..) => None,
2409 // Numeric isn't (yet) indexable — exact-decimal index keys
2410 // would need a stable scale-normalised representation.
2411 // Interval isn't index-eligible either (and can't reach this
2412 // path through column storage anyway).
2413 Value::Null
2414 | Value::Float(_)
2415 | Value::Vector(_)
2416 | Value::Sq8Vector(_)
2417 | Value::HalfVector(_)
2418 | Value::Numeric { .. }
2419 | Value::Interval { .. }
2420 | Value::Json(_)
2421 | Value::Bytes(_)
2422 | Value::TextArray(_)
2423 | Value::IntArray(_)
2424 | Value::BigIntArray(_)
2425 | Value::TsVector(_)
2426 | Value::TsQuery(_)
2427 | Value::Real(_) => None,
2428 }
2429 }
2430}
2431
2432/// A single-column secondary index. v2.0 carries either a B-tree map
2433/// (the default — used for equality / range lookups on scalar columns)
2434/// or a navigable-small-world graph (used for kNN over vector
2435/// columns).
2436#[derive(Debug, Clone)]
2437pub struct Index {
2438 pub name: String,
2439 pub column_position: usize,
2440 pub kind: IndexKind,
2441 /// v6.8.0 — column positions of `INCLUDE (col1, col2, …)`
2442 /// non-key columns. Carries the planner's "this query is
2443 /// covered by the index" signal; lookup paths still resolve
2444 /// via the `RowLocator` to fetch the row body, but EXPLAIN
2445 /// surfaces the covered-scan annotation so operators can
2446 /// confirm the planner sees the coverage.
2447 ///
2448 /// Empty `Vec` = no `INCLUDE` clause (the legacy shape). v12
2449 /// catalog snapshots deserialise with an empty vec.
2450 pub included_columns: Vec<usize>,
2451 /// v6.8.1 — partial-index predicate stored as its canonical
2452 /// Display form (the engine re-parses it on the maintenance
2453 /// path). `None` = unconditional index (the legacy shape).
2454 /// Persisted as `[u8 has_pred][u16 LE len][bytes]` on the
2455 /// catalog snapshot (FILE_VERSION 12, appended after
2456 /// `included_columns`).
2457 pub partial_predicate: Option<String>,
2458 /// v6.8.2 — expression-index key, stored as the expression's
2459 /// canonical Display form. `None` = bare column-reference
2460 /// index (the legacy shape). Persisted alongside
2461 /// `partial_predicate` on the v12 catalog snapshot.
2462 pub expression: Option<String>,
2463 /// v7.39 (read01 round 52) — `CREATE UNIQUE INDEX … NULLS NOT DISTINCT`
2464 /// (PG 15+): a NULL in the key no longer exempts the row, so two
2465 /// all-NULL keys collide. Default `false` = SQL-standard NULLS DISTINCT.
2466 /// Persisted in the index appendix (FILE_VERSION 62+); older catalogs
2467 /// deserialise with `false`.
2468 pub nulls_not_distinct: bool,
2469 /// v7.39 (round 537) — the key column's ordering clause, as written.
2470 ///
2471 /// SPG's index does not scan in a direction, so this changes no
2472 /// lookup; `pg_indexes.indexdef` is a reproduction of the DDL and
2473 /// dropping the clause made `CREATE INDEX i ON t (a DESC NULLS
2474 /// LAST)` read back as `(a)` — a dump lost it and a schema diff saw
2475 /// drift every run. `nulls_first` is `None` when the statement did
2476 /// not say, in which case PG's default applies and neither word is
2477 /// rendered.
2478 pub descending: bool,
2479 pub nulls_first: Option<bool>,
2480 /// v7.39 (round 538) — an explicit `COLLATE` on the key, as written.
2481 /// SPG orders text by bytes, so it changes no comparison; PG prints
2482 /// it because a named collation and an inherited one are different
2483 /// objects even where they sort identically.
2484 pub collation: Option<String>,
2485 /// v7.9.29 — `CREATE UNIQUE INDEX …`. When true the engine
2486 /// rejects INSERTs whose key already appears in this index
2487 /// (combined with `partial_predicate` when present — only
2488 /// rows matching the predicate enter the uniqueness check).
2489 /// Catalog FILE_VERSION 16+; older snapshots deserialise
2490 /// with `false`. mailrs K1.
2491 pub is_unique: bool,
2492 /// v7.9.29 — extra (non-leading) column positions for
2493 /// multi-column indexes (`CREATE INDEX … (a, b, c)`). The
2494 /// planner today still only uses the leading
2495 /// `column_position` for index seeks, but UNIQUE INDEX
2496 /// enforcement walks the full tuple so partial-unique
2497 /// invariants like CalDAV `(calendar_id, uid,
2498 /// recurrence_id)` are enforced correctly. Catalog
2499 /// FILE_VERSION 16+; older snapshots deserialise empty.
2500 pub extra_column_positions: Vec<usize>,
2501}
2502
2503/// Default neighbor degree (M) for the NSW graph. Picked at construction
2504/// time and persisted with the index.
2505pub const NSW_DEFAULT_M: usize = 16;
2506
2507/// v5.2.2: outcome of a successful [`Catalog::freeze_oldest_to_cold`]
2508/// call. The catalog state has already been mutated by the time this
2509/// is returned (hot rows dropped + segment registered + Cold locators
2510/// flipped). The caller's only remaining concern is `segment_bytes` —
2511/// persist them to disk under `<db>.spg/segments/seg_<id>.spg` so a
2512/// future restart can reload via the v5.1 `SPG_PRELOAD_COLD_SEGMENT`
2513/// path. (v5.3's manifest will subsume this manual step.)
2514#[derive(Debug, Clone)]
2515pub struct FreezeReport {
2516 /// Id allocated by [`Catalog::load_segment_bytes`] for the new
2517 /// cold-tier segment. Stable across the call's success path.
2518 pub segment_id: u32,
2519 /// Number of rows that moved hot → cold. Equals the `max_rows`
2520 /// the caller asked for (the API is strict on the count).
2521 pub frozen_rows: usize,
2522 /// Hot-tier bytes reclaimed by the freeze — the
2523 /// [`Table::hot_bytes`] delta before vs after. Useful to feed
2524 /// back into the freezer's budget check on the next tick.
2525 pub bytes_freed: u64,
2526 /// Encoded segment bytes, byte-identical to what
2527 /// [`encode_segment`] produced. The catalog already owns a
2528 /// copy inside `cold_segments`; this hand-off lets the caller
2529 /// persist them without re-encoding.
2530 pub segment_bytes: Vec<u8>,
2531}
2532
2533/// v6.7.4 — read-only output of [`Catalog::prepare_freeze_slice`].
2534/// Carries every row body + key in a contiguous hot-row range,
2535/// already encoded and sorted by PK so the coordinator's merge
2536/// step is a k-way merge over already-sorted streams.
2537///
2538/// `Vec<FreezeSlice>` from N independent workers feeds
2539/// [`Catalog::commit_freeze_slices`], which concats + encodes the
2540/// merged segment + atomically swaps the catalog state.
2541#[derive(Debug, Clone)]
2542pub struct FreezeSlice {
2543 /// Hot-row index range this slice covered (half-open, in the
2544 /// table's `rows: PersistentVec` ordering at call time). The
2545 /// commit step uses this to compute the union range that
2546 /// gets passed to [`Table::delete_rows`].
2547 pub row_range: core::ops::Range<usize>,
2548 /// `(pk_u64, encoded_row_body, IndexKey)` triples, sorted
2549 /// ascending by `pk_u64`. Per-slice sort happens inside
2550 /// `prepare_freeze_slice`; the coordinator does only a
2551 /// k-way merge to reach the global PK ordering
2552 /// [`encode_segment`] requires.
2553 pub rows: Vec<(u64, Vec<u8>, IndexKey)>,
2554}
2555
2556/// v6.7.3 — outcome of a [`Catalog::compact_cold_segments`] call.
2557/// The catalog state has already been mutated when this is returned:
2558/// the merged segment is loaded into `cold_segments`, the source
2559/// segment slots are tombstoned (`None`), and every BTree-index
2560/// `RowLocator::Cold` that previously pointed at a source now
2561/// points at the merged segment. The caller's remaining job is to
2562/// persist `merged_segment_bytes` under
2563/// `<db>.spg/segments/seg_<merged_segment_id>.spg` and update the
2564/// in-memory `segment_id → path` map (remove the source ids, add
2565/// the merged id) so the next CHECKPOINT writes a manifest that
2566/// no longer lists the retired sources.
2567///
2568/// On a no-op (fewer than 2 candidate segments under the threshold),
2569/// `merged_segment_id` is `None` and `sources` is empty; the
2570/// catalog was not mutated.
2571#[derive(Debug, Clone)]
2572pub struct CompactReport {
2573 /// Source segment ids that were merged + tombstoned.
2574 pub sources: Vec<u32>,
2575 /// Id allocated for the merged segment. `None` on no-op.
2576 pub merged_segment_id: Option<u32>,
2577 /// Encoded merged-segment bytes (empty on no-op).
2578 pub merged_segment_bytes: Vec<u8>,
2579 /// Number of rows that landed in the merged segment.
2580 pub merged_rows: usize,
2581 /// `Σ source.num_rows − merged_rows`. Rows present in source
2582 /// segment payloads but unreferenced by any live BTree
2583 /// `Cold` locator — DELETE'd-but-still-frozen rows that
2584 /// compaction GC'd during the merge.
2585 pub deleted_rows_pruned: usize,
2586 /// `Σ source.bytes() − merged.bytes()`. Estimate of on-disk
2587 /// space the merge will reclaim once the source segment files
2588 /// are GC'd. Saturating subtract — never negative.
2589 pub bytes_reclaimed_estimate: u64,
2590}
2591
2592#[derive(Debug, Clone)]
2593pub enum IndexKind {
2594 /// v4.40: structural-sharing B-tree over `IndexKey`. Replaces the v0.8
2595 /// `BTreeMap<IndexKey, Vec<usize>>` — `Index::clone` is now an `Arc`
2596 /// bump regardless of index size, so `Catalog::clone` inside the
2597 /// v4.34 auto-commit wrap stays O(1) even for tables with secondary
2598 /// indices (the case that bottlenecked v4.39 at 1M rows in the
2599 /// sweep).
2600 ///
2601 /// v5.1: value type widened from `Vec<usize>` to `Vec<RowLocator>` so
2602 /// a single key can point to a mix of hot-tier rows (`RowLocator::Hot`,
2603 /// equivalent to the pre-v5 `usize` row index) and cold-tier rows
2604 /// (`RowLocator::Cold { segment_id, page_offset }`) once the v5.2
2605 /// freezer starts producing them. Pre-v5.2 only `Hot` entries appear
2606 /// — the on-disk encoding stays at `FILE_VERSION` 8 (raw u64 row index)
2607 /// because every locator round-trips through `RowLocator::from_legacy_v8_u64`
2608 /// without information loss. `FILE_VERSION` 9 with tagged encoding lands
2609 /// alongside the first freezer commit (v5.1 step 2b / v5.2).
2610 BTree(PersistentBTreeMap<IndexKey, crate::posting::PostingList>),
2611 /// Navigable-small-world graph for vector kNN search.
2612 Nsw(NswGraph),
2613 /// v6.7.1 — BRIN (Block Range INdex). Pure metadata: BRIN
2614 /// indexes carry NO in-memory key→locator map. The (min,
2615 /// max) summaries live in each cold-tier segment's v2
2616 /// envelope sidecar; the BRIN entry in `Table.indices` only
2617 /// records THAT a BRIN index exists on this column so the
2618 /// segment encoder + planner can opt into the summary path.
2619 Brin {
2620 /// The cell type at `column_position` at CREATE INDEX time.
2621 /// Used by the planner to type-check WHERE-clause range
2622 /// predicates against the BRIN-indexed column.
2623 column_type: DataType,
2624 },
2625 /// v7.12.3 — GIN inverted index over a `tsvector` column.
2626 ///
2627 /// Storage shape: `lexeme word → Vec<RowLocator>`. The posting
2628 /// list per word is appended in row-order, so range scans are
2629 /// O(matching rows) once the per-word lookup is done. Multi-
2630 /// term queries intersect / union posting lists.
2631 ///
2632 /// `IndexKey::from_value(TsVector)` returns `None` — GIN doesn't
2633 /// participate in `try_index_seek` (which is BTree-equality-keyed).
2634 /// The engine consults this index through `try_gin_lookup` on
2635 /// `WHERE col @@ tsquery` predicates instead.
2636 ///
2637 /// Backed by a `PersistentBTreeMap` so `Catalog::clone` (the
2638 /// per-write snapshot) stays O(1) — same structural-sharing
2639 /// invariant as BTree.
2640 Gin(PersistentBTreeMap<alloc::string::String, crate::posting::PostingList>),
2641 /// v7.15.0 — `USING gin (col gin_trgm_ops)` over a `TEXT`
2642 /// column. Posting lists map `trigram` (PG-compatible 3-byte
2643 /// shingle on the lower-cased + space-padded input) to row
2644 /// locators. The planner uses this index to accelerate
2645 /// `WHERE col LIKE '…'` / `ILIKE '…'` / `similarity(col, q) >
2646 /// t` — every literal run of length ≥ 1 in the pattern
2647 /// produces a trigram set, the engine intersects the posting
2648 /// lists, and the LIKE / similarity predicate is re-evaluated
2649 /// per candidate row to filter the over-approximation.
2650 /// Persisted via tag-4 index payload in `FILE_VERSION` 24+.
2651 GinTrgm(PersistentBTreeMap<alloc::string::String, crate::posting::PostingList>),
2652 /// v7.17.0 Phase 2.2 — MySQL `FULLTEXT KEY (col)` over a
2653 /// `TEXT` / `VARCHAR` column. Posting lists map
2654 /// `tsvector('simple') lexeme` to row locators. At insert /
2655 /// build time the engine derives the lexemes from the cell
2656 /// via the same lower-case tokenisation rule as
2657 /// `to_tsvector('simple', ...)` — the column itself stays a
2658 /// plain text type on disk (mysqldump round-trips would be
2659 /// broken otherwise). The planner uses this index to
2660 /// accelerate MySQL-shape `MATCH(col) AGAINST('term')`
2661 /// queries by mapping them onto the existing tsquery `@@`
2662 /// walker. Persisted via tag-5 index payload in
2663 /// `FILE_VERSION` 33+.
2664 GinFulltext(PersistentBTreeMap<alloc::string::String, crate::posting::PostingList>),
2665 /// v7.37.8(sentori Epic 5 P2)— `USING gin (col)` over a
2666 /// `JSON` / `JSONB` column. Posting lists map a canonical
2667 /// `(path, leaf)` token(see [`crate::jsonb_gin::extract_tokens`])
2668 /// to row locators so the planner can resolve
2669 /// `<col> @> <jsonb_literal>` to a candidate row set via
2670 /// posting-list intersection + per-row `json::contains`
2671 /// re-verification. Pre-7.37.8 the same DDL loaded as a
2672 /// BTree fallback so `pg_dump` JSONB-GIN scripts kept loading
2673 /// without query-time acceleration. Persisted via tag-6 index
2674 /// payload in `FILE_VERSION` 51+.
2675 GinJsonb(PersistentBTreeMap<alloc::string::String, crate::posting::PostingList>),
2676}
2677
2678impl IndexKind {
2679 /// v7.31 (memory campaign, C2) — bytes this index variant holds
2680 /// resident in RAM, computed by walking its OWN structure rather
2681 /// than a parametric guess made by the engine. Replaces the old
2682 /// `spg_admin::memory_stats` inline match, which charged NSW with
2683 /// a stale `m_max_0 * 8` per node (neighbour slots are `u32` = 4 B
2684 /// since v6.1.x, and most nodes never fill `m_max_0`) and lumped
2685 /// every GIN family index into a flat 1 KiB token — a gross
2686 /// undercount for the text-heavy posting lists that dominate
2687 /// mailrs' footprint. Per-entry container overhead uses the
2688 /// 3-word (24 B on 64-bit) `Vec`/`String` header as the charge.
2689 ///
2690 /// O(index entries): operator/monitoring surface (`memory_stats` /
2691 /// `spg_memory_stats`), not a query path.
2692 #[must_use]
2693 pub fn approx_resident_bytes(&self) -> u64 {
2694 const HEADER: usize = 24; // Vec/String 3-word header on 64-bit.
2695 let loc = core::mem::size_of::<RowLocator>();
2696 match self {
2697 IndexKind::BTree(map) => {
2698 let key = core::mem::size_of::<IndexKey>();
2699 map.iter()
2700 .map(|(_, locs)| (key + HEADER + locs.len() * loc) as u64)
2701 .sum()
2702 }
2703 IndexKind::Nsw(g) => {
2704 // `levels` is one byte per node; each layer's adjacency
2705 // is a `Vec<u32>` per node whose actual length we walk
2706 // (the dense layer-0 list dominates, but upper layers
2707 // are sparse — the old estimate ignored that).
2708 let mut b = g.levels.len() as u64;
2709 for layer in &g.layers {
2710 for nbrs in layer.iter() {
2711 b += (HEADER + nbrs.len() * core::mem::size_of::<u32>()) as u64;
2712 }
2713 }
2714 b
2715 }
2716 // BRIN carries NO in-memory key→locator map (the (min,max)
2717 // summaries live in cold-segment sidecars on disk); the
2718 // resident footprint is just the column-type token.
2719 IndexKind::Brin { .. } => core::mem::size_of::<DataType>() as u64,
2720 IndexKind::Gin(map)
2721 | IndexKind::GinTrgm(map)
2722 | IndexKind::GinFulltext(map)
2723 | IndexKind::GinJsonb(map) => map
2724 .iter()
2725 .map(|(word, postings)| {
2726 (word.len() + HEADER + HEADER + postings.len() * loc) as u64
2727 })
2728 .sum(),
2729 }
2730 }
2731}
2732
2733/// Multi-layer HNSW graph (v2.13). Each node is assigned a `top_level`;
2734/// it appears in layers `0..=top_level`. Higher layers are sparser, so
2735/// search starts from the entry at the top layer, greedy-descends to
2736/// layer 0, and beam-searches there. Layer 0 keeps a larger neighbour
2737/// budget (`m_max_0 = 2 * m` per the HNSW paper); upper layers cap at
2738/// `m`. The struct name stays `NswGraph` so external users / on-disk
2739/// callers don't have to track a rename — the algorithm changed, the
2740/// data slot didn't.
2741#[derive(Debug, Clone)]
2742pub struct NswGraph {
2743 /// Max neighbours per node on layers ≥ 1.
2744 pub m: usize,
2745 /// Max neighbours on layer 0 (the dense bottom layer). HNSW
2746 /// convention: `m_max_0 = 2 * m`.
2747 pub m_max_0: usize,
2748 /// Entry point — the node that sits on the topmost layer. Search
2749 /// always starts here.
2750 pub entry: Option<usize>,
2751 /// Top layer of the entry node (== `layers.len() - 1` when populated).
2752 pub entry_level: u8,
2753 /// `levels[i]` = top layer of node `i`. Nodes whose vector cell is
2754 /// NULL / non-Vector have `levels[i] = 0` and no neighbour entries.
2755 ///
2756 /// v5.5.0: backed by `PersistentVec` so `NswGraph::clone` (and the
2757 /// `Catalog::clone` on every group-commit write that contains it) is O(1)
2758 /// structural-sharing instead of an O(N) element copy.
2759 pub levels: PersistentVec<u8>,
2760 /// `layers[l][i]` = neighbours of node `i` at layer `l`. Inner vec
2761 /// is empty when node `i` doesn't reach layer `l`.
2762 ///
2763 /// v5.5.0: the per-node middle dimension (the O(N) one) is a
2764 /// `PersistentVec`; the outer layer dimension stays a plain `Vec`
2765 /// (layer count ≤ 8, so its clone is O(1) in practice) and the inner
2766 /// neighbour list stays a `Vec` (bounded by `m_max_0`).
2767 ///
2768 /// v6.1.x: neighbour slot widened from `usize` (8 B on 64-bit) to
2769 /// `u32` (4 B). Row indices are catalog-bounded by `u32::MAX` (4G
2770 /// rows per table); the cast at the NSW boundary asserts this. At
2771 /// 1M dim-128 SQ8, layer 0 adjacency alone shrinks by ~128 MiB
2772 /// — the largest single contribution to the v6.0.5-measured
2773 /// 624 MiB ambition gap. On-disk format already used u32 LE, so
2774 /// this is a pure in-memory layout change; no `FILE_VERSION` bump.
2775 pub layers: Vec<PersistentVec<Vec<u32>>>,
2776}
2777
2778impl NswGraph {
2779 fn new(m: usize) -> Self {
2780 Self {
2781 m,
2782 m_max_0: m.saturating_mul(2),
2783 entry: None,
2784 entry_level: 0,
2785 levels: PersistentVec::new(),
2786 layers: alloc::vec![PersistentVec::new()],
2787 }
2788 }
2789
2790 /// Max-neighbour budget for layer `l`.
2791 pub const fn cap_for_layer(&self, layer: u8) -> usize {
2792 if layer == 0 { self.m_max_0 } else { self.m }
2793 }
2794}
2795
2796/// Deterministic level assignment, seeded on the row index so the same
2797/// insert order reproduces the same topology. Distribution is roughly
2798/// HNSW-flavoured with `mL ≈ 1/ln(M) ≈ 0.36` for M=16: each 4-bit
2799/// chunk that comes up zero promotes the node one layer (so P(level ≥
2800/// L) ≈ (1/16)^L).
2801#[allow(clippy::verbose_bit_mask)] // clippy suggests trailing_zeros(); we need an explicit MAX cap and a stable distribution shape.
2802pub fn nsw_assign_level(row_idx: usize) -> u8 {
2803 const MAX_LEVEL: u8 = 7; // 7 ⇒ ~16^7 ≈ 2.7e8 expected nodes between promotions; ample.
2804 // SplitMix-style mixer — cheap and seedable.
2805 let mut x = (row_idx as u64).wrapping_mul(0x9E37_79B9_7F4A_7C15);
2806 x ^= x >> 30;
2807 x = x.wrapping_mul(0xBF58_476D_1CE4_E5B9);
2808 x ^= x >> 27;
2809 x = x.wrapping_mul(0x94D0_49BB_1331_11EB);
2810 x ^= x >> 31;
2811 // Count contiguous low-end zero nibbles (4-bit chunks). Each zero
2812 // nibble has probability 1/16, mirroring HNSW's `mL ≈ 1/ln(M)` for
2813 // M=16. `trailing_zeros / 4` would lose the ordering when x = 0, so
2814 // a plain loop with a cap is clearer.
2815 let mut level: u8 = 0;
2816 while x & 0xF == 0 && level < MAX_LEVEL {
2817 level += 1;
2818 x >>= 4;
2819 }
2820 level
2821}
2822
2823impl Index {
2824 fn new_btree(name: String, column_position: usize) -> Self {
2825 Self {
2826 name,
2827 column_position,
2828 kind: IndexKind::BTree(PersistentBTreeMap::new()),
2829 included_columns: Vec::new(),
2830 partial_predicate: None,
2831 expression: None,
2832 is_unique: false,
2833 nulls_not_distinct: false,
2834 descending: false,
2835 nulls_first: None,
2836 collation: None,
2837 extra_column_positions: Vec::new(),
2838 }
2839 }
2840
2841 fn new_nsw(name: String, column_position: usize, m: usize) -> Self {
2842 Self {
2843 name,
2844 column_position,
2845 kind: IndexKind::Nsw(NswGraph::new(m)),
2846 included_columns: Vec::new(),
2847 partial_predicate: None,
2848 expression: None,
2849 is_unique: false,
2850 nulls_not_distinct: false,
2851 descending: false,
2852 nulls_first: None,
2853 collation: None,
2854 extra_column_positions: Vec::new(),
2855 }
2856 }
2857
2858 /// v6.7.1 — BRIN index constructor. BRIN carries no in-memory
2859 /// data; the `column_type` snapshot is used by the segment
2860 /// encoder + planner for type-checking range predicates.
2861 fn new_brin(name: String, column_position: usize, column_type: DataType) -> Self {
2862 Self {
2863 name,
2864 column_position,
2865 kind: IndexKind::Brin { column_type },
2866 included_columns: Vec::new(),
2867 partial_predicate: None,
2868 expression: None,
2869 is_unique: false,
2870 nulls_not_distinct: false,
2871 descending: false,
2872 nulls_first: None,
2873 collation: None,
2874 extra_column_positions: Vec::new(),
2875 }
2876 }
2877
2878 /// v7.12.3 — GIN inverted-index constructor. Empty posting-list
2879 /// map; caller (typically [`Table::add_gin_index`] or
2880 /// [`Table::restore_gin_index`]) populates it from existing rows
2881 /// or from a deserialised snapshot.
2882 fn new_gin(name: String, column_position: usize) -> Self {
2883 Self {
2884 name,
2885 column_position,
2886 kind: IndexKind::Gin(PersistentBTreeMap::new()),
2887 included_columns: Vec::new(),
2888 partial_predicate: None,
2889 expression: None,
2890 is_unique: false,
2891 nulls_not_distinct: false,
2892 descending: false,
2893 nulls_first: None,
2894 collation: None,
2895 extra_column_positions: Vec::new(),
2896 }
2897 }
2898
2899 /// v7.15.0 — `gin_trgm_ops`-flavoured GIN constructor. Same
2900 /// shape as `new_gin` but the posting-list keys are 3-byte
2901 /// trigram shingles (`pg_trgm`-compatible) and the column
2902 /// type is `TEXT` / `VARCHAR` (not `TSVECTOR`).
2903 fn new_gin_trgm(name: String, column_position: usize) -> Self {
2904 Self {
2905 name,
2906 column_position,
2907 kind: IndexKind::GinTrgm(PersistentBTreeMap::new()),
2908 included_columns: Vec::new(),
2909 partial_predicate: None,
2910 expression: None,
2911 is_unique: false,
2912 nulls_not_distinct: false,
2913 descending: false,
2914 nulls_first: None,
2915 collation: None,
2916 extra_column_positions: Vec::new(),
2917 }
2918 }
2919
2920 /// v7.17.0 Phase 2.2 — MySQL `FULLTEXT KEY` GIN constructor.
2921 /// Same shape as `new_gin_trgm` but the posting-list keys
2922 /// are lower-cased word lexemes (`to_tsvector('simple', col)`
2923 /// equivalent) instead of trigrams, and the column type is
2924 /// `TEXT` / `VARCHAR` (not `TSVECTOR`).
2925 fn new_gin_fulltext(name: String, column_position: usize) -> Self {
2926 Self {
2927 name,
2928 column_position,
2929 kind: IndexKind::GinFulltext(PersistentBTreeMap::new()),
2930 included_columns: Vec::new(),
2931 partial_predicate: None,
2932 expression: None,
2933 is_unique: false,
2934 nulls_not_distinct: false,
2935 descending: false,
2936 nulls_first: None,
2937 collation: None,
2938 extra_column_positions: Vec::new(),
2939 }
2940 }
2941
2942 /// v7.37.8(sentori Epic 5 P2)— JSONB-GIN constructor. Same
2943 /// shape as the other GIN-family indexes; posting-list keys
2944 /// are the canonical `(path, leaf)` tokens emitted by
2945 /// `crate::jsonb_gin::extract_tokens`. Maintains posting
2946 /// lists from `Value::Json` cells(JSONB is a synonym for the
2947 /// same in-memory string-backed Value).
2948 fn new_gin_jsonb(name: String, column_position: usize) -> Self {
2949 Self {
2950 name,
2951 column_position,
2952 kind: IndexKind::GinJsonb(PersistentBTreeMap::new()),
2953 included_columns: Vec::new(),
2954 partial_predicate: None,
2955 expression: None,
2956 is_unique: false,
2957 nulls_not_distinct: false,
2958 descending: false,
2959 nulls_first: None,
2960 collation: None,
2961 extra_column_positions: Vec::new(),
2962 }
2963 }
2964
2965 /// v7.34.4 — descending-order iterator over `(IndexKey, locators)`
2966 /// pairs for a BTree index, with O(log N) descent to the rightmost
2967 /// leaf and lazy emission thereafter. Returns an empty iterator
2968 /// for non-BTree index kinds — callers handle both uniformly.
2969 /// Used by the ORDER BY `<indexed col>` DESC + LIMIT N executor
2970 /// path: walking only the first N matches off the rightmost leaf
2971 /// avoids the per-row materialisation + partial-sort cost on
2972 /// large tables (mailrs `content_worker` at 250 k rows).
2973 pub fn iter_desc(
2974 &self,
2975 ) -> alloc::boxed::Box<dyn Iterator<Item = (&IndexKey, &crate::posting::PostingList)> + '_>
2976 {
2977 match &self.kind {
2978 IndexKind::BTree(m) => alloc::boxed::Box::new(m.iter_rev()),
2979 IndexKind::Nsw(_)
2980 | IndexKind::Brin { .. }
2981 | IndexKind::Gin(_)
2982 | IndexKind::GinTrgm(_)
2983 | IndexKind::GinFulltext(_)
2984 | IndexKind::GinJsonb(_) => alloc::boxed::Box::new(core::iter::empty()),
2985 }
2986 }
2987
2988 /// v7.34.4 — ascending-order iterator over `(IndexKey, locators)`
2989 /// pairs. Mirror of `iter_desc` for ORDER BY ... ASC + LIMIT N.
2990 pub fn iter_asc(
2991 &self,
2992 ) -> alloc::boxed::Box<dyn Iterator<Item = (&IndexKey, &crate::posting::PostingList)> + '_>
2993 {
2994 match &self.kind {
2995 IndexKind::BTree(m) => alloc::boxed::Box::new(m.iter()),
2996 IndexKind::Nsw(_)
2997 | IndexKind::Brin { .. }
2998 | IndexKind::Gin(_)
2999 | IndexKind::GinTrgm(_)
3000 | IndexKind::GinFulltext(_)
3001 | IndexKind::GinJsonb(_) => alloc::boxed::Box::new(core::iter::empty()),
3002 }
3003 }
3004
3005 /// Look up the locators stored under `key` (B-tree only). Returns
3006 /// an empty slice when the key is absent or the index isn't a
3007 /// BTree — callers can treat both cases uniformly.
3008 ///
3009 /// v5.1: return type widened from `&[usize]` to `&[RowLocator]`.
3010 /// Pre-v5.2 callers can read the slice and `.as_hot().unwrap()`
3011 /// each entry (no `Cold` variants exist until the freezer lands);
3012 /// post-v5.2 callers dispatch hot vs. cold per locator.
3013 pub fn lookup_eq(&self, key: &IndexKey) -> &crate::posting::PostingList {
3014 match &self.kind {
3015 IndexKind::BTree(m) => m.get(key).map_or(&EMPTY_POSTINGS, |l| l),
3016 // BRIN / NSW / GIN / trigram-GIN / fulltext-GIN have
3017 // no IndexKey-keyed map; lookup is a no-op. GIN uses
3018 // [`Index::gin_lookup_word`] instead.
3019 IndexKind::Nsw(_)
3020 | IndexKind::Brin { .. }
3021 | IndexKind::Gin(_)
3022 | IndexKind::GinTrgm(_)
3023 | IndexKind::GinFulltext(_)
3024 | IndexKind::GinJsonb(_) => &EMPTY_POSTINGS,
3025 }
3026 }
3027
3028 /// v7.37.43 (INSUBQ B-2) — specialised lookup for integer-PK probes.
3029 /// `try_count_star_pk_in_subquery_fast` already holds an `i64` (the
3030 /// inner survivor key); skip the `IndexKey::from_value` enum-dispatch
3031 /// trip and build the key inline. ~20 ns × N_survivors saved on
3032 /// the INSUBQ hot loop.
3033 #[inline]
3034 pub fn lookup_eq_i64(&self, n: i64) -> &crate::posting::PostingList {
3035 match &self.kind {
3036 IndexKind::BTree(m) => m.get(&IndexKey::Int(n)).map_or(&EMPTY_POSTINGS, |l| l),
3037 IndexKind::Nsw(_)
3038 | IndexKind::Brin { .. }
3039 | IndexKind::Gin(_)
3040 | IndexKind::GinTrgm(_)
3041 | IndexKind::GinFulltext(_)
3042 | IndexKind::GinJsonb(_) => &EMPTY_POSTINGS,
3043 }
3044 }
3045
3046 /// v7.38 (perf, index range scan) — flatten the row locators for every key
3047 /// in `[lo, hi]` (bounds per `core::ops::Bound`) via the BTree's `O(log N +
3048 /// k)` range walk. Returns `None` once more than `cap` locators accumulate
3049 /// — a "this range isn't selective enough, seq-scan instead" signal that
3050 /// stops a wide range from materialising a near-full table's worth of rows
3051 /// through the index. BTree only (other kinds → None).
3052 pub fn lookup_range_capped(
3053 &self,
3054 lo: core::ops::Bound<&IndexKey>,
3055 hi: core::ops::Bound<&IndexKey>,
3056 cap: usize,
3057 ) -> Option<Vec<RowLocator>> {
3058 self.lookup_range_capped_by(lo, hi, cap, |_| true)
3059 }
3060
3061 /// v7.39 (round 490) — the same range walk, but the caller decides
3062 /// which locators are worth carrying, and the cap counts only those.
3063 ///
3064 /// A BTree index holds one locator per row VERSION. On a churned table
3065 /// the dead versions are still in there: round 490 measured a
3066 /// 1000-row range handing back 61 000 locators after 60
3067 /// delete-and-reinsert cycles with the background vacuum switched off.
3068 /// Every caller then dropped the dead ones — the mutation paths and the
3069 /// SELECT range path all test `is_row_visible` and `continue` — but only
3070 /// after they had been collected into a `Vec`, sorted, and walked.
3071 ///
3072 /// Handing the predicate down means the walk keeps ~1000, and the cap
3073 /// (which exists so an index walk never costs more than the scan it
3074 /// replaces) is once again measured in rows a caller will actually look
3075 /// at. Round 461 had to add the dead count to the budget to stop the
3076 /// seek being refused outright; with the filter here that compensation
3077 /// is no longer needed.
3078 pub fn lookup_range_capped_by(
3079 &self,
3080 lo: core::ops::Bound<&IndexKey>,
3081 hi: core::ops::Bound<&IndexKey>,
3082 cap: usize,
3083 keep: impl Fn(RowLocator) -> bool,
3084 ) -> Option<Vec<RowLocator>> {
3085 match &self.kind {
3086 IndexKind::BTree(m) => {
3087 let mut out: Vec<RowLocator> = Vec::new();
3088 for (_, locs) in m.range(lo, hi) {
3089 out.extend(locs.iter().copied().filter(|l| keep(*l)));
3090 if out.len() > cap {
3091 return None;
3092 }
3093 }
3094 Some(out)
3095 }
3096 IndexKind::Nsw(_)
3097 | IndexKind::Brin { .. }
3098 | IndexKind::Gin(_)
3099 | IndexKind::GinTrgm(_)
3100 | IndexKind::GinFulltext(_)
3101 | IndexKind::GinJsonb(_) => None,
3102 }
3103 }
3104
3105 /// v7.39 (round 560) — the index range as (key, locator) pairs.
3106 ///
3107 /// `lookup_range_capped_by` throws the KEY away and returns only
3108 /// locators, so a query whose projection is exactly the indexed
3109 /// column still goes to the row store for a value the walk already
3110 /// had in hand — paying per row for something the index knows.
3111 ///
3112 /// Uncapped on purpose: an index-only walk touches no row, so the
3113 /// selectivity ceiling that keeps a seek from being worse than the
3114 /// scan it replaces does not apply to it.
3115 ///
3116 /// v7.39 (round 562) — and it does not collect, either. This
3117 /// returned a `Vec<(IndexKey, RowLocator)>`: for a 100k-row range,
3118 /// 100k key clones into a `Vec::new()` that doubles its way up to
3119 /// several MB, all to be walked once and dropped. A profile of the
3120 /// server serving that query put 20% of the connection thread's CPU
3121 /// on the collect alone, with another 18% in the allocator beside
3122 /// it. The caller consumes the pairs in order and needs the key
3123 /// only by reference, so it can have the walk itself.
3124 pub fn range_keyed(
3125 &self,
3126 lo: core::ops::Bound<&IndexKey>,
3127 hi: core::ops::Bound<&IndexKey>,
3128 ) -> Option<impl Iterator<Item = (&IndexKey, RowLocator)> + '_> {
3129 match &self.kind {
3130 IndexKind::BTree(m) => Some(
3131 m.range(lo, hi)
3132 .flat_map(|(k, locs)| locs.iter().map(move |l| (k, *l))),
3133 ),
3134 IndexKind::Nsw(_)
3135 | IndexKind::Brin { .. }
3136 | IndexKind::Gin(_)
3137 | IndexKind::GinTrgm(_)
3138 | IndexKind::GinFulltext(_)
3139 | IndexKind::GinJsonb(_) => None,
3140 }
3141 }
3142
3143 /// v7.12.3 — GIN posting-list lookup. Returns the row locators
3144 /// whose `tsvector` cell contains `word`. Empty when the word is
3145 /// absent from the index or this isn't a GIN index.
3146 pub fn gin_lookup_word(&self, word: &str) -> &crate::posting::PostingList {
3147 match &self.kind {
3148 // v7.17.0 Phase 2.2 — fulltext-GIN shares the same
3149 // lexeme-keyed posting list shape as the
3150 // tsvector-typed GIN, so the same lookup applies.
3151 IndexKind::Gin(m) | IndexKind::GinFulltext(m) => {
3152 m.get(&String::from(word)).map_or(&EMPTY_POSTINGS, |l| l)
3153 }
3154 IndexKind::BTree(_)
3155 | IndexKind::Nsw(_)
3156 | IndexKind::Brin { .. }
3157 | IndexKind::GinTrgm(_)
3158 | IndexKind::GinJsonb(_) => &EMPTY_POSTINGS,
3159 }
3160 }
3161
3162 /// v7.15.0 — trigram-GIN posting-list lookup. Returns the row
3163 /// locators whose indexed `TEXT` cell contains the trigram
3164 /// `tri`. Empty when the trigram is absent or this isn't a
3165 /// trigram-GIN index.
3166 pub fn gin_trgm_lookup(&self, tri: &str) -> &crate::posting::PostingList {
3167 match &self.kind {
3168 IndexKind::GinTrgm(m) => m.get(&String::from(tri)).map_or(&EMPTY_POSTINGS, |l| l),
3169 IndexKind::BTree(_)
3170 | IndexKind::Nsw(_)
3171 | IndexKind::Brin { .. }
3172 | IndexKind::Gin(_)
3173 | IndexKind::GinFulltext(_)
3174 | IndexKind::GinJsonb(_) => &EMPTY_POSTINGS,
3175 }
3176 }
3177
3178 /// v7.37.8(sentori Epic 5 P2)— JSONB-GIN posting-list lookup.
3179 /// Returns the row locators whose indexed JSONB cell carries
3180 /// the canonical `token`(see [`crate::jsonb_gin::extract_tokens`]).
3181 /// Empty when the token is absent or this isn't a JSONB-GIN
3182 /// index. Planners drive `<col> @> <jsonb_literal>` through here.
3183 pub fn gin_jsonb_lookup(&self, token: &str) -> &crate::posting::PostingList {
3184 match &self.kind {
3185 IndexKind::GinJsonb(m) => m.get(&String::from(token)).map_or(&EMPTY_POSTINGS, |l| l),
3186 IndexKind::BTree(_)
3187 | IndexKind::Nsw(_)
3188 | IndexKind::Brin { .. }
3189 | IndexKind::Gin(_)
3190 | IndexKind::GinTrgm(_)
3191 | IndexKind::GinFulltext(_) => &EMPTY_POSTINGS,
3192 }
3193 }
3194
3195 /// Borrow the NSW graph (if this is an NSW index). Callers that need
3196 /// the graph for a kNN search go through here.
3197 pub const fn nsw(&self) -> Option<&NswGraph> {
3198 match &self.kind {
3199 IndexKind::Nsw(g) => Some(g),
3200 IndexKind::BTree(_)
3201 | IndexKind::Brin { .. }
3202 | IndexKind::Gin(_)
3203 | IndexKind::GinTrgm(_)
3204 | IndexKind::GinFulltext(_)
3205 | IndexKind::GinJsonb(_) => None,
3206 }
3207 }
3208
3209 /// v6.7.1 — true when this index is a BRIN (block range) index.
3210 /// Used by the segment encoder to opt into BRIN sidecar emission
3211 /// at freeze time, and by the planner to opt into page-skipping
3212 /// on range predicates.
3213 pub const fn is_brin(&self) -> bool {
3214 matches!(self.kind, IndexKind::Brin { .. })
3215 }
3216
3217 /// v7.15.0 — true when this index is a trigram GIN
3218 /// (`gin_trgm_ops`-flavoured). Used by the LIKE planner to
3219 /// opt into trigram acceleration.
3220 pub const fn is_gin_trgm(&self) -> bool {
3221 matches!(self.kind, IndexKind::GinTrgm(_))
3222 }
3223
3224 /// v7.12.3 — true when this index is a GIN inverted index.
3225 /// Used by the planner to opt into posting-list acceleration on
3226 /// `WHERE col @@ tsquery` predicates.
3227 pub const fn is_gin(&self) -> bool {
3228 matches!(self.kind, IndexKind::Gin(_))
3229 }
3230
3231 /// v7.17.0 Phase 2.2 — true when this index is a fulltext
3232 /// GIN over a TEXT / VARCHAR column (MySQL `FULLTEXT KEY`
3233 /// surface). Used by the planner to opt the FULLTEXT-indexed
3234 /// column into MATCH AGAINST acceleration.
3235 pub const fn is_gin_fulltext(&self) -> bool {
3236 matches!(self.kind, IndexKind::GinFulltext(_))
3237 }
3238
3239 /// v7.37.8(sentori Epic 5 P2)— true when this index is a
3240 /// real JSONB-GIN(posting-list backed). Used by the planner
3241 /// to opt `<col> @> <jsonb_literal>` into posting-list seek.
3242 pub const fn is_gin_jsonb(&self) -> bool {
3243 matches!(self.kind, IndexKind::GinJsonb(_))
3244 }
3245}
3246
3247/// In-memory table: schema + a persistent row vector + secondary indices.
3248///
3249/// v4.39: `rows` is a [`PersistentVec`] (Bitmapped Vector Trie, 32-way) so
3250/// `Table::clone()` is `O(1)` — the whole reason for v4.39's existence is
3251/// to make `Catalog::clone()` cheap inside the v4.34 auto-commit wrap.
3252///
3253/// v5.2.1: `hot_bytes` tracks the encoded byte size of every row currently
3254/// in [`Self::rows`], summed over rows. Updated incrementally by `insert`
3255/// (+= encoded row size), `delete_rows` (-= removed rows' encoded sizes),
3256/// and `update_row` (-= old size, += new size). The value is what the
3257/// v5.2 freezer reads to decide when to demote cold rows — when the
3258/// catalog-wide sum crosses `SPG_HOT_TIER_BYTES` (default 4 GiB) the
3259/// freezer thread wakes. v5.2.1 ships measurement only; the freezer
3260/// itself lands in v5.2.2. Stored as `u64` so a single field clone in
3261/// `Catalog::clone` stays at the O(1) invariant v4.39 built.
3262/// v7.34 (crash-recovery P0 #2) — one row-level physical redo record.
3263/// Row-level redo replaces statement-based WAL replay (which re-executes
3264/// each SQL through the full engine — O(records × catalog_rows), the
3265/// superlinear recovery hang root-caused on the mailrs crash-recovery
3266/// P0). A `RowChange` is the exact storage mutation the engine applied
3267/// (`Table::insert` / `update_row` / `delete_rows`); replaying it on a
3268/// catalog restored from the matching checkpoint reproduces the state
3269/// WITHOUT re-validating uniqueness/FK/parse/plan — O(changed rows).
3270///
3271/// Positions are physical, not key-based: `serialize`/`deserialize`
3272/// preserve row order exactly (rows written + read back in `self.rows`
3273/// order) and the mutation ops are deterministic, so the same op sequence
3274/// replayed from the same checkpoint reproduces the same positions. This
3275/// matches PostgreSQL's physical redo and supports tables with no primary
3276/// key. (Caveat handled at replay integration: a post-checkpoint cold-tier
3277/// freeze shifts hot positions and must itself be logged or fenced by a
3278/// checkpoint — see `row-level-redo-design`.)
3279/// ## v7.37.15 (Epic W slice 1) — additive MVCC identity metadata
3280///
3281/// Each variant now also carries, additively, the stable
3282/// [`RowId`](row_header::RowId) of the affected row(s) and the
3283/// **writer version** (`xmin` for an insert, `xmax` for a
3284/// delete/update). This is the codec foundation for making
3285/// in-place MVCC tombstones durable across crash/upgrade recovery.
3286///
3287/// Two important properties for the durability path:
3288///
3289/// 1. **Replay resolution is UNCHANGED.** `apply_redo_run_on_table`
3290/// still resolves every change by physical `pos`/`positions`
3291/// exactly as before. The new metadata is *carried but unused*
3292/// by replay in this slice; resolving-by-`RowId` and
3293/// header-preserving replay are later slices.
3294/// 2. **Backward compatibility.** A redo payload written by
3295/// pre-Epic-W code carries no metadata; [`decode_redo_log`]
3296/// fills `rowid`/`rowids` with [`RowId::UNASSIGNED`](row_header::RowId::UNASSIGNED)
3297/// (empty for `Delete`) and `writer_version` with `0`. See the
3298/// codec version gate in [`encode_redo_log`]/[`decode_redo_log`].
3299///
3300/// The `writer_version` is captured as `0` at the storage layer
3301/// (`Table::insert`/`delete_rows`/`update_row` don't have the
3302/// committing `TxId`), then **stamped with the real committing
3303/// version by the engine** after it drains the statement's changes
3304/// (Epic W slice 2 — [`RowChange::set_writer_version`], driven from
3305/// `Engine::writer_version_for_current_stmt`). All changes from one
3306/// statement share the one version. Replay still resolves by
3307/// physical position and does not read `writer_version` — that is a
3308/// later slice (header-preserving replay).
3309#[derive(Debug, Clone, PartialEq)]
3310pub enum RowChange {
3311 /// Append `row` to `table`.
3312 Insert {
3313 table: String,
3314 row: Row<'static>,
3315 /// Epic W: stable id the appended row will receive.
3316 /// [`RowId::UNASSIGNED`](row_header::RowId::UNASSIGNED) when
3317 /// decoded from a pre-Epic-W redo payload.
3318 rowid: row_header::RowId,
3319 /// Epic W: writer version (`xmin`). `0` until the writing
3320 /// `TxId` is threaded to the storage layer (later slice).
3321 writer_version: u64,
3322 },
3323 /// Replace the row at physical `pos` in `table` with `new_row`.
3324 Update {
3325 table: String,
3326 pos: usize,
3327 new_row: Vec<Value<'static>>,
3328 /// Epic W: stable id of the row at `pos`.
3329 /// [`RowId::UNASSIGNED`](row_header::RowId::UNASSIGNED) when
3330 /// decoded from a pre-Epic-W redo payload.
3331 rowid: row_header::RowId,
3332 /// Epic W: writer version (`xmax` of the superseded tuple).
3333 /// `0` until the writing `TxId` is threaded (later slice).
3334 writer_version: u64,
3335 },
3336 /// Remove the rows at the given physical `positions` from `table`.
3337 Delete {
3338 table: String,
3339 positions: Vec<usize>,
3340 /// Epic W: stable ids parallel to `positions` (same length,
3341 /// [`RowId::UNASSIGNED`](row_header::RowId::UNASSIGNED) for an
3342 /// out-of-bounds input position). **Empty** when decoded from
3343 /// a pre-Epic-W redo payload (no metadata was recorded).
3344 rowids: Vec<row_header::RowId>,
3345 /// Epic W: writer version (`xmax`). `0` until the writing
3346 /// `TxId` is threaded to the storage layer (later slice).
3347 writer_version: u64,
3348 },
3349 /// v7.37.15 (Epic W durable-tombstone slice) — an **in-place MVCC
3350 /// delete**: the row(s) named by `rowids` are NOT physically
3351 /// removed; their header `xmax` is stamped so newer snapshots stop
3352 /// seeing them (vacuum reclaims later). This is the redo shape of
3353 /// the gate-on (`SPG_MVCC_INPLACE`) DELETE / UPDATE-old-version /
3354 /// ON-CONFLICT paths, which call [`Table::mark_row_deleted`]
3355 /// instead of `delete_rows`.
3356 ///
3357 /// Unlike `Delete`, the target is named by **stable `RowId`**, not
3358 /// physical position: a tombstone keeps the slot, so position would
3359 /// be ambiguous after later compaction, and the header-preserving
3360 /// replay must re-find the exact row the writer tombstoned. On
3361 /// replay the id is matched against the ids the same redo run
3362 /// produced (an `Insert`'s `rowid`, or the table's ids snapshotted
3363 /// at run start); an id that cannot be resolved is skipped and
3364 /// counted (see `apply_redo_run_on_table`) — this is the documented
3365 /// cross-checkpoint limitation until the V6 envelope persists ids.
3366 Tombstone {
3367 table: String,
3368 /// Stable ids of the tombstoned rows (from `self.rowids()[pos]`
3369 /// at capture). Never empty for a recorded tombstone.
3370 rowids: Vec<row_header::RowId>,
3371 /// The version stamped into each target row's header `xmax`
3372 /// (the deleting statement's writer version).
3373 xmax: u64,
3374 },
3375}
3376
3377impl RowChange {
3378 /// v7.39 (round 736) — which table this change applies to.
3379 #[must_use]
3380 pub fn table_name(&self) -> &str {
3381 match self {
3382 Self::Insert { table, .. }
3383 | Self::Update { table, .. }
3384 | Self::Delete { table, .. }
3385 | Self::Tombstone { table, .. } => table,
3386 }
3387 }
3388
3389 /// v7.37.15 (Epic W slice 2) — stamp the committing writer
3390 /// version onto this change. Every change drained from a single
3391 /// statement shares one version (the statement's `xmin`/`xmax`),
3392 /// so the engine calls this on each drained change with the value
3393 /// from [`Engine::writer_version_for_current_stmt`]. Additive
3394 /// metadata only: replay still resolves by physical position and
3395 /// does not read `writer_version` (that is a later slice).
3396 pub fn set_writer_version(&mut self, v: u64) {
3397 match self {
3398 RowChange::Insert { writer_version, .. }
3399 | RowChange::Update { writer_version, .. }
3400 | RowChange::Delete { writer_version, .. } => *writer_version = v,
3401 // A tombstone captures `xmax` directly from the deleting
3402 // statement's version at record time (via
3403 // `mark_row_deleted`), so it already equals `v`. Keep the
3404 // "one statement, one version" invariant mechanical by
3405 // asserting agreement in debug builds rather than silently
3406 // overwriting a possibly-different value.
3407 RowChange::Tombstone { xmax, .. } => {
3408 debug_assert_eq!(
3409 *xmax, v,
3410 "tombstone xmax must match the statement writer version"
3411 );
3412 *xmax = v;
3413 }
3414 }
3415 }
3416}
3417
3418/// v7.37.15 (Epic W slice 1) — leading marker byte of the
3419/// metadata-carrying redo layout. A **pre-Epic-W** redo payload leads
3420/// with `FILE_VERSION` (8..=52 today, rising ~1 per release); this
3421/// marker is `0xFF` and can therefore never collide with a real
3422/// `FILE_VERSION`, so [`decode_redo_log`] tells the two layouts apart
3423/// by inspecting the first byte alone. The compile-time assertion
3424/// below makes the "never collide" invariant a hard build gate: if
3425/// `FILE_VERSION` ever climbs toward `0xFF` the build breaks and forces
3426/// a redesign long before an ambiguity could ship.
3427const REDO_META_MARKER: u8 = 0xFF;
3428/// v7.37.15 (Epic W slice 1) — version of the metadata-carrying redo
3429/// layout that follows [`REDO_META_MARKER`]. Bumped when the per-change
3430/// metadata shape changes; an unknown value is a hard decode error.
3431const REDO_META_VERSION: u8 = 1;
3432
3433/// v7.37.15 (Epic W durable-tombstone slice) — process-wide count of
3434/// [`RowChange::Tombstone`] targets that `apply_redo` could NOT resolve
3435/// to a row by `RowId`. A non-zero value is expected only across a
3436/// checkpoint boundary (the table's ids are reassigned on deserialize
3437/// and the V6 envelope does not yet persist them), where a tombstone
3438/// naming a pre-checkpoint row is left visible rather than mis-applied.
3439/// Surfaced for observability; never affects correctness of the resolved
3440/// tombstones. Read via [`unresolved_tombstone_count`].
3441static UNRESOLVED_TOMBSTONES: core::sync::atomic::AtomicU64 = core::sync::atomic::AtomicU64::new(0);
3442
3443/// v7.39 (flip crash-replay P0) — observability read for the replay
3444/// tombstones that could not be resolved to a row (each one is a
3445/// resurrected delete).
3446#[must_use]
3447pub fn unresolved_tombstones() -> u64 {
3448 UNRESOLVED_TOMBSTONES.load(core::sync::atomic::Ordering::Relaxed)
3449}
3450
3451/// v7.37.15 (Epic W durable-tombstone slice) — read the process-wide
3452/// count of redo tombstones that could not be resolved to a row by
3453/// `RowId` during `apply_redo`. See [`UNRESOLVED_TOMBSTONES`].
3454#[must_use]
3455pub fn unresolved_tombstone_count() -> u64 {
3456 UNRESOLVED_TOMBSTONES.load(core::sync::atomic::Ordering::Relaxed)
3457}
3458// Provably-unambiguous old/new distinction: the pre-Epic-W layout's
3459// first byte is `FILE_VERSION`, which must stay strictly below the
3460// marker forever.
3461const _: () = assert!(FILE_VERSION < REDO_META_MARKER);
3462
3463/// v7.34 (crash-recovery P0 #2), extended v7.37.15 (Epic W slice 1) —
3464/// encode a row-level redo log to bytes for a WAL record.
3465///
3466/// ## Layout (Epic W metadata-carrying form, always emitted now)
3467///
3468/// `[u8 REDO_META_MARKER=0xFF][u8 REDO_META_VERSION][u8 FILE_VERSION]
3469/// [u32 count]` then per change `[u8 op][str table]` and, per op:
3470/// - `Insert [u32 n][value×n][u64 rowid][u64 writer_version]`
3471/// - `Update [u32 pos][u32 n][value×n][u64 rowid][u64 writer_version]`
3472/// - `Delete [u32 n][u32 pos×n][u64 rowid×n][u64 writer_version]`
3473/// - `Tombstone [u32 n][u64 rowid×n][u64 xmax]` (op byte 3; only ever
3474/// emitted under the metadata-carrying layout — the pre-Epic-W layout
3475/// had no in-place tombstone, so a legacy stream can never carry it)
3476///
3477/// Positions are physical (u32 ≤ 4 G rows). The `FILE_VERSION` byte
3478/// still rides along (now the 3rd byte) so the value codec decodes
3479/// string / BYTEA escapes exactly as before.
3480///
3481/// ## Backward compatibility
3482///
3483/// The **pre-Epic-W** layout was `[u8 FILE_VERSION][u32 count]…` with
3484/// no per-change metadata. [`decode_redo_log`] still decodes that form
3485/// (first byte < `0xFF`) byte-for-byte identically — every WAL file
3486/// written by released code replays unchanged.
3487#[must_use]
3488pub fn encode_redo_log(changes: &[RowChange]) -> Vec<u8> {
3489 let mut out = Vec::new();
3490 out.push(REDO_META_MARKER);
3491 out.push(REDO_META_VERSION);
3492 out.push(FILE_VERSION);
3493 codec::write_u32(&mut out, changes.len() as u32);
3494 let write_values = |out: &mut Vec<u8>, vals: &[Value<'static>]| {
3495 codec::write_u32(out, vals.len() as u32);
3496 for v in vals {
3497 codec::write_value(out, v);
3498 }
3499 };
3500 for change in changes {
3501 match change {
3502 RowChange::Insert {
3503 table,
3504 row,
3505 rowid,
3506 writer_version,
3507 } => {
3508 out.push(0);
3509 codec::write_str(&mut out, table);
3510 write_values(&mut out, &row.values);
3511 codec::write_u64(&mut out, rowid.0);
3512 codec::write_u64(&mut out, *writer_version);
3513 }
3514 RowChange::Update {
3515 table,
3516 pos,
3517 new_row,
3518 rowid,
3519 writer_version,
3520 } => {
3521 out.push(1);
3522 codec::write_str(&mut out, table);
3523 codec::write_u32(&mut out, *pos as u32);
3524 write_values(&mut out, new_row);
3525 codec::write_u64(&mut out, rowid.0);
3526 codec::write_u64(&mut out, *writer_version);
3527 }
3528 RowChange::Delete {
3529 table,
3530 positions,
3531 rowids,
3532 writer_version,
3533 } => {
3534 out.push(2);
3535 codec::write_str(&mut out, table);
3536 codec::write_u32(&mut out, positions.len() as u32);
3537 for p in positions {
3538 codec::write_u32(&mut out, *p as u32);
3539 }
3540 // Epic W: one RowId per position (parallel). Capture
3541 // sites always produce `rowids.len() == positions.len()`;
3542 // this assertion pins that invariant at encode time so a
3543 // mismatch is a loud bug, not a silently short payload.
3544 debug_assert_eq!(
3545 rowids.len(),
3546 positions.len(),
3547 "redo Delete: rowids must be parallel to positions"
3548 );
3549 for rid in rowids {
3550 codec::write_u64(&mut out, rid.0);
3551 }
3552 codec::write_u64(&mut out, *writer_version);
3553 }
3554 RowChange::Tombstone {
3555 table,
3556 rowids,
3557 xmax,
3558 } => {
3559 out.push(3);
3560 codec::write_str(&mut out, table);
3561 codec::write_u32(&mut out, rowids.len() as u32);
3562 for rid in rowids {
3563 codec::write_u64(&mut out, rid.0);
3564 }
3565 codec::write_u64(&mut out, *xmax);
3566 }
3567 }
3568 }
3569 out
3570}
3571
3572/// v7.34, extended v7.37.15 (Epic W slice 1) — decode a row-level redo
3573/// log written by [`encode_redo_log`].
3574///
3575/// Decodes **both** the Epic W metadata-carrying layout (first byte
3576/// `REDO_META_MARKER = 0xFF`) and the pre-Epic-W layout (first byte is
3577/// `FILE_VERSION`, always `< 0xFF`). For the old layout the per-change
3578/// metadata is absent, so `rowid`/`rowids` come back
3579/// [`RowId::UNASSIGNED`](row_header::RowId::UNASSIGNED) (empty for
3580/// `Delete`) and `writer_version` comes back `0`.
3581///
3582/// A truncated / corrupt buffer is a hard error — never a panic — the
3583/// embedding layer frames each record with its own length + CRC, so a
3584/// frame that decodes short is corruption, not a torn tail.
3585pub fn decode_redo_log(bytes: &[u8]) -> Result<Vec<RowChange>, StorageError> {
3586 let first = *bytes
3587 .first()
3588 .ok_or_else(|| StorageError::Corrupt("redo log: empty".into()))?;
3589 // Epic W: `0xFF` marker ⇒ metadata-carrying layout; anything else
3590 // is a pre-Epic-W `FILE_VERSION` byte (old layout, no metadata).
3591 let has_meta = first == REDO_META_MARKER;
3592 let (codec_version, header_len) = if has_meta {
3593 let meta_version = *bytes
3594 .get(1)
3595 .ok_or_else(|| StorageError::Corrupt("redo log: short header".into()))?;
3596 if meta_version != REDO_META_VERSION {
3597 return Err(StorageError::Corrupt(alloc::format!(
3598 "redo log: unknown metadata version {meta_version}"
3599 )));
3600 }
3601 let file_version = *bytes
3602 .get(2)
3603 .ok_or_else(|| StorageError::Corrupt("redo log: short header".into()))?;
3604 // header = [marker][meta_version][file_version]
3605 (file_version, 3usize)
3606 } else {
3607 // Old layout: the first byte IS the FILE_VERSION.
3608 (first, 1usize)
3609 };
3610 let mut cur = codec::Cursor::new(bytes).with_codec_version(codec_version);
3611 for _ in 0..header_len {
3612 cur.read_u8()?;
3613 }
3614 let count = cur.read_u32()? as usize;
3615 let mut read_values =
3616 |cur: &mut codec::Cursor<'_>| -> Result<Vec<Value<'static>>, StorageError> {
3617 let n = cur.read_u32()? as usize;
3618 let mut vals = Vec::with_capacity(n);
3619 for _ in 0..n {
3620 vals.push(cur.read_value()?);
3621 }
3622 Ok(vals)
3623 };
3624 let mut changes = Vec::with_capacity(count);
3625 for _ in 0..count {
3626 let op = cur.read_u8()?;
3627 let table = cur.read_str()?;
3628 let change = match op {
3629 0 => {
3630 let row = Row::new(read_values(&mut cur)?);
3631 let (rowid, writer_version) = if has_meta {
3632 (row_header::RowId(cur.read_u64()?), cur.read_u64()?)
3633 } else {
3634 (row_header::RowId::UNASSIGNED, 0)
3635 };
3636 RowChange::Insert {
3637 table,
3638 row,
3639 rowid,
3640 writer_version,
3641 }
3642 }
3643 1 => {
3644 let pos = cur.read_u32()? as usize;
3645 let new_row = read_values(&mut cur)?;
3646 let (rowid, writer_version) = if has_meta {
3647 (row_header::RowId(cur.read_u64()?), cur.read_u64()?)
3648 } else {
3649 (row_header::RowId::UNASSIGNED, 0)
3650 };
3651 RowChange::Update {
3652 table,
3653 pos,
3654 new_row,
3655 rowid,
3656 writer_version,
3657 }
3658 }
3659 2 => {
3660 let n = cur.read_u32()? as usize;
3661 let mut positions = Vec::with_capacity(n);
3662 for _ in 0..n {
3663 positions.push(cur.read_u32()? as usize);
3664 }
3665 let (rowids, writer_version) = if has_meta {
3666 let mut rowids = Vec::with_capacity(n);
3667 for _ in 0..n {
3668 rowids.push(row_header::RowId(cur.read_u64()?));
3669 }
3670 (rowids, cur.read_u64()?)
3671 } else {
3672 // Old layout carried no RowId metadata.
3673 (Vec::new(), 0)
3674 };
3675 RowChange::Delete {
3676 table,
3677 positions,
3678 rowids,
3679 writer_version,
3680 }
3681 }
3682 // Op 3 is the Epic W in-place tombstone — it only exists in
3683 // the metadata-carrying layout. Guarding on `has_meta` means
3684 // a legacy stream that happens to contain a `3` byte here is
3685 // reported as an unknown op (corruption), never mis-decoded.
3686 3 if has_meta => {
3687 let n = cur.read_u32()? as usize;
3688 let mut rowids = Vec::with_capacity(n);
3689 for _ in 0..n {
3690 rowids.push(row_header::RowId(cur.read_u64()?));
3691 }
3692 let xmax = cur.read_u64()?;
3693 RowChange::Tombstone {
3694 table,
3695 rowids,
3696 xmax,
3697 }
3698 }
3699 other => {
3700 return Err(StorageError::Corrupt(alloc::format!(
3701 "redo log: unknown op {other}"
3702 )));
3703 }
3704 };
3705 changes.push(change);
3706 }
3707 Ok(changes)
3708}
3709
3710/// v7.39 (pg_stat knife B) — per-table scan counters, bumped from
3711/// `&self` read paths. Clone (tx shadow catalogs clone tables) copies
3712/// the current values; the counters are volatile like PG's cumulative
3713/// stats.
3714#[derive(Debug, Default)]
3715pub struct ScanStats {
3716 pub seq_scan: core::sync::atomic::AtomicU64,
3717 pub seq_tup_read: core::sync::atomic::AtomicU64,
3718 pub idx_scan: core::sync::atomic::AtomicU64,
3719 pub idx_tup_fetch: core::sync::atomic::AtomicU64,
3720}
3721
3722impl Clone for ScanStats {
3723 fn clone(&self) -> Self {
3724 use core::sync::atomic::{AtomicU64, Ordering};
3725 Self {
3726 seq_scan: AtomicU64::new(self.seq_scan.load(Ordering::Relaxed)),
3727 seq_tup_read: AtomicU64::new(self.seq_tup_read.load(Ordering::Relaxed)),
3728 idx_scan: AtomicU64::new(self.idx_scan.load(Ordering::Relaxed)),
3729 idx_tup_fetch: AtomicU64::new(self.idx_tup_fetch.load(Ordering::Relaxed)),
3730 }
3731 }
3732}
3733
3734/// v7.39 (round 215) — the lower-bound sort key for a range value, used by
3735/// the range-exclusion index. The bound as an `i128` (unbounded lower =
3736/// `i128::MIN`, sorting first) plus an inclusivity rank (inclusive lower
3737/// sorts before exclusive at the same value, `[3` before `(3`). Returns
3738/// `None` for range kinds whose bound isn't an integer scalar (numrange's
3739/// numeric/bignum), for empty ranges, and for non-range values — the caller
3740/// then keeps the O(n) scan rather than risk an unsound order. Int4/Int8/
3741/// Date/Ts/TsTz all reduce here (tstzrange bounds are `Value::Timestamp`).
3742/// Maintenance (index build) and query (overlap probe) MUST agree on this
3743/// key, so both sides call exactly this function.
3744#[must_use]
3745pub fn range_excl_index_key(v: &Value<'_>) -> Option<(i128, u8)> {
3746 let Value::Range {
3747 lower,
3748 lower_inc,
3749 empty,
3750 ..
3751 } = v
3752 else {
3753 return None;
3754 };
3755 if *empty {
3756 return None;
3757 }
3758 let key = match lower {
3759 None => i128::MIN,
3760 Some(b) => match b.as_ref() {
3761 Value::SmallInt(n) => i128::from(*n),
3762 Value::Int(n) => i128::from(*n),
3763 Value::BigInt(n) => i128::from(*n),
3764 Value::Date(n) => i128::from(*n),
3765 Value::Timestamp(n) => i128::from(*n),
3766 _ => return None,
3767 },
3768 };
3769 Some((key, u8::from(!*lower_inc)))
3770}
3771
3772/// v7.39 (round 215) — a per-table range-exclusion index: an incrementally
3773/// maintained map from a range column's lower-bound key
3774/// ([`range_excl_index_key`]) to the physical row locators carrying that
3775/// bound. Lets EXCLUDE enforcement find the few candidate rows a new range
3776/// might overlap in O(log n) instead of scanning every row (measured O(N²),
3777/// r213). Because the stored ranges under a valid `EXCLUDE (col WITH &&)`
3778/// are pairwise disjoint, a candidate overlaps only its predecessor or the
3779/// successors whose lower bound precedes its upper — a handful of probes.
3780///
3781/// NOT persisted: rebuilt from the (persisted) exclusion constraints + rows
3782/// on catalog load, exactly like BRIN re-derives. Backed by a
3783/// `PersistentBTreeMap` so `Table::clone` (the per-write snapshot) stays
3784/// O(1). Locators to tombstoned rows are left in place and filtered by the
3785/// consumer via `is_deleted()` at query time — the established index pattern.
3786#[derive(Debug, Clone)]
3787pub struct ExclRangeIndex {
3788 /// The constrained range column's position in the table.
3789 pub column_position: usize,
3790 /// Lower-bound key → row locators. A key maps to a `Vec` because a
3791 /// tombstoned-then-reinserted bound can transiently collide; live rows
3792 /// under the constraint are disjoint so each key has one live locator.
3793 pub map: PersistentBTreeMap<(i128, u8), crate::posting::PostingList>,
3794}
3795
3796#[derive(Debug, Clone)]
3797pub struct Table {
3798 schema: TableSchema,
3799 /// v7.37.15 (Phase C.1) — stable per-catalog relation identity.
3800 /// [`RelId::UNASSIGNED`](row_header::RelId::UNASSIGNED) until
3801 /// `Catalog::create_table` (or the deserialize dense-assign pass)
3802 /// stamps a real id. Keys the Phase C.4 row-lock table and the
3803 /// Phase C.5 `RelationStore`; survives `DROP TABLE` slot shifts.
3804 rel_id: row_header::RelId,
3805 rows: PersistentVec<Row<'static>>,
3806 /// v7.37.15 (Phase A.2) — per-row MVCC visibility headers
3807 /// parallel to `rows`. `headers.len() == rows.len()` is the
3808 /// load-bearing invariant; debug builds assert it on every
3809 /// scan boundary, release builds rely on it from
3810 /// disciplined insert / delete / update paths.
3811 ///
3812 /// Pre-v7.37.15-loaded tables (every row currently in the
3813 /// fleet) start as `RowHeader::frozen()` — `is_all_visible_fast()`
3814 /// returns `true`, so the per-row visibility gate Phase B
3815 /// adds is a no-op against any snapshot.
3816 ///
3817 /// Headers are NOT yet serialised into the envelope at this
3818 /// commit — on snapshot deserialize every row gets a fresh
3819 /// `RowHeader::frozen()`. Phase D adds the visibility-map
3820 /// + segment-freeze story which makes serialisation
3821 /// meaningful; until then the on-disk story is "the catalog
3822 /// is the set of visible rows."
3823 headers: PersistentVec<row_header::RowHeader>,
3824 /// v7.37.15 (Phase C.1) — stable per-relation row identity
3825 /// parallel to `rows` / `headers`. `rowids[i]` is the never-
3826 /// reused [`RowId`](row_header::RowId) of the row physically at
3827 /// slot `i`; `rowids.len() == rows.len()` joins the same load-
3828 /// bearing lock-step invariant as `headers`. Compaction (delete
3829 /// / vacuum) rebuilds all three vecs together so the id travels
3830 /// with the row while the slot shifts.
3831 ///
3832 /// Introduced additively: allocated + kept lock-step, but index
3833 /// locators still address rows by physical slot at this commit.
3834 /// Later phases migrate the lock table (C.4), HOT chains (D),
3835 /// and the WAL (Epic W) to address by `RowId`.
3836 ///
3837 /// Not yet serialised into the envelope — on load every row is
3838 /// assigned a fresh dense id `1..=len` (see `next_rowid`), which
3839 /// is sufficient while the id is process-local bookkeeping. The
3840 /// V6 envelope (Phase C.6) will persist ids so a WAL redo can
3841 /// name a row across restart.
3842 rowids: PersistentVec<row_header::RowId>,
3843 /// v7.37.15 (Phase C.1) — per-relation monotonic allocator for
3844 /// `rowids`. Starts at 1 (0 is the `RowId::UNASSIGNED` sentinel);
3845 /// every append takes `next_rowid` then increments. Never reused
3846 /// even after the row is deleted / vacuumed, so a stale lock /
3847 /// redo reference can be detected rather than silently aliasing a
3848 /// later row that reused the slot.
3849 next_rowid: u64,
3850 /// v7.37.16 (autovacuum) — live count of tombstoned-but-present hot
3851 /// rows (`headers[i].xmax != XMAX_ALIVE`). Maintained incrementally:
3852 /// `mark_row_deleted` / `mark_rows_deleted` increment (the only
3853 /// tombstone producers), `delete_rows_no_index` recomputes over the
3854 /// survivors (it is the compaction hub every physical removal —
3855 /// including vacuum — flows through), and the v53 snapshot loader
3856 /// recounts verbatim-restored headers. Drives the engine's
3857 /// autovacuum threshold; not persisted (recomputed on load).
3858 dead_rows: u64,
3859 /// v7.39 (pg_stat knife A) — volatile per-table write counters
3860 /// backing `pg_stat_user_tables.n_tup_ins/upd/del`. Not persisted
3861 /// (PG's cumulative stats are shared-memory-volatile too — a
3862 /// restart zeroes them).
3863 stat_tup_ins: u64,
3864 stat_tup_upd: u64,
3865 stat_tup_del: u64,
3866 /// v7.39 (pg_stat knife B) — volatile scan counters
3867 /// (`seq_scan/seq_tup_read/idx_scan/idx_tup_fetch`). Atomics: the
3868 /// read paths that bump them hold only `&Table`.
3869 scan_stats: ScanStats,
3870 /// v7.39 (pg_stat knife C) — wall-clock stamps (unix µs, from the
3871 /// host ClockFn) for pg_stat_user_tables' last_autovacuum /
3872 /// last_analyze. Volatile, like PG's cumulative stats. SPG has no
3873 /// manual-VACUUM statement semantics, so last_vacuum stays NULL.
3874 last_autovacuum_us: Option<i64>,
3875 last_analyze_us: Option<i64>,
3876 indices: Vec<Index>,
3877 hot_bytes: u64,
3878 /// v6.7.0 — cached count of rows currently materialised in the
3879 /// cold tier via `RowLocator::Cold` entries across THIS table's
3880 /// indices. Populated by `ANALYZE` (walks every BTree index and
3881 /// counts Cold locators); the count survives until the next
3882 /// ANALYZE recomputes it. Surfaced via `spg_statistic.cold_row_count`
3883 /// and `spg_stat_segment.table_name`.
3884 ///
3885 /// Honest scope: this is a CACHED count, not a live one.
3886 /// Freezer / promote / DELETE don't currently update the cache
3887 /// incrementally — they invalidate it by setting the
3888 /// `cold_row_count_stale` flag, and the next ANALYZE re-walks.
3889 /// Incremental maintenance is a v6.7.x candidate if observation
3890 /// shows the ANALYZE walk cost dominates.
3891 cold_row_count: u64,
3892 /// v6.7.0 — set when the cached `cold_row_count` may be wrong
3893 /// because rows moved into / out of the cold tier since the last
3894 /// ANALYZE. The virtual-table surface reports the cached value
3895 /// regardless (operators run ANALYZE to refresh).
3896 cold_row_count_stale: bool,
3897 /// v7.34 (crash-recovery P0 #2) — row-level redo capture buffer.
3898 /// `None` (default, in-memory mode) captures nothing — zero overhead.
3899 /// `Some` (set by the engine when persistence is on, before a
3900 /// mutating call) makes `insert` / `update_row` / `delete_rows`
3901 /// record the physical [`RowChange`] they applied, which the engine
3902 /// drains after the statement and writes to the WAL in place of the
3903 /// SQL text. Transient: never serialized; a `Catalog::clone` between
3904 /// enable and drain copies it (cheap — empty in the steady state).
3905 redo_log: Option<Vec<RowChange>>,
3906 /// v7.39 (round 215) — per-`EXCLUDE`-constraint range-overlap indexes,
3907 /// one per single-`&&` constraint on an integer-keyable range column.
3908 /// Maintained incrementally on insert / update / rebuild (mirroring the
3909 /// BTree secondary indexes); NOT serialized — rebuilt from the schema's
3910 /// exclusion constraints on load. Empty for tables with no EXCLUDE
3911 /// constraint (the common case), so `Table::clone` pays nothing.
3912 excl_indexes: Vec<ExclRangeIndex>,
3913 /// v7.39 (round 493) — the snapshot floor below which a deleted row
3914 /// version is invisible to everyone, as of the statement now running.
3915 ///
3916 /// Runtime only: never serialised, and `0` (the default) prunes
3917 /// nothing, so any path that forgets to set it is merely slower, not
3918 /// wrong. The engine sets it from `vacuum_oldest_active()` — the same
3919 /// floor `vacuum` itself takes — before the statement's inserts.
3920 prune_horizon: u64,
3921}
3922
3923/// Catalog: insertion-ordered `Vec<Table>` for stable iter / serialize,
3924/// plus a `BTreeMap<String, usize>` sidecar index so `get` / `get_mut`
3925/// run in O(log n) instead of the old linear scan with per-element
3926/// string compares.
3927///
3928/// A pure `BTreeMap<String, Table>` was tried in an interim version
3929/// of v3.1.2 and regressed the single-table catalog benches by ~10%
3930/// (the per-element `BTreeMap` overhead outweighs the lookup win
3931/// when n is small). The sidecar shape preserves the insertion-order
3932/// iteration the on-disk encoding relies on and keeps `last_mut`
3933/// (used by the deserialize hot path) cheap.
3934/// v7.39 (pg_stat blks knife) — catalog-wide cold-tier read counter
3935/// backing pg_stat_database.blks_read. Row-granular (SPG has no 8 KB
3936/// page notion): one cold-segment row resolution = one "block read",
3937/// one hot row access = one "block hit" — the hit RATIO monitoring
3938/// dashboards compute keeps its meaning. Volatile like PG's stats.
3939#[derive(Debug, Default)]
3940pub struct ColdReadStats {
3941 pub cold_reads: core::sync::atomic::AtomicU64,
3942}
3943
3944impl Clone for ColdReadStats {
3945 fn clone(&self) -> Self {
3946 Self {
3947 cold_reads: core::sync::atomic::AtomicU64::new(
3948 self.cold_reads.load(core::sync::atomic::Ordering::Relaxed),
3949 ),
3950 }
3951 }
3952}
3953
3954#[derive(Debug, Clone, Default)]
3955pub struct Catalog {
3956 /// v7.39 (pg_stat blks knife) — see [`ColdReadStats`].
3957 pub cold_read_stats: ColdReadStats,
3958 tables: Vec<Table>,
3959 /// `name → tables[index]`. Kept in lock-step with `tables`.
3960 /// `create_table` is the only write path.
3961 by_name: BTreeMap<String, usize>,
3962 /// v7.39 (round 436) — the current session's temporary-table namespace.
3963 /// A temp table is stored under `<prefix><name>`, and every lookup tries
3964 /// that first: exactly PG's `pg_temp` search-path rule, and MySQL's
3965 /// "a TEMPORARY table shadows a permanent one of the same name".
3966 ///
3967 /// Process-local, never serialised: the engine sets it per session, and
3968 /// a catalog read back from disk starts with none. Kept here rather than
3969 /// at each of the ~170 engine call sites because `by_name` is private —
3970 /// this is the ONE place a table name becomes an index.
3971 temp_prefix: Option<String>,
3972 /// v7.39 (round 496) — the names of tables this catalog handle has had
3973 /// changed since the set was last cleared.
3974 ///
3975 /// Runtime only, never serialised. A transaction's shadow catalog
3976 /// clears it at BEGIN, so at COMMIT the set is exactly the tables the
3977 /// transaction changed — which is what lets a commit that cannot use
3978 /// the row-level merge install only those tables instead of the whole
3979 /// catalog, leaving another session's concurrent work in place.
3980 ///
3981 /// Recorded where the change actually happens (`get_mut`,
3982 /// `create_table`, `drop_table`) rather than from the statement
3983 /// classifier: round 494 tried classification for a correctness gate
3984 /// and it was wrong, because `SELECT lo_write(…)` reads as read-only.
3985 dirty_tables: alloc::collections::BTreeSet<String>,
3986 /// v7.37.15 (Phase C.1) — monotonic allocator for stable
3987 /// [`RelId`](row_header::RelId)s. Pre-incremented on each
3988 /// `create_table` so real ids start at 1 (0 is `UNASSIGNED`);
3989 /// never reused even after `DROP TABLE`, so a stale lock / redo
3990 /// reference is detectable. Process-local bookkeeping — not yet
3991 /// serialised; `deserialize` re-assigns dense ids on load (the
3992 /// V6 envelope, Phase C.6, will round-trip real ids).
3993 next_rel_id: u64,
3994 /// v5.1: in-memory cold-tier segments. Side-loaded via
3995 /// [`Catalog::load_segment_bytes`] — they live outside the
3996 /// catalog snapshot (caller persists them as separate files
3997 /// and re-loads on boot, until v5.3's `CatalogManifest` makes
3998 /// that wiring automatic). `RowLocator::Cold { segment_id, .. }`
3999 /// indexes this `Vec`. Cleared on `Catalog::new` / fresh
4000 /// `deserialize`.
4001 ///
4002 /// `Arc` wrap keeps `Catalog::clone` at O(N segments) bumps
4003 /// (rather than O(total segment bytes) memcpy) so the v4.42
4004 /// group-commit pre-image rollback invariant — clone is
4005 /// effectively free — survives the cold-tier addition.
4006 ///
4007 /// v6.7.3 — slots became `Option<…>` so cold-segment compaction
4008 /// can tombstone merged sources without breaking the
4009 /// `segment_id = index_into_vec` contract that on-disk
4010 /// `RowLocator::Cold { segment_id }` already serialized.
4011 /// `None` slot = the segment was retired by compaction; the
4012 /// physical file may still be on disk (next CHECKPOINT writes
4013 /// a manifest that no longer lists it, and the file becomes
4014 /// an orphan eligible for offline cleanup).
4015 cold_segments: Vec<Option<Arc<OwnedSegment>>>,
4016 /// v7.12.4 — user-defined functions (PL/pgSQL + SQL).
4017 /// Keyed by function name (PG overloading is out of scope).
4018 /// Bodies are stored as the raw source text the parser saw
4019 /// between `$$ ... $$`; the engine re-parses on each
4020 /// invocation. This keeps `spg-storage` free of `spg-sql`
4021 /// dependency — same pattern as partial-index predicates.
4022 functions: BTreeMap<String, FunctionDef>,
4023 /// v7.12.4 — triggers in insertion order. PG18-measured (round
4024 /// 753): PG fires same-event triggers in NAME order (a_trig
4025 /// before z_trig regardless of creation order); SPG fires in
4026 /// insertion order — a real divergence, ledgered as F31-B2.
4027 triggers: Vec<TriggerDef>,
4028 /// v7.39 (round 139) — query-rewrite RULEs, flat like triggers.
4029 rules: Vec<RuleDef>,
4030 /// v7.39 (round 280) — extended-statistics objects. Recorded so a
4031 /// pg_dump restores them and reflection reports them; the planner
4032 /// does not consult them yet.
4033 statistics_ext: Vec<StatisticsExtDef>,
4034 /// v7.39 (round 287) — server-side large objects, keyed by OID.
4035 /// PG stores them as 2 KB pages in `pg_largeobject`; the page split
4036 /// is a storage detail of ITS heap, so SPG holds the whole byte
4037 /// string and renders the pages on read. What must match is the
4038 /// observable surface: the OIDs, the bytes, and the page rows.
4039 large_objects: alloc::collections::BTreeMap<u32, Vec<u8>>,
4040 /// v7.17.0 — catalogued SEQUENCE objects (Phase 1.1). Each
4041 /// `nextval(name)` reaches in here, atomically increments
4042 /// `last_value` / flips `is_called`, returns the new value.
4043 /// Persisted in catalog FILE_VERSION 26+; older catalogs
4044 /// deserialise with an empty map.
4045 sequences: BTreeMap<String, SequenceDef>,
4046 /// v7.39 (read01 round 60) — the `public` schema's ACL (PG
4047 /// `pg_namespace.nspacl`). EMPTY = PG's default, which is not "nothing":
4048 /// PUBLIC holds USAGE and the owner holds USAGE + CREATE. Materialised on
4049 /// the first GRANT / REVOKE, exactly like a table's relacl.
4050 schema_acl: Vec<AclItem>,
4051 /// v7.39 (read01 round 60) — the database's ACL. EMPTY = PG's default:
4052 /// PUBLIC holds CONNECT + TEMPORARY, the owner holds all three.
4053 database_acl: Vec<AclItem>,
4054 /// v7.17.0 — catalogued VIEW objects (Phase 1.2). Each
4055 /// `SELECT FROM v` at engine exec-time looks up `v` here and
4056 /// prepends the view body as a synthetic CTE. Persisted in
4057 /// catalog FILE_VERSION 27+; older catalogs deserialise with
4058 /// an empty map.
4059 views: BTreeMap<String, ViewDef>,
4060 /// v7.17.0 — catalogued MATERIALIZED VIEW source registry
4061 /// (Phase 1.3). Maps name → SELECT source. The materialised
4062 /// rows themselves live as a regular `Table` with the same
4063 /// name; REFRESH re-parses + re-executes the source against
4064 /// the table. Persisted in catalog FILE_VERSION 28+;
4065 /// older catalogs deserialise with an empty map.
4066 materialized_views: BTreeMap<String, String>,
4067 /// v7.17.0 — catalogued user-defined ENUM types (Phase 1.4).
4068 /// Maps name → label list. Columns reference these by name
4069 /// via `ColumnSchema.user_enum_type`. Persisted in catalog
4070 /// FILE_VERSION 29+; older catalogs deserialise with an empty
4071 /// map.
4072 enum_types: BTreeMap<String, EnumDef>,
4073 /// v7.17.0 — catalogued user-defined DOMAIN types (Phase 1.5).
4074 /// Maps name → base + CHECK constraints. Columns reference
4075 /// these by name via `ColumnSchema.user_domain_type`.
4076 /// Persisted in catalog FILE_VERSION 30+; older catalogs
4077 /// deserialise with an empty map.
4078 domain_types: BTreeMap<String, DomainDef>,
4079 /// v7.39 (read01 round 50) — `COMMENT ON <kind> <obj> IS '…'` store.
4080 /// Keyed by a canonical `"<kind>:<name>"` string (`"table:t"`,
4081 /// `"column:t.c"`, `"index:i"`, `"view:v"`, …) so a new commentable
4082 /// object kind needs no schema change. `COMMENT … IS NULL` removes the
4083 /// entry. Persisted in catalog FILE_VERSION 61+; older catalogs
4084 /// deserialise with an empty map. Read back by obj_description /
4085 /// col_description and the pg_description view.
4086 comments: BTreeMap<String, String>,
4087 /// v7.39 (round 547) — PG's `pg_db_role_setting`: the GUC defaults
4088 /// `ALTER ROLE … SET` / `ALTER DATABASE … SET` record, applied when
4089 /// a session starts.
4090 ///
4091 /// Keyed exactly as PG keys it — `(database, role)` where an empty
4092 /// name is PG's oid 0, meaning "all". So `ALTER ROLE ALL SET` is
4093 /// `("", "")`, `ALTER DATABASE d SET` is `(d, "")`, `ALTER ROLE r
4094 /// SET` is `("", r)` and `ALTER ROLE r IN DATABASE d SET` is
4095 /// `(d, r)`. The value is that scope's parameter list.
4096 db_role_settings: BTreeMap<(String, String), BTreeMap<String, String>>,
4097 /// v7.39 (round 550) — replication slots, by name.
4098 ///
4099 /// A slot in PG is two things: a named record, and a reservation
4100 /// that holds WAL back. SPG keeps the record — which is what every
4101 /// setup script and monitoring query reads — and reports
4102 /// `wal_status = 'unreserved'`, PG's own word for a slot that no
4103 /// longer holds WAL. The whole family used to answer NULL and
4104 /// report success, so `pg_drop_replication_slot('nosuchslot')` said
4105 /// it worked and a setup script created nothing.
4106 ///
4107 /// Value: (plugin, slot_type). `plugin` is empty for a physical slot.
4108 replication_slots: BTreeMap<String, (String, String)>,
4109 /// v7.37.42-T2 ζ-B — catalogued user-defined COMPOSITE types
4110 /// (`CREATE TYPE name AS (field_name field_type, …)`). Columns
4111 /// reference these by name via
4112 /// `ColumnSchema.user_composite_type` (parallel to
4113 /// `user_enum_type` / `user_domain_type`). Persisted in catalog
4114 /// FILE_VERSION 52+; older catalogs deserialise with an empty
4115 /// map.
4116 composite_types: BTreeMap<String, CompositeDef>,
4117 /// v7.17.0 — schema-namespace registry (Phase 1.6). Tracks
4118 /// which schemas exist. `public`, `pg_catalog`, and
4119 /// `information_schema` are built-in and always present.
4120 /// Schema-qualified table references still strip the prefix
4121 /// at lookup time per v7.16-and-earlier — full
4122 /// schema-as-isolation is v7.18+ scope. Persisted in catalog
4123 /// FILE_VERSION 31+; older catalogs deserialise with just
4124 /// the built-ins.
4125 schemas: alloc::collections::BTreeSet<String>,
4126}
4127
4128/// v7.12.4 — catalogued user-defined function. `body` is the raw
4129/// source text between `$$ ... $$`; the engine re-parses it on
4130/// invocation. This keeps the storage codec stable when the
4131/// PL/pgSQL surface grows (no breaking-change risk on the disk
4132/// format).
4133// v7.39 (round 322, V46) — no longer `Eq`: COST / ROWS are f64, as in PG.
4134#[derive(Debug, Clone, PartialEq)]
4135pub struct FunctionDef {
4136 pub name: String,
4137 /// Display form of the argument list, e.g.
4138 /// `"(name TEXT, ts TIMESTAMP)"`. Empty `"()"` for the trigger
4139 /// function shape. Parser-side canonicalised before storage.
4140 pub args_repr: String,
4141 /// Display form of the return type, e.g. `"TRIGGER"` /
4142 /// `"INT"` / `"SETOF text"`. The engine special-cases
4143 /// `"TRIGGER"` (case-insensitive) to gate trigger-only
4144 /// semantics (NEW/OLD).
4145 pub returns: String,
4146 /// `LANGUAGE` clause, lowercased. `"plpgsql"` / `"sql"`.
4147 pub language: String,
4148 /// Source body of the function. PL/pgSQL: includes the
4149 /// surrounding `BEGIN ... END;`. SQL: includes the
4150 /// statement(s). The engine re-parses on invocation; bad
4151 /// bodies surface as a parse error at CALL time, not CREATE.
4152 pub body: String,
4153 /// v7.39 (read01 round 61) — the role that ran CREATE FUNCTION.
4154 pub owner: Option<String>,
4155 /// v7.39 (read01 round 61) — explicit GRANTs (PG `pg_proc.proacl`). EMPTY
4156 /// is NOT "nobody may call it": PG grants EXECUTE to PUBLIC by default, and
4157 /// leaves proacl NULL to say so. The list materialises on the first
4158 /// GRANT / REVOKE.
4159 pub acl: Vec<AclItem>,
4160 /// v7.39 (round 322, V46) — `IMMUTABLE` / `STRICT` / `PARALLEL SAFE` /
4161 /// `SECURITY DEFINER` / `LEAKPROOF` / `COST` / `ROWS`. `strict` is the
4162 /// only one with execution semantics today (a NULL argument yields a
4163 /// NULL result without running the body); the rest are recorded so
4164 /// `pg_get_functiondef` and `pg_proc` report what was declared.
4165 pub volatility: u8,
4166 pub strict: bool,
4167 pub security_definer: bool,
4168 pub leakproof: bool,
4169 pub parallel: u8,
4170 pub cost: Option<f64>,
4171 pub rows: Option<f64>,
4172}
4173
4174/// v7.39 (round 322, V46) — `FunctionDef.volatility` codes: PG's
4175/// `pg_proc.provolatile` letters.
4176pub const FN_VOLATILE: u8 = b'v';
4177pub const FN_IMMUTABLE: u8 = b'i';
4178pub const FN_STABLE: u8 = b's';
4179
4180/// v7.39 (round 322, V46) — `FunctionDef.parallel` codes: PG's
4181/// `pg_proc.proparallel` letters.
4182pub const FN_PARALLEL_UNSAFE: u8 = b'u';
4183pub const FN_PARALLEL_RESTRICTED: u8 = b'r';
4184pub const FN_PARALLEL_SAFE: u8 = b's';
4185
4186/// v7.39 (round 315, V19) — which catalogued function does a persisted
4187/// ACL key refer to?
4188///
4189/// The key was computed by whichever formula was current when the image
4190/// was written, and the multi-word fix changed that formula for bare
4191/// types like `double precision`. A miss therefore does NOT mean "no
4192/// such function": an older image's key would land nowhere and its owner
4193/// and grants would be dropped in silence. Exact match first, then the
4194/// pre-fix formula.
4195#[must_use]
4196pub fn resolve_stored_function_key(
4197 functions: &BTreeMap<String, FunctionDef>,
4198 stored: &str,
4199) -> Option<String> {
4200 if functions.contains_key(stored) {
4201 return Some(stored.to_string());
4202 }
4203 functions
4204 .values()
4205 .find(|f| function_signature_key_legacy(&f.name, &f.args_repr) == stored)
4206 .map(|f| function_signature_key(&f.name, &f.args_repr))
4207}
4208
4209/// v7.39 (round 344, V49) — re-exported from [`spg_sql`], which owns the
4210/// SQL type spellings. This crate carried a byte-identical copy because
4211/// the two were siblings that did not depend on each other; spg-sql is a
4212/// dependency-free leaf, so the dependency is acyclic and the publish
4213/// order already puts it first. One list, one place to keep it right.
4214pub use spg_sql::parser::is_multiword_type_phrase;
4215
4216/// v7.39 (round 315, V19) — the signature key as computed BEFORE the
4217/// multi-word fix, used only to recognise what an older image wrote.
4218///
4219/// The function catalogue recomputes its keys from the stored name and
4220/// argument text on load, so it needs no migration. The ACL block does
4221/// not: it persists the computed key as a string and matches on it. A
4222/// key that changed shape would simply fail to match, and the owner and
4223/// grants would be dropped without a word — so the loader falls back to
4224/// this when the stored key finds nothing.
4225#[must_use]
4226pub fn function_signature_key_legacy(name: &str, args_repr: &str) -> String {
4227 let inner = args_repr
4228 .trim()
4229 .trim_start_matches('(')
4230 .trim_end_matches(')');
4231 let types: Vec<String> = if inner.trim().is_empty() {
4232 Vec::new()
4233 } else {
4234 inner
4235 .split(',')
4236 .map(|part| {
4237 let mut words: Vec<&str> = part.split_whitespace().collect();
4238 if !words.is_empty()
4239 && (words[0].eq_ignore_ascii_case("OUT")
4240 || words[0].eq_ignore_ascii_case("INOUT"))
4241 {
4242 words.remove(0);
4243 }
4244 let ty = if words.len() >= 2 {
4245 words[1..].join(" ")
4246 } else {
4247 words.first().map_or(String::new(), |w| (*w).to_string())
4248 };
4249 normalize_type_name(&ty)
4250 })
4251 .collect()
4252 };
4253 format!("{}({})", name.to_ascii_lowercase(), types.join(","))
4254}
4255
4256pub fn function_signature_key(name: &str, args_repr: &str) -> String {
4257 let types = function_arg_types(args_repr);
4258 format!("{}({})", name.to_ascii_lowercase(), types.join(","))
4259}
4260
4261/// The declared argument TYPES of a function, out of its `args_repr`
4262/// (`"(x INT, y DOUBLE PRECISION)"` → `["int", "float"]`). An entry may be a
4263/// bare type with no name (`"(INT)"`).
4264#[must_use]
4265pub fn function_arg_types(args_repr: &str) -> Vec<String> {
4266 let inner = args_repr
4267 .trim()
4268 .trim_start_matches('(')
4269 .trim_end_matches(')');
4270 if inner.trim().is_empty() {
4271 return Vec::new();
4272 }
4273 inner
4274 .split(',')
4275 .map(|part| {
4276 let mut words: Vec<&str> = part.split_whitespace().collect();
4277 // `OUT x INT` / `INOUT x INT` — the mode is not part of the type.
4278 if !words.is_empty()
4279 && (words[0].eq_ignore_ascii_case("OUT") || words[0].eq_ignore_ascii_case("INOUT"))
4280 {
4281 words.remove(0);
4282 }
4283 // v7.39 (round 315, V19) — two or more words is USUALLY
4284 // `name TYPE`, but not when the type itself is spelled in
4285 // several words. `double precision` was read as a parameter
4286 // named "double" of type "precision", so it keyed differently
4287 // from `x double precision` — the same signature written two
4288 // ways did not resolve to the same function. Decide by asking
4289 // whether the whole phrase names a type first; only then is
4290 // the leading word a parameter name.
4291 let whole = words.join(" ");
4292 let ty = if words.len() >= 2 && !is_multiword_type_phrase(&whole) {
4293 words[1..].join(" ")
4294 } else {
4295 whole
4296 };
4297 normalize_type_name(&ty)
4298 })
4299 .collect()
4300}
4301
4302/// v7.39 (read01 round 65) — the declared argument NAMES of a function (`""` for
4303/// a bare type with no name).
4304#[must_use]
4305pub fn function_arg_names(args_repr: &str) -> Vec<String> {
4306 let inner = args_repr
4307 .trim()
4308 .trim_start_matches('(')
4309 .trim_end_matches(')');
4310 if inner.trim().is_empty() {
4311 return Vec::new();
4312 }
4313 inner
4314 .split(',')
4315 .map(|part| {
4316 let mut words: Vec<&str> = part.split_whitespace().collect();
4317 if !words.is_empty()
4318 && (words[0].eq_ignore_ascii_case("OUT") || words[0].eq_ignore_ascii_case("INOUT"))
4319 {
4320 words.remove(0);
4321 }
4322 if words.len() >= 2 {
4323 words[0].to_string()
4324 } else {
4325 String::new()
4326 }
4327 })
4328 .collect()
4329}
4330
4331/// Fold PG's type aliases so a signature key is stable across spellings.
4332/// Unknown names pass through lower-cased — consistency is what the key needs.
4333#[must_use]
4334pub fn normalize_type_name(ty: &str) -> String {
4335 let t = ty.trim().to_ascii_lowercase();
4336 // Peel a precision/length modifier: `numeric(10,2)`, `varchar(64)`.
4337 let base = t.split_once('(').map_or(t.as_str(), |(h, _)| h).trim();
4338 match base {
4339 "int" | "int4" | "integer" => "int",
4340 "bigint" | "int8" => "bigint",
4341 "smallint" | "int2" => "smallint",
4342 "text" | "varchar" | "character varying" | "char" | "character" | "bpchar" => "text",
4343 "bool" | "boolean" => "bool",
4344 "float" | "float8" | "double precision" => "float",
4345 "real" | "float4" => "real",
4346 "numeric" | "decimal" => "numeric",
4347 "timestamptz" | "timestamp with time zone" => "timestamptz",
4348 "timestamp" | "timestamp without time zone" => "timestamp",
4349 other => other,
4350 }
4351 .to_string()
4352}
4353
4354/// v7.12.4 — catalogued trigger. References its function by
4355/// name; the function must exist at TRIGGER creation time
4356/// (forward references are deferred to v7.12.5+).
4357#[derive(Debug, Clone, PartialEq, Eq)]
4358pub struct TriggerDef {
4359 pub name: String,
4360 /// Watched table. Trigger is dropped when the table drops.
4361 pub table: String,
4362 /// `"BEFORE"` / `"AFTER"` / `"INSTEAD OF"`. Stored as the
4363 /// uppercased keyword so deserialised catalogs round-trip
4364 /// without canonicalisation surprises.
4365 pub timing: String,
4366 /// Each entry is one of `"INSERT"` / `"UPDATE"` / `"DELETE"`
4367 /// / `"TRUNCATE"`. `INSERT OR UPDATE` parses to two entries.
4368 pub events: Vec<String>,
4369 /// `"ROW"` / `"STATEMENT"`. v7.12.4 ships `"ROW"` only;
4370 /// `"STATEMENT"` parses and persists but the executor
4371 /// refuses it at trigger fire time.
4372 pub for_each: String,
4373 /// Name of the PL/pgSQL function to invoke.
4374 pub function: String,
4375 /// v7.13.0 — `UPDATE OF col, col, …` column-list filter
4376 /// (mailrs round-5 G7). Non-empty means the trigger fires
4377 /// only when at least one of these columns appears in the
4378 /// UPDATE's SET list. Empty = no column filter. Stored in
4379 /// catalog FILE_VERSION 23+; older catalogs deserialise with
4380 /// an empty vec.
4381 pub update_columns: Vec<String>,
4382 /// v7.16.1 — whether the trigger fires when its watched
4383 /// event occurs. Toggled by `ALTER TABLE … { ENABLE |
4384 /// DISABLE } TRIGGER …`; pg_dump --disable-triggers wraps
4385 /// every data block with a DISABLE/ENABLE pair so the
4386 /// rows already-computed in prod don't get re-rewritten.
4387 /// Defaults to `true` at CREATE TRIGGER time. Stored in
4388 /// catalog FILE_VERSION 25+; older catalogs deserialise
4389 /// with `enabled = true`.
4390 pub enabled: bool,
4391 /// v7.39 (round 138) — the deparsed `WHEN ( condition )` predicate text
4392 /// (re-parsed at fire time to filter row triggers). Empty = no WHEN.
4393 /// Persisted from FILE_VERSION 70; older catalogs read back empty.
4394 pub when_condition: String,
4395}
4396
4397/// v7.39 (round 280) — one `CREATE STATISTICS` object.
4398#[derive(Debug, Clone, PartialEq, Eq)]
4399pub struct StatisticsExtDef {
4400 pub name: String,
4401 pub table: String,
4402 /// PG's single-letter kinds: `d` ndistinct, `f` dependencies,
4403 /// `m` mcv. PG's default set is all three.
4404 pub kinds: Vec<String>,
4405 pub columns: Vec<String>,
4406}
4407
4408/// v7.39 (round 139) — a catalogued query-rewrite RULE. Stored flat like
4409/// `TriggerDef`, keyed by `(name, table)`. Command / WHEN text is deparsed SQL
4410/// re-parsed at rewrite time (the same round-trip trick as
4411/// `TriggerDef.when_condition`). Persisted from FILE_VERSION 71.
4412#[derive(Debug, Clone, PartialEq, Eq)]
4413pub struct RuleDef {
4414 pub name: String,
4415 pub table: String,
4416 /// Event keyword, uppercased: `INSERT` / `UPDATE` / `DELETE` / `SELECT`.
4417 pub event: String,
4418 /// `true` = `DO INSTEAD`, `false` = `DO ALSO`.
4419 pub instead: bool,
4420 /// Deparsed `WHERE` predicate text; empty = unconditional.
4421 pub when_condition: String,
4422 /// Deparsed DO command statements; empty = `NOTHING`.
4423 pub commands: Vec<String>,
4424}
4425
4426/// v7.17.0 — catalogued SEQUENCE. PG semantics: a counter object
4427/// returning monotonically increasing values via `nextval(name)`.
4428/// `last_value` is the most recent value handed out; `is_called`
4429/// is false until the first `nextval`/`setval`. Stored separately
4430/// from tables in the catalog.
4431#[derive(Debug, Clone, PartialEq, Eq)]
4432pub struct SequenceDef {
4433 pub name: String,
4434 /// Data type — narrows the i64 range. PG default BIGINT.
4435 pub data_type: SequenceDataType,
4436 pub start: i64,
4437 pub increment: i64,
4438 pub min_value: i64,
4439 pub max_value: i64,
4440 pub cache: i64,
4441 pub cycle: bool,
4442 /// `OWNED BY` target — `(table, column)` or NONE.
4443 pub owned_by: Option<(String, String)>,
4444 /// Most recently handed-out value. Meaningless when
4445 /// `is_called == false`; in that case the NEXT `nextval`
4446 /// will return `start`.
4447 pub last_value: i64,
4448 pub is_called: bool,
4449 /// v7.39 (read01 round 60) — the role that ran CREATE SEQUENCE. `None` = an
4450 /// image written before FILE_VERSION 66, which predates sequence owners.
4451 pub owner: Option<String>,
4452 /// v7.39 (read01 round 60) — explicit GRANTs on this sequence. A sequence's
4453 /// meaningful privileges are SELECT (`currval`), UPDATE (`setval`) and
4454 /// USAGE (`nextval`).
4455 pub acl: Vec<AclItem>,
4456}
4457
4458/// v7.17.0 — sequence integer width.
4459#[derive(Debug, Clone, Copy, PartialEq, Eq)]
4460pub enum SequenceDataType {
4461 SmallInt,
4462 Int,
4463 BigInt,
4464}
4465
4466/// v7.17.0 Phase 1.6 — built-in schema names that every Catalog
4467/// understands without an explicit CREATE SCHEMA. Used by
4468/// [`Catalog::schema_exists`] and the engine's schema-qualified
4469/// lookup path.
4470#[must_use]
4471pub fn is_builtin_schema(name: &str) -> bool {
4472 name.eq_ignore_ascii_case("public")
4473 || name.eq_ignore_ascii_case("pg_catalog")
4474 || name.eq_ignore_ascii_case("information_schema")
4475}
4476
4477/// v7.17.0 — parse a PG-canonical UUID text representation into the
4478/// 16-byte network-order layout used by `Value::Uuid`. Accepted input
4479/// shapes (all case-insensitive):
4480/// * Canonical hyphenated 8-4-4-4-12 (`550e8400-e29b-41d4-a716-446655440000`)
4481/// * Unhyphenated 32-char hex (`550e8400e29b41d4a716446655440000`)
4482/// * Either form wrapped in `{ ... }`
4483///
4484/// Returns `None` for any malformed input (wrong length, non-hex
4485/// characters, misplaced hyphens). The caller surfaces a SQL error
4486/// at coercion time — silent acceptance of garbage would mask
4487/// application bugs and is exactly the divergence from PG that
4488/// breaks the 0-change cutover promise.
4489#[must_use]
4490pub fn parse_uuid_str(input: &str) -> Option<[u8; 16]> {
4491 let s = input.trim();
4492 // Strip surrounding braces if present.
4493 let s = if let Some(inner) = s.strip_prefix('{').and_then(|x| x.strip_suffix('}')) {
4494 inner
4495 } else {
4496 s
4497 };
4498 // Two valid shapes after braces are stripped: 32 hex chars or
4499 // the canonical 36-char hyphenated form.
4500 let hex: String = match s.len() {
4501 32 => s.to_ascii_lowercase(),
4502 36 => {
4503 // Hyphens must be exactly at positions 8, 13, 18, 23.
4504 let b = s.as_bytes();
4505 if b[8] != b'-' || b[13] != b'-' || b[18] != b'-' || b[23] != b'-' {
4506 return None;
4507 }
4508 let mut out = String::with_capacity(32);
4509 out.push_str(&s[0..8]);
4510 out.push_str(&s[9..13]);
4511 out.push_str(&s[14..18]);
4512 out.push_str(&s[19..23]);
4513 out.push_str(&s[24..36]);
4514 out.make_ascii_lowercase();
4515 out
4516 }
4517 _ => return None,
4518 };
4519 let bytes = hex.as_bytes();
4520 let mut out = [0u8; 16];
4521 for i in 0..16 {
4522 let hi = hex_nibble(bytes[i * 2])?;
4523 let lo = hex_nibble(bytes[i * 2 + 1])?;
4524 out[i] = (hi << 4) | lo;
4525 }
4526 Some(out)
4527}
4528
4529fn hex_nibble(b: u8) -> Option<u8> {
4530 match b {
4531 b'0'..=b'9' => Some(b - b'0'),
4532 b'a'..=b'f' => Some(10 + b - b'a'),
4533 b'A'..=b'F' => Some(10 + b - b'A'),
4534 _ => None,
4535 }
4536}
4537
4538/// v7.17.0 — render a `Value::Uuid` payload as the canonical
4539/// lowercase 8-4-4-4-12 hyphenated form PG `text` cast surfaces.
4540#[must_use]
4541pub fn format_uuid(b: &[u8; 16]) -> String {
4542 const HEX: &[u8; 16] = b"0123456789abcdef";
4543 let mut out = String::with_capacity(36);
4544 for (i, byte) in b.iter().enumerate() {
4545 if matches!(i, 4 | 6 | 8 | 10) {
4546 out.push('-');
4547 }
4548 out.push(HEX[(byte >> 4) as usize] as char);
4549 out.push(HEX[(byte & 0x0f) as usize] as char);
4550 }
4551 out
4552}
4553
4554/// v7.17.0 Phase 1.5 — catalogued user-defined DOMAIN. A domain
4555/// is a named CHECK-constrained alias over a built-in type;
4556/// columns bound to it inherit the base type plus the CHECK
4557/// predicates + NOT NULL + DEFAULT at INSERT/UPDATE time.
4558/// v7.37.17 (Phase E RC rebase) — the write-set one writer version left
4559/// on a table, addressed by stable [`row_header::RowId`]s so it can be
4560/// replayed onto a fresher clone of the relation whose physical slots
4561/// differ. Produced by [`Table::extract_tx_writeset`], consumed by
4562/// [`Table::replay_tx_writeset`].
4563#[derive(Debug, Clone, Default)]
4564pub struct TxWriteSet {
4565 /// INSERTs and UPDATE-new-versions (`header.xmin == v`).
4566 pub inserted: Vec<(row_header::RowId, Row<'static>)>,
4567 /// DELETE / UPDATE-old-version targets (`header.xmax == v`).
4568 pub tombstoned: Vec<row_header::RowId>,
4569}
4570
4571impl TxWriteSet {
4572 #[must_use]
4573 pub fn is_empty(&self) -> bool {
4574 self.inserted.is_empty() && self.tombstoned.is_empty()
4575 }
4576}
4577
4578/// v7.39 (round 260) — one named CHECK on a domain. PG auto-names an
4579/// unnamed one `<domain>_check`, then `_check1`, `_check2`, … (probed).
4580#[derive(Debug, Clone, PartialEq, Eq)]
4581pub struct DomainCheck {
4582 pub name: String,
4583 /// The predicate source, referencing the pseudo-column `VALUE`.
4584 pub expr: String,
4585}
4586
4587/// `default` / `checks` are stored as Display-form source so
4588/// `spg-storage` stays free of `spg-sql` dependency — same
4589/// pattern as FunctionDef / ViewDef.
4590#[derive(Debug, Clone, PartialEq, Eq)]
4591pub struct DomainDef {
4592 pub name: String,
4593 pub base_type: DataType,
4594 pub nullable: bool,
4595 pub default: Option<String>,
4596 /// v7.39 (round 260) — each CHECK carries its constraint NAME, so
4597 /// `ALTER DOMAIN … DROP CONSTRAINT <name>` can find it and the
4598 /// violation message can report the constraint that actually failed.
4599 /// PG's auto-naming for an unnamed check is `<domain>_check`, then
4600 /// `_check1`, `_check2`, … (probed).
4601 pub checks: Vec<DomainCheck>,
4602 /// v7.39 (round 258/259) — when this domain was declared over ANOTHER
4603 /// domain (`CREATE DOMAIN child AS parent CHECK (…)`), the parent's
4604 /// name. `base_type` is the ultimate scalar type either way, so
4605 /// without this the parent's constraints were invisible and a value
4606 /// violating them was silently accepted. PG checks the whole chain,
4607 /// base-first, and an `ALTER DOMAIN` on the parent takes effect for
4608 /// the child immediately (probed) — so the chain is walked at check
4609 /// time rather than copied at CREATE time. Catalog FILE_VERSION 74+.
4610 pub base_domain: Option<String>,
4611}
4612
4613/// v7.17.0 Phase 1.4 — catalogued user-defined ENUM type. The
4614/// label vector is order-preserving (PG enum ordering follows the
4615/// declared order). At INSERT/UPDATE on a column bound to this
4616/// enum, the engine looks up the value against `labels` and
4617/// rejects non-members.
4618#[derive(Debug, Clone, PartialEq, Eq)]
4619pub struct EnumDef {
4620 pub name: String,
4621 pub labels: Vec<String>,
4622}
4623
4624/// v7.37.42-T2 ζ-B — catalogued user-defined COMPOSITE type
4625/// (`CREATE TYPE name AS (field_name field_type, ...)`). Order
4626/// matters: PG composite literals are positional, and SPG mirrors
4627/// that. Stored as ordered `(name, DataType)` pairs to keep the
4628/// codec straightforward and to allow eventual `Value::Composite`
4629/// bodies to encode positionally. Persisted in catalog FILE_VERSION
4630/// 52+; older catalogs deserialise with an empty composite_types
4631/// map. Composite types can be used as a column type by spelling
4632/// the composite's name; the resolution from
4633/// `ColumnSchema.user_composite_type = Some(name)` happens at the
4634/// engine boundary (parallel to `user_enum_type` /
4635/// `user_domain_type`). The dense storage shape — JSON-text body
4636/// keyed by the composite's field list — keeps the codec free of
4637/// recursive `Value` bodies until the full Value::Composite arena
4638/// migration in a later phase.
4639#[derive(Debug, Clone, PartialEq, Eq)]
4640pub struct CompositeDef {
4641 pub name: String,
4642 /// Ordered `(field_name, field_type)` pairs. PG composite
4643 /// literals are positional, so order is part of the type's
4644 /// identity.
4645 pub fields: Vec<(String, DataType)>,
4646 /// v7.39 (round 264) — parallel to `fields`: the USER type name of
4647 /// each field when it is itself a composite (or another named user
4648 /// type). `DataType` has no room for one, so a nested composite
4649 /// field resolved to the parser's Text placeholder and the inner
4650 /// record stayed TEXT — `(x).inner.street` errored, `pg_typeof`
4651 /// said text, and `row_to_json` nested a string instead of an
4652 /// object. Same shape as `ColumnSchema.user_composite_type` and
4653 /// `DomainDef.base_domain`. Catalog FILE_VERSION 76+; an older
4654 /// catalog reads all-None, which is what it meant.
4655 pub field_user_types: Vec<Option<String>>,
4656}
4657
4658/// v7.17.0 Phase 1.2 — catalogued VIEW. The body is stored as the
4659/// raw source text the parser saw between `AS` and the statement
4660/// terminator; the engine re-parses on each invocation. Same
4661/// pattern as `FunctionDef` — keeps `spg-storage` free of
4662/// `spg-sql` dependency.
4663#[derive(Debug, Clone, PartialEq, Eq)]
4664pub struct ViewDef {
4665 pub name: String,
4666 /// Optional `(col, col, …)` rename list. Empty when the body's
4667 /// projected names are used directly.
4668 pub columns: Vec<String>,
4669 /// Raw SELECT source. Display-rendered at storage time so the
4670 /// catalog round-trips a deterministic form regardless of
4671 /// whitespace / comments in the original input. Re-parsed at
4672 /// SELECT-from-view time to materialise as a synthetic CTE.
4673 pub body: String,
4674 /// v7.39 (round 132) — `WITH CHECK OPTION`: 0 = none, 1 = LOCAL,
4675 /// 2 = CASCADED. A storage-local u8 (no dependency on the SQL AST).
4676 /// Persisted from FILE_VERSION 69; older catalogs read back as 0.
4677 pub check_option: u8,
4678}
4679
4680impl SequenceDataType {
4681 /// PG default min/max per AS clause.
4682 pub fn default_bounds(self, increment_positive: bool) -> (i64, i64) {
4683 match self {
4684 Self::SmallInt => {
4685 if increment_positive {
4686 (1, i64::from(i16::MAX))
4687 } else {
4688 (i64::from(i16::MIN), -1)
4689 }
4690 }
4691 Self::Int => {
4692 if increment_positive {
4693 (1, i64::from(i32::MAX))
4694 } else {
4695 (i64::from(i32::MIN), -1)
4696 }
4697 }
4698 Self::BigInt => {
4699 if increment_positive {
4700 (1, i64::MAX)
4701 } else {
4702 (i64::MIN, -1)
4703 }
4704 }
4705 }
4706 }
4707}
4708
4709impl Catalog {
4710 /// v7.37.15 (Phase D) — fleet-wide vacuum pass. Walks every
4711 /// user table and reclaims rows whose delete-commit version is
4712 /// older than `oldest_active_snapshot`. Returns an aggregated
4713 /// report with per-table breakdown so hosts can emit metrics.
4714 ///
4715 /// `dry_run = true` reports the work without doing it. Use it
4716 /// to estimate the cost before scheduling a real pass.
4717 pub fn vacuum_all(
4718 &mut self,
4719 oldest_active_snapshot: u64,
4720 dry_run: bool,
4721 ) -> vacuum::VacuumReport {
4722 let mut total = vacuum::VacuumReport::default();
4723 // Snapshot the table names so we don't hold an immutable
4724 // borrow during the get_mut loop.
4725 let names: Vec<String> = self
4726 .tables
4727 .iter()
4728 .map(|t| t.schema().name.clone())
4729 .collect();
4730 for name in names {
4731 let Some(t) = self.get_mut(&name) else {
4732 continue;
4733 };
4734 let r = t.vacuum(oldest_active_snapshot, dry_run);
4735 if r.rows_reclaimed > 0 {
4736 total.per_table.push((name, r.rows_reclaimed));
4737 }
4738 total.rows_reclaimed += r.rows_reclaimed;
4739 total.rows_examined += r.rows_examined;
4740 }
4741 total
4742 }
4743
4744 pub const fn new() -> Self {
4745 Self {
4746 cold_read_stats: ColdReadStats {
4747 cold_reads: core::sync::atomic::AtomicU64::new(0),
4748 },
4749 tables: Vec::new(),
4750 by_name: BTreeMap::new(),
4751 temp_prefix: None,
4752 dirty_tables: alloc::collections::BTreeSet::new(),
4753 next_rel_id: 0,
4754 cold_segments: Vec::new(),
4755 functions: BTreeMap::new(),
4756 triggers: Vec::new(),
4757 rules: Vec::new(),
4758 statistics_ext: Vec::new(),
4759 large_objects: alloc::collections::BTreeMap::new(),
4760 sequences: BTreeMap::new(),
4761 schema_acl: Vec::new(),
4762 database_acl: Vec::new(),
4763 views: BTreeMap::new(),
4764 materialized_views: BTreeMap::new(),
4765 enum_types: BTreeMap::new(),
4766 domain_types: BTreeMap::new(),
4767 comments: BTreeMap::new(),
4768 db_role_settings: BTreeMap::new(),
4769 replication_slots: BTreeMap::new(),
4770 composite_types: BTreeMap::new(),
4771 schemas: alloc::collections::BTreeSet::new(),
4772 }
4773 }
4774
4775 /// v7.12.4 — read-only view of catalogued user-defined
4776 /// functions. Engine callers go through here to look up the
4777 /// function body before re-parsing it for invocation.
4778 pub const fn functions(&self) -> &BTreeMap<String, FunctionDef> {
4779 &self.functions
4780 }
4781
4782 /// v7.12.4 — register a new user-defined function. With
4783 /// `or_replace = false`, errors if the name is taken. The
4784 /// engine validates the body before passing it here.
4785 pub fn create_function(
4786 &mut self,
4787 def: FunctionDef,
4788 or_replace: bool,
4789 ) -> Result<(), StorageError> {
4790 // v7.39 (read01 round 62) — functions are keyed by SIGNATURE, not by
4791 // name: `f(int)` and `f(text)` are two functions, as in PG. Keying by
4792 // name alone made a second overload an "already exists" error — so a
4793 // pg_dump carrying an overload set could not restore — and, worse, a
4794 // call to one overload silently ran the other.
4795 let key = function_signature_key(&def.name, &def.args_repr);
4796 if !or_replace && self.functions.contains_key(&key) {
4797 return Err(StorageError::Corrupt(format!(
4798 "function {:?} already exists (drop or use CREATE OR REPLACE)",
4799 def.name
4800 )));
4801 }
4802 self.functions.insert(key, def);
4803 Ok(())
4804 }
4805
4806 /// v7.39 (read01 round 62) — every overload of `name`.
4807 #[must_use]
4808 pub fn functions_named(&self, name: &str) -> Vec<&FunctionDef> {
4809 self.functions
4810 .values()
4811 .filter(|f| f.name.eq_ignore_ascii_case(name))
4812 .collect()
4813 }
4814
4815 /// v7.39 (read01 round 62) — one overload, by its signature key.
4816 #[must_use]
4817 pub fn function_by_key(&self, key: &str) -> Option<&FunctionDef> {
4818 self.functions.get(key)
4819 }
4820
4821 /// v7.39 (read01 round 62) — drop ONE overload. `true` if it was there.
4822 pub fn drop_function_by_key(&mut self, key: &str) -> bool {
4823 self.functions.remove(key).is_some()
4824 }
4825
4826 /// v7.12.4 — remove a user-defined function by name. Returns
4827 /// `true` if a function was removed, `false` if none matched.
4828 /// Caller decides whether to surface `if_exists` semantics.
4829 /// v7.39 (read01 round 62) — with no signature, PG drops the function only
4830 /// when the name is unambiguous. SPG mirrors that: this removes EVERY
4831 /// overload of `name`, and the caller (ddl.rs) refuses the ambiguous case
4832 /// before getting here.
4833 pub fn drop_function(&mut self, name: &str) -> bool {
4834 let keys: Vec<String> = self
4835 .functions
4836 .iter()
4837 .filter(|(_, f)| f.name.eq_ignore_ascii_case(name))
4838 .map(|(k, _)| k.clone())
4839 .collect();
4840 let hit = !keys.is_empty();
4841 for k in keys {
4842 self.functions.remove(&k);
4843 }
4844 hit
4845 }
4846
4847 /// v7.17.0 — read-only handle to catalogued sequences.
4848 /// v7.39 (read01 round 60) — the `public` schema's ACL (PG nspacl).
4849 #[must_use]
4850 pub fn schema_acl(&self) -> &[AclItem] {
4851 &self.schema_acl
4852 }
4853
4854 pub fn schema_acl_mut(&mut self) -> &mut Vec<AclItem> {
4855 &mut self.schema_acl
4856 }
4857
4858 /// v7.39 (read01 round 60) — the database's ACL.
4859 #[must_use]
4860 pub fn database_acl(&self) -> &[AclItem] {
4861 &self.database_acl
4862 }
4863
4864 pub fn database_acl_mut(&mut self) -> &mut Vec<AclItem> {
4865 &mut self.database_acl
4866 }
4867
4868 /// v7.39 (read01 round 60) — mutable sequence access, for GRANT.
4869 /// v7.39 (round 469) — resolves the session's temporary sequence
4870 /// first, like its read-only twin. `nextval` and `setval` reach the
4871 /// map through here, so a temporary sequence shadowing a permanent one
4872 /// advances the temporary one — measured against PG18, where the
4873 /// permanent sequence's counter is untouched while the temp exists.
4874 pub fn sequence_mut(&mut self, name: &str) -> Option<&mut SequenceDef> {
4875 let key = self.sequence_key(name);
4876 self.sequences.get_mut(&key)
4877 }
4878
4879 /// v7.39 (read01 round 61) — mutable function access, for GRANT.
4880 pub fn function_mut(&mut self, name: &str) -> Option<&mut FunctionDef> {
4881 self.functions.get_mut(name)
4882 }
4883
4884 /// Every catalogued sequence, temp ones included under their mangled
4885 /// storage names. Listing code filters these through
4886 /// [`Self::listed_name`]; anything resolving ONE name by its logical
4887 /// spelling wants [`Self::sequence`] instead.
4888 pub const fn sequences_all(&self) -> &BTreeMap<String, SequenceDef> {
4889 &self.sequences
4890 }
4891
4892 /// v7.39 (round 469) — resolve one sequence by its logical name, the
4893 /// session's temporary one winning over a permanent one of the same
4894 /// name. The same rule [`Self::resolve_index`] applies to tables.
4895 #[must_use]
4896 pub fn sequence(&self, name: &str) -> Option<&SequenceDef> {
4897 if let Some(mangled) = self.temp_name_for(name)
4898 && let Some(def) = self.sequences.get(&mangled)
4899 {
4900 return Some(def);
4901 }
4902 self.sequences.get(name)
4903 }
4904
4905 /// Does a sequence of this logical name exist for this session?
4906 #[must_use]
4907 pub fn has_sequence(&self, name: &str) -> bool {
4908 self.sequence(name).is_some()
4909 }
4910
4911 /// The storage key a sequence of this logical name resolves to — the
4912 /// session's temp mangling when it has one, else the name itself.
4913 #[must_use]
4914 pub fn sequence_key(&self, name: &str) -> String {
4915 if let Some(mangled) = self.temp_name_for(name)
4916 && self.sequences.contains_key(&mangled)
4917 {
4918 return mangled;
4919 }
4920 name.into()
4921 }
4922
4923 /// v7.17.0 — register a new SEQUENCE. Errors if `name`
4924 /// collides with an existing sequence and `if_not_exists`
4925 /// is false.
4926 pub fn create_sequence(
4927 &mut self,
4928 def: SequenceDef,
4929 if_not_exists: bool,
4930 ) -> Result<(), StorageError> {
4931 if self.sequences.contains_key(&def.name) {
4932 if if_not_exists {
4933 return Ok(());
4934 }
4935 // v7.39 (read01 round 47) — a sequence is a relation to PG (42P07).
4936 return Err(StorageError::Corrupt(format!(
4937 "relation {:?} already exists",
4938 def.name
4939 )));
4940 }
4941 self.sequences.insert(def.name.clone(), def);
4942 Ok(())
4943 }
4944
4945 /// v7.17.0 — remove a SEQUENCE by name. Returns `true` if a
4946 /// sequence was removed, `false` if none matched. Caller
4947 /// surfaces IF EXISTS semantics.
4948 /// v7.39 (read01 round 49) — `ALTER SEQUENCE old RENAME TO new`.
4949 /// Errors when `old` is missing or `new` is taken; the SequenceDef's own
4950 /// `name` field is rewritten so it stays self-describing.
4951 pub fn rename_sequence(&mut self, old: &str, new: &str) -> Result<(), StorageError> {
4952 if !self.sequences.contains_key(old) {
4953 return Err(StorageError::Corrupt(format!(
4954 "relation {old:?} does not exist"
4955 )));
4956 }
4957 if self.sequences.contains_key(new) {
4958 return Err(StorageError::Corrupt(format!(
4959 "relation {new:?} already exists"
4960 )));
4961 }
4962 if let Some(mut def) = self.sequences.remove(old) {
4963 def.name = new.to_string();
4964 self.sequences.insert(new.to_string(), def);
4965 }
4966 Ok(())
4967 }
4968
4969 pub fn drop_sequence(&mut self, name: &str) -> bool {
4970 self.sequences.remove(name).is_some()
4971 }
4972
4973 /// v7.17.0 — atomic nextval. Increments `last_value` per
4974 /// `increment`, returns the new value, sets `is_called`.
4975 /// Returns an error on CYCLE-less overflow.
4976 /// v7.39 (round 497) — the counter state of every sequence, for
4977 /// carrying across a commit install.
4978 ///
4979 /// A sequence's VALUE is not transactional in PG: `nextval` advances
4980 /// shared state that a rollback does not give back, because two
4981 /// sessions must never receive the same number. SPG keeps sequences in
4982 /// the catalog, and a transaction works on a catalog CLONE, so
4983 /// installing that clone at COMMIT would restore whatever the counter
4984 /// was at BEGIN. These two let the install put the live counters back.
4985 #[must_use]
4986 pub fn sequence_counters(&self) -> Vec<(String, i64, bool)> {
4987 self.sequences
4988 .iter()
4989 .map(|(k, d)| (k.clone(), d.last_value, d.is_called))
4990 .collect()
4991 }
4992
4993 /// Restore counters saved by [`Self::sequence_counters`], for the
4994 /// sequences that still exist. A sequence the transaction CREATED is
4995 /// absent from the saved set and keeps the value it was given.
4996 pub fn restore_sequence_counters(&mut self, saved: &[(String, i64, bool)]) {
4997 for (k, last, called) in saved {
4998 if let Some(d) = self.sequences.get_mut(k) {
4999 d.last_value = *last;
5000 d.is_called = *called;
5001 }
5002 }
5003 }
5004
5005 pub fn sequence_next_value(&mut self, name: &str) -> Result<i64, StorageError> {
5006 let key = self.sequence_key(name);
5007 let Some(seq) = self.sequences.get_mut(&key) else {
5008 return Err(StorageError::TableNotFound { name: name.into() });
5009 };
5010 // PG semantics: when !is_called (fresh sequence or
5011 // setval(_, false)), the next nextval returns the stored
5012 // `last_value`. When is_called, it advances by `increment`
5013 // and CYCLE-wraps on overflow.
5014 let candidate = if seq.is_called {
5015 let next = seq.last_value.checked_add(seq.increment).ok_or_else(|| {
5016 StorageError::Corrupt(format!("sequence {name:?} arithmetic overflow"))
5017 })?;
5018 if seq.increment > 0 {
5019 if next > seq.max_value {
5020 if seq.cycle {
5021 seq.min_value
5022 } else {
5023 // v7.39 (round 220) — PG's 2200H wording, not a
5024 // Corrupt-classed error.
5025 return Err(StorageError::SequenceExhausted {
5026 name: name.into(),
5027 limit: seq.max_value,
5028 is_max: true,
5029 });
5030 }
5031 } else {
5032 next
5033 }
5034 } else if next < seq.min_value {
5035 if seq.cycle {
5036 seq.max_value
5037 } else {
5038 return Err(StorageError::SequenceExhausted {
5039 name: name.into(),
5040 limit: seq.min_value,
5041 is_max: false,
5042 });
5043 }
5044 } else {
5045 next
5046 }
5047 } else {
5048 seq.last_value
5049 };
5050 seq.last_value = candidate;
5051 seq.is_called = true;
5052 Ok(candidate)
5053 }
5054
5055 /// v7.17.0 — currval. Errors if the session has never called
5056 /// nextval on this sequence (PG semantics). At the catalog
5057 /// level we approximate "session" with "is_called persisted";
5058 /// the engine session-tracking layer can wrap this for the
5059 /// strict per-session semantics later.
5060 pub fn sequence_current_value(&self, name: &str) -> Result<i64, StorageError> {
5061 let Some(seq) = self.sequences.get(name) else {
5062 return Err(StorageError::TableNotFound { name: name.into() });
5063 };
5064 if !seq.is_called {
5065 return Err(StorageError::Corrupt(format!(
5066 "currval of sequence {name:?} is not yet defined in this session"
5067 )));
5068 }
5069 Ok(seq.last_value)
5070 }
5071
5072 /// v7.17.0 — setval(name, value [, is_called]). PG returns
5073 /// `value` regardless. `is_called=true` means the NEXT
5074 /// nextval will return `value + increment`; `is_called=false`
5075 /// means the next nextval will return `value`.
5076 pub fn sequence_set_value(
5077 &mut self,
5078 name: &str,
5079 value: i64,
5080 is_called: bool,
5081 ) -> Result<i64, StorageError> {
5082 let key = self.sequence_key(name);
5083 let Some(seq) = self.sequences.get_mut(&key) else {
5084 return Err(StorageError::TableNotFound { name: name.into() });
5085 };
5086 // v7.39 (round 244) — PG refuses a value outside the sequence's
5087 // range (22003); SPG accepted it silently, leaving last_value out
5088 // of bounds.
5089 if value < seq.min_value || value > seq.max_value {
5090 return Err(StorageError::Unsupported(format!(
5091 "setval: value {value} is out of bounds for sequence \"{name}\" ({}..{})",
5092 seq.min_value, seq.max_value
5093 )));
5094 }
5095 seq.last_value = value;
5096 seq.is_called = is_called;
5097 Ok(value)
5098 }
5099
5100 /// v7.17.0 Phase 1.2 — read-only handle to catalogued views. Temp ones
5101 /// are in here under their mangled storage names; listing code filters
5102 /// through [`Self::listed_name`], and anything resolving ONE name by
5103 /// its logical spelling wants [`Self::view`].
5104 pub const fn views_all(&self) -> &BTreeMap<String, ViewDef> {
5105 &self.views
5106 }
5107
5108 /// v7.39 (round 469) — resolve one view by its logical name, the
5109 /// session's temporary one winning over a permanent one of the same
5110 /// name.
5111 #[must_use]
5112 pub fn view(&self, name: &str) -> Option<&ViewDef> {
5113 if let Some(mangled) = self.temp_name_for(name)
5114 && let Some(def) = self.views.get(&mangled)
5115 {
5116 return Some(def);
5117 }
5118 self.views.get(name)
5119 }
5120
5121 /// Does a view of this logical name exist for this session?
5122 #[must_use]
5123 pub fn has_view(&self, name: &str) -> bool {
5124 self.view(name).is_some()
5125 }
5126
5127 /// The storage key a view of this logical name resolves to.
5128 #[must_use]
5129 pub fn view_key(&self, name: &str) -> String {
5130 if let Some(mangled) = self.temp_name_for(name)
5131 && self.views.contains_key(&mangled)
5132 {
5133 return mangled;
5134 }
5135 name.into()
5136 }
5137
5138 /// v7.17.0 Phase 1.2 — install a VIEW. `or_replace=true`
5139 /// overwrites an existing entry; `if_not_exists=true` is a
5140 /// silent no-op when the name is taken. Errors if both flags
5141 /// are off and the name collides.
5142 pub fn create_view(
5143 &mut self,
5144 def: ViewDef,
5145 or_replace: bool,
5146 if_not_exists: bool,
5147 ) -> Result<(), StorageError> {
5148 if self.views.contains_key(&def.name) {
5149 if or_replace {
5150 self.views.insert(def.name.clone(), def);
5151 return Ok(());
5152 }
5153 if if_not_exists {
5154 return Ok(());
5155 }
5156 // v7.39 (read01 round 47) — a view is a relation to PG (42P07).
5157 return Err(StorageError::Corrupt(format!(
5158 "relation {:?} already exists",
5159 def.name
5160 )));
5161 }
5162 // Reject name collision with tables / sequences — same
5163 // namespace per PG.
5164 if self.by_name.contains_key(&def.name) {
5165 return Err(StorageError::Corrupt(format!(
5166 "view {:?} would shadow an existing table",
5167 def.name
5168 )));
5169 }
5170 if self.sequences.contains_key(&def.name) {
5171 return Err(StorageError::Corrupt(format!(
5172 "view {:?} would shadow an existing sequence",
5173 def.name
5174 )));
5175 }
5176 self.views.insert(def.name.clone(), def);
5177 Ok(())
5178 }
5179
5180 /// v7.17.0 Phase 1.2 — remove a view by name. Returns true if
5181 /// a view was removed.
5182 pub fn drop_view(&mut self, name: &str) -> bool {
5183 self.views.remove(name).is_some()
5184 }
5185
5186 /// v7.17.0 Phase 1.3 — read-only handle to the materialised-
5187 /// view source registry. Each entry pairs with a regular
5188 /// table of the same name that holds the cached rows.
5189 pub const fn materialized_views(&self) -> &BTreeMap<String, String> {
5190 &self.materialized_views
5191 }
5192
5193 /// v7.17.0 Phase 1.3 — register a source for a materialised
5194 /// view. Caller has already created the backing table.
5195 pub fn register_materialized_view(&mut self, name: String, body: String) {
5196 self.materialized_views.insert(name, body);
5197 }
5198
5199 /// v7.17.0 Phase 1.3 — drop the source registry entry. Returns
5200 /// true if a source was unregistered. Caller separately drops
5201 /// the backing table.
5202 pub fn drop_materialized_view_source(&mut self, name: &str) -> bool {
5203 self.materialized_views.remove(name).is_some()
5204 }
5205
5206 /// v7.17.0 Phase 1.4 — read-only handle to user-defined ENUM
5207 /// catalog.
5208 pub const fn enum_types(&self) -> &BTreeMap<String, EnumDef> {
5209 &self.enum_types
5210 }
5211
5212 /// v7.17.0 Phase 1.4 — install a new ENUM type. Errors if
5213 /// `name` collides with an existing enum (no IF NOT EXISTS
5214 /// per PG semantics for CREATE TYPE).
5215 pub fn create_enum_type(&mut self, def: EnumDef) -> Result<(), StorageError> {
5216 if self.enum_types.contains_key(&def.name) {
5217 return Err(StorageError::Corrupt(format!(
5218 "type {:?} already exists",
5219 def.name
5220 )));
5221 }
5222 self.enum_types.insert(def.name.clone(), def);
5223 Ok(())
5224 }
5225
5226 /// v7.17.0 Phase 1.4 — drop an ENUM type by name. Returns
5227 /// true if a type was removed.
5228 /// v7.37 D.55 — `ALTER TYPE … ADD VALUE`. Appends `label` to an existing
5229 /// enum's ordered label list, or inserts it before/after an existing label.
5230 /// `if_not_exists` makes a duplicate a no-op; otherwise a duplicate errors.
5231 /// Returns `Ok(true)` if a label was added, `Ok(false)` if it already existed
5232 /// (only possible under `if_not_exists`).
5233 /// v7.39 (read01 round 49) — `ALTER TYPE t RENAME VALUE 'old' TO 'new'`.
5234 /// The parser used to swallow this form as a no-op, so the rename was
5235 /// accepted and silently ignored. Renaming in place keeps the label's
5236 /// sort position, which is what PG does (enumsortorder is untouched).
5237 pub fn rename_enum_value(
5238 &mut self,
5239 type_name: &str,
5240 old: &str,
5241 new: &str,
5242 ) -> Result<(), StorageError> {
5243 let def = self
5244 .enum_types
5245 .get_mut(type_name)
5246 .ok_or_else(|| StorageError::Corrupt(format!("type {type_name:?} does not exist")))?;
5247 if def.labels.iter().any(|l| l == new) {
5248 return Err(StorageError::Corrupt(format!(
5249 "enum label {new:?} already exists"
5250 )));
5251 }
5252 let at = def.labels.iter().position(|l| l == old).ok_or_else(|| {
5253 StorageError::Corrupt(format!("{old:?} is not an existing enum label"))
5254 })?;
5255 def.labels[at] = new.to_string();
5256 Ok(())
5257 }
5258
5259 /// v7.39 (read01 round 50) — set (or, with `None`, remove) the comment on
5260 /// an object. `key` is the canonical `"<kind>:<name>"` form.
5261 pub fn set_comment(&mut self, key: &str, text: Option<&str>) {
5262 match text {
5263 Some(t) => {
5264 self.comments.insert(key.to_string(), t.to_string());
5265 }
5266 None => {
5267 self.comments.remove(key);
5268 }
5269 }
5270 }
5271
5272 /// v7.39 (read01 round 50) — the comment on an object, if any.
5273 #[must_use]
5274 pub fn comment(&self, key: &str) -> Option<&str> {
5275 self.comments.get(key).map(String::as_str)
5276 }
5277
5278 /// v7.39 (round 547) — record a GUC default for a scope. An empty
5279 /// database or role name is PG's oid 0 ("all"). `None` value
5280 /// removes just that parameter, as PG's RESET does.
5281 pub fn set_db_role_setting(
5282 &mut self,
5283 database: &str,
5284 role: &str,
5285 param: &str,
5286 value: Option<&str>,
5287 ) {
5288 let key = (database.to_string(), role.to_string());
5289 match value {
5290 Some(v) => {
5291 self.db_role_settings
5292 .entry(key)
5293 .or_default()
5294 .insert(param.to_ascii_lowercase(), v.to_string());
5295 }
5296 None => {
5297 if let Some(m) = self.db_role_settings.get_mut(&key) {
5298 m.remove(¶m.to_ascii_lowercase());
5299 if m.is_empty() {
5300 self.db_role_settings.remove(&key);
5301 }
5302 }
5303 }
5304 }
5305 }
5306
5307 /// v7.39 (round 550) — create a replication slot. `Err` carries
5308 /// PG's own message for a duplicate.
5309 ///
5310 /// # Errors
5311 /// When a slot of that name already exists.
5312 pub fn create_replication_slot(
5313 &mut self,
5314 name: &str,
5315 plugin: &str,
5316 slot_type: &str,
5317 ) -> Result<(), String> {
5318 if self.replication_slots.contains_key(name) {
5319 return Err(alloc::format!("replication slot \"{name}\" already exists"));
5320 }
5321 self.replication_slots.insert(
5322 name.to_string(),
5323 (plugin.to_string(), slot_type.to_string()),
5324 );
5325 Ok(())
5326 }
5327
5328 /// # Errors
5329 /// When no slot of that name exists — PG's message, and the case
5330 /// that used to report success.
5331 pub fn drop_replication_slot(&mut self, name: &str) -> Result<(), String> {
5332 if self.replication_slots.remove(name).is_none() {
5333 return Err(alloc::format!("replication slot \"{name}\" does not exist"));
5334 }
5335 Ok(())
5336 }
5337
5338 #[must_use]
5339 pub const fn replication_slots(&self) -> &BTreeMap<String, (String, String)> {
5340 &self.replication_slots
5341 }
5342
5343 /// PG's RESET ALL: drops this scope's whole entry, leaving the
5344 /// other scopes alone — measured on PG18, where `ALTER ROLE r RESET
5345 /// ALL` left the ALL, the database and the role-in-database rows.
5346 pub fn reset_db_role_settings(&mut self, database: &str, role: &str) {
5347 self.db_role_settings
5348 .remove(&(database.to_string(), role.to_string()));
5349 }
5350
5351 #[must_use]
5352 pub const fn db_role_settings(&self) -> &BTreeMap<(String, String), BTreeMap<String, String>> {
5353 &self.db_role_settings
5354 }
5355
5356 /// v7.39 (read01 round 50) — every `(key, text)` pair, for the
5357 /// pg_description view.
5358 #[must_use]
5359 pub const fn comments(&self) -> &BTreeMap<String, String> {
5360 &self.comments
5361 }
5362
5363 /// v7.39 (read01 round 50) — drop every comment whose key names `obj`
5364 /// (the object itself and, for a table, its columns). Called when the
5365 /// object is dropped so a later object of the same name doesn't inherit
5366 /// a stale comment.
5367 pub fn drop_comments_for(&mut self, kind: &str, name: &str) {
5368 let exact = alloc::format!("{kind}:{name}");
5369 let col_prefix = alloc::format!("column:{name}.");
5370 self.comments
5371 .retain(|k, _| *k != exact && !k.starts_with(&col_prefix));
5372 }
5373
5374 pub fn add_enum_value(
5375 &mut self,
5376 type_name: &str,
5377 label: &str,
5378 if_not_exists: bool,
5379 position: Option<(bool, String)>,
5380 ) -> Result<bool, StorageError> {
5381 let def = self
5382 .enum_types
5383 .get_mut(type_name)
5384 .ok_or_else(|| StorageError::Corrupt(format!("type {type_name:?} does not exist")))?;
5385 if def.labels.iter().any(|l| l == label) {
5386 if if_not_exists {
5387 return Ok(false);
5388 }
5389 // v7.39 (read01 round 49) — PG wording (42710 at the wire).
5390 return Err(StorageError::Corrupt(format!(
5391 "enum label {label:?} already exists"
5392 )));
5393 }
5394 match position {
5395 None => def.labels.push(label.to_string()),
5396 Some((is_before, anchor)) => {
5397 let at = def
5398 .labels
5399 .iter()
5400 .position(|l| l == &anchor)
5401 .ok_or_else(|| {
5402 StorageError::Corrupt(format!(
5403 "enum label {anchor:?} does not exist in type {type_name:?}"
5404 ))
5405 })?;
5406 let idx = if is_before { at } else { at + 1 };
5407 def.labels.insert(idx, label.to_string());
5408 }
5409 }
5410 Ok(true)
5411 }
5412
5413 pub fn drop_enum_type(&mut self, name: &str) -> bool {
5414 self.enum_types.remove(name).is_some()
5415 }
5416
5417 /// v7.17.0 Phase 1.5 — read-only handle to DOMAIN catalog.
5418 pub const fn domain_types(&self) -> &BTreeMap<String, DomainDef> {
5419 &self.domain_types
5420 }
5421
5422 /// v7.17.0 Phase 1.5 — install a DOMAIN. Errors on collision
5423 /// with an existing domain.
5424 pub fn create_domain_type(&mut self, def: DomainDef) -> Result<(), StorageError> {
5425 if self.domain_types.contains_key(&def.name) {
5426 return Err(StorageError::Corrupt(format!(
5427 "domain {:?} already exists",
5428 def.name
5429 )));
5430 }
5431 self.domain_types.insert(def.name.clone(), def);
5432 Ok(())
5433 }
5434
5435 /// v7.17.0 Phase 1.5 — drop a DOMAIN by name.
5436 pub fn drop_domain_type(&mut self, name: &str) -> bool {
5437 self.domain_types.remove(name).is_some()
5438 }
5439
5440 /// v7.37.42-T2 ζ-B — read-only handle to user-defined COMPOSITE
5441 /// catalog. Used by the engine to resolve
5442 /// `ColumnSchema.user_composite_type` lookups + by
5443 /// information_schema-style introspection.
5444 pub const fn composite_types(&self) -> &BTreeMap<String, CompositeDef> {
5445 &self.composite_types
5446 }
5447
5448 /// v7.37.42-T2 ζ-B — install a new COMPOSITE type. Errors if
5449 /// `name` already exists in the composite registry (PG forbids
5450 /// IF NOT EXISTS on CREATE TYPE composite; the engine surfaces
5451 /// the collision with the existing name).
5452 pub fn create_composite_type(&mut self, def: CompositeDef) -> Result<(), StorageError> {
5453 if self.composite_types.contains_key(&def.name) {
5454 return Err(StorageError::Corrupt(format!(
5455 "type {:?} already exists",
5456 def.name
5457 )));
5458 }
5459 self.composite_types.insert(def.name.clone(), def);
5460 Ok(())
5461 }
5462
5463 /// v7.37.42-T2 ζ-B — drop a COMPOSITE type by name. Returns
5464 /// true if a type was removed.
5465 pub fn drop_composite_type(&mut self, name: &str) -> bool {
5466 self.composite_types.remove(name).is_some()
5467 }
5468
5469 /// v7.17.0 Phase 1.6 — read-only handle to the user-created
5470 /// schema registry. Built-in schemas (`public`, `pg_catalog`,
5471 /// `information_schema`) are NOT included here; use
5472 /// [`schema_exists`](Self::schema_exists) for the full
5473 /// check.
5474 pub const fn user_schemas(&self) -> &alloc::collections::BTreeSet<String> {
5475 &self.schemas
5476 }
5477
5478 /// v7.17.0 Phase 1.6 — schema-name resolver. Returns true
5479 /// for built-in schemas + every user-CREATEd one. Used by
5480 /// CREATE SCHEMA collision checks and (future) by
5481 /// information_schema.schemata.
5482 pub fn schema_exists(&self, name: &str) -> bool {
5483 is_builtin_schema(name) || self.schemas.contains(name)
5484 }
5485
5486 /// v7.17.0 Phase 1.6 — register a new schema. Errors if the
5487 /// name already exists and `if_not_exists=false`. Built-in
5488 /// names cannot be redeclared.
5489 pub fn create_schema(&mut self, name: String, if_not_exists: bool) -> Result<(), StorageError> {
5490 if is_builtin_schema(&name) {
5491 if if_not_exists {
5492 return Ok(());
5493 }
5494 return Err(StorageError::Corrupt(format!(
5495 "schema {name:?} is built-in and cannot be redeclared"
5496 )));
5497 }
5498 if self.schemas.contains(&name) {
5499 if if_not_exists {
5500 return Ok(());
5501 }
5502 return Err(StorageError::Corrupt(format!(
5503 "schema {name:?} already exists"
5504 )));
5505 }
5506 self.schemas.insert(name);
5507 Ok(())
5508 }
5509
5510 /// v7.17.0 Phase 1.6 — drop a user-created schema. Returns
5511 /// true if a schema was removed. Built-in names always
5512 /// return false (cannot be dropped). Tables that previously
5513 /// used the schema as a prefix keep their bare name and stay
5514 /// queryable — this is the "prefix routing, not isolation"
5515 /// posture documented in v7.17 Phase 1.6.
5516 pub fn drop_schema(&mut self, name: &str) -> Result<bool, StorageError> {
5517 if is_builtin_schema(name) {
5518 return Err(StorageError::Corrupt(format!(
5519 "schema {name:?} is built-in and cannot be dropped"
5520 )));
5521 }
5522 Ok(self.schemas.remove(name))
5523 }
5524
5525 /// v7.17.0 — ALTER SEQUENCE option merge. Caller-provided
5526 /// updates overwrite the matching fields; unset fields keep
5527 /// their stored values. RESTART variants update last_value
5528 /// directly per PG: `RESTART` resets to current `start`;
5529 /// `RESTART WITH n` resets to `n`.
5530 #[allow(clippy::too_many_arguments)]
5531 pub fn alter_sequence(
5532 &mut self,
5533 name: &str,
5534 increment: Option<i64>,
5535 min_value: Option<i64>,
5536 max_value: Option<i64>,
5537 start: Option<i64>,
5538 restart: Option<Option<i64>>,
5539 cache: Option<i64>,
5540 cycle: Option<bool>,
5541 owned_by: Option<Option<(String, String)>>,
5542 ) -> Result<(), StorageError> {
5543 let Some(seq) = self.sequences.get_mut(name) else {
5544 return Err(StorageError::TableNotFound { name: name.into() });
5545 };
5546 if let Some(v) = increment {
5547 seq.increment = v;
5548 }
5549 if let Some(v) = min_value {
5550 seq.min_value = v;
5551 }
5552 if let Some(v) = max_value {
5553 seq.max_value = v;
5554 }
5555 if let Some(v) = start {
5556 seq.start = v;
5557 }
5558 if let Some(restart_value) = restart {
5559 seq.last_value = restart_value.unwrap_or(seq.start);
5560 seq.is_called = false;
5561 }
5562 if let Some(v) = cache {
5563 seq.cache = v;
5564 }
5565 if let Some(v) = cycle {
5566 seq.cycle = v;
5567 }
5568 if let Some(v) = owned_by {
5569 seq.owned_by = v;
5570 }
5571 Ok(())
5572 }
5573
5574 /// v7.12.4 — read-only slice of all catalogued triggers.
5575 /// Engine row-write paths filter this by (table, event,
5576 /// timing) and fire matches in slice order.
5577 pub fn triggers(&self) -> &[TriggerDef] {
5578 &self.triggers
5579 }
5580
5581 /// v7.15.0 — mutable handle to the trigger slice for
5582 /// `ALTER TABLE … RENAME COLUMN`, which rewrites every
5583 /// `update_columns` entry that referenced the renamed
5584 /// column.
5585 pub fn triggers_mut(&mut self) -> &mut Vec<TriggerDef> {
5586 &mut self.triggers
5587 }
5588
5589 /// v7.12.4 — register a new trigger. With `or_replace = false`,
5590 /// errors when a trigger with the same name already exists on
5591 /// the same table (PG scoping rule — trigger names are
5592 /// per-table, not global). Trigger function must already
5593 /// exist in the catalog at registration time.
5594 pub fn create_trigger(
5595 &mut self,
5596 def: TriggerDef,
5597 or_replace: bool,
5598 ) -> Result<(), StorageError> {
5599 // v7.39 (round 137) — a trigger may target a base table (BEFORE / AFTER)
5600 // or a view (INSTEAD OF). The engine enforces the timing↔target rule;
5601 // storage only requires the relation to exist as one or the other.
5602 if !self.by_name.contains_key(&def.table) && !self.views.contains_key(&def.table) {
5603 return Err(StorageError::TableNotFound {
5604 name: def.table.clone(),
5605 });
5606 }
5607 // v7.39 (read01 round 62) — functions are keyed by SIGNATURE now. A
5608 // trigger names its function by NAME (a trigger function takes no
5609 // arguments), so the existence check goes through the name index.
5610 if self.functions_named(&def.function).is_empty() {
5611 // v7.39 (round 710) — PG's wording: the FUNCTION is what does
5612 // not exist (`function nosuch_fn() does not exist`), and the
5613 // old message rode `Corrupt`'s on-disk banner besides.
5614 return Err(StorageError::Corrupt(format!(
5615 "function {}() does not exist",
5616 def.function
5617 )));
5618 }
5619 let dup = self
5620 .triggers
5621 .iter()
5622 .position(|t| t.name == def.name && t.table == def.table);
5623 match (dup, or_replace) {
5624 (Some(_), false) => Err(StorageError::Corrupt(format!(
5625 "trigger {:?} already exists on table {:?}",
5626 def.name, def.table
5627 ))),
5628 (Some(i), true) => {
5629 self.triggers[i] = def;
5630 Ok(())
5631 }
5632 (None, _) => {
5633 self.triggers.push(def);
5634 Ok(())
5635 }
5636 }
5637 }
5638
5639 /// v7.12.4 — remove a trigger by `(name, table)`. Returns
5640 /// `true` if one was removed.
5641 pub fn drop_trigger(&mut self, name: &str, table: &str) -> bool {
5642 let before = self.triggers.len();
5643 self.triggers
5644 .retain(|t| !(t.name == name && t.table == table));
5645 before != self.triggers.len()
5646 }
5647
5648 /// v7.39 (round 139) — the catalogued query-rewrite RULEs.
5649 pub fn rules(&self) -> &[RuleDef] {
5650 &self.rules
5651 }
5652
5653 /// v7.39 (round 280) — the catalogued extended-statistics objects.
5654 #[must_use]
5655 pub fn statistics_ext(&self) -> &[StatisticsExtDef] {
5656 &self.statistics_ext
5657 }
5658
5659 /// v7.39 (round 287) — every large object, ascending by OID.
5660 #[must_use]
5661 pub fn large_objects(&self) -> &alloc::collections::BTreeMap<u32, Vec<u8>> {
5662 &self.large_objects
5663 }
5664
5665 /// The bytes of one large object, or `None` when no such OID exists.
5666 #[must_use]
5667 pub fn large_object(&self, oid: u32) -> Option<&[u8]> {
5668 self.large_objects.get(&oid).map(Vec::as_slice)
5669 }
5670
5671 /// Create a large object. `oid` of 0 means "pick one" — PG's
5672 /// `lo_create(0)` / `lo_creat(-1)` spelling. Errors when the
5673 /// requested OID is taken.
5674 pub fn create_large_object(&mut self, oid: u32, bytes: Vec<u8>) -> Result<u32, String> {
5675 let id = if oid == 0 {
5676 self.next_large_object_oid()
5677 } else {
5678 oid
5679 };
5680 if self.large_objects.contains_key(&id) {
5681 return Err(format!("large object {id} already exists"));
5682 }
5683 self.large_objects.insert(id, bytes);
5684 Ok(id)
5685 }
5686
5687 /// Overwrite `len` bytes at `offset` (0-based), growing the object
5688 /// with zero bytes if the write starts past the end — PG's
5689 /// `lo_put` semantics.
5690 pub fn put_large_object(&mut self, oid: u32, offset: usize, data: &[u8]) -> Result<(), String> {
5691 let Some(buf) = self.large_objects.get_mut(&oid) else {
5692 return Err(format!("large object {oid} does not exist"));
5693 };
5694 let end = offset.saturating_add(data.len());
5695 if buf.len() < end {
5696 buf.resize(end, 0);
5697 }
5698 buf[offset..end].copy_from_slice(data);
5699 Ok(())
5700 }
5701
5702 /// v7.39 (round 306) — `lo_truncate`. PG's truncate sets the object
5703 /// to exactly `len` bytes in BOTH directions: it shortens, and it
5704 /// GROWS with zero fill when `len` exceeds the current size
5705 /// (measured — `lo_truncate(fd, 8)` over a 4-byte object leaves
5706 /// eight bytes, the last four zero).
5707 pub fn truncate_large_object(&mut self, oid: u32, len: usize) -> Result<(), String> {
5708 let Some(buf) = self.large_objects.get_mut(&oid) else {
5709 return Err(format!("large object {oid} does not exist"));
5710 };
5711 buf.resize(len, 0);
5712 Ok(())
5713 }
5714
5715 /// Remove a large object. `false` when the OID was not there.
5716 pub fn unlink_large_object(&mut self, oid: u32) -> bool {
5717 self.large_objects.remove(&oid).is_some()
5718 }
5719
5720 /// The next free OID in PG's user band.
5721 /// v7.39 (round 343, V40) — large objects have their own oid band.
5722 /// It used to start at 16_384, which is where user TABLES start, so
5723 /// the first large object and the first table shared an oid — and
5724 /// `pg_largeobject_metadata.oid` is joinable against `pg_class.oid`,
5725 /// so a join across them matched a row that has nothing to do with
5726 /// it. (PG cannot collide: every oid there comes off one counter.)
5727 /// An object already stored keeps the oid it was given; only new
5728 /// ones land in the band.
5729 fn next_large_object_oid(&self) -> u32 {
5730 self.large_objects
5731 .keys()
5732 .next_back()
5733 .map_or(500_000, |m| m.saturating_add(1))
5734 }
5735
5736 /// Register one. `Err(name)` when the name is taken.
5737 pub fn create_statistics_ext(&mut self, def: StatisticsExtDef) -> Result<(), String> {
5738 if self.statistics_ext.iter().any(|s| s.name == def.name) {
5739 return Err(def.name);
5740 }
5741 self.statistics_ext.push(def);
5742 Ok(())
5743 }
5744
5745 /// Drop one by name; false when absent.
5746 pub fn drop_statistics_ext(&mut self, name: &str) -> bool {
5747 let before = self.statistics_ext.len();
5748 self.statistics_ext.retain(|s| s.name != name);
5749 before != self.statistics_ext.len()
5750 }
5751
5752 /// v7.39 (round 139) — register a RULE. Its target relation (table or view)
5753 /// must exist; `or_replace` overwrites a same-(name,table) rule.
5754 pub fn create_rule(&mut self, def: RuleDef, or_replace: bool) -> Result<(), StorageError> {
5755 if !self.by_name.contains_key(&def.table) && !self.views.contains_key(&def.table) {
5756 return Err(StorageError::TableNotFound {
5757 name: def.table.clone(),
5758 });
5759 }
5760 let dup = self
5761 .rules
5762 .iter()
5763 .position(|r| r.name == def.name && r.table == def.table);
5764 match (dup, or_replace) {
5765 (Some(_), false) => Err(StorageError::Corrupt(format!(
5766 "rule {:?} for relation {:?} already exists",
5767 def.name, def.table
5768 ))),
5769 (Some(i), true) => {
5770 self.rules[i] = def;
5771 Ok(())
5772 }
5773 (None, _) => {
5774 self.rules.push(def);
5775 Ok(())
5776 }
5777 }
5778 }
5779
5780 /// v7.39 (round 139) — drop a RULE by `(name, table)`.
5781 pub fn drop_rule(&mut self, name: &str, table: &str) -> bool {
5782 let before = self.rules.len();
5783 self.rules.retain(|r| !(r.name == name && r.table == table));
5784 before != self.rules.len()
5785 }
5786
5787 pub fn create_table(&mut self, schema: TableSchema) -> Result<(), StorageError> {
5788 if self.by_name.contains_key(&schema.name) {
5789 return Err(StorageError::DuplicateTable {
5790 name: schema.name.clone(),
5791 });
5792 }
5793 let idx = self.tables.len();
5794 let name = schema.name.clone();
5795 self.tables.push(Table::new(schema));
5796 self.by_name.insert(name.clone(), idx);
5797 // v7.39 (round 496) — see `dirty_tables`.
5798 self.dirty_tables.insert(name);
5799 // v7.37.15 (Phase C.1) — stamp the new relation with a stable,
5800 // monotonic, never-reused RelId. Pre-increment so ids start at
5801 // 1 (0 = UNASSIGNED); a later DROP TABLE frees the slot but not
5802 // the id.
5803 self.next_rel_id += 1;
5804 let rid = row_header::RelId(self.next_rel_id);
5805 self.tables[idx].set_rel_id(rid);
5806 Ok(())
5807 }
5808
5809 /// v7.39 (round 436) — the session's temporary table of this name wins
5810 /// over a permanent one, as `pg_temp` does in PG's search path and as
5811 /// MySQL's TEMPORARY shadowing does. Every name → index resolution in
5812 /// this catalog goes through here.
5813 fn resolve_index(&self, name: &str) -> Option<usize> {
5814 if let Some(prefix) = &self.temp_prefix {
5815 let mut mangled = String::with_capacity(prefix.len() + name.len());
5816 mangled.push_str(prefix);
5817 mangled.push_str(name);
5818 if let Some(idx) = self.by_name.get(&mangled) {
5819 return Some(*idx);
5820 }
5821 }
5822 self.by_name.get(name).copied()
5823 }
5824
5825 /// v7.39 (round 436) — install the calling session's temp namespace.
5826 /// `None` disables temp resolution entirely (a session that never made
5827 /// one pays a single `Option` check per lookup).
5828 pub fn set_temp_prefix(&mut self, prefix: Option<String>) {
5829 self.temp_prefix = prefix;
5830 }
5831
5832 /// The mangled storage name a temp table of `name` takes in this
5833 /// session, or `None` when the session has no temp namespace.
5834 #[must_use]
5835 pub fn temp_name_for(&self, name: &str) -> Option<String> {
5836 self.temp_prefix
5837 .as_ref()
5838 .map(|p| alloc::format!("{p}{name}"))
5839 }
5840
5841 pub fn get(&self, name: &str) -> Option<&Table> {
5842 let idx = self.resolve_index(name)?;
5843 self.tables.get(idx)
5844 }
5845
5846 pub fn get_mut(&mut self, name: &str) -> Option<&mut Table> {
5847 let idx = self.resolve_index(name)?;
5848 // v7.39 (round 496) — the choke point for changing a table, so the
5849 // record is taken here. Over-approximate on purpose: a caller that
5850 // takes the handle and writes nothing merely carries that table
5851 // through a commit, which is the old behaviour.
5852 let recorded = self.tables.get(idx).map(|t| t.schema().name.clone());
5853 if let Some(n) = recorded {
5854 self.dirty_tables.insert(n);
5855 }
5856 self.tables.get_mut(idx)
5857 }
5858
5859 /// v7.39 (round 496) — the tables changed through this handle since
5860 /// [`Self::clear_dirty_tables`]. See `dirty_tables`.
5861 #[must_use]
5862 pub fn dirty_tables(&self) -> &alloc::collections::BTreeSet<String> {
5863 &self.dirty_tables
5864 }
5865
5866 /// v7.39 (round 496) — start a fresh recording window. A transaction's
5867 /// shadow calls this at BEGIN so the set means "changed by this tx".
5868 pub fn clear_dirty_tables(&mut self) {
5869 self.dirty_tables.clear();
5870 }
5871
5872 /// v7.39 (round 496) — put `table` in at `name`, replacing any table
5873 /// already there and keeping the rest of the catalog untouched.
5874 ///
5875 /// The commit-time table-granularity merge needs exactly this: take
5876 /// the latest committed catalog, then overwrite only the tables the
5877 /// transaction changed.
5878 pub fn install_table(&mut self, name: &str, table: Table) {
5879 match self.by_name.get(name).copied() {
5880 Some(idx) => self.tables[idx] = table,
5881 None => {
5882 let idx = self.tables.len();
5883 self.tables.push(table);
5884 self.by_name.insert(name.into(), idx);
5885 }
5886 }
5887 self.dirty_tables.insert(name.into());
5888 }
5889
5890 /// v7.37.42 (docker-fair SCALARSQ attack) — resolve a table name to
5891 /// its insertion-order index ONCE, so callers that need to fetch the
5892 /// same table many times (per-row PK probes in correlated scalar
5893 /// subqueries) can avoid the per-call `BTreeMap<String, usize>` string
5894 /// descent. The returned index is stable for the lifetime of the
5895 /// catalog snapshot the caller holds (same engine read guard).
5896 pub fn tables_position_of(&self, name: &str) -> Option<usize> {
5897 self.resolve_index(name)
5898 }
5899
5900 /// Direct positional fetch counterpart to [`tables_position_of`].
5901 /// `idx` must come from `tables_position_of` against the same catalog
5902 /// snapshot — out-of-range returns `None`.
5903 pub fn tables_at(&self, idx: usize) -> Option<&Table> {
5904 self.tables.get(idx)
5905 }
5906
5907 /// v7.34 (crash-recovery P0 #2) — replay a row-level redo log onto
5908 /// this catalog (the [`RowChange`] physical-redo apply primitive that
5909 /// row-level WAL recovery will use in place of statement re-execution).
5910 /// Applies each change in order via the same `Table` mutators the
5911 /// engine used — no uniqueness/FK/parse/plan: the original execution
5912 /// already validated, replay trusts and applies. Positions are
5913 /// physical and only valid when replayed from the matching checkpoint
5914 /// baseline in original order (see [`RowChange`] docs).
5915 ///
5916 /// A change naming an absent table, or whose position is out of range,
5917 /// is a corrupt/misaligned log and surfaces as an error rather than a
5918 /// silent skip.
5919 pub fn apply_redo(&mut self, changes: &[RowChange]) -> Result<(), StorageError> {
5920 // v7.37.5 (mailrs crash-recovery Ask 3) — true batched replay.
5921 // Pre-v7.37.5 each `RowChange::Delete` record ran a fresh
5922 // O(N) PersistentVec rebuild + O(N × indices × log N)
5923 // `rebuild_indices()` — 5000 records × 100k rows × 13 indices
5924 // ≈ 27 min on the mailrs prod-shape WAL.
5925 //
5926 // The strategy: group consecutive changes by table, and for
5927 // each run, compose all the row-level mutations through a
5928 // single "live" tracking vector + a per-table operation log,
5929 // then apply rows + indices ONCE at the end. The result:
5930 // - DELETE blow-up: O(records × rows × indices × log rows)
5931 // → O(rows × indices × log rows) — one rebuild per run.
5932 // - Row-position semantics preserved: positions in a later
5933 // `Delete` / `Update` record reference the layout produced
5934 // by every earlier change; we walk the live-vector
5935 // forward as each change is processed so positions
5936 // translate correctly to the ORIGINAL row index space.
5937 //
5938 // For correctness, even with this batching `apply_redo`
5939 // remains in-order: a single per-table run only batches
5940 // a contiguous slice of changes targeting that table; a
5941 // mid-run change targeting a DIFFERENT table forces a
5942 // flush of the current run.
5943 let mut runs: alloc::vec::Vec<(String, alloc::vec::Vec<&RowChange>)> =
5944 alloc::vec::Vec::new();
5945 for change in changes {
5946 // v7.39 (flip crash-replay P0) — a replayed tombstone carries
5947 // the xmax the CRASHED process allocated, but this process's
5948 // version cursor restarted; without advancing it past every
5949 // replayed version, `Snapshot::visible`'s "deletion is in the
5950 // future" branch (xmax > snapshot.version) resurrects every
5951 // replayed delete. Same recovery contract as the snapshot
5952 // loader (`observe_persisted_version`, the pg_control-style
5953 // nextXid recovery).
5954 if let RowChange::Tombstone { xmax, .. } = change {
5955 row_header::observe_persisted_version(*xmax);
5956 }
5957 let table = match change {
5958 RowChange::Insert { table, .. }
5959 | RowChange::Update { table, .. }
5960 | RowChange::Delete { table, .. }
5961 | RowChange::Tombstone { table, .. } => table.clone(),
5962 };
5963 if runs.last().map(|(t, _)| t.as_str()) != Some(table.as_str()) {
5964 runs.push((table, alloc::vec::Vec::new()));
5965 }
5966 runs.last_mut().unwrap().1.push(change);
5967 }
5968 for (table_name, run) in runs {
5969 self.apply_redo_run_on_table(&table_name, &run)?;
5970 }
5971 Ok(())
5972 }
5973
5974 /// v7.37.5 — apply a contiguous slice of `RowChange`s all
5975 /// targeting the same `table_name`. Composes row mutations
5976 /// through a single live-tracking vector + a single tail
5977 /// for appended `Insert`s + a single in-place edit set for
5978 /// `Update`s, then writes the final row layout to
5979 /// `self.rows` and rebuilds indices ONCE.
5980 fn apply_redo_run_on_table(
5981 &mut self,
5982 table_name: &str,
5983 run: &[&RowChange],
5984 ) -> Result<(), StorageError> {
5985 // Look up the table once; the unchecked unwrap is safe
5986 // because the caller just resolved `table_name` for each
5987 // change.
5988 let table = self.get_mut(table_name).ok_or_else(|| {
5989 StorageError::Corrupt(alloc::format!("redo: unknown table {table_name:?}"))
5990 })?;
5991 // Live-tracking over both pre-existing rows and tail-
5992 // appended Insert rows. `live[i] = true` initially for
5993 // every existing row. Appended Inserts extend with `true`.
5994 // A `Delete` flips entries to `false` (using the position
5995 // mapping that walks live indices in order). An `Update`
5996 // edits in place — collected into an overlay map keyed by
5997 // ORIGINAL row position so later Updates win.
5998 let original_rows: alloc::vec::Vec<Row<'static>> = table.rows().iter().cloned().collect();
5999 let mut live: alloc::vec::Vec<bool> = alloc::vec![true; original_rows.len()];
6000 let mut tail: alloc::vec::Vec<Row<'static>> = alloc::vec::Vec::new();
6001 // Overlay: index into ORIGINAL row space (existing rows
6002 // 0..original_rows.len()) or into tail (offset
6003 // original_rows.len()). Map -> new values.
6004 let mut overlay: alloc::collections::BTreeMap<usize, alloc::vec::Vec<Value<'static>>> =
6005 alloc::collections::BTreeMap::new();
6006 // v7.37.15 (Epic W durable-tombstone slice) — extra bookkeeping
6007 // ONLY when this run actually carries an in-place `Tombstone`.
6008 // A tombstone keeps its row physically present but stamps `xmax`
6009 // on the header; the run finalizer `set_rows_and_rebuild_indices`
6010 // freezes every header (and reassigns ids), so we must re-stamp
6011 // in a post-pass keyed by RowId. When the run has no tombstone
6012 // (every default gate-off replay) this is all skipped and the
6013 // path below stays byte-for-byte the legacy one.
6014 let has_tomb = run.iter().any(|c| matches!(c, RowChange::Tombstone { .. }));
6015 // Ids of the pre-existing rows, snapshotted parallel to
6016 // `original_rows`, and ids of the tail rows filled from each
6017 // `Insert`'s carried `rowid`. Together they let a tombstone name
6018 // the exact row the writer stamped, independent of the ids the
6019 // finalizer will hand out. (When `!has_tomb`, both stay empty.)
6020 // v7.39 (flip crash-replay P0) — ids are tracked UNCONDITIONALLY
6021 // now: the finalizer preserves them so a later WAL record's
6022 // tombstone can still name rows this record produced.
6023 let orig_rowids: alloc::vec::Vec<row_header::RowId> =
6024 table.rowids().iter().copied().collect();
6025 // Headers snapshotted in lock-step: the finalizer preserves
6026 // them so earlier records' tombstone stamps survive.
6027 let orig_headers: alloc::vec::Vec<row_header::RowHeader> =
6028 table.headers().iter().copied().collect();
6029 let mut tail_rowids: alloc::vec::Vec<row_header::RowId> = alloc::vec::Vec::new();
6030 // (RowId, xmax) of every row this run tombstones.
6031 let mut tomb_targets: alloc::vec::Vec<(row_header::RowId, u64)> = alloc::vec::Vec::new();
6032 // Helper: given a "current" position (i.e. position in
6033 // the post-prior-deletes layout), translate to the
6034 // ABSOLUTE position in the unified live + tail space
6035 // by walking the live vector + tail. Returns None when
6036 // the position is out of range.
6037 fn translate(live: &[bool], tail_len: usize, current_pos: usize) -> Option<usize> {
6038 // Walk live[..] counting live entries until we hit
6039 // current_pos. Then if not yet matched, dip into tail.
6040 let mut seen = 0usize;
6041 for (i, &alive) in live.iter().enumerate() {
6042 if alive {
6043 if seen == current_pos {
6044 return Some(i);
6045 }
6046 seen += 1;
6047 }
6048 }
6049 // Position lives in tail. tail_len rows in the tail
6050 // are all live (we haven't deleted any tail rows in
6051 // this simplification; if we did, we'd extend `live`).
6052 let off = current_pos - seen;
6053 if off < tail_len {
6054 Some(live.len() + off)
6055 } else {
6056 None
6057 }
6058 }
6059 for change in run {
6060 match *change {
6061 RowChange::Insert { row, rowid, .. } => {
6062 // Validate against schema before recording the
6063 // change so a corrupt log surfaces as an error
6064 // rather than silently mis-applying.
6065 if row.len() != table.schema().columns.len() {
6066 return Err(StorageError::ArityMismatch {
6067 expected: table.schema().columns.len(),
6068 actual: row.len(),
6069 });
6070 }
6071 tail.push(row.clone());
6072 // Keep the id lock-step with `tail` so a later
6073 // tombstone (this run or a later WAL record) can
6074 // find the row by the id the writer captured.
6075 tail_rowids.push(*rowid);
6076 }
6077 RowChange::Update { pos, new_row, .. } => {
6078 if new_row.len() != table.schema().columns.len() {
6079 return Err(StorageError::ArityMismatch {
6080 expected: table.schema().columns.len(),
6081 actual: new_row.len(),
6082 });
6083 }
6084 let abs = translate(&live, tail.len(), *pos).ok_or_else(|| {
6085 StorageError::Corrupt(alloc::format!(
6086 "redo: update_row position {pos} out of bounds in table {table_name:?}",
6087 ))
6088 })?;
6089 // Tail edits are applied directly to `tail`
6090 // (we own it); existing-row edits land in
6091 // the overlay map keyed by original index.
6092 if abs < live.len() {
6093 overlay.insert(abs, new_row.clone());
6094 } else {
6095 tail[abs - live.len()] = Row::new(new_row.clone());
6096 }
6097 }
6098 RowChange::Delete { positions, .. } => {
6099 // De-dup + sort so the translate walk stays
6100 // monotone (the second translate doesn't have
6101 // to redo work the first one did, in principle;
6102 // we keep it simple here and re-walk per
6103 // position). Bounds-filter silently mirrors
6104 // `Table::delete_rows`.
6105 let mut sorted: alloc::vec::Vec<usize> = positions.clone();
6106 sorted.sort_unstable();
6107 sorted.dedup();
6108 // Walk live[] once per Delete record to
6109 // translate all positions in this record's
6110 // post-prior-deletes layout to absolute
6111 // indices. We MUST defer the live[] flip
6112 // until after all positions are translated
6113 // so two positions in the same record
6114 // (e.g. [3, 7]) reference the same layout.
6115 let mut to_flip_live: alloc::vec::Vec<usize> = alloc::vec::Vec::new();
6116 let mut to_flip_tail: alloc::vec::Vec<usize> = alloc::vec::Vec::new();
6117 // Two-pointer walk: live[i] scanned monotonically,
6118 // sorted positions consumed in order.
6119 let mut seen = 0usize;
6120 let mut sp = sorted.iter().peekable();
6121 for (i, &alive) in live.iter().enumerate() {
6122 if !alive {
6123 continue;
6124 }
6125 while let Some(&&p) = sp.peek() {
6126 if seen == p {
6127 to_flip_live.push(i);
6128 sp.next();
6129 } else {
6130 break;
6131 }
6132 }
6133 if sp.peek().is_none() {
6134 break;
6135 }
6136 seen += 1;
6137 }
6138 // Remaining positions fall into the tail.
6139 for &p in sp {
6140 // p >= seen and refers to the (p - seen)-th
6141 // entry in tail. Filter out-of-bounds.
6142 let off = p - seen;
6143 if off < tail.len() {
6144 to_flip_tail.push(off);
6145 }
6146 }
6147 for i in to_flip_live {
6148 live[i] = false;
6149 // Any pending overlay edit for this
6150 // index is moot — the row is gone.
6151 overlay.remove(&i);
6152 }
6153 // Tail deletes: remove in REVERSE order so
6154 // shifting indices stay valid.
6155 to_flip_tail.sort_unstable();
6156 to_flip_tail.dedup();
6157 for off in to_flip_tail.into_iter().rev() {
6158 tail.remove(off);
6159 {
6160 // Keep the id vector lock-step with `tail`.
6161 tail_rowids.remove(off);
6162 }
6163 // Re-key tail-relative overlay entries that
6164 // were past `off` — in practice tail edits
6165 // are applied directly so the overlay map
6166 // only holds existing-row keys; nothing to
6167 // do here.
6168 }
6169 }
6170 RowChange::Tombstone { rowids, xmax, .. } => {
6171 // An in-place tombstone leaves the row physically
6172 // present — it does not touch `live` / `tail` /
6173 // `overlay`. Record the (id, xmax) targets; the
6174 // post-finalizer pass re-stamps `xmax` onto the
6175 // matching row's (otherwise-frozen) header.
6176 for rid in rowids {
6177 tomb_targets.push((*rid, *xmax));
6178 }
6179 }
6180 }
6181 }
6182 // Compose the final row layout: keep existing rows where
6183 // live[i] = true, applying overlay edits in place; then
6184 // append the surviving tail.
6185 let mut new_rows: PersistentVec<Row> = PersistentVec::new();
6186 let mut new_hot_bytes: u64 = 0;
6187 let schema_snapshot = table.schema().clone();
6188 // Parallel to `new_rows` (only built when `has_tomb`): the RowId
6189 // of each row in its FINAL slot, so the post-pass can map a
6190 // tombstone target id → the slot to re-stamp `xmax` on.
6191 let mut final_rowids: alloc::vec::Vec<row_header::RowId> = alloc::vec::Vec::new();
6192 let mut final_headers: alloc::vec::Vec<row_header::RowHeader> = alloc::vec::Vec::new();
6193 for (i, row) in original_rows.into_iter().enumerate() {
6194 if !live[i] {
6195 continue;
6196 }
6197 let final_row = if let Some(new_values) = overlay.remove(&i) {
6198 Row::new(new_values)
6199 } else {
6200 row
6201 };
6202 new_hot_bytes = new_hot_bytes
6203 .saturating_add(row_body_encoded_len(&final_row, &schema_snapshot) as u64);
6204 new_rows.push_mut(final_row);
6205 final_rowids.push(
6206 orig_rowids
6207 .get(i)
6208 .copied()
6209 .unwrap_or(row_header::RowId::UNASSIGNED),
6210 );
6211 final_headers.push(
6212 orig_headers
6213 .get(i)
6214 .copied()
6215 .unwrap_or_else(row_header::RowHeader::frozen),
6216 );
6217 }
6218 for (off, row) in tail.into_iter().enumerate() {
6219 new_hot_bytes =
6220 new_hot_bytes.saturating_add(row_body_encoded_len(&row, &schema_snapshot) as u64);
6221 new_rows.push_mut(row);
6222 final_rowids.push(
6223 tail_rowids
6224 .get(off)
6225 .copied()
6226 .unwrap_or(row_header::RowId::UNASSIGNED),
6227 );
6228 final_headers.push(row_header::RowHeader::frozen());
6229 }
6230 // v7.39 (flip crash-replay P0) — id-preserving finalizer, so a
6231 // LATER WAL record's tombstone still resolves rows this record
6232 // produced (per-statement replay used to reassign ids between
6233 // records, orphaning every cross-record tombstone target).
6234 table.set_rows_and_rebuild_indices_with_rowids(
6235 new_rows,
6236 new_hot_bytes,
6237 &final_rowids,
6238 &final_headers,
6239 );
6240 // v7.37.15 (Epic W durable-tombstone slice) — header-preserving
6241 // re-stamp. `set_rows_and_rebuild_indices` above froze every
6242 // header, so any row this run tombstoned is currently all-
6243 // visible again. Re-apply the `xmax` stamp by matching the
6244 // tombstone's target RowId against the final-slot id map. This
6245 // is what makes a gate-on DELETE durable across replay without
6246 // changing the on-disk snapshot format (headers/ids are still
6247 // NOT serialised — that is the deferred V6 coupling; see below).
6248 if has_tomb && !tomb_targets.is_empty() {
6249 let mut id_to_slot: alloc::collections::BTreeMap<row_header::RowId, usize> =
6250 alloc::collections::BTreeMap::new();
6251 for (slot, rid) in final_rowids.iter().enumerate() {
6252 if *rid != row_header::RowId::UNASSIGNED {
6253 id_to_slot.insert(*rid, slot);
6254 }
6255 }
6256 let table = self.get_mut(table_name).ok_or_else(|| {
6257 StorageError::Corrupt(alloc::format!("redo: unknown table {table_name:?}"))
6258 })?;
6259 for (rid, xmax) in &tomb_targets {
6260 match id_to_slot.get(rid) {
6261 Some(&slot) => {
6262 // First-deleter-wins + bounds handled inside.
6263 let _ = table.mark_row_deleted(slot, *xmax);
6264 }
6265 None => {
6266 // The target row was not produced by THIS redo
6267 // run and its id was not in the run-start
6268 // snapshot — the documented cross-checkpoint
6269 // limitation: after a checkpoint restore the
6270 // table's ids are reassigned (not yet persisted
6271 // in the envelope), so a tombstone naming a
6272 // pre-checkpoint row cannot be resolved by id.
6273 // Skipping leaves the row visible (identical to
6274 // the pre-Epic-W non-durable behaviour); it is
6275 // never a correctness regression, only an
6276 // unclosed durability gap the V6 envelope slice
6277 // closes. Counted for observability.
6278 UNRESOLVED_TOMBSTONES.fetch_add(1, core::sync::atomic::Ordering::Relaxed);
6279 }
6280 }
6281 }
6282 }
6283 Ok(())
6284 }
6285
6286 fn table_for_redo(&mut self, name: &str) -> Result<&mut Table, StorageError> {
6287 self.get_mut(name)
6288 .ok_or_else(|| StorageError::Corrupt(alloc::format!("redo: unknown table {name:?}")))
6289 }
6290
6291 /// v7.34 (crash-recovery P0 #2) — enable row-level redo capture on
6292 /// every table (the engine calls this before a mutating statement
6293 /// when persistence is on; idempotent, keeps any in-flight capture).
6294 pub fn enable_redo_all(&mut self) {
6295 for t in &mut self.tables {
6296 t.enable_redo();
6297 }
6298 }
6299
6300 /// v7.34 — drain the row-level redo captured across all tables, in
6301 /// table order then per-table apply order, and stop capturing. The
6302 /// engine calls this after a successful mutating statement and writes
6303 /// the returned [`RowChange`]s to the WAL in place of the SQL text.
6304 pub fn drain_redo(&mut self) -> Vec<RowChange> {
6305 let mut all = Vec::new();
6306 for t in &mut self.tables {
6307 all.extend(t.take_redo());
6308 }
6309 all
6310 }
6311
6312 pub fn table_count(&self) -> usize {
6313 self.tables.len()
6314 }
6315
6316 /// v7.14.0 — remove a table by name. Returns `true` when the
6317 /// table existed (and is now gone), `false` when it didn't.
6318 /// Used by `DROP TABLE` from pg_dump / mysqldump preambles
6319 /// where the dump re-creates schema and starts with
6320 /// `DROP TABLE IF EXISTS`.
6321 pub fn drop_table(&mut self, name: &str) -> bool {
6322 // v7.39 (round 436) — resolve through the session's temp namespace
6323 // first, exactly as a read would: MariaDB's plain `DROP TABLE tmp`
6324 // drops the TEMPORARY one and leaves a permanent namesake standing
6325 // (measured). Removing by the raw name would have dropped the
6326 // permanent table out from under every other session.
6327 let key = match self.temp_prefix.as_ref() {
6328 Some(p) => {
6329 let mangled = alloc::format!("{p}{name}");
6330 if self.by_name.contains_key(&mangled) {
6331 mangled
6332 } else {
6333 name.into()
6334 }
6335 }
6336 None => name.into(),
6337 };
6338 let Some(idx) = self.by_name.remove(&key) else {
6339 return false;
6340 };
6341 // v7.39 (round 496) — see `dirty_tables`. Recorded under the
6342 // RESOLVED key, which is what a commit-time merge looks up.
6343 self.dirty_tables.insert(key.clone());
6344 // swap_remove invalidates the trailing index → rebuild
6345 // by_name for affected entries.
6346 self.tables.swap_remove(idx);
6347 // Re-stamp moved table's index slot in by_name.
6348 if idx < self.tables.len() {
6349 let moved_name = self.tables[idx].schema.name.clone();
6350 self.by_name.insert(moved_name, idx);
6351 }
6352 true
6353 }
6354
6355 /// v7.16.2 — rename a table (mailrs round-10 A.5). Updates
6356 /// the schema name, the catalog name → index map, and
6357 /// rewrites every reference dangling at the table name:
6358 /// * every FK on every OTHER table whose `parent_table`
6359 /// pointed at the old name now points at the new
6360 /// name, so FK enforcement keeps working
6361 /// * every trigger watching the table updates its `table`
6362 /// field
6363 /// Returns `Ok` on success; `Err(StorageError::TableNotFound)`
6364 /// when the old name isn't in the catalog and
6365 /// `Err(StorageError::DuplicateTable)` when the new name is
6366 /// already taken.
6367 pub fn rename_table(&mut self, old: &str, new: &str) -> Result<(), StorageError> {
6368 if old == new {
6369 return Ok(());
6370 }
6371 if self.by_name.contains_key(new) {
6372 return Err(StorageError::Corrupt(format!(
6373 "rename_table: target name {new:?} already exists"
6374 )));
6375 }
6376 let idx = self
6377 .by_name
6378 .remove(old)
6379 .ok_or_else(|| StorageError::TableNotFound { name: old.into() })?;
6380 self.tables[idx].schema.name = new.to_string();
6381 self.by_name.insert(new.to_string(), idx);
6382 for t in &mut self.tables {
6383 for fk in &mut t.schema.foreign_keys {
6384 if fk.parent_table == old {
6385 fk.parent_table = new.to_string();
6386 }
6387 }
6388 }
6389 for trig in &mut self.triggers {
6390 if trig.table == old {
6391 trig.table = new.to_string();
6392 }
6393 }
6394 Ok(())
6395 }
6396
6397 /// v7.16.2 — rename an index by name. Walks every table
6398 /// since the index lives on its owning table; updates the
6399 /// name in place. Errors with `IndexNotFound` when no
6400 /// index matches. mailrs round-10 A.5.
6401 pub fn rename_index(&mut self, old: &str, new: &str) -> Result<(), StorageError> {
6402 if old == new {
6403 return Ok(());
6404 }
6405 // Reject the new name if it already exists anywhere.
6406 for t in &self.tables {
6407 if t.indices.iter().any(|i| i.name == new) {
6408 return Err(StorageError::Corrupt(format!(
6409 "rename_index: target name {new:?} already exists"
6410 )));
6411 }
6412 }
6413 for t in &mut self.tables {
6414 for i in &mut t.indices {
6415 if i.name == old {
6416 i.name = new.to_string();
6417 return Ok(());
6418 }
6419 }
6420 }
6421 Err(StorageError::IndexNotFound { name: old.into() })
6422 }
6423
6424 /// v7.14.0 — remove a named index across the catalog.
6425 /// Returns `true` when found + dropped.
6426 pub fn drop_named_index(&mut self, name: &str) -> bool {
6427 for t in &mut self.tables {
6428 let before = t.indices.len();
6429 t.indices.retain(|i| i.name != name);
6430 if t.indices.len() != before {
6431 return true;
6432 }
6433 }
6434 false
6435 }
6436
6437 /// Borrow-free copy of every table's name in catalog order
6438 /// (= insertion order, matching the on-disk encoding).
6439 pub fn table_names(&self) -> Vec<String> {
6440 self.tables.iter().map(|t| t.schema.name.clone()).collect()
6441 }
6442
6443 /// v7.39 (round 436) — the marker every session's temporary-table
6444 /// namespace starts with. Public so the catalog synths can tell a
6445 /// temp table from an ordinary one without knowing the session id.
6446 pub const TEMP_NAME_MARKER: &'static str = "__spg_temp_";
6447
6448 /// v7.39 (round 437) — how a stored table name should appear to the
6449 /// CALLING session in a catalog listing (SHOW TABLES, pg_class,
6450 /// information_schema, …):
6451 /// * an ordinary table → its own name
6452 /// * this session's temporary table → its logical name, prefix stripped
6453 /// * another session's temporary table → `None`, i.e. not listed
6454 ///
6455 /// Measured on both oracles: MariaDB 11 and PG 18 each list the calling
6456 /// session's own temporary tables and neither lists anybody else's.
6457 /// Round 436 stored temp tables under a prefix without teaching the
6458 /// listings about it, so the mangled names leaked to every client.
6459 #[must_use]
6460 pub fn listed_name<'a>(&self, stored: &'a str) -> Option<&'a str> {
6461 if !stored.starts_with(Self::TEMP_NAME_MARKER) {
6462 return Some(stored);
6463 }
6464 let prefix = self.temp_prefix.as_ref()?;
6465 stored.strip_prefix(prefix.as_str())
6466 }
6467
6468 /// The listing names of every table this session may see, in catalog
6469 /// order. See [`Catalog::listed_name`].
6470 #[must_use]
6471 pub fn visible_table_names(&self) -> Vec<String> {
6472 self.tables
6473 .iter()
6474 .filter_map(|t| self.listed_name(&t.schema.name).map(String::from))
6475 .collect()
6476 }
6477
6478 /// v5.1: register a cold-tier segment that already lives in
6479 /// memory (caller did the file read). Returns the
6480 /// `segment_id` that `RowLocator::Cold { segment_id, .. }`
6481 /// will reference — currently this is just the index into
6482 /// `cold_segments`, but treat it as an opaque token.
6483 ///
6484 /// Storage is `no_std`, so file I/O is the caller's
6485 /// responsibility — `spg-server` reads the file and forwards
6486 /// the bytes here. The bytes stay resident in the catalog
6487 /// for the life of the `Catalog`, parsed only once.
6488 pub fn load_segment_bytes(&mut self, bytes: Vec<u8>) -> Result<u32, StorageError> {
6489 let id = u32::try_from(self.cold_segments.len()).map_err(|_| {
6490 StorageError::Corrupt("cold segment count would exceed u32::MAX".into())
6491 })?;
6492 let seg = OwnedSegment::from_bytes(bytes)
6493 .map_err(|e| StorageError::Corrupt(format!("cold segment parse failed: {e}")))?;
6494 self.cold_segments.push(Some(Arc::new(seg)));
6495 Ok(id)
6496 }
6497
6498 /// v6.7.3 — register a cold-tier segment at a specific id. Used
6499 /// by the spg-server manifest-boot path so segments whose
6500 /// neighbouring ids were retired by compaction still get back
6501 /// the same `segment_id` they had pre-restart (the
6502 /// `RowLocator::Cold { segment_id }` baked into the BTree-index
6503 /// snapshot persists across restart and must continue to
6504 /// resolve).
6505 ///
6506 /// Pads the Vec with `None` slots up to `target_id` if needed.
6507 /// Errors when the target slot is already occupied (would
6508 /// stomp another segment), the parse fails, or `target_id`
6509 /// exceeds `u32::MAX`.
6510 pub fn load_segment_bytes_at(
6511 &mut self,
6512 target_id: u32,
6513 bytes: Vec<u8>,
6514 ) -> Result<(), StorageError> {
6515 let seg = OwnedSegment::from_bytes(bytes)
6516 .map_err(|e| StorageError::Corrupt(format!("cold segment parse failed: {e}")))?;
6517 let idx = target_id as usize;
6518 while self.cold_segments.len() <= idx {
6519 self.cold_segments.push(None);
6520 }
6521 if self.cold_segments[idx].is_some() {
6522 return Err(StorageError::Corrupt(format!(
6523 "load_segment_bytes_at: segment_id {target_id} already occupied"
6524 )));
6525 }
6526 self.cold_segments[idx] = Some(Arc::new(seg));
6527 Ok(())
6528 }
6529
6530 /// v6.7.3 — retire a cold-tier segment slot (compaction-driven).
6531 /// The physical file is the caller's concern (typically kept
6532 /// on disk until the next CHECKPOINT writes a manifest that
6533 /// no longer lists it); this just flips the in-memory slot
6534 /// to `None` so later cold lookups for `segment_id` resolve
6535 /// as "unknown" instead of returning a stale row.
6536 ///
6537 /// No-op when the slot is already `None`. Errors only when
6538 /// `segment_id` is out of bounds.
6539 pub fn tombstone_segment(&mut self, segment_id: u32) -> Result<(), StorageError> {
6540 let idx = segment_id as usize;
6541 if idx >= self.cold_segments.len() {
6542 return Err(StorageError::Corrupt(format!(
6543 "tombstone_segment: segment_id {segment_id} out of bounds (len={})",
6544 self.cold_segments.len()
6545 )));
6546 }
6547 self.cold_segments[idx] = None;
6548 Ok(())
6549 }
6550
6551 /// Number of *active* (non-tombstoned) cold segments.
6552 #[must_use]
6553 pub fn cold_segment_count(&self) -> usize {
6554 self.cold_segments.iter().filter(|s| s.is_some()).count()
6555 }
6556
6557 /// v7.37.42 (docker-fair SCALARSQ attack 3) — short-circuit guard
6558 /// for scan loops that conditionally walk the cold tier. Returns
6559 /// `false` when the catalog has never loaded a cold segment (or all
6560 /// segments are tombstoned), so callers can skip the per-table cold
6561 /// PK-index walk entirely on hot-only databases. O(N segments);
6562 /// typical N is small (single-digit) so the check is sub-µs.
6563 #[must_use]
6564 pub fn has_any_cold_segments(&self) -> bool {
6565 self.cold_segments.iter().any(Option::is_some)
6566 }
6567
6568 /// Slot count including tombstones (= the next id the
6569 /// no-arg `load_segment_bytes` would allocate).
6570 #[must_use]
6571 pub fn cold_segment_slot_count(&self) -> usize {
6572 self.cold_segments.len()
6573 }
6574
6575 /// v6.2.7 — list every *active* cold-tier segment id known to
6576 /// this catalog (skips compaction tombstones since v6.7.3).
6577 /// Used by EXPLAIN ANALYZE to annotate scan nodes with the
6578 /// segments they could have walked.
6579 #[must_use]
6580 pub fn cold_segment_ids_global(&self) -> Vec<u32> {
6581 self.cold_segments
6582 .iter()
6583 .enumerate()
6584 .filter_map(|(i, s)| s.as_ref().map(|_| i as u32))
6585 .collect()
6586 }
6587
6588 /// v5.2.1: sum of `Table::hot_bytes` across every table. The v5.2
6589 /// freezer compares this against `SPG_HOT_TIER_BYTES` (parsed at
6590 /// server startup; default 4 GiB) and wakes when the budget is
6591 /// crossed. Pre-freezer (v5.2.1) this is measurement-only — the
6592 /// counter exposes whether the budget is being approached without
6593 /// triggering any demotion.
6594 #[must_use]
6595 pub fn hot_tier_bytes(&self) -> u64 {
6596 self.tables
6597 .iter()
6598 .map(Table::hot_bytes)
6599 .fold(0u64, u64::saturating_add)
6600 }
6601
6602 /// v5.2.2: freeze the **first** `max_rows` rows of `table_name`'s
6603 /// hot tier into a brand-new cold-tier segment. The named `BTree`
6604 /// index supplies the per-row PK (its column must be an integer
6605 /// type — v5.2.2 only supports `IndexKey::Int` PKs, matching the
6606 /// `index_key_as_u64` constraint used by the cold-tier lookup
6607 /// path). On success returns a [`FreezeReport`] with the
6608 /// freshly-allocated segment id, the count of rows that moved,
6609 /// the encoded segment bytes (so the caller can persist them to
6610 /// disk for later reload via `SPG_PRELOAD_COLD_SEGMENT`), and the
6611 /// hot-tier byte delta that was reclaimed.
6612 ///
6613 /// **Semantics**:
6614 /// 1. The first `max_rows` rows (by hot-tier position — same as
6615 /// insertion order under v4.39 `PersistentVec`) are read.
6616 /// 2. Rows are sorted ascending by PK and serialised into a new
6617 /// segment via [`encode_segment`].
6618 /// 3. The hot rows are dropped via [`Table::delete_rows`]; the
6619 /// `rebuild_indices` it triggers regenerates `Hot` locators
6620 /// for every remaining row (their positions shift down by
6621 /// `max_rows`). Existing `Cold` locators in this index — from
6622 /// a previous freeze — are also rebuilt **but with empty
6623 /// payload** since rebuild reads only `self.rows`; this
6624 /// routine re-registers them at the end of the call so the
6625 /// user-visible state preserves all prior cold locators.
6626 /// 4. The new segment is loaded into `self.cold_segments` via
6627 /// [`Catalog::load_segment_bytes`] (allocating a fresh
6628 /// `segment_id`). New `Cold` locators are registered on the
6629 /// named index — one per frozen row.
6630 ///
6631 /// **v5.2.2 limits** (relaxed in later sub-versions):
6632 /// - INSERT-only flow: subsequent UPDATE/DELETE on a frozen row
6633 /// returns a stale-locator error (no promote-on-write until
6634 /// v5.2.3).
6635 /// - Single-table scope: callers iterate tables themselves.
6636 /// - All-or-nothing: returns `Err` and leaves catalog unchanged
6637 /// if any step fails before the atomic swap point.
6638 ///
6639 /// Errors:
6640 /// - [`StorageError::Corrupt`] for missing table/index, non-`BTree`
6641 /// index, non-integer PK column, `max_rows == 0`, or
6642 /// `max_rows > row_count`.
6643 /// - The encoder's [`SegmentError`] surfaces as `Corrupt` (the
6644 /// only realistic source is "a single row is larger than the
6645 /// page size"; SPG schemas don't hit it in practice).
6646 pub fn freeze_oldest_to_cold(
6647 &mut self,
6648 table_name: &str,
6649 index_name: &str,
6650 max_rows: usize,
6651 ) -> Result<FreezeReport, StorageError> {
6652 // --- validation phase: never mutates ---------------------
6653 if max_rows == 0 {
6654 return Err(StorageError::Corrupt(
6655 "freeze_oldest_to_cold: max_rows must be > 0".into(),
6656 ));
6657 }
6658 let table = self.get(table_name).ok_or_else(|| {
6659 StorageError::Corrupt(format!(
6660 "freeze_oldest_to_cold: table {table_name:?} not found"
6661 ))
6662 })?;
6663 if max_rows > table.rows.len() {
6664 return Err(StorageError::Corrupt(format!(
6665 "freeze_oldest_to_cold: max_rows {max_rows} > row_count {}",
6666 table.rows.len()
6667 )));
6668 }
6669 let idx = table
6670 .indices
6671 .iter()
6672 .find(|i| i.name == index_name)
6673 .ok_or_else(|| {
6674 StorageError::Corrupt(format!(
6675 "freeze_oldest_to_cold: index {index_name:?} not found on {table_name:?}"
6676 ))
6677 })?;
6678 if !matches!(idx.kind, IndexKind::BTree(_)) {
6679 return Err(StorageError::Corrupt(format!(
6680 "freeze_oldest_to_cold: index {index_name:?} is NSW; only BTree indices may freeze"
6681 )));
6682 }
6683 let column_position = idx.column_position;
6684
6685 // --- segment build phase: reads only --------------------
6686 let schema = table.schema.clone();
6687 let mut to_freeze: Vec<(u64, Vec<u8>, IndexKey)> = Vec::with_capacity(max_rows);
6688 for row_idx in 0..max_rows {
6689 let row = table.rows.get(row_idx).expect("bounds-checked above");
6690 let key = IndexKey::from_value(&row.values[column_position]).ok_or_else(|| {
6691 StorageError::Corrupt(format!(
6692 "freeze_oldest_to_cold: row {row_idx} has NULL / non-key value in index column"
6693 ))
6694 })?;
6695 let pk_u64 = index_key_as_u64(&key).ok_or_else(|| {
6696 StorageError::Corrupt(format!(
6697 "freeze_oldest_to_cold: index {index_name:?} column type is non-integer; \
6698 v5.2.2 cold tier requires IndexKey::Int (Text PK lands in v5.5+)"
6699 ))
6700 })?;
6701 to_freeze.push((pk_u64, encode_row_body_dense(row, &schema), key));
6702 }
6703 // encode_segment requires ascending u64 keys. Sort by PK
6704 // before encoding; the caller's row-position order is not
6705 // necessarily PK order (e.g. workloads that insert random
6706 // PKs).
6707 to_freeze.sort_by_key(|(k, _, _)| *k);
6708 // Reject duplicate PKs — encode_segment also rejects them
6709 // (`SegmentError::UnsortedKey`), but the resulting error
6710 // message there is misleading. Surface a clearer one.
6711 for w in to_freeze.windows(2) {
6712 if w[0].0 == w[1].0 {
6713 return Err(StorageError::Corrupt(format!(
6714 "freeze_oldest_to_cold: duplicate PK {} in freeze batch",
6715 w[0].0
6716 )));
6717 }
6718 }
6719 // Snapshot the (key, locator) pairs that will be registered
6720 // post-swap. Cloning the IndexKey out before the move makes
6721 // the registration loop borrow-free.
6722 let post_swap_keys: Vec<IndexKey> = to_freeze.iter().map(|(_, _, k)| k.clone()).collect();
6723 // Segment encode is now infallible w.r.t. ordering. Map the
6724 // `SegmentError` into a `StorageError::Corrupt` so the
6725 // public surface stays one error type.
6726 let seg_rows: Vec<(u64, Vec<u8>)> = to_freeze
6727 .into_iter()
6728 .map(|(k, body, _)| (k, body))
6729 .collect();
6730 let frozen_rows = seg_rows.len();
6731 let (seg_bytes, _meta) = encode_segment(seg_rows.into_iter(), 0.01, SEGMENT_PAGE_BYTES)
6732 .map_err(|e| StorageError::Corrupt(format!("freeze_oldest_to_cold: encode: {e}")))?;
6733
6734 // --- atomic swap phase: mutations only past this point ---
6735 // v5.2.3 made `Table::rebuild_indices` preserve every Cold
6736 // locator across the per-table rebuild, so `delete_rows`
6737 // below no longer wipes prior-freeze cold entries. The pre-
6738 // v5.2.3 capture-then-re-register that used to live here
6739 // was removed in v5.3.1 — keeping it would double-count
6740 // every prior-frozen key's Cold locator on each subsequent
6741 // freeze.
6742 let bytes_before = self.get(table_name).expect("just validated").hot_bytes();
6743 let positions: Vec<usize> = (0..max_rows).collect();
6744 let t_mut = self
6745 .get_mut(table_name)
6746 .expect("just validated; still present");
6747 let removed = t_mut.delete_rows(&positions);
6748 debug_assert_eq!(removed, max_rows, "delete_rows count matches request");
6749 let bytes_after = t_mut.hot_bytes();
6750 let bytes_freed = bytes_before.saturating_sub(bytes_after);
6751
6752 let segment_id = self
6753 .load_segment_bytes(seg_bytes.clone())
6754 .map_err(|e| StorageError::Corrupt(format!("freeze_oldest_to_cold: load: {e}")))?;
6755 let new_cold = post_swap_keys.into_iter().map(|k| {
6756 (
6757 k,
6758 RowLocator::Cold {
6759 segment_id,
6760 page_offset: 0,
6761 },
6762 )
6763 });
6764 let t_mut = self.get_mut(table_name).expect("still present");
6765 t_mut.register_cold_locators(index_name, new_cold)?;
6766 // r944 — a freeze has to say that it froze something.
6767 //
6768 // `has_cold_rows_fast()` reads the cached count, and neither
6769 // freeze path touched it, so afterwards it answered "no cold
6770 // rows" while cold rows existed. That predicate gates four join
6771 // paths, and a gate that wrongly declines the cold-aware path
6772 // drops the frozen rows from the answer.
6773 //
6774 // Marking it stale rather than adding to it: stale reads as
6775 // true, which is the safe direction, and this function cannot
6776 // know the exact total (rows may already have been cold). ANALYZE
6777 // recomputes the number.
6778 t_mut.mark_cold_row_count_stale();
6779
6780 Ok(FreezeReport {
6781 segment_id,
6782 frozen_rows,
6783 bytes_freed,
6784 segment_bytes: seg_bytes,
6785 })
6786 }
6787
6788 /// v5.1: borrow the cold segment at `segment_id`. Used by the
6789 /// spg-server preload path to enumerate (key, locator) pairs
6790 /// after loading a segment, so it can call
6791 /// [`Table::register_cold_locators`] without re-parsing the
6792 /// bytes.
6793 #[must_use]
6794 pub fn cold_segment(&self, segment_id: u32) -> Option<&OwnedSegment> {
6795 self.cold_segments
6796 .get(segment_id as usize)
6797 .and_then(|s| s.as_deref())
6798 }
6799
6800 /// v5.1: resolve a single `RowLocator::Cold` to its underlying
6801 /// `Row`. Decoupled from [`Catalog::lookup_by_pk`] so callers
6802 /// iterating a multi-locator slice (e.g. the engine's index
6803 /// seek path) can dispatch per locator instead of getting back
6804 /// only the first row for a key. Returns `None` when the
6805 /// segment isn't registered, the key isn't `u64`-coercible, or
6806 /// the segment doesn't actually carry the key (bloom or page-
6807 /// index reject).
6808 pub fn resolve_cold_locator(
6809 &self,
6810 table_name: &str,
6811 segment_id: u32,
6812 key: &IndexKey,
6813 ) -> Option<Row<'static>> {
6814 let t = self.get(table_name)?;
6815 let u64_key = index_key_as_u64(key)?;
6816 let seg = self.cold_segments.get(segment_id as usize)?.as_ref()?;
6817 let payload = seg.lookup(u64_key)?;
6818 let (row, _) = decode_row_body_dense(&payload, &t.schema, seg.codec_version()).ok()?;
6819 // v7.39 (pg_stat blks knife) — one cold-tier "block read".
6820 self.cold_read_stats
6821 .cold_reads
6822 .fetch_add(1, core::sync::atomic::Ordering::Relaxed);
6823 Some(row)
6824 }
6825
6826 /// v5.1: indexed PK lookup that dispatches per locator,
6827 /// returning the first matching row from either the hot tier
6828 /// (`Table::rows`) or a registered cold segment.
6829 ///
6830 /// The cold path requires the index column to be coercible to
6831 /// a `u64` (the segment's PK type) and the segment payload to
6832 /// be a [`encode_row_body_dense`]-encoded row body for the
6833 /// same schema. v5.1 ships this for BIGINT / INT / SMALLINT
6834 /// PKs; other types fall through to hot-only behavior.
6835 ///
6836 /// Returns `None` if (a) the table or index doesn't exist,
6837 /// (b) the key isn't in the index at all, or (c) the key was
6838 /// resolved to a stale locator (Hot index out of range, Cold
6839 /// segment id unknown, segment lookup miss). Does not surface
6840 /// segment-decode errors — those would indicate corrupted
6841 /// cold-tier files and should be caught at
6842 /// [`Catalog::load_segment_bytes`] time.
6843 pub fn lookup_by_pk(&self, table: &str, index_name: &str, key: &IndexKey) -> Option<Row<'_>> {
6844 let t = self.get(table)?;
6845 let idx = t.indices.iter().find(|i| i.name == index_name)?;
6846 let locators = idx.lookup_eq(key);
6847 let cold_u64_key = index_key_as_u64(key);
6848 for loc in locators {
6849 match *loc {
6850 RowLocator::Hot(i) => {
6851 if let Some(row) = t.rows.get(i) {
6852 return Some(row.clone());
6853 }
6854 }
6855 RowLocator::Cold {
6856 segment_id,
6857 page_offset: _,
6858 } => {
6859 let Some(u64_key) = cold_u64_key else {
6860 // Key type not coercible to u64 — cold tier
6861 // only handles BIGINT/INT/SMALLINT in v5.1.
6862 continue;
6863 };
6864 let Some(seg) = self
6865 .cold_segments
6866 .get(segment_id as usize)
6867 .and_then(|s| s.as_deref())
6868 else {
6869 // v6.7.3 — `None` slot = compaction
6870 // retired this segment; the live locator
6871 // on a freshly-compacted index points to
6872 // the merged segment_id, so a Cold hit
6873 // here against a tombstone means the BTree
6874 // entry hasn't been swapped yet (mid-
6875 // compaction reader race) or the caller is
6876 // looking up a stale snapshot. Skip — the
6877 // next locator in the list, if any, is
6878 // typically the merged segment.
6879 continue;
6880 };
6881 let Some(payload) = seg.lookup(u64_key) else {
6882 continue;
6883 };
6884 let (row, _) =
6885 decode_row_body_dense(&payload, &t.schema, seg.codec_version()).ok()?;
6886 return Some(row);
6887 }
6888 }
6889 }
6890 None
6891 }
6892
6893 /// v5.2.3: promote a frozen row back to the hot tier so an
6894 /// UPDATE / DELETE can mutate it. Reads the cold-tier row body
6895 /// (decoded from its registered segment), pushes it into
6896 /// `table.rows` via [`Table::insert`] (which also adds a fresh
6897 /// `Hot(new_idx)` locator on `index_name`), then retires the
6898 /// shadowed `Cold` locator via
6899 /// [`Table::remove_cold_locators_for_key`]. The cold-tier row
6900 /// in the segment file becomes garbage — recoverable when a
6901 /// future cold-segment compaction job lands.
6902 ///
6903 /// Returns:
6904 /// - `Ok(Some(new_hot_idx))` when the key resolved through a
6905 /// cold locator and the promote completed. `new_hot_idx` is
6906 /// the position the row now occupies in `table.rows`.
6907 /// - `Ok(None)` when the key has no Cold locator on the index
6908 /// (already hot, or wasn't present at all). Callers treat this
6909 /// as "nothing to do here, fall back to the hot-only path".
6910 ///
6911 /// Errors when the table / index doesn't exist, the index isn't
6912 /// `BTree`, the cold segment is missing / can't decode the row,
6913 /// or the inferred row body fails `Table::insert` validation.
6914 pub fn promote_cold_row(
6915 &mut self,
6916 table_name: &str,
6917 index_name: &str,
6918 key: &IndexKey,
6919 ) -> Result<Option<usize>, StorageError> {
6920 let cold_loc = self.find_cold_locator(table_name, index_name, key)?;
6921 let Some((segment_id, _page_offset)) = cold_loc else {
6922 return Ok(None);
6923 };
6924 let u64_key = index_key_as_u64(key).ok_or_else(|| {
6925 StorageError::Corrupt(
6926 "promote_cold_row: key type not coercible to u64 (cold tier requires integer PK)"
6927 .into(),
6928 )
6929 })?;
6930 // Read the row body from the segment. Borrow the segment +
6931 // schema short-term so we can then take `&mut self` for the
6932 // hot-side insert.
6933 let schema = self
6934 .get(table_name)
6935 .ok_or_else(|| {
6936 StorageError::Corrupt(format!("promote_cold_row: table {table_name:?} not found"))
6937 })?
6938 .schema
6939 .clone();
6940 let seg = self
6941 .cold_segments
6942 .get(segment_id as usize)
6943 .and_then(|s| s.as_ref())
6944 .ok_or_else(|| {
6945 StorageError::Corrupt(format!(
6946 "promote_cold_row: segment {segment_id} not registered on catalog"
6947 ))
6948 })?;
6949 let payload = seg.lookup(u64_key).ok_or_else(|| {
6950 StorageError::Corrupt(format!(
6951 "promote_cold_row: key {u64_key} resolves to segment {segment_id} \
6952 but the segment's bloom/page lookup didn't return a row"
6953 ))
6954 })?;
6955 let (row, _consumed) = decode_row_body_dense(&payload, &schema, seg.codec_version())?;
6956 // Insert the promoted row into the hot tier. `Table::insert`
6957 // appends to `self.rows`, adds a `Hot(new_idx)` locator to
6958 // every BTree index covering the row's keyed columns, and
6959 // increments `hot_bytes`.
6960 let t = self
6961 .get_mut(table_name)
6962 .expect("table existed at lookup time");
6963 t.insert(row)?;
6964 let new_hot_idx =
6965 t.rows.len().checked_sub(1).ok_or_else(|| {
6966 StorageError::Corrupt("promote_cold_row: empty after insert".into())
6967 })?;
6968 // The hot insert added Hot(new_idx) alongside the still-
6969 // present Cold locator. Drop the Cold entry so future
6970 // lookups return only the fresh hot row.
6971 t.remove_cold_locators_for_key(index_name, key)?;
6972 Ok(Some(new_hot_idx))
6973 }
6974
6975 /// v5.2.3: shadow a frozen row's index entry. Used by DELETE
6976 /// when the row to remove lives in a cold-tier segment — the
6977 /// row body stays in the segment file (becoming garbage) but
6978 /// every `Cold` locator for `key` on `index_name` is removed
6979 /// so PK lookups stop returning it.
6980 ///
6981 /// Returns the number of cold locators retired (0 when the key
6982 /// has no cold entries — the DELETE fell on a hot row or a
6983 /// key that was already absent). Errors when the table /
6984 /// index doesn't exist or the index isn't `BTree`.
6985 ///
6986 /// Cold-segment compaction (which merges shadowed-heavy
6987 /// segments and reclaims their disk footprint) lands in a
6988 /// later v5.x sub-version; until then, repeated UPDATE/DELETE
6989 /// of cold rows can amplify cold-segment disk usage by up to
6990 /// 1-2× — still well under typical LSM-tree shadowing because
6991 /// SPG segments are bulk-baked, not write-merged.
6992 pub fn shadow_cold_row(
6993 &mut self,
6994 table_name: &str,
6995 index_name: &str,
6996 key: &IndexKey,
6997 ) -> Result<usize, StorageError> {
6998 let t = self.get_mut(table_name).ok_or_else(|| {
6999 StorageError::Corrupt(format!("shadow_cold_row: table {table_name:?} not found"))
7000 })?;
7001 t.remove_cold_locators_for_key(index_name, key)
7002 }
7003
7004 /// v6.7.4 — read-only slice preparation for the parallel
7005 /// freezer. Walks rows in `row_range`, builds the
7006 /// `(pk_u64, encoded_body, IndexKey)` triples that the
7007 /// coordinator's k-way merge consumes, sorts the slice by
7008 /// `pk_u64`, and returns a [`FreezeSlice`].
7009 ///
7010 /// Caller invariants:
7011 /// - `row_range.end <= table.rows.len()` (caller's job to
7012 /// compute the partition).
7013 /// - All slices passed to `commit_freeze_slices` must cover a
7014 /// contiguous half-open range `[0, total_max_rows)` with no
7015 /// gaps and no overlaps. The coordinator validates this
7016 /// invariant before committing.
7017 ///
7018 /// `&self`-only — multiple workers can run this concurrently
7019 /// against the same `Catalog` reference under the engine's
7020 /// write lock (workers don't mutate; the coordinator does).
7021 pub fn prepare_freeze_slice(
7022 &self,
7023 table_name: &str,
7024 index_name: &str,
7025 row_range: core::ops::Range<usize>,
7026 ) -> Result<FreezeSlice, StorageError> {
7027 let table = self.get(table_name).ok_or_else(|| {
7028 StorageError::Corrupt(format!(
7029 "prepare_freeze_slice: table {table_name:?} not found"
7030 ))
7031 })?;
7032 let idx = table
7033 .indices
7034 .iter()
7035 .find(|i| i.name == index_name)
7036 .ok_or_else(|| {
7037 StorageError::Corrupt(format!(
7038 "prepare_freeze_slice: index {index_name:?} not found on {table_name:?}"
7039 ))
7040 })?;
7041 if !matches!(idx.kind, IndexKind::BTree(_)) {
7042 return Err(StorageError::Corrupt(format!(
7043 "prepare_freeze_slice: index {index_name:?} is NSW; only BTree indices may freeze"
7044 )));
7045 }
7046 if row_range.end > table.rows.len() {
7047 return Err(StorageError::Corrupt(format!(
7048 "prepare_freeze_slice: row_range end {} > row_count {}",
7049 row_range.end,
7050 table.rows.len()
7051 )));
7052 }
7053 let column_position = idx.column_position;
7054 let schema = table.schema.clone();
7055 let mut rows: Vec<(u64, Vec<u8>, IndexKey)> = Vec::with_capacity(row_range.len());
7056 for row_idx in row_range.clone() {
7057 let row = table.rows.get(row_idx).expect("bounds-checked above");
7058 let key = IndexKey::from_value(&row.values[column_position]).ok_or_else(|| {
7059 StorageError::Corrupt(format!(
7060 "prepare_freeze_slice: row {row_idx} has NULL / non-key value in index column"
7061 ))
7062 })?;
7063 let pk_u64 = index_key_as_u64(&key).ok_or_else(|| {
7064 StorageError::Corrupt(format!(
7065 "prepare_freeze_slice: index {index_name:?} column type is non-integer; \
7066 v5.2.2 cold tier requires IndexKey::Int (Text PK lands in v5.5+)"
7067 ))
7068 })?;
7069 rows.push((pk_u64, encode_row_body_dense(row, &schema), key));
7070 }
7071 rows.sort_by_key(|(k, _, _)| *k);
7072 Ok(FreezeSlice { row_range, rows })
7073 }
7074
7075 /// v6.7.4 — coordinator commit step. Merges N
7076 /// [`FreezeSlice`]s into one segment via the standard
7077 /// [`encode_segment`] path, atomically swaps the catalog
7078 /// state (delete the union row range + register Cold
7079 /// locators + load the segment).
7080 ///
7081 /// Validates that the slices cover a contiguous, gap-free,
7082 /// overlap-free half-open range starting at index 0 (the
7083 /// freezer always freezes "oldest first" — same semantics as
7084 /// the single-threaded [`Catalog::freeze_oldest_to_cold`]).
7085 ///
7086 /// Empty `slices` → no-op success (returns a zero-row report
7087 /// without mutating). Total row count = `Σ slice.rows.len()`.
7088 pub fn commit_freeze_slices(
7089 &mut self,
7090 table_name: &str,
7091 index_name: &str,
7092 slices: Vec<FreezeSlice>,
7093 ) -> Result<FreezeReport, StorageError> {
7094 // --- validation phase: never mutates ---------------------
7095 let table = self.get(table_name).ok_or_else(|| {
7096 StorageError::Corrupt(format!(
7097 "commit_freeze_slices: table {table_name:?} not found"
7098 ))
7099 })?;
7100 let idx = table
7101 .indices
7102 .iter()
7103 .find(|i| i.name == index_name)
7104 .ok_or_else(|| {
7105 StorageError::Corrupt(format!(
7106 "commit_freeze_slices: index {index_name:?} not found on {table_name:?}"
7107 ))
7108 })?;
7109 if !matches!(idx.kind, IndexKind::BTree(_)) {
7110 return Err(StorageError::Corrupt(format!(
7111 "commit_freeze_slices: index {index_name:?} is NSW; only BTree indices may freeze"
7112 )));
7113 }
7114 // Validate slice coverage: contiguous from 0, no gaps, no
7115 // overlaps. Allow the caller to pass slices in any order —
7116 // sort by row_range.start first.
7117 let mut ordered = slices;
7118 ordered.sort_by_key(|s| s.row_range.start);
7119 // Drop fully-empty slices that fell out of an uneven
7120 // partition; they carry no data but contribute to the
7121 // contiguity check, so keep them in line.
7122 let mut expected_start = 0usize;
7123 for s in &ordered {
7124 if s.row_range.start != expected_start {
7125 return Err(StorageError::Corrupt(format!(
7126 "commit_freeze_slices: gap/overlap at row {}; expected start {}",
7127 s.row_range.start, expected_start
7128 )));
7129 }
7130 expected_start = s.row_range.end;
7131 }
7132 let max_rows = expected_start;
7133 if max_rows > table.rows.len() {
7134 return Err(StorageError::Corrupt(format!(
7135 "commit_freeze_slices: total row range {} exceeds row_count {}",
7136 max_rows,
7137 table.rows.len()
7138 )));
7139 }
7140 if max_rows == 0 {
7141 return Ok(FreezeReport {
7142 segment_id: u32::MAX,
7143 frozen_rows: 0,
7144 bytes_freed: 0,
7145 segment_bytes: Vec::new(),
7146 });
7147 }
7148
7149 // --- segment build phase: reads only --------------------
7150 // K-way merge of already-sorted slices. Each slice's rows
7151 // are ascending by pk_u64; we keep a per-slice cursor and
7152 // pull the next-smallest head until every cursor drains.
7153 let total_rows: usize = ordered.iter().map(|s| s.rows.len()).sum();
7154 if total_rows != max_rows {
7155 return Err(StorageError::Corrupt(format!(
7156 "commit_freeze_slices: total slice rows {total_rows} ≠ row_range coverage {max_rows}"
7157 )));
7158 }
7159 let mut cursors: Vec<usize> = alloc::vec![0; ordered.len()];
7160 let mut merged: Vec<(u64, Vec<u8>, IndexKey)> = Vec::with_capacity(total_rows);
7161 loop {
7162 // Pick the slice whose head row has the smallest key
7163 // and isn't yet exhausted.
7164 let mut pick: Option<usize> = None;
7165 for (i, c) in cursors.iter().enumerate() {
7166 let slice = &ordered[i];
7167 if *c >= slice.rows.len() {
7168 continue;
7169 }
7170 match pick {
7171 None => pick = Some(i),
7172 Some(j) => {
7173 if slice.rows[*c].0 < ordered[j].rows[cursors[j]].0 {
7174 pick = Some(i);
7175 }
7176 }
7177 }
7178 }
7179 let Some(i) = pick else { break };
7180 let row = ordered[i].rows[cursors[i]].clone();
7181 cursors[i] += 1;
7182 merged.push(row);
7183 }
7184 // Reject duplicate PKs — same error as the single-threaded
7185 // path so callers get a uniform surface.
7186 for w in merged.windows(2) {
7187 if w[0].0 == w[1].0 {
7188 return Err(StorageError::Corrupt(format!(
7189 "commit_freeze_slices: duplicate PK {} across slices",
7190 w[0].0
7191 )));
7192 }
7193 }
7194 let post_swap_keys: Vec<IndexKey> = merged.iter().map(|(_, _, k)| k.clone()).collect();
7195 let seg_rows: Vec<(u64, Vec<u8>)> =
7196 merged.into_iter().map(|(k, body, _)| (k, body)).collect();
7197 let frozen_rows = seg_rows.len();
7198 let (seg_bytes, _meta) = encode_segment(seg_rows.into_iter(), 0.01, SEGMENT_PAGE_BYTES)
7199 .map_err(|e| StorageError::Corrupt(format!("commit_freeze_slices: encode: {e}")))?;
7200
7201 // --- atomic swap phase: mutations only past this point ---
7202 let bytes_before = self.get(table_name).expect("just validated").hot_bytes();
7203 let positions: Vec<usize> = (0..max_rows).collect();
7204 let t_mut = self
7205 .get_mut(table_name)
7206 .expect("just validated; still present");
7207 let removed = t_mut.delete_rows(&positions);
7208 debug_assert_eq!(removed, max_rows, "delete_rows count matches request");
7209 let bytes_after = t_mut.hot_bytes();
7210 let bytes_freed = bytes_before.saturating_sub(bytes_after);
7211
7212 let segment_id = self
7213 .load_segment_bytes(seg_bytes.clone())
7214 .map_err(|e| StorageError::Corrupt(format!("commit_freeze_slices: load: {e}")))?;
7215 let new_cold = post_swap_keys.into_iter().map(|k| {
7216 (
7217 k,
7218 RowLocator::Cold {
7219 segment_id,
7220 page_offset: 0,
7221 },
7222 )
7223 });
7224 let t_mut = self.get_mut(table_name).expect("still present");
7225 t_mut.register_cold_locators(index_name, new_cold)?;
7226 // r944 — a freeze has to say that it froze something.
7227 //
7228 // `has_cold_rows_fast()` reads the cached count, and neither
7229 // freeze path touched it, so afterwards it answered "no cold
7230 // rows" while cold rows existed. That predicate gates four join
7231 // paths, and a gate that wrongly declines the cold-aware path
7232 // drops the frozen rows from the answer.
7233 //
7234 // Marking it stale rather than adding to it: stale reads as
7235 // true, which is the safe direction, and this function cannot
7236 // know the exact total (rows may already have been cold). ANALYZE
7237 // recomputes the number.
7238 t_mut.mark_cold_row_count_stale();
7239
7240 Ok(FreezeReport {
7241 segment_id,
7242 frozen_rows,
7243 bytes_freed,
7244 segment_bytes: seg_bytes,
7245 })
7246 }
7247
7248 /// v6.7.3 — compact every cold segment on `(table, index)` whose
7249 /// `OwnedSegment::bytes().len()` is below `target_segment_bytes`
7250 /// into a single larger merged segment. Rows present in source
7251 /// segment payloads but no longer referenced by any
7252 /// `RowLocator::Cold` on the index (DELETE'd + frozen rows
7253 /// retired via [`Catalog::shadow_cold_row`]) are GC'd in the
7254 /// merge.
7255 ///
7256 /// **Semantics**:
7257 /// 1. Walk the BTree index to collect every Cold locator that
7258 /// targets a small (< threshold) segment. Each such
7259 /// `(key, segment_id)` becomes a row in the merged segment;
7260 /// payload is looked up from the source segment in-place.
7261 /// 2. Encode the collected rows into one new segment via
7262 /// [`encode_segment`]; register it via
7263 /// [`Catalog::load_segment_bytes`] (allocating a fresh
7264 /// `merged_segment_id` at the end of `cold_segments`).
7265 /// 3. Rewrite the BTree index in one pass: every
7266 /// `RowLocator::Cold { segment_id ∈ sources }` becomes
7267 /// `RowLocator::Cold { segment_id = merged_id, page_offset = 0 }`.
7268 /// Hot locators are untouched.
7269 /// 4. Tombstone every source slot via
7270 /// [`Catalog::tombstone_segment`]. Source segment payloads
7271 /// are no longer reachable through the catalog; the on-disk
7272 /// files are the caller's concern.
7273 ///
7274 /// On fewer than 2 candidate segments the catalog is **not**
7275 /// mutated and a no-op report (`merged_segment_id: None`,
7276 /// `sources: []`) is returned. This is the routine case — a
7277 /// freshly-frozen table has at most 1 small segment, no merge
7278 /// possible.
7279 ///
7280 /// Atomicity: every mutating step runs after the read-only
7281 /// gather phase, so a panic before the merge encode leaves the
7282 /// catalog unchanged. The mutation block itself (load + rewrite +
7283 /// tombstone) takes only `&mut self` — callers serialise the
7284 /// engine write lock outside this function.
7285 ///
7286 /// Errors when the table / index doesn't exist, the index isn't
7287 /// `BTree`, the index column type isn't u64-coercible (cold-tier
7288 /// pre-condition), or a source segment fails its in-place
7289 /// row-body lookup (would indicate prior catalog corruption).
7290 pub fn compact_cold_segments(
7291 &mut self,
7292 table_name: &str,
7293 index_name: &str,
7294 target_segment_bytes: u64,
7295 ) -> Result<CompactReport, StorageError> {
7296 // --- validation phase ----------------------------------
7297 let t = self.get(table_name).ok_or_else(|| {
7298 StorageError::Corrupt(format!(
7299 "compact_cold_segments: table {table_name:?} not found"
7300 ))
7301 })?;
7302 let idx = t
7303 .indices
7304 .iter()
7305 .find(|i| i.name == index_name)
7306 .ok_or_else(|| {
7307 StorageError::Corrupt(format!(
7308 "compact_cold_segments: index {index_name:?} not found on {table_name:?}"
7309 ))
7310 })?;
7311 let map = match &idx.kind {
7312 IndexKind::BTree(m) => m,
7313 IndexKind::Nsw(_)
7314 | IndexKind::Brin { .. }
7315 | IndexKind::Gin(_)
7316 | IndexKind::GinTrgm(_)
7317 | IndexKind::GinFulltext(_)
7318 | IndexKind::GinJsonb(_) => {
7319 return Err(StorageError::Corrupt(format!(
7320 "compact_cold_segments: index {index_name:?} is not BTree; \
7321 compaction applies only to BTree cold-tier indices"
7322 )));
7323 }
7324 };
7325
7326 // --- gather phase --------------------------------------
7327 // Step A: every segment_id this BTree index Cold-references.
7328 let mut referenced_ids: BTreeSet<u32> = BTreeSet::new();
7329 for (_key, locators) in map.iter() {
7330 for loc in locators {
7331 if let RowLocator::Cold { segment_id, .. } = loc {
7332 referenced_ids.insert(*segment_id);
7333 }
7334 }
7335 }
7336 // Step B: keep only the small + still-active ones.
7337 let candidate_set: BTreeSet<u32> = referenced_ids
7338 .into_iter()
7339 .filter(|id| {
7340 self.cold_segments
7341 .get(*id as usize)
7342 .and_then(|s| s.as_deref())
7343 .is_some_and(|s| (s.bytes().len() as u64) < target_segment_bytes)
7344 })
7345 .collect();
7346 if candidate_set.len() < 2 {
7347 return Ok(CompactReport {
7348 sources: Vec::new(),
7349 merged_segment_id: None,
7350 merged_segment_bytes: Vec::new(),
7351 merged_rows: 0,
7352 deleted_rows_pruned: 0,
7353 bytes_reclaimed_estimate: 0,
7354 });
7355 }
7356 // Step C: pre-count source rows for the deleted-pruned metric.
7357 let mut source_row_count: usize = 0;
7358 let mut source_byte_total: u64 = 0;
7359 for &id in &candidate_set {
7360 let seg = self.cold_segments[id as usize]
7361 .as_ref()
7362 .expect("candidate selected only when slot is Some");
7363 source_row_count = source_row_count.saturating_add(seg.meta().num_rows as usize);
7364 source_byte_total = source_byte_total.saturating_add(seg.bytes().len() as u64);
7365 }
7366 // Step D: collect (key, body) pairs from every live Cold
7367 // locator pointing at a candidate. dedupe by key — one
7368 // BTree key resolves to at most one cold payload (the
7369 // freezer + promote/shadow flow keeps Cold locators
7370 // unique per key).
7371 let mut collected: BTreeMap<u64, (Vec<u8>, IndexKey)> = BTreeMap::new();
7372 for (key, locators) in map.iter() {
7373 for loc in locators {
7374 let RowLocator::Cold { segment_id, .. } = loc else {
7375 continue;
7376 };
7377 if !candidate_set.contains(segment_id) {
7378 continue;
7379 }
7380 let u64_key = index_key_as_u64(key).ok_or_else(|| {
7381 StorageError::Corrupt(format!(
7382 "compact_cold_segments: index {index_name:?} has non-integer Cold key; \
7383 cold tier requires IndexKey::Int (Text PK lands in v5.5+)"
7384 ))
7385 })?;
7386 let seg = self.cold_segments[*segment_id as usize]
7387 .as_ref()
7388 .expect("candidate slot guaranteed Some above");
7389 let payload = seg.lookup(u64_key).ok_or_else(|| {
7390 StorageError::Corrupt(format!(
7391 "compact_cold_segments: BTree {index_name:?} points key={u64_key} \
7392 at segment {segment_id} but the segment lookup missed"
7393 ))
7394 })?;
7395 collected.insert(u64_key, (payload, key.clone()));
7396 break;
7397 }
7398 }
7399 let merged_rows = collected.len();
7400 let deleted_rows_pruned = source_row_count.saturating_sub(merged_rows);
7401
7402 // Step E: encode the merged segment. `BTreeMap<u64, _>`
7403 // iteration is ascending by key, which is what
7404 // `encode_segment` requires.
7405 let seg_rows: Vec<(u64, Vec<u8>)> = collected
7406 .iter()
7407 .map(|(k, (body, _))| (*k, body.clone()))
7408 .collect();
7409 let (seg_bytes, _meta) = encode_segment(seg_rows.into_iter(), 0.01, SEGMENT_PAGE_BYTES)
7410 .map_err(|e| StorageError::Corrupt(format!("compact_cold_segments: encode: {e}")))?;
7411 let merged_bytes_len = seg_bytes.len() as u64;
7412
7413 // --- atomic mutation phase ------------------------------
7414 let merged_segment_id = self
7415 .load_segment_bytes(seg_bytes.clone())
7416 .map_err(|e| StorageError::Corrupt(format!("compact_cold_segments: load: {e}")))?;
7417
7418 // Rewrite the BTree index: every Cold locator pointing at
7419 // a candidate source becomes a Cold locator pointing at
7420 // the merged segment. Use a flat collect-then-replace
7421 // pattern so we never hold a `&self` borrow across the
7422 // `&mut self` write.
7423 let entries: Vec<(IndexKey, crate::posting::PostingList)> = {
7424 let t = self
7425 .get(table_name)
7426 .expect("table existed at the start of this fn");
7427 let idx = t
7428 .indices
7429 .iter()
7430 .find(|i| i.name == index_name)
7431 .expect("index existed at the start of this fn");
7432 let IndexKind::BTree(map) = &idx.kind else {
7433 unreachable!("validated above");
7434 };
7435 map.iter().map(|(k, v)| (k.clone(), v.clone())).collect()
7436 };
7437 let t_mut = self
7438 .get_mut(table_name)
7439 .expect("table existed at the start of this fn");
7440 let idx_mut = t_mut
7441 .indices
7442 .iter_mut()
7443 .find(|i| i.name == index_name)
7444 .expect("index existed at the start of this fn");
7445 let IndexKind::BTree(map_mut) = &mut idx_mut.kind else {
7446 unreachable!("validated above");
7447 };
7448 for (key, locators) in entries {
7449 let mut new_locs = crate::posting::PostingList::new();
7450 let mut changed = false;
7451 for loc in &locators {
7452 match *loc {
7453 RowLocator::Cold {
7454 segment_id,
7455 page_offset: _,
7456 } if candidate_set.contains(&segment_id) => {
7457 let replacement = RowLocator::Cold {
7458 segment_id: merged_segment_id,
7459 page_offset: 0,
7460 };
7461 if !new_locs.contains(replacement) {
7462 new_locs.push(replacement);
7463 }
7464 changed = true;
7465 }
7466 other => new_locs.push(other),
7467 }
7468 }
7469 if changed {
7470 map_mut.insert_mut(key, new_locs);
7471 }
7472 }
7473
7474 // Tombstone every source slot. Last step — failures here
7475 // would leave the segment double-referenced in both
7476 // memory + manifest, but `tombstone_segment` only errors
7477 // on out-of-bounds, which we've already validated.
7478 for &id in &candidate_set {
7479 self.tombstone_segment(id)?;
7480 }
7481
7482 let bytes_reclaimed_estimate = source_byte_total.saturating_sub(merged_bytes_len);
7483 Ok(CompactReport {
7484 sources: candidate_set.into_iter().collect(),
7485 merged_segment_id: Some(merged_segment_id),
7486 merged_segment_bytes: seg_bytes,
7487 merged_rows,
7488 deleted_rows_pruned,
7489 bytes_reclaimed_estimate,
7490 })
7491 }
7492
7493 /// Internal helper: scan `(table, index)` for a `Cold` locator
7494 /// keyed by `key`. Returns `Ok(Some((segment_id, page_offset)))`
7495 /// when found, `Ok(None)` when the key has only hot entries
7496 /// or no entries at all, `Err` on the same input-validation
7497 /// errors as the public `promote_cold_row` / `shadow_cold_row`.
7498 fn find_cold_locator(
7499 &self,
7500 table_name: &str,
7501 index_name: &str,
7502 key: &IndexKey,
7503 ) -> Result<Option<(u32, u32)>, StorageError> {
7504 let t = self.get(table_name).ok_or_else(|| {
7505 StorageError::Corrupt(format!("find_cold_locator: table {table_name:?} not found"))
7506 })?;
7507 let idx = t
7508 .indices
7509 .iter()
7510 .find(|i| i.name == index_name)
7511 .ok_or_else(|| {
7512 StorageError::Corrupt(format!(
7513 "find_cold_locator: index {index_name:?} not found on {table_name:?}"
7514 ))
7515 })?;
7516 if !matches!(idx.kind, IndexKind::BTree(_)) {
7517 return Err(StorageError::Corrupt(format!(
7518 "find_cold_locator: index {index_name:?} is NSW; promote-on-write only applies to BTree indices"
7519 )));
7520 }
7521 for loc in idx.lookup_eq(key) {
7522 if let RowLocator::Cold {
7523 segment_id,
7524 page_offset,
7525 } = *loc
7526 {
7527 return Ok(Some((segment_id, page_offset)));
7528 }
7529 }
7530 Ok(None)
7531 }
7532}
7533
7534/// Coerce an [`IndexKey`] to the `u64` that v5.1 cold-tier
7535/// segments use as their on-disk PK. Returns `None` for keys that
7536/// aren't representable as `u64` — Text PKs need a hash mapping
7537/// the segment writer baked in (deferred to v5.2+), Bool PKs are
7538/// almost never wide enough to be sharded into a cold tier.
7539fn index_key_as_u64(key: &IndexKey) -> Option<u64> {
7540 match key {
7541 // Reinterpret the i64 bit pattern as u64. Cold-tier segments
7542 // are sorted by this u64 view, so the chosen interpretation
7543 // only has to match between insert (bake_segment / freezer)
7544 // and lookup — using cast_unsigned keeps both sides honest
7545 // and silences clippy::cast_sign_loss.
7546 IndexKey::Int(n) => Some(n.cast_unsigned()),
7547 // Text / Bool / Uuid PKs aren't representable as u64 and so
7548 // can't participate in the u64-sorted cold-tier segment
7549 // PK layout. Same deferral story as Text — lookup falls
7550 // through the in-memory btree.
7551 IndexKey::Text(_) | IndexKey::Bool(_) | IndexKey::Uuid(_) => None,
7552 }
7553}
7554
7555#[derive(Debug, Clone, PartialEq, Eq)]
7556#[non_exhaustive]
7557pub enum StorageError {
7558 DuplicateTable {
7559 name: String,
7560 },
7561 TableNotFound {
7562 name: String,
7563 },
7564 ArityMismatch {
7565 expected: usize,
7566 actual: usize,
7567 },
7568 TypeMismatch {
7569 column: String,
7570 expected: DataType,
7571 actual: DataType,
7572 position: usize,
7573 },
7574 NullInNotNull {
7575 column: String,
7576 },
7577 /// Index with this name already exists on the table.
7578 DuplicateIndex {
7579 name: String,
7580 },
7581 /// Column referenced by an index doesn't exist on the table.
7582 ColumnNotFound {
7583 column: String,
7584 },
7585 /// On-disk format failed to parse — corrupted file, wrong magic, truncated
7586 /// payload, or unknown tag bytes.
7587 Corrupt(String),
7588 /// v6.0.4 — ALTER INDEX targeted an index name that doesn't
7589 /// exist on any table in this catalog.
7590 IndexNotFound {
7591 name: String,
7592 },
7593 /// v6.0.4 — operation requested isn't supported on this index
7594 /// kind / column type (e.g. ALTER INDEX REBUILD on a `BTree`
7595 /// index, or REBUILD WITH (encoding=…) on a non-vector column).
7596 Unsupported(String),
7597 /// v7.39 (round 220) — a CYCLE-less sequence ran past its bound.
7598 /// PG's 2200H phrasing: `nextval: reached maximum value of
7599 /// sequence "s" (n)` (`is_max: false` = the MINVALUE direction).
7600 SequenceExhausted {
7601 name: String,
7602 limit: i64,
7603 is_max: bool,
7604 },
7605}
7606
7607impl fmt::Display for StorageError {
7608 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
7609 match self {
7610 // v7.39 (read01 round 47) — PG's 42P07 wording.
7611 Self::DuplicateTable { name } => write!(f, "relation \"{name}\" already exists"),
7612 // v7.39 (read01 round 47) — PG's wording for a missing relation
7613 // (42P01). DROP TABLE says "table" and raises its own error at
7614 // the engine; every other path (SELECT / ALTER / …) says
7615 // "relation", which is what this carries.
7616 Self::TableNotFound { name } => write!(f, "relation \"{name}\" does not exist"),
7617 Self::ArityMismatch { expected, actual } => write!(
7618 f,
7619 "row arity mismatch: expected {expected} columns, got {actual}"
7620 ),
7621 Self::TypeMismatch {
7622 column,
7623 expected,
7624 actual,
7625 position,
7626 } => write!(
7627 f,
7628 "type mismatch in column {column:?} (position {position}): expected {expected}, got {actual}"
7629 ),
7630 Self::NullInNotNull { column } => {
7631 // v7.39 (SQLSTATE fidelity) — PG's 23502 phrasing (the
7632 // relation-qualified long form is added by engine call
7633 // sites that know the table name).
7634 write!(
7635 f,
7636 "null value in column \"{column}\" violates not-null constraint"
7637 )
7638 }
7639 // v7.39 (read01 round 47) — an index is a relation to PG (42P07).
7640 Self::DuplicateIndex { name } => write!(f, "relation \"{name}\" already exists"),
7641 // v7.39 (round 701) — PG's wording, and the same fix `EvalError::
7642 // ColumnNotFound` took in read01 round 81 with the same reason:
7643 // "column not found: x" matches none of the wire layer's `does
7644 // not exist` patterns, so a missing column reached the client as
7645 // the generic error class. The eval-side variant was changed and
7646 // the storage-side one was not, so which sentence you got
7647 // depended on which layer noticed — `CREATE INDEX ix ON t(nope)`
7648 // came out of storage and kept the old spelling.
7649 Self::ColumnNotFound { column } => write!(f, "column \"{column}\" does not exist"),
7650 Self::Corrupt(detail) => write!(f, "corrupt on-disk format: {detail}"),
7651 Self::IndexNotFound { name } => write!(f, "index \"{name}\" does not exist"),
7652 Self::Unsupported(detail) => write!(f, "unsupported: {detail}"),
7653 // v7.39 (round 220) — PG's exact 2200H wording.
7654 Self::SequenceExhausted {
7655 name,
7656 limit,
7657 is_max,
7658 } => write!(
7659 f,
7660 "nextval: reached {} value of sequence \"{name}\" ({limit})",
7661 if *is_max { "maximum" } else { "minimum" }
7662 ),
7663 }
7664 }
7665}
7666
7667impl ColumnSchema {
7668 pub fn new(name: impl Into<String>, ty: DataType, nullable: bool) -> Self {
7669 Self {
7670 name: name.into(),
7671 ty,
7672 nullable,
7673 collation_name: None,
7674 default: None,
7675 runtime_default: None,
7676 auto_increment: false,
7677 user_enum_type: None,
7678 user_domain_type: None,
7679 user_composite_type: None,
7680 acl: Vec::new(),
7681 on_update_runtime: None,
7682 collation: Collation::Binary,
7683 is_unsigned: false,
7684 inline_enum_variants: None,
7685 inline_set_variants: None,
7686 generated_stored_expr: None,
7687 identity_always: false,
7688 default_text: None,
7689 auto_restart: None,
7690 scalar_row_source: false,
7691 mysql_int_width: None,
7692 mysql_fsp: None,
7693 }
7694 }
7695
7696 /// Builder-style helper to attach a default value to an otherwise
7697 /// plain column schema. Used by the engine when CREATE TABLE
7698 /// specifies `column TYPE DEFAULT <expr>`.
7699 #[must_use]
7700 pub fn with_default(mut self, default: Value<'static>) -> Self {
7701 self.default = Some(default);
7702 self
7703 }
7704
7705 /// v7.9.21 — builder for runtime-evaluated defaults
7706 /// (`DEFAULT now()`, `DEFAULT CURRENT_TIMESTAMP`, …).
7707 /// `expr` is the Expr's `Display` form, re-parsed by the
7708 /// engine at each INSERT.
7709 #[must_use]
7710 pub fn with_runtime_default(mut self, expr: impl Into<String>) -> Self {
7711 self.runtime_default = Some(expr.into());
7712 self
7713 }
7714
7715 /// Builder-style helper to mark a column as `AUTO_INCREMENT`.
7716 #[must_use]
7717 pub const fn with_auto_increment(mut self) -> Self {
7718 self.auto_increment = true;
7719 self
7720 }
7721}
7722
7723impl TableSchema {
7724 pub fn new(name: impl Into<String>, columns: Vec<ColumnSchema>) -> Self {
7725 Self {
7726 name: name.into(),
7727 columns,
7728 hot_tier_bytes: None,
7729 foreign_keys: Vec::new(),
7730 uniqueness_constraints: Vec::new(),
7731 exclusion_constraints: Vec::new(),
7732 checks: Vec::new(),
7733 partition_role: None,
7734 policies: Vec::new(),
7735 row_security: false,
7736 force_row_security: false,
7737 owner: None,
7738 acl: Vec::new(),
7739 }
7740 }
7741}
7742
7743// =========================================================================
7744// Persistent binary format for the catalog.
7745//
7746// Layout (little-endian throughout):
7747//
7748// [magic "SPGDB001" 8 bytes][version u8]
7749// [table_count u32]
7750// for each table:
7751// [name_len u16][name bytes]
7752// [col_count u16]
7753// for each col:
7754// [name_len u16][name bytes]
7755// [type_tag u8 + optional payload]
7756// 1=Int 2=BigInt 3=Float 4=Text 5=Bool
7757// 6=Vector(u32 dim)
7758// 7=SmallInt
7759// 8=Varchar(u32 max)
7760// 9=Char(u32 size)
7761// 10=Numeric(u8 precision, u8 scale)
7762// 11=Date
7763// 12=Timestamp
7764// [nullable u8] 0/1
7765// [default_tag u8] 0=none 1=value (followed by [value_tag u8] + bytes)
7766// [row_count u32]
7767// for each row, for each col, one [value_tag u8] + value bytes:
7768// tag 0 (Null) → no body
7769// tag 1 (Int) → i32 LE
7770// tag 2 (BigInt) → i64 LE
7771// tag 3 (Float) → f64 LE
7772// tag 4 (Text) → u16 LE len + UTF-8 bytes
7773// tag 5 (Bool) → u8 0/1
7774// tag 6 (Vector) → u32 LE dim + dim×f32 LE
7775// tag 7 (SmallInt) → i16 LE
7776// tag 8 (Numeric) → i128 LE (16 bytes) + u8 scale
7777// tag 9 (Date) → i32 LE (days since Unix epoch)
7778// tag 10 (Timestamp) → i64 LE (microseconds since Unix epoch)
7779//
7780// Bumped to version 3 when NUMERIC was added; to version 4 when
7781// AUTO_INCREMENT (per-column flag) + NSW index `kind` byte landed;
7782// to version 5 when DATE / TIMESTAMP were added; to version 6 when
7783// NSW graph topology started travelling on disk (v2.7); to version 7
7784// when the NSW topology became multi-layer HNSW (v2.13); to version 8
7785// when row encoding switched to schema-driven dense layout (v3.0.2 —
7786// per-row NULL bitmap + per-column fixed-width body, no per-cell type
7787// tag).
7788// =========================================================================
7789
7790const FILE_MAGIC: &[u8; 8] = b"SPGDB001";
7791/// Current catalog snapshot format version emitted by [`Catalog::serialize`].
7792///
7793/// v9 (v5.2) extends v8 by serialising `BTree` index entries directly — every
7794/// `(IndexKey, Vec<RowLocator>)` pair travels on disk with the v5.1
7795/// `RowLocator::write_le` tag-prefixed codec. v8 `BTree` indices stored no
7796/// entries at all (the map was rebuilt from `Table::rows` on load); v9
7797/// preserves on-disk Cold locators so freezer-produced cold-tier index
7798/// entries survive a catalog snapshot round-trip. v8 readers are accepted
7799/// by version dispatch in [`Catalog::deserialize`] — every entry decodes
7800/// as `RowLocator::Hot(_)` via `add_index` rebuild, identical to v5.1
7801/// behaviour.
7802/// v6.7.2 — bumped from 10 to 11 to append per-table
7803/// `hot_tier_bytes: Option<u64>` after the per-table indices
7804/// section. v10 catalogs (v6.7.1) load with `hot_tier_bytes =
7805/// None` for every table (the deserialiser short-circuits when
7806/// version < 11). v11 snapshots written by a pre-v6.7.2 binary
7807/// fail loudly at the version check, matching the v6.1.2 /
7808/// v6.1.4 / v6.2.0 / v6.7.1 envelope-bump upgrade fences.
7809///
7810/// v6.8.0 — bumped from 11 to 12: per-index
7811/// `included_columns: Vec<u16>` appended at the tail of each
7812/// index payload. v11 (= v6.7.2) catalogs load with
7813/// `included_columns = Vec::new()` for every index — same
7814/// "older readers, append-only extension" pattern as the v6.7.2
7815/// hot_tier_bytes byte.
7816/// v7.13.0 — bumped from 22 to 23. mailrs round-5 G3 / G10.
7817/// Per-table appendix gains two new sections:
7818/// * `checks: Vec<String>` — CHECK predicate sources (Display
7819/// form of the AST Expr); re-parsed on INSERT/UPDATE to
7820/// enforce against candidate rows. Same persistence pattern
7821/// as `Index::partial_predicate`.
7822/// * Per `UniquenessConstraint`: trailing `nulls_not_distinct:
7823/// u8` flag for PG 15+ `UNIQUE NULLS NOT DISTINCT (cols)`
7824/// semantics.
7825/// v22 catalogs deserialise with empty `checks` and every UC
7826/// at `nulls_not_distinct = false`.
7827/// v24 introduces:
7828/// * Index kind tag 4 = trigram-GIN (`gin_trgm_ops`-flavoured
7829/// `USING gin` over a TEXT/VARCHAR column). Payload shape is
7830/// identical to tag-3 GIN (String → Vec<RowLocator>); the
7831/// keys are PG-compatible 3-byte trigram shingles instead of
7832/// tsvector lexemes. v23 catalogs deserialise unchanged — no
7833/// v23 writer ever emitted tag 4.
7834/// v25 introduces:
7835/// * Per `TriggerDef`: trailing `enabled: u8` flag (mailrs
7836/// round-9 A.2.b — `ALTER TABLE … { ENABLE | DISABLE }
7837/// TRIGGER …`). v24 catalogs deserialise with every trigger
7838/// `enabled = true`, matching pre-v7.16.1 behaviour.
7839/// v26 introduces (v7.17.0 Phase 1.1):
7840/// * Trailing SEQUENCE catalog block after triggers. Encoded
7841/// as `u32 count` followed by per-sequence:
7842/// `name`, `data_type: u8` (0=SmallInt,1=Int,2=BigInt),
7843/// `start i64`, `increment i64`, `min_value i64`,
7844/// `max_value i64`, `cache i64`, `cycle u8`,
7845/// `owned_by_tag u8` (0=NONE, 1=Column → `table`,`column`),
7846/// `last_value i64`, `is_called u8`. v25-and-below catalogs
7847/// deserialise with an empty sequences map.
7848/// v27 introduces (v7.17.0 Phase 1.2):
7849/// * Trailing VIEW catalog block after sequences. Encoded as
7850/// `u32 count` followed by per-view:
7851/// `name`, `column_count u16`, then column names, then
7852/// `body` long-string. v26-and-below catalogs deserialise
7853/// with an empty views map.
7854/// v28 introduces (v7.17.0 Phase 1.3):
7855/// * Trailing MATERIALIZED VIEW source registry block after
7856/// views. Encoded as `u32 count` followed by per-entry:
7857/// `name`, `body` long-string. The materialised rows live
7858/// as a regular Table of the same name (already covered by
7859/// the pre-existing tables block). v27-and-below catalogs
7860/// deserialise with an empty map.
7861/// v29 introduces (v7.17.0 Phase 1.4):
7862/// * Per-table user_enum_type appendix (after the CHECK
7863/// appendix). Layout: `u16 count` followed by per-binding
7864/// `[u16 col_pos][str enum_name]`. Only columns whose
7865/// `user_enum_type` is Some land here; the catalog stays
7866/// compact for the common no-enum case.
7867/// * Trailing ENUM types catalog block after materialized
7868/// views. Encoded as `u32 count` followed by per-entry:
7869/// `name`, `u16 label_count`, then `label_count` short
7870/// strings. v28-and-below catalogs deserialise with an
7871/// empty enum_types map and every column's
7872/// `user_enum_type = None`.
7873/// v30 introduces (v7.17.0 Phase 1.5):
7874/// * Per-table user_domain_type appendix (after the
7875/// user_enum_type appendix). Same shape as the enum one.
7876/// * Trailing DOMAIN types catalog block after the enum
7877/// block. Encoded as `u32 count` followed by per-entry:
7878/// `name`, `data_type` byte, `nullable u8`,
7879/// `default_present u8` + optional default string,
7880/// `u16 check_count` then `check_count` Display-form
7881/// CHECK strings. v29-and-below catalogs deserialise with
7882/// an empty domain_types map and `user_domain_type = None`.
7883/// v31 introduces (v7.17.0 Phase 1.6):
7884/// * Trailing user-schemas block after the DOMAIN block.
7885/// Encoded as `u32 count` followed by `count` schema-name
7886/// short strings. Built-in schemas (`public`, `pg_catalog`,
7887/// `information_schema`) are NOT serialised — they're
7888/// hardcoded in `is_builtin_schema`. v30-and-below catalogs
7889/// deserialise with an empty user-schemas set.
7890/// v32 introduces (v7.17.0 Phase 2.1):
7891/// * Per-table on_update_runtime appendix (after the
7892/// user_domain_type appendix). Layout: `u16 count` followed
7893/// by per-binding `[u16 col_pos][str expr_src]`. Only
7894/// columns whose `on_update_runtime` is Some land here;
7895/// the catalog stays compact when no MySQL-shaped table
7896/// uses the attribute. v31-and-below catalogs deserialise
7897/// with every column's `on_update_runtime = None`.
7898/// v33 introduces (v7.17.0 Phase 2.2):
7899/// * Index kind tag 5 = fulltext-GIN (MySQL `FULLTEXT KEY`
7900/// surface over a TEXT / VARCHAR column). Payload shape is
7901/// identical to tag-3 / tag-4 GIN (`String → Vec<RowLocator>`);
7902/// the keys are lower-cased word lexemes (same rule as
7903/// `to_tsvector('simple', text)`). v32 catalogs deserialise
7904/// unchanged — no v32 writer ever emitted tag 5, and FULLTEXT
7905/// KEY was silently dropped pre-v7.17 so no rebuild shim is
7906/// needed for round-tripped catalogs.
7907/// v34 introduces (v7.17.0 Phase 2.5):
7908/// * Per-table collation appendix (after the on_update_runtime
7909/// appendix). Sparse layout: only columns whose `collation`
7910/// is non-Binary land here. `u16 count` then per-binding
7911/// `[u16 col_pos][u8 collation_tag]` where the tag matches
7912/// `Collation::TAG_*`. Snapshots written by v33-and-below
7913/// readers deserialise every column with `collation =
7914/// Binary`, preserving the prior byte-wise compare
7915/// semantics. Unknown tags read back as Binary too — keeps
7916/// a forward-compat path if a future v35 adds variants
7917/// and someone rolls back to a v34 reader.
7918/// v35 introduces (v7.17.0 Phase 4.4):
7919/// * Per-table is_unsigned appendix (after the collation
7920/// appendix). Sparse layout: only `is_unsigned = true`
7921/// columns land. `u16 count` then per-binding `[u16 col_pos]`.
7922/// v34-and-below catalogs deserialise every column as
7923/// `is_unsigned = false`, preserving the prior silent-
7924/// accept behaviour for negative inserts on UNSIGNED columns.
7925/// v46 introduces (v7.23, mailrs round-14):
7926/// * Escaped short-string codec — `write_str` lengths >= 0xFFFF
7927/// emit `[u16 0xFFFF][u32 real_len]` so TEXT cells (mail bodies,
7928/// document text) above 64 KiB encode instead of panicking.
7929/// One-way upgrade: v45-and-below readers reject v46 catalogs
7930/// loudly via the version gate; v46 readers decode v45 catalogs
7931/// with the plain-u16 rules (0xFFFF is a legitimate length
7932/// there).
7933/// v47 introduces (v7.27, mailrs round-21):
7934/// * Escaped lengths for the REMAINING u16-length cell payloads —
7935/// BYTEA cells, TEXT[] elements, tsvector lexemes and tsquery
7936/// terms — the same `[u16 0xFFFF][u32 real_len]` escape v46
7937/// gave short strings. Round-14 fixed TEXT and missed these;
7938/// round-21 fired the BYTEA twin during a production migration.
7939/// One-way upgrade, same posture as v46.
7940/// v48 introduces (v7.37.5 β-P2, sentori cutover window):
7941/// * `INTERVAL` becomes a real column type. Catalog tag 34 in
7942/// `write_data_type`; per-row body is a fixed 16 bytes
7943/// (i64 micros + i32 days + i32 months, LE, PG-byte-equal
7944/// field order). The runtime-only days collapse is gone —
7945/// `'1 day'` and `'24 hours'` are stored distinctly. One-way
7946/// upgrade: v47 catalogs without INTERVAL columns deserialise
7947/// identically; v47 readers fed a v48 catalog that contains
7948/// INTERVAL hit the explicit "unknown data type tag: 34"
7949/// fence in `read_data_type`.
7950/// v49 introduces (v7.37.6-B, sentori Epic 2 P0):
7951/// * Per-table partition role appendix(declarative
7952/// `PARTITION BY RANGE` parent / range child / DEFAULT
7953/// child)。Layout, written **after** the inline_set_variants
7954/// appendix and **before** the per-table block close:
7955/// `[u8 role_tag]`
7956/// 0 = `None`(普通表,后向兼容默认)
7957/// 1 = `Parent`: `[u8 kind_tag (0=Range)]`
7958/// `[u16 key_col_count]` `(× u16 col_pos)`
7959/// `[u16 tmpl_count]` `(× str source)`
7960/// 2 = `Range`: `[str parent_name]` `[Bound]` `[Bound]`
7961/// 3 = `Default`: `[str parent_name]`
7962/// `PartitionBound` codec:
7963/// `[u8 bound_tag]` 0=MinValue 1=MaxValue 2=TimestampTz(`[i64 LE micros]`)
7964/// v48-and-below readers stop after the inline_set_variants
7965/// block — they don't see this appendix and deserialise every
7966/// table with `partition_role = None`. v49 writers always emit
7967/// `[0]` for plain tables, so the encoding stays one-byte-cheap.
7968/// v50 introduces (v7.37.7, sentori Epic 3 P1):
7969/// * Per-table `generated_stored_expr` appendix(stored generated
7970/// columns — `GENERATED ALWAYS AS (<expr>) STORED`)。Layout,
7971/// written **after** the partition_role appendix and before
7972/// the per-table block close:
7973/// `[u16 binding_count]`
7974/// `binding_count × { [u16 col_pos][str expr_source] }`
7975/// Sparse — only generated columns land here, so plain-shape
7976/// catalogs stay byte-for-byte identical save for the new
7977/// u16 zero count. v49-and-below readers stop after the
7978/// partition_role appendix; v50 readers default every column
7979/// to `generated_stored_expr = None` when this block is absent.
7980/// v51 introduces (v7.37.8, sentori Epic 5 P2):
7981/// * Per-index tag byte 6 = `GinJsonb`(real posting-list GIN
7982/// over a JSONB column). Payload shape mirrors tag-3 / 4 / 5:
7983/// `[u32 posting_list_count]` then `(str token, u32 locator_count,
7984/// locators …)` per posting list. Same `write_str` /
7985/// `RowLocator::write_le` codec as the rest of the GIN family.
7986/// v50 catalogs never wrote tag 6(the same DDL loaded as a
7987/// BTree fallback); v51 readers see tag 6 explicitly and dispatch
7988/// into `IndexKind::GinJsonb`.
7989/// v52 introduces (v7.37.42-T2 ζ-B composite + domain metasystem):
7990/// * Trailing COMPOSITE-types catalog block after the
7991/// user-schemas block. Encoded as `u32 count` followed by
7992/// per-entry: `name`, `u16 field_count`, then `field_count`
7993/// `[str field_name][data_type]` pairs (`write_data_type` is
7994/// reused). v51-and-below catalogs deserialise with an empty
7995/// composite_types map; v52 readers tolerate v51 catalogs by
7996/// stopping at the schema block (no composite block present
7997/// ⇒ empty map). Composite types are referenced by columns
7998/// via `ColumnSchema.user_composite_type`, mirroring the
7999/// `user_enum_type` / `user_domain_type` pattern. The block
8000/// lands here (not as a per-table appendix) so dropping the
8001/// composite type registers globally and DROP TYPE can find it
8002/// without a table scan.
8003/// v53 introduces (v7.37.16 Epic W — cross-checkpoint tombstone
8004/// durability):
8005/// * Trailing per-table MVCC appendix carrying, for every row,
8006/// its `RowHeader` (`xmin:u64`, `xmax:u64`, `flags:u8`) and its
8007/// stable `RowId` (`u64`), followed by the relation's
8008/// `next_rowid:u64`. Layout per table (after the v50
8009/// generated_stored_expr block, before the table loop closes):
8010/// `[u32 row_count]` (== `Table::rows().len()`, cross-check)
8011/// per row in physical order:
8012/// `[u64 xmin][u64 xmax][u8 flags][u64 rowid]`
8013/// `[u64 next_rowid]`
8014/// v52-and-below catalogs never wrote this block; their reader
8015/// stops after the last per-table appendix and
8016/// `deserialize_rows` leaves every row `RowHeader::frozen()`
8017/// with dense 1..=N ids — the exact pre-v53 contract. A v53
8018/// reader instead reconstructs headers + ids VERBATIM, so a
8019/// tombstone-redo naming a row inserted before the last
8020/// checkpoint resolves by `RowId` across the base-snapshot
8021/// boundary (closing the coupling the Epic W WAL slices deferred
8022/// to this format bump). Because the reader routes on `version`,
8023/// the block is strictly backward-compatible: old images load
8024/// byte-for-byte as before. `SPG_MVCC_INPLACE` is unaffected —
8025/// a gate-off database's rows are all frozen/alive, so
8026/// persisting + restoring their headers is observationally a
8027/// no-op.
8028/// v7.38 (read01 P5.05) — v54 appends a CRC32C over the whole preceding
8029/// image so a corrupted `base.spg` is caught on load instead of silently
8030/// deserialising garbage. Older images (v8..=53) carry no trailer and load
8031/// unchanged.
8032/// v7.39 (round 210) — v72 appends a per-table EXCLUDE-constraint appendix
8033/// (sparse: only tables carrying an EXCLUDE write it) at the very end of the
8034/// per-table block, after the column-ACL appendix. A v71 reader stops before
8035/// it and its tables read back with no exclusion constraints, which is what
8036/// they were.
8037/// v7.39 (round 220) — v73 appends a per-table identity-RESTART appendix
8038/// (sparse: [u16 count] then per entry [u16 col_pos][i64 LE floor]) after
8039/// the EXCLUDE appendix. A v72 reader stops before it; its columns read
8040/// back with no RESTART floor, losing only an un-consumed
8041/// `ALTER … RESTART WITH` across a restart.
8042const FILE_VERSION: u8 = 89;
8043
8044/// v7.37 (round 833) — the codec version to decode a row that
8045/// [`encode_row_body_dense`] has just produced.
8046///
8047/// That encoder always writes the newest form, and every decoder gate is
8048/// a `codec_version >= N` feature test, so a freshly encoded row must be
8049/// read at the current version. Cold segments carry their own version in
8050/// their header and keep passing that; this is for in-process round
8051/// trips — sort runs on temp storage — where the bytes never outlive the
8052/// build that wrote them.
8053pub const CURRENT_ROW_CODEC_VERSION: u8 = FILE_VERSION;
8054/// First version that appends the trailing CRC32C integrity trailer.
8055const FILE_VERSION_CRC_TRAILER: u8 = 54;
8056/// Oldest format version [`Catalog::deserialize`] still accepts. v8 is the
8057/// v3.0.2 dense-row layout; pre-v8 catalogs require an offline migration.
8058const MIN_SUPPORTED_FILE_VERSION: u8 = 8;
8059
8060// IndexKey wire format (v9):
8061// tag 0 = Int → [i64 LE]
8062// tag 1 = Text → [u16 LE len + UTF-8 bytes] (via write_str / read_str)
8063// tag 2 = Bool → [u8 0/1]
8064const INDEX_KEY_TAG_INT: u8 = 0;
8065const INDEX_KEY_TAG_TEXT: u8 = 1;
8066const INDEX_KEY_TAG_BOOL: u8 = 2;
8067/// v7.17.0 — `IndexKey::Uuid([u8; 16])`. Body = raw 16 bytes
8068/// (RFC 4122 byte order). Persisted only in FILE_VERSION 36+
8069/// catalogs.
8070const INDEX_KEY_TAG_UUID: u8 = 3;
8071
8072impl Catalog {
8073 /// Serialize the whole catalog (schema + every row) into a self-contained
8074 /// byte buffer. Format is documented above the impl block.
8075 pub fn serialize(&self) -> Vec<u8> {
8076 let mut out = Vec::with_capacity(64);
8077 out.extend_from_slice(FILE_MAGIC);
8078 out.push(FILE_VERSION);
8079 write_u32(
8080 &mut out,
8081 u32::try_from(self.tables.len()).expect("≤ 4G tables"),
8082 );
8083 for t in &self.tables {
8084 write_str(&mut out, &t.schema.name);
8085 write_u16(
8086 &mut out,
8087 u16::try_from(t.schema.columns.len()).expect("≤ 65k columns/table"),
8088 );
8089 for c in &t.schema.columns {
8090 write_str(&mut out, &c.name);
8091 write_data_type(&mut out, c.ty);
8092 out.push(u8::from(c.nullable));
8093 match &c.default {
8094 None => out.push(0),
8095 Some(v) => {
8096 out.push(1);
8097 write_value(&mut out, v);
8098 }
8099 }
8100 out.push(u8::from(c.auto_increment));
8101 }
8102 write_u32(
8103 &mut out,
8104 u32::try_from(t.rows.len()).expect("≤ 4G rows/table"),
8105 );
8106 // v3.0.2 dense row encoding (FILE_VERSION 8): per-row NULL
8107 // bitmap, then tightly-packed bodies. Identical wire format
8108 // as before — extracted into `encode_row_body_dense` so cold-
8109 // tier segments (v5.1+) can share the encoding.
8110 for row in &t.rows {
8111 out.extend_from_slice(&encode_row_body_dense(row, &t.schema));
8112 }
8113 // Index definitions. Per-index payload:
8114 // [name][col_pos u16][kind u8]
8115 // kind 0 = B-tree (no params — rebuilt on load)
8116 // kind 1 = NSW graph (u16 M + serialized graph)
8117 // For NSW the graph topology travels on disk so startup
8118 // doesn't re-run the O(n²M) rebuild — see v2.7 notes.
8119 write_u16(
8120 &mut out,
8121 u16::try_from(t.indices.len()).expect("≤ 65k indices/table"),
8122 );
8123 for idx in &t.indices {
8124 write_str(&mut out, &idx.name);
8125 write_u16(
8126 &mut out,
8127 u16::try_from(idx.column_position).expect("≤ 65k columns/table"),
8128 );
8129 match &idx.kind {
8130 IndexKind::BTree(map) => {
8131 out.push(0);
8132 // v9: serialise the full PB map. Each entry's
8133 // RowLocator list travels with the tag-prefixed
8134 // codec from `row_locator::write_le`, so freezer-
8135 // produced Cold locators survive a snapshot
8136 // round-trip. v8 BTree wrote nothing here and
8137 // rebuilt from rows — v9 readers tolerate v8 by
8138 // version dispatch in `Catalog::deserialize`.
8139 write_u32(
8140 &mut out,
8141 u32::try_from(map.len()).expect("≤ 4G index entries/index"),
8142 );
8143 for (key, locators) in map {
8144 write_index_key(&mut out, key);
8145 write_u32(
8146 &mut out,
8147 u32::try_from(locators.len()).expect("≤ 4G locators/key"),
8148 );
8149 for loc in locators {
8150 loc.write_le(&mut out);
8151 }
8152 }
8153 }
8154 IndexKind::Nsw(g) => {
8155 out.push(1);
8156 write_u16(&mut out, u16::try_from(g.m).expect("≤ 65k NSW neighbours"));
8157 write_nsw_graph(&mut out, g);
8158 }
8159 IndexKind::Brin { column_type } => {
8160 // v6.7.1 — tag byte 2 = BRIN. Payload is the
8161 // column type code (1 byte mapping to the
8162 // shared DataType numeric encoding); no
8163 // further data — BRIN summaries live in
8164 // cold segments, not the catalog.
8165 out.push(2);
8166 write_data_type(&mut out, *column_type);
8167 }
8168 IndexKind::Gin(map) => {
8169 // v7.12.3 — tag byte 3 = GIN. Payload mirrors
8170 // the BTree encoding but with String (lexeme
8171 // word) keys instead of IndexKey. Tag-prefixed
8172 // RowLocator codec so freezer-produced Cold
8173 // locators survive snapshot round-trip.
8174 // FILE_VERSION 21+; v20 catalogs never wrote a
8175 // GIN index (the AM degraded to BTree fallback
8176 // pre-v7.12.3), so no migration shim is needed.
8177 out.push(3);
8178 write_u32(
8179 &mut out,
8180 u32::try_from(map.len()).expect("≤ 4G GIN posting lists"),
8181 );
8182 for (word, locators) in map {
8183 write_str(&mut out, word);
8184 write_u32(
8185 &mut out,
8186 u32::try_from(locators.len()).expect("≤ 4G locators/posting list"),
8187 );
8188 for loc in locators {
8189 loc.write_le(&mut out);
8190 }
8191 }
8192 }
8193 IndexKind::GinTrgm(map) => {
8194 // v7.15.0 — tag byte 4 = GinTrgm
8195 // (`gin_trgm_ops` GIN over a TEXT column).
8196 // Payload shape is identical to tag-3 GIN —
8197 // `String → Vec<RowLocator>` posting lists.
8198 // The String keys are 3-byte trigrams instead
8199 // of tsvector lexemes; the deserializer
8200 // dispatches on the tag, not the key shape.
8201 // FILE_VERSION 24+; v23 catalogs never wrote
8202 // a trigram-GIN.
8203 out.push(4);
8204 write_u32(
8205 &mut out,
8206 u32::try_from(map.len()).expect("≤ 4G trigram-GIN posting lists"),
8207 );
8208 for (tri, locators) in map {
8209 write_str(&mut out, tri);
8210 write_u32(
8211 &mut out,
8212 u32::try_from(locators.len()).expect("≤ 4G locators/posting list"),
8213 );
8214 for loc in locators {
8215 loc.write_le(&mut out);
8216 }
8217 }
8218 }
8219 IndexKind::GinFulltext(map) => {
8220 // v7.17.0 Phase 2.2 — tag byte 5 =
8221 // GinFulltext (MySQL `FULLTEXT KEY` GIN
8222 // over a TEXT/VARCHAR column). Payload
8223 // shape mirrors tag-3 / tag-4 GIN —
8224 // `String → Vec<RowLocator>` posting
8225 // lists keyed by lower-cased word
8226 // lexemes. FILE_VERSION 33+; v32 catalogs
8227 // never wrote a fulltext-GIN (FULLTEXT
8228 // KEY was silently dropped pre-v7.17).
8229 out.push(5);
8230 write_u32(
8231 &mut out,
8232 u32::try_from(map.len()).expect("≤ 4G fulltext-GIN posting lists"),
8233 );
8234 for (lex, locators) in map {
8235 write_str(&mut out, lex);
8236 write_u32(
8237 &mut out,
8238 u32::try_from(locators.len()).expect("≤ 4G locators/posting list"),
8239 );
8240 for loc in locators {
8241 loc.write_le(&mut out);
8242 }
8243 }
8244 }
8245 IndexKind::GinJsonb(map) => {
8246 // v7.37.8 — tag byte 6 = GinJsonb
8247 // (real posting-list GIN over a JSONB
8248 // column; sentori Epic 5 P2). Payload
8249 // shape mirrors tag-3 / 4 / 5 — keys are
8250 // the canonical `(path, leaf)` tokens
8251 // from `jsonb_gin::extract_tokens`.
8252 // FILE_VERSION 51+; v50 catalogs never
8253 // wrote a JSONB-GIN (the same DDL loaded
8254 // as a BTree fallback).
8255 out.push(6);
8256 write_u32(
8257 &mut out,
8258 u32::try_from(map.len()).expect("≤ 4G JSONB-GIN posting lists"),
8259 );
8260 for (token, locators) in map {
8261 write_str(&mut out, token);
8262 write_u32(
8263 &mut out,
8264 u32::try_from(locators.len()).expect("≤ 4G locators/posting list"),
8265 );
8266 for loc in locators {
8267 loc.write_le(&mut out);
8268 }
8269 }
8270 }
8271 }
8272 // v6.8.0 — included_columns appendix per index.
8273 // Layout: [u16 num_included][num × u16 column_position].
8274 // v11 readers stop before this u16 (deserialise loop
8275 // gated on version >= 12); v12+ readers always
8276 // consume it. Empty Vec serialises as a bare 0u16.
8277 write_u16(
8278 &mut out,
8279 u16::try_from(idx.included_columns.len()).expect("≤ 65k INCLUDE columns/index"),
8280 );
8281 for col_pos in &idx.included_columns {
8282 write_u16(
8283 &mut out,
8284 u16::try_from(*col_pos).expect("≤ 65k columns/table"),
8285 );
8286 }
8287 // v6.8.1 — partial_predicate appendix per index.
8288 // Layout: [u8 has_pred][u16 LE len][bytes (if has_pred)].
8289 // Same v12 gate as included_columns.
8290 match &idx.partial_predicate {
8291 None => out.push(0),
8292 Some(pred) => {
8293 out.push(1);
8294 write_str(&mut out, pred);
8295 }
8296 }
8297 // v6.8.2 — expression appendix. Same shape as
8298 // partial_predicate.
8299 match &idx.expression {
8300 None => out.push(0),
8301 Some(expr) => {
8302 out.push(1);
8303 write_str(&mut out, expr);
8304 }
8305 }
8306 // v7.9.29 — is_unique appendix (FILE_VERSION 16+).
8307 // Single byte 0/1. v15-and-below readers stop before
8308 // this byte; v16 readers always consume it. mailrs K1.
8309 out.push(u8::from(idx.is_unique));
8310 // v7.9.29 — extra_column_positions appendix.
8311 // Layout: [u16 count][count × u16 column_position].
8312 write_u16(
8313 &mut out,
8314 u16::try_from(idx.extra_column_positions.len())
8315 .expect("≤ 65k extra cols / index"),
8316 );
8317 for cp in &idx.extra_column_positions {
8318 write_u16(&mut out, u16::try_from(*cp).expect("≤ 65k columns/table"));
8319 }
8320 // v7.39 (read01 round 52) — nulls_not_distinct (FILE_VERSION
8321 // 62+). Appended at the end of the per-index block so the v16
8322 // layout above is untouched; v61-and-below readers stop before
8323 // this byte and default the flag to false (NULLS DISTINCT).
8324 out.push(u8::from(idx.nulls_not_distinct));
8325 // v7.39 (round 537) — the key column's ordering clause
8326 // (FILE_VERSION 83+).
8327 out.push(u8::from(idx.descending));
8328 out.push(match idx.nulls_first {
8329 None => 0,
8330 Some(true) => 1,
8331 Some(false) => 2,
8332 });
8333 // v7.39 (round 538) — the key's explicit collation
8334 // (FILE_VERSION 84+).
8335 match &idx.collation {
8336 Some(c) => {
8337 out.push(1);
8338 write_str(&mut out, c);
8339 }
8340 None => out.push(0),
8341 }
8342 }
8343 // v6.7.2 — per-table hot_tier_bytes Option<u64>.
8344 // Layout: [u8 has_value][u64 LE value (if has_value)].
8345 // v10 readers stop before this byte (deserialise loop
8346 // gated on version >= 11); v11+ readers always
8347 // consume it.
8348 match t.schema.hot_tier_bytes {
8349 None => out.push(0),
8350 Some(n) => {
8351 out.push(1);
8352 out.extend_from_slice(&n.to_le_bytes());
8353 }
8354 }
8355 // v7.6.1 — FOREIGN KEY appendix (catalog FILE_VERSION 13+).
8356 // Layout: [u16 LE fk_count]
8357 // per fk:
8358 // [u8 has_name] [str name (if has_name)]
8359 // [u16 LE local_arity] [u16 LE local_pos]*arity
8360 // [str parent_table]
8361 // [u16 LE parent_arity] [u16 LE parent_pos]*arity
8362 // [u8 on_delete_tag] [u8 on_update_tag]
8363 // Older catalogs (v12 and below) skip this block entirely;
8364 // their reader stops before this byte.
8365 write_u16(
8366 &mut out,
8367 u16::try_from(t.schema.foreign_keys.len()).expect("≤ 65k FKs/table"),
8368 );
8369 for fk in &t.schema.foreign_keys {
8370 match &fk.name {
8371 None => out.push(0),
8372 Some(n) => {
8373 out.push(1);
8374 write_str(&mut out, n);
8375 }
8376 }
8377 write_u16(
8378 &mut out,
8379 u16::try_from(fk.local_columns.len()).expect("≤ 65k FK columns"),
8380 );
8381 for &p in &fk.local_columns {
8382 write_u16(&mut out, u16::try_from(p).expect("≤ 65k columns/table"));
8383 }
8384 write_str(&mut out, &fk.parent_table);
8385 write_u16(
8386 &mut out,
8387 u16::try_from(fk.parent_columns.len()).expect("≤ 65k FK parent columns"),
8388 );
8389 for &p in &fk.parent_columns {
8390 write_u16(&mut out, u16::try_from(p).expect("≤ 65k columns/table"));
8391 }
8392 out.push(fk.on_delete.tag());
8393 out.push(fk.on_update.tag());
8394 // v7.38 (read01, T29) — MATCH type tag (FILE_VERSION 55+).
8395 out.push(fk.match_type.tag());
8396 // v7.39 (round 288) — constraint timing (FILE_VERSION 79+).
8397 // One byte, bit 0 = DEFERRABLE, bit 1 = INITIALLY DEFERRED.
8398 out.push(u8::from(fk.deferrable) | (u8::from(fk.initially_deferred) << 1));
8399 }
8400 // v7.9.19 — UniquenessConstraint appendix (catalog
8401 // FILE_VERSION 15+). Layout per table after the FK
8402 // block:
8403 // [u16 count]
8404 // per constraint:
8405 // [u8 is_primary_key]
8406 // [u16 arity][u16 col_pos]*arity
8407 // Older catalogs (v14 and below) skip this block.
8408 write_u16(
8409 &mut out,
8410 u16::try_from(t.schema.uniqueness_constraints.len())
8411 .expect("≤ 65k uniqueness constraints/table"),
8412 );
8413 for uc in &t.schema.uniqueness_constraints {
8414 out.push(u8::from(uc.is_primary_key));
8415 write_u16(
8416 &mut out,
8417 u16::try_from(uc.columns.len()).expect("≤ 65k cols in uniqueness constraint"),
8418 );
8419 for &p in &uc.columns {
8420 write_u16(&mut out, u16::try_from(p).expect("≤ 65k columns/table"));
8421 }
8422 // v7.13.0 — `nulls_not_distinct` flag
8423 // (FILE_VERSION 23+). Always written by writers at
8424 // version 23+; deserialise gates on `version >= 23`
8425 // so v22-and-below catalogs round-trip cleanly.
8426 out.push(u8::from(uc.nulls_not_distinct));
8427 }
8428 // v7.9.21 — runtime_default appendix per table.
8429 // Layout: [u16 count] then for each:
8430 // [u16 col_pos][str expr]
8431 // Only columns whose runtime_default is Some land here;
8432 // catalog stays compact for the common literal-default
8433 // case.
8434 let mut rt_defaults: Vec<(usize, &str)> = Vec::new();
8435 for (i, c) in t.schema.columns.iter().enumerate() {
8436 if let Some(e) = &c.runtime_default {
8437 rt_defaults.push((i, e.as_str()));
8438 }
8439 }
8440 write_u16(
8441 &mut out,
8442 u16::try_from(rt_defaults.len()).expect("≤ 65k runtime defaults/table"),
8443 );
8444 for (pos, expr) in rt_defaults {
8445 write_u16(&mut out, u16::try_from(pos).expect("≤ 65k columns/table"));
8446 write_str(&mut out, expr);
8447 }
8448 // v7.13.0 — CHECK constraint appendix per table.
8449 // Layout: [u16 count] then `count` Display-form
8450 // expression strings. Re-parsed on every INSERT/UPDATE
8451 // by the engine. FILE_VERSION 23+ only; v22 readers
8452 // never reach this block because the writer also moves
8453 // to v23 in lock-step.
8454 write_u16(
8455 &mut out,
8456 u16::try_from(t.schema.checks.len()).expect("≤ 65k CHECK constraints/table"),
8457 );
8458 for c in &t.schema.checks {
8459 // v7.39 (read01 round 48) — the expr stays in this v23
8460 // appendix (byte layout unchanged for old readers); the
8461 // name rides the v60 constraint-name appendix at the tail.
8462 write_str(&mut out, c.expr.as_str());
8463 }
8464 // v7.17.0 Phase 1.4 — per-table user_enum_type
8465 // appendix. Layout: [u16 count] then
8466 // [u16 col_pos][str enum_name] per binding. Only
8467 // columns whose user_enum_type is Some land here.
8468 let mut enum_bindings: Vec<(usize, &str)> = Vec::new();
8469 for (i, c) in t.schema.columns.iter().enumerate() {
8470 if let Some(e) = &c.user_enum_type {
8471 enum_bindings.push((i, e.as_str()));
8472 }
8473 }
8474 write_u16(
8475 &mut out,
8476 u16::try_from(enum_bindings.len()).expect("≤ 65k enum-typed columns/table"),
8477 );
8478 for (pos, ename) in enum_bindings {
8479 write_u16(&mut out, u16::try_from(pos).expect("≤ 65k columns/table"));
8480 write_str(&mut out, ename);
8481 }
8482 // v7.17.0 Phase 1.5 — per-table user_domain_type
8483 // appendix. Same layout as the enum one. v29-and-
8484 // below readers stop after the enum appendix.
8485 let mut domain_bindings: Vec<(usize, &str)> = Vec::new();
8486 for (i, c) in t.schema.columns.iter().enumerate() {
8487 if let Some(d) = &c.user_domain_type {
8488 domain_bindings.push((i, d.as_str()));
8489 }
8490 }
8491 write_u16(
8492 &mut out,
8493 u16::try_from(domain_bindings.len()).expect("≤ 65k domain-typed columns/table"),
8494 );
8495 for (pos, dname) in domain_bindings {
8496 write_u16(&mut out, u16::try_from(pos).expect("≤ 65k columns/table"));
8497 write_str(&mut out, dname);
8498 }
8499 // v7.17.0 Phase 2.1 — per-table on_update_runtime
8500 // appendix. Sparse: only ON UPDATE-bound columns.
8501 let mut on_update_bindings: Vec<(usize, &str)> = Vec::new();
8502 for (i, c) in t.schema.columns.iter().enumerate() {
8503 if let Some(e) = &c.on_update_runtime {
8504 on_update_bindings.push((i, e.as_str()));
8505 }
8506 }
8507 write_u16(
8508 &mut out,
8509 u16::try_from(on_update_bindings.len()).expect("≤ 65k ON UPDATE columns/table"),
8510 );
8511 for (pos, expr_src) in on_update_bindings {
8512 write_u16(&mut out, u16::try_from(pos).expect("≤ 65k columns/table"));
8513 write_str(&mut out, expr_src);
8514 }
8515 // v7.17.0 Phase 2.5 — per-table collation appendix.
8516 // Sparse: only non-Binary columns land. Layout:
8517 // `[u16 count][u16 col_pos][u8 tag] × count`.
8518 let mut coll_bindings: Vec<(usize, u8)> = Vec::new();
8519 for (i, c) in t.schema.columns.iter().enumerate() {
8520 let tag = match c.collation {
8521 Collation::Binary => continue,
8522 Collation::CaseInsensitive => Collation::TAG_CASE_INSENSITIVE,
8523 };
8524 coll_bindings.push((i, tag));
8525 }
8526 write_u16(
8527 &mut out,
8528 u16::try_from(coll_bindings.len()).expect("≤ 65k collation bindings/table"),
8529 );
8530 for (pos, tag) in coll_bindings {
8531 write_u16(&mut out, u16::try_from(pos).expect("≤ 65k columns/table"));
8532 out.push(tag);
8533 }
8534 // v7.17.0 Phase 4.4 — per-table is_unsigned appendix.
8535 // Sparse: only UNSIGNED columns land. Layout:
8536 // `[u16 count][u16 col_pos] × count`.
8537 let mut unsigned_bindings: Vec<usize> = Vec::new();
8538 for (i, c) in t.schema.columns.iter().enumerate() {
8539 if c.is_unsigned {
8540 unsigned_bindings.push(i);
8541 }
8542 }
8543 write_u16(
8544 &mut out,
8545 u16::try_from(unsigned_bindings.len()).expect("≤ 65k UNSIGNED columns/table"),
8546 );
8547 for pos in unsigned_bindings {
8548 write_u16(&mut out, u16::try_from(pos).expect("≤ 65k columns/table"));
8549 }
8550 // v7.17.0 Phase 3.P0-36 — per-table inline_enum_variants
8551 // appendix. Sparse: only ENUM columns land. Layout:
8552 // `[u16 count] then per binding [u16 col_pos]
8553 // [u16 variant_count] then variant strings`.
8554 // FILE_VERSION 41+; v40 readers never reach this block.
8555 let mut enum_inline_bindings: Vec<(usize, &[String])> = Vec::new();
8556 for (i, c) in t.schema.columns.iter().enumerate() {
8557 if let Some(vs) = &c.inline_enum_variants {
8558 enum_inline_bindings.push((i, vs.as_slice()));
8559 }
8560 }
8561 write_u16(
8562 &mut out,
8563 u16::try_from(enum_inline_bindings.len()).expect("≤ 65k inline-ENUM columns/table"),
8564 );
8565 for (pos, variants) in enum_inline_bindings {
8566 write_u16(&mut out, u16::try_from(pos).expect("≤ 65k columns/table"));
8567 write_u16(
8568 &mut out,
8569 u16::try_from(variants.len()).expect("≤ 65k variants/ENUM"),
8570 );
8571 for v in variants {
8572 write_str(&mut out, v.as_str());
8573 }
8574 }
8575 // v7.17.0 Phase 3.P0-37 — per-table inline_set_variants
8576 // appendix. Same layout as the inline ENUM block.
8577 // FILE_VERSION 42+; v41 readers never reach this block.
8578 let mut set_inline_bindings: Vec<(usize, &[String])> = Vec::new();
8579 for (i, c) in t.schema.columns.iter().enumerate() {
8580 if let Some(vs) = &c.inline_set_variants {
8581 set_inline_bindings.push((i, vs.as_slice()));
8582 }
8583 }
8584 write_u16(
8585 &mut out,
8586 u16::try_from(set_inline_bindings.len()).expect("≤ 65k inline-SET columns/table"),
8587 );
8588 for (pos, variants) in set_inline_bindings {
8589 write_u16(&mut out, u16::try_from(pos).expect("≤ 65k columns/table"));
8590 write_u16(
8591 &mut out,
8592 u16::try_from(variants.len()).expect("≤ 65k variants/SET"),
8593 );
8594 for v in variants {
8595 write_str(&mut out, v.as_str());
8596 }
8597 }
8598 // v7.37.6-B — partition role appendix(FILE_VERSION 49+)。
8599 // Layout 详见 FILE_VERSION 49 docstring。普通表 = 单字节 0。
8600 write_partition_role(&mut out, t.schema.partition_role.as_ref());
8601 // v7.37.7 — per-table generated_stored_expr appendix
8602 // (FILE_VERSION 50+). Sparse: only columns whose
8603 // generated_stored_expr is Some land here.
8604 let mut gen_bindings: Vec<(usize, &str)> = Vec::new();
8605 for (i, c) in t.schema.columns.iter().enumerate() {
8606 if let Some(src) = &c.generated_stored_expr {
8607 gen_bindings.push((i, src.as_str()));
8608 }
8609 }
8610 write_u16(
8611 &mut out,
8612 u16::try_from(gen_bindings.len()).expect("≤ 65k GENERATED STORED columns/table"),
8613 );
8614 for (pos, src) in gen_bindings {
8615 write_u16(&mut out, u16::try_from(pos).expect("≤ 65k columns/table"));
8616 write_str(&mut out, src);
8617 }
8618 // v7.38 (read01) — per-table default_text appendix
8619 // (FILE_VERSION 58+). Sparse: only columns whose default_text
8620 // is Some land here. Mirrors the generated_stored_expr shape.
8621 let mut default_texts: Vec<(usize, &str)> = Vec::new();
8622 for (i, c) in t.schema.columns.iter().enumerate() {
8623 if let Some(src) = &c.default_text {
8624 default_texts.push((i, src.as_str()));
8625 }
8626 }
8627 write_u16(
8628 &mut out,
8629 u16::try_from(default_texts.len()).expect("≤ 65k defaulted columns/table"),
8630 );
8631 for (pos, src) in default_texts {
8632 write_u16(&mut out, u16::try_from(pos).expect("≤ 65k columns/table"));
8633 write_str(&mut out, src);
8634 }
8635 // v7.39 (RLS) — per-table policy appendix + the two RLS flags
8636 // (FILE_VERSION 59+). Written after the default_text block and
8637 // before the MVCC row appendix, so a v58 reader stops before it.
8638 // Layout: [u8 row_security][u8 force] [u16 policy_count] then per
8639 // policy: [str name][u8 cmd][u8 permissive][u16 role_count]
8640 // (role_count × str) [u8 has_using](+str)[u8 has_check](+str).
8641 out.push(u8::from(t.schema.row_security));
8642 out.push(u8::from(t.schema.force_row_security));
8643 write_u16(
8644 &mut out,
8645 u16::try_from(t.schema.policies.len()).expect("≤ 65k policies/table"),
8646 );
8647 for p in &t.schema.policies {
8648 write_str(&mut out, &p.name);
8649 out.push(p.cmd.to_wire_byte());
8650 out.push(u8::from(p.permissive));
8651 write_u16(
8652 &mut out,
8653 u16::try_from(p.roles.len()).expect("≤ 65k roles/policy"),
8654 );
8655 for r in &p.roles {
8656 write_str(&mut out, r);
8657 }
8658 match &p.using_expr {
8659 Some(s) => {
8660 out.push(1);
8661 write_str(&mut out, s);
8662 }
8663 None => out.push(0),
8664 }
8665 match &p.with_check_expr {
8666 Some(s) => {
8667 out.push(1);
8668 write_str(&mut out, s);
8669 }
8670 None => out.push(0),
8671 }
8672 }
8673 // v7.37.16 (Epic W) — per-row MVCC header + stable RowId
8674 // appendix (FILE_VERSION 53+). Persists xmin/xmax/flags +
8675 // RowId for every row so a tombstone naming a pre-checkpoint
8676 // row survives a serialize→deserialize base restore
8677 // (cross-checkpoint tombstone durability). `headers` /
8678 // `rowids` are lock-step parallel to `rows` (invariant held
8679 // at every mutation boundary), so the count is `rows.len()`
8680 // and the zipped walk visits them in physical row order —
8681 // the same order the rows block above was written in. v52
8682 // readers never reach this block (the writer also moves to
8683 // v53 in lock-step); a v53 reader restores headers + ids
8684 // verbatim instead of freezing + dense-assigning.
8685 debug_assert_eq!(
8686 t.rows.len(),
8687 t.headers.len(),
8688 "headers must be lock-step with rows at serialize"
8689 );
8690 debug_assert_eq!(
8691 t.rows.len(),
8692 t.rowids.len(),
8693 "rowids must be lock-step with rows at serialize"
8694 );
8695 write_u32(
8696 &mut out,
8697 u32::try_from(t.rows.len()).expect("≤ 4G rows/table"),
8698 );
8699 for (h, rid) in t.headers.iter().zip(t.rowids.iter()) {
8700 out.extend_from_slice(&h.xmin.to_le_bytes());
8701 out.extend_from_slice(&h.xmax.to_le_bytes());
8702 out.push(h.flags);
8703 out.extend_from_slice(&rid.0.to_le_bytes());
8704 }
8705 out.extend_from_slice(&t.next_rowid.to_le_bytes());
8706 // v7.39 (read01 round 48) — constraint-name appendix
8707 // (FILE_VERSION 60+). Index-aligned to the CHECK and
8708 // uniqueness-constraint appendices written above, so the
8709 // existing byte layouts stay untouched and a v59 catalog still
8710 // decodes (its constraints just come back unnamed).
8711 // Layout: [u16 check_count] then per check
8712 // [u8 has_name] ([str name] when has_name)
8713 // [u16 uc_count] then per uc the same pair.
8714 write_u16(
8715 &mut out,
8716 u16::try_from(t.schema.checks.len()).expect("≤ 65k CHECK constraints/table"),
8717 );
8718 for c in &t.schema.checks {
8719 match &c.name {
8720 Some(n) => {
8721 out.push(1);
8722 write_str(&mut out, n);
8723 }
8724 None => out.push(0),
8725 }
8726 }
8727 write_u16(
8728 &mut out,
8729 u16::try_from(t.schema.uniqueness_constraints.len())
8730 .expect("≤ 65k uniqueness constraints/table"),
8731 );
8732 for uc in &t.schema.uniqueness_constraints {
8733 match &uc.name {
8734 Some(n) => {
8735 out.push(1);
8736 write_str(&mut out, n);
8737 }
8738 None => out.push(0),
8739 }
8740 }
8741 // v7.39 (read01 round 56) — user_composite_type appendix
8742 // (FILE_VERSION 63+). Sparse, at the very end of the per-table
8743 // block: only composite-typed columns land here, so a v62 reader
8744 // stops before it and its composite columns stay plain JSON.
8745 let mut comp_bindings: Vec<(usize, &str)> = Vec::new();
8746 for (i, c) in t.schema.columns.iter().enumerate() {
8747 if let Some(n) = &c.user_composite_type {
8748 comp_bindings.push((i, n.as_str()));
8749 }
8750 }
8751 write_u16(
8752 &mut out,
8753 u16::try_from(comp_bindings.len()).expect("≤ 65k composite-typed columns/table"),
8754 );
8755 for (pos, n) in comp_bindings {
8756 write_u16(&mut out, u16::try_from(pos).expect("≤ 65k columns/table"));
8757 write_str(&mut out, n);
8758 }
8759 // v7.39 (read01 round 57) — owner + ACL appendix (FILE_VERSION
8760 // 64+), at the very end of the per-table block so a v63 reader
8761 // stops before it (its tables then read back owner-less, i.e.
8762 // owned by the login role, with no grants — which is exactly what
8763 // they were).
8764 match &t.schema.owner {
8765 Some(o) => {
8766 out.push(1);
8767 write_str(&mut out, o);
8768 }
8769 None => out.push(0),
8770 }
8771 write_u16(
8772 &mut out,
8773 u16::try_from(t.schema.acl.len()).expect("≤ 65k aclitems/table"),
8774 );
8775 for a in &t.schema.acl {
8776 write_str(&mut out, &a.grantee);
8777 write_u16(&mut out, a.privs);
8778 write_u16(&mut out, a.grantable);
8779 write_str(&mut out, &a.grantor);
8780 }
8781 // v7.39 (read01 round 59) — COLUMN acl appendix (FILE_VERSION 65+),
8782 // sparse: only columns that carry a grant land here, so a v64 reader
8783 // stops before it and its columns read back un-granted, which is
8784 // what they were.
8785 let granted: Vec<(usize, &ColumnSchema)> = t
8786 .schema
8787 .columns
8788 .iter()
8789 .enumerate()
8790 .filter(|(_, c)| !c.acl.is_empty())
8791 .collect();
8792 write_u16(
8793 &mut out,
8794 u16::try_from(granted.len()).expect("≤ 65k granted columns/table"),
8795 );
8796 for (pos, c) in granted {
8797 write_u16(&mut out, u16::try_from(pos).expect("≤ 65k columns/table"));
8798 write_u16(
8799 &mut out,
8800 u16::try_from(c.acl.len()).expect("≤ 65k aclitems/column"),
8801 );
8802 for a in &c.acl {
8803 write_str(&mut out, &a.grantee);
8804 write_u16(&mut out, a.privs);
8805 write_u16(&mut out, a.grantable);
8806 write_str(&mut out, &a.grantor);
8807 }
8808 }
8809 // v7.39 (round 210) — EXCLUDE-constraint appendix (FILE_VERSION
8810 // 72+), at the very end of the per-table block so a v71 reader
8811 // stops before it and its tables read back with no exclusion
8812 // constraints. Layout: [u16 excl_count] then per constraint
8813 // [str name] [u8 has_method](+str) [u16 elem_count] then per
8814 // element [u16 col_pos][str op].
8815 write_u16(
8816 &mut out,
8817 u16::try_from(t.schema.exclusion_constraints.len())
8818 .expect("≤ 65k exclusion constraints/table"),
8819 );
8820 for ex in &t.schema.exclusion_constraints {
8821 write_str(&mut out, &ex.name);
8822 match &ex.method {
8823 Some(m) => {
8824 out.push(1);
8825 write_str(&mut out, m);
8826 }
8827 None => out.push(0),
8828 }
8829 write_u16(
8830 &mut out,
8831 u16::try_from(ex.elements.len()).expect("≤ 65k elements/exclusion"),
8832 );
8833 for (pos, op) in &ex.elements {
8834 write_u16(&mut out, u16::try_from(*pos).expect("≤ 65k columns/table"));
8835 write_str(&mut out, op);
8836 }
8837 }
8838 // v7.39 (round 220) — identity-RESTART appendix (FILE_VERSION
8839 // 73+), sparse: only columns carrying a RESTART floor land here.
8840 let restarts: Vec<(usize, i64)> = t
8841 .schema
8842 .columns
8843 .iter()
8844 .enumerate()
8845 .filter_map(|(i, c)| c.auto_restart.map(|n| (i, n)))
8846 .collect();
8847 write_u16(
8848 &mut out,
8849 u16::try_from(restarts.len()).expect("≤ 65k restart columns/table"),
8850 );
8851 for (pos, n) in restarts {
8852 write_u16(&mut out, u16::try_from(pos).expect("≤ 65k columns/table"));
8853 out.extend_from_slice(&n.to_le_bytes());
8854 }
8855 // v7.39 (round 386, type-fidelity epic P1) — per-table
8856 // mysql_int_width appendix (FILE_VERSION 81+). Sparse: only
8857 // TINYINT / MEDIUMINT columns land. Layout:
8858 // `[u16 count]([u16 col_pos][u8 width_tag]) × count`
8859 // (tag 0 = Tiny, 1 = Medium). v80-and-below readers stop after
8860 // the identity-RESTART appendix, leaving every column at None.
8861 let int_widths: Vec<(usize, u8)> = t
8862 .schema
8863 .columns
8864 .iter()
8865 .enumerate()
8866 .filter_map(|(i, c)| {
8867 c.mysql_int_width.map(|w| {
8868 let tag = match w {
8869 MysqlIntWidth::Tiny => 0u8,
8870 MysqlIntWidth::Medium => 1u8,
8871 MysqlIntWidth::Small => 2u8,
8872 MysqlIntWidth::Int => 3u8,
8873 MysqlIntWidth::Big => 4u8,
8874 };
8875 (i, tag)
8876 })
8877 })
8878 .collect();
8879 write_u16(
8880 &mut out,
8881 u16::try_from(int_widths.len()).expect("≤ 65k narrow-int columns/table"),
8882 );
8883 for (pos, tag) in int_widths {
8884 write_u16(&mut out, u16::try_from(pos).expect("≤ 65k columns/table"));
8885 out.push(tag);
8886 }
8887 // v7.39 (round 424, type-fidelity epic) — per-table mysql_fsp
8888 // appendix (FILE_VERSION 82+). Sparse: only MySQL-declared
8889 // temporal columns land. Layout:
8890 // `[u16 count]([u16 col_pos][u8 fsp]) × count`, fsp in 0..=6.
8891 // v81-and-below readers stop after the int-width appendix,
8892 // leaving every column at None (PG microsecond behaviour).
8893 let fsps: Vec<(usize, u8)> = t
8894 .schema
8895 .columns
8896 .iter()
8897 .enumerate()
8898 .filter_map(|(i, c)| c.mysql_fsp.map(|p| (i, p)))
8899 .collect();
8900 write_u16(
8901 &mut out,
8902 u16::try_from(fsps.len()).expect("≤ 65k temporal columns/table"),
8903 );
8904 for (pos, fsp) in fsps {
8905 write_u16(&mut out, u16::try_from(pos).expect("≤ 65k columns/table"));
8906 out.push(fsp);
8907 }
8908 // v7.39 (round 652) — CHECK-validated appendix (FILE_VERSION
8909 // 87+). Sparse the other way round from the ones above: the
8910 // common case is every constraint validated, so only the
8911 // NOT VALID ones are written, by their index into the CHECK
8912 // appendix. Layout: `[u16 count]([u16 check_idx]) × count`.
8913 let unvalidated: Vec<usize> = t
8914 .schema
8915 .checks
8916 .iter()
8917 .enumerate()
8918 .filter_map(|(i, c)| (!c.validated).then_some(i))
8919 .collect();
8920 write_u16(
8921 &mut out,
8922 u16::try_from(unvalidated.len()).expect("≤ 65k CHECK constraints/table"),
8923 );
8924 for idx in unvalidated {
8925 write_u16(&mut out, u16::try_from(idx).expect("≤ 65k CHECK/table"));
8926 }
8927 // v7.39 (round 677) — per-column collation names (FILE_VERSION
8928 // 88+). Sparse: only the columns that were written with an
8929 // explicit `COLLATE` appear, so a table that declares none pays
8930 // two bytes. Layout: `[u16 count]([u16 col_idx][str]) × count`.
8931 //
8932 // Without this the declaration survives CREATE TABLE and dies
8933 // at the next restart — measured: a column declared
8934 // `COLLATE "C"` reported attcollation 950 in the session that
8935 // created it and 100 after a reload.
8936 let collated: Vec<(usize, &str)> = t
8937 .schema
8938 .columns
8939 .iter()
8940 .enumerate()
8941 .filter_map(|(i, c)| c.collation_name.as_deref().map(|n| (i, n)))
8942 .collect();
8943 write_u16(
8944 &mut out,
8945 u16::try_from(collated.len()).expect("≤ 65k columns/table"),
8946 );
8947 for (idx, name) in collated {
8948 write_u16(&mut out, u16::try_from(idx).expect("≤ 65k columns/table"));
8949 write_str(&mut out, name);
8950 }
8951 // v7.39 (round 711) — PK/UNIQUE constraint timing (FILE_VERSION
8952 // 89+). Dense, one byte per uniqueness constraint in
8953 // declaration order, the same bit layout the FK block has
8954 // carried since round 288: bit 0 = DEFERRABLE, bit 1 =
8955 // INITIALLY DEFERRED. A v88 reader stops before it.
8956 write_u16(
8957 &mut out,
8958 u16::try_from(t.schema.uniqueness_constraints.len())
8959 .expect("≤ 65k uniqueness constraints/table"),
8960 );
8961 for uc in &t.schema.uniqueness_constraints {
8962 out.push(u8::from(uc.deferrable) | (u8::from(uc.initially_deferred) << 1));
8963 }
8964 }
8965 // v7.12.4 — catalog-wide appendix: user-defined functions
8966 // then triggers. FILE_VERSION 22+ only. v21 and earlier
8967 // readers stop after the last table; v22 readers always
8968 // consume two `u32` counts (possibly zero).
8969 //
8970 // Function entry layout:
8971 // [str name] [str args_repr] [str returns]
8972 // [str language] [str body]
8973 // Trigger entry layout:
8974 // [str name] [str table] [str timing]
8975 // [u16 event_count] (event_count × str)
8976 // [str for_each] [str function]
8977 write_u32(
8978 &mut out,
8979 u32::try_from(self.functions.len()).expect("≤ 4G functions"),
8980 );
8981 for fd in self.functions.values() {
8982 write_str(&mut out, &fd.name);
8983 write_str(&mut out, &fd.args_repr);
8984 write_str(&mut out, &fd.returns);
8985 write_str(&mut out, &fd.language);
8986 write_str_long(&mut out, &fd.body);
8987 }
8988 write_u32(
8989 &mut out,
8990 u32::try_from(self.triggers.len()).expect("≤ 4G triggers"),
8991 );
8992 for td in &self.triggers {
8993 write_str(&mut out, &td.name);
8994 write_str(&mut out, &td.table);
8995 write_str(&mut out, &td.timing);
8996 write_u16(
8997 &mut out,
8998 u16::try_from(td.events.len()).expect("≤ 65k events / trigger"),
8999 );
9000 for ev in &td.events {
9001 write_str(&mut out, ev);
9002 }
9003 write_str(&mut out, &td.for_each);
9004 write_str(&mut out, &td.function);
9005 // v7.13.0 — `UPDATE OF cols` filter
9006 // (FILE_VERSION 23+). v22 readers omit; v23 writers
9007 // always emit (possibly zero).
9008 write_u16(
9009 &mut out,
9010 u16::try_from(td.update_columns.len()).expect("≤ 65k cols / trigger"),
9011 );
9012 for c in &td.update_columns {
9013 write_str(&mut out, c);
9014 }
9015 // v7.16.1 — TriggerDef.enabled (FILE_VERSION 25+).
9016 out.push(u8::from(td.enabled));
9017 // v7.39 (round 138) — WHEN condition text (FILE_VERSION 70+).
9018 write_str(&mut out, &td.when_condition);
9019 }
9020 // v7.17.0 Phase 1.1 — SEQUENCE catalog block (FILE_VERSION 26+).
9021 write_u32(
9022 &mut out,
9023 u32::try_from(self.sequences.len()).expect("≤ 4G sequences"),
9024 );
9025 for seq in self.sequences.values() {
9026 write_str(&mut out, &seq.name);
9027 out.push(match seq.data_type {
9028 SequenceDataType::SmallInt => 0,
9029 SequenceDataType::Int => 1,
9030 SequenceDataType::BigInt => 2,
9031 });
9032 out.extend_from_slice(&seq.start.to_le_bytes());
9033 out.extend_from_slice(&seq.increment.to_le_bytes());
9034 out.extend_from_slice(&seq.min_value.to_le_bytes());
9035 out.extend_from_slice(&seq.max_value.to_le_bytes());
9036 out.extend_from_slice(&seq.cache.to_le_bytes());
9037 out.push(u8::from(seq.cycle));
9038 match &seq.owned_by {
9039 None => out.push(0),
9040 Some((table, column)) => {
9041 out.push(1);
9042 write_str(&mut out, table);
9043 write_str(&mut out, column);
9044 }
9045 }
9046 out.extend_from_slice(&seq.last_value.to_le_bytes());
9047 out.push(u8::from(seq.is_called));
9048 }
9049 // v7.17.0 Phase 1.2 — VIEW catalog block (FILE_VERSION 27+).
9050 write_u32(
9051 &mut out,
9052 u32::try_from(self.views.len()).expect("≤ 4G views"),
9053 );
9054 for view in self.views.values() {
9055 write_str(&mut out, &view.name);
9056 write_u16(
9057 &mut out,
9058 u16::try_from(view.columns.len()).expect("≤ 65k cols / view"),
9059 );
9060 for c in &view.columns {
9061 write_str(&mut out, c);
9062 }
9063 write_str_long(&mut out, &view.body);
9064 // v7.39 (round 132, FILE_VERSION 69+) — WITH CHECK OPTION marker.
9065 out.push(view.check_option);
9066 }
9067 // v7.17.0 Phase 1.3 — MATERIALIZED VIEW source registry
9068 // (FILE_VERSION 28+). The backing rows live as a regular
9069 // table of the same name already in the tables block.
9070 write_u32(
9071 &mut out,
9072 u32::try_from(self.materialized_views.len()).expect("≤ 4G materialized views"),
9073 );
9074 for (name, body) in &self.materialized_views {
9075 write_str(&mut out, name);
9076 write_str_long(&mut out, body);
9077 }
9078 // v7.17.0 Phase 1.4 — ENUM types catalog block
9079 // (FILE_VERSION 29+).
9080 write_u32(
9081 &mut out,
9082 u32::try_from(self.enum_types.len()).expect("≤ 4G enum types"),
9083 );
9084 for e in self.enum_types.values() {
9085 write_str(&mut out, &e.name);
9086 write_u16(
9087 &mut out,
9088 u16::try_from(e.labels.len()).expect("≤ 65k labels / enum"),
9089 );
9090 for l in &e.labels {
9091 write_str(&mut out, l);
9092 }
9093 }
9094 // v7.17.0 Phase 1.5 — DOMAIN types catalog block
9095 // (FILE_VERSION 30+).
9096 write_u32(
9097 &mut out,
9098 u32::try_from(self.domain_types.len()).expect("≤ 4G domain types"),
9099 );
9100 for d in self.domain_types.values() {
9101 write_str(&mut out, &d.name);
9102 write_data_type(&mut out, d.base_type);
9103 out.push(u8::from(d.nullable));
9104 match &d.default {
9105 None => out.push(0),
9106 Some(s) => {
9107 out.push(1);
9108 write_str(&mut out, s);
9109 }
9110 }
9111 write_u16(
9112 &mut out,
9113 u16::try_from(d.checks.len()).expect("≤ 65k CHECKs / domain"),
9114 );
9115 for c in &d.checks {
9116 write_str(&mut out, &c.expr);
9117 // v7.39 (round 260) — the constraint name (FILE_VERSION 75+).
9118 write_str(&mut out, &c.name);
9119 }
9120 // v7.39 (round 259) — the parent domain (FILE_VERSION 74+).
9121 match &d.base_domain {
9122 None => out.push(0),
9123 Some(s) => {
9124 out.push(1);
9125 write_str(&mut out, s);
9126 }
9127 }
9128 }
9129 // v7.17.0 Phase 1.6 — user-schemas registry
9130 // (FILE_VERSION 31+). Built-ins are hardcoded in
9131 // `is_builtin_schema` and not persisted.
9132 write_u32(
9133 &mut out,
9134 u32::try_from(self.schemas.len()).expect("≤ 4G schemas"),
9135 );
9136 for name in &self.schemas {
9137 write_str(&mut out, name);
9138 }
9139 // v7.37.42-T2 ζ-B — COMPOSITE types catalog block
9140 // (FILE_VERSION 52+). Each entry: name, u16 field_count,
9141 // then field_count `[str field_name][data_type]` pairs.
9142 write_u32(
9143 &mut out,
9144 u32::try_from(self.composite_types.len()).expect("≤ 4G composite types"),
9145 );
9146 for c in self.composite_types.values() {
9147 write_str(&mut out, &c.name);
9148 write_u16(
9149 &mut out,
9150 u16::try_from(c.fields.len()).expect("≤ 65k fields / composite"),
9151 );
9152 for (i, (fname, fty)) in c.fields.iter().enumerate() {
9153 write_str(&mut out, fname);
9154 write_data_type(&mut out, *fty);
9155 // v7.39 (round 264) — the field's user type (v76+).
9156 match c.field_user_types.get(i).and_then(Option::as_ref) {
9157 None => out.push(0),
9158 Some(n) => {
9159 out.push(1);
9160 write_str(&mut out, n);
9161 }
9162 }
9163 }
9164 }
9165 // v7.39 (read01 round 50) — COMMENT store (FILE_VERSION 61+).
9166 // Catalog-wide, written last (before the CRC trailer) so every older
9167 // reader stops before it. Layout: [u32 count] then [str key][str text].
9168 write_u32(
9169 &mut out,
9170 u32::try_from(self.comments.len()).expect("≤ 4G comments"),
9171 );
9172 for (k, v) in &self.comments {
9173 write_str(&mut out, k);
9174 write_str_long(&mut out, v);
9175 }
9176 // v7.39 (read01 round 60) — non-table ACLs (FILE_VERSION 66+), catalog-
9177 // wide and written last so a v65 reader stops before them. The sequence
9178 // block itself sits mid-image and cannot grow without breaking older
9179 // readers, so a sequence's owner + ACL rides here, keyed by name.
9180 let acl_out = |out: &mut Vec<u8>, acl: &[AclItem]| {
9181 write_u16(out, u16::try_from(acl.len()).expect("≤ 65k aclitems"));
9182 for a in acl {
9183 write_str(out, &a.grantee);
9184 write_u16(out, a.privs);
9185 write_u16(out, a.grantable);
9186 write_str(out, &a.grantor);
9187 }
9188 };
9189 let owned: Vec<&SequenceDef> = self
9190 .sequences
9191 .values()
9192 .filter(|s| s.owner.is_some() || !s.acl.is_empty())
9193 .collect();
9194 write_u32(
9195 &mut out,
9196 u32::try_from(owned.len()).expect("≤ 4G sequences"),
9197 );
9198 for seq in owned {
9199 write_str(&mut out, &seq.name);
9200 match &seq.owner {
9201 Some(o) => {
9202 out.push(1);
9203 write_str(&mut out, o);
9204 }
9205 None => out.push(0),
9206 }
9207 acl_out(&mut out, &seq.acl);
9208 }
9209 acl_out(&mut out, &self.schema_acl);
9210 acl_out(&mut out, &self.database_acl);
9211 // v7.39 (read01 round 61) — FUNCTION owner + ACL (FILE_VERSION 67+).
9212 // The function block sits mid-image like the sequence one, so this
9213 // rides the catalog-wide tail too, keyed by name.
9214 let fns: Vec<&FunctionDef> = self
9215 .functions
9216 .values()
9217 .filter(|f| f.owner.is_some() || !f.acl.is_empty())
9218 .collect();
9219 write_u32(&mut out, u32::try_from(fns.len()).expect("≤ 4G functions"));
9220 for f in fns {
9221 // v7.39 (read01 round 62) — keyed by SIGNATURE now: two overloads
9222 // have two ACLs.
9223 write_str(&mut out, &function_signature_key(&f.name, &f.args_repr));
9224 match &f.owner {
9225 Some(o) => {
9226 out.push(1);
9227 write_str(&mut out, o);
9228 }
9229 None => out.push(0),
9230 }
9231 acl_out(&mut out, &f.acl);
9232 }
9233 // v7.39 (round 139) — RULE catalog block (FILE_VERSION 71+), catalog-
9234 // wide and written last (right before the CRC trailer) so every older
9235 // reader stops cleanly before it. Layout: [u32 count] then per rule
9236 // [str name][str table][str event][u8 instead][str when]
9237 // [u16 cmd_count]([str cmd] × cmd_count).
9238 write_u32(
9239 &mut out,
9240 u32::try_from(self.rules.len()).expect("≤ 4G rules"),
9241 );
9242 for r in &self.rules {
9243 write_str(&mut out, &r.name);
9244 write_str(&mut out, &r.table);
9245 write_str(&mut out, &r.event);
9246 out.push(u8::from(r.instead));
9247 write_str(&mut out, &r.when_condition);
9248 write_u16(
9249 &mut out,
9250 u16::try_from(r.commands.len()).expect("≤ 65k commands / rule"),
9251 );
9252 for c in &r.commands {
9253 write_str(&mut out, c);
9254 }
9255 }
9256 // v7.39 (round 280) — extended-statistics block (FILE_VERSION
9257 // 77+), appended after the RULE block for the same reason: an
9258 // older reader stops cleanly before it. Layout: [u32 count]
9259 // then per object [str name][str table][u16 n]([str kind] × n)
9260 // [u16 m]([str column] × m).
9261 write_u32(
9262 &mut out,
9263 u32::try_from(self.statistics_ext.len()).expect("≤ 4G statistics objects"),
9264 );
9265 for st in &self.statistics_ext {
9266 write_str(&mut out, &st.name);
9267 write_str(&mut out, &st.table);
9268 write_u16(
9269 &mut out,
9270 u16::try_from(st.kinds.len()).expect("≤ 65k kinds"),
9271 );
9272 for k in &st.kinds {
9273 write_str(&mut out, k);
9274 }
9275 write_u16(
9276 &mut out,
9277 u16::try_from(st.columns.len()).expect("≤ 65k columns"),
9278 );
9279 for c in &st.columns {
9280 write_str(&mut out, c);
9281 }
9282 }
9283 // v7.39 (round 287) — large-object block (FILE_VERSION 78+),
9284 // appended after the statistics block for the same reason: an
9285 // older reader stops cleanly before it. Layout: [u32 count]
9286 // then per object [u32 oid][u32 len][len bytes].
9287 write_u32(
9288 &mut out,
9289 u32::try_from(self.large_objects.len()).expect("≤ 4G large objects"),
9290 );
9291 for (oid, bytes) in &self.large_objects {
9292 write_u32(&mut out, *oid);
9293 write_u32(
9294 &mut out,
9295 u32::try_from(bytes.len()).expect("≤ 4G per object"),
9296 );
9297 out.extend_from_slice(bytes);
9298 }
9299 // v7.39 (round 322, V46) — function-attribute block (FILE_VERSION
9300 // 80+), appended last for the same reason as every block before
9301 // it: an older reader stops cleanly ahead of it and simply sees
9302 // functions with PG's default attributes. Only functions that
9303 // declared something non-default are written. Layout: [u32 count]
9304 // then per function [str signature_key][u8 volatility][u8 flags]
9305 // [u8 parallel][f64 cost or NaN][f64 rows or NaN], where flags bit
9306 // 0 = strict, 1 = security definer, 2 = leakproof.
9307 let attr_fns: Vec<(&String, &FunctionDef)> = self
9308 .functions
9309 .iter()
9310 .filter(|(_, f)| {
9311 f.volatility != FN_VOLATILE
9312 || f.strict
9313 || f.security_definer
9314 || f.leakproof
9315 || f.parallel != FN_PARALLEL_UNSAFE
9316 || f.cost.is_some()
9317 || f.rows.is_some()
9318 })
9319 .collect();
9320 write_u32(
9321 &mut out,
9322 u32::try_from(attr_fns.len()).expect("≤ 4G functions"),
9323 );
9324 for (key, f) in attr_fns {
9325 write_str(&mut out, key);
9326 out.push(f.volatility);
9327 let flags = u8::from(f.strict)
9328 | (u8::from(f.security_definer) << 1)
9329 | (u8::from(f.leakproof) << 2);
9330 out.push(flags);
9331 out.push(f.parallel);
9332 out.extend_from_slice(&f.cost.unwrap_or(f64::NAN).to_le_bytes());
9333 out.extend_from_slice(&f.rows.unwrap_or(f64::NAN).to_le_bytes());
9334 }
9335 // v7.38 (read01 P5.05) — CRC32C trailer over the whole image so a
9336 // corrupted snapshot is rejected on load. FILE_VERSION is >= the
9337 // trailer version, so this always runs for freshly-written images.
9338 // v7.39 (round 547) — pg_db_role_setting (FILE_VERSION 85+),
9339 // catalog-wide and written LAST so a v84 reader stops before it.
9340 // Layout: [u32 scopes] then [str database][str role][u32 params]
9341 // then [str name][str value] per param.
9342 write_u32(
9343 &mut out,
9344 u32::try_from(self.db_role_settings.len()).expect("≤ 4G scopes"),
9345 );
9346 for ((db, role), params) in &self.db_role_settings {
9347 write_str(&mut out, db);
9348 write_str(&mut out, role);
9349 write_u32(&mut out, u32::try_from(params.len()).expect("≤ 4G params"));
9350 for (name, value) in params {
9351 write_str(&mut out, name);
9352 write_str(&mut out, value);
9353 }
9354 }
9355 // v7.39 (round 550) — replication slots (FILE_VERSION 86+),
9356 // written LAST so a v85 reader stops before them.
9357 write_u32(
9358 &mut out,
9359 u32::try_from(self.replication_slots.len()).expect("≤ 4G slots"),
9360 );
9361 for (name, (plugin, slot_type)) in &self.replication_slots {
9362 write_str(&mut out, name);
9363 write_str(&mut out, plugin);
9364 write_str(&mut out, slot_type);
9365 }
9366 let crc = spg_crypto::crc32c::crc32c(&out);
9367 write_u32(&mut out, crc);
9368 out
9369 }
9370
9371 /// Deserialize a previously-serialized catalog. Rejects bad magic, version
9372 /// mismatch, unknown tags, truncation, and trailing bytes.
9373 pub fn deserialize(buf: &[u8]) -> Result<Self, StorageError> {
9374 let mut cur = Cursor::new(buf);
9375 let magic = cur.take(8)?;
9376 if magic != FILE_MAGIC {
9377 return Err(StorageError::Corrupt(format!(
9378 "bad magic: expected SPGDB001, got {magic:?}"
9379 )));
9380 }
9381 let version = cur.read_u8()?;
9382 if !(MIN_SUPPORTED_FILE_VERSION..=FILE_VERSION).contains(&version) {
9383 return Err(StorageError::Corrupt(format!(
9384 "unsupported file version: {version} (supported: {MIN_SUPPORTED_FILE_VERSION}..={FILE_VERSION})"
9385 )));
9386 }
9387 // v7.23/v7.27 — escape decoding is version-gated (see
9388 // STR_LEN_ESCAPE / Cursor::codec_version).
9389 cur.codec_version = version;
9390 let table_count = cur.read_u32()? as usize;
9391 let mut cat = Self::new();
9392 for _ in 0..table_count {
9393 deserialize_table(&mut cur, &mut cat, version)?;
9394 }
9395 // v7.37.15 (Phase C.1) — stamp dense stable RelIds on load.
9396 // Pre-V6 envelopes carry no ids; a dense 1..=N assignment is
9397 // sufficient while RelId is process-local bookkeeping (the V6
9398 // envelope, Phase C.6, will round-trip real ids). Sets the
9399 // allocator above the loaded ids so a post-load CREATE TABLE
9400 // never collides.
9401 for (i, t) in cat.tables.iter_mut().enumerate() {
9402 t.set_rel_id(row_header::RelId((i as u64) + 1));
9403 }
9404 cat.next_rel_id = cat.tables.len() as u64;
9405 // v7.12.4 — catalog-wide function + trigger appendix.
9406 // FILE_VERSION 22+ only; v21 and earlier catalogs stop
9407 // after the last table.
9408 if version >= 22 {
9409 let fn_count = cur.read_u32()? as usize;
9410 for _ in 0..fn_count {
9411 let name = cur.read_str()?;
9412 let args_repr = cur.read_str()?;
9413 let returns = cur.read_str()?;
9414 let language = cur.read_str()?;
9415 let body = cur.read_str_long()?;
9416 let key = function_signature_key(&name, &args_repr);
9417 cat.functions.insert(
9418 key,
9419 FunctionDef {
9420 name,
9421 args_repr,
9422 returns,
9423 language,
9424 body,
9425 owner: None,
9426 acl: Vec::new(),
9427 volatility: FN_VOLATILE,
9428 strict: false,
9429 security_definer: false,
9430 leakproof: false,
9431 parallel: FN_PARALLEL_UNSAFE,
9432 cost: None,
9433 rows: None,
9434 },
9435 );
9436 }
9437 let trg_count = cur.read_u32()? as usize;
9438 for _ in 0..trg_count {
9439 let name = cur.read_str()?;
9440 let table = cur.read_str()?;
9441 let timing = cur.read_str()?;
9442 let ev_count = cur.read_u16()? as usize;
9443 let mut events = Vec::with_capacity(ev_count);
9444 for _ in 0..ev_count {
9445 events.push(cur.read_str()?);
9446 }
9447 let for_each = cur.read_str()?;
9448 let function = cur.read_str()?;
9449 // v7.13.0 — trailing `UPDATE OF cols` filter
9450 // (FILE_VERSION 23+ only; v22 catalogs omit and
9451 // deserialise with an empty vec).
9452 let update_columns = if version >= 23 {
9453 let n = cur.read_u16()? as usize;
9454 let mut cols = Vec::with_capacity(n);
9455 for _ in 0..n {
9456 cols.push(cur.read_str()?);
9457 }
9458 cols
9459 } else {
9460 Vec::new()
9461 };
9462 // v7.16.1 — TriggerDef.enabled (FILE_VERSION 25+).
9463 // v24-and-below catalogs deserialise with `true`
9464 // — pre-v7.16.1 every trigger always fired.
9465 let enabled = if version >= 25 {
9466 cur.read_u8()? != 0
9467 } else {
9468 true
9469 };
9470 // v7.39 (round 138) — WHEN condition text added at FILE_VERSION
9471 // 70; older catalogs read back empty (no WHEN filter).
9472 let when_condition = if version >= 70 {
9473 cur.read_str()?
9474 } else {
9475 String::new()
9476 };
9477 cat.triggers.push(TriggerDef {
9478 name,
9479 table,
9480 timing,
9481 events,
9482 for_each,
9483 function,
9484 update_columns,
9485 enabled,
9486 when_condition,
9487 });
9488 }
9489 }
9490 // v7.17.0 Phase 1.1 — SEQUENCE block (FILE_VERSION 26+).
9491 // v25-and-below catalogs omit; we leave the map empty.
9492 if version >= 26 {
9493 let seq_count = cur.read_u32()? as usize;
9494 for _ in 0..seq_count {
9495 let name = cur.read_str()?;
9496 let data_type = match cur.read_u8()? {
9497 0 => SequenceDataType::SmallInt,
9498 1 => SequenceDataType::Int,
9499 2 => SequenceDataType::BigInt,
9500 other => {
9501 return Err(StorageError::Corrupt(format!(
9502 "unknown SEQUENCE data-type tag {other}"
9503 )));
9504 }
9505 };
9506 let start = cur.read_i64()?;
9507 let increment = cur.read_i64()?;
9508 let min_value = cur.read_i64()?;
9509 let max_value = cur.read_i64()?;
9510 let cache = cur.read_i64()?;
9511 let cycle = cur.read_u8()? != 0;
9512 let owned_by = match cur.read_u8()? {
9513 0 => None,
9514 1 => {
9515 let t = cur.read_str()?;
9516 let c = cur.read_str()?;
9517 Some((t, c))
9518 }
9519 other => {
9520 return Err(StorageError::Corrupt(format!(
9521 "unknown SEQUENCE owned-by tag {other}"
9522 )));
9523 }
9524 };
9525 let last_value = cur.read_i64()?;
9526 let is_called = cur.read_u8()? != 0;
9527 cat.sequences.insert(
9528 name.clone(),
9529 SequenceDef {
9530 name,
9531 data_type,
9532 start,
9533 increment,
9534 min_value,
9535 max_value,
9536 cache,
9537 cycle,
9538 owned_by,
9539 last_value,
9540 is_called,
9541 owner: None,
9542 acl: Vec::new(),
9543 },
9544 );
9545 }
9546 }
9547 // v7.17.0 Phase 1.2 — VIEW block (FILE_VERSION 27+).
9548 // v26-and-below catalogs omit; we leave the map empty.
9549 if version >= 27 {
9550 let view_count = cur.read_u32()? as usize;
9551 for _ in 0..view_count {
9552 let name = cur.read_str()?;
9553 let col_count = cur.read_u16()? as usize;
9554 let mut columns = Vec::with_capacity(col_count);
9555 for _ in 0..col_count {
9556 columns.push(cur.read_str()?);
9557 }
9558 let body = cur.read_str_long()?;
9559 // v7.39 (round 132) — check-option marker added at FILE_VERSION
9560 // 69; older catalogs default to 0 (no check option).
9561 let check_option = if version >= 69 { cur.read_u8()? } else { 0 };
9562 cat.views.insert(
9563 name.clone(),
9564 ViewDef {
9565 name,
9566 columns,
9567 body,
9568 check_option,
9569 },
9570 );
9571 }
9572 }
9573 // v7.17.0 Phase 1.3 — MATERIALIZED VIEW source registry
9574 // (FILE_VERSION 28+). v27-and-below catalogs omit.
9575 if version >= 28 {
9576 let mv_count = cur.read_u32()? as usize;
9577 for _ in 0..mv_count {
9578 let name = cur.read_str()?;
9579 let body = cur.read_str_long()?;
9580 cat.materialized_views.insert(name, body);
9581 }
9582 }
9583 // v7.17.0 Phase 1.4 — ENUM types catalog block
9584 // (FILE_VERSION 29+).
9585 if version >= 29 {
9586 let etype_count = cur.read_u32()? as usize;
9587 for _ in 0..etype_count {
9588 let name = cur.read_str()?;
9589 let label_count = cur.read_u16()? as usize;
9590 let mut labels = Vec::with_capacity(label_count);
9591 for _ in 0..label_count {
9592 labels.push(cur.read_str()?);
9593 }
9594 cat.enum_types
9595 .insert(name.clone(), EnumDef { name, labels });
9596 }
9597 }
9598 // v7.17.0 Phase 1.5 — DOMAIN types catalog block
9599 // (FILE_VERSION 30+).
9600 if version >= 30 {
9601 let dtype_count = cur.read_u32()? as usize;
9602 for _ in 0..dtype_count {
9603 let name = cur.read_str()?;
9604 let base_type = cur.read_data_type()?;
9605 let nullable = cur.read_u8()? != 0;
9606 let default = match cur.read_u8()? {
9607 0 => None,
9608 1 => Some(cur.read_str()?),
9609 other => {
9610 return Err(StorageError::Corrupt(format!(
9611 "unknown DOMAIN default tag {other}"
9612 )));
9613 }
9614 };
9615 let check_count = cur.read_u16()? as usize;
9616 let mut checks: Vec<DomainCheck> = Vec::with_capacity(check_count);
9617 for i in 0..check_count {
9618 let expr = cur.read_str()?;
9619 // v7.39 (round 260) — names arrived in FILE_VERSION 75.
9620 // An older catalog gets PG's auto-naming applied to the
9621 // checks it stored, which is what they would have been.
9622 let cname = if version >= 75 {
9623 cur.read_str()?
9624 } else if i == 0 {
9625 alloc::format!("{name}_check")
9626 } else {
9627 alloc::format!("{name}_check{i}")
9628 };
9629 checks.push(DomainCheck { name: cname, expr });
9630 }
9631 // v7.39 (round 259) — the parent domain. Absent before
9632 // FILE_VERSION 74; an older catalog reads as a domain over
9633 // a scalar, which is what it was.
9634 let base_domain = if version >= 74 {
9635 match cur.read_u8()? {
9636 0 => None,
9637 1 => Some(cur.read_str()?),
9638 other => {
9639 return Err(StorageError::Corrupt(alloc::format!(
9640 "domain base_domain tag {other}"
9641 )));
9642 }
9643 }
9644 } else {
9645 None
9646 };
9647 cat.domain_types.insert(
9648 name.clone(),
9649 DomainDef {
9650 name,
9651 base_type,
9652 nullable,
9653 default,
9654 checks,
9655 base_domain,
9656 },
9657 );
9658 }
9659 }
9660 // v7.17.0 Phase 1.6 — user-schemas registry
9661 // (FILE_VERSION 31+).
9662 if version >= 31 {
9663 let sch_count = cur.read_u32()? as usize;
9664 for _ in 0..sch_count {
9665 let name = cur.read_str()?;
9666 cat.schemas.insert(name);
9667 }
9668 }
9669 // v7.37.42-T2 ζ-B — COMPOSITE types catalog block
9670 // (FILE_VERSION 52+). v51-and-below readers stop at the
9671 // user-schemas block; v52 readers fed a v51 catalog see no
9672 // composite block and default to an empty map.
9673 if version >= 52 {
9674 let ctype_count = cur.read_u32()? as usize;
9675 for _ in 0..ctype_count {
9676 let name = cur.read_str()?;
9677 let field_count = cur.read_u16()? as usize;
9678 let mut fields = Vec::with_capacity(field_count);
9679 let mut field_user_types: Vec<Option<String>> = Vec::with_capacity(field_count);
9680 for _ in 0..field_count {
9681 let fname = cur.read_str()?;
9682 let fty = cur.read_data_type()?;
9683 // v7.39 (round 264) — present from FILE_VERSION 76.
9684 let ut = if version >= 76 {
9685 match cur.read_u8()? {
9686 0 => None,
9687 1 => Some(cur.read_str()?),
9688 other => {
9689 return Err(StorageError::Corrupt(alloc::format!(
9690 "composite field user-type tag {other}"
9691 )));
9692 }
9693 }
9694 } else {
9695 None
9696 };
9697 fields.push((fname, fty));
9698 field_user_types.push(ut);
9699 }
9700 cat.composite_types.insert(
9701 name.clone(),
9702 CompositeDef {
9703 name,
9704 fields,
9705 field_user_types,
9706 },
9707 );
9708 }
9709 }
9710 // v7.39 (read01 round 50) — COMMENT store (FILE_VERSION 61+).
9711 if version >= 61 {
9712 let comment_count = cur.read_u32()? as usize;
9713 for _ in 0..comment_count {
9714 let key = cur.read_str()?;
9715 let text = cur.read_str_long()?;
9716 cat.comments.insert(key, text);
9717 }
9718 }
9719 // v7.39 (read01 round 60) — non-table ACLs (FILE_VERSION 66+).
9720 if version >= 66 {
9721 let read_acl = |cur: &mut Cursor| -> Result<Vec<AclItem>, StorageError> {
9722 let n = cur.read_u16()? as usize;
9723 let mut acl = Vec::with_capacity(n);
9724 for _ in 0..n {
9725 let grantee = cur.read_str()?;
9726 let privs = cur.read_u16()?;
9727 let grantable = cur.read_u16()?;
9728 let grantor = cur.read_str()?;
9729 acl.push(AclItem {
9730 grantee,
9731 privs,
9732 grantable,
9733 grantor,
9734 });
9735 }
9736 Ok(acl)
9737 };
9738 let seq_count = cur.read_u32()? as usize;
9739 for _ in 0..seq_count {
9740 let name = cur.read_str()?;
9741 let owner = if cur.read_u8()? == 1 {
9742 Some(cur.read_str()?)
9743 } else {
9744 None
9745 };
9746 let acl = read_acl(&mut cur)?;
9747 if let Some(seq) = cat.sequences.get_mut(&name) {
9748 seq.owner = owner;
9749 seq.acl = acl;
9750 }
9751 }
9752 cat.schema_acl = read_acl(&mut cur)?;
9753 cat.database_acl = read_acl(&mut cur)?;
9754 // v7.39 (read01 round 61) — FUNCTION owner + ACL (v67+; keyed by
9755 // signature from v68, when overloads became possible).
9756 if version >= 67 {
9757 let fn_count = cur.read_u32()? as usize;
9758 for _ in 0..fn_count {
9759 let name = cur.read_str()?;
9760 let owner = if cur.read_u8()? == 1 {
9761 Some(cur.read_str()?)
9762 } else {
9763 None
9764 };
9765 let acl = read_acl(&mut cur)?;
9766 // v7.39 (round 315, V19) — the stored key was computed
9767 // by whichever formula was current when the image was
9768 // written. A miss is not "no such function": before the
9769 // multi-word fix, `f(double precision)` keyed as
9770 // `f(precision)`, so an older image's grants would land
9771 // nowhere and vanish silently. Fall back to matching by
9772 // the old formula, which re-attaches them.
9773 let target = resolve_stored_function_key(&cat.functions, &name);
9774 if let Some(k) = target
9775 && let Some(f) = cat.functions.get_mut(&k)
9776 {
9777 f.owner = owner;
9778 f.acl = acl;
9779 }
9780 }
9781 }
9782 }
9783 // v7.39 (round 139) — RULE catalog block (FILE_VERSION 71+), read from
9784 // the tail right before the CRC trailer. Pre-71 images stop before it.
9785 if version >= 71 {
9786 let rule_count = cur.read_u32()? as usize;
9787 for _ in 0..rule_count {
9788 let name = cur.read_str()?;
9789 let table = cur.read_str()?;
9790 let event = cur.read_str()?;
9791 let instead = cur.read_u8()? != 0;
9792 let when_condition = cur.read_str()?;
9793 let cmd_count = cur.read_u16()? as usize;
9794 let mut commands = Vec::with_capacity(cmd_count);
9795 for _ in 0..cmd_count {
9796 commands.push(cur.read_str()?);
9797 }
9798 cat.rules.push(RuleDef {
9799 name,
9800 table,
9801 event,
9802 instead,
9803 when_condition,
9804 commands,
9805 });
9806 }
9807 }
9808 // v7.39 (round 280) — extended-statistics block (FILE_VERSION
9809 // 77+). Pre-77 images stop before it.
9810 if version >= 77 {
9811 let count = cur.read_u32()? as usize;
9812 for _ in 0..count {
9813 let name = cur.read_str()?;
9814 let table = cur.read_str()?;
9815 let nk = cur.read_u16()? as usize;
9816 let mut kinds = Vec::with_capacity(nk);
9817 for _ in 0..nk {
9818 kinds.push(cur.read_str()?);
9819 }
9820 let nc = cur.read_u16()? as usize;
9821 let mut columns = Vec::with_capacity(nc);
9822 for _ in 0..nc {
9823 columns.push(cur.read_str()?);
9824 }
9825 cat.statistics_ext.push(StatisticsExtDef {
9826 name,
9827 table,
9828 kinds,
9829 columns,
9830 });
9831 }
9832 }
9833 // v7.39 (round 287) — large-object block (FILE_VERSION 78+).
9834 // Pre-78 images stop before it.
9835 if version >= 78 {
9836 let count = cur.read_u32()? as usize;
9837 for _ in 0..count {
9838 let oid = cur.read_u32()?;
9839 let len = cur.read_u32()? as usize;
9840 let bytes = cur.read_bytes(len)?;
9841 cat.large_objects.insert(oid, bytes);
9842 }
9843 }
9844 // v7.39 (round 322, V46) — function-attribute block (FILE_VERSION
9845 // 80+). Pre-80 images stop before it and keep PG's defaults.
9846 if version >= 80 {
9847 let count = cur.read_u32()? as usize;
9848 for _ in 0..count {
9849 let key = cur.read_str()?;
9850 let volatility = cur.read_u8()?;
9851 let flags = cur.read_u8()?;
9852 let parallel = cur.read_u8()?;
9853 let cost = f64::from_le_bytes(cur.read_bytes(8)?.try_into().unwrap_or([0; 8]));
9854 let rows = f64::from_le_bytes(cur.read_bytes(8)?.try_into().unwrap_or([0; 8]));
9855 if let Some(f) = cat.functions.get_mut(&key) {
9856 f.volatility = volatility;
9857 f.strict = flags & 1 != 0;
9858 f.security_definer = flags & 2 != 0;
9859 f.leakproof = flags & 4 != 0;
9860 f.parallel = parallel;
9861 f.cost = (!cost.is_nan()).then_some(cost);
9862 f.rows = (!rows.is_nan()).then_some(rows);
9863 }
9864 }
9865 }
9866 // v7.39 (round 547) — pg_db_role_setting (FILE_VERSION 85+).
9867 // Pre-85 images stop before it and carry no GUC defaults.
9868 if version >= 85 {
9869 let scopes = cur.read_u32()? as usize;
9870 for _ in 0..scopes {
9871 let db = cur.read_str()?;
9872 let role = cur.read_str()?;
9873 let params = cur.read_u32()? as usize;
9874 let mut m: BTreeMap<String, String> = BTreeMap::new();
9875 for _ in 0..params {
9876 let name = cur.read_str()?;
9877 let value = cur.read_str()?;
9878 m.insert(name, value);
9879 }
9880 if !m.is_empty() {
9881 cat.db_role_settings.insert((db, role), m);
9882 }
9883 }
9884 }
9885 // v7.39 (round 550) — replication slots (FILE_VERSION 86+).
9886 if version >= 86 {
9887 let count = cur.read_u32()? as usize;
9888 for _ in 0..count {
9889 let name = cur.read_str()?;
9890 let plugin = cur.read_str()?;
9891 let slot_type = cur.read_str()?;
9892 cat.replication_slots.insert(name, (plugin, slot_type));
9893 }
9894 }
9895 // v7.38 (read01 P5.05) — v54+ images end with a CRC32C over every
9896 // preceding byte; verify it before accepting the snapshot. Older
9897 // images have no trailer and fall through to the trailing-byte check.
9898 if version >= FILE_VERSION_CRC_TRAILER {
9899 let crc_start = cur.pos;
9900 let stored = cur.read_u32()?;
9901 let computed = spg_crypto::crc32c::crc32c(&buf[..crc_start]);
9902 if computed != stored {
9903 return Err(StorageError::Corrupt(format!(
9904 "base snapshot CRC mismatch: computed {computed:#010x}, stored {stored:#010x}"
9905 )));
9906 }
9907 }
9908 if cur.pos < buf.len() {
9909 return Err(StorageError::Corrupt(format!(
9910 "trailing bytes: {} unread",
9911 buf.len() - cur.pos
9912 )));
9913 }
9914 Ok(cat)
9915 }
9916}
9917
9918#[cfg(test)]
9919mod tests;