axon_frontend/store_schema.rs
1//! v1.31.0 (D1) — the closed `axonstore` column-schema catalog,
2//! Rust frontend side.
3//!
4//! Three closed forms an `axonstore` may declare its column schema in:
5//!
6//! - **Inline** — `schema { col: Type [constraint…], … }`. The column
7//! schema lives in source. Use case: small static schemas, the
8//! schema that ships with the application source.
9//! - **Manifest reference** — `schema: "qualified.name"`. The column
10//! schema lives in a checked-in `.axon-schema.yml` (or
11//! `.axon-schema.json`) manifest, referenced by qualified name. Use
12//! case: large schemas, schemas captured by `axon store introspect`
13//! against an existing database.
14//! - **Per-tenant env-var schema namespace** — `schema: env:VAR` (or
15//! quoted `schema: "env:VAR"`). The schema NAMESPACE (e.g.
16//! `tenant_42`) is resolved at deploy time from the named
17//! environment variable; the columns themselves come from a
18//! manifest entry keyed on the resolved namespace + table name.
19//! Use case: schema-per-tenant topology.
20//!
21//! This module defines the AST surface only — the type-checker proof
22//! against these declarations lives in v1.31.0 / v1.31.0 (the
23//! `StoreColumnProof` pass), shipping in subsequent steps.
24//!
25//! Mirror: `axon/compiler/ast_nodes.py` (`StoreSchemaNode`,
26//! `StoreColumnNode`) — the Python frontend has carried an
27//! inline-form-only surface as forward-compat dead code since v1.30.0;
28//! v1.31.0 makes both sides authoritative, brings the Rust side to
29//! parity, and adds the new manifest-ref + env-var forms cross-stack.
30
31use crate::tokens::Trivia;
32
33// ════════════════════════════════════════════════════════════════════
34// D1 — the closed 15-type catalog (compile-time mirror of the v1.30.0
35// `PgTypeClass` runtime catalog)
36// ════════════════════════════════════════════════════════════════════
37
38/// The closed column-type catalog an `axonstore` may declare a column
39/// as. Mirrors the v1.30.0 [`crate::ir_nodes::IRStoreColumnType`]
40/// surface and the Postgres runtime's `PgTypeClass` (in
41/// `axon-rs/src/store/postgres_backend.rs`) one-for-one.
42///
43/// Source-level surface accepts both the canonical PascalCase name
44/// AND a small set of common lowercase aliases (`int` for `Int`,
45/// `boolean` for `Bool`, `integer` for `Int`, …) — see
46/// [`StoreColumnType::from_token`]. The AST always carries the
47/// canonical PascalCase variant; the alias is normalized at parse
48/// time.
49///
50/// A column whose declared type is OUTSIDE this catalog is a parse
51/// error at `axon check` time with a precise message + Levenshtein
52/// suggestions. The honest-scope boundary is named: Postgres types
53/// outside the catalog — `enum`, `domain`, array, `citext`, PostGIS
54/// `geometry`, custom composites — remain `UnsupportedColumnType`,
55/// tracked for the v1.31.0+ "broaden the catalog" follow-on.
56#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
57pub enum StoreColumnType {
58 Uuid,
59 Text,
60 Int,
61 BigInt,
62 Float,
63 Double,
64 Bool,
65 Timestamptz,
66 Timestamp,
67 Date,
68 Time,
69 Jsonb,
70 Json,
71 Bytea,
72 Numeric,
73}
74
75impl StoreColumnType {
76 /// The closed catalog, in canonical declaration order — useful for
77 /// exhaustive iteration in tests + the smart-suggest dictionary.
78 pub const ALL: &'static [StoreColumnType] = &[
79 StoreColumnType::Uuid,
80 StoreColumnType::Text,
81 StoreColumnType::Int,
82 StoreColumnType::BigInt,
83 StoreColumnType::Float,
84 StoreColumnType::Double,
85 StoreColumnType::Bool,
86 StoreColumnType::Timestamptz,
87 StoreColumnType::Timestamp,
88 StoreColumnType::Date,
89 StoreColumnType::Time,
90 StoreColumnType::Jsonb,
91 StoreColumnType::Json,
92 StoreColumnType::Bytea,
93 StoreColumnType::Numeric,
94 ];
95
96 /// The canonical PascalCase declaration name — exactly what an
97 /// adopter writes in source and exactly what the IR / manifest
98 /// serializes as. Stable surface — adopters tooling can rely on it.
99 pub fn canonical_name(self) -> &'static str {
100 match self {
101 StoreColumnType::Uuid => "Uuid",
102 StoreColumnType::Text => "Text",
103 StoreColumnType::Int => "Int",
104 StoreColumnType::BigInt => "BigInt",
105 StoreColumnType::Float => "Float",
106 StoreColumnType::Double => "Double",
107 StoreColumnType::Bool => "Bool",
108 StoreColumnType::Timestamptz => "Timestamptz",
109 StoreColumnType::Timestamp => "Timestamp",
110 StoreColumnType::Date => "Date",
111 StoreColumnType::Time => "Time",
112 StoreColumnType::Jsonb => "Jsonb",
113 StoreColumnType::Json => "Json",
114 StoreColumnType::Bytea => "Bytea",
115 StoreColumnType::Numeric => "Numeric",
116 }
117 }
118
119 /// Parse a source-level token (an identifier or keyword) into a
120 /// catalog variant. Accepts the canonical name AND a small set of
121 /// common aliases — case-insensitive at the level of the alias
122 /// table to maximise ergonomics, but the AST always carries the
123 /// canonical variant so the IR is deterministic.
124 ///
125 /// Aliases (D5 ergonomic floor — not load-bearing, not promised in
126 /// the public contract; the canonical name is the supported form):
127 ///
128 /// - `int`, `integer`, `int4` → `Int`
129 /// - `bigint`, `int8` → `BigInt`
130 /// - `bool`, `boolean` → `Bool`
131 /// - `text`, `varchar`, `string` → `Text`
132 /// - `uuid` → `Uuid`
133 /// - `float`, `float4`, `real` → `Float`
134 /// - `double`, `float8` → `Double`
135 /// - `timestamptz` → `Timestamptz`
136 /// - `timestamp` → `Timestamp`
137 /// - `date` → `Date`
138 /// - `time` → `Time`
139 /// - `jsonb` → `Jsonb`
140 /// - `json` → `Json`
141 /// - `bytea` → `Bytea`
142 /// - `numeric`, `decimal` → `Numeric`
143 ///
144 /// Anything else returns `None` — the parser surfaces it as an
145 /// `axon-T8xx`-class error with the closed-catalog list.
146 pub fn from_token(name: &str) -> Option<StoreColumnType> {
147 // Canonical (PascalCase) lookup first — exact-match.
148 for &t in Self::ALL {
149 if t.canonical_name() == name {
150 return Some(t);
151 }
152 }
153 // Alias table — case-insensitive on the source token.
154 match name.to_ascii_lowercase().as_str() {
155 "int" | "integer" | "int4" => Some(StoreColumnType::Int),
156 "bigint" | "int8" => Some(StoreColumnType::BigInt),
157 "bool" | "boolean" => Some(StoreColumnType::Bool),
158 "text" | "varchar" | "string" => Some(StoreColumnType::Text),
159 "uuid" => Some(StoreColumnType::Uuid),
160 "float" | "float4" | "real" => Some(StoreColumnType::Float),
161 "double" | "float8" => Some(StoreColumnType::Double),
162 "timestamptz" => Some(StoreColumnType::Timestamptz),
163 "timestamp" => Some(StoreColumnType::Timestamp),
164 "date" => Some(StoreColumnType::Date),
165 "time" => Some(StoreColumnType::Time),
166 "jsonb" => Some(StoreColumnType::Jsonb),
167 "json" => Some(StoreColumnType::Json),
168 "bytea" => Some(StoreColumnType::Bytea),
169 "numeric" | "decimal" => Some(StoreColumnType::Numeric),
170 _ => None,
171 }
172 }
173
174 /// All canonical names — useful for the smart-suggest dictionary
175 /// when the parser rejects an unknown type.
176 pub fn all_canonical_names() -> Vec<&'static str> {
177 Self::ALL.iter().map(|t| t.canonical_name()).collect()
178 }
179}
180
181impl std::fmt::Display for StoreColumnType {
182 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
183 f.write_str(self.canonical_name())
184 }
185}
186
187// ════════════════════════════════════════════════════════════════════
188// AST nodes — inline schema form
189// ════════════════════════════════════════════════════════════════════
190
191/// One column entry in an inline `schema { col: Type [constraint…], … }`
192/// block.
193///
194/// The closed-set constraint vocabulary is shared with the Python AST
195/// (`StoreColumnNode`): `primary_key`, `auto_increment`, `not_null`,
196/// `unique`, `default <literal>`.
197#[derive(Debug, Clone)]
198pub struct StoreColumn {
199 pub name: String,
200 pub col_type: StoreColumnType,
201 pub primary_key: bool,
202 pub auto_increment: bool,
203 pub not_null: bool,
204 pub unique: bool,
205 /// Literal default value, source-text verbatim. The runtime does
206 /// not interpolate; the database supplies the default. Empty when
207 /// no `default …` constraint is declared.
208 pub default_value: String,
209 /// v1.31.0 (D2) — `true` iff this column is declared with
210 /// `GENERATED ALWAYS AS IDENTITY` or `GENERATED BY DEFAULT AS
211 /// IDENTITY` in the live database (`pg_attribute.attidentity` is
212 /// `'a'` or `'d'`). Distinct from `auto_increment` (which marks
213 /// the legacy SERIAL pattern via a `nextval(...)` default
214 /// expression). T803 treats an `identity` column as safe-to-omit
215 /// from a `persist` because Postgres auto-fills it.
216 ///
217 /// Backwards-compatibility (D5): the field defaults to `false`,
218 /// matching v1.38.2 behavior for every column. A manifest written
219 /// against v1.38.2 round-trips byte-identically.
220 pub identity: bool,
221 /// v2.26.0 (D1) — `true` iff the column is declared with the
222 /// `index` constraint, e.g. `payload: Json index`. This is the
223 /// program-visible declaration of an index as a CAPABILITY-HONEST
224 /// EFFECT: the index is part of the source the deploy gate sees, never
225 /// a silent out-of-band DBA action (doctrine: infrastructure is
226 /// visible to the program). The index METHOD is chosen by the backend
227 /// from the column type — a `Json`/`Jsonb` column gets a Postgres GIN
228 /// path index (`USING gin (col jsonb_path_ops)`), any other column a
229 /// plain b-tree. Defaults to `false` — a pre-v2.26.0 manifest round-trips
230 /// byte-identically.
231 pub indexed: bool,
232 /// v2.26.0 (D1) — the OPTIONAL shape LENS on a `Json` / `Jsonb`
233 /// column: `payload: Json<UserEvent>` records `Some("UserEvent")`,
234 /// a bare `payload: Json` records `None`. The lens is a COMPILE-TIME
235 /// expectation only — the column's physical type stays `jsonb` and
236 /// the runtime navigates it totally regardless (a declared-but-absent
237 /// field degrades to null, never crashes; doctrine
238 /// `open_data_is_total`). The type-checker validates that the named
239 /// shape is a declared struct `type` (`axon-T840`); the parser
240 /// rejects a `<T>` on any non-`Json` column type (`axon-T841`). Only
241 /// ever `Some(_)` for `StoreColumnType::{Json, Jsonb}`. Defaults to
242 /// `None` — a v1.38–1.23 manifest round-trips byte-identically.
243 pub json_shape: Option<String>,
244 pub line: u32,
245 pub column: u32,
246}
247
248/// v2.48.0 — the FIXED, compiler-synthesized column schema of a
249/// `backend: secrets` metadata store (doctrine
250/// `rotation_without_revelation`). A secrets-backed axonstore is a
251/// read-only METADATA view over the tenant's secret custody: the four
252/// columns below are everything a flow may ever see — the secret VALUE
253/// has no column, no type, and no term that evaluates to it. Declaring
254/// an explicit `schema` on a secrets store is `axon-T900` (the shape is
255/// law, not adopter choice); write verbs against it are `axon-T897`.
256///
257/// `expires_at` is nullable BY DESIGN: expiry is declared metadata
258/// (`time_is_an_explicit_input`), written by the seeder or the rotation
259/// commit — a secret without a declared expiry simply never matches an
260/// `expires_at <` filter.
261pub fn secrets_metadata_schema(line: u32, column: u32) -> StoreColumnSchema {
262 let col = |name: &str, col_type: StoreColumnType, not_null: bool| StoreColumn {
263 name: name.to_string(),
264 col_type,
265 primary_key: false,
266 auto_increment: false,
267 not_null,
268 unique: false,
269 default_value: String::new(),
270 identity: false,
271 indexed: false,
272 json_shape: None,
273 line,
274 column,
275 };
276 StoreColumnSchema::Inline {
277 columns: vec![
278 col("key", StoreColumnType::Text, true),
279 col("version", StoreColumnType::Int, true),
280 col("created_at", StoreColumnType::Timestamptz, true),
281 col("expires_at", StoreColumnType::Timestamptz, false),
282 ],
283 leading_trivia: Vec::new(),
284 line,
285 column,
286 }
287}
288
289// ════════════════════════════════════════════════════════════════════
290// AST node — the three closed `schema:` declaration forms
291// ════════════════════════════════════════════════════════════════════
292
293/// v1.31.0 (D1) — the three closed forms an `axonstore` may declare
294/// its column schema in. The AST captures the form; the v1.31.0 / v1.31.0
295/// `StoreColumnProof` pass consumes the resolved column set (regardless
296/// of form) and proves every store reference against it.
297///
298/// `pub` so consumers (the type-checker, the runtime registry, the LSP)
299/// can match exhaustively. Variants are `#[non_exhaustive]`-style only
300/// at the doc level — additions go through a plan ratification per the
301/// founder discipline.
302#[derive(Debug, Clone)]
303pub enum StoreColumnSchema {
304 /// Form (a) — `schema { col: Type [constraint…], … }`.
305 Inline {
306 columns: Vec<StoreColumn>,
307 /// Trivia attached to the opening `schema` keyword.
308 leading_trivia: Vec<Trivia>,
309 line: u32,
310 column: u32,
311 },
312 /// Form (b) — `schema: "qualified.name"`. The qualified name
313 /// resolves against a checked-in manifest entry (`.axon-schema.yml`
314 /// / `.axon-schema.json`) at `axon check` time.
315 ManifestRef {
316 qualified_name: String,
317 line: u32,
318 column: u32,
319 },
320 /// Form (c) — `schema: env:VAR` (or quoted `schema: "env:VAR"`).
321 /// The env-var resolves to the schema NAMESPACE at deploy time;
322 /// the manifest then provides the column set for `<namespace>.<table>`.
323 EnvVar {
324 /// The env-var name (no `env:` prefix; the prefix was stripped
325 /// at parse time).
326 var_name: String,
327 line: u32,
328 column: u32,
329 },
330}
331
332impl StoreColumnSchema {
333 /// `true` iff this is the inline form. Convenience for the
334 /// v1.31.0 / v1.31.0 type-checker, which can short-circuit a manifest
335 /// lookup when the columns are already in the AST.
336 pub fn is_inline(&self) -> bool {
337 matches!(self, StoreColumnSchema::Inline { .. })
338 }
339
340 /// Returns the inline columns when the form is inline; `None`
341 /// otherwise. The type-checker uses this to obtain the column
342 /// set without a manifest round-trip.
343 pub fn inline_columns(&self) -> Option<&[StoreColumn]> {
344 match self {
345 StoreColumnSchema::Inline { columns, .. } => Some(columns),
346 _ => None,
347 }
348 }
349
350 /// The source location of the `schema` keyword, for diagnostic
351 /// rendering (the v1.20.0 source-context block points at this).
352 pub fn loc(&self) -> (u32, u32) {
353 match self {
354 StoreColumnSchema::Inline { line, column, .. }
355 | StoreColumnSchema::ManifestRef { line, column, .. }
356 | StoreColumnSchema::EnvVar { line, column, .. } => (*line, *column),
357 }
358 }
359
360 /// A short form name (`"inline"` / `"manifest_ref"` / `"env_var"`)
361 /// for diagnostic prose + the IR's tagged-union serialization.
362 pub fn form_name(&self) -> &'static str {
363 match self {
364 StoreColumnSchema::Inline { .. } => "inline",
365 StoreColumnSchema::ManifestRef { .. } => "manifest_ref",
366 StoreColumnSchema::EnvVar { .. } => "env_var",
367 }
368 }
369}
370
371// ════════════════════════════════════════════════════════════════════
372// Unit tests — the closed catalog + parse/canonical-form discipline
373// ════════════════════════════════════════════════════════════════════
374
375#[cfg(test)]
376mod tests {
377 use super::*;
378
379 #[test]
380 fn catalog_has_exactly_15_variants() {
381 // The plan-vivo section 4 D1 commits to exactly 15 types. A future
382 // catalog broadening goes through a plan ratification — this
383 // pin catches an accidental addition.
384 assert_eq!(StoreColumnType::ALL.len(), 15);
385 }
386
387 #[test]
388 fn every_variant_has_a_unique_canonical_name() {
389 let mut names: Vec<&'static str> =
390 StoreColumnType::ALL.iter().map(|t| t.canonical_name()).collect();
391 names.sort();
392 let total = names.len();
393 names.dedup();
394 assert_eq!(
395 names.len(),
396 total,
397 "canonical names must be unique across the catalog"
398 );
399 }
400
401 #[test]
402 fn every_canonical_name_parses_back_to_its_variant() {
403 for &t in StoreColumnType::ALL {
404 assert_eq!(
405 StoreColumnType::from_token(t.canonical_name()),
406 Some(t),
407 "{} did not round-trip",
408 t.canonical_name()
409 );
410 }
411 }
412
413 #[test]
414 fn common_aliases_resolve_to_the_canonical_variant() {
415 for (alias, expected) in [
416 ("int", StoreColumnType::Int),
417 ("integer", StoreColumnType::Int),
418 ("int4", StoreColumnType::Int),
419 ("bigint", StoreColumnType::BigInt),
420 ("int8", StoreColumnType::BigInt),
421 ("bool", StoreColumnType::Bool),
422 ("boolean", StoreColumnType::Bool),
423 ("text", StoreColumnType::Text),
424 ("varchar", StoreColumnType::Text),
425 ("string", StoreColumnType::Text),
426 ("uuid", StoreColumnType::Uuid),
427 ("float", StoreColumnType::Float),
428 ("real", StoreColumnType::Float),
429 ("double", StoreColumnType::Double),
430 ("float8", StoreColumnType::Double),
431 ("numeric", StoreColumnType::Numeric),
432 ("decimal", StoreColumnType::Numeric),
433 ("timestamptz", StoreColumnType::Timestamptz),
434 ("timestamp", StoreColumnType::Timestamp),
435 ("date", StoreColumnType::Date),
436 ("time", StoreColumnType::Time),
437 ("jsonb", StoreColumnType::Jsonb),
438 ("json", StoreColumnType::Json),
439 ("bytea", StoreColumnType::Bytea),
440 ] {
441 assert_eq!(
442 StoreColumnType::from_token(alias),
443 Some(expected),
444 "alias `{alias}` did not resolve to `{}`",
445 expected.canonical_name()
446 );
447 }
448 }
449
450 #[test]
451 fn alias_lookup_is_case_insensitive_on_the_alias_table() {
452 // Adopter ergonomics — the alias table tolerates case.
453 // (Canonical names match exact-case; aliases are case-insensitive.)
454 assert_eq!(StoreColumnType::from_token("INTEGER"), Some(StoreColumnType::Int));
455 assert_eq!(StoreColumnType::from_token("Boolean"), Some(StoreColumnType::Bool));
456 assert_eq!(StoreColumnType::from_token("UUID"), Some(StoreColumnType::Uuid));
457 }
458
459 #[test]
460 fn unknown_type_names_return_none() {
461 for unknown in [
462 "Money", "Interval", "Cidr", "Inet", "Macaddr", "Geometry",
463 "enum", "domain", "citext", "array", "anything", "", " ",
464 "Tier", "MyCustomType",
465 ] {
466 assert_eq!(
467 StoreColumnType::from_token(unknown),
468 None,
469 "unknown type `{unknown}` must not resolve"
470 );
471 }
472 }
473
474 #[test]
475 fn display_is_canonical_name() {
476 for &t in StoreColumnType::ALL {
477 assert_eq!(t.to_string(), t.canonical_name());
478 }
479 }
480
481 #[test]
482 fn schema_form_names_are_the_three_closed_forms() {
483 let inline = StoreColumnSchema::Inline {
484 columns: vec![],
485 leading_trivia: vec![],
486 line: 0,
487 column: 0,
488 };
489 let manifest_ref = StoreColumnSchema::ManifestRef {
490 qualified_name: "public.tenants".into(),
491 line: 0,
492 column: 0,
493 };
494 let env_var = StoreColumnSchema::EnvVar {
495 var_name: "TENANT_SCHEMA".into(),
496 line: 0,
497 column: 0,
498 };
499 assert_eq!(inline.form_name(), "inline");
500 assert_eq!(manifest_ref.form_name(), "manifest_ref");
501 assert_eq!(env_var.form_name(), "env_var");
502 assert!(inline.is_inline());
503 assert!(!manifest_ref.is_inline());
504 assert!(!env_var.is_inline());
505 assert!(inline.inline_columns().is_some());
506 assert!(manifest_ref.inline_columns().is_none());
507 assert!(env_var.inline_columns().is_none());
508 }
509}