Skip to main content

nodedb_client/traits/core/
trait_def.rs

1// SPDX-License-Identifier: Apache-2.0
2
3//! The `NodeDb` trait: one query interface for Origin and Lite.
4//!
5//! Application code writes against this trait once. Lite runs each call on
6//! in-process engines. The native and pgwire clients send it to Origin. All
7//! methods are `async`: Tokio on native targets, `wasm-bindgen-futures` on
8//! WASM.
9//!
10//! `NodeDb` is one trait, so an implementation is one `impl` block and one
11//! import. Long provided-method bodies live in `default_impls`.
12//!
13//! Each method states its contract first. The `On Lite` / `On Remote` /
14//! `On Native` lines name what each backend runs. `Remote` is the pgwire
15//! client. `Native` is the MessagePack client, the canonical typed
16//! transport. "Key column" is a collection's declared primary key, else `id`.
17
18use std::collections::HashSet;
19
20use async_trait::async_trait;
21
22use nodedb_types::document::Document;
23use nodedb_types::dropped_collection::DroppedCollection;
24use nodedb_types::error::NodeDbResult;
25use nodedb_types::filter::{EdgeFilter, MetadataFilter};
26use nodedb_types::graph::GraphStats;
27use nodedb_types::id::{EdgeId, NodeId};
28use nodedb_types::protocol::Limits;
29use nodedb_types::result::{QueryResult, SearchResult, SubGraph};
30use nodedb_types::text_search::TextSearchParams;
31use nodedb_types::value::Value;
32
33use super::default_impls;
34use super::marker::NodeDbMarker;
35use crate::traits::document::CollectionPurgedHandler;
36
37/// Unified database interface for NodeDB.
38///
39/// Implementations:
40/// - `NodeDbLite`: in-process HNSW/CSR/Loro engines on the edge device.
41///   Writes produce CRDT deltas synced to Origin in the background.
42/// - `NativeClient`: the MessagePack protocol to Origin.
43/// - `NodeDbRemote`: SQL over pgwire to Origin.
44#[cfg_attr(not(target_arch = "wasm32"), async_trait)]
45#[cfg_attr(target_arch = "wasm32", async_trait(?Send))]
46pub trait NodeDb: NodeDbMarker {
47    /// Search for the `k` nearest vectors to `query` in `collection`.
48    ///
49    /// Results are ordered by ascending distance. `filter` constrains which
50    /// vectors are considered. `Some(allowed_ids)` applies before ranking on
51    /// every implementation: the top-k comes from the allowed set even when
52    /// nearer vectors lie outside it. An empty set returns no result.
53    ///
54    /// On Lite: in-memory HNSW search with the id prefilter pushed into
55    /// traversal.
56    /// On Remote: `SELECT id, vector_distance(..) AS distance FROM <collection>
57    /// [WHERE <filter>] [AND <key column> IN (..)] ORDER BY vector_distance(..)
58    /// LIMIT <k>`. The server lowers the key restriction to a candidate bitmap
59    /// the index search honors.
60    /// On Native: the ids travel in the request. The server lowers them to
61    /// the same candidate bitmap.
62    async fn vector_search(
63        &self,
64        collection: &str,
65        query: &[f32],
66        k: usize,
67        filter: Option<&MetadataFilter>,
68        allowed_ids: Option<&HashSet<String>>,
69    ) -> NodeDbResult<Vec<SearchResult>>;
70
71    /// Insert a vector with optional metadata into `collection`.
72    ///
73    /// On Lite: HNSW insert + CRDT delta + persist to local storage.
74    /// On Remote: `INSERT INTO collection (id, embedding, metadata) VALUES (...)`.
75    async fn vector_insert(
76        &self,
77        collection: &str,
78        id: &str,
79        embedding: &[f32],
80        metadata: Option<Document>,
81    ) -> NodeDbResult<()>;
82
83    /// Delete a vector by ID from `collection`.
84    ///
85    /// On Lite: marks deleted in HNSW + emits CRDT tombstone.
86    /// On Remote: `DELETE FROM collection WHERE id = $1`.
87    async fn vector_delete(&self, collection: &str, id: &str) -> NodeDbResult<()>;
88
89    /// Traverse the graph from `start` up to `depth` hops within `collection`.
90    ///
91    /// Edges are scoped per collection, so the caller picks which graph to
92    /// walk. Returns the discovered subgraph (nodes + edges). `direction`
93    /// selects outgoing, incoming, or both orientations.
94    ///
95    /// Every label of the filter is followed. Every property filter must
96    /// admit an edge before it is crossed. Each returned edge carries its
97    /// properties.
98    ///
99    /// On Lite: CSR pointer-chasing in contiguous memory.
100    /// On Remote and Native: `GRAPH TRAVERSE IN '<collection>' FROM '<start>'
101    /// DEPTH <n> DIRECTION out|in|both [LABEL '<a>', '<b>' …]
102    /// [EDGE WHERE <property filters>]`, one round trip.
103    async fn graph_traverse(
104        &self,
105        collection: &str,
106        start: &NodeId,
107        depth: u8,
108        direction: nodedb_types::graph::Direction,
109        edge_filter: Option<&EdgeFilter>,
110    ) -> NodeDbResult<SubGraph>;
111
112    /// Insert a directed edge `from` → `to` with label `edge_type` into
113    /// `collection`. Returns the generated edge ID.
114    ///
115    /// On Lite: mutable adjacency buffer + CRDT delta + local storage.
116    /// On Remote: `GRAPH INSERT EDGE IN '<collection>' FROM '<from>' TO '<to>' TYPE '<label>'`.
117    async fn graph_insert_edge(
118        &self,
119        collection: &str,
120        from: &NodeId,
121        to: &NodeId,
122        edge_type: &str,
123        properties: Option<Document>,
124    ) -> NodeDbResult<EdgeId>;
125
126    /// Delete a graph edge by ID from `collection`.
127    ///
128    /// On Lite: marks deleted + CRDT tombstone.
129    /// On Remote: `GRAPH DELETE EDGE IN '<collection>' FROM '<src>' TO '<dst>' TYPE '<label>'`.
130    async fn graph_delete_edge(&self, collection: &str, edge_id: &EdgeId) -> NodeDbResult<()>;
131
132    /// Read aggregated graph statistics from the persisted edge store.
133    ///
134    /// `Some(name)` returns zero or one entry for that collection. `None`
135    /// returns one entry per collection that has edges. Each entry holds the
136    /// edge count, distinct node count, distinct label count, and per-label
137    /// edge counts sorted by label.
138    ///
139    /// `as_of` pins the read to a past system time (ms since Unix epoch).
140    /// `None` reads the live state. Lite returns an error for `Some`.
141    ///
142    /// On Lite: direct read of the local edge store.
143    /// On Remote: `SHOW GRAPH STATS [<'collection'>] [AS OF SYSTEM TIME <ms>]`.
144    async fn graph_stats(
145        &self,
146        collection: Option<&str>,
147        as_of: Option<i64>,
148    ) -> NodeDbResult<Vec<GraphStats>>;
149
150    /// Run PageRank on a graph collection's edges.
151    ///
152    /// `personalization` biases the initial rank toward its seed nodes
153    /// (values are normalized). `None` runs uniform-init PageRank.
154    /// `damping` defaults to 0.85, `max_iterations` to 20.
155    ///
156    /// Returns `(node_id, rank)` pairs sorted by rank descending. Ranks sum to 1.0.
157    async fn graph_pagerank(
158        &self,
159        collection: &str,
160        personalization: Option<std::collections::HashMap<String, f64>>,
161        damping: Option<f64>,
162        max_iterations: Option<u32>,
163    ) -> NodeDbResult<Vec<(String, f64)>> {
164        default_impls::graph_pagerank_default(collection, personalization, damping, max_iterations)
165    }
166
167    /// Get a document by ID from `collection`.
168    ///
169    /// A missing document is `Ok(None)`. An error means the read failed.
170    /// Every field reads back with its stored type, on every implementation.
171    ///
172    /// On Lite: direct Loro state read.
173    /// On Remote: `SELECT to_jsonb(*) AS document FROM <collection> WHERE
174    /// <key column> = '<id>'` on the simple-query protocol. The row object
175    /// decodes field by field to the values the native client returns.
176    async fn document_get(&self, collection: &str, id: &str) -> NodeDbResult<Option<Document>>;
177
178    /// Put (insert or replace) a document into `collection`.
179    ///
180    /// The document's `id` is the key. An existing document with that id is
181    /// replaced whole (last-writer-wins locally, CRDT merge on sync).
182    ///
183    /// Each field is a top-level column. The key column holds the document
184    /// id. A field under the key column must equal the document id. A
185    /// declared key column reads back as a field. The implicit `id` does not.
186    ///
187    /// On Lite: Loro apply + CRDT delta + local storage.
188    /// On Remote: `DELETE` then `INSERT INTO <collection> (<key column>,
189    /// <fields>) VALUES (...)`, every value a bound parameter. The pair is
190    /// atomic. Inside the caller's transaction block it runs under a
191    /// savepoint and never ends that block. A failed put rolls back to the
192    /// savepoint and leaves the block usable. Outside a block it runs in its
193    /// own transaction.
194    async fn document_put(&self, collection: &str, doc: Document) -> NodeDbResult<()>;
195
196    /// Delete a document by ID from `collection`.
197    ///
198    /// On Lite: Loro delete + CRDT tombstone.
199    /// On Remote: `DELETE FROM <collection> WHERE <key column> = $1`.
200    async fn document_delete(&self, collection: &str, id: &str) -> NodeDbResult<()>;
201
202    /// Put a document and insert its embedding under one CRDT lock acquisition.
203    ///
204    /// Equivalent to `document_put` then `vector_insert` into
205    /// `vector_collection`. An empty `embedding` makes it `document_put`.
206    /// The default makes the two calls. Implementations that can batch both
207    /// under one lock override it.
208    async fn document_put_with_vector(
209        &self,
210        doc_collection: &str,
211        doc: Document,
212        vector_collection: &str,
213        id: &str,
214        embedding: &[f32],
215    ) -> NodeDbResult<()> {
216        default_impls::document_put_with_vector_default(
217            self,
218            doc_collection,
219            doc,
220            vector_collection,
221            id,
222            embedding,
223        )
224        .await
225    }
226
227    /// Read a document as of a system time, optionally at a valid time.
228    ///
229    /// Only valid on collections created `WITH (bitemporal=true)`; a plain
230    /// collection returns an error. `as_of_ms: None` reads the live version.
231    /// `Some(t)` reads the version visible at system time `t`.
232    /// `valid_time_ms: Some(vt)` also requires
233    /// `valid_from_ms <= vt < valid_until_ms`.
234    ///
235    /// Implementations without bitemporal reads return `Err`.
236    async fn document_get_as_of(
237        &self,
238        collection: &str,
239        id: &str,
240        as_of_ms: Option<i64>,
241        valid_time_ms: Option<i64>,
242    ) -> NodeDbResult<Option<Document>> {
243        default_impls::document_get_as_of_default(collection, id, as_of_ms, valid_time_ms)
244    }
245
246    /// Put a document with explicit valid-time bounds.
247    ///
248    /// Only valid on collections created `WITH (bitemporal=true)`.
249    /// `valid_from_ms` defaults to the system time, `valid_until_ms` to
250    /// open-ended. Implementations without bitemporal writes return `Err`.
251    async fn document_put_with_valid_time(
252        &self,
253        collection: &str,
254        doc: Document,
255        valid_from_ms: Option<i64>,
256        valid_until_ms: Option<i64>,
257    ) -> NodeDbResult<()> {
258        default_impls::document_put_with_valid_time_default(
259            collection,
260            doc,
261            valid_from_ms,
262            valid_until_ms,
263        )
264    }
265
266    /// Insert a new block into a document's movable list at `index`.
267    ///
268    /// `list_path` addresses the list (dot-path for nested containers).
269    /// `fields` must be `Value::Object(..)`. Each key becomes a field of the
270    /// new map block. `index` is required: a defaulted position corrupts
271    /// list order.
272    ///
273    /// On Lite: `nodedb_crdt::list_ops::list_insert_container` + CRDT delta.
274    /// On Native: `OpCode::CrdtListInsert` with `list_path` / `list_index` /
275    /// `list_fields_json`.
276    async fn list_insert(
277        &self,
278        collection: &str,
279        document_id: &str,
280        list_path: &str,
281        index: usize,
282        fields: &Value,
283    ) -> NodeDbResult<()>;
284
285    /// Delete the block at `index` from a document's movable list.
286    ///
287    /// On Lite: removes the local entry + CRDT tombstone delta.
288    /// On Native: `OpCode::CrdtListDelete` with `list_path` / `list_index`.
289    async fn list_delete(
290        &self,
291        collection: &str,
292        document_id: &str,
293        list_path: &str,
294        index: usize,
295    ) -> NodeDbResult<()>;
296
297    /// Move the block at `from_index` to `to_index` in a document's movable list.
298    ///
299    /// The two indexes are distinct wire fields. Swapping them reorders the
300    /// list to the wrong shape.
301    ///
302    /// On Lite: repositions the local entry + CRDT delta.
303    /// On Native: `OpCode::CrdtListMove` with `list_path` /
304    /// `list_from_index` / `list_to_index`.
305    async fn list_move(
306        &self,
307        collection: &str,
308        document_id: &str,
309        list_path: &str,
310        from_index: usize,
311        to_index: usize,
312    ) -> NodeDbResult<()>;
313
314    /// Insert a vector into the named field `field_name` of `collection`.
315    ///
316    /// Each named field has its own HNSW index, so a collection holds several
317    /// embeddings (e.g. `title_embedding`, `body_embedding`). The default
318    /// returns `Err`: delegating to `vector_insert` would drop `field_name`
319    /// and land the vector in the wrong field.
320    async fn vector_insert_field(
321        &self,
322        collection: &str,
323        field_name: &str,
324        id: &str,
325        embedding: &[f32],
326        metadata: Option<Document>,
327    ) -> NodeDbResult<()> {
328        default_impls::vector_insert_field_default(collection, field_name, id, embedding, metadata)
329    }
330
331    /// Search the named vector field `field_name`.
332    ///
333    /// The default returns `Err`: delegating to `vector_search` would drop
334    /// `field_name` and search the wrong field.
335    async fn vector_search_field(
336        &self,
337        collection: &str,
338        field_name: &str,
339        query: &[f32],
340        k: usize,
341        filter: Option<&MetadataFilter>,
342    ) -> NodeDbResult<Vec<SearchResult>> {
343        default_impls::vector_search_field_default(collection, field_name, query, k, filter)
344    }
345
346    /// Find the shortest path between two nodes.
347    ///
348    /// Returns node IDs from `from` to `to`, or `None` when no path exists
349    /// within `max_depth` hops. The path follows outgoing edges the filter
350    /// admits.
351    ///
352    /// The default is a breadth-first search, one depth-1 `graph_traverse`
353    /// per expanded node.
354    /// On Remote and Native: `GRAPH PATH IN '<collection>' FROM '<from>'
355    /// TO '<to>' MAX_DEPTH <n> [LABEL '<a>', '<b>' …]
356    /// [EDGE WHERE <property filters>]`, one round trip.
357    async fn graph_shortest_path(
358        &self,
359        collection: &str,
360        from: &NodeId,
361        to: &NodeId,
362        max_depth: u8,
363        edge_filter: Option<&EdgeFilter>,
364    ) -> NodeDbResult<Option<Vec<NodeId>>> {
365        default_impls::graph_shortest_path_default(
366            self,
367            collection,
368            from,
369            to,
370            max_depth,
371            edge_filter,
372        )
373        .await
374    }
375
376    /// Full-text BM25 search of the indexed `field` of `collection`.
377    ///
378    /// Every BM25 index covers one declared field, so the caller names it.
379    /// An empty `field` searches the whole-document index. Returns ids with
380    /// scores, best first, at most `top_k`. `Some(allowed_ids)` returns only
381    /// those ids.
382    ///
383    /// `params` means the same on every implementation. `mode` combines the
384    /// terms (`Or`: any, `And`: all). `fuzzy`, or the collection's fuzzy
385    /// default, lets a term with no exact match fall back to a Levenshtein
386    /// match. [`TextSearchParams::default`] is `{ mode: Or, fuzzy: false }`.
387    ///
388    /// On Lite: honors every `params` value, and applies `allowed_ids` after
389    /// an over-fetch.
390    /// On Remote and Native: one shared statement, `SELECT id, bm25_score(..,
391    /// opts) AS score FROM <collection> WHERE text_match(.., opts) [AND <key
392    /// column> IN (..)] ORDER BY score DESC LIMIT <k>`, with `opts` the
393    /// non-default `mode => '..'` and `fuzzy => ..`. Both return the same ids
394    /// and scores. A double-quoted query is a phrase query, which takes only
395    /// the default `params`.
396    async fn text_search(
397        &self,
398        collection: &str,
399        field: &str,
400        query: &str,
401        top_k: usize,
402        params: TextSearchParams,
403        allowed_ids: Option<&HashSet<String>>,
404    ) -> NodeDbResult<Vec<SearchResult>>;
405
406    /// Batch insert vectors — amortizes CRDT delta export to O(1) per batch.
407    async fn batch_vector_insert(
408        &self,
409        collection: &str,
410        vectors: &[(&str, &[f32])],
411    ) -> NodeDbResult<()> {
412        default_impls::batch_vector_insert_default(self, collection, vectors).await
413    }
414
415    /// Batch insert graph edges into `collection` — amortizes CRDT
416    /// delta export to O(1) per batch.
417    async fn batch_graph_insert_edges(
418        &self,
419        collection: &str,
420        edges: &[(&str, &str, &str)],
421    ) -> NodeDbResult<()> {
422        default_impls::batch_graph_insert_edges_default(self, collection, edges).await
423    }
424
425    /// The protocol version negotiated during the connection handshake.
426    /// `0` for an implementation that performs no handshake.
427    fn proto_version(&self) -> u16 {
428        0
429    }
430
431    /// The raw capability bitfield advertised by the server. `0` when no
432    /// handshake ran. `Capabilities::from_raw` gives named predicates.
433    fn capabilities(&self) -> u64 {
434        0
435    }
436
437    /// The server version string from `HelloAckFrame` (e.g. `"0.1.0-dev"`).
438    /// Empty when no handshake ran.
439    fn server_version(&self) -> String {
440        String::new()
441    }
442
443    /// Per-operation limits announced by the server. Every field is `None`
444    /// when no handshake ran. `None` means no server-side cap.
445    fn limits(&self) -> Limits {
446        Limits::default()
447    }
448
449    /// Execute a raw SQL query with parameters.
450    ///
451    /// On Lite: requires the `sql` feature. Without it, returns
452    /// `NodeDbError::SqlNotEnabled`.
453    /// On Remote: pass-through to Origin via pgwire.
454    ///
455    /// The typed methods above cover most agent workloads. Use this for BI
456    /// tools, ORMs, and ad-hoc queries.
457    async fn execute_sql(&self, query: &str, params: &[Value]) -> NodeDbResult<QueryResult>;
458
459    /// Restore a soft-deleted collection within its retention window.
460    ///
461    /// Runs `UNDROP COLLECTION <name>` through `execute_sql`. Fails with
462    /// 42P01 once the retention window has elapsed, or with 42501 when the
463    /// caller is neither the preserved owner nor an admin.
464    async fn undrop_collection(&self, name: &str) -> NodeDbResult<()> {
465        default_impls::undrop_collection_default(self, name).await
466    }
467
468    /// Hard-delete a collection, skipping soft-delete and retention.
469    ///
470    /// Runs `DROP COLLECTION <name> PURGE`. Admin-only: the server rejects
471    /// other callers with 42501. The data is unrecoverable.
472    async fn drop_collection_purge(&self, name: &str) -> NodeDbResult<()> {
473        default_impls::drop_collection_purge_default(self, name).await
474    }
475
476    /// List the current tenant's soft-deleted collections still within their
477    /// retention window. Empty when there are none.
478    ///
479    /// Reads `tenant_id, name, owner, deactivated_at_ns,
480    /// retention_expires_at_ns` from `_system.dropped_collections`.
481    async fn list_dropped_collections(&self) -> NodeDbResult<Vec<DroppedCollection>> {
482        default_impls::list_dropped_collections_default(self).await
483    }
484
485    /// Register a handler fired when a collection the caller synced is purged
486    /// on Origin and the local copy is removed.
487    ///
488    /// The default returns `NodeDbError::storage` ("not supported").
489    /// Implementations with a sync client (Lite) override it. A stateless
490    /// pgwire client has nothing to push, so the default is correct there.
491    async fn on_collection_purged(&self, _handler: CollectionPurgedHandler) -> NodeDbResult<()> {
492        default_impls::on_collection_purged_default()
493    }
494}