1mod decompose;
15mod flush;
16mod model;
17pub(crate) mod projection;
18mod reconstruct;
19mod schema;
20
21use std::path::{Path, PathBuf};
22
23use futures::executor::block_on;
24use gluesql::core::error::Error as GlueError;
25use gluesql::prelude::{Glue, MemoryStorage, ParamLiteral, Payload, Value as GValue};
26
27use crate::doc::Doc;
28use crate::error::{OpysError, Result};
29use crate::project::Project;
30use crate::project_config::ProjectConfig;
31
32#[allow(unused_imports)] pub(crate) use decompose::split_tag;
34pub(crate) use decompose::{decompose, id_num};
35pub use flush::FlushPlan;
36pub(crate) use model::Baseline;
37
38pub struct Store {
40 glue: Glue<MemoryStorage>,
41 base: PathBuf,
43 loaded: Vec<(i64, PathBuf)>,
45 retired_loaded: usize,
47 retired_legacy: bool,
50 next_dkey: i64,
51 next_rkey: i64,
52}
53
54pub struct LoadedCorpus {
59 pub docs: Vec<(Doc, Option<String>)>,
62 pub errors: Vec<String>,
64 pub retired: Vec<(String, String)>,
66 pub retired_legacy: bool,
68}
69
70impl Store {
71 pub fn build(prj: &Project, loaded: LoadedCorpus) -> Result<(Store, Vec<String>)> {
75 let mut glue = Glue::new(MemoryStorage::default());
76 block_on(glue.execute(schema::DDL)).map_err(store_err)?;
77 let mut store = Store {
78 glue,
79 base: prj.base.clone(),
80 loaded: Vec::new(),
81 retired_loaded: 0,
82 retired_legacy: loaded.retired_legacy,
83 next_dkey: 1,
84 next_rkey: 1,
85 };
86
87 let mut doc_rows: Vec<Vec<ParamLiteral>> = Vec::new();
88 let mut tag_rows: Vec<Vec<ParamLiteral>> = Vec::new();
89 let mut rel_rows: Vec<Vec<ParamLiteral>> = Vec::new();
90 let mut fm_rows: Vec<Vec<ParamLiteral>> = Vec::new();
91 for (doc, mtime) in &loaded.docs {
92 let dkey = store.next_dkey;
93 store.next_dkey += 1;
94 let relpath = store.relpath_of(&doc.path)?;
95 let rows = decompose(&prj.pcfg, doc)?;
96 doc_rows.push(vec![
97 dkey.into_param(),
98 rows.id.clone().into_param(),
99 rows.num.into_param(),
100 rows.type_name.clone().into_param(),
101 rows.status.clone().into_param(),
102 rows.title.clone().into_param(),
103 rows.created.clone().into_param(),
104 rows.updated.clone().into_param(),
105 rows.body.clone().into_param(),
106 relpath.clone().into_param(),
107 relpath.clone().into_param(), doc.to_text().into_param(), mtime.clone().into_param(),
110 ]);
111 push_child_rows(dkey, &rows, &mut tag_rows, &mut rel_rows, &mut fm_rows);
112 store.loaded.push((dkey, doc.path.clone()));
113 }
114 store.insert_batch("docs", 13, doc_rows)?;
115 store.insert_batch("tags", 6, tag_rows)?;
116 store.insert_batch("relations", 9, rel_rows)?;
117 store.insert_batch("fm_fields", 6, fm_rows)?;
118
119 let mut rows = Vec::with_capacity(loaded.retired.len());
120 for (id, title) in &loaded.retired {
121 let rkey = store.next_rkey;
122 store.next_rkey += 1;
123 rows.push(vec![
124 rkey.into_param(),
125 id.clone().into_param(),
126 id_num(id).into_param(),
127 title.clone().into_param(),
128 ]);
129 }
130 store.retired_loaded = rows.len();
131 store.insert_batch("retired", 4, rows)?;
132 Ok((store, loaded.errors))
133 }
134
135 fn run(&mut self, sql: &str, params: Vec<ParamLiteral>) -> Result<Vec<Payload>> {
140 block_on(self.glue.execute_with_params(sql, params)).map_err(store_err)
141 }
142
143 pub fn exec(&mut self, sql: &str, params: Vec<ParamLiteral>) -> Result<()> {
145 self.run(sql, params).map(|_| ())
146 }
147
148 pub fn select(
151 &mut self,
152 sql: &str,
153 params: Vec<ParamLiteral>,
154 ) -> Result<(Vec<String>, Vec<Vec<GValue>>)> {
155 let payloads = self.run(sql, params)?;
156 match payloads.into_iter().last() {
157 Some(Payload::Select { labels, rows }) => Ok((labels, rows)),
158 other => Err(OpysError::Store(format!(
159 "expected a SELECT result, got {other:?}"
160 ))),
161 }
162 }
163
164 pub fn scalar(&mut self, sql: &str, params: Vec<ParamLiteral>) -> Result<Option<GValue>> {
166 let (_, rows) = self.select(sql, params)?;
167 Ok(rows
168 .into_iter()
169 .next()
170 .and_then(|r| r.into_iter().next().filter(|v| !matches!(v, GValue::Null))))
171 }
172
173 fn insert_batch(
175 &mut self,
176 table: &str,
177 ncols: usize,
178 rows: Vec<Vec<ParamLiteral>>,
179 ) -> Result<()> {
180 if rows.is_empty() {
181 return Ok(());
182 }
183 let chunk_rows = (600 / ncols).max(1);
184 for chunk in rows.chunks(chunk_rows) {
185 let mut sql = format!("INSERT INTO {table} VALUES ");
186 let mut params: Vec<ParamLiteral> = Vec::with_capacity(chunk.len() * ncols);
187 let mut n = 0usize;
188 for (i, row) in chunk.iter().enumerate() {
189 debug_assert_eq!(row.len(), ncols);
190 if i > 0 {
191 sql.push_str(", ");
192 }
193 sql.push('(');
194 for c in 0..ncols {
195 if c > 0 {
196 sql.push_str(", ");
197 }
198 n += 1;
199 sql.push_str(&format!("${n}"));
200 }
201 sql.push(')');
202 params.extend(row.iter().cloned());
203 }
204 self.exec(&sql, params)?;
205 }
206 Ok(())
207 }
208
209 pub fn dkey_opt(&mut self, id: &str) -> Result<Option<i64>> {
216 Ok(self
217 .scalar(
218 "SELECT dkey FROM docs WHERE id = $1 ORDER BY dkey LIMIT 1",
219 vec![id.into_param()],
220 )?
221 .as_ref()
222 .and_then(g_i64))
223 }
224
225 pub fn dkey_of(&mut self, id: &str) -> Result<i64> {
227 self.dkey_opt(id)?
228 .ok_or_else(|| OpysError::NotFound { id: id.to_string() })
229 }
230
231 pub fn has_relation(&mut self, id: &str, field: &str, target: &str) -> Result<bool> {
234 let Some(dkey) = self.dkey_opt(id)? else {
235 return Ok(false);
236 };
237 Ok(self
238 .scalar(
239 "SELECT 1 FROM relations WHERE dkey = $1 AND field = $2 AND ref_id = $3 LIMIT 1",
240 vec![dkey.into_param(), field.into_param(), target.into_param()],
241 )?
242 .is_some())
243 }
244
245 pub fn put_doc(&mut self, pcfg: &ProjectConfig, dkey: Option<i64>, doc: &Doc) -> Result<i64> {
250 let rows = decompose(pcfg, doc)?;
251 let relpath = self.relpath_of(&doc.path)?;
252 let dkey = match dkey {
253 Some(k) => {
254 self.exec(
255 "UPDATE docs SET id = $1, num = $2, type = $3, status = $4, title = $5, \
256 created = $6, updated = $7, body = $8, path = $9 WHERE dkey = $10",
257 vec![
258 rows.id.clone().into_param(),
259 rows.num.into_param(),
260 rows.type_name.clone().into_param(),
261 rows.status.clone().into_param(),
262 rows.title.clone().into_param(),
263 rows.created.clone().into_param(),
264 rows.updated.clone().into_param(),
265 rows.body.clone().into_param(),
266 relpath.into_param(),
267 k.into_param(),
268 ],
269 )?;
270 for table in ["tags", "relations", "fm_fields"] {
271 self.exec(
272 &format!("DELETE FROM {table} WHERE dkey = $1"),
273 vec![k.into_param()],
274 )?;
275 }
276 k
277 }
278 None => {
279 let k = self.next_dkey;
280 self.next_dkey += 1;
281 self.exec(
282 "INSERT INTO docs VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, \
283 NULL, NULL, NULL)",
284 vec![
285 k.into_param(),
286 rows.id.clone().into_param(),
287 rows.num.into_param(),
288 rows.type_name.clone().into_param(),
289 rows.status.clone().into_param(),
290 rows.title.clone().into_param(),
291 rows.created.clone().into_param(),
292 rows.updated.clone().into_param(),
293 rows.body.clone().into_param(),
294 relpath.into_param(),
295 ],
296 )?;
297 k
298 }
299 };
300 let (mut t, mut r, mut f) = (Vec::new(), Vec::new(), Vec::new());
301 push_child_rows(dkey, &rows, &mut t, &mut r, &mut f);
302 self.insert_batch("tags", 6, t)?;
303 self.insert_batch("relations", 9, r)?;
304 self.insert_batch("fm_fields", 6, f)?;
305 Ok(dkey)
306 }
307
308 pub fn delete_doc(&mut self, dkey: i64) -> Result<()> {
310 for table in ["docs", "tags", "relations", "fm_fields"] {
311 self.exec(
312 &format!("DELETE FROM {table} WHERE dkey = $1"),
313 vec![dkey.into_param()],
314 )?;
315 }
316 Ok(())
317 }
318
319 pub fn touch(&mut self, dkey: i64, now: &str) -> Result<()> {
325 self.exec(
327 "DELETE FROM fm_fields WHERE dkey = $1 AND key = 'updated'",
328 vec![dkey.into_param()],
329 )?;
330 self.exec(
331 "UPDATE docs SET updated = $1 WHERE dkey = $2",
332 vec![now.into_param(), dkey.into_param()],
333 )?;
334 let has_created_col = self
335 .scalar(
336 "SELECT created FROM docs WHERE dkey = $1",
337 vec![dkey.into_param()],
338 )?
339 .is_some();
340 let has_created_fm = self
341 .scalar(
342 "SELECT COUNT(*) FROM fm_fields WHERE dkey = $1 AND key = 'created'",
343 vec![dkey.into_param()],
344 )?
345 .as_ref()
346 .and_then(g_i64)
347 .unwrap_or(0)
348 > 0;
349 if !has_created_col && !has_created_fm {
350 self.exec(
351 "UPDATE docs SET created = $1 WHERE dkey = $2",
352 vec![now.into_param(), dkey.into_param()],
353 )?;
354 }
355 Ok(())
356 }
357
358 pub fn set_path(&mut self, dkey: i64, relpath: &str) -> Result<()> {
362 self.exec(
363 "UPDATE docs SET path = $1 WHERE dkey = $2",
364 vec![relpath.into_param(), dkey.into_param()],
365 )
366 }
367
368 pub fn set_canonical_path(&mut self, pcfg: &ProjectConfig, dkey: i64) -> Result<()> {
372 let (_, rows) = self.select(
373 "SELECT id, status FROM docs WHERE dkey = $1",
374 vec![dkey.into_param()],
375 )?;
376 let Some(row) = rows.into_iter().next() else {
377 return Ok(());
378 };
379 if let (Some(id), Some(status)) = (g_str(&row[0]), g_str(&row[1])) {
380 let rel = pcfg.doc_relpath(&id, &status);
381 self.exec(
382 "UPDATE docs SET path = $1 WHERE dkey = $2",
383 vec![path_str(&rel).into_param(), dkey.into_param()],
384 )?;
385 }
386 Ok(())
387 }
388
389 pub fn retire_id(&mut self, id: &str, title: &str) -> Result<()> {
391 let rkey = self.next_rkey;
392 self.next_rkey += 1;
393 self.exec(
394 "INSERT INTO retired VALUES ($1, $2, $3, $4)",
395 vec![
396 rkey.into_param(),
397 id.into_param(),
398 id_num(id).into_param(),
399 title.into_param(),
400 ],
401 )?;
402 Ok(())
403 }
404
405 pub fn doc_ids(&mut self) -> Result<std::collections::HashSet<String>> {
408 let (_, rows) = self.select("SELECT id FROM docs WHERE id IS NOT NULL", vec![])?;
409 Ok(rows.into_iter().filter_map(|r| g_str(&r[0])).collect())
410 }
411
412 pub fn title_of(&mut self, id: &str) -> Result<Option<String>> {
414 Ok(self
415 .scalar(
416 "SELECT title FROM docs WHERE id = $1 ORDER BY dkey LIMIT 1",
417 vec![id.into_param()],
418 )?
419 .as_ref()
420 .and_then(g_str))
421 }
422
423 pub fn max_doc_num(&mut self) -> Result<i64> {
426 let mut max = 0i64;
427 for sql in [
428 "SELECT MAX(num) FROM docs",
429 "SELECT MAX(ref_num) FROM relations",
430 "SELECT MAX(num) FROM retired",
431 ] {
432 if let Some(n) = self.scalar(sql, vec![])?.as_ref().and_then(g_i64) {
433 max = max.max(n);
434 }
435 }
436 Ok(max)
437 }
438
439 pub fn next_id_for(&mut self, prefix: &str, pad: usize) -> Result<String> {
441 let next = self.max_doc_num()? + 1;
442 Ok(format!("{prefix}-{next:0pad$}"))
443 }
444
445 fn relpath_of(&self, path: &Path) -> Result<String> {
450 let rel = path.strip_prefix(&self.base).map_err(|_| {
451 OpysError::Store(format!(
452 "doc path {} is not under the inventory base {}",
453 path.display(),
454 self.base.display()
455 ))
456 })?;
457 Ok(path_str(rel))
458 }
459
460 pub(crate) fn abspath(&self, relpath: &str) -> PathBuf {
462 self.base.join(relpath)
463 }
464}
465
466fn push_child_rows(
468 dkey: i64,
469 rows: &decompose::DocRows,
470 tags: &mut Vec<Vec<ParamLiteral>>,
471 rels: &mut Vec<Vec<ParamLiteral>>,
472 fms: &mut Vec<Vec<ParamLiteral>>,
473) {
474 let doc_id = rows.id.clone();
475 for t in &rows.tags {
476 tags.push(vec![
477 dkey.into_param(),
478 doc_id.clone().into_param(),
479 t.seq.into_param(),
480 t.tag.clone().into_param(),
481 t.key.clone().into_param(),
482 t.value.clone().into_param(),
483 ]);
484 }
485 for r in &rows.relations {
486 rels.push(vec![
487 dkey.into_param(),
488 doc_id.clone().into_param(),
489 r.field.into_param(),
490 r.seq.into_param(),
491 r.ref_id.clone().into_param(),
492 r.ref_num.into_param(),
493 r.raw_value.clone().into_param(),
494 r.title.clone().into_param(),
495 r.struck.into_param(),
496 ]);
497 }
498 for f in &rows.fm_fields {
499 fms.push(vec![
500 dkey.into_param(),
501 doc_id.clone().into_param(),
502 f.key.clone().into_param(),
503 f.value_yaml.clone().into_param(),
504 f.value.clone().into_param(),
505 f.kind.into_param(),
506 ]);
507 }
508}
509
510pub(crate) trait IntoParam {
513 fn into_param(self) -> ParamLiteral;
514}
515impl<T: gluesql::core::translate::IntoParamLiteral> IntoParam for T {
516 fn into_param(self) -> ParamLiteral {
517 self.into_param_literal()
518 }
519}
520
521fn store_err(e: GlueError) -> OpysError {
522 OpysError::Store(e.to_string())
523}
524
525pub(crate) fn g_str(v: &GValue) -> Option<String> {
530 match v {
531 GValue::Str(s) => Some(s.clone()),
532 _ => None,
533 }
534}
535
536pub(crate) fn g_i64(v: &GValue) -> Option<i64> {
537 match v {
538 GValue::I64(n) => Some(*n),
539 GValue::I32(n) => Some(i64::from(*n)),
540 GValue::I16(n) => Some(i64::from(*n)),
541 GValue::I8(n) => Some(i64::from(*n)),
542 GValue::U64(n) => i64::try_from(*n).ok(),
543 GValue::U32(n) => Some(i64::from(*n)),
544 _ => None,
545 }
546}
547
548#[allow(dead_code)] pub(crate) fn g_bool(v: &GValue) -> Option<bool> {
550 match v {
551 GValue::Bool(b) => Some(*b),
552 _ => None,
553 }
554}
555
556fn path_str(p: &Path) -> String {
558 p.to_string_lossy().into_owned()
559}
560
561#[cfg(test)]
562mod tests {
563 use super::*;
564
565 const CFG: &str = r#"
566pad = 4
567[types.feature]
568prefix = "FEAT"
569statuses = ["planned", "implemented", "archived"]
570default_status = "planned"
571status_dirs = { archived = "_archived" }
572[types.feature.fields.priority]
573type = "string"
574[types.feature.fields.area]
575type = "list"
576[types.task]
577prefix = "TASK"
578statuses = ["todo", "done"]
579default_status = "todo"
580terminal_statuses = ["done"]
581"#;
582
583 fn project_with(docs: &[(&str, &str)]) -> (tempfile::TempDir, Project) {
585 let dir = tempfile::tempdir().expect("tempdir");
586 std::fs::write(dir.path().join("opys.toml"), CFG).unwrap();
587 let base = dir.path().join("opys");
588 std::fs::create_dir_all(&base).unwrap();
589 for (rel, text) in docs {
590 let p = base.join(rel);
591 std::fs::create_dir_all(p.parent().unwrap()).unwrap();
592 std::fs::write(p, text).unwrap();
593 }
594 let prj = Project::open(&dir.path().to_string_lossy()).expect("open project");
595 (dir, prj)
596 }
597
598 fn store_of(prj: &Project, docs: &[(&str, &str)]) -> (Store, Vec<String>) {
601 let parsed: Vec<(Doc, Option<String>)> = docs
602 .iter()
603 .map(|(rel, text)| (Doc::parse(prj.base.join(rel), text).expect("parse"), None))
604 .collect();
605 let retired = crate::retired::read(&prj.base);
606 let retired_legacy = crate::retired::legacy_path(&prj.base).exists();
607 Store::build(
608 prj,
609 LoadedCorpus {
610 docs: parsed,
611 errors: Vec::new(),
612 retired,
613 retired_legacy,
614 },
615 )
616 .expect("build")
617 }
618
619 #[test]
623 fn glue_probes() {
624 let (_t, prj) = project_with(&[]);
625 let (mut s, errs) = store_of(&prj, &[]);
626 assert!(errs.is_empty());
627
628 assert!(s
630 .scalar("SELECT MAX(num) FROM docs", vec![])
631 .unwrap()
632 .is_none());
633
634 s.exec(
636 "INSERT INTO retired VALUES ($1, $2, $3, $4)",
637 vec![
638 1i64.into_param(),
639 "FEAT-0002".into_param(),
640 2i64.into_param(),
641 "FEAT-0002 # retired".into_param(),
642 ],
643 )
644 .unwrap();
645 let v = s
646 .scalar(
647 "SELECT num FROM retired WHERE id = $1 LIMIT 1",
648 vec!["FEAT-0002".into_param()],
649 )
650 .unwrap();
651 assert_eq!(v.as_ref().and_then(g_i64), Some(2));
652 s.exec(
653 "UPDATE retired SET title = $1 WHERE id = $2",
654 vec!["x".into_param(), "FEAT-0002".into_param()],
655 )
656 .unwrap();
657 s.exec(
658 "DELETE FROM retired WHERE id = $1",
659 vec!["FEAT-0002".into_param()],
660 )
661 .unwrap();
662
663 s.exec(
665 "INSERT INTO relations VALUES \
666 ($1,$2,$3,$4,$5,$6,$7,$8,$9), ($10,$11,$12,$13,$14,$15,$16,$17,$18)",
667 vec![
668 1i64.into_param(),
669 "A-1".into_param(),
670 "references".into_param(),
671 0i64.into_param(),
672 "B-2".into_param(),
673 2i64.into_param(),
674 "T".into_param(),
675 "T".into_param(),
676 false.into_param(),
677 1i64.into_param(),
678 "A-1".into_param(),
679 "blocks".into_param(),
680 1i64.into_param(),
681 "B-2".into_param(),
682 2i64.into_param(),
683 "~~T~~".into_param(),
684 "T".into_param(),
685 true.into_param(),
686 ],
687 )
688 .unwrap();
689 let n = s
690 .scalar("SELECT COUNT(DISTINCT ref_id) FROM relations", vec![])
691 .unwrap();
692 assert_eq!(n.as_ref().and_then(g_i64), Some(1));
693 let c = s
697 .scalar("SELECT COALESCE(MAX(num), 0) FROM docs", vec![])
698 .unwrap();
699 assert_eq!(c.as_ref().and_then(g_i64), None);
700 let struck = s
701 .scalar(
702 "SELECT COUNT(*) FROM relations WHERE struck = $1",
703 vec![true.into_param()],
704 )
705 .unwrap();
706 assert_eq!(struck.as_ref().and_then(g_i64), Some(1));
707
708 let (_, rows) = s
711 .select(
712 "SELECT r.ref_id FROM relations r \
713 LEFT JOIN relations r2 ON r2.field = r.field AND r2.ref_id = 'nope' \
714 WHERE r2.ref_id IS NULL ORDER BY r.field",
715 vec![],
716 )
717 .unwrap();
718 assert_eq!(rows.len(), 2);
719 }
720
721 #[test]
724 fn fixpoint_over_adversarial_docs() {
725 let docs: &[(&str, &str)] = &[
726 ("FEAT-0001.md",
728 "---\nid: FEAT-0001\nstatus: planned\ntags: [osc, area:parsing]\n\
729 created: \"2026-01-01T00:00:00Z\"\npriority: high\nreferences:\n \
730 TASK-0002: ~~Closed thing~~\n TASK-0003: Live thing\n---\n\n# One\n\nBody prose.\n"),
731 ("FEAT-0004.md",
733 "---\nid: FEAT-0004\nstatus: 5\ntags: []\nreferences: {}\n---\n\n# Four\n"),
734 ("FEAT-0005.md",
736 "---\nid: FEAT-0005\nstatus: planned\nmeta:\n nested:\n deep: [1, 2]\n\
737 note: |\n line one\n line two\nblocked_by:\n TASK-0002: 7\n---\n\n# Five\n"),
738 ("FEAT-0006.md",
740 "---\nid: FEAT-0006\nstatus: planned\nreferences:\n TASK-0009: Nine\n \
741 TASK-0002: Two\n---\n\n# Six\n"),
742 ("stray/FEAT-0007.md",
744 "---\nid: FEAT-0007\nstatus: planned\npriority: \"5\"\narea: [ui, core]\n---\n\n# Seven\n"),
745 ];
746 let (_t, prj) = project_with(docs);
747 let (mut s, errs) = store_of(&prj, docs);
748 assert!(errs.is_empty(), "parse errors: {errs:?}");
749
750 let orig: Vec<Doc> = docs
751 .iter()
752 .map(|(rel, text)| Doc::parse(prj.base.join(rel), text).unwrap())
753 .collect();
754 let rebuilt = s.all_docs().unwrap();
755 assert_eq!(orig.len(), rebuilt.len());
756 for (o, (_, r)) in orig.iter().zip(&rebuilt) {
757 assert_eq!(
758 o.to_text(),
759 r.to_text(),
760 "fixpoint failed for {}",
761 o.path.display()
762 );
763 assert_eq!(o.path, r.path);
764 }
765 }
766
767 #[test]
770 fn touch_bumps_updated_and_respects_existing_created() {
771 let docs: &[(&str, &str)] = &[
772 (
773 "FEAT-0001.md",
774 "---\nid: FEAT-0001\nstatus: planned\n---\n\n# A\n",
775 ),
776 (
777 "FEAT-0002.md",
778 "---\nid: FEAT-0002\nstatus: planned\ncreated: 7\nupdated: 8\n---\n\n# B\n",
779 ),
780 ];
781 let (_t, prj) = project_with(docs);
782 let (mut s, _) = store_of(&prj, docs);
783
784 let k1 = s.dkey_of("FEAT-0001").unwrap();
785 s.touch(k1, "2026-03-03T00:00:00Z").unwrap();
786 let d1 = s.doc(k1).unwrap();
787 assert_eq!(
788 d1.frontmatter.get_str("created"),
789 Some("2026-03-03T00:00:00Z")
790 );
791 assert_eq!(
792 d1.frontmatter.get_str("updated"),
793 Some("2026-03-03T00:00:00Z")
794 );
795
796 let k2 = s.dkey_of("FEAT-0002").unwrap();
798 s.touch(k2, "2026-03-03T00:00:00Z").unwrap();
799 let d2 = s.doc(k2).unwrap();
800 assert_eq!(
801 d2.frontmatter.get("created"),
802 Some(&serde_norway::Value::Number(7.into()))
803 );
804 assert_eq!(
805 d2.frontmatter.get_str("updated"),
806 Some("2026-03-03T00:00:00Z")
807 );
808 }
809
810 #[test]
813 fn next_id_covers_docs_relations_and_ledger() {
814 let docs: &[(&str, &str)] = &[(
815 "FEAT-0001.md",
816 "---\nid: FEAT-0001\nstatus: planned\nreferences:\n TASK-0007: ~~Gone~~\n---\n\n# A\n",
817 )];
818 let (_t, prj) = project_with(docs);
819 std::fs::write(prj.base.join("_retired.txt"), "FEAT-0012 # retired\n").unwrap();
820 let (mut s, _) = store_of(&prj, docs);
821 assert_eq!(s.next_id_for("FEAT", 4).unwrap(), "FEAT-0013");
822 }
823
824 #[test]
827 fn user_query_rejects_non_select() {
828 let docs: &[(&str, &str)] = &[(
829 "FEAT-0001.md",
830 "---\nid: FEAT-0001\nstatus: planned\n---\n\n# A\n",
831 )];
832 let (_t, prj) = project_with(docs);
833 let (mut s, _) = store_of(&prj, docs);
834
835 for bad in [
836 "DELETE FROM docs",
837 "DROP TABLE docs",
838 "INSERT INTO docs SELECT * FROM docs",
839 "UPDATE docs SET status = 'x'",
840 "DELETE FROM docs; SELECT 1",
841 ] {
842 let err = s.run_user_query(bad, &[]).unwrap_err();
843 assert!(
844 err.contains("must be a SELECT") || err.contains("query failed"),
845 "{bad}: {err}"
846 );
847 }
848 let n = s.scalar("SELECT COUNT(*) FROM docs", vec![]).unwrap();
850 assert_eq!(n.as_ref().and_then(g_i64), Some(1));
851
852 let (labels, rows) = s
853 .run_user_query("SELECT id, status FROM docs ORDER BY id", &[])
854 .unwrap();
855 assert_eq!(labels, vec!["id", "status"]);
856 assert_eq!(
857 rows,
858 vec![vec!["FEAT-0001".to_string(), "planned".to_string()]]
859 );
860 }
861}