Skip to main content

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}