Skip to main content

quarb_firebase/
lib.rs

1//! Firebase Realtime Database adapter for the Quarb query engine.
2//!
3//! An RTDB *is* a JSON tree, so the mapping is `quarb-json`'s —
4//! objects' fields (and arrays' elements) as named children,
5//! scalars carrying the value, traits and `;;;type` naming the
6//! JSON kind — but the tree lives on the other end of a REST API
7//! and can be enormous (the public Hacker News database has tens
8//! of millions of nodes), so nothing is ever fetched whole.
9//!
10//! **Loading model: everything lazy, one node at a time.** A node
11//! materializes on first touch with a `?shallow=true` GET — a
12//! scalar arrives as its value, a container as its key set — and
13//! is cached for the adapter's lifetime. Two consequences to
14//! respect: each newly-touched node is one HTTP request, and
15//! *unanchored descent* (`//name`) over a large database walks
16//! everything it touches — keep queries anchored the way you'd
17//! keep BigQuery queries off `SELECT *`.
18//!
19//! **Properties are direct fetches.** `::field` on a node GETs
20//! `path/field.json` — the cheapest possible request (one
21//! scalar, no shallow walk of siblings). This is a deliberate
22//! ergonomic divergence from `quarb-json` (where fields are only
23//! child hops): on a remote tree, `/items/42::score` should cost
24//! one tiny request, and does.
25//!
26//! **References resolve by hint.** RTDB has no schema, so `~>`
27//! always takes a hint naming a root-relative container:
28//! `::parent~>item` reads the `parent` field and lands on
29//! `<base>/item/<value>` — the same convention as the relational
30//! adapters' hint (the target "table"). Chains work.
31//!
32//! **Target syntax**: `firebase://HOST/BASE/PATH[?QUERY]` — e.g.
33//! `firebase://hacker-news.firebaseio.com/v0`. HTTPS is assumed.
34//! Anything in the query string is appended to every request:
35//! `?auth=SECRET` (legacy tokens) or `?access_token=TOKEN`
36//! (OAuth2) authenticate private databases; public ones need
37//! nothing.
38//!
39//! **Declared references.** The database holds no schema, so the
40//! reference schema can be supplied client-side: a *refs*
41//! document mapping field names to root-relative target
42//! containers —
43//! `{"refs": {"parent": "item", "by": "user", "kids/*": "item"}}`
44//! (the `field/*` form declares an array field whose *elements*
45//! reference the target). With refs supplied, bare `~>` resolves
46//! (`::parent~>`), and `->` crosslinks enumerate: every declared
47//! field with a value becomes a labeled, probed edge, including
48//! one edge per element for array fields. An inline hint always
49//! overrides. Reverse resolution (`<~`) stays empty: it would
50//! require scanning the referrer container, which is exactly what
51//! opaque containers refuse (a server-side `.indexOn` query is
52//! the recorded v2 path).
53//!
54//! The adapter only ever GETs; the language stays read-only.
55
56use quarb::{AstAdapter, NodeId, Value};
57use serde_json::Value as Json;
58use std::cell::RefCell;
59use std::collections::HashMap;
60
61/// An error connecting to or reading a database.
62#[derive(Debug, thiserror::Error)]
63pub enum FirebaseError {
64    #[error("firebase: {0}")]
65    Http(#[from] Box<ureq::Error>),
66    #[error("firebase: {0}")]
67    Api(String),
68    #[error("firebase target: {0} (expected firebase://HOST/BASE/PATH[?QUERY])")]
69    Target(String),
70}
71
72/// What a fetched node turned out to be.
73enum Kind {
74    /// A scalar (string, number, boolean) — or JSON null.
75    Scalar(Value),
76    /// A container: children in deterministic order (numeric keys
77    /// numerically, then the rest lexically — array-ish nodes read
78    /// in element order).
79    Container(Vec<NodeId>),
80    /// A container that refuses enumeration (permission-scoped or
81    /// unbounded — the database answered the shallow GET with a
82    /// 401). Its children are reachable by *name* only: `/item/1`
83    /// navigates, `/item/*` is empty. This is the honest shape of
84    /// databases like the public Hacker News API, whose `/v0/item`
85    /// holds tens of millions of keys.
86    Opaque,
87}
88
89struct Node {
90    /// The RTDB path below the base, `""` for the root.
91    path: String,
92    name: Option<String>,
93    parent: Option<NodeId>,
94    /// `None` until first touch.
95    kind: RefCell<Option<Kind>>,
96}
97
98/// A failed GET, classified so the caller can tell a
99/// permission-denied container (an exact `401`) from a real
100/// error — and so the request URL, which carries the `?auth=…`
101/// secret, never enters the message.
102enum GetError {
103    /// A non-2xx HTTP status.
104    Status(u16),
105    /// A transport or decode failure. Built from ureq's error
106    /// *kind* (and its higher-level detail: host, scheme), never
107    /// its URL — so `?auth=…` / `access_token=…` cannot leak.
108    Other(String),
109}
110
111impl std::fmt::Display for GetError {
112    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
113        match self {
114            GetError::Status(code) => write!(f, "status code {code}"),
115            GetError::Other(msg) => f.write_str(msg),
116        }
117    }
118}
119
120/// A declared reference schema: field name (or `field/*` for
121/// array elements) → root-relative target container.
122pub type Refs = std::collections::HashMap<String, String>;
123
124/// Parse a refs document: `{"refs": {"parent": "item", ...}}`.
125pub fn parse_refs(text: &str) -> Result<Refs, FirebaseError> {
126    let json: Json = serde_json::from_str(text)
127        .map_err(|e| FirebaseError::Api(format!("refs document: {e}")))?;
128    let map = json
129        .get("refs")
130        .and_then(|v| v.as_object())
131        .ok_or_else(|| {
132            FirebaseError::Api(
133                "refs document: expected {\"refs\": {\"field\": \"container\"}}".into(),
134            )
135        })?;
136    map.iter()
137        .map(|(k, v)| {
138            v.as_str()
139                .map(|t| (k.clone(), t.to_string()))
140                .ok_or_else(|| {
141                    FirebaseError::Api(format!("refs document: '{k}' target must be a string"))
142                })
143        })
144        .collect()
145}
146
147/// A Firebase Realtime Database (subtree), exposed as an arbor.
148pub struct FirebaseAdapter {
149    /// `https://HOST/BASE/PATH` (no trailing slash).
150    base: String,
151    /// The query string to append to every request (auth).
152    query: String,
153    nodes: RefCell<Vec<Node>>,
154    /// path → node, so re-discovered nodes intern to the same id.
155    by_path: RefCell<HashMap<String, NodeId>>,
156    /// The declared reference schema (client-side; the database
157    /// has none).
158    refs: Refs,
159}
160
161impl FirebaseAdapter {
162    /// Connect to `firebase://HOST/BASE/PATH[?QUERY]`. Verifies
163    /// the target answers (one shallow GET of the root).
164    pub fn connect(target: &str) -> Result<Self, FirebaseError> {
165        Self::connect_with_refs(target, Refs::new())
166    }
167
168    /// [`connect`], with a declared reference schema (see the
169    /// module doc): bare `~>` and `->` crosslinks work for the
170    /// declared fields.
171    pub fn connect_with_refs(target: &str, refs: Refs) -> Result<Self, FirebaseError> {
172        let rest = target
173            .strip_prefix("firebase://")
174            .ok_or_else(|| FirebaseError::Target(target.to_string()))?;
175        let (path, query) = match rest.split_once('?') {
176            Some((p, q)) => (p, q.to_string()),
177            None => (rest, String::new()),
178        };
179        if path.is_empty() {
180            return Err(FirebaseError::Target(target.to_string()));
181        }
182        let adapter = FirebaseAdapter {
183            base: format!("https://{}", path.trim_end_matches('/')),
184            query,
185            nodes: RefCell::new(vec![Node {
186                path: String::new(),
187                name: None,
188                parent: None,
189                kind: RefCell::new(None),
190            }]),
191            by_path: RefCell::new(HashMap::new()),
192            refs,
193        };
194        // Touch the root: transport errors surface here, not
195        // mid-query. A 401 is not fatal — it marks the root
196        // opaque (readable by name, not enumerable), which is how
197        // permission-scoped databases answer.
198        adapter
199            .fetch(NodeId(0))
200            .map_err(|e| FirebaseError::Api(format!("probing the database root: {e}")))?;
201        Ok(adapter)
202    }
203
204    /// A human-readable locator: the RTDB path.
205    pub fn locator(&self, node: NodeId) -> String {
206        let path = &self.nodes.borrow()[node.0 as usize].path;
207        if path.is_empty() {
208            "/".to_string()
209        } else {
210            format!("/{path}")
211        }
212    }
213
214    fn url(&self, path: &str, shallow: bool) -> String {
215        let mut url = if path.is_empty() {
216            format!("{}.json", self.base)
217        } else {
218            format!("{}/{path}.json", self.base)
219        };
220        let mut params = Vec::new();
221        if shallow {
222            params.push("shallow=true".to_string());
223        }
224        if !self.query.is_empty() {
225            params.push(self.query.clone());
226        }
227        if !params.is_empty() {
228            url.push('?');
229            url.push_str(&params.join("&"));
230        }
231        url
232    }
233
234    fn get(&self, url: &str) -> Result<Json, GetError> {
235        // Never stringify the ureq error itself: its `Display`
236        // splices the full request URL — auth secret and all —
237        // into the message. Classify structurally instead.
238        let resp = match ureq::get(url).call() {
239            Ok(resp) => resp,
240            Err(ureq::Error::Status(code, _)) => return Err(GetError::Status(code)),
241            Err(ureq::Error::Transport(t)) => {
242                let mut msg = t.kind().to_string();
243                if let Some(detail) = t.message() {
244                    msg.push_str(": ");
245                    msg.push_str(detail);
246                }
247                return Err(GetError::Other(msg));
248            }
249        };
250        resp.into_json()
251            .map_err(|e| GetError::Other(format!("decoding response: {e}")))
252    }
253
254    /// Intern a child node under `parent`.
255    fn intern(&self, parent: NodeId, key: &str) -> NodeId {
256        let path = {
257            let nodes = self.nodes.borrow();
258            let ppath = &nodes[parent.0 as usize].path;
259            if ppath.is_empty() {
260                key.to_string()
261            } else {
262                format!("{ppath}/{key}")
263            }
264        };
265        if let Some(&id) = self.by_path.borrow().get(&path) {
266            return id;
267        }
268        let mut nodes = self.nodes.borrow_mut();
269        let id = NodeId(nodes.len() as u64);
270        nodes.push(Node {
271            path: path.clone(),
272            name: Some(key.to_string()),
273            parent: Some(parent),
274            kind: RefCell::new(None),
275        });
276        self.by_path.borrow_mut().insert(path, id);
277        id
278    }
279
280    /// Materialize a node on first touch: one shallow GET. Errors
281    /// degrade to an empty container with a warning on stderr (the
282    /// adapter trait has no error channel mid-navigation).
283    fn fetch(&self, node: NodeId) -> Result<(), String> {
284        let (path, fetched) = {
285            let nodes = self.nodes.borrow();
286            let n = &nodes[node.0 as usize];
287            (n.path.clone(), n.kind.borrow().is_some())
288        };
289        if fetched {
290            return Ok(());
291        }
292        let json = match self.get(&self.url(&path, true)) {
293            Ok(j) => j,
294            // Permission-scoped or unbounded containers answer
295            // enumeration with a 401: mark opaque, keep navigating
296            // by name. Match the status structurally — a substring
297            // test misfired on any error (e.g. a 500) whose URL or
298            // message merely contained "401".
299            Err(GetError::Status(401)) => {
300                *self.nodes.borrow()[node.0 as usize].kind.borrow_mut() = Some(Kind::Opaque);
301                return Ok(());
302            }
303            Err(e) => return Err(e.to_string()),
304        };
305        let kind = match &json {
306            Json::Object(map) => {
307                // Deterministic order: numeric keys numerically
308                // (array-ish nodes in element order), the rest
309                // lexically after them.
310                let mut keys: Vec<&String> = map.keys().collect();
311                keys.sort_by(|a, b| match (a.parse::<i64>(), b.parse::<i64>()) {
312                    (Ok(x), Ok(y)) => x.cmp(&y),
313                    (Ok(_), Err(_)) => std::cmp::Ordering::Less,
314                    (Err(_), Ok(_)) => std::cmp::Ordering::Greater,
315                    (Err(_), Err(_)) => a.cmp(b),
316                });
317                let children = keys.iter().map(|k| self.intern(node, k)).collect();
318                Kind::Container(children)
319            }
320            Json::Array(items) => {
321                let children = (0..items.len())
322                    .map(|i| self.intern(node, &i.to_string()))
323                    .collect();
324                Kind::Container(children)
325            }
326            other => Kind::Scalar(scalar_of(other)),
327        };
328        *self.nodes.borrow()[node.0 as usize].kind.borrow_mut() = Some(kind);
329        Ok(())
330    }
331
332    fn touched(&self, node: NodeId) {
333        if let Err(e) = self.fetch(node) {
334            let path = self.locator(node);
335            eprintln!("quarb-firebase: fetching {path}: {e}");
336            *self.nodes.borrow()[node.0 as usize].kind.borrow_mut() =
337                Some(Kind::Container(Vec::new()));
338        }
339    }
340
341    /// One field of a node, as a single direct GET (cached as an
342    /// interned child).
343    fn field(&self, node: NodeId, name: &str) -> Option<Value> {
344        let child = self.intern(node, name);
345        self.touched(child);
346        let nodes = self.nodes.borrow();
347        match &*nodes[child.0 as usize].kind.borrow() {
348            Some(Kind::Scalar(v)) => match v {
349                Value::Null => None,
350                other => Some(other.clone()),
351            },
352            _ => None,
353        }
354    }
355}
356
357/// The scalar value of a JSON primitive.
358fn scalar_of(value: &Json) -> Value {
359    match value {
360        Json::Bool(b) => Value::Bool(*b),
361        Json::Number(n) => n
362            .as_i64()
363            .map(Value::Int)
364            .or_else(|| n.as_f64().map(Value::Float))
365            .unwrap_or(Value::Null),
366        Json::String(s) => Value::Str(s.clone()),
367        _ => Value::Null,
368    }
369}
370
371impl AstAdapter for FirebaseAdapter {
372    fn root(&self) -> NodeId {
373        NodeId(0)
374    }
375
376    fn children(&self, node: NodeId) -> Vec<NodeId> {
377        self.touched(node);
378        let nodes = self.nodes.borrow();
379        match &*nodes[node.0 as usize].kind.borrow() {
380            Some(Kind::Container(c)) => c.clone(),
381            _ => Vec::new(),
382        }
383    }
384
385    fn name(&self, node: NodeId) -> Option<String> {
386        self.nodes.borrow()[node.0 as usize].name.clone()
387    }
388
389    fn parent(&self, node: NodeId) -> Option<NodeId> {
390        self.nodes.borrow()[node.0 as usize].parent
391    }
392
393    /// Name-addressed navigation — the path through opaque
394    /// containers (one direct GET; no enumeration). Enumerated
395    /// containers answer from their key set without a request.
396    fn children_named(&self, node: NodeId, name: &str) -> Vec<NodeId> {
397        self.touched(node);
398        {
399            let nodes = self.nodes.borrow();
400            match &*nodes[node.0 as usize].kind.borrow() {
401                Some(Kind::Scalar(_)) => return Vec::new(),
402                Some(Kind::Container(c)) => {
403                    return c
404                        .iter()
405                        .copied()
406                        .filter(|&c| nodes[c.0 as usize].name.as_deref() == Some(name))
407                        .collect();
408                }
409                _ => {}
410            }
411        }
412        // Opaque: probe the named child directly.
413        let child = self.intern(node, name);
414        self.touched(child);
415        let nodes = self.nodes.borrow();
416        match &*nodes[child.0 as usize].kind.borrow() {
417            Some(Kind::Scalar(Value::Null)) | None => Vec::new(),
418            _ => vec![child],
419        }
420    }
421
422    /// The JSON kind, once known: `<object>` / `<string>` /
423    /// `<number>` / `<boolean>`.
424    fn traits(&self, node: NodeId) -> Vec<String> {
425        self.touched(node);
426        let nodes = self.nodes.borrow();
427        let t = match &*nodes[node.0 as usize].kind.borrow() {
428            Some(Kind::Container(_) | Kind::Opaque) => "object",
429            Some(Kind::Scalar(Value::Str(_))) => "string",
430            Some(Kind::Scalar(Value::Int(_) | Value::Float(_))) => "number",
431            Some(Kind::Scalar(Value::Bool(_))) => "boolean",
432            _ => "null",
433        };
434        vec![t.to_string()]
435    }
436
437    /// `::field` — one direct GET of `path/field.json`.
438    fn property(&self, node: NodeId, name: &str) -> Option<Value> {
439        self.field(node, name)
440    }
441
442    /// A scalar projects to its value; a container has no default
443    /// projection.
444    fn default_value(&self, node: NodeId) -> Option<Value> {
445        self.touched(node);
446        let nodes = self.nodes.borrow();
447        match &*nodes[node.0 as usize].kind.borrow() {
448            Some(Kind::Scalar(v)) => Some(v.clone()),
449            _ => None,
450        }
451    }
452
453    /// `;;;type`, `;;;length` (children of a container), and
454    /// `;;;path` (the RTDB path — the node's address in the
455    /// database).
456    fn metadata(&self, node: NodeId, key: &str) -> Option<Value> {
457        match key {
458            "path" => Some(Value::Str(self.locator(node))),
459            "type" => Some(Value::Str(self.traits(node).remove(0))),
460            "length" => {
461                self.touched(node);
462                let nodes = self.nodes.borrow();
463                match &*nodes[node.0 as usize].kind.borrow() {
464                    Some(Kind::Container(c)) => Some(Value::Int(c.len() as i64)),
465                    Some(Kind::Scalar(Value::Str(s))) => Some(Value::Int(s.chars().count() as i64)),
466                    _ => None,
467                }
468            }
469            _ => None,
470        }
471    }
472
473    /// Hint-based only (RTDB has no schema): `::parent~>item`
474    /// reads the `parent` field and lands on `<base>/item/<value>`
475    /// — the hint names a root-relative container, like the
476    /// relational adapters' target table.
477    fn resolve(&self, node: NodeId, property: &str, hint: Option<&str>) -> Option<NodeId> {
478        let container = hint.or_else(|| self.refs.get(property).map(String::as_str))?;
479        let value = self.field(node, property)?;
480        let root_child = self.intern(NodeId(0), container);
481        let target = self.intern(root_child, &value.to_string());
482        self.touched(target);
483        let nodes = self.nodes.borrow();
484        match &*nodes[target.0 as usize].kind.borrow() {
485            Some(Kind::Scalar(Value::Null)) | None => None,
486            _ => Some(target),
487        }
488    }
489
490    /// Every declared field with a value is an outgoing crosslink,
491    /// labeled by the field name; a `field/*` declaration yields
492    /// one edge per array element. Each edge is probed (one GET)
493    /// so dangling references stay out.
494    fn links(&self, node: NodeId) -> Vec<(String, NodeId)> {
495        let mut declared: Vec<(&String, &String)> = self.refs.iter().collect();
496        declared.sort();
497        let mut out = Vec::new();
498        for (field, target) in declared {
499            let root_child = self.intern(NodeId(0), target);
500            if let Some(elem_field) = field.strip_suffix("/*") {
501                let container = self.intern(node, elem_field);
502                for elem in self.children(container) {
503                    let Some(v) = self.default_value(elem) else {
504                        continue;
505                    };
506                    let t = self.intern(root_child, &v.to_string());
507                    self.touched(t);
508                    let nodes = self.nodes.borrow();
509                    if !matches!(
510                        &*nodes[t.0 as usize].kind.borrow(),
511                        Some(Kind::Scalar(Value::Null)) | None
512                    ) {
513                        out.push((elem_field.to_string(), t));
514                    }
515                }
516            } else if let Some(v) = self.field(node, field) {
517                let t = self.intern(root_child, &v.to_string());
518                self.touched(t);
519                let nodes = self.nodes.borrow();
520                if !matches!(
521                    &*nodes[t.0 as usize].kind.borrow(),
522                    Some(Kind::Scalar(Value::Null)) | None
523                ) {
524                    out.push((field.clone(), t));
525                }
526            }
527        }
528        out
529    }
530}