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    /// Field name on the User row that, when truthy, marks the
157    /// session as `auth.is_admin = true`. Default unset (only the
158    /// PYLON_ADMIN_TOKEN env-bearer path grants admin). When set,
159    /// resolving a session cookie loads the user, reads this field,
160    /// and lifts is_admin if it's `true`/`1`/non-empty.
161    ///
162    /// Apps that want per-user admin (Studio access for specific
163    /// User rows instead of a shared bootstrap token) set this to
164    /// `"isAdmin"` (or whichever bool field they store on User).
165    /// Pylon-cloud uses this so platform admins sign in with their
166    /// regular account and Studio respects the role.
167    ///
168    /// Bootstrap pattern: PYLON_ADMIN_TOKEN keeps working for CI /
169    /// fresh deploys with no User rows yet. The two paths are
170    /// additive — admin token OR matching admin field both grant
171    /// `is_admin`.
172    #[serde(default, skip_serializing_if = "Option::is_none")]
173    pub admin_field: Option<String>,
174}
175
176impl Default for ManifestAuthUserConfig {
177    fn default() -> Self {
178        Self {
179            entity: default_user_entity(),
180            expose: Vec::new(),
181            hide: Vec::new(),
182            admin_field: None,
183        }
184    }
185}
186
187fn default_user_entity() -> String {
188    "User".into()
189}
190
191#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
192pub struct ManifestAuthSessionConfig {
193    /// Lifetime of new sessions in seconds. Default 30 days.
194    #[serde(default = "default_session_lifetime")]
195    pub expires_in: u64,
196    /// Cookie cache config — bakes the listed claims into the cookie
197    /// itself so `/api/auth/me`-style probes can resolve identity
198    /// without a session-store lookup. Mirrors better-auth's
199    /// `session.cookieCache`.
200    #[serde(default)]
201    pub cookie_cache: ManifestAuthCookieCacheConfig,
202}
203
204impl Default for ManifestAuthSessionConfig {
205    fn default() -> Self {
206        Self {
207            expires_in: default_session_lifetime(),
208            cookie_cache: ManifestAuthCookieCacheConfig::default(),
209        }
210    }
211}
212
213fn default_session_lifetime() -> u64 {
214    30 * 24 * 60 * 60
215}
216
217/// Cookie-cache settings. When `enabled`, the session cookie carries
218/// a signed JWT-style envelope including the claims listed in
219/// `claims` (defaults to `is_admin` + `tenant_id`). Cookie reads
220/// resolve identity without touching the session store, at the cost
221/// of staleness up to `max_age` seconds.
222#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
223pub struct ManifestAuthCookieCacheConfig {
224    #[serde(default)]
225    pub enabled: bool,
226    /// Max age of the cached claims in seconds. After this, the
227    /// cookie envelope is treated as expired and the session store
228    /// is consulted again. Default 5 minutes — same as better-auth.
229    #[serde(default = "default_cookie_cache_max_age")]
230    pub max_age: u64,
231    /// Auth-context fields baked into the cookie envelope. Always
232    /// includes `user_id`; the operator opts in to anything else.
233    #[serde(default = "default_cookie_cache_claims")]
234    pub claims: Vec<String>,
235}
236
237impl Default for ManifestAuthCookieCacheConfig {
238    fn default() -> Self {
239        Self {
240            enabled: false,
241            max_age: default_cookie_cache_max_age(),
242            claims: default_cookie_cache_claims(),
243        }
244    }
245}
246
247fn default_cookie_cache_max_age() -> u64 {
248    5 * 60
249}
250
251fn default_cookie_cache_claims() -> Vec<String> {
252    vec!["is_admin".into(), "tenant_id".into()]
253}
254
255#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
256pub struct ManifestEntity {
257    pub name: String,
258    pub fields: Vec<ManifestField>,
259    pub indexes: Vec<ManifestIndex>,
260    #[serde(default, skip_serializing_if = "Vec::is_empty")]
261    pub relations: Vec<ManifestRelation>,
262    /// Opt-in faceted search config. `None` = entity isn't searchable;
263    /// `Some(cfg)` makes the runtime create FTS5 + facet-bitmap shadow
264    /// tables on schema push and maintain them on every write.
265    #[serde(default, skip_serializing_if = "Option::is_none")]
266    pub search: Option<ManifestSearchConfig>,
267    /// Local-first / CRDT mode. Default `true` — every entity is backed
268    /// by a Loro doc, mutations merge as CRDTs, multi-device offline
269    /// edits converge cleanly. Set `false` to opt out per entity (audit
270    /// logs, append-only archives, anything that doesn't need offline
271    /// merge and where you want to skip the per-write Loro overhead).
272    /// The SQLite-projected row shape is identical either way; queries
273    /// and indexes don't change between modes.
274    #[serde(default = "default_crdt_enabled")]
275    pub crdt: bool,
276}
277
278fn default_crdt_enabled() -> bool {
279    true
280}
281
282impl Default for ManifestEntity {
283    fn default() -> Self {
284        Self {
285            name: String::new(),
286            fields: Vec::new(),
287            indexes: Vec::new(),
288            relations: Vec::new(),
289            search: None,
290            crdt: true,
291        }
292    }
293}
294
295/// Per-entity search declaration. Lives on the manifest so both the
296/// storage layer (schema push) and the runtime (write-time maintenance
297/// + query endpoints) read the same shape.
298///
299/// Kept in `pylon-kernel` intentionally — other crates depend on kernel
300/// but not on each other, so this is the only place every layer can
301/// agree on the config surface.
302#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
303pub struct ManifestSearchConfig {
304    #[serde(default)]
305    pub text: Vec<String>,
306    #[serde(default)]
307    pub facets: Vec<String>,
308    #[serde(default)]
309    pub sortable: Vec<String>,
310    /// Tokenizer language for the FTS index (Postgres `to_tsvector` /
311    /// `plainto_tsquery` config). Only the names Postgres ships in
312    /// `pg_ts_config` are valid: `english`, `spanish`, `german`,
313    /// `french`, `simple`, etc. SQLite ignores the field — its FTS5
314    /// virtual table uses `unicode61 remove_diacritics 2` regardless,
315    /// which is language-agnostic. Defaults to `english` so existing
316    /// manifests don't change behavior.
317    #[serde(default)]
318    pub language: Option<String>,
319}
320
321impl ManifestSearchConfig {
322    pub fn is_empty(&self) -> bool {
323        self.text.is_empty() && self.facets.is_empty() && self.sortable.is_empty()
324    }
325
326    /// Resolve the tsvector language config — caller's `language` if
327    /// set, otherwise `english`. Only used by the Postgres backend;
328    /// SQLite ignores it.
329    pub fn language_or_default(&self) -> &str {
330        self.language.as_deref().unwrap_or("english")
331    }
332}
333
334#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
335pub struct ManifestRelation {
336    pub name: String,
337    pub target: String,
338    pub field: String,
339    #[serde(default)]
340    pub many: bool,
341}
342
343#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
344pub struct ManifestField {
345    pub name: String,
346    #[serde(rename = "type")]
347    pub field_type: String,
348    pub optional: bool,
349    pub unique: bool,
350    /// CRDT container override for this field. `None` = pick a sensible
351    /// default for the field type (most things are LWW; `richtext`
352    /// defaults to LoroText). Typed enum so typos in the manifest
353    /// fail at deserialize time instead of at first write.
354    ///
355    /// Ignored when the entity has `crdt: false` (the LWW-only escape
356    /// hatch on the entity itself).
357    #[serde(default, skip_serializing_if = "Option::is_none")]
358    pub crdt: Option<CrdtAnnotation>,
359}
360
361/// Per-field CRDT container override. Wire format is the lowercase
362/// kebab-case string each variant maps to (e.g. `"text"`, `"movable-list"`),
363/// so JSON manifests look the same as before — but a typo like
364/// `crdt: "txt"` now fails at manifest deserialization with a clear
365/// "unknown variant" error instead of slipping through and erroring at
366/// first write.
367///
368/// Variants intentionally mirror the categories
369/// [`pylon_crdt::CrdtFieldKind`] knows how to instantiate. New CRDT
370/// container types added to Loro show up as new variants here, plus a
371/// match arm in `pylon_crdt::field_kind`.
372#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
373#[serde(rename_all = "kebab-case")]
374pub enum CrdtAnnotation {
375    /// Explicit LWW register (matches the default for most scalar types).
376    Lww,
377    /// Upgrade `string` → `LoroText` for collaborative character-level merge.
378    Text,
379    /// Upgrade `int`/`float` → `LoroCounter` so concurrent increments add
380    /// instead of stomping. Reserved — apply_patch returns
381    /// "not yet implemented" until the projection layer learns counters.
382    Counter,
383    /// `LoroList` for ordered collections. Reserved.
384    List,
385    /// `LoroMovableList` for reorderable lists (kanban, prioritized todo).
386    /// Reserved.
387    #[serde(rename = "movable-list")]
388    MovableList,
389    /// `LoroTree` for hierarchical data (folders, threaded comments).
390    /// Reserved.
391    Tree,
392}
393
394impl CrdtAnnotation {
395    /// Wire-format string. Stable across versions; changing this breaks
396    /// every persisted manifest on disk.
397    pub fn as_str(self) -> &'static str {
398        match self {
399            Self::Lww => "lww",
400            Self::Text => "text",
401            Self::Counter => "counter",
402            Self::List => "list",
403            Self::MovableList => "movable-list",
404            Self::Tree => "tree",
405        }
406    }
407}
408
409impl std::fmt::Display for CrdtAnnotation {
410    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
411        f.write_str(self.as_str())
412    }
413}
414
415#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
416pub struct ManifestIndex {
417    pub name: String,
418    pub fields: Vec<String>,
419    pub unique: bool,
420}
421
422#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
423pub struct ManifestRoute {
424    pub path: String,
425    pub mode: String,
426    #[serde(skip_serializing_if = "Option::is_none")]
427    pub query: Option<String>,
428    #[serde(skip_serializing_if = "Option::is_none")]
429    pub auth: Option<String>,
430}
431
432#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
433pub struct ManifestQuery {
434    pub name: String,
435    #[serde(default, skip_serializing_if = "Vec::is_empty")]
436    pub input: Vec<ManifestField>,
437}
438
439#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
440pub struct ManifestAction {
441    pub name: String,
442    #[serde(default, skip_serializing_if = "Vec::is_empty")]
443    pub input: Vec<ManifestField>,
444}
445
446/// Row-level access policy attached to an entity or action.
447///
448/// `allow` is the legacy single-gate expression used for every kind of
449/// access. The optional `allow_*` fields let callers differentiate read
450/// from write from delete. When a per-action field is present it wins;
451/// otherwise the engine falls back to `allow`. That keeps old manifests
452/// working unchanged while enabling finer-grained ownership rules —
453/// "anyone can read, only the author can edit or delete."
454#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
455pub struct ManifestPolicy {
456    pub name: String,
457    #[serde(skip_serializing_if = "Option::is_none")]
458    pub entity: Option<String>,
459    #[serde(skip_serializing_if = "Option::is_none")]
460    pub action: Option<String>,
461    #[serde(default, skip_serializing_if = "String::is_empty")]
462    pub allow: String,
463    /// Overrides `allow` for reads (pull, list, get). Optional.
464    #[serde(default, rename = "allowRead", skip_serializing_if = "Option::is_none")]
465    pub allow_read: Option<String>,
466    /// Overrides `allow` for inserts. Optional; falls back to `allow_write`
467    /// then `allow`.
468    #[serde(
469        default,
470        rename = "allowInsert",
471        skip_serializing_if = "Option::is_none"
472    )]
473    pub allow_insert: Option<String>,
474    /// Overrides `allow`/`allow_write` for updates. Optional.
475    #[serde(
476        default,
477        rename = "allowUpdate",
478        skip_serializing_if = "Option::is_none"
479    )]
480    pub allow_update: Option<String>,
481    /// Overrides `allow`/`allow_write` for deletes. Optional.
482    #[serde(
483        default,
484        rename = "allowDelete",
485        skip_serializing_if = "Option::is_none"
486    )]
487    pub allow_delete: Option<String>,
488    /// Shared fallback for any write (insert/update/delete) when the
489    /// more-specific field isn't set. Optional.
490    #[serde(
491        default,
492        rename = "allowWrite",
493        skip_serializing_if = "Option::is_none"
494    )]
495    pub allow_write: Option<String>,
496}
497
498#[cfg(test)]
499mod tests {
500    use super::*;
501
502    #[test]
503    fn exit_code_values() {
504        assert_eq!(ExitCode::Ok.as_i32(), 0);
505        assert_eq!(ExitCode::Error.as_i32(), 1);
506        assert_eq!(ExitCode::Usage.as_i32(), 64);
507        assert_eq!(ExitCode::Unavailable.as_i32(), 69);
508    }
509
510    #[test]
511    fn severity_display() {
512        assert_eq!(format!("{}", Severity::Error), "error");
513        assert_eq!(format!("{}", Severity::Warning), "warning");
514        assert_eq!(format!("{}", Severity::Info), "info");
515    }
516
517    #[test]
518    fn diagnostic_display_without_hint() {
519        let d = Diagnostic {
520            severity: Severity::Error,
521            code: "TEST".into(),
522            message: "something failed".into(),
523            span: None,
524            hint: None,
525        };
526        assert_eq!(format!("{d}"), "[error] TEST: something failed");
527    }
528
529    #[test]
530    fn diagnostic_display_with_hint() {
531        let d = Diagnostic {
532            severity: Severity::Warning,
533            code: "WARN".into(),
534            message: "check this".into(),
535            span: None,
536            hint: Some("try again".into()),
537        };
538        assert_eq!(
539            format!("{d}"),
540            "[warning] WARN: check this (hint: try again)"
541        );
542    }
543
544    #[test]
545    fn manifest_version_constant() {
546        assert_eq!(MANIFEST_VERSION, 1);
547    }
548}