1use std::collections::{HashMap, HashSet};
9
10use omgbase_graph::{
11 DstKind, EdgeDescriptor, ProjectedNode, Provenance, canonical_path, doc_dir, node_rows,
12 resolve_relative,
13};
14use rusqlite::{Connection, OptionalExtension, params};
15
16use crate::error::Result;
17use crate::mint::Mint;
18
19pub fn project_section_nodes(conn: &Connection, doc_id: &str) -> Result<Vec<ProjectedNode>> {
24 let mut stmt = conn.prepare_cached(
25 "SELECT s.heading_block, s.level, s.first_ordinal, s.last_ordinal, hb.text
26 FROM sections s
27 JOIN blocks hb ON hb.block_id = s.heading_block AND hb.doc_id = s.doc_id
28 WHERE s.doc_id = ?1 AND hb.deleted_commit IS NULL
29 ORDER BY s.first_ordinal",
30 )?;
31 let rows = stmt.query_map(params![doc_id], |r| {
32 Ok(ProjectedNode::section(
33 &r.get::<_, String>(0)?,
34 &r.get::<_, String>(4)?,
35 r.get(1)?,
36 r.get(2)?,
37 r.get(3)?,
38 ))
39 })?;
40 Ok(rows.collect::<std::result::Result<Vec<_>, _>>()?)
41}
42
43pub fn delete_doc_nodes(conn: &Connection, doc_id: &str) -> Result<()> {
47 let rows: Vec<(i64, Option<String>, Option<String>)> = {
48 let mut stmt =
49 conn.prepare_cached("SELECT rowid, name, value FROM nodes WHERE doc_id = ?1")?;
50 let it = stmt.query_map(params![doc_id], |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)))?;
51 it.collect::<std::result::Result<Vec<_>, _>>()?
52 };
53 let mut del = conn.prepare_cached(
54 "INSERT INTO nodes_fts(nodes_fts, rowid, name, value) VALUES('delete', ?1, ?2, ?3)",
55 )?;
56 for (rowid, name, value) in rows {
57 del.execute(params![
58 rowid,
59 name.unwrap_or_default(),
60 value.unwrap_or_default()
61 ])?;
62 }
63 conn.execute("DELETE FROM nodes WHERE doc_id = ?1", params![doc_id])?;
64 Ok(())
65}
66
67pub fn write_doc_nodes(
71 conn: &Connection,
72 repo_id: &str,
73 doc_id: &str,
74 nodes: &[ProjectedNode],
75) -> Result<usize> {
76 delete_doc_nodes(conn, doc_id)?;
77 let mut insert = conn.prepare_cached(
78 "INSERT INTO nodes (node_id, repo_id, doc_id, block_id, kind, name, value, span_start, span_end, attrs)
79 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)",
80 )?;
81 let mut fts =
82 conn.prepare_cached("INSERT INTO nodes_fts(rowid, name, value) VALUES (?1, ?2, ?3)")?;
83 for row in node_rows(doc_id, nodes) {
84 let n = row.node;
85 insert.execute(params![
86 row.node_id,
87 repo_id,
88 doc_id,
89 n.block_id,
90 n.kind.as_str(),
91 n.name,
92 n.value,
93 n.span.map(|(s, _)| s as i64),
94 n.span.map(|(_, e)| e as i64),
95 serde_json::Value::Object(n.attrs.clone()).to_string(),
96 ])?;
97 let rowid = conn.last_insert_rowid();
98 fts.execute(params![
99 rowid,
100 n.name.as_deref().unwrap_or(""),
101 n.value.as_deref().unwrap_or("")
102 ])?;
103 }
104 Ok(nodes.len())
105}
106
107#[derive(Clone, Debug, PartialEq, Eq)]
111pub struct ResolvedEdge {
112 pub src_block: Option<String>,
113 pub src_field: Option<String>,
114 pub predicate: String,
115 pub dst_kind: DstKind,
116 pub dst_node: String,
118 pub anchor: Option<String>,
119 pub provenance: Provenance,
120}
121
122impl ResolvedEdge {
123 #[must_use]
125 pub fn key(&self) -> String {
126 edge_key(
127 self.src_block.as_deref(),
128 self.src_field.as_deref(),
129 &self.predicate,
130 &self.dst_node,
131 self.anchor.as_deref(),
132 )
133 }
134}
135
136fn edge_key(
137 src_block: Option<&str>,
138 src_field: Option<&str>,
139 predicate: &str,
140 dst_node: &str,
141 anchor: Option<&str>,
142) -> String {
143 format!(
144 "{}|{}|{predicate}|{dst_node}|{}",
145 src_block.unwrap_or(""),
146 src_field.unwrap_or(""),
147 anchor.unwrap_or("")
148 )
149}
150
151pub fn resolve_external(
154 conn: &Connection,
155 minter: &mut Mint<'_>,
156 repo_id: &str,
157 uri: &str,
158) -> Result<String> {
159 let existing: Option<String> = conn
160 .query_row(
161 "SELECT node_id FROM external_nodes WHERE repo_id = ?1 AND uri = ?2",
162 params![repo_id, uri],
163 |r| r.get(0),
164 )
165 .optional()?;
166 if let Some(id) = existing {
167 return Ok(id);
168 }
169 let id = minter.mint("x")?;
170 conn.execute(
171 "INSERT INTO external_nodes (node_id, repo_id, uri) VALUES (?1, ?2, ?3)",
172 params![id, repo_id, uri],
173 )?;
174 Ok(id)
175}
176
177pub fn resolve_doc_path(conn: &Connection, repo_id: &str, path: &str) -> Result<String> {
180 let canonical = canonical_path(path);
181 let doc: Option<String> = conn
182 .query_row(
183 "SELECT doc_id FROM docs WHERE repo_id = ?1 AND path = ?2 AND deleted_commit IS NULL",
184 params![repo_id, canonical],
185 |r| r.get(0),
186 )
187 .optional()?;
188 Ok(doc.unwrap_or_else(|| format!("phantom:{canonical}")))
189}
190
191pub fn resolve_edges(
196 conn: &Connection,
197 minter: &mut Mint<'_>,
198 repo_id: &str,
199 src_doc: &str,
200 doc_path: &str,
201 descriptors: &[EdgeDescriptor],
202) -> Result<Vec<ResolvedEdge>> {
203 let dir = doc_dir(doc_path);
204 let mut out = Vec::with_capacity(descriptors.len());
205 for e in descriptors {
206 let (dst_kind, dst_node) = if e.dst_kind == DstKind::External {
207 (
208 DstKind::External,
209 resolve_external(conn, minter, repo_id, &e.target)?,
210 )
211 } else if e.target.is_empty() {
212 (DstKind::Document, src_doc.to_owned())
213 } else {
214 (
215 DstKind::Document,
216 resolve_doc_path(conn, repo_id, &resolve_relative(&e.target, dir))?,
217 )
218 };
219 out.push(ResolvedEdge {
220 src_block: e.src_block.clone(),
221 src_field: e.src_field.clone(),
222 predicate: e.predicate.clone(),
223 dst_kind,
224 dst_node,
225 anchor: e.anchor.clone(),
226 provenance: e.provenance,
227 });
228 }
229 Ok(out)
230}
231
232pub fn maintain_edges(
240 conn: &Connection,
241 minter: &mut Mint<'_>,
242 repo_id: &str,
243 src_doc: &str,
244 commit_id: &str,
245 edges: &[ResolvedEdge],
246) -> Result<()> {
247 let open: Vec<(String, String)> = {
248 let mut stmt = conn.prepare_cached(
249 "SELECT edge_id, src_block, src_field, predicate, dst_node, anchor FROM edges
250 WHERE src_doc = ?1 AND to_commit IS NULL ORDER BY rowid",
251 )?;
252 let it = stmt.query_map(params![src_doc], |r| {
253 let src_block: Option<String> = r.get(1)?;
254 let src_field: Option<String> = r.get(2)?;
255 let predicate: String = r.get(3)?;
256 let dst_node: String = r.get(4)?;
257 let anchor: Option<String> = r.get(5)?;
258 Ok((
259 r.get::<_, String>(0)?,
260 edge_key(
261 src_block.as_deref(),
262 src_field.as_deref(),
263 &predicate,
264 &dst_node,
265 anchor.as_deref(),
266 ),
267 ))
268 })?;
269 it.collect::<std::result::Result<Vec<_>, _>>()?
270 };
271 let open_keys: HashSet<&str> = open.iter().map(|(_, k)| k.as_str()).collect();
272
273 let mut wanted: HashSet<String> = HashSet::new();
274 {
275 let mut insert = conn.prepare_cached(
276 "INSERT INTO edges (edge_id, repo_id, src_doc, src_block, src_field, predicate, dst_kind, dst_node, anchor, provenance, from_commit, to_commit)
277 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, NULL)",
278 )?;
279 for e in edges {
280 let key = e.key();
281 if !wanted.insert(key.clone()) {
282 continue;
283 }
284 if open_keys.contains(key.as_str()) {
285 continue;
286 }
287 insert.execute(params![
288 minter.mint("e")?,
289 repo_id,
290 src_doc,
291 e.src_block,
292 e.src_field,
293 e.predicate,
294 e.dst_kind.as_str(),
295 e.dst_node,
296 e.anchor,
297 e.provenance.as_str(),
298 commit_id,
299 ])?;
300 }
301 }
302 {
303 let mut close =
304 conn.prepare_cached("UPDATE edges SET to_commit = ?1 WHERE edge_id = ?2")?;
305 let mut kept: HashSet<&str> = HashSet::new();
306 for (edge_id, key) in &open {
307 if wanted.contains(key) && kept.insert(key.as_str()) {
308 continue;
309 }
310 close.execute(params![commit_id, edge_id])?;
311 }
312 }
313 rebuild_doc_edges(conn, src_doc)
314}
315
316pub fn rebuild_doc_edges(conn: &Connection, src_doc: &str) -> Result<()> {
320 conn.execute("DELETE FROM doc_edges WHERE src_doc = ?1", params![src_doc])?;
321 let rows: Vec<(String, String, String, Option<String>)> = {
322 let mut stmt = conn.prepare_cached(
323 "SELECT predicate, dst_node, dst_kind, src_block FROM edges
324 WHERE src_doc = ?1 AND to_commit IS NULL ORDER BY rowid",
325 )?;
326 let it = stmt.query_map(params![src_doc], |r| {
327 Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?))
328 })?;
329 it.collect::<std::result::Result<Vec<_>, _>>()?
330 };
331 let mut order: Vec<(String, String, String)> = Vec::new();
332 let mut groups: HashMap<(String, String, String), (i64, Vec<String>)> = HashMap::new();
333 for (predicate, dst_node, dst_kind, src_block) in rows {
334 let key = (predicate, dst_node, dst_kind);
335 let entry = groups.entry(key.clone()).or_insert_with(|| {
336 order.push(key);
337 (0, Vec::new())
338 });
339 entry.0 += 1;
340 if let Some(b) = src_block {
341 entry.1.push(b);
342 }
343 }
344 let mut insert = conn.prepare_cached(
345 "INSERT INTO doc_edges (src_doc, predicate, dst_node, dst_kind, count, samples) VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
346 )?;
347 for key in order {
348 let (count, samples) = &groups[&key];
349 let samples: Vec<&str> = samples.iter().take(3).map(String::as_str).collect();
350 insert.execute(params![
351 src_doc,
352 key.0,
353 key.1,
354 key.2,
355 count,
356 serde_json::to_string(&samples).expect("strings serialize"),
357 ])?;
358 }
359 Ok(())
360}
361
362pub fn adopt_phantoms(conn: &Connection, path: &str, doc_id: &str) -> Result<()> {
368 let phantom = format!("phantom:{}", canonical_path(path));
369 let affected: Vec<String> = {
370 let mut stmt = conn.prepare_cached(
371 "SELECT DISTINCT src_doc FROM edges WHERE dst_node = ?1 AND to_commit IS NULL",
372 )?;
373 let it = stmt.query_map(params![phantom], |r| r.get(0))?;
374 it.collect::<std::result::Result<Vec<_>, _>>()?
375 };
376 if affected.is_empty() {
377 return Ok(());
378 }
379 conn.execute(
380 "UPDATE edges SET dst_node = ?1 WHERE dst_node = ?2 AND to_commit IS NULL",
381 params![doc_id, phantom],
382 )?;
383 for src in &affected {
384 rebuild_doc_edges(conn, src)?;
385 }
386 Ok(())
387}
388
389#[cfg(test)]
390mod tests {
391 use omgbase_graph::node_id;
392 use omgbase_reconcile::Config;
393 use rusqlite::types::Value as Sql;
394
395 use crate::{SequentialMinter, Store};
396
397 const T0: &str = "2026-09-26T10:00:00.000Z";
398 const T1: &str = "2026-09-26T10:01:00.000Z";
399 const T2: &str = "2026-09-26T10:02:00.000Z";
400
401 fn fixture() -> (Store, String) {
402 let mut store =
403 Store::open_in_memory_with_minter(Box::new(SequentialMinter::new())).unwrap();
404 let repo = store.create_repo("fixture").unwrap();
405 (store, repo)
406 }
407
408 fn observe(store: &mut Store, repo: &str, path: &str, source: &str, ts: &str) -> String {
409 store
410 .observe_one(repo, path, source, ts, &Config::default())
411 .unwrap()
412 .doc_id
413 }
414
415 fn rows(store: &Store, sql: &str) -> Vec<Vec<Sql>> {
416 let mut stmt = store.conn().prepare(sql).unwrap();
417 let n = stmt.column_count();
418 stmt.query_map([], |r| (0..n).map(|i| r.get::<_, Sql>(i)).collect())
419 .unwrap()
420 .map(|r| r.unwrap())
421 .collect()
422 }
423
424 fn texts(store: &Store, sql: &str) -> Vec<String> {
425 rows(store, sql)
426 .into_iter()
427 .map(|r| match &r[0] {
428 Sql::Text(t) => t.clone(),
429 Sql::Null => "null".to_owned(),
430 other => format!("{other:?}"),
431 })
432 .collect()
433 }
434
435 #[test]
436 fn nodes_are_written_with_ids_spans_and_fts() {
437 let (mut store, repo) = fixture();
438 let doc = observe(
439 &mut store,
440 &repo,
441 "a.md",
442 "# Tïtle\n\nSee [x](y.md) and [[w]] ^a\n\n- [ ] todo\n\n## Sub\n\nk:: v\n",
443 T0,
444 );
445 let kinds = texts(&store, "SELECT kind FROM nodes ORDER BY rowid");
446 assert_eq!(
447 kinds,
448 [
449 "md:link",
450 "md:wikilink",
451 "md:anchor",
452 "md:task",
453 "md:inline_field",
454 "md:section",
455 "md:section"
456 ]
457 );
458 let link = rows(
459 &store,
460 "SELECT node_id, block_id, name, value, span_start, span_end, attrs FROM nodes WHERE kind = 'md:link'",
461 );
462 assert_eq!(link[0][0], Sql::Text(node_id(&doc, "b_1", "md:link", 0)));
463 assert_eq!(link[0][1], Sql::Text("b_1".into()));
464 assert_eq!(link[0][2], Sql::Text("x".into()));
465 assert_eq!(link[0][3], Sql::Text("y.md".into()));
466 assert_eq!(link[0][4], Sql::Integer(4));
467 assert_eq!(link[0][5], Sql::Integer(13));
468 assert_eq!(link[0][6], Sql::Text("{}".into()));
469 let sections = rows(
470 &store,
471 "SELECT block_id, name, value, span_start, attrs FROM nodes WHERE kind = 'md:section' ORDER BY rowid",
472 );
473 assert_eq!(sections[0][0], Sql::Text("b_0".into()));
474 assert_eq!(sections[0][1], Sql::Text("Tïtle".into()));
475 assert_eq!(sections[0][2], Sql::Null);
476 assert_eq!(sections[0][3], Sql::Null);
477 assert_eq!(
478 sections[0][4],
479 Sql::Text(r#"{"level":1,"first_ordinal":0,"last_ordinal":4}"#.into())
480 );
481 let task = rows(
482 &store,
483 "SELECT attrs, value FROM nodes WHERE kind = 'md:task'",
484 );
485 assert_eq!(task[0][0], Sql::Text(r#"{"checked":false}"#.into()));
486 assert_eq!(task[0][1], Sql::Text("todo".into()));
487 let hits = texts(
489 &store,
490 "SELECT n.kind FROM nodes_fts f JOIN nodes n ON n.rowid = f.rowid WHERE nodes_fts MATCH 'todo'",
491 );
492 assert_eq!(hits, ["md:task"]);
493 observe(&mut store, &repo, "a.md", "# Title\n\nplain\n", T1);
495 assert_eq!(
496 texts(&store, "SELECT kind FROM nodes ORDER BY rowid"),
497 ["md:section"]
498 );
499 assert!(texts(
500 &store,
501 "SELECT n.kind FROM nodes_fts f JOIN nodes n ON n.rowid = f.rowid WHERE nodes_fts MATCH 'todo'"
502 )
503 .is_empty());
504 let stale: i64 = store
505 .conn()
506 .query_row(
507 "SELECT count(*) FROM nodes_fts WHERE nodes_fts MATCH 'todo'",
508 [],
509 |r| r.get(0),
510 )
511 .unwrap();
512 assert_eq!(
513 stale, 0,
514 "the external-content delete removed the index entry"
515 );
516 }
517
518 #[test]
519 fn edges_open_close_and_roll_up() {
520 let (mut store, repo) = fixture();
521 let a = observe(
522 &mut store,
523 &repo,
524 "a.md",
525 "See [b](b.md) and [b again](./b.md)\n\n<https://x.com/> and https://x.com\n",
526 T0,
527 );
528 let edges = rows(
531 &store,
532 "SELECT edge_id, src_block, predicate, dst_kind, dst_node, provenance, from_commit, to_commit FROM edges ORDER BY rowid",
533 );
534 assert_eq!(edges.len(), 2);
535 assert_eq!(edges[0][0], Sql::Text("e_0".into()));
536 assert_eq!(edges[0][1], Sql::Text("b_0".into()));
537 assert_eq!(edges[0][4], Sql::Text("phantom:b.md".into()));
538 assert_eq!(edges[0][6], Sql::Text("c_0".into()));
539 assert_eq!(edges[0][7], Sql::Null);
540 assert_eq!(edges[1][1], Sql::Text("b_1".into()));
541 assert_eq!(edges[1][3], Sql::Text("external".into()));
542 assert_eq!(edges[1][4], Sql::Text("x_0".into()));
543 assert_eq!(
544 texts(&store, "SELECT uri FROM external_nodes"),
545 ["https://x.com"]
546 );
547 let roll = rows(
548 &store,
549 "SELECT predicate, dst_node, dst_kind, count, samples FROM doc_edges ORDER BY dst_node",
550 );
551 assert_eq!(roll.len(), 2);
552 assert_eq!(roll[0][1], Sql::Text("phantom:b.md".into()));
553 assert_eq!(roll[0][3], Sql::Integer(1));
554 assert_eq!(roll[0][4], Sql::Text(r#"["b_0"]"#.into()));
555
556 let b = observe(&mut store, &repo, "b.md", "# B\n", T1);
558 let adopted = rows(
559 &store,
560 "SELECT edge_id, dst_node, from_commit, to_commit FROM edges WHERE edge_id = 'e_0'",
561 );
562 assert_eq!(adopted[0][1], Sql::Text(b.clone()));
563 assert_eq!(adopted[0][2], Sql::Text("c_0".into()));
564 assert_eq!(adopted[0][3], Sql::Null);
565 assert_eq!(
566 texts(
567 &store,
568 "SELECT dst_node FROM doc_edges WHERE src_doc = 'd_0' ORDER BY dst_node"
569 ),
570 [b.as_str(), "x_0"]
571 );
572
573 observe(
579 &mut store,
580 &repo,
581 "a.md",
582 "See [b](b.md) and [b again](./b.md)\n\nNow [here](#Top) only\n\nrel:: https://x.com/\n",
583 T2,
584 );
585 let edges = rows(
586 &store,
587 "SELECT edge_id, src_block, src_field, predicate, dst_kind, dst_node, anchor, from_commit, to_commit FROM edges ORDER BY rowid",
588 );
589 assert_eq!(edges.len(), 5);
590 assert_eq!(
591 (&edges[0][7], &edges[0][8]),
592 (&Sql::Text("c_0".into()), &Sql::Null)
593 );
594 assert_eq!(
595 edges[1][8],
596 Sql::Text("c_2".into()),
597 "the bare-URL edge closed"
598 );
599 assert_eq!(edges[2][0], Sql::Text("e_2".into()));
600 assert_eq!(
601 edges[2][5],
602 Sql::Text(a.clone()),
603 "a pure fragment targets the doc itself"
604 );
605 assert_eq!(edges[2][6], Sql::Text("Top".into()));
606 assert_eq!(edges[2][7], Sql::Text("c_2".into()));
607 assert_eq!(edges[3][2], Sql::Text("rel".into()));
608 assert_eq!(edges[3][3], Sql::Text("rel".into()));
609 assert_eq!(
610 edges[3][5],
611 Sql::Text("x_0".into()),
612 "external node reused, none minted"
613 );
614 assert_eq!(edges[4][3], Sql::Text("references".into()));
615 assert_eq!(edges[4][5], Sql::Text("x_0".into()));
616 assert_eq!(edges[4][0], Sql::Text("e_4".into()));
617 assert_eq!(texts(&store, "SELECT node_id FROM external_nodes"), ["x_0"]);
618 let roll = rows(
619 &store,
620 "SELECT predicate, dst_node, count FROM doc_edges WHERE src_doc = 'd_0' ORDER BY predicate, dst_node",
621 );
622 assert_eq!(roll.len(), 4);
623 assert_eq!(roll[0][0], Sql::Text("references".into()));
624 assert_eq!(roll[3][0], Sql::Text("rel".into()));
625 }
626
627 #[test]
628 fn containers_count_once_and_ref_targets_are_documents() {
629 let (mut store, repo) = fixture();
630 observe(&mut store, &repo, "a.md", "- see [[n^blk]]\n", T0);
631 let edges = rows(
632 &store,
633 "SELECT src_block, dst_kind, dst_node, anchor FROM edges ORDER BY rowid",
634 );
635 assert_eq!(edges.len(), 1);
637 assert_eq!(edges[0][0], Sql::Text("b_1".into()));
638 assert_eq!(edges[0][1], Sql::Text("document".into()));
639 assert_eq!(edges[0][2], Sql::Text("phantom:n".into()));
640 assert_eq!(edges[0][3], Sql::Text("blk".into()));
641 let roll = rows(&store, "SELECT count, samples FROM doc_edges");
642 assert_eq!(roll[0][0], Sql::Integer(1));
643 assert_eq!(roll[0][1], Sql::Text(r#"["b_1"]"#.into()));
644 }
645
646 #[test]
647 fn frontmatter_relations_resolve_relative_to_the_root_and_carry_no_block() {
648 let (mut store, repo) = fixture();
649 observe(
650 &mut store,
651 &repo,
652 "dir/a.md",
653 "---\nOwner: /people/x.md\nrels: [\"[[n]]\", \"[[m]]\"]\n---\n\nSee [s](./s.md) and [u](../u.md)\n",
654 T0,
655 );
656 let edges = rows(
657 &store,
658 "SELECT src_block, src_field, predicate, dst_node, provenance FROM edges ORDER BY rowid",
659 );
660 let view: Vec<(String, String)> = edges
661 .iter()
662 .map(|r| {
663 let f = |v: &Sql| match v {
664 Sql::Text(t) => t.clone(),
665 _ => "null".to_owned(),
666 };
667 (f(&r[2]), f(&r[3]))
668 })
669 .collect();
670 assert_eq!(
671 view,
672 [
673 ("references".to_owned(), "phantom:dir/s.md".to_owned()),
674 ("references".to_owned(), "phantom:u.md".to_owned()),
675 ("Owner".to_owned(), "phantom:people/x.md".to_owned()),
676 ("rels".to_owned(), "phantom:n".to_owned()),
677 ("rels".to_owned(), "phantom:m".to_owned()),
678 ]
679 );
680 assert_eq!(edges[2][0], Sql::Null);
681 assert_eq!(edges[2][4], Sql::Text("frontmatter".into()));
682 let samples = texts(
683 &store,
684 "SELECT samples FROM doc_edges WHERE predicate = 'Owner'",
685 );
686 assert_eq!(samples, ["[]"]);
687 }
688
689 #[test]
690 fn deletion_leaves_the_graph_rows() {
691 let (mut store, repo) = fixture();
692 observe(&mut store, &repo, "a.md", "[b](b.md)\n", T0);
693 observe(&mut store, &repo, "b.md", "# B\n\n[a](a.md)\n", T1);
694 assert_eq!(
695 texts(&store, "SELECT dst_node FROM edges ORDER BY rowid"),
696 ["d_1", "d_0"]
697 );
698 store.observe_delete(&repo, "b.md", T2).unwrap();
699 assert_eq!(
700 texts(
701 &store,
702 "SELECT dst_node FROM edges WHERE to_commit IS NULL ORDER BY rowid"
703 ),
704 ["d_1", "d_0"],
705 "edges stay open and keep the tombstoned doc's id"
706 );
707 let counts: Vec<i64> = ["nodes", "doc_edges"]
708 .iter()
709 .map(|t| {
710 store
711 .conn()
712 .query_row(&format!("SELECT count(*) FROM {t} WHERE 1"), [], |r| {
713 r.get(0)
714 })
715 .unwrap()
716 })
717 .collect();
718 assert_eq!(
719 counts,
720 [3, 2],
721 "a's link; b's section and link; both rollups"
722 );
723 observe(&mut store, &repo, "c.md", "[b](b.md)\n", T2);
725 assert_eq!(
726 texts(&store, "SELECT dst_node FROM edges WHERE src_doc = 'd_2'"),
727 ["phantom:b.md"]
728 );
729 observe(
732 &mut store,
733 &repo,
734 "b.md",
735 "# B2\n",
736 "2026-09-26T10:03:00.000Z",
737 );
738 assert_eq!(
739 texts(
740 &store,
741 "SELECT dst_node FROM edges WHERE to_commit IS NULL AND dst_node LIKE 'd_%' AND src_doc != 'd_1' ORDER BY src_doc"
742 ),
743 ["d_1", "d_1"]
744 );
745 assert_eq!(
746 texts(
747 &store,
748 "SELECT dst_node FROM doc_edges WHERE src_doc = 'd_2'"
749 ),
750 ["d_1"],
751 "the affected rollup is recomputed"
752 );
753 }
754
755 #[test]
756 fn mint_order_is_x_during_resolution_then_e() {
757 let (mut store, repo) = fixture();
758 observe(
759 &mut store,
760 &repo,
761 "a.md",
762 "<https://b.com> then <https://a.com>\n",
763 T0,
764 );
765 let ext = rows(
766 &store,
767 "SELECT node_id, uri FROM external_nodes ORDER BY node_id",
768 );
769 assert_eq!(
770 ext[0],
771 vec![Sql::Text("x_0".into()), Sql::Text("https://b.com".into())]
772 );
773 assert_eq!(
774 ext[1],
775 vec![Sql::Text("x_1".into()), Sql::Text("https://a.com".into())]
776 );
777 assert_eq!(
778 texts(&store, "SELECT edge_id FROM edges ORDER BY rowid"),
779 ["e_0", "e_1"]
780 );
781 }
782}