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