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