Skip to main content

rustbrain_core/storage/
db.rs

1//! SQLite database handle with transactional, FTS-safe APIs.
2//!
3//! Prefer [`crate::Brain`] for application code. Use [`Database`] when building
4//! custom tools that need direct control over transactions, FTS, or migrations.
5
6use crate::error::{BrainError, Result};
7use crate::fts::escape_fts5_query;
8use crate::types::{Edge, Node, NodeType};
9use rusqlite::{params, Connection, OptionalExtension};
10use std::collections::{HashMap, HashSet};
11use std::path::Path;
12
13#[cfg(feature = "ast")]
14use crate::ast::SymbolAnchor;
15
16/// Master transactional store for a single brain (`.brain/db.sqlite`).
17///
18/// The connection is crate-visible for ranked-query helpers; external
19/// consumers use typed methods only.
20pub struct Database {
21    pub(crate) conn: Connection,
22}
23
24impl Database {
25    /// Open or create a database at `path`, apply pragmas, and run migrations.
26    pub fn open<P: AsRef<Path>>(path: P) -> Result<Self> {
27        let conn = Connection::open(path.as_ref())?;
28        let db = Self { conn };
29        db.configure_connection()?;
30        super::migrations::migrate(&db.conn)?;
31        Ok(db)
32    }
33
34    /// Open an in-memory database (tests).
35    pub fn open_in_memory() -> Result<Self> {
36        let conn = Connection::open_in_memory()?;
37        let db = Self { conn };
38        db.configure_connection()?;
39        super::migrations::migrate(&db.conn)?;
40        Ok(db)
41    }
42
43    fn configure_connection(&self) -> Result<()> {
44        self.conn.pragma_update(None, "foreign_keys", true)?;
45        self.conn.pragma_update(None, "busy_timeout", 5000i32)?;
46        // WAL is best-effort (no-op / different for pure in-memory DBs).
47        let _ = self.conn.pragma_update(None, "journal_mode", "WAL");
48        let _ = self.conn.pragma_update(None, "synchronous", "NORMAL");
49        Ok(())
50    }
51
52    /// Run `f` inside a single transaction (unchecked; suitable for &self).
53    pub fn with_transaction<T, F>(&self, f: F) -> Result<T>
54    where
55        F: FnOnce(&Connection) -> Result<T>,
56    {
57        let tx = self.conn.unchecked_transaction()?;
58        let result = f(&tx)?;
59        tx.commit()?;
60        Ok(result)
61    }
62
63    /// Insert or update a node. Preserves `created_at` on conflict when the
64    /// existing row is older (caller should pass original created_at when known).
65    pub fn insert_node(&self, node: &Node) -> Result<()> {
66        self.insert_node_on(&self.conn, node)
67    }
68
69    pub(crate) fn insert_node_on(&self, conn: &Connection, node: &Node) -> Result<()> {
70        // Preserve created_at if node already exists.
71        let existing_created: Option<i64> = conn
72            .query_row(
73                "SELECT created_at FROM nodes WHERE id = ?1",
74                [&node.id],
75                |row| row.get(0),
76            )
77            .optional()?;
78
79        let created_at = existing_created.unwrap_or(node.created_at);
80
81        conn.execute(
82            "INSERT INTO nodes (id, node_type, title, file_path, symbol_hash, summary, content_hash, created_at, updated_at)
83             VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)
84             ON CONFLICT(id) DO UPDATE SET
85                node_type = excluded.node_type,
86                title = excluded.title,
87                file_path = excluded.file_path,
88                symbol_hash = excluded.symbol_hash,
89                summary = excluded.summary,
90                content_hash = excluded.content_hash,
91                updated_at = excluded.updated_at",
92            params![
93                node.id,
94                node.node_type.as_str(),
95                node.title,
96                node.file_path,
97                node.symbol_hash.map(|h| h as i64),
98                node.summary,
99                node.content_hash,
100                created_at,
101                node.updated_at,
102            ],
103        )?;
104        Ok(())
105    }
106
107    /// Fetch content_hash for a node if present.
108    pub fn get_content_hash(&self, node_id: &str) -> Result<Option<String>> {
109        let v: Option<String> = self
110            .conn
111            .query_row(
112                "SELECT content_hash FROM nodes WHERE id = ?1",
113                [node_id],
114                |row| row.get(0),
115            )
116            .optional()?;
117        Ok(v)
118    }
119
120    /// Insert or update a weighted edge. Both endpoints must exist.
121    pub fn insert_edge(&self, edge: &Edge) -> Result<()> {
122        self.insert_edge_on(&self.conn, edge)
123    }
124
125    pub(crate) fn insert_edge_on(&self, conn: &Connection, edge: &Edge) -> Result<()> {
126        conn.execute(
127            "INSERT INTO edges (source_id, target_id, relation_type, weight, decay_rate, created_at)
128             VALUES (?1, ?2, ?3, ?4, ?5, ?6)
129             ON CONFLICT(source_id, target_id, relation_type) DO UPDATE SET
130                weight = excluded.weight,
131                decay_rate = excluded.decay_rate",
132            params![
133                edge.source_id,
134                edge.target_id,
135                edge.relation_type,
136                edge.weight,
137                edge.decay_rate,
138                edge.created_at,
139            ],
140        )?;
141        Ok(())
142    }
143
144    /// Record an unresolved WikiLink for later resolution / reporting.
145    pub fn insert_pending_link(
146        &self,
147        source_id: &str,
148        raw_target: &str,
149        relation_type: &str,
150        created_at: i64,
151    ) -> Result<()> {
152        self.insert_pending_link_on(&self.conn, source_id, raw_target, relation_type, created_at)
153    }
154
155    pub(crate) fn insert_pending_link_on(
156        &self,
157        conn: &Connection,
158        source_id: &str,
159        raw_target: &str,
160        relation_type: &str,
161        created_at: i64,
162    ) -> Result<()> {
163        conn.execute(
164            "INSERT INTO pending_links (source_id, raw_target, relation_type, created_at)
165             VALUES (?1, ?2, ?3, ?4)
166             ON CONFLICT(source_id, raw_target, relation_type) DO NOTHING",
167            params![source_id, raw_target, relation_type, created_at],
168        )?;
169        Ok(())
170    }
171
172    /// Clear all pending links originating from `source_id` (before re-resolve).
173    pub fn clear_pending_links_for(&self, source_id: &str) -> Result<()> {
174        self.conn
175            .execute("DELETE FROM pending_links WHERE source_id = ?1", [source_id])?;
176        Ok(())
177    }
178
179    pub(crate) fn clear_pending_links_for_on(
180        &self,
181        conn: &Connection,
182        source_id: &str,
183    ) -> Result<()> {
184        conn.execute("DELETE FROM pending_links WHERE source_id = ?1", [source_id])?;
185        Ok(())
186    }
187
188    /// Clear outbound relates_to edges for a source before re-link (idempotent reindex).
189    pub(crate) fn clear_edges_from_on(
190        &self,
191        conn: &Connection,
192        source_id: &str,
193        relation_type: &str,
194    ) -> Result<()> {
195        conn.execute(
196            "DELETE FROM edges WHERE source_id = ?1 AND relation_type = ?2",
197            params![source_id, relation_type],
198        )?;
199        Ok(())
200    }
201
202    /// Delete all soft auto-edges (`relation_type` like `auto_%`).
203    pub fn clear_all_auto_edges(&self) -> Result<()> {
204        self.conn
205            .execute("DELETE FROM edges WHERE relation_type LIKE 'auto_%'", [])?;
206        Ok(())
207    }
208
209    /// Delete soft auto-edges where `node_id` is source or target.
210    pub fn clear_auto_edges_involving(&self, node_id: &str) -> Result<()> {
211        self.conn.execute(
212            "DELETE FROM edges WHERE relation_type LIKE 'auto_%' AND (source_id = ?1 OR target_id = ?1)",
213            [node_id],
214        )?;
215        Ok(())
216    }
217
218    /// Insert or update a code symbol anchor row (AST metadata).
219    #[cfg(feature = "ast")]
220    pub fn insert_symbol_anchor(&self, anchor: &SymbolAnchor) -> Result<()> {
221        self.conn.execute(
222            "INSERT INTO symbol_anchors (symbol_hash, crate_name, module_path, symbol_name, file_path, start_line, end_line, doc_comment)
223             VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)
224             ON CONFLICT(symbol_hash) DO UPDATE SET
225                crate_name = excluded.crate_name,
226                module_path = excluded.module_path,
227                symbol_name = excluded.symbol_name,
228                file_path = excluded.file_path,
229                start_line = excluded.start_line,
230                end_line = excluded.end_line,
231                doc_comment = excluded.doc_comment",
232            params![
233                anchor.symbol_hash as i64,
234                anchor.crate_name,
235                anchor.module_path,
236                anchor.symbol_name,
237                anchor.file_path,
238                anchor.start_line,
239                anchor.end_line,
240                anchor.doc_comment,
241            ],
242        )?;
243        Ok(())
244    }
245
246    /// Replace the full tag set for a node.
247    pub fn replace_node_tags(&self, node_id: &str, tags: &[String]) -> Result<()> {
248        self.replace_node_tags_on(&self.conn, node_id, tags)
249    }
250
251    pub(crate) fn replace_node_tags_on(
252        &self,
253        conn: &Connection,
254        node_id: &str,
255        tags: &[String],
256    ) -> Result<()> {
257        conn.execute("DELETE FROM node_tags WHERE node_id = ?1", [node_id])?;
258        for tag in tags {
259            conn.execute(
260                "INSERT OR IGNORE INTO node_tags (node_id, tag) VALUES (?1, ?2)",
261                params![node_id, tag],
262            )?;
263        }
264        Ok(())
265    }
266
267    /// Replace aliases for a node.
268    pub fn replace_node_aliases(&self, node_id: &str, aliases: &[String]) -> Result<()> {
269        self.replace_node_aliases_on(&self.conn, node_id, aliases)
270    }
271
272    pub(crate) fn replace_node_aliases_on(
273        &self,
274        conn: &Connection,
275        node_id: &str,
276        aliases: &[String],
277    ) -> Result<()> {
278        conn.execute("DELETE FROM node_aliases WHERE node_id = ?1", [node_id])?;
279        for alias in aliases {
280            let key = alias.trim().to_lowercase();
281            if key.is_empty() {
282                continue;
283            }
284            conn.execute(
285                "INSERT INTO node_aliases (alias, node_id) VALUES (?1, ?2)
286                 ON CONFLICT(alias) DO UPDATE SET node_id = excluded.node_id",
287                params![key, node_id],
288            )?;
289        }
290        Ok(())
291    }
292
293    /// Idempotent FTS upsert: delete existing rows for `node_id`, then insert once.
294    pub fn index_fts(&self, node_id: &str, title: &str, content: &str, tags: &str) -> Result<()> {
295        self.index_fts_on(&self.conn, node_id, title, content, tags)
296    }
297
298    /// Fetch indexed FTS body/content for a node (for context excerpts).
299    pub fn get_fts_content(&self, node_id: &str) -> Result<Option<String>> {
300        let mut stmt = self
301            .conn
302            .prepare("SELECT content FROM node_fts WHERE node_id = ?1 LIMIT 1")?;
303        let content: Option<String> = stmt
304            .query_row(params![node_id], |row| row.get(0))
305            .optional()?;
306        Ok(content)
307    }
308
309    pub(crate) fn index_fts_on(
310        &self,
311        conn: &Connection,
312        node_id: &str,
313        title: &str,
314        content: &str,
315        tags: &str,
316    ) -> Result<()> {
317        conn.execute("DELETE FROM node_fts WHERE node_id = ?1", [node_id])?;
318        conn.execute(
319            "INSERT INTO node_fts (node_id, title, content, tags) VALUES (?1, ?2, ?3, ?4)",
320            params![node_id, title, content, tags],
321        )?;
322        Ok(())
323    }
324
325    /// Search nodes using FTS5 BM25 ranking with a safely escaped query.
326    pub fn search_fts(&self, query: &str) -> Result<Vec<Node>> {
327        let escaped = escape_fts5_query(query)?;
328        let mut stmt = self.conn.prepare(
329            "SELECT n.id, n.node_type, n.title, n.file_path, n.symbol_hash, n.summary,
330                    n.content_hash, n.created_at, n.updated_at
331             FROM node_fts f
332             JOIN nodes n ON n.id = f.node_id
333             WHERE node_fts MATCH ?1
334             ORDER BY rank
335             LIMIT 50",
336        )?;
337
338        let rows = stmt.query_map(params![escaped], |row| {
339            let type_str: String = row.get(1)?;
340            let node_type = NodeType::parse(&type_str).unwrap_or(NodeType::Concept);
341            let symbol_hash_raw: Option<i64> = row.get(4)?;
342            Ok(Node {
343                id: row.get(0)?,
344                node_type,
345                title: row.get(2)?,
346                file_path: row.get(3)?,
347                symbol_hash: symbol_hash_raw.map(|h| h as u64),
348                summary: row.get(5)?,
349                content_hash: row.get(6)?,
350                created_at: row.get(7)?,
351                updated_at: row.get(8)?,
352            })
353        })?;
354
355        let mut results = Vec::new();
356        for r in rows {
357            results.push(r.map_err(BrainError::from)?);
358        }
359        Ok(results)
360    }
361
362    /// List node ids with the given `node_type` string (e.g. `"adr"`).
363    pub fn list_node_ids_by_type(&self, node_type: &str) -> Result<Vec<String>> {
364        let mut stmt = self
365            .conn
366            .prepare("SELECT id FROM nodes WHERE node_type = ?1 ORDER BY id")?;
367        let rows = stmt.query_map(params![node_type], |row| row.get(0))?;
368        let mut out = Vec::new();
369        for r in rows {
370            out.push(r?);
371        }
372        Ok(out)
373    }
374
375    /// Fetch a single node by id, if present.
376    pub fn get_node(&self, id: &str) -> Result<Option<Node>> {
377        let mut stmt = self.conn.prepare(
378            "SELECT id, node_type, title, file_path, symbol_hash, summary, content_hash, created_at, updated_at
379             FROM nodes WHERE id = ?1",
380        )?;
381        let node = stmt
382            .query_row(params![id], Self::map_node_row)
383            .optional()?;
384        Ok(node)
385    }
386
387    fn map_node_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<Node> {
388        let type_str: String = row.get(1)?;
389        let node_type = NodeType::parse(&type_str).unwrap_or(NodeType::Concept);
390        let symbol_hash_raw: Option<i64> = row.get(4)?;
391        Ok(Node {
392            id: row.get(0)?,
393            node_type,
394            title: row.get(2)?,
395            file_path: row.get(3)?,
396            symbol_hash: symbol_hash_raw.map(|h| h as u64),
397            summary: row.get(5)?,
398            content_hash: row.get(6)?,
399            created_at: row.get(7)?,
400            updated_at: row.get(8)?,
401        })
402    }
403
404    /// Number of rows in `nodes`.
405    pub fn count_nodes(&self) -> Result<usize> {
406        let count: usize = self
407            .conn
408            .query_row("SELECT COUNT(*) FROM nodes", [], |row| row.get(0))?;
409        Ok(count)
410    }
411
412    /// Number of rows in `edges`.
413    pub fn count_edges(&self) -> Result<usize> {
414        let count: usize = self
415            .conn
416            .query_row("SELECT COUNT(*) FROM edges", [], |row| row.get(0))?;
417        Ok(count)
418    }
419
420    /// Number of rows in the FTS5 `node_fts` table (should equal indexed notes).
421    pub fn count_fts_rows(&self) -> Result<usize> {
422        let count: usize = self
423            .conn
424            .query_row("SELECT COUNT(*) FROM node_fts", [], |row| row.get(0))?;
425        Ok(count)
426    }
427
428    /// Number of unresolved WikiLink / symbol refs in `pending_links`.
429    pub fn count_pending_links(&self) -> Result<usize> {
430        let count: usize = self
431            .conn
432            .query_row("SELECT COUNT(*) FROM pending_links", [], |row| row.get(0))?;
433        Ok(count)
434    }
435
436    /// Number of AST symbol anchor rows.
437    pub fn count_symbol_anchors(&self) -> Result<usize> {
438        let count: usize = self
439            .conn
440            .query_row("SELECT COUNT(*) FROM symbol_anchors", [], |row| row.get(0))?;
441        Ok(count)
442    }
443
444    /// All node IDs in stable ascending order (CSR index order).
445    pub fn get_all_node_ids(&self) -> Result<Vec<String>> {
446        let mut stmt = self
447            .conn
448            .prepare("SELECT id FROM nodes ORDER BY id ASC")?;
449        let rows = stmt.query_map([], |row| row.get(0))?;
450        let mut ids = Vec::new();
451        for r in rows {
452            ids.push(r?);
453        }
454        Ok(ids)
455    }
456
457    /// Full edge records (relation_type preserved).
458    pub fn get_all_edges(&self) -> Result<Vec<Edge>> {
459        let mut stmt = self.conn.prepare(
460            "SELECT source_id, target_id, relation_type, weight, decay_rate, created_at FROM edges",
461        )?;
462        let rows = stmt.query_map([], |row| {
463            let weight: f64 = row.get(3)?;
464            let decay: f64 = row.get(4)?;
465            Ok(Edge {
466                source_id: row.get(0)?,
467                target_id: row.get(1)?,
468                relation_type: row.get(2)?,
469                weight: weight as f32,
470                decay_rate: decay as f32,
471                created_at: row.get(5)?,
472            })
473        })?;
474
475        let mut edges = Vec::new();
476        for r in rows {
477            edges.push(r?);
478        }
479        Ok(edges)
480    }
481
482    /// Lightweight edge triples for CSR compile: (source, target, weight).
483    pub fn get_csr_edges(&self) -> Result<Vec<(String, String, f32)>> {
484        Ok(self
485            .get_all_edges()?
486            .into_iter()
487            .map(|e| (e.source_id, e.target_id, e.weight))
488            .collect())
489    }
490
491    /// All nodes ordered by id ascending (export / CSR compile order).
492    pub fn get_all_nodes(&self) -> Result<Vec<Node>> {
493        let mut stmt = self.conn.prepare(
494            "SELECT id, node_type, title, file_path, symbol_hash, summary, content_hash, created_at, updated_at
495             FROM nodes ORDER BY id ASC",
496        )?;
497        let rows = stmt.query_map([], Self::map_node_row)?;
498        let mut nodes = Vec::new();
499        for r in rows {
500            nodes.push(r?);
501        }
502        Ok(nodes)
503    }
504
505    /// Build resolution maps: ids, alias→id, lowercase title→id.
506    #[allow(clippy::type_complexity)]
507    pub fn link_resolution_maps(
508        &self,
509    ) -> Result<(
510        HashSet<String>,
511        HashMap<String, String>,
512        HashMap<String, String>,
513    )> {
514        let mut ids = HashSet::new();
515        let mut titles: HashMap<String, String> = HashMap::new();
516        {
517            let mut stmt = self.conn.prepare("SELECT id, title FROM nodes")?;
518            let rows = stmt.query_map([], |row| {
519                Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
520            })?;
521            for r in rows {
522                let (id, title) = r?;
523                titles
524                    .entry(title.to_lowercase())
525                    .or_insert_with(|| id.clone());
526                ids.insert(id);
527            }
528        }
529
530        let mut aliases: HashMap<String, String> = HashMap::new();
531        {
532            let mut stmt = self.conn.prepare("SELECT alias, node_id FROM node_aliases")?;
533            let rows = stmt.query_map([], |row| {
534                Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
535            })?;
536            for r in rows {
537                let (alias, node_id) = r?;
538                aliases.insert(alias, node_id);
539            }
540        }
541
542        Ok((ids, aliases, titles))
543    }
544
545    /// Attempt to resolve all pending links; returns (resolved, still_pending).
546    pub fn resolve_pending_links(&self) -> Result<(usize, usize)> {
547        let (ids, aliases, titles) = self.link_resolution_maps()?;
548        let pending: Vec<(String, String, String, i64)> = {
549            let mut stmt = self.conn.prepare(
550                "SELECT source_id, raw_target, relation_type, created_at FROM pending_links",
551            )?;
552            let rows = stmt.query_map([], |row| {
553                Ok((
554                    row.get::<_, String>(0)?,
555                    row.get::<_, String>(1)?,
556                    row.get::<_, String>(2)?,
557                    row.get::<_, i64>(3)?,
558                ))
559            })?;
560            let mut v = Vec::new();
561            for r in rows {
562                v.push(r?);
563            }
564            v
565        };
566
567        // Symbol node id set for `symbol:…` pending targets.
568        let symbol_ids: std::collections::HashSet<String> = ids
569            .iter()
570            .filter(|id| id.starts_with("symbol/"))
571            .cloned()
572            .collect();
573
574        let mut resolved = 0usize;
575        for (source_id, raw_target, relation_type, created_at) in &pending {
576            let target_id = if let Some(sym_path) = raw_target.strip_prefix("symbol:") {
577                crate::symbols::parse_symbol_path(sym_path)
578                    .and_then(|s| crate::symbols::resolve_symbol_ref(&s, &symbol_ids))
579                    .or_else(|| {
580                        // bare name via aliases/titles
581                        crate::symbols::parse_symbol_path(sym_path).and_then(|s| {
582                            crate::id::resolve_link_target(
583                                &s.symbol_name,
584                                &ids,
585                                &aliases,
586                                &titles,
587                            )
588                        })
589                    })
590            } else {
591                crate::id::resolve_link_target(raw_target, &ids, &aliases, &titles)
592            };
593
594            if let Some(target_id) = target_id {
595                let edge = Edge {
596                    source_id: source_id.clone(),
597                    target_id,
598                    relation_type: relation_type.clone(),
599                    weight: 1.0,
600                    decay_rate: 0.0,
601                    created_at: *created_at,
602                };
603                self.insert_edge(&edge)?;
604                self.conn.execute(
605                    "DELETE FROM pending_links WHERE source_id = ?1 AND raw_target = ?2 AND relation_type = ?3",
606                    params![source_id, raw_target, relation_type],
607                )?;
608                resolved += 1;
609            }
610        }
611
612        let still = self.count_pending_links()?;
613        Ok((resolved, still))
614    }
615}
616
617#[cfg(test)]
618mod tests {
619    use super::*;
620    use crate::types::NodeType;
621
622    fn sample_node(id: &str) -> Node {
623        Node {
624            id: id.to_string(),
625            node_type: NodeType::Concept,
626            title: id.to_string(),
627            file_path: Some(format!("{id}.md")),
628            symbol_hash: None,
629            summary: Some("summary".into()),
630            content_hash: Some("abc".into()),
631            created_at: 100,
632            updated_at: 100,
633        }
634    }
635
636    #[test]
637    fn fts_idempotent_reindex() {
638        let db = Database::open_in_memory().unwrap();
639        let n = sample_node("docs/raft");
640        db.insert_node(&n).unwrap();
641        db.index_fts(&n.id, &n.title, "raft consensus protocol", "raft").unwrap();
642        db.index_fts(&n.id, &n.title, "raft consensus protocol v2", "raft").unwrap();
643        assert_eq!(db.count_fts_rows().unwrap(), 1);
644        let hits = db.search_fts("raft").unwrap();
645        assert_eq!(hits.len(), 1);
646    }
647
648    #[test]
649    fn edge_requires_endpoints() {
650        let db = Database::open_in_memory().unwrap();
651        let edge = Edge {
652            source_id: "a".into(),
653            target_id: "b".into(),
654            relation_type: "relates_to".into(),
655            weight: 1.0,
656            decay_rate: 0.0,
657            created_at: 1,
658        };
659        assert!(db.insert_edge(&edge).is_err());
660    }
661
662    #[test]
663    fn full_edge_roundtrip() {
664        let db = Database::open_in_memory().unwrap();
665        db.insert_node(&sample_node("a")).unwrap();
666        db.insert_node(&sample_node("b")).unwrap();
667        let edge = Edge {
668            source_id: "a".into(),
669            target_id: "b".into(),
670            relation_type: "implements".into(),
671            weight: 0.75,
672            decay_rate: 0.1,
673            created_at: 42,
674        };
675        db.insert_edge(&edge).unwrap();
676        let edges = db.get_all_edges().unwrap();
677        assert_eq!(edges.len(), 1);
678        assert_eq!(edges[0].relation_type, "implements");
679        assert!((edges[0].weight - 0.75).abs() < 1e-6);
680        assert!((edges[0].decay_rate - 0.1).abs() < 1e-6);
681        assert_eq!(edges[0].created_at, 42);
682    }
683
684    #[test]
685    fn preserves_created_at_on_upsert() {
686        let db = Database::open_in_memory().unwrap();
687        let mut n = sample_node("x");
688        n.created_at = 10;
689        n.updated_at = 10;
690        db.insert_node(&n).unwrap();
691        n.created_at = 999;
692        n.updated_at = 20;
693        n.title = "updated".into();
694        db.insert_node(&n).unwrap();
695        let got = db.get_node("x").unwrap().unwrap();
696        assert_eq!(got.created_at, 10);
697        assert_eq!(got.updated_at, 20);
698        assert_eq!(got.title, "updated");
699    }
700}