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)]
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}
108
109#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
110pub struct ManifestEntity {
111    pub name: String,
112    pub fields: Vec<ManifestField>,
113    pub indexes: Vec<ManifestIndex>,
114    #[serde(default, skip_serializing_if = "Vec::is_empty")]
115    pub relations: Vec<ManifestRelation>,
116    /// Opt-in faceted search config. `None` = entity isn't searchable;
117    /// `Some(cfg)` makes the runtime create FTS5 + facet-bitmap shadow
118    /// tables on schema push and maintain them on every write.
119    #[serde(default, skip_serializing_if = "Option::is_none")]
120    pub search: Option<ManifestSearchConfig>,
121    /// Local-first / CRDT mode. Default `true` — every entity is backed
122    /// by a Loro doc, mutations merge as CRDTs, multi-device offline
123    /// edits converge cleanly. Set `false` to opt out per entity (audit
124    /// logs, append-only archives, anything that doesn't need offline
125    /// merge and where you want to skip the per-write Loro overhead).
126    /// The SQLite-projected row shape is identical either way; queries
127    /// and indexes don't change between modes.
128    #[serde(default = "default_crdt_enabled")]
129    pub crdt: bool,
130}
131
132fn default_crdt_enabled() -> bool {
133    true
134}
135
136impl Default for ManifestEntity {
137    fn default() -> Self {
138        Self {
139            name: String::new(),
140            fields: Vec::new(),
141            indexes: Vec::new(),
142            relations: Vec::new(),
143            search: None,
144            crdt: true,
145        }
146    }
147}
148
149/// Per-entity search declaration. Lives on the manifest so both the
150/// storage layer (schema push) and the runtime (write-time maintenance
151/// + query endpoints) read the same shape.
152///
153/// Kept in `pylon-kernel` intentionally — other crates depend on kernel
154/// but not on each other, so this is the only place every layer can
155/// agree on the config surface.
156#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
157pub struct ManifestSearchConfig {
158    #[serde(default)]
159    pub text: Vec<String>,
160    #[serde(default)]
161    pub facets: Vec<String>,
162    #[serde(default)]
163    pub sortable: Vec<String>,
164}
165
166impl ManifestSearchConfig {
167    pub fn is_empty(&self) -> bool {
168        self.text.is_empty() && self.facets.is_empty() && self.sortable.is_empty()
169    }
170}
171
172#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
173pub struct ManifestRelation {
174    pub name: String,
175    pub target: String,
176    pub field: String,
177    #[serde(default)]
178    pub many: bool,
179}
180
181#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
182pub struct ManifestField {
183    pub name: String,
184    #[serde(rename = "type")]
185    pub field_type: String,
186    pub optional: bool,
187    pub unique: bool,
188    /// CRDT container override for this field. `None` = pick a sensible
189    /// default for the field type (most things are LWW; `richtext`
190    /// defaults to LoroText). Typed enum so typos in the manifest
191    /// fail at deserialize time instead of at first write.
192    ///
193    /// Ignored when the entity has `crdt: false` (the LWW-only escape
194    /// hatch on the entity itself).
195    #[serde(default, skip_serializing_if = "Option::is_none")]
196    pub crdt: Option<CrdtAnnotation>,
197}
198
199/// Per-field CRDT container override. Wire format is the lowercase
200/// kebab-case string each variant maps to (e.g. `"text"`, `"movable-list"`),
201/// so JSON manifests look the same as before — but a typo like
202/// `crdt: "txt"` now fails at manifest deserialization with a clear
203/// "unknown variant" error instead of slipping through and erroring at
204/// first write.
205///
206/// Variants intentionally mirror the categories
207/// [`pylon_crdt::CrdtFieldKind`] knows how to instantiate. New CRDT
208/// container types added to Loro show up as new variants here, plus a
209/// match arm in `pylon_crdt::field_kind`.
210#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
211#[serde(rename_all = "kebab-case")]
212pub enum CrdtAnnotation {
213    /// Explicit LWW register (matches the default for most scalar types).
214    Lww,
215    /// Upgrade `string` → `LoroText` for collaborative character-level merge.
216    Text,
217    /// Upgrade `int`/`float` → `LoroCounter` so concurrent increments add
218    /// instead of stomping. Reserved — apply_patch returns
219    /// "not yet implemented" until the projection layer learns counters.
220    Counter,
221    /// `LoroList` for ordered collections. Reserved.
222    List,
223    /// `LoroMovableList` for reorderable lists (kanban, prioritized todo).
224    /// Reserved.
225    #[serde(rename = "movable-list")]
226    MovableList,
227    /// `LoroTree` for hierarchical data (folders, threaded comments).
228    /// Reserved.
229    Tree,
230}
231
232impl CrdtAnnotation {
233    /// Wire-format string. Stable across versions; changing this breaks
234    /// every persisted manifest on disk.
235    pub fn as_str(self) -> &'static str {
236        match self {
237            Self::Lww => "lww",
238            Self::Text => "text",
239            Self::Counter => "counter",
240            Self::List => "list",
241            Self::MovableList => "movable-list",
242            Self::Tree => "tree",
243        }
244    }
245}
246
247impl std::fmt::Display for CrdtAnnotation {
248    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
249        f.write_str(self.as_str())
250    }
251}
252
253#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
254pub struct ManifestIndex {
255    pub name: String,
256    pub fields: Vec<String>,
257    pub unique: bool,
258}
259
260#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
261pub struct ManifestRoute {
262    pub path: String,
263    pub mode: String,
264    #[serde(skip_serializing_if = "Option::is_none")]
265    pub query: Option<String>,
266    #[serde(skip_serializing_if = "Option::is_none")]
267    pub auth: Option<String>,
268}
269
270#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
271pub struct ManifestQuery {
272    pub name: String,
273    #[serde(default, skip_serializing_if = "Vec::is_empty")]
274    pub input: Vec<ManifestField>,
275}
276
277#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
278pub struct ManifestAction {
279    pub name: String,
280    #[serde(default, skip_serializing_if = "Vec::is_empty")]
281    pub input: Vec<ManifestField>,
282}
283
284/// Row-level access policy attached to an entity or action.
285///
286/// `allow` is the legacy single-gate expression used for every kind of
287/// access. The optional `allow_*` fields let callers differentiate read
288/// from write from delete. When a per-action field is present it wins;
289/// otherwise the engine falls back to `allow`. That keeps old manifests
290/// working unchanged while enabling finer-grained ownership rules —
291/// "anyone can read, only the author can edit or delete."
292#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
293pub struct ManifestPolicy {
294    pub name: String,
295    #[serde(skip_serializing_if = "Option::is_none")]
296    pub entity: Option<String>,
297    #[serde(skip_serializing_if = "Option::is_none")]
298    pub action: Option<String>,
299    #[serde(default, skip_serializing_if = "String::is_empty")]
300    pub allow: String,
301    /// Overrides `allow` for reads (pull, list, get). Optional.
302    #[serde(default, rename = "allowRead", skip_serializing_if = "Option::is_none")]
303    pub allow_read: Option<String>,
304    /// Overrides `allow` for inserts. Optional; falls back to `allow_write`
305    /// then `allow`.
306    #[serde(
307        default,
308        rename = "allowInsert",
309        skip_serializing_if = "Option::is_none"
310    )]
311    pub allow_insert: Option<String>,
312    /// Overrides `allow`/`allow_write` for updates. Optional.
313    #[serde(
314        default,
315        rename = "allowUpdate",
316        skip_serializing_if = "Option::is_none"
317    )]
318    pub allow_update: Option<String>,
319    /// Overrides `allow`/`allow_write` for deletes. Optional.
320    #[serde(
321        default,
322        rename = "allowDelete",
323        skip_serializing_if = "Option::is_none"
324    )]
325    pub allow_delete: Option<String>,
326    /// Shared fallback for any write (insert/update/delete) when the
327    /// more-specific field isn't set. Optional.
328    #[serde(
329        default,
330        rename = "allowWrite",
331        skip_serializing_if = "Option::is_none"
332    )]
333    pub allow_write: Option<String>,
334}
335
336#[cfg(test)]
337mod tests {
338    use super::*;
339
340    #[test]
341    fn exit_code_values() {
342        assert_eq!(ExitCode::Ok.as_i32(), 0);
343        assert_eq!(ExitCode::Error.as_i32(), 1);
344        assert_eq!(ExitCode::Usage.as_i32(), 64);
345        assert_eq!(ExitCode::Unavailable.as_i32(), 69);
346    }
347
348    #[test]
349    fn severity_display() {
350        assert_eq!(format!("{}", Severity::Error), "error");
351        assert_eq!(format!("{}", Severity::Warning), "warning");
352        assert_eq!(format!("{}", Severity::Info), "info");
353    }
354
355    #[test]
356    fn diagnostic_display_without_hint() {
357        let d = Diagnostic {
358            severity: Severity::Error,
359            code: "TEST".into(),
360            message: "something failed".into(),
361            span: None,
362            hint: None,
363        };
364        assert_eq!(format!("{d}"), "[error] TEST: something failed");
365    }
366
367    #[test]
368    fn diagnostic_display_with_hint() {
369        let d = Diagnostic {
370            severity: Severity::Warning,
371            code: "WARN".into(),
372            message: "check this".into(),
373            span: None,
374            hint: Some("try again".into()),
375        };
376        assert_eq!(
377            format!("{d}"),
378            "[warning] WARN: check this (hint: try again)"
379        );
380    }
381
382    #[test]
383    fn manifest_version_constant() {
384        assert_eq!(MANIFEST_VERSION, 1);
385    }
386}