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