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    /// Optional SQL predicate that turns this into a *partial* index.
421    /// Both SQLite and Postgres support `CREATE [UNIQUE] INDEX … WHERE`,
422    /// which lets the same index enforce different cardinality rules
423    /// for different rows — e.g. a unique constraint on
424    /// `(createdBy)` only when `plan = 'hobby'`, so a single user can
425    /// own multiple paid orgs but only one hobby org.
426    ///
427    /// Skipped on serialize when None so existing manifests stay
428    /// byte-for-byte identical.
429    #[serde(default, skip_serializing_if = "Option::is_none", rename = "where")]
430    pub where_clause: Option<String>,
431}
432
433#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
434pub struct ManifestRoute {
435    pub path: String,
436    pub mode: String,
437    #[serde(skip_serializing_if = "Option::is_none")]
438    pub query: Option<String>,
439    #[serde(skip_serializing_if = "Option::is_none")]
440    pub auth: Option<String>,
441}
442
443#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
444pub struct ManifestQuery {
445    pub name: String,
446    #[serde(default, skip_serializing_if = "Vec::is_empty")]
447    pub input: Vec<ManifestField>,
448}
449
450#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
451pub struct ManifestAction {
452    pub name: String,
453    #[serde(default, skip_serializing_if = "Vec::is_empty")]
454    pub input: Vec<ManifestField>,
455}
456
457/// Row-level access policy attached to an entity or action.
458///
459/// `allow` is the legacy single-gate expression used for every kind of
460/// access. The optional `allow_*` fields let callers differentiate read
461/// from write from delete. When a per-action field is present it wins;
462/// otherwise the engine falls back to `allow`. That keeps old manifests
463/// working unchanged while enabling finer-grained ownership rules —
464/// "anyone can read, only the author can edit or delete."
465#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
466pub struct ManifestPolicy {
467    pub name: String,
468    #[serde(skip_serializing_if = "Option::is_none")]
469    pub entity: Option<String>,
470    #[serde(skip_serializing_if = "Option::is_none")]
471    pub action: Option<String>,
472    #[serde(default, skip_serializing_if = "String::is_empty")]
473    pub allow: String,
474    /// Overrides `allow` for reads (pull, list, get). Optional.
475    #[serde(default, rename = "allowRead", skip_serializing_if = "Option::is_none")]
476    pub allow_read: Option<String>,
477    /// Overrides `allow` for inserts. Optional; falls back to `allow_write`
478    /// then `allow`.
479    #[serde(
480        default,
481        rename = "allowInsert",
482        skip_serializing_if = "Option::is_none"
483    )]
484    pub allow_insert: Option<String>,
485    /// Overrides `allow`/`allow_write` for updates. Optional.
486    #[serde(
487        default,
488        rename = "allowUpdate",
489        skip_serializing_if = "Option::is_none"
490    )]
491    pub allow_update: Option<String>,
492    /// Overrides `allow`/`allow_write` for deletes. Optional.
493    #[serde(
494        default,
495        rename = "allowDelete",
496        skip_serializing_if = "Option::is_none"
497    )]
498    pub allow_delete: Option<String>,
499    /// Shared fallback for any write (insert/update/delete) when the
500    /// more-specific field isn't set. Optional.
501    #[serde(
502        default,
503        rename = "allowWrite",
504        skip_serializing_if = "Option::is_none"
505    )]
506    pub allow_write: Option<String>,
507}
508
509#[cfg(test)]
510mod tests {
511    use super::*;
512
513    #[test]
514    fn exit_code_values() {
515        assert_eq!(ExitCode::Ok.as_i32(), 0);
516        assert_eq!(ExitCode::Error.as_i32(), 1);
517        assert_eq!(ExitCode::Usage.as_i32(), 64);
518        assert_eq!(ExitCode::Unavailable.as_i32(), 69);
519    }
520
521    #[test]
522    fn severity_display() {
523        assert_eq!(format!("{}", Severity::Error), "error");
524        assert_eq!(format!("{}", Severity::Warning), "warning");
525        assert_eq!(format!("{}", Severity::Info), "info");
526    }
527
528    #[test]
529    fn diagnostic_display_without_hint() {
530        let d = Diagnostic {
531            severity: Severity::Error,
532            code: "TEST".into(),
533            message: "something failed".into(),
534            span: None,
535            hint: None,
536        };
537        assert_eq!(format!("{d}"), "[error] TEST: something failed");
538    }
539
540    #[test]
541    fn diagnostic_display_with_hint() {
542        let d = Diagnostic {
543            severity: Severity::Warning,
544            code: "WARN".into(),
545            message: "check this".into(),
546            span: None,
547            hint: Some("try again".into()),
548        };
549        assert_eq!(
550            format!("{d}"),
551            "[warning] WARN: check this (hint: try again)"
552        );
553    }
554
555    #[test]
556    fn manifest_version_constant() {
557        assert_eq!(MANIFEST_VERSION, 1);
558    }
559}