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}