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}
290
291impl ManifestSearchConfig {
292    pub fn is_empty(&self) -> bool {
293        self.text.is_empty() && self.facets.is_empty() && self.sortable.is_empty()
294    }
295}
296
297#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
298pub struct ManifestRelation {
299    pub name: String,
300    pub target: String,
301    pub field: String,
302    #[serde(default)]
303    pub many: bool,
304}
305
306#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
307pub struct ManifestField {
308    pub name: String,
309    #[serde(rename = "type")]
310    pub field_type: String,
311    pub optional: bool,
312    pub unique: bool,
313    /// CRDT container override for this field. `None` = pick a sensible
314    /// default for the field type (most things are LWW; `richtext`
315    /// defaults to LoroText). Typed enum so typos in the manifest
316    /// fail at deserialize time instead of at first write.
317    ///
318    /// Ignored when the entity has `crdt: false` (the LWW-only escape
319    /// hatch on the entity itself).
320    #[serde(default, skip_serializing_if = "Option::is_none")]
321    pub crdt: Option<CrdtAnnotation>,
322}
323
324/// Per-field CRDT container override. Wire format is the lowercase
325/// kebab-case string each variant maps to (e.g. `"text"`, `"movable-list"`),
326/// so JSON manifests look the same as before — but a typo like
327/// `crdt: "txt"` now fails at manifest deserialization with a clear
328/// "unknown variant" error instead of slipping through and erroring at
329/// first write.
330///
331/// Variants intentionally mirror the categories
332/// [`pylon_crdt::CrdtFieldKind`] knows how to instantiate. New CRDT
333/// container types added to Loro show up as new variants here, plus a
334/// match arm in `pylon_crdt::field_kind`.
335#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
336#[serde(rename_all = "kebab-case")]
337pub enum CrdtAnnotation {
338    /// Explicit LWW register (matches the default for most scalar types).
339    Lww,
340    /// Upgrade `string` → `LoroText` for collaborative character-level merge.
341    Text,
342    /// Upgrade `int`/`float` → `LoroCounter` so concurrent increments add
343    /// instead of stomping. Reserved — apply_patch returns
344    /// "not yet implemented" until the projection layer learns counters.
345    Counter,
346    /// `LoroList` for ordered collections. Reserved.
347    List,
348    /// `LoroMovableList` for reorderable lists (kanban, prioritized todo).
349    /// Reserved.
350    #[serde(rename = "movable-list")]
351    MovableList,
352    /// `LoroTree` for hierarchical data (folders, threaded comments).
353    /// Reserved.
354    Tree,
355}
356
357impl CrdtAnnotation {
358    /// Wire-format string. Stable across versions; changing this breaks
359    /// every persisted manifest on disk.
360    pub fn as_str(self) -> &'static str {
361        match self {
362            Self::Lww => "lww",
363            Self::Text => "text",
364            Self::Counter => "counter",
365            Self::List => "list",
366            Self::MovableList => "movable-list",
367            Self::Tree => "tree",
368        }
369    }
370}
371
372impl std::fmt::Display for CrdtAnnotation {
373    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
374        f.write_str(self.as_str())
375    }
376}
377
378#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
379pub struct ManifestIndex {
380    pub name: String,
381    pub fields: Vec<String>,
382    pub unique: bool,
383}
384
385#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
386pub struct ManifestRoute {
387    pub path: String,
388    pub mode: String,
389    #[serde(skip_serializing_if = "Option::is_none")]
390    pub query: Option<String>,
391    #[serde(skip_serializing_if = "Option::is_none")]
392    pub auth: Option<String>,
393}
394
395#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
396pub struct ManifestQuery {
397    pub name: String,
398    #[serde(default, skip_serializing_if = "Vec::is_empty")]
399    pub input: Vec<ManifestField>,
400}
401
402#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
403pub struct ManifestAction {
404    pub name: String,
405    #[serde(default, skip_serializing_if = "Vec::is_empty")]
406    pub input: Vec<ManifestField>,
407}
408
409/// Row-level access policy attached to an entity or action.
410///
411/// `allow` is the legacy single-gate expression used for every kind of
412/// access. The optional `allow_*` fields let callers differentiate read
413/// from write from delete. When a per-action field is present it wins;
414/// otherwise the engine falls back to `allow`. That keeps old manifests
415/// working unchanged while enabling finer-grained ownership rules —
416/// "anyone can read, only the author can edit or delete."
417#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
418pub struct ManifestPolicy {
419    pub name: String,
420    #[serde(skip_serializing_if = "Option::is_none")]
421    pub entity: Option<String>,
422    #[serde(skip_serializing_if = "Option::is_none")]
423    pub action: Option<String>,
424    #[serde(default, skip_serializing_if = "String::is_empty")]
425    pub allow: String,
426    /// Overrides `allow` for reads (pull, list, get). Optional.
427    #[serde(default, rename = "allowRead", skip_serializing_if = "Option::is_none")]
428    pub allow_read: Option<String>,
429    /// Overrides `allow` for inserts. Optional; falls back to `allow_write`
430    /// then `allow`.
431    #[serde(
432        default,
433        rename = "allowInsert",
434        skip_serializing_if = "Option::is_none"
435    )]
436    pub allow_insert: Option<String>,
437    /// Overrides `allow`/`allow_write` for updates. Optional.
438    #[serde(
439        default,
440        rename = "allowUpdate",
441        skip_serializing_if = "Option::is_none"
442    )]
443    pub allow_update: Option<String>,
444    /// Overrides `allow`/`allow_write` for deletes. Optional.
445    #[serde(
446        default,
447        rename = "allowDelete",
448        skip_serializing_if = "Option::is_none"
449    )]
450    pub allow_delete: Option<String>,
451    /// Shared fallback for any write (insert/update/delete) when the
452    /// more-specific field isn't set. Optional.
453    #[serde(
454        default,
455        rename = "allowWrite",
456        skip_serializing_if = "Option::is_none"
457    )]
458    pub allow_write: Option<String>,
459}
460
461#[cfg(test)]
462mod tests {
463    use super::*;
464
465    #[test]
466    fn exit_code_values() {
467        assert_eq!(ExitCode::Ok.as_i32(), 0);
468        assert_eq!(ExitCode::Error.as_i32(), 1);
469        assert_eq!(ExitCode::Usage.as_i32(), 64);
470        assert_eq!(ExitCode::Unavailable.as_i32(), 69);
471    }
472
473    #[test]
474    fn severity_display() {
475        assert_eq!(format!("{}", Severity::Error), "error");
476        assert_eq!(format!("{}", Severity::Warning), "warning");
477        assert_eq!(format!("{}", Severity::Info), "info");
478    }
479
480    #[test]
481    fn diagnostic_display_without_hint() {
482        let d = Diagnostic {
483            severity: Severity::Error,
484            code: "TEST".into(),
485            message: "something failed".into(),
486            span: None,
487            hint: None,
488        };
489        assert_eq!(format!("{d}"), "[error] TEST: something failed");
490    }
491
492    #[test]
493    fn diagnostic_display_with_hint() {
494        let d = Diagnostic {
495            severity: Severity::Warning,
496            code: "WARN".into(),
497            message: "check this".into(),
498            span: None,
499            hint: Some("try again".into()),
500        };
501        assert_eq!(
502            format!("{d}"),
503            "[warning] WARN: check this (hint: try again)"
504        );
505    }
506
507    #[test]
508    fn manifest_version_constant() {
509        assert_eq!(MANIFEST_VERSION, 1);
510    }
511}