Skip to main content

pylon_kernel/
lib.rs

1use std::fmt;
2
3use serde::{Deserialize, Serialize};
4
5pub mod clock;
6pub mod errors;
7pub mod studio;
8pub mod util;
9
10pub use clock::{Clock, MockClock, SystemClock};
11pub use studio::StudioConfig;
12
13pub const VERSION: &str = env!("CARGO_PKG_VERSION");
14
15// ---------------------------------------------------------------------------
16// Exit codes
17// ---------------------------------------------------------------------------
18
19#[derive(Debug, Clone, Copy, PartialEq, Eq)]
20pub enum ExitCode {
21    Ok = 0,
22    Error = 1,
23    Usage = 64,
24    Unavailable = 69,
25}
26
27impl ExitCode {
28    pub const fn as_i32(self) -> i32 {
29        self as i32
30    }
31}
32
33// ---------------------------------------------------------------------------
34// Severity & Span — shared diagnostic primitives
35// ---------------------------------------------------------------------------
36
37#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
38#[serde(rename_all = "lowercase")]
39pub enum Severity {
40    Error,
41    Warning,
42    Info,
43}
44
45impl fmt::Display for Severity {
46    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
47        match self {
48            Severity::Error => f.write_str("error"),
49            Severity::Warning => f.write_str("warning"),
50            Severity::Info => f.write_str("info"),
51        }
52    }
53}
54
55/// Optional source location for a diagnostic.
56#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
57pub struct Span {
58    pub file: String,
59    #[serde(skip_serializing_if = "Option::is_none")]
60    pub line: Option<u32>,
61    #[serde(skip_serializing_if = "Option::is_none")]
62    pub column: Option<u32>,
63}
64
65// ---------------------------------------------------------------------------
66// Diagnostic — structured, machine-readable error/warning
67// ---------------------------------------------------------------------------
68
69#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
70pub struct Diagnostic {
71    pub severity: Severity,
72    pub code: String,
73    pub message: String,
74    #[serde(skip_serializing_if = "Option::is_none")]
75    pub span: Option<Span>,
76    #[serde(skip_serializing_if = "Option::is_none")]
77    pub hint: Option<String>,
78}
79
80impl fmt::Display for Diagnostic {
81    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
82        write!(f, "[{}] {}: {}", self.severity, self.code, self.message)?;
83        if let Some(hint) = &self.hint {
84            write!(f, " (hint: {hint})")?;
85        }
86        Ok(())
87    }
88}
89
90// ---------------------------------------------------------------------------
91// AppManifest — canonical manifest shape
92// ---------------------------------------------------------------------------
93
94pub const MANIFEST_VERSION: u32 = 1;
95
96#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
97pub struct AppManifest {
98    pub manifest_version: u32,
99    pub name: String,
100    pub version: String,
101    pub entities: Vec<ManifestEntity>,
102    pub routes: Vec<ManifestRoute>,
103    #[serde(default)]
104    pub queries: Vec<ManifestQuery>,
105    #[serde(default)]
106    pub actions: Vec<ManifestAction>,
107    #[serde(default)]
108    pub policies: Vec<ManifestPolicy>,
109    /// App-level auth configuration. Mirrors better-auth's
110    /// `betterAuth({ user, session, trustedOrigins })` shape — controls
111    /// the manifest entity name pylon treats as the User table, which
112    /// fields get exposed via `/api/auth/session`, the cookie claims
113    /// cache, and per-app trusted origins.
114    ///
115    /// Defaults are sensible (`User` entity, hide `passwordHash`,
116    /// 30-day sessions, no cookie cache, trusted-origins from
117    /// `PYLON_TRUSTED_ORIGINS` env) so apps that don't define an
118    /// `auth({...})` block in app.ts still work.
119    #[serde(default)]
120    pub auth: ManifestAuthConfig,
121}
122
123/// Pylon's auth configuration block — emitted by the SDK's
124/// `auth({...})` factory in app.ts. All fields optional; missing
125/// values fall back to framework defaults.
126#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
127pub struct ManifestAuthConfig {
128    #[serde(default)]
129    pub user: ManifestAuthUserConfig,
130    #[serde(default)]
131    pub session: ManifestAuthSessionConfig,
132    /// Per-app trusted origins for OAuth `?callback=` validation.
133    /// Merged with anything in `PYLON_TRUSTED_ORIGINS` env.
134    #[serde(default, skip_serializing_if = "Vec::is_empty")]
135    pub trusted_origins: Vec<String>,
136}
137
138#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
139pub struct ManifestAuthUserConfig {
140    /// Manifest entity name pylon treats as the User table.
141    /// Default `"User"` — the convention every existing pylon app
142    /// already follows.
143    #[serde(default = "default_user_entity")]
144    pub entity: String,
145    /// Optional allowlist of fields exposed via `/api/auth/session`.
146    /// When set, ONLY these fields appear in the response (`id` is
147    /// always included). Useful for apps that want strict schemas.
148    #[serde(default, skip_serializing_if = "Vec::is_empty")]
149    pub expose: Vec<String>,
150    /// Additional fields to strip from the User row before responding.
151    /// Combined with the framework defaults (`passwordHash` plus
152    /// anything starting with `_`). Use this for app-specific
153    /// secrets stored on the User row.
154    #[serde(default, skip_serializing_if = "Vec::is_empty")]
155    pub hide: Vec<String>,
156}
157
158impl Default for ManifestAuthUserConfig {
159    fn default() -> Self {
160        Self {
161            entity: default_user_entity(),
162            expose: Vec::new(),
163            hide: Vec::new(),
164        }
165    }
166}
167
168fn default_user_entity() -> String {
169    "User".into()
170}
171
172#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
173pub struct ManifestAuthSessionConfig {
174    /// Lifetime of new sessions in seconds. Default 30 days.
175    #[serde(default = "default_session_lifetime")]
176    pub expires_in: u64,
177    /// Cookie cache config — bakes the listed claims into the cookie
178    /// itself so `/api/auth/me`-style probes can resolve identity
179    /// without a session-store lookup. Mirrors better-auth's
180    /// `session.cookieCache`.
181    #[serde(default)]
182    pub cookie_cache: ManifestAuthCookieCacheConfig,
183}
184
185impl Default for ManifestAuthSessionConfig {
186    fn default() -> Self {
187        Self {
188            expires_in: default_session_lifetime(),
189            cookie_cache: ManifestAuthCookieCacheConfig::default(),
190        }
191    }
192}
193
194fn default_session_lifetime() -> u64 {
195    30 * 24 * 60 * 60
196}
197
198/// Cookie-cache settings. When `enabled`, the session cookie carries
199/// a signed JWT-style envelope including the claims listed in
200/// `claims` (defaults to `is_admin` + `tenant_id`). Cookie reads
201/// resolve identity without touching the session store, at the cost
202/// of staleness up to `max_age` seconds.
203#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
204pub struct ManifestAuthCookieCacheConfig {
205    #[serde(default)]
206    pub enabled: bool,
207    /// Max age of the cached claims in seconds. After this, the
208    /// cookie envelope is treated as expired and the session store
209    /// is consulted again. Default 5 minutes — same as better-auth.
210    #[serde(default = "default_cookie_cache_max_age")]
211    pub max_age: u64,
212    /// Auth-context fields baked into the cookie envelope. Always
213    /// includes `user_id`; the operator opts in to anything else.
214    #[serde(default = "default_cookie_cache_claims")]
215    pub claims: Vec<String>,
216}
217
218impl Default for ManifestAuthCookieCacheConfig {
219    fn default() -> Self {
220        Self {
221            enabled: false,
222            max_age: default_cookie_cache_max_age(),
223            claims: default_cookie_cache_claims(),
224        }
225    }
226}
227
228fn default_cookie_cache_max_age() -> u64 {
229    5 * 60
230}
231
232fn default_cookie_cache_claims() -> Vec<String> {
233    vec!["is_admin".into(), "tenant_id".into()]
234}
235
236#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
237pub struct ManifestEntity {
238    pub name: String,
239    pub fields: Vec<ManifestField>,
240    pub indexes: Vec<ManifestIndex>,
241    #[serde(default, skip_serializing_if = "Vec::is_empty")]
242    pub relations: Vec<ManifestRelation>,
243    /// Opt-in faceted search config. `None` = entity isn't searchable;
244    /// `Some(cfg)` makes the runtime create FTS5 + facet-bitmap shadow
245    /// tables on schema push and maintain them on every write.
246    #[serde(default, skip_serializing_if = "Option::is_none")]
247    pub search: Option<ManifestSearchConfig>,
248    /// Local-first / CRDT mode. Default `true` — every entity is backed
249    /// by a Loro doc, mutations merge as CRDTs, multi-device offline
250    /// edits converge cleanly. Set `false` to opt out per entity (audit
251    /// logs, append-only archives, anything that doesn't need offline
252    /// merge and where you want to skip the per-write Loro overhead).
253    /// The SQLite-projected row shape is identical either way; queries
254    /// and indexes don't change between modes.
255    #[serde(default = "default_crdt_enabled")]
256    pub crdt: bool,
257}
258
259fn default_crdt_enabled() -> bool {
260    true
261}
262
263impl Default for ManifestEntity {
264    fn default() -> Self {
265        Self {
266            name: String::new(),
267            fields: Vec::new(),
268            indexes: Vec::new(),
269            relations: Vec::new(),
270            search: None,
271            crdt: true,
272        }
273    }
274}
275
276/// Per-entity search declaration. Lives on the manifest so both the
277/// storage layer (schema push) and the runtime (write-time maintenance
278/// + query endpoints) read the same shape.
279///
280/// Kept in `pylon-kernel` intentionally — other crates depend on kernel
281/// but not on each other, so this is the only place every layer can
282/// agree on the config surface.
283#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
284pub struct ManifestSearchConfig {
285    #[serde(default)]
286    pub text: Vec<String>,
287    #[serde(default)]
288    pub facets: Vec<String>,
289    #[serde(default)]
290    pub sortable: Vec<String>,
291    /// Tokenizer language for the FTS index (Postgres `to_tsvector` /
292    /// `plainto_tsquery` config). Only the names Postgres ships in
293    /// `pg_ts_config` are valid: `english`, `spanish`, `german`,
294    /// `french`, `simple`, etc. SQLite ignores the field — its FTS5
295    /// virtual table uses `unicode61 remove_diacritics 2` regardless,
296    /// which is language-agnostic. Defaults to `english` so existing
297    /// manifests don't change behavior.
298    #[serde(default)]
299    pub language: Option<String>,
300}
301
302impl ManifestSearchConfig {
303    pub fn is_empty(&self) -> bool {
304        self.text.is_empty() && self.facets.is_empty() && self.sortable.is_empty()
305    }
306
307    /// Resolve the tsvector language config — caller's `language` if
308    /// set, otherwise `english`. Only used by the Postgres backend;
309    /// SQLite ignores it.
310    pub fn language_or_default(&self) -> &str {
311        self.language.as_deref().unwrap_or("english")
312    }
313}
314
315#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
316pub struct ManifestRelation {
317    pub name: String,
318    pub target: String,
319    pub field: String,
320    #[serde(default)]
321    pub many: bool,
322}
323
324#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
325pub struct ManifestField {
326    pub name: String,
327    #[serde(rename = "type")]
328    pub field_type: String,
329    pub optional: bool,
330    pub unique: bool,
331    /// CRDT container override for this field. `None` = pick a sensible
332    /// default for the field type (most things are LWW; `richtext`
333    /// defaults to LoroText). Typed enum so typos in the manifest
334    /// fail at deserialize time instead of at first write.
335    ///
336    /// Ignored when the entity has `crdt: false` (the LWW-only escape
337    /// hatch on the entity itself).
338    #[serde(default, skip_serializing_if = "Option::is_none")]
339    pub crdt: Option<CrdtAnnotation>,
340}
341
342/// Per-field CRDT container override. Wire format is the lowercase
343/// kebab-case string each variant maps to (e.g. `"text"`, `"movable-list"`),
344/// so JSON manifests look the same as before — but a typo like
345/// `crdt: "txt"` now fails at manifest deserialization with a clear
346/// "unknown variant" error instead of slipping through and erroring at
347/// first write.
348///
349/// Variants intentionally mirror the categories
350/// [`pylon_crdt::CrdtFieldKind`] knows how to instantiate. New CRDT
351/// container types added to Loro show up as new variants here, plus a
352/// match arm in `pylon_crdt::field_kind`.
353#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
354#[serde(rename_all = "kebab-case")]
355pub enum CrdtAnnotation {
356    /// Explicit LWW register (matches the default for most scalar types).
357    Lww,
358    /// Upgrade `string` → `LoroText` for collaborative character-level merge.
359    Text,
360    /// Upgrade `int`/`float` → `LoroCounter` so concurrent increments add
361    /// instead of stomping. Reserved — apply_patch returns
362    /// "not yet implemented" until the projection layer learns counters.
363    Counter,
364    /// `LoroList` for ordered collections. Reserved.
365    List,
366    /// `LoroMovableList` for reorderable lists (kanban, prioritized todo).
367    /// Reserved.
368    #[serde(rename = "movable-list")]
369    MovableList,
370    /// `LoroTree` for hierarchical data (folders, threaded comments).
371    /// Reserved.
372    Tree,
373}
374
375impl CrdtAnnotation {
376    /// Wire-format string. Stable across versions; changing this breaks
377    /// every persisted manifest on disk.
378    pub fn as_str(self) -> &'static str {
379        match self {
380            Self::Lww => "lww",
381            Self::Text => "text",
382            Self::Counter => "counter",
383            Self::List => "list",
384            Self::MovableList => "movable-list",
385            Self::Tree => "tree",
386        }
387    }
388}
389
390impl std::fmt::Display for CrdtAnnotation {
391    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
392        f.write_str(self.as_str())
393    }
394}
395
396#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
397pub struct ManifestIndex {
398    pub name: String,
399    pub fields: Vec<String>,
400    pub unique: bool,
401}
402
403#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
404pub struct ManifestRoute {
405    pub path: String,
406    pub mode: String,
407    #[serde(skip_serializing_if = "Option::is_none")]
408    pub query: Option<String>,
409    #[serde(skip_serializing_if = "Option::is_none")]
410    pub auth: Option<String>,
411}
412
413#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
414pub struct ManifestQuery {
415    pub name: String,
416    #[serde(default, skip_serializing_if = "Vec::is_empty")]
417    pub input: Vec<ManifestField>,
418}
419
420#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
421pub struct ManifestAction {
422    pub name: String,
423    #[serde(default, skip_serializing_if = "Vec::is_empty")]
424    pub input: Vec<ManifestField>,
425}
426
427/// Row-level access policy attached to an entity or action.
428///
429/// `allow` is the legacy single-gate expression used for every kind of
430/// access. The optional `allow_*` fields let callers differentiate read
431/// from write from delete. When a per-action field is present it wins;
432/// otherwise the engine falls back to `allow`. That keeps old manifests
433/// working unchanged while enabling finer-grained ownership rules —
434/// "anyone can read, only the author can edit or delete."
435#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
436pub struct ManifestPolicy {
437    pub name: String,
438    #[serde(skip_serializing_if = "Option::is_none")]
439    pub entity: Option<String>,
440    #[serde(skip_serializing_if = "Option::is_none")]
441    pub action: Option<String>,
442    #[serde(default, skip_serializing_if = "String::is_empty")]
443    pub allow: String,
444    /// Overrides `allow` for reads (pull, list, get). Optional.
445    #[serde(default, rename = "allowRead", skip_serializing_if = "Option::is_none")]
446    pub allow_read: Option<String>,
447    /// Overrides `allow` for inserts. Optional; falls back to `allow_write`
448    /// then `allow`.
449    #[serde(
450        default,
451        rename = "allowInsert",
452        skip_serializing_if = "Option::is_none"
453    )]
454    pub allow_insert: Option<String>,
455    /// Overrides `allow`/`allow_write` for updates. Optional.
456    #[serde(
457        default,
458        rename = "allowUpdate",
459        skip_serializing_if = "Option::is_none"
460    )]
461    pub allow_update: Option<String>,
462    /// Overrides `allow`/`allow_write` for deletes. Optional.
463    #[serde(
464        default,
465        rename = "allowDelete",
466        skip_serializing_if = "Option::is_none"
467    )]
468    pub allow_delete: Option<String>,
469    /// Shared fallback for any write (insert/update/delete) when the
470    /// more-specific field isn't set. Optional.
471    #[serde(
472        default,
473        rename = "allowWrite",
474        skip_serializing_if = "Option::is_none"
475    )]
476    pub allow_write: Option<String>,
477}
478
479#[cfg(test)]
480mod tests {
481    use super::*;
482
483    #[test]
484    fn exit_code_values() {
485        assert_eq!(ExitCode::Ok.as_i32(), 0);
486        assert_eq!(ExitCode::Error.as_i32(), 1);
487        assert_eq!(ExitCode::Usage.as_i32(), 64);
488        assert_eq!(ExitCode::Unavailable.as_i32(), 69);
489    }
490
491    #[test]
492    fn severity_display() {
493        assert_eq!(format!("{}", Severity::Error), "error");
494        assert_eq!(format!("{}", Severity::Warning), "warning");
495        assert_eq!(format!("{}", Severity::Info), "info");
496    }
497
498    #[test]
499    fn diagnostic_display_without_hint() {
500        let d = Diagnostic {
501            severity: Severity::Error,
502            code: "TEST".into(),
503            message: "something failed".into(),
504            span: None,
505            hint: None,
506        };
507        assert_eq!(format!("{d}"), "[error] TEST: something failed");
508    }
509
510    #[test]
511    fn diagnostic_display_with_hint() {
512        let d = Diagnostic {
513            severity: Severity::Warning,
514            code: "WARN".into(),
515            message: "check this".into(),
516            span: None,
517            hint: Some("try again".into()),
518        };
519        assert_eq!(
520            format!("{d}"),
521            "[warning] WARN: check this (hint: try again)"
522        );
523    }
524
525    #[test]
526    fn manifest_version_constant() {
527        assert_eq!(MANIFEST_VERSION, 1);
528    }
529}