Skip to main content

nodedb_client/
traits.rs

1// SPDX-License-Identifier: Apache-2.0
2
3//! The `NodeDb` trait: unified query interface for both Origin and Lite.
4//!
5//! Application code writes against this trait once. The runtime determines
6//! whether queries execute locally (in-memory engines on Lite) or remotely
7//! (pgwire to Origin).
8//!
9//! All methods are `async` — on native this runs on Tokio, on WASM this
10//! runs on `wasm-bindgen-futures`.
11
12use std::sync::Arc;
13
14use async_trait::async_trait;
15
16use nodedb_types::document::Document;
17use nodedb_types::dropped_collection::DroppedCollection;
18use nodedb_types::error::{NodeDbError, NodeDbResult};
19use nodedb_types::filter::{EdgeFilter, MetadataFilter};
20use nodedb_types::id::{EdgeId, NodeId};
21use nodedb_types::protocol::Limits;
22use nodedb_types::result::{QueryResult, SearchResult, SubGraph};
23use nodedb_types::text_search::TextSearchParams;
24use nodedb_types::value::Value;
25
26/// Event passed to `NodeDb::on_collection_purged` handlers.
27///
28/// Emitted on the sync client when Origin pushes a `CollectionPurged`
29/// wire message and on Lite after local hard-delete completes, so
30/// application code can flush UI caches, drop derived indexes, etc.
31/// Handler callsites must not block — the dispatch path is on the
32/// sync client's receive loop.
33#[derive(Debug, Clone)]
34pub struct CollectionPurgedEvent {
35    pub tenant_id: u64,
36    pub name: String,
37    /// WAL LSN at which the purge was applied. Handlers can compare
38    /// this against locally-observed LSNs for resume/replay logic.
39    pub purge_lsn: u64,
40}
41
42/// Handler registered via `NodeDb::on_collection_purged`. Fn-ref
43/// (not FnMut) so the same handler can fire from multiple threads
44/// without interior mutability ceremony at every call site.
45pub type CollectionPurgedHandler = Arc<dyn Fn(CollectionPurgedEvent) + Send + Sync + 'static>;
46
47/// Marker bound for `NodeDb` and the futures it returns.
48///
49/// On native targets the bound is `Send + Sync` — matching the multi-thread
50/// Tokio runtime that backs both Origin and the desktop / mobile-FFI Lite
51/// callers. On `wasm32` the bound is empty: JS is single-threaded, so
52/// requiring `Send` on futures returned by the trait would force every
53/// `!Send` engine internal (redb transactions, `Rc<...>`, etc.) to be
54/// rewritten for no benefit.
55///
56/// The `#[async_trait]` attribute on the trait + each impl is correspondingly
57/// cfg-swapped between the default (`Send` futures) and `?Send` (no `Send`
58/// bound) variants.
59#[cfg(not(target_arch = "wasm32"))]
60pub trait NodeDbMarker: Send + Sync {}
61#[cfg(not(target_arch = "wasm32"))]
62impl<T: Send + Sync + ?Sized> NodeDbMarker for T {}
63
64#[cfg(target_arch = "wasm32")]
65pub trait NodeDbMarker {}
66#[cfg(target_arch = "wasm32")]
67impl<T: ?Sized> NodeDbMarker for T {}
68
69/// Unified database interface for NodeDB.
70///
71/// Two implementations:
72/// - `NodeDbLite`: executes queries against in-memory HNSW/CSR/Loro engines
73///   on the edge device. Writes produce CRDT deltas synced to Origin in background.
74/// - `NodeDbRemote`: translates trait calls into parameterized SQL and sends
75///   them over pgwire to the Origin cluster.
76///
77/// The developer writes agent logic once. Switching between local and cloud
78/// is a one-line configuration change.
79#[cfg_attr(not(target_arch = "wasm32"), async_trait)]
80#[cfg_attr(target_arch = "wasm32", async_trait(?Send))]
81pub trait NodeDb: NodeDbMarker {
82    // ─── Vector Operations ───────────────────────────────────────────
83
84    /// Search for the `k` nearest vectors to `query` in `collection`.
85    ///
86    /// Returns results ordered by ascending distance. Optional metadata
87    /// filter constrains which vectors are considered.
88    ///
89    /// On Lite: direct in-memory HNSW search. Sub-millisecond.
90    /// On Remote: translated to `SELECT ... ORDER BY embedding <-> $1 LIMIT $2`.
91    async fn vector_search(
92        &self,
93        collection: &str,
94        query: &[f32],
95        k: usize,
96        filter: Option<&MetadataFilter>,
97    ) -> NodeDbResult<Vec<SearchResult>>;
98
99    /// Insert a vector with optional metadata into `collection`.
100    ///
101    /// On Lite: inserts into in-memory HNSW + emits CRDT delta + persists to SQLite.
102    /// On Remote: translated to `INSERT INTO collection (id, embedding, metadata) VALUES (...)`.
103    async fn vector_insert(
104        &self,
105        collection: &str,
106        id: &str,
107        embedding: &[f32],
108        metadata: Option<Document>,
109    ) -> NodeDbResult<()>;
110
111    /// Delete a vector by ID from `collection`.
112    ///
113    /// On Lite: marks deleted in HNSW + emits CRDT tombstone.
114    /// On Remote: `DELETE FROM collection WHERE id = $1`.
115    async fn vector_delete(&self, collection: &str, id: &str) -> NodeDbResult<()>;
116
117    // ─── Graph Operations ────────────────────────────────────────────
118
119    /// Traverse the graph from `start` up to `depth` hops within
120    /// `collection`.
121    ///
122    /// `collection` names the graph collection holding the adjacency
123    /// data. NodeDB's graph overlay scopes edges per collection, so the
124    /// caller picks which graph to walk. Returns the discovered subgraph
125    /// (nodes + edges). Optional edge filter constrains which edges are
126    /// followed.
127    ///
128    /// On Lite: direct CSR pointer-chasing in contiguous memory. Microseconds.
129    /// On Remote: `GRAPH TRAVERSE FROM '<start>' DEPTH <n> [LABEL '<l>']`.
130    async fn graph_traverse(
131        &self,
132        collection: &str,
133        start: &NodeId,
134        depth: u8,
135        edge_filter: Option<&EdgeFilter>,
136    ) -> NodeDbResult<SubGraph>;
137
138    /// Insert a directed edge from `from` to `to` with the given label
139    /// into `collection`.
140    ///
141    /// Returns the generated edge ID.
142    ///
143    /// On Lite: appends to mutable adjacency buffer + CRDT delta + SQLite.
144    /// On Remote: `GRAPH INSERT EDGE IN '<collection>' FROM '<from>' TO '<to>' TYPE '<label>'`.
145    async fn graph_insert_edge(
146        &self,
147        collection: &str,
148        from: &NodeId,
149        to: &NodeId,
150        edge_type: &str,
151        properties: Option<Document>,
152    ) -> NodeDbResult<EdgeId>;
153
154    /// Delete a graph edge by ID from `collection`.
155    ///
156    /// On Lite: marks deleted + CRDT tombstone.
157    /// On Remote: `GRAPH DELETE EDGE IN '<collection>' FROM '<src>' TO '<dst>' TYPE '<label>'`.
158    async fn graph_delete_edge(&self, collection: &str, edge_id: &EdgeId) -> NodeDbResult<()>;
159
160    // ─── Document Operations ─────────────────────────────────────────
161
162    /// Get a document by ID from `collection`.
163    ///
164    /// On Lite: direct Loro state read. Sub-millisecond.
165    /// On Remote: `SELECT * FROM collection WHERE id = $1`.
166    async fn document_get(&self, collection: &str, id: &str) -> NodeDbResult<Option<Document>>;
167
168    /// Put (insert or update) a document into `collection`.
169    ///
170    /// The document's `id` field determines the key. If a document with that
171    /// ID already exists, it is overwritten (last-writer-wins locally; CRDT
172    /// merge on sync).
173    ///
174    /// On Lite: Loro apply + CRDT delta + SQLite persist.
175    /// On Remote: `INSERT ... ON CONFLICT (id) DO UPDATE SET ...`.
176    async fn document_put(&self, collection: &str, doc: Document) -> NodeDbResult<()>;
177
178    /// Delete a document by ID from `collection`.
179    ///
180    /// On Lite: Loro delete + CRDT tombstone.
181    /// On Remote: `DELETE FROM collection WHERE id = $1`.
182    async fn document_delete(&self, collection: &str, id: &str) -> NodeDbResult<()>;
183
184    // ─── Named Vector Fields ──────────────────────────────────────────
185
186    /// Insert a vector into a named field within a collection.
187    ///
188    /// Enables multiple embeddings per collection (e.g., "title_embedding",
189    /// "body_embedding") with independent HNSW indexes.
190    ///
191    /// Default returns `Err` — silently delegating to `vector_insert` and
192    /// dropping `field_name` would land the vector in the wrong field.
193    /// Implementations that route through to a server with field-aware
194    /// support must override.
195    async fn vector_insert_field(
196        &self,
197        collection: &str,
198        field_name: &str,
199        id: &str,
200        embedding: &[f32],
201        metadata: Option<Document>,
202    ) -> NodeDbResult<()> {
203        let _ = (collection, id, embedding, metadata);
204        Err(NodeDbError::storage(format!(
205            "vector_insert_field is not implemented on this client; \
206             field_name={field_name} would have been silently dropped"
207        )))
208    }
209
210    /// Search a named vector field.
211    ///
212    /// Default returns `Err` — silently delegating to `vector_search`
213    /// and dropping `field_name` would search the wrong field.
214    /// Implementations that route through to a server with field-aware
215    /// support must override.
216    async fn vector_search_field(
217        &self,
218        collection: &str,
219        field_name: &str,
220        query: &[f32],
221        k: usize,
222        filter: Option<&MetadataFilter>,
223    ) -> NodeDbResult<Vec<SearchResult>> {
224        let _ = (collection, query, k, filter);
225        Err(NodeDbError::storage(format!(
226            "vector_search_field is not implemented on this client; \
227             field_name={field_name} would have been silently dropped"
228        )))
229    }
230
231    // ─── Graph Shortest Path ────────────────────────────────────────
232
233    /// Find the shortest path between two nodes.
234    ///
235    /// Returns the path as a list of node IDs (`from` first, `to` last),
236    /// or `None` if no path exists within `max_depth` hops.
237    ///
238    /// Default: forward breadth-first search built on `graph_traverse`.
239    /// Each frontier expansion calls `graph_traverse(node, 1,
240    /// edge_filter)` to discover outgoing neighbors. Inherits the
241    /// underlying impl's edge direction semantics. Implementations with
242    /// a server-side shortest-path operator (e.g. NodeDB's
243    /// `GRAPH PATH FROM <src> TO <dst>` DSL) should override for
244    /// performance — round-tripping per-hop is O(path_length) wire
245    /// hops.
246    async fn graph_shortest_path(
247        &self,
248        collection: &str,
249        from: &NodeId,
250        to: &NodeId,
251        max_depth: u8,
252        edge_filter: Option<&EdgeFilter>,
253    ) -> NodeDbResult<Option<Vec<NodeId>>> {
254        if from == to {
255            return Ok(Some(vec![from.clone()]));
256        }
257        if max_depth == 0 {
258            return Ok(None);
259        }
260
261        // Map of `node -> parent` used to reconstruct the path once the
262        // target is reached. The source has no parent entry.
263        let mut parent: std::collections::HashMap<NodeId, NodeId> =
264            std::collections::HashMap::new();
265        let mut frontier: Vec<NodeId> = vec![from.clone()];
266
267        for _ in 0..max_depth {
268            let mut next_frontier: Vec<NodeId> = Vec::new();
269            for node in &frontier {
270                let sg = self
271                    .graph_traverse(collection, node, 1, edge_filter)
272                    .await?;
273                for edge in &sg.edges {
274                    // Only follow edges originating from the current
275                    // node — `graph_traverse` may include adjacent
276                    // edges that don't extend the BFS frontier.
277                    if &edge.from != node {
278                        continue;
279                    }
280                    let dst = &edge.to;
281                    if dst == from || parent.contains_key(dst) {
282                        continue;
283                    }
284                    parent.insert(dst.clone(), node.clone());
285                    if dst == to {
286                        let mut path = vec![to.clone()];
287                        let mut cur = to.clone();
288                        while &cur != from {
289                            let p = parent
290                                .get(&cur)
291                                .expect("BFS reached `to` so all ancestors are tracked")
292                                .clone();
293                            path.push(p.clone());
294                            cur = p;
295                        }
296                        path.reverse();
297                        return Ok(Some(path));
298                    }
299                    next_frontier.push(dst.clone());
300                }
301            }
302            if next_frontier.is_empty() {
303                return Ok(None);
304            }
305            frontier = next_frontier;
306        }
307        Ok(None)
308    }
309
310    // ─── Text Search ────────────────────────────────────────────────
311
312    /// Full-text search with BM25 scoring against the FTS-indexed
313    /// `field` on `collection`.
314    ///
315    /// NodeDB's FTS is per-field — every BM25 index is scoped to one
316    /// declared field, so the caller names which field to search.
317    /// Returns document IDs with relevance scores, ordered by
318    /// descending score. Pass [`TextSearchParams::default()`] for
319    /// standard OR-mode non-fuzzy search.
320    ///
321    /// Default returns `Err` — `Ok(Vec::new())` is indistinguishable
322    /// from a real "no matches" answer and would silently mask the
323    /// missing implementation. Implementations must override (e.g., a
324    /// `SEARCH IN '<collection>' FIELD '<field>' QUERY '<q>'` round-trip
325    /// via `execute_sql`).
326    async fn text_search(
327        &self,
328        collection: &str,
329        field: &str,
330        query: &str,
331        top_k: usize,
332        params: TextSearchParams,
333    ) -> NodeDbResult<Vec<SearchResult>> {
334        let _ = (collection, field, query, top_k, params);
335        Err(NodeDbError::storage(
336            "text_search is not implemented on this client",
337        ))
338    }
339
340    // ─── Batch Operations ───────────────────────────────────────────
341
342    /// Batch insert vectors — amortizes CRDT delta export to O(1) per batch.
343    async fn batch_vector_insert(
344        &self,
345        collection: &str,
346        vectors: &[(&str, &[f32])],
347    ) -> NodeDbResult<()> {
348        for &(id, embedding) in vectors {
349            self.vector_insert(collection, id, embedding, None).await?;
350        }
351        Ok(())
352    }
353
354    /// Batch insert graph edges into `collection` — amortizes CRDT
355    /// delta export to O(1) per batch.
356    async fn batch_graph_insert_edges(
357        &self,
358        collection: &str,
359        edges: &[(&str, &str, &str)],
360    ) -> NodeDbResult<()> {
361        for &(from, to, label) in edges {
362            let src = NodeId::try_new(from)
363                .map_err(|e| NodeDbError::storage(format!("invalid node id: {e}")))?;
364            let dst = NodeId::try_new(to)
365                .map_err(|e| NodeDbError::storage(format!("invalid node id: {e}")))?;
366            self.graph_insert_edge(collection, &src, &dst, label, None)
367                .await?;
368        }
369        Ok(())
370    }
371
372    // ─── Connection Metadata ─────────────────────────────────────────────
373
374    /// The protocol version negotiated during the connection handshake.
375    ///
376    /// Returns `0` for implementations that do not maintain a persistent
377    /// connection and therefore never perform a handshake.
378    fn proto_version(&self) -> u16 {
379        0
380    }
381
382    /// The raw capability bitfield advertised by the server.
383    ///
384    /// Returns `0` when no handshake was performed. Use
385    /// `Capabilities::from_raw(self.capabilities())` for named predicates.
386    fn capabilities(&self) -> u64 {
387        0
388    }
389
390    /// The server version string from `HelloAckFrame` (e.g. `"0.1.0-dev"`).
391    ///
392    /// Returns an empty string when no handshake was performed.
393    fn server_version(&self) -> String {
394        String::new()
395    }
396
397    /// Per-operation limits announced by the server.
398    ///
399    /// All fields are `None` when no handshake was performed — the caller
400    /// should treat `None` as "no server-side cap" for that dimension.
401    fn limits(&self) -> Limits {
402        Limits::default()
403    }
404
405    // ─── SQL Escape Hatch ────────────────────────────────────────────
406
407    /// Execute a raw SQL query with parameters.
408    ///
409    /// On Lite: requires the `sql` feature flag (compiles in DataFusion parser).
410    ///   Returns `NodeDbError::SqlNotEnabled` if the feature is not compiled in.
411    /// On Remote: pass-through to Origin via pgwire.
412    ///
413    /// For most AI agent workloads, the typed methods above are sufficient
414    /// and faster. Use this for BI tools, existing ORMs, or ad-hoc queries.
415    async fn execute_sql(&self, query: &str, params: &[Value]) -> NodeDbResult<QueryResult>;
416
417    // ─── Collection Lifecycle (soft-delete / undrop / hard-delete) ───
418
419    /// Restore a soft-deleted collection within its retention window.
420    ///
421    /// Equivalent to `UNDROP COLLECTION <name>`. Fails with 42P01 if
422    /// the retention window has elapsed and the row is gone, or with
423    /// 42501 if the caller is neither preserved owner nor admin.
424    ///
425    /// Default impl routes through `execute_sql` so any implementation
426    /// that can execute SQL inherits the correct behavior for free.
427    async fn undrop_collection(&self, name: &str) -> NodeDbResult<()> {
428        let sql = format!("UNDROP COLLECTION {}", quote_ident(name));
429        self.execute_sql(&sql, &[]).await?;
430        Ok(())
431    }
432
433    /// Hard-delete a collection, skipping soft-delete and retention.
434    ///
435    /// Equivalent to `DROP COLLECTION <name> PURGE`. Admin-only on the
436    /// server; the server rejects non-admin callers with 42501.
437    /// Bypasses the retention safety net — data is unrecoverable.
438    async fn drop_collection_purge(&self, name: &str) -> NodeDbResult<()> {
439        let sql = format!("DROP COLLECTION {} PURGE", quote_ident(name));
440        self.execute_sql(&sql, &[]).await?;
441        Ok(())
442    }
443
444    /// List every soft-deleted collection in the current tenant that
445    /// is still within its retention window.
446    ///
447    /// Equivalent to `SELECT tenant_id, name, owner, deactivated_at_ns,
448    /// retention_expires_at_ns FROM _system.dropped_collections`.
449    /// Returns `Vec<DroppedCollection>` — empty if no soft-deleted rows
450    /// exist for the caller's tenant.
451    async fn list_dropped_collections(&self) -> NodeDbResult<Vec<DroppedCollection>> {
452        let sql = "SELECT tenant_id, name, owner, engine_type, \
453                   deactivated_at_ns, retention_expires_at_ns \
454                   FROM _system.dropped_collections";
455        let result = self.execute_sql(sql, &[]).await?;
456        crate::row_decode::parse_dropped_collection_rows(&result.rows)
457    }
458
459    /// Register a handler fired when a collection the caller has
460    /// synced is purged on Origin and the local copy is removed.
461    ///
462    /// Default impl returns `NodeDbError::storage` with a
463    /// `"not supported"` detail — implementations that maintain a
464    /// sync client (Lite, any future push-capable remote client)
465    /// override with registration into their internal handler list.
466    /// Stateless clients (pgwire-only `NodeDbRemote`) have nothing
467    /// to push, so the default rejection is the correct behavior.
468    async fn on_collection_purged(&self, _handler: CollectionPurgedHandler) -> NodeDbResult<()> {
469        Err(NodeDbError::storage(
470            "on_collection_purged is not supported on this client — \
471             requires a push-capable sync connection (NodeDbLite or a \
472             sync-enabled remote client)",
473        ))
474    }
475}
476
477/// Quote a SQL identifier. Wraps in double-quotes only if the name
478/// contains anything other than `[A-Za-z0-9_]` or starts with a digit —
479/// the unquoted fast-path keeps the usual case cheap. Doubles any
480/// internal double-quotes per the SQL identifier-escape rule.
481///
482/// Lives next to the trait default impls (rather than in the remote
483/// client's `quote_identifier`) because the trait defaults for
484/// `undrop_collection` / `drop_collection_purge` build SQL without any
485/// feature-gated transport in scope.
486fn quote_ident(name: &str) -> String {
487    let needs_quote = name.is_empty()
488        || name.chars().next().is_some_and(|c| c.is_ascii_digit())
489        || !name.chars().all(|c| c.is_ascii_alphanumeric() || c == '_');
490    if needs_quote {
491        let escaped = name.replace('"', "\"\"");
492        format!("\"{escaped}\"")
493    } else {
494        name.to_string()
495    }
496}
497
498#[cfg(test)]
499mod tests {
500    use super::*;
501    use crate::capabilities::Capabilities;
502    use std::collections::HashMap;
503
504    /// Mock implementation to verify the trait is object-safe and
505    /// can be used as `Arc<dyn NodeDb>`.
506    struct MockDb;
507
508    #[cfg_attr(not(target_arch = "wasm32"), async_trait)]
509    #[cfg_attr(target_arch = "wasm32", async_trait(?Send))]
510    impl NodeDb for MockDb {
511        async fn vector_search(
512            &self,
513            _collection: &str,
514            _query: &[f32],
515            _k: usize,
516            _filter: Option<&MetadataFilter>,
517        ) -> NodeDbResult<Vec<SearchResult>> {
518            Ok(vec![SearchResult {
519                id: "vec-1".into(),
520                node_id: None,
521                distance: 0.1,
522                metadata: HashMap::new(),
523            }])
524        }
525
526        async fn vector_insert(
527            &self,
528            _collection: &str,
529            _id: &str,
530            _embedding: &[f32],
531            _metadata: Option<Document>,
532        ) -> NodeDbResult<()> {
533            Ok(())
534        }
535
536        async fn vector_delete(&self, _collection: &str, _id: &str) -> NodeDbResult<()> {
537            Ok(())
538        }
539
540        async fn graph_traverse(
541            &self,
542            _collection: &str,
543            _start: &NodeId,
544            _depth: u8,
545            _edge_filter: Option<&EdgeFilter>,
546        ) -> NodeDbResult<SubGraph> {
547            Ok(SubGraph::empty())
548        }
549
550        async fn graph_insert_edge(
551            &self,
552            _collection: &str,
553            from: &NodeId,
554            to: &NodeId,
555            edge_type: &str,
556            _properties: Option<Document>,
557        ) -> NodeDbResult<EdgeId> {
558            EdgeId::try_first(from.clone(), to.clone(), edge_type)
559                .map_err(|e| NodeDbError::storage(format!("invalid edge label: {e}")))
560        }
561
562        async fn graph_delete_edge(
563            &self,
564            _collection: &str,
565            _edge_id: &EdgeId,
566        ) -> NodeDbResult<()> {
567            Ok(())
568        }
569
570        async fn document_get(
571            &self,
572            _collection: &str,
573            id: &str,
574        ) -> NodeDbResult<Option<Document>> {
575            let mut doc = Document::new(id);
576            doc.set("title", Value::String("test".into()));
577            Ok(Some(doc))
578        }
579
580        async fn document_put(&self, _collection: &str, _doc: Document) -> NodeDbResult<()> {
581            Ok(())
582        }
583
584        async fn document_delete(&self, _collection: &str, _id: &str) -> NodeDbResult<()> {
585            Ok(())
586        }
587
588        async fn execute_sql(&self, _query: &str, _params: &[Value]) -> NodeDbResult<QueryResult> {
589            Ok(QueryResult::empty())
590        }
591    }
592
593    /// Verify the trait is object-safe (can be used as `dyn NodeDb`).
594    #[test]
595    fn trait_is_object_safe() {
596        fn _accepts_dyn(_db: &dyn NodeDb) {}
597        let db = MockDb;
598        _accepts_dyn(&db);
599    }
600
601    /// Verify the trait can be wrapped in `Arc<dyn NodeDb>`.
602    #[test]
603    fn trait_works_with_arc() {
604        use std::sync::Arc;
605        let db: Arc<dyn NodeDb> = Arc::new(MockDb);
606        // Just verify it compiles — the Arc<dyn> pattern is the primary API.
607        let _ = db;
608    }
609
610    #[tokio::test]
611    async fn mock_vector_search() {
612        let db = MockDb;
613        let results = db
614            .vector_search("embeddings", &[0.1, 0.2, 0.3], 5, None)
615            .await
616            .unwrap();
617        assert_eq!(results.len(), 1);
618        assert_eq!(results[0].id, "vec-1");
619        assert!(results[0].distance < 1.0);
620    }
621
622    #[tokio::test]
623    async fn mock_vector_insert_and_delete() {
624        let db = MockDb;
625        db.vector_insert("coll", "v1", &[1.0, 2.0], None)
626            .await
627            .unwrap();
628        db.vector_delete("coll", "v1").await.unwrap();
629    }
630
631    #[tokio::test]
632    async fn mock_graph_operations() {
633        let db = MockDb;
634        let start = NodeId::try_new("alice").expect("test fixture");
635        let subgraph = db.graph_traverse("social", &start, 2, None).await.unwrap();
636        assert_eq!(subgraph.node_count(), 0);
637
638        let from = NodeId::try_new("alice").expect("test fixture");
639        let to = NodeId::try_new("bob").expect("test fixture");
640        let edge_id = db
641            .graph_insert_edge("social", &from, &to, "KNOWS", None)
642            .await
643            .unwrap();
644        assert_eq!(edge_id.src.as_str(), "alice");
645        assert_eq!(edge_id.dst.as_str(), "bob");
646        assert_eq!(edge_id.label, "KNOWS");
647        assert_eq!(edge_id.seq, 0);
648
649        db.graph_delete_edge("social", &edge_id).await.unwrap();
650    }
651
652    #[tokio::test]
653    async fn mock_document_operations() {
654        let db = MockDb;
655        let doc = db.document_get("notes", "n1").await.unwrap().unwrap();
656        assert_eq!(doc.id, "n1");
657        assert_eq!(doc.get_str("title"), Some("test"));
658
659        let mut new_doc = Document::new("n2");
660        new_doc.set("body", Value::String("hello".into()));
661        db.document_put("notes", new_doc).await.unwrap();
662
663        db.document_delete("notes", "n1").await.unwrap();
664    }
665
666    #[tokio::test]
667    async fn mock_execute_sql() {
668        let db = MockDb;
669        let result = db.execute_sql("SELECT 1", &[]).await.unwrap();
670        assert_eq!(result.row_count(), 0);
671    }
672
673    /// Verify the full "one API, any runtime" pattern from the TDD.
674    #[tokio::test]
675    async fn unified_api_pattern() {
676        use std::sync::Arc;
677
678        // This is the pattern from NodeDB.md:
679        // let db: Arc<dyn NodeDb> = Arc::new(NodeDbLite::open(...));
680        //   OR
681        // let db: Arc<dyn NodeDb> = Arc::new(NodeDbRemote::connect(...));
682        //
683        // Application code is identical either way:
684        let db: Arc<dyn NodeDb> = Arc::new(MockDb);
685
686        let results = db
687            .vector_search("knowledge_base", &[0.1, 0.2], 5, None)
688            .await
689            .unwrap();
690        assert!(!results.is_empty());
691
692        let start = NodeId::from_validated(results[0].id.clone());
693        let _subgraph = db
694            .graph_traverse("knowledge_base", &start, 2, None)
695            .await
696            .unwrap();
697
698        let doc = Document::new("note-1");
699        db.document_put("notes", doc).await.unwrap();
700    }
701
702    /// Default `proto_version()` returns 0 for impls that do not override.
703    #[test]
704    fn default_proto_version_is_zero() {
705        let db = MockDb;
706        assert_eq!(db.proto_version(), 0);
707    }
708
709    /// Default `capabilities()` returns 0 for impls that do not override.
710    #[test]
711    fn default_capabilities_is_zero() {
712        let db = MockDb;
713        assert_eq!(db.capabilities(), 0);
714        // Wrapping in Capabilities gives all-false predicates.
715        let caps = Capabilities::from_raw(db.capabilities());
716        assert!(!caps.supports_streaming());
717        assert!(!caps.supports_graphrag());
718    }
719
720    /// Default `server_version()` returns an empty string.
721    #[test]
722    fn default_server_version_is_empty() {
723        let db = MockDb;
724        assert!(db.server_version().is_empty());
725    }
726
727    /// Default `limits()` returns all-None limits.
728    #[test]
729    fn default_limits_all_none() {
730        let db = MockDb;
731        let limits = db.limits();
732        assert!(limits.max_vector_dim.is_none());
733        assert!(limits.max_top_k.is_none());
734        assert!(limits.max_scan_limit.is_none());
735        assert!(limits.max_batch_size.is_none());
736        assert!(limits.max_crdt_delta_bytes.is_none());
737        assert!(limits.max_query_text_bytes.is_none());
738        assert!(limits.max_graph_depth.is_none());
739    }
740
741    /// Capabilities newtype works as documented.
742    #[test]
743    fn capabilities_newtype_smoke() {
744        use nodedb_types::protocol::{CAP_FTS, CAP_STREAMING};
745        let caps = Capabilities::from_raw(CAP_STREAMING | CAP_FTS);
746        assert!(caps.supports_streaming());
747        assert!(caps.supports_fts());
748        assert!(!caps.supports_graphrag());
749        assert!(!caps.supports_crdt());
750    }
751}