Skip to main content

pylon_http/
lib.rs

1use std::fmt;
2
3// ---------------------------------------------------------------------------
4// HttpMethod — platform-agnostic HTTP verb
5// ---------------------------------------------------------------------------
6
7#[derive(Debug, Clone, Copy, PartialEq, Eq)]
8pub enum HttpMethod {
9    Get,
10    Post,
11    Put,
12    Patch,
13    Delete,
14    Options,
15    Head,
16}
17
18impl HttpMethod {
19    /// Parse an HTTP method string. Returns `None` for unrecognized methods.
20    pub fn try_parse(s: &str) -> Option<Self> {
21        match s {
22            "GET" | "get" => Some(Self::Get),
23            "POST" | "post" => Some(Self::Post),
24            "PUT" | "put" => Some(Self::Put),
25            "PATCH" | "patch" => Some(Self::Patch),
26            "DELETE" | "delete" => Some(Self::Delete),
27            "OPTIONS" | "options" => Some(Self::Options),
28            "HEAD" | "head" => Some(Self::Head),
29            _ => None,
30        }
31    }
32
33    /// Parse an HTTP method string, falling back to `Get` for unrecognized methods.
34    /// Prefer `try_parse` to detect malformed inputs; this remains for compatibility.
35    #[allow(clippy::should_implement_trait)]
36    pub fn from_str(s: &str) -> Self {
37        Self::try_parse(s).unwrap_or(Self::Get)
38    }
39
40    pub fn as_str(&self) -> &'static str {
41        match self {
42            Self::Get => "GET",
43            Self::Post => "POST",
44            Self::Put => "PUT",
45            Self::Patch => "PATCH",
46            Self::Delete => "DELETE",
47            Self::Options => "OPTIONS",
48            Self::Head => "HEAD",
49        }
50    }
51
52    /// True for methods that never have a request body.
53    pub fn is_bodyless(&self) -> bool {
54        matches!(self, Self::Get | Self::Head | Self::Options | Self::Delete)
55    }
56}
57
58impl fmt::Display for HttpMethod {
59    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
60        f.write_str(self.as_str())
61    }
62}
63
64// ---------------------------------------------------------------------------
65// DataError — platform-agnostic error from data operations
66// ---------------------------------------------------------------------------
67
68#[derive(Debug, Clone)]
69pub struct DataError {
70    pub code: String,
71    pub message: String,
72}
73
74impl fmt::Display for DataError {
75    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
76        write!(f, "[{}] {}", self.code, self.message)
77    }
78}
79
80impl std::error::Error for DataError {}
81
82// ---------------------------------------------------------------------------
83// DataStore — platform-agnostic data access trait
84// ---------------------------------------------------------------------------
85
86/// Platform-agnostic data store trait.
87///
88/// Implemented by `Runtime` (SQLite, self-hosted) and `D1DataStore` (Workers).
89/// All methods are synchronous to keep the trait `Send + Sync` and simple;
90/// Workers adapters can use `block_on` or similar bridging.
91pub trait DataStore: Send + Sync {
92    fn manifest(&self) -> &pylon_kernel::AppManifest;
93
94    fn insert(&self, entity: &str, data: &serde_json::Value) -> Result<String, DataError>;
95
96    fn get_by_id(&self, entity: &str, id: &str) -> Result<Option<serde_json::Value>, DataError>;
97
98    fn list(&self, entity: &str) -> Result<Vec<serde_json::Value>, DataError>;
99
100    fn list_after(
101        &self,
102        entity: &str,
103        after: Option<&str>,
104        limit: usize,
105    ) -> Result<Vec<serde_json::Value>, DataError>;
106
107    fn update(&self, entity: &str, id: &str, data: &serde_json::Value) -> Result<bool, DataError>;
108
109    fn delete(&self, entity: &str, id: &str) -> Result<bool, DataError>;
110
111    fn lookup(
112        &self,
113        entity: &str,
114        field: &str,
115        value: &str,
116    ) -> Result<Option<serde_json::Value>, DataError>;
117
118    fn link(
119        &self,
120        entity: &str,
121        id: &str,
122        relation: &str,
123        target_id: &str,
124    ) -> Result<bool, DataError>;
125
126    fn unlink(&self, entity: &str, id: &str, relation: &str) -> Result<bool, DataError>;
127
128    fn query_filtered(
129        &self,
130        entity: &str,
131        filter: &serde_json::Value,
132    ) -> Result<Vec<serde_json::Value>, DataError>;
133
134    fn query_graph(&self, query: &serde_json::Value) -> Result<serde_json::Value, DataError>;
135
136    /// Run an aggregation query.
137    ///
138    /// Spec shape (same vocabulary in the HTTP body):
139    /// ```json
140    /// {
141    ///   "count": "*",
142    ///   "sum": ["amount"],
143    ///   "avg": ["price"],
144    ///   "min": ["createdAt"],
145    ///   "max": ["createdAt"],
146    ///   "groupBy": ["status"],
147    ///   "where": { ...standard filter... }
148    /// }
149    /// ```
150    /// Returns `{rows: [{count, sum_amount, ...}]}`.
151    /// Default implementation returns `NOT_SUPPORTED`; Runtime overrides it.
152    fn aggregate(
153        &self,
154        _entity: &str,
155        _spec: &serde_json::Value,
156    ) -> Result<serde_json::Value, DataError> {
157        Err(DataError {
158            code: "NOT_SUPPORTED".into(),
159            message: "aggregate() is not implemented by this backend".into(),
160        })
161    }
162
163    /// Execute transactional operations. Each element is a JSON object with
164    /// `op` ("insert"/"update"/"delete"), `entity`, and optionally `id`/`data`.
165    ///
166    /// Returns per-operation results. The implementation decides whether to
167    /// use real SQL transactions (Runtime) or sequential execution (D1).
168    fn transact(
169        &self,
170        ops: &[serde_json::Value],
171    ) -> Result<(bool, Vec<serde_json::Value>), DataError>;
172
173    /// Run a faceted full-text search against a searchable entity. `query`
174    /// is a JSON object with the keys defined by `SearchQuery` in
175    /// `pylon_storage::search`; returns a JSON object shaped like
176    /// `SearchResult` (`{ hits, facetCounts, total, tookMs }`).
177    ///
178    /// Default impl returns `NOT_SUPPORTED`; Runtime overrides it. The
179    /// value is raw JSON (not a typed struct) so backends without a
180    /// dependency on pylon-storage can still compile.
181    fn search(
182        &self,
183        _entity: &str,
184        _query: &serde_json::Value,
185    ) -> Result<serde_json::Value, DataError> {
186        Err(DataError {
187            code: "NOT_SUPPORTED".into(),
188            message: "search() is not implemented by this backend".into(),
189        })
190    }
191
192    /// Return the binary CRDT snapshot for a row, used by the router
193    /// to ship a binary update over WebSocket after every successful
194    /// write.
195    ///
196    /// Return value semantics:
197    /// - `Ok(Some(bytes))` — entity is CRDT-mode and bytes are the
198    ///   current Loro snapshot for the row.
199    /// - `Ok(None)` — **either** the entity is `crdt: false` (LWW
200    ///   opt-out) **or** this backend doesn't support CRDT mode at
201    ///   all. Callers MUST treat both cases identically: skip the
202    ///   binary broadcast and rely on the JSON change event for
203    ///   client invalidation. The conflation is intentional — every
204    ///   caller today does the same thing in both cases, and a
205    ///   richer enum (NotCrdtMode / NotSupported) would be carried
206    ///   through every layer for no behavioral payoff.
207    /// - `Err(_)` — entity is CRDT-mode but the snapshot fetch
208    ///   itself failed (schema lookup, sidecar read, decode). Log
209    ///   and continue; the JSON change event already covers the
210    ///   correctness path.
211    ///
212    /// Default impl returns `Ok(None)` so backends that don't support
213    /// CRDT mode (e.g. the Workers D1 store at time of writing)
214    /// compile without ceremony. Per the Ok(None) semantics above,
215    /// this is correct behavior, not a stub.
216    fn crdt_snapshot(&self, _entity: &str, _row_id: &str) -> Result<Option<Vec<u8>>, DataError> {
217        Ok(None)
218    }
219
220    /// Apply a binary CRDT update from a client to the row's LoroDoc,
221    /// project the new state into the SQLite materialized view, and
222    /// return the post-merge snapshot bytes (so the caller can
223    /// broadcast them to OTHER subscribed clients).
224    ///
225    /// `update` is opaque Loro bytes — either a snapshot or an
226    /// incremental delta. Loro's import contract accepts both shapes,
227    /// so the store doesn't need to know which the client sent.
228    ///
229    /// Errors:
230    /// - `ENTITY_NOT_FOUND` — unknown entity in the manifest.
231    /// - `NOT_SUPPORTED` — entity is `crdt: false` (LWW opt-out) or
232    ///   the backend doesn't implement CRDT mode.
233    /// - `CRDT_DECODE_FAILED` — bytes weren't a valid Loro update.
234    /// - Storage failures from the underlying SQLite write.
235    ///
236    /// Default impl returns `NOT_SUPPORTED` so backends without CRDT
237    /// support compile cleanly.
238    fn crdt_apply_update(
239        &self,
240        _entity: &str,
241        _row_id: &str,
242        _update: &[u8],
243    ) -> Result<Vec<u8>, DataError> {
244        Err(DataError {
245            code: "NOT_SUPPORTED".into(),
246            message: "crdt_apply_update() is not implemented by this backend".into(),
247        })
248    }
249}
250
251#[cfg(test)]
252mod tests {
253    use super::*;
254
255    #[test]
256    fn http_method_roundtrip() {
257        assert_eq!(HttpMethod::from_str("GET"), HttpMethod::Get);
258        assert_eq!(HttpMethod::from_str("post"), HttpMethod::Post);
259        assert_eq!(HttpMethod::from_str("DELETE"), HttpMethod::Delete);
260        assert_eq!(HttpMethod::Get.as_str(), "GET");
261    }
262
263    #[test]
264    fn data_error_display() {
265        let e = DataError {
266            code: "TEST".into(),
267            message: "fail".into(),
268        };
269        assert_eq!(format!("{e}"), "[TEST] fail");
270    }
271}