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}