Skip to main content

opys_engine/store/
flush.rs

1//! Persisting the store back to the durable medium is split in two: this module
2//! computes a **medium-agnostic** [`FlushPlan`] from the store's rows and load
3//! snapshot (no filesystem access), and the storage backend executes it. The
4//! plan makes the medium match the tables: docs deleted from the store lose
5//! their files, docs whose `path` moved are renamed, and a doc's file is
6//! (re)written only when its reconstructed canonical text differs from the text
7//! rendered at load. Ordering on apply is deletes → renames → writes, so a
8//! rename never lands on a path a delete is about to vacate. Finally
9//! `_retired.md` is (re)written iff an id was reserved this run, and any legacy
10//! `_retired.txt` is migrated to it.
11//!
12//! The `opys-backend-markdown-local` crate holds the local-filesystem executor
13//! (`apply_plan`) and the load-side walk/parse; a backend for another medium
14//! would execute the same plan differently.
15
16use std::collections::HashSet;
17use std::path::PathBuf;
18
19use crate::error::Result;
20use crate::project::Project;
21
22use super::{g_i64, g_str, Store};
23
24/// What a flush must persist, as absolute paths and rendered text — computed
25/// without touching the filesystem so any backend can execute it.
26#[derive(Debug, Default)]
27pub struct FlushPlan {
28    /// Document files to remove (a doc was deleted/closed/retired).
29    pub deletes: Vec<PathBuf>,
30    /// Document files to move (`from`, `to`) after a layout/status change.
31    pub renames: Vec<(PathBuf, PathBuf)>,
32    /// Document files to (re)write (`path`, canonical text).
33    pub writes: Vec<(PathBuf, String)>,
34    /// The retired ledger to (re)write (`_retired.md` path, serialized text).
35    pub retired_write: Option<(PathBuf, String)>,
36    /// A legacy `_retired.txt` to delete (migrated to `_retired.md`).
37    pub legacy_remove: Option<PathBuf>,
38}
39
40impl Store {
41    /// Compute the [`FlushPlan`] — no filesystem access. Consumes the store, the
42    /// final read of an invocation.
43    pub fn flush_plan(mut self, prj: &Project) -> Result<FlushPlan> {
44        let rows = self.full_rows()?;
45        let mut plan = FlushPlan::default();
46
47        // 1. Deletions: loaded rows that no longer exist in `docs`.
48        let surviving: HashSet<i64> = rows.iter().map(|r| r.dkey).collect();
49        for (dkey, load_path) in &self.loaded {
50            if !surviving.contains(dkey) {
51                plan.deletes.push(load_path.clone());
52            }
53        }
54
55        // 2. Renames: the authoritative path moved away from the load path.
56        for r in &rows {
57            if let Some(orig) = &r.orig_relpath {
58                if *orig != r.relpath {
59                    plan.renames
60                        .push((self.abspath(orig), self.abspath(&r.relpath)));
61                }
62            }
63        }
64
65        // 3. Writes: new docs always; existing docs only on logical change.
66        for r in &rows {
67            let text = r.doc.to_text();
68            let write = match &r.orig_text {
69                None => true,
70                Some(orig) => *orig != text,
71            };
72            if write {
73                plan.writes.push((self.abspath(&r.relpath), text));
74            }
75        }
76
77        // 4. Retired ledger: (re)write `_retired.md` when an id was reserved
78        //    this run or a legacy `_retired.txt` is present (migration). The
79        //    legacy-present flag was captured at load, so this stays fs-free.
80        let (_, retired) = self.select(
81            "SELECT rkey, id, num, title FROM retired ORDER BY rkey",
82            vec![],
83        )?;
84        let grew = retired.len() > self.retired_loaded;
85        if grew || self.retired_legacy {
86            let mut entries: Vec<(u64, (String, String))> = retired
87                .iter()
88                .map(|r| {
89                    let num = g_i64(&r[2]).map(|n| n as u64).unwrap_or(u64::MAX);
90                    let id = g_str(&r[1]).unwrap_or_default();
91                    let title = g_str(&r[3]).unwrap_or_default();
92                    (num, (id, title))
93                })
94                .collect();
95            entries.sort_by_key(|e| e.0); // stable: ties keep rkey order
96            let pairs: Vec<(String, String)> = entries.into_iter().map(|e| e.1).collect();
97            if !pairs.is_empty() {
98                plan.retired_write = Some((
99                    crate::retired::path(&prj.base),
100                    crate::retired::serialize(&pairs),
101                ));
102            }
103            if self.retired_legacy {
104                plan.legacy_remove = Some(crate::retired::legacy_path(&prj.base));
105            }
106        }
107        Ok(plan)
108    }
109}