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}