Skip to main content

opys_engine/store/
mod.rs

1//! The in-memory SQL store — the internal working representation of the
2//! inventory. Markdown files remain the durable storage; per CLI invocation
3//! the corpus is loaded and decomposed into GlueSQL tables (`schema`), commands
4//! operate on those tables (plus reconstructed [`Doc`]s for the Rust-side
5//! invariants: rules, linkify, mdprism), and [`Store::flush`] writes changed
6//! documents back to disk (create/rename/delete/ledger included).
7//!
8//! Internal SQL conventions: values are ALWAYS bound as `$n` parameters (never
9//! string-interpolated); no `IN (subquery)`, no FROM-subqueries, no `UNION`
10//! (unsupported), no correlated subqueries — GlueSQL executes those as O(n×m)
11//! nested loops or rejects them. Multi-table aggregates run as separate simple
12//! statements combined in Rust.
13
14mod 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)] // used by command ports as they land
33pub(crate) use decompose::split_tag;
34pub(crate) use decompose::{decompose, id_num};
35pub use flush::FlushPlan;
36pub(crate) use model::Baseline;
37
38/// The in-memory corpus database for one CLI invocation.
39pub struct Store {
40    glue: Glue<MemoryStorage>,
41    /// Inventory base directory (absolute); `docs.path` is stored relative to it.
42    base: PathBuf,
43    /// Load snapshot `(dkey, absolute path)` — deletion detection at flush.
44    loaded: Vec<(i64, PathBuf)>,
45    /// Ledger row count at load; flush rewrites the ledger iff it grew.
46    retired_loaded: usize,
47    /// Whether a legacy plaintext `_retired.txt` was present at load, so flush
48    /// migrates it to `_retired.md` even when no id was reserved this run.
49    retired_legacy: bool,
50    next_dkey: i64,
51    next_rkey: i64,
52}
53
54/// An already-read, already-parsed corpus snapshot — the input to
55/// [`Store::build`]. The storage backend produces this from the durable medium
56/// (reading and parsing documents and the retired ledger); `build` turns it
57/// into the working store with no filesystem access.
58pub struct LoadedCorpus {
59    /// Each parsed document paired with its original mtime (rfc3339), used to
60    /// backfill missing timestamps during sync.
61    pub docs: Vec<(Doc, Option<String>)>,
62    /// Non-fatal parse errors (unparsable files were skipped).
63    pub errors: Vec<String>,
64    /// The retired-id ledger as `(id, title)` pairs.
65    pub retired: Vec<(String, String)>,
66    /// Whether a legacy `_retired.txt` was present (triggers migration on flush).
67    pub retired_legacy: bool,
68}
69
70impl Store {
71    /// Build the working store from an already-read, already-parsed
72    /// [`LoadedCorpus`] — the medium-agnostic half of loading (no filesystem
73    /// access). The storage backend reads the medium and calls this.
74    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(), // orig_path
108                doc.to_text().into_param(),   // orig_text
109                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    // ------------------------------------------------------------------
136    // SQL primitives
137    // ------------------------------------------------------------------
138
139    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    /// Execute a non-SELECT internal statement.
144    pub fn exec(&mut self, sql: &str, params: Vec<ParamLiteral>) -> Result<()> {
145        self.run(sql, params).map(|_| ())
146    }
147
148    /// Execute an internal SELECT; returns (column labels, rows) of the last
149    /// payload.
150    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    /// One-row-one-column SELECT convenience; `None` when no row or NULL.
165    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    /// Multi-row INSERT with parameter binding, chunked to bound statement size.
174    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    // ------------------------------------------------------------------
210    // Doc-level API
211    // ------------------------------------------------------------------
212
213    /// The row key for an id — first match in load (path) order, mirroring
214    /// `Project::find` on a corpus with duplicate ids. `None` if absent.
215    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    /// The row key for an id, or a not-found error.
226    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    /// Whether the doc with `id` (first in load order) has an entry for
232    /// `target` in relation `field`. `false` if the id is absent.
233    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    /// Insert (`dkey = None`) or replace (`Some`) a document's rows. The doc's
246    /// `path` must already be its intended location (absolute, under base).
247    /// Replacement preserves the row's load snapshot (`orig_*`), so flush still
248    /// diffs against the original file.
249    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    /// Remove a document's rows entirely (close/retire); flush deletes the file.
309    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    /// Stamp the auto-maintained timestamps for a *user-initiated* edit: always
320    /// refresh `updated`; set `created` only when the document has no `created`
321    /// key at all (a wrong-typed value living in `fm_fields` blocks backfill,
322    /// exactly like today's `contains_key` check). Housekeeping (sync,
323    /// reconcile, strikes, cleanup) must never call this.
324    pub fn touch(&mut self, dkey: i64, now: &str) -> Result<()> {
325        // `updated` is overwritten wherever it lives.
326        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    /// Overwrite `docs.path` directly (the relative form; flush performs the
359    /// rename). Callers that already know the target path use this to avoid the
360    /// read in [`Self::set_canonical_path`].
361    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    /// Point `docs.path` at the canonical layout path for the doc's id/status
369    /// (no-op when either is missing, matching `save_doc`). Flush performs the
370    /// actual rename.
371    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    /// Reserve a retired id with its last-known `title`; flush writes the ledger.
390    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    /// The set of live document ids (the resolution universe for
406    /// `rules::evaluate` and reference targets).
407    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    /// The title of the doc with `id` (first in load order), or `None`.
413    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    /// Highest numeric id across live docs, every relation-map entry (struck or
424    /// not), and the retired ledger — the global monotonic sequence.
425    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    /// Next id for a type prefix: one past the global max, zero-padded.
440    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    // ------------------------------------------------------------------
446
447    /// A doc's absolute path relativized to the inventory base (the `path`
448    /// column representation).
449    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    /// The absolute path for a `path` column value.
461    pub(crate) fn abspath(&self, relpath: &str) -> PathBuf {
462        self.base.join(relpath)
463    }
464}
465
466/// Push a decomposed doc's child-table parameter rows.
467fn 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
510/// Convert any `IntoParamLiteral` into a `ParamLiteral` (ergonomic shorthand;
511/// `Option<T>` maps `None` → SQL NULL).
512pub(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
525// ----------------------------------------------------------------------
526// GValue decode helpers
527// ----------------------------------------------------------------------
528
529pub(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)] // used by command ports as they land
549pub(crate) fn g_bool(v: &GValue) -> Option<bool> {
550    match v {
551        GValue::Bool(b) => Some(*b),
552        _ => None,
553    }
554}
555
556/// A path as a `path`-column string (forward-slash normalized display form).
557fn 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    /// A temp project: opys.toml + base dir with the given (relpath, text) docs.
584    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    /// Build a store from in-memory-parsed docs (no document fs) — the store's
599    /// data logic under test; real load/flush fs lives in the backend crate.
600    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    /// Every GlueSQL construct the store (and the future sync engine) relies
620    /// on, probed in one place so a gluesql upgrade failing any of them is
621    /// caught here, not in a command.
622    #[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        // MAX over an empty table is NULL → scalar() → None.
629        assert!(s
630            .scalar("SELECT MAX(num) FROM docs", vec![])
631            .unwrap()
632            .is_none());
633
634        // Parameterized INSERT / SELECT / UPDATE / DELETE round-trip.
635        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        // COUNT(DISTINCT …), COALESCE, boolean params.
664        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        // Pin a GlueSQL 0.19 gap: COALESCE over an aggregate does NOT rescue
694        // the empty-table NULL. Internal code must default in Rust (as
695        // `max_doc_num` does), never via COALESCE(MAX(…), n).
696        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        // Multi-condition LEFT JOIN … ON a AND b, with IS NULL filtering — the
709        // shape the sync engine's missing-inverse-edge queries use.
710        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    /// The gate invariant: decompose → insert → reconstruct → to_text is
722    /// byte-identical for every fidelity edge shape.
723    #[test]
724    fn fixpoint_over_adversarial_docs() {
725        let docs: &[(&str, &str)] = &[
726            // Plain canonical doc with tags, relations (incl. tombstone), fields.
727            ("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            // Wrong-typed core fields + empty tags + empty relation map.
732            ("FEAT-0004.md",
733             "---\nid: FEAT-0004\nstatus: 5\ntags: []\nreferences: {}\n---\n\n# Four\n"),
734            // Nested custom field, block scalar, non-string relation value.
735            ("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            // Hand-edited (unsorted) relation map order must round-trip.
739            ("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            // Missing id; string-that-looks-like-a-number; list field.
743            ("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    /// `updated` always bumps; `created` backfills only when the key is absent
768    /// everywhere — a wrong-typed `created` (in fm_fields) blocks the backfill.
769    #[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        // Wrong-typed created (int) must survive; wrong-typed updated is replaced.
797        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    /// The id sequence covers live docs, relation-map ids (struck or not), and
811    /// the retired ledger.
812    #[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    /// User SQL is statement-guarded on the plan: no mutation can execute, even
825    /// ahead of a trailing SELECT.
826    #[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        // Nothing executed: the doc row is intact.
849        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}