Skip to main content

pylon_core/schema/
mod.rs

1//
2// This source file is part of the Pylon open source project.
3//
4// Copyright (c) 2026 Jaldis B.V.
5//
6// Licensed under the MIT OR Apache-2.0 license (the "License");
7// you may not use this file except in compliance with the License.
8// You may obtain a copy of the License at
9//
10//     https://opensource.org/licenses/MIT
11//     https://www.apache.org/licenses/LICENSE-2.0
12//
13// Unless required by applicable law or agreed to in writing, software
14// distributed under the License is distributed on an "AS IS" BASIS,
15// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
16// See the License for the specific language governing permissions and
17// limitations under the License.
18//
19
20pub mod tuple_type;
21
22// ── Deletion policies ──────────────────────────────────────────────────────────
23
24#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
25pub enum DeleteSide {
26    Target,
27    Source,
28}
29
30#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
31pub enum DeleteAction {
32    Allow,
33    Restrict,
34    DeferredRestrict,
35    DeleteSource,
36    DeleteTarget,
37    DeleteTargetIfOrphan,
38}
39
40#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
41pub struct OnDeletePolicy {
42    pub side: DeleteSide,
43    pub action: DeleteAction,
44}
45
46// ── Mutation rewrites ──────────────────────────────────────────────────────────
47
48#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
49pub struct RewriteEntry {
50    /// Bitmask: 1=Insert, 2=Update, 4=Delete (mirrors Python On IntFlag).
51    pub on: u8,
52    /// PyQL expression evaluated as the new value.
53    pub handler: String,
54}
55
56// ── Post-commit signal registrations ───────────────────────────────────────────
57
58/// One `on=` registration for a type from the Python-side signal registry —
59/// just the operation bitmask, never the handler itself (the actual
60/// callable stays Python-only and never crosses into this descriptor).
61/// Combined across every handler registered for a type, this drives
62/// whether the DDL emitter attaches a capture trigger to that type's table
63/// at all (see `export::signal_trigger_infos`).
64#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
65pub struct SignalEntry {
66    /// Bitmask: 1=Insert, 2=Update, 4=Delete (mirrors Python On IntFlag,
67    /// same convention as `RewriteEntry.on`).
68    pub on: u8,
69}
70
71// ── Pointer descriptors ────────────────────────────────────────────────────────
72
73#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
74pub struct PropertyDescriptor {
75    pub name: String,
76    /// PostgreSQL column type, e.g. `text`, `int8`, `uuid`. Always a plain
77    /// base type — every read/write/cast/comparison site relies on that, so
78    /// a registered custom scalar's own DOMAIN name lives in `column_type`
79    /// instead, never here.
80    pub pg_type: String,
81    pub nullable: bool,
82    /// SQL expression for the column DEFAULT clause.
83    pub default_sql: Option<String>,
84    /// PyQL expression to be compiled to SQL at DDL-emit time.
85    /// Takes precedence over `default_sql` when both could be set (they won't be).
86    pub default_pyql: Option<String>,
87    pub description: Option<String>,
88    /// Pre-compiled SQL CHECK expressions, e.g. `"price >= 0"`.
89    pub check_constraints: Vec<String>,
90    /// True when a UNIQUE constraint applies to this column alone.
91    pub is_exclusive: bool,
92    /// True when this column is the table primary key.
93    pub is_pk: bool,
94    /// True when the transpiler should reject PyQL updates targeting this pointer.
95    pub is_readonly: bool,
96    pub rewrites: Vec<RewriteEntry>,
97    /// `Some` only for a structural `pylon.Tuple[...]`-typed property — its
98    /// element shape, for decode-time `ShapeNode` building. A *nominal*
99    /// `@pylon.named_tuple`-typed property instead carries its shape via the
100    /// `__nt__:module::Name` `pg_type` marker + `NamedTupleDescriptor.members`.
101    /// An `Array[Tuple[...]]` property carries its *element's* member shape
102    /// here, with the array-ness left to `pg_type`'s own `[]` suffix.
103    pub tuple_members: Option<Vec<TupleMemberDescriptor>>,
104    /// `Some("\"schema\".\"Name\"")` only when this property's scalar type is
105    /// a *registered* custom scalar (see `pylon.scalar(..., name=...)` /
106    /// the `@pylon.scalar` decorator form) — the schema-qualified name of
107    /// the PostgreSQL DOMAIN that scalar compiles to (see
108    /// `export::emit_scalars`). Consulted only for the column's own DDL
109    /// type (`CREATE TABLE` / `ADD COLUMN`); every other use of this
110    /// property (casts, comparisons, wire decode) keeps using `pg_type`'s
111    /// plain base type, so a domain-typed column still round-trips exactly
112    /// like its base type — Postgres enforces the domain's CHECK on writes
113    /// regardless of which type name the read/write path itself uses.
114    pub column_type: Option<String>,
115}
116
117/// A property's `pg_type` with the `__nt__:module::Name` nominal-tuple
118/// marker resolved to the type that column really has — `jsonb`, or
119/// `jsonb[]` for an `Array[SomeNamedTuple]`, which stores one jsonb tuple
120/// per element. Every other `pg_type` passes through untouched.
121pub fn resolved_pg_type(pg_type: &str) -> &str {
122    match pg_type.strip_prefix("__nt__:") {
123        Some(marker) if marker.ends_with("[]") => "jsonb[]",
124        Some(_) => "jsonb",
125        None => pg_type,
126    }
127}
128
129/// The PostgreSQL type a property's column is *declared* with — the one
130/// place that decides it, shared by the DDL emitter and the migration diff
131/// so a column and the diff's idea of that column can't drift apart.
132///
133/// Three kinds of property have a column type that isn't just `pg_type`: a
134/// tuple, which gets a composite type of its own (see `tuple_type`); a
135/// registered custom scalar, which gets its DOMAIN (`column_type`); and a
136/// nominal tuple marker, which `resolved_pg_type` resolves. `owner_module`
137/// is the module of the type declaring the property — where a structural
138/// tuple's composite type lives.
139pub fn column_ddl_type(p: &PropertyDescriptor, owner_module: &str) -> String {
140    if let Some(composite) = tuple_type::property_column_type(p, owner_module) {
141        return composite;
142    }
143    match &p.column_type {
144        Some(domain) => domain.clone(),
145        None => resolved_pg_type(&p.pg_type).to_string(),
146    }
147}
148
149#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
150pub struct LinkDescriptor {
151    pub name: String,
152    /// Qualified name of the target type, e.g. `catalog::Category`.
153    pub target: String,
154    pub nullable: bool,
155    pub description: Option<String>,
156    pub default_pyql: Option<String>,
157    /// True when a UNIQUE constraint applies to this FK column alone.
158    pub is_exclusive: bool,
159    /// True when the transpiler should reject PyQL updates targeting this pointer.
160    pub is_readonly: bool,
161    pub rewrites: Vec<RewriteEntry>,
162    pub on_delete: Vec<OnDeletePolicy>,
163    /// Qualified name of the explicit junction type, if any — when set, this
164    /// link is backed by a junction table (source, target, plus the
165    /// junction's own properties) instead of a `{name}_id` FK column on the
166    /// source table, the same storage MultiLink's own `through` already
167    /// uses, just constrained to at most one row per source.
168    pub through: Option<String>,
169}
170
171impl LinkDescriptor {
172    pub fn is_junction_backed(&self) -> bool {
173        self.through.is_some()
174    }
175}
176
177#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
178pub struct MultiLinkDescriptor {
179    pub name: String,
180    /// Qualified name of the target type.
181    pub target: String,
182    /// Qualified name of the explicit junction type, if any.
183    pub through: Option<String>,
184    pub nullable: bool,
185    pub description: Option<String>,
186    pub default_pyql: Option<String>,
187    pub on_delete: Vec<OnDeletePolicy>,
188    /// True when a target may be linked from at most one source, which makes
189    /// the backlink single.
190    #[serde(default)]
191    pub is_exclusive: bool,
192}
193
194#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
195pub struct ComputedDescriptor {
196    pub name: String,
197    /// PyQL expression evaluated at query time.
198    pub expression: String,
199    /// PostgreSQL return type, if known at schema-build time.
200    pub return_type: Option<String>,
201    /// The qualified object type a link-valued computed selects
202    /// (`Computed[Link[Email], "…"]`) — `None` for a scalar computed, whose
203    /// type is `return_type` instead. A consumer that has only the schema to
204    /// go on (the schema browser, an editor) cannot tell the two apart
205    /// otherwise: both carry a bare expression string.
206    #[serde(default)]
207    pub link_target: Option<String>,
208    /// Whether that link-valued computed selects many.
209    #[serde(default)]
210    pub link_multi: bool,
211}
212
213// ── Type-level constructs ──────────────────────────────────────────────────────
214
215// ── Search index ───────────────────────────────────────────────────────────────
216
217#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
218pub enum SearchBackend {
219    Postgres,
220    OpenSearch,
221    Meilisearch,
222}
223
224#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
225pub enum SearchWeight {
226    A,
227    B,
228    C,
229    D,
230}
231
232impl SearchWeight {
233    pub fn as_str(&self) -> &'static str {
234        match self {
235            SearchWeight::A => "A",
236            SearchWeight::B => "B",
237            SearchWeight::C => "C",
238            SearchWeight::D => "D",
239        }
240    }
241}
242
243#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
244pub struct SearchPointerDescriptor {
245    pub name: String,
246    pub weight: SearchWeight,
247}
248
249#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
250pub struct SearchIndexDescriptor {
251    pub index_name: Option<String>,
252    pub backend: SearchBackend,
253    pub pointers: Vec<SearchPointerDescriptor>,
254}
255
256impl SearchIndexDescriptor {
257    pub fn column_name(&self) -> String {
258        match &self.index_name {
259            None => "__search__".to_string(),
260            Some(name) => format!("__search_{}__", name),
261        }
262    }
263
264    /// Deferred search index name: `"module__table[__name]"` in lowercase.
265    pub fn deferred_index_name(&self, module: &str, type_name: &str) -> String {
266        let base = format!("{}__{}", module, type_name).to_lowercase();
267        match &self.index_name {
268            None => base,
269            Some(n) => format!("{}__{}", base, n.to_lowercase()),
270        }
271    }
272}
273
274/// How a partitioned type's ranges are sized.
275///
276/// Range partitioning on a time column only — the case declarative
277/// partitioning actually pays off for, and the only one that has a sensible
278/// automatic maintenance story (create the next few ranges, drop the ones
279/// past retention). List and hash partitioning need a key set known up
280/// front, which is a different feature, not a parameter of this one.
281#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
282pub enum PartitionInterval {
283    Daily,
284    Weekly,
285    Monthly,
286    Yearly,
287}
288
289impl PartitionInterval {
290    /// The PostgreSQL interval literal for one partition's width.
291    pub fn as_pg_interval(self) -> &'static str {
292        match self {
293            PartitionInterval::Daily => "1 day",
294            PartitionInterval::Weekly => "1 week",
295            PartitionInterval::Monthly => "1 month",
296            PartitionInterval::Yearly => "1 year",
297        }
298    }
299
300    pub fn as_str(self) -> &'static str {
301        match self {
302            PartitionInterval::Daily => "daily",
303            PartitionInterval::Weekly => "weekly",
304            PartitionInterval::Monthly => "monthly",
305            PartitionInterval::Yearly => "yearly",
306        }
307    }
308
309    pub fn parse(s: &str) -> Option<Self> {
310        match s {
311            "daily" => Some(PartitionInterval::Daily),
312            "weekly" => Some(PartitionInterval::Weekly),
313            "monthly" => Some(PartitionInterval::Monthly),
314            "yearly" => Some(PartitionInterval::Yearly),
315            _ => None,
316        }
317    }
318}
319
320/// Declarative range partitioning for one type, maintained by pg_partman.
321///
322/// A type carries at most one of these: a table has exactly one partition
323/// key, so a second declaration isn't a refinement, it's a contradiction.
324/// See `validate_partitions`.
325#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
326pub struct PartitionDescriptor {
327    /// The property partitioned on. Must be a non-nullable date/timestamp
328    /// property of this type — PostgreSQL requires the partition key to be
329    /// part of the primary key and to never be NULL.
330    pub pointer: String,
331    pub interval: PartitionInterval,
332    /// How many future partitions to keep pre-created. A write landing in a
333    /// range that doesn't exist yet fails, so this is the safety margin
334    /// against maintenance falling behind.
335    pub premake: u32,
336    /// Drop partitions older than this many intervals. `None` keeps
337    /// everything — the safe default, since the alternative silently deletes
338    /// data on a schedule.
339    pub retention: Option<u32>,
340}
341
342impl PartitionDescriptor {
343    /// `retention` as a PostgreSQL interval literal, for `part_config`.
344    pub fn retention_interval(&self) -> Option<String> {
345        self.retention.map(|n| match self.interval {
346            PartitionInterval::Daily => format!("{n} days"),
347            PartitionInterval::Weekly => format!("{n} weeks"),
348            PartitionInterval::Monthly => format!("{n} months"),
349            PartitionInterval::Yearly => format!("{n} years"),
350        })
351    }
352}
353
354#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
355pub struct VectorIndexDescriptor {
356    /// `None` = default (bare) index; `Some(name)` = named index.
357    pub index_name: Option<String>,
358    /// Source pointers whose text is concatenated to form the embedding input.
359    pub pointers: Vec<String>,
360    /// Embedding model identifier, e.g. `"mistral-embed"`.
361    pub model: String,
362    /// Distance metric: `"cosine"` | `"euclidean"` | `"inner_product"`.
363    pub metric: String,
364    /// Embedding dimension, e.g. `1024`.
365    pub dimensions: u32,
366}
367
368impl VectorIndexDescriptor {
369    /// PostgreSQL column name for this index's vector column.
370    pub fn column_name(&self) -> String {
371        match &self.index_name {
372            None => "__vector__".to_string(),
373            Some(name) => format!("__vector_{}__", name),
374        }
375    }
376
377    /// pgvector operator class for the configured metric.
378    pub fn ops_class(&self) -> &'static str {
379        match self.metric.as_str() {
380            "euclidean" => "vector_l2_ops",
381            "inner_product" => "vector_ip_ops",
382            _ => "vector_cosine_ops",
383        }
384    }
385}
386
387#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
388pub struct IndexDescriptor {
389    /// Pointer names for a simple or composite index; empty when is_expression=true.
390    pub pointers: Vec<String>,
391    /// PyQL expression for an expression index.
392    pub expression: Option<String>,
393    pub unique: bool,
394    /// PyQL partial-index predicate.
395    pub unless: Option<String>,
396}
397
398#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
399pub struct TriggerDescriptor {
400    /// Bitmask: 1=Insert, 2=Update, 4=Delete.
401    pub on: u8,
402    /// "Before" | "After" | "InsteadOf"
403    pub timing: String,
404    pub handler: String,
405}
406
407#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
408pub enum TypeConstraint {
409    /// Composite UNIQUE INDEX across multiple pointers.
410    Exclusive {
411        pointers: Vec<String>,
412        unless: Option<String>,
413    },
414    /// Arbitrary CHECK constraint expressed as a PyQL boolean expression.
415    Expression { expr: String },
416}
417
418// ── Type descriptor ────────────────────────────────────────────────────────────
419
420#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
421pub struct TypeDescriptor {
422    /// Unqualified name, e.g. `Product`.
423    pub name: String,
424    /// Pylon module, e.g. `catalog`.
425    pub module: String,
426    /// PostgreSQL table (or view) name, e.g. `catalog_product`.
427    pub table: String,
428    /// True for `@pylon.abstract` / `@pylon.interface`.
429    pub abstract_: bool,
430    /// True when a PostgreSQL VIEW is emitted (always true for `@pylon.interface`).
431    pub materialized: bool,
432    pub description: Option<String>,
433    /// Qualified names of abstract (non-materialized) parents.
434    pub parents: Vec<String>,
435    /// Qualified names of interface parents.
436    pub interfaces: Vec<String>,
437    /// Qualified names of the concrete types this one extends, nearest first
438    /// (`BrandAddonBundle` extends `BrandAddon`). Each has a table of its own,
439    /// and reading one of them reads this type's rows too.
440    #[serde(default)]
441    pub bases: Vec<String>,
442    /// Flattened properties (includes inherited from abstract parents).
443    pub properties: Vec<PropertyDescriptor>,
444    /// Flattened links.
445    pub links: Vec<LinkDescriptor>,
446    /// Flattened multi-links.
447    pub multilinks: Vec<MultiLinkDescriptor>,
448    /// Computed (virtual) pointers.
449    pub computed: Vec<ComputedDescriptor>,
450    /// Composite UNIQUE and CHECK constraints (own + inherited from abstract parents).
451    pub constraints: Vec<TypeConstraint>,
452    /// Non-unique indexes (own + inherited from abstract parents).
453    pub indexes: Vec<IndexDescriptor>,
454    /// Declarative range partitioning, at most one per type. `None` for an
455    /// ordinary table.
456    #[serde(default)]
457    pub partition: Option<PartitionDescriptor>,
458    /// Vector (embedding) indexes.
459    pub vector_indexes: Vec<VectorIndexDescriptor>,
460    /// Full-text search indexes.
461    pub search_indexes: Vec<SearchIndexDescriptor>,
462    /// Triggers (own + inherited from abstract parents).
463    pub triggers: Vec<TriggerDescriptor>,
464    /// True for `@pylon.junction` — type is a junction table for a MultiLink.
465    pub junction: bool,
466    /// Post-commit signal registrations from the Python-side registry — one
467    /// entry per distinct `on=` bitmask a handler was registered with (not
468    /// one per handler). Empty unless at least one signal targets this
469    /// type; drives whether a capture trigger gets attached at all.
470    pub signals: Vec<SignalEntry>,
471}
472
473// ── Scalar / enum / global descriptors ────────────────────────────────────────
474
475#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
476pub struct ScalarDescriptor {
477    /// Qualified scalar name, e.g. `default::Email`.
478    pub name: String,
479    pub module: String,
480    /// Pylon base scalar, e.g. `Str`, `Int64`.
481    pub base: String,
482    /// PostgreSQL base type, e.g. `text`, `int8`.
483    pub pg_type: String,
484    /// Pre-compiled SQL CHECK expressions for the DOMAIN constraint.
485    pub check_constraints: Vec<String>,
486    /// True when the scalar extends `pylon.Sequence` — generates a PostgreSQL SEQUENCE.
487    pub is_sequence: bool,
488}
489
490#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
491pub struct EnumDescriptor {
492    pub name: String,
493    pub module: String,
494    pub members: Vec<String>,
495}
496
497/// One member's type within a named-tuple/tuple-shaped value — recursive so a
498/// member can itself be a nested tuple. Drives decode-time shape building
499/// (`ShapeNode`) so a jsonb-backed tuple value decodes with real per-member
500/// types instead of an opaque dict/list.
501#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
502pub enum TupleMemberKind {
503    Scalar {
504        pg_type: String,
505    },
506    Enum {
507        module: String,
508        name: String,
509    },
510    /// A member typed as a registered `@pylon.named_tuple` class.
511    NamedTuple {
512        module: String,
513        name: String,
514    },
515    /// A member typed as a nested structural `pylon.Tuple[...]`.
516    Tuple {
517        members: Vec<TupleMemberDescriptor>,
518    },
519}
520
521#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
522pub struct TupleMemberDescriptor {
523    /// `None` for an unnamed/positional element of a structural tuple.
524    pub name: Option<String>,
525    pub kind: TupleMemberKind,
526}
527
528/// A registered (nominal) `@pylon.named_tuple` type — used both for cast-target
529/// resolution (`<module::Name>expr` → jsonb) and, via `members`, for decoding a
530/// value read back from a column/cast of this type with real per-member types
531/// instead of an opaque dict.
532#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
533pub struct NamedTupleDescriptor {
534    pub name: String,
535    pub module: String,
536    pub members: Vec<TupleMemberDescriptor>,
537}
538
539#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
540pub struct GlobalDescriptor {
541    pub name: String,
542    pub module: String,
543    pub scalar_type: String,
544    pub required: bool,
545    pub default_expr: Option<String>,
546    /// PyQL expression string for computed globals (e.g. `select User filter .id = global current_user_id`).
547    /// When set, the global is computed from this expression at query time rather than injected as a parameter.
548    pub computed_expr: Option<String>,
549}
550
551// ── Function descriptors ────────────────────────────────────────────────────────
552
553#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
554pub struct FunctionParamDescriptor {
555    pub name: String,
556    /// PostgreSQL type string, e.g. `int8`, `text`, `uuid`.
557    pub pg_type: String,
558}
559
560#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
561pub struct FunctionDescriptor {
562    pub name: String,
563    pub module: String,
564    pub params: Vec<FunctionParamDescriptor>,
565    /// For scalar returns: the PG type (e.g. `int8`).
566    /// For object returns: the qualified type name (e.g. `default::Account`).
567    pub return_pg_type: String,
568    pub return_is_object: bool,
569    /// True when the return is `set[T]`.
570    pub return_is_set: bool,
571    /// True when the return type is a polymorphic interface (abstract + materialized).
572    pub return_is_polymorphic: bool,
573    /// "immutable" | "stable" | "volatile"
574    pub volatility: String,
575    /// PyQL expression string (the function body from the docstring).
576    pub body: String,
577}
578
579// ── Alias descriptor ───────────────────────────────────────────────────────────
580
581#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
582pub struct AliasDescriptor {
583    pub name: String,
584    pub module: String,
585    /// PyQL expression string (the alias body).
586    pub expr: String,
587}
588
589// ── Channel descriptor ──────────────────────────────────────────────────────────
590
591/// The shape of a `Channel`'s payload, as declared in the schema DSL.
592#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
593pub enum ChannelPayload {
594    /// A registered object type, by its qualified name (e.g. `default::User`) —
595    /// `notify()` sends the object, `listen()` decodes into that type.
596    Type(String),
597    /// A plain scalar's Postgres type (e.g. `text`, `int8`).
598    Scalar(String),
599    /// An ad hoc named-field payload (`pylon.Object(...)`) — field name to
600    /// scalar Postgres type, in declaration order. No backing table; decodes
601    /// client-side into a `pylon.Object` instance.
602    Object(Vec<(String, String)>),
603}
604
605/// A PostgreSQL pub/sub channel (`NOTIFY`/`LISTEN`) declared in the schema.
606/// Has zero physical DDL footprint — nothing here ever produces a DDL step
607/// in `diff_schema_steps`; its mere presence in a schema is exactly the kind
608/// of content-only change `schema_content_changed` exists to catch.
609#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
610pub struct ChannelDescriptor {
611    pub name: String,
612    pub module: String,
613    /// The actual PostgreSQL NOTIFY/LISTEN channel identifier. Unlike every
614    /// other named construct in this descriptor, Postgres channels have no
615    /// schema namespacing at all — a flat, database-wide identifier — so
616    /// this is already fully disambiguated (module folded in) by the time
617    /// it gets here; see `pylon.schema._channels.wire_name_for_channel`.
618    pub wire_name: String,
619    pub payload: ChannelPayload,
620    pub description: Option<String>,
621}
622
623// ── Top-level schema ───────────────────────────────────────────────────────────
624
625#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
626pub struct SchemaDescriptor {
627    pub types: Vec<TypeDescriptor>,
628    pub scalars: Vec<ScalarDescriptor>,
629    pub enums: Vec<EnumDescriptor>,
630    pub named_tuples: Vec<NamedTupleDescriptor>,
631    pub globals: Vec<GlobalDescriptor>,
632    pub functions: Vec<FunctionDescriptor>,
633    pub aliases: Vec<AliasDescriptor>,
634    pub channels: Vec<ChannelDescriptor>,
635    /// `ir::functions_needing_globals`, which compiles every function body
636    /// and is asked for by every compile that calls one. Filled on first use,
637    /// so a schema must not change once it has been compiled against.
638    #[serde(skip)]
639    pub functions_needing_globals: Derived<std::collections::HashSet<String>>,
640}
641
642/// A value computed from the schema on first use. A clone starts out empty,
643/// so a copy that is then changed never reads the original's value.
644#[derive(Debug)]
645pub struct Derived<T>(std::sync::OnceLock<T>);
646
647impl<T> Derived<T> {
648    pub fn get_or_init(&self, init: impl FnOnce() -> T) -> &T {
649        self.0.get_or_init(init)
650    }
651}
652
653impl<T> Default for Derived<T> {
654    fn default() -> Self {
655        Derived(std::sync::OnceLock::new())
656    }
657}
658
659impl<T> Clone for Derived<T> {
660    fn clone(&self) -> Self {
661        Derived::default()
662    }
663}
664
665impl SchemaDescriptor {
666    /// Find a declared `Channel` by bare or `module::name` reference — the
667    /// single lookup both `notify()` (`ir::compiler::Compiler::resolve_channel`)
668    /// and `pylon-client`'s `Client::listen()` resolve a channel argument
669    /// through, so a schema author's `notify(Foo, ...)` and a client's
670    /// `listen("Foo")` agree on exactly the same name.
671    pub fn find_channel(&self, name: &str) -> Option<&ChannelDescriptor> {
672        self.channels
673            .iter()
674            .find(|c| c.name == name || format!("{}::{}", c.module, c.name) == name)
675    }
676}
677
678#[cfg(test)]
679mod tests {
680    use super::*;
681
682    fn schema_with_one_channel() -> SchemaDescriptor {
683        SchemaDescriptor {
684            channels: vec![ChannelDescriptor {
685                name: "Pings".into(),
686                module: "shop".into(),
687                wire_name: "shop__pings".into(),
688                payload: ChannelPayload::Scalar("text".into()),
689                description: None,
690            }],
691            ..Default::default()
692        }
693    }
694
695    #[test]
696    fn find_channel_matches_bare_name() {
697        let schema = schema_with_one_channel();
698        assert_eq!(
699            schema.find_channel("Pings").map(|c| c.wire_name.as_str()),
700            Some("shop__pings")
701        );
702    }
703
704    #[test]
705    fn find_channel_matches_qualified_name() {
706        let schema = schema_with_one_channel();
707        assert_eq!(
708            schema.find_channel("shop::Pings").map(|c| c.wire_name.as_str()),
709            Some("shop__pings")
710        );
711    }
712
713    #[test]
714    fn find_channel_returns_none_for_unknown_name() {
715        let schema = schema_with_one_channel();
716        assert!(schema.find_channel("NoSuchChannel").is_none());
717    }
718}