nervusdb_v2/
lib.rs

1//! # NervusDB v2 (Rust-First Edition)
2//!
3//! **The "SQLite" of Graph Databases for Rust.**
4//!
5//! NervusDB is an embedded graph database designed for local-first applications.
6//! It provides a unified, zero-config experience for managing persistent graph data
7//! with strong consistency and safety guarantees.
8//!
9//! ## 🚀 Quickstart
10//!
11//! Add `nervusdb` to your `Cargo.toml`. Then, you can start building your graph:
12//!
13//! ```rust,no_run
14//! use nervusdb_v2::{Db, Result};
15//!
16//! fn main() -> Result<()> {
17//!     // 1. Open the database (creates .ndb and .wal files)
18//!     let db = Db::open("my_graph.ndb")?;
19//!
20//!     // 2. Write Data
21//!     let mut txn = db.begin_write();
22//!     // (APIs for node creation in progress, see examples/tour.rs)
23//!     txn.commit()?;
24//!
25//!     // 3. Query Data (Cypher)
26//!     let snapshot = db.snapshot();
27//!     // snapshot.query("MATCH (n) RETURN n", ...);
28//!
29//!     Ok(())
30//! }
31//! ```
32//!
33//! ## 💡 Core Concepts
34//!
35//! - **[`Db`]**: The entry point. Handles file management, locking, and engine initialization.
36//!   Safe to share across threads (it uses internal locking).
37//! - **[`WriteTxn`]**: Exclusive access for modifying the graph. ACID compliant.
38//! - **[`ReadTxn`] / [`Snapshot`]**: Consistent view of the graph for querying. Non-blocking.
39//! - **[`query`]**: The Cypher execution engine (re-exported from `nervusdb-v2-query`).
40//!
41//! ## 📦 Feature Flags
42//!
43//! | Flag | Description | Default |
44//! |------|-------------|---------|
45//! | `async` | (Planned) Enable async `Db` and `Txn` wrappers | `false` |
46//! | `serde` | (Implicit) Serde support for property values | `true` |
47
48mod error;
49
50use nervusdb_v2_api::GraphStore;
51use nervusdb_v2_storage::api::StorageSnapshot;
52use nervusdb_v2_storage::engine::GraphEngine;
53use nervusdb_v2_storage::snapshot::Snapshot;
54use std::collections::BTreeMap;
55use std::path::{Path, PathBuf};
56
57pub use error::{Error, Result};
58pub use nervusdb_v2_api::{
59    EdgeKey, ExternalId, GraphSnapshot, InternalNodeId, LabelId, PropertyValue, RelTypeId,
60};
61pub use nervusdb_v2_query as query;
62
63/// The main database handle for NervusDB v2.
64///
65/// # Example
66///
67/// ```ignore
68/// use nervusdb_v2::Db;
69///
70/// let db = Db::open("my_graph.ndb").unwrap();
71/// ```
72///
73/// # Concurrency
74///
75/// `Db` can be shared across threads. Internal mutations are serialized
76/// through a single writer lock.
77#[derive(Debug)]
78pub struct Db {
79    engine: GraphEngine,
80    ndb_path: PathBuf,
81    wal_path: PathBuf,
82}
83
84impl Db {
85    /// Opens a database at the given path.
86    ///
87    /// The path can be:
88    /// - A directory path: files will be created as `<path>.ndb` and `<path>.wal`
89    /// - An explicit `.ndb` or `.wal` path: the other file is inferred
90    ///
91    /// Returns an error if the database cannot be opened.
92    pub fn open(path: impl AsRef<Path>) -> Result<Self> {
93        let path = path.as_ref();
94        let (ndb_path, wal_path) = derive_paths(path);
95        Self::open_paths(ndb_path, wal_path)
96    }
97
98    /// Opens a database with explicit paths for the data and WAL files.
99    ///
100    /// # Example
101    ///
102    /// ```ignore
103    /// let db = Db::open_paths("graph.ndb", "graph.wal").unwrap();
104    /// ```
105    pub fn open_paths(ndb_path: impl AsRef<Path>, wal_path: impl AsRef<Path>) -> Result<Self> {
106        let ndb_path = ndb_path.as_ref().to_path_buf();
107        let wal_path = wal_path.as_ref().to_path_buf();
108        let engine = GraphEngine::open(&ndb_path, &wal_path)?;
109        Ok(Self {
110            engine,
111            ndb_path,
112            wal_path,
113        })
114    }
115
116    /// Returns the path to the main data file (`.ndb`).
117    #[inline]
118    pub fn ndb_path(&self) -> &Path {
119        &self.ndb_path
120    }
121
122    /// Returns the path to the WAL file (`.wal`).
123    #[inline]
124    pub fn wal_path(&self) -> &Path {
125        &self.wal_path
126    }
127
128    /// Begins a read-only transaction.
129    ///
130    /// The returned `ReadTxn` provides a consistent view of the database
131    /// at the time of creation. It can be used concurrently with other
132    /// read transactions and will not see writes that commit after its creation.
133    pub fn begin_read(&self) -> ReadTxn {
134        ReadTxn {
135            snapshot: self.engine.begin_read(),
136        }
137    }
138
139    /// Creates a snapshot for query execution.
140    ///
141    /// Returns a `DbSnapshot` that implements `GraphSnapshot` trait,
142    /// suitable for use with the query engine.
143    pub fn snapshot(&self) -> DbSnapshot {
144        DbSnapshot(self.engine.snapshot())
145    }
146
147    /// Begins a write transaction.
148    ///
149    /// Write transactions are exclusive - only one can exist at a time.
150    /// The transaction must be explicitly committed with `commit()`.
151    ///
152    /// # Panics
153    ///
154    /// Panics if another write transaction is already in progress.
155    pub fn begin_write(&self) -> WriteTxn<'_> {
156        WriteTxn {
157            inner: self.engine.begin_write(),
158        }
159    }
160
161    /// Triggers a compaction operation.
162    ///
163    /// Compaction merges frozen MemTables into CSR segments and removes
164    /// tombstoned entries. This is a potentially expensive operation
165    /// that should be done during maintenance windows.
166    pub fn compact(&self) -> Result<()> {
167        self.engine.compact().map_err(Error::from)
168    }
169
170    /// Creates a durability checkpoint.
171    ///
172    /// In MVP, this is equivalent to `compact()`. Future versions may
173    /// implement lightweight checkpoints that don't require full compaction.
174    pub fn checkpoint(&self) -> Result<()> {
175        // MVP: checkpoint == explicit compaction boundary + durability manifest.
176        self.engine.compact().map_err(Error::from)
177    }
178
179    /// Explicitly closes the DB and performs a best-effort checkpoint-on-close (T106).
180    ///
181    /// This is intentionally not implemented in `Drop` to avoid hiding expensive IO.
182    pub fn close(self) -> Result<()> {
183        self.engine.checkpoint_on_close().map_err(Error::from)?;
184        Ok(())
185    }
186
187    /// Creates an index on the specified label and property.
188    ///
189    /// # Example
190    /// ```ignore
191    /// db.create_index("User", "email")?;
192    /// ```
193    pub fn create_index(&self, label: &str, property: &str) -> Result<()> {
194        self.engine
195            .create_index(label, property)
196            .map_err(Error::from)
197    }
198}
199
200/// A wrapper around the storage snapshot to hide internal types.
201pub struct DbSnapshot(StorageSnapshot);
202
203impl GraphSnapshot for DbSnapshot {
204    type Neighbors<'a> = Box<dyn Iterator<Item = EdgeKey> + 'a>;
205
206    fn neighbors(&self, src: InternalNodeId, rel: Option<RelTypeId>) -> Self::Neighbors<'_> {
207        Box::new(self.0.neighbors(src, rel))
208    }
209
210    fn nodes(&self) -> Box<dyn Iterator<Item = InternalNodeId> + '_> {
211        self.0.nodes()
212    }
213
214    fn resolve_external(&self, iid: InternalNodeId) -> Option<ExternalId> {
215        self.0.resolve_external(iid)
216    }
217
218    fn node_label(&self, iid: InternalNodeId) -> Option<LabelId> {
219        self.0.node_label(iid)
220    }
221
222    fn is_tombstoned_node(&self, iid: InternalNodeId) -> bool {
223        self.0.is_tombstoned_node(iid)
224    }
225
226    fn node_property(&self, iid: InternalNodeId, key: &str) -> Option<PropertyValue> {
227        self.0.node_property(iid, key)
228    }
229
230    fn edge_property(&self, edge: EdgeKey, key: &str) -> Option<PropertyValue> {
231        self.0.edge_property(edge, key)
232    }
233
234    fn node_properties(&self, iid: InternalNodeId) -> Option<BTreeMap<String, PropertyValue>> {
235        self.0.node_properties(iid)
236    }
237
238    fn edge_properties(&self, edge: EdgeKey) -> Option<BTreeMap<String, PropertyValue>> {
239        self.0.edge_properties(edge)
240    }
241
242    fn resolve_label_id(&self, name: &str) -> Option<LabelId> {
243        self.0.resolve_label_id(name)
244    }
245
246    fn resolve_rel_type_id(&self, name: &str) -> Option<RelTypeId> {
247        self.0.resolve_rel_type_id(name)
248    }
249
250    fn resolve_label_name(&self, id: LabelId) -> Option<String> {
251        self.0.resolve_label_name(id)
252    }
253
254    fn resolve_rel_type_name(&self, id: RelTypeId) -> Option<String> {
255        self.0.resolve_rel_type_name(id)
256    }
257
258    fn lookup_index(
259        &self,
260        label: &str,
261        field: &str,
262        value: &PropertyValue,
263    ) -> Option<Vec<InternalNodeId>> {
264        self.0.lookup_index(label, field, value)
265    }
266
267    fn node_count(&self, label: Option<LabelId>) -> u64 {
268        self.0.node_count(label)
269    }
270
271    fn edge_count(&self, rel: Option<RelTypeId>) -> u64 {
272        self.0.edge_count(rel)
273    }
274}
275
276/// A read-only transaction.
277///
278/// Created by [`Db::begin_read()`]. Provides consistent snapshot access.
279#[derive(Debug, Clone)]
280pub struct ReadTxn {
281    snapshot: Snapshot,
282}
283
284impl ReadTxn {
285    /// Gets outgoing neighbors of a node.
286    ///
287    /// Returns an iterator over edges. If `rel` is `Some`, only edges
288    /// of that relationship type are returned.
289    pub fn neighbors(
290        &self,
291        src: InternalNodeId,
292        rel: Option<RelTypeId>,
293    ) -> impl Iterator<Item = EdgeKey> + '_ {
294        self.snapshot.neighbors(src, rel).map(|k| EdgeKey {
295            src: k.src,
296            rel: k.rel,
297            dst: k.dst,
298        })
299    }
300}
301
302/// A write transaction.
303///
304/// Created by [`Db::begin_write()`]. All modifications are buffered
305/// until `commit()` is called. The transaction consumes `self` on commit.
306pub struct WriteTxn<'a> {
307    inner: nervusdb_v2_storage::engine::WriteTxn<'a>,
308}
309
310impl<'a> WriteTxn<'a> {
311    /// Creates a new node with the given external ID and label.
312    ///
313    /// Returns the internal node ID for use in subsequent operations.
314    pub fn create_node(
315        &mut self,
316        external_id: ExternalId,
317        label_id: LabelId,
318    ) -> Result<InternalNodeId> {
319        self.inner
320            .create_node(external_id, label_id)
321            .map_err(Error::from)
322    }
323
324    /// Gets or creates a label ID for the given name.
325    pub fn get_or_create_label(&mut self, name: &str) -> Result<LabelId> {
326        self.inner.get_or_create_label(name).map_err(Error::from)
327    }
328
329    /// Creates a directed edge from source to destination.
330    ///
331    /// The relationship type is identified by `rel`.
332    pub fn create_edge(&mut self, src: InternalNodeId, rel: RelTypeId, dst: InternalNodeId) {
333        self.inner.create_edge(src, rel, dst);
334    }
335
336    /// Soft-deletes a node.
337    ///
338    /// The node becomes invisible to queries but its data is retained
339    /// until compaction removes it. Outgoing edges are also hidden.
340    pub fn tombstone_node(&mut self, node: InternalNodeId) {
341        self.inner.tombstone_node(node);
342    }
343
344    /// Soft-deletes an edge.
345    ///
346    /// The edge becomes invisible to neighbor queries.
347    pub fn tombstone_edge(&mut self, src: InternalNodeId, rel: RelTypeId, dst: InternalNodeId) {
348        self.inner.tombstone_edge(src, rel, dst);
349    }
350
351    /// Sets a property on a node.
352    ///
353    /// If the property already exists, it is overwritten.
354    pub fn set_node_property(
355        &mut self,
356        node: InternalNodeId,
357        key: String,
358        value: PropertyValue,
359    ) -> Result<()> {
360        let storage_value = convert_to_storage_property_value(value);
361        self.inner.set_node_property(node, key, storage_value);
362        Ok(())
363    }
364
365    /// Sets a property on an edge.
366    ///
367    /// If the property already exists, it is overwritten.
368    pub fn set_edge_property(
369        &mut self,
370        src: InternalNodeId,
371        rel: RelTypeId,
372        dst: InternalNodeId,
373        key: String,
374        value: PropertyValue,
375    ) -> Result<()> {
376        let storage_value = convert_to_storage_property_value(value);
377        self.inner
378            .set_edge_property(src, rel, dst, key, storage_value);
379        Ok(())
380    }
381
382    /// Removes a property from a node.
383    ///
384    /// If the property doesn't exist, this is a no-op.
385    pub fn remove_node_property(&mut self, node: InternalNodeId, key: &str) -> Result<()> {
386        self.inner.remove_node_property(node, key);
387        Ok(())
388    }
389
390    /// Removes a property from an edge.
391    ///
392    /// If the property doesn't exist, this is a no-op.
393    pub fn remove_edge_property(
394        &mut self,
395        src: InternalNodeId,
396        rel: RelTypeId,
397        dst: InternalNodeId,
398        key: &str,
399    ) -> Result<()> {
400        self.inner.remove_edge_property(src, rel, dst, key);
401        Ok(())
402    }
403
404    /// Commits the transaction.
405    ///
406    /// All modifications are written to the WAL and made visible
407    /// to new read transactions. The transaction is consumed.
408    ///
409    /// # Returns
410    ///
411    /// Returns `Ok(())` on success, or an error if commit fails.
412    pub fn commit(self) -> Result<()> {
413        self.inner.commit().map_err(Error::from)
414    }
415}
416
417fn convert_to_storage_property_value(
418    v: PropertyValue,
419) -> nervusdb_v2_storage::property::PropertyValue {
420    use nervusdb_v2_storage::property::PropertyValue as StoragePropertyValue;
421    match v {
422        PropertyValue::Null => StoragePropertyValue::Null,
423        PropertyValue::Bool(b) => StoragePropertyValue::Bool(b),
424        PropertyValue::Int(i) => StoragePropertyValue::Int(i),
425        PropertyValue::Float(f) => StoragePropertyValue::Float(f),
426        PropertyValue::String(s) => StoragePropertyValue::String(s),
427        PropertyValue::DateTime(i) => StoragePropertyValue::DateTime(i),
428        PropertyValue::Blob(b) => StoragePropertyValue::Blob(b),
429        PropertyValue::List(l) => StoragePropertyValue::List(
430            l.into_iter()
431                .map(convert_to_storage_property_value)
432                .collect(),
433        ),
434        PropertyValue::Map(m) => StoragePropertyValue::Map(
435            m.into_iter()
436                .map(|(k, v)| (k, convert_to_storage_property_value(v)))
437                .collect(),
438        ),
439    }
440}
441
442fn derive_paths(path: &Path) -> (PathBuf, PathBuf) {
443    match path.extension().and_then(|e| e.to_str()) {
444        Some("ndb") => (path.to_path_buf(), path.with_extension("wal")),
445        Some("wal") => (path.with_extension("ndb"), path.to_path_buf()),
446        _ => (path.with_extension("ndb"), path.with_extension("wal")),
447    }
448}
449
450// Implement WriteableGraph for Facade WriteTxn
451// This bridges the Facade (v2) with the Query Engine (v2-query)
452impl nervusdb_v2_query::WriteableGraph for WriteTxn<'_> {
453    fn create_node(
454        &mut self,
455        external_id: ExternalId,
456        label_id: LabelId,
457    ) -> nervusdb_v2_query::Result<InternalNodeId> {
458        self.inner
459            .create_node(external_id, label_id)
460            .map_err(|e| nervusdb_v2_query::Error::Other(e.to_string()))
461    }
462
463    fn create_edge(
464        &mut self,
465        src: InternalNodeId,
466        rel: RelTypeId,
467        dst: InternalNodeId,
468    ) -> nervusdb_v2_query::Result<()> {
469        self.inner.create_edge(src, rel, dst);
470        Ok(())
471    }
472
473    fn set_node_property(
474        &mut self,
475        node: InternalNodeId,
476        key: String,
477        value: nervusdb_v2_storage::property::PropertyValue,
478    ) -> nervusdb_v2_query::Result<()> {
479        // Query Engine uses storage PropertyValue directly now (from re-export)
480        self.inner.set_node_property(node, key, value);
481        Ok(())
482    }
483
484    fn set_edge_property(
485        &mut self,
486        src: InternalNodeId,
487        rel: RelTypeId,
488        dst: InternalNodeId,
489        key: String,
490        value: nervusdb_v2_storage::property::PropertyValue,
491    ) -> nervusdb_v2_query::Result<()> {
492        self.inner.set_edge_property(src, rel, dst, key, value);
493        Ok(())
494    }
495
496    fn tombstone_node(&mut self, node: InternalNodeId) -> nervusdb_v2_query::Result<()> {
497        self.inner.tombstone_node(node);
498        Ok(())
499    }
500
501    fn tombstone_edge(
502        &mut self,
503        src: InternalNodeId,
504        rel: RelTypeId,
505        dst: InternalNodeId,
506    ) -> nervusdb_v2_query::Result<()> {
507        self.inner.tombstone_edge(src, rel, dst);
508        Ok(())
509    }
510
511    fn get_or_create_label_id(&mut self, name: &str) -> nervusdb_v2_query::Result<LabelId> {
512        self.inner
513            .get_or_create_label(name)
514            .map_err(|e| nervusdb_v2_query::Error::Other(e.to_string()))
515    }
516
517    fn get_or_create_rel_type_id(&mut self, name: &str) -> nervusdb_v2_query::Result<RelTypeId> {
518        self.inner
519            .get_or_create_rel_type(name)
520            .map_err(|e| nervusdb_v2_query::Error::Other(e.to_string()))
521    }
522}