Skip to main content

nomograph_workflow/
store.rs

1//! Shared Store adapter over `nomograph_claim::Store + View`.
2//!
3//! Both synthesist and lattice used to own identical copies of this
4//! adapter (SynthStore / LattStore). Folded into one type here so the
5//! wrapping convention — schema-validated appends, SQLite view synced
6//! after each write, session-scoped asserter — has a single source of
7//! truth.
8
9use std::path::{Path, PathBuf};
10
11use anyhow::{Context, Result, anyhow, bail};
12use nomograph_claim::{Claim, ClaimId, ClaimType, Store as ClaimStore, View};
13use serde_json::Value;
14
15use crate::legacy::{find_legacy_v1_db, legacy_migration_error};
16
17/// Directory inside a project that holds the claim substrate.
18///
19/// Visible name, plural, no nicknames. Matches D3 in
20/// `keaton/research/graph-primitive/BUILDING.md`.
21pub const CLAIMS_DIR: &str = "claims";
22
23fn local_asserter() -> String {
24    let user = std::env::var("USER").unwrap_or_else(|_| "unknown".into());
25    format!("user:local:{user}")
26}
27
28/// Synthesist / lattice adapter over a project's `claims/` directory.
29///
30/// Writes go through [`Store::append`]; reads go through
31/// [`Store::query`]. Every append syncs the view before returning so
32/// follow-up reads see the write.
33pub struct Store {
34    inner: ClaimStore,
35    view: View,
36    asserted_by: String,
37    root: PathBuf,
38}
39
40impl Store {
41    /// Open (or initialize) a Store.
42    ///
43    /// Resolution order:
44    ///   1. `SYNTHESIST_DIR` env var (set by `--data-dir` in the CLI,
45    ///      or exported directly). Points at the directory CONTAINING
46    ///      `claims/`. An explicit override that doesn't resolve to an
47    ///      existing store is a hard error — silent fallback would mask
48    ///      misconfiguration (e.g. a typo yielding an empty estate).
49    ///   2. Parent-directory walk from cwd. Opens the first ancestor
50    ///      containing `claims/genesis.amc`.
51    ///   3. If none, initialize a fresh store at `cwd/claims/` (unless
52    ///      a v1 `.synth/main.db` is in scope, in which case we refuse
53    ///      and name the migrator).
54    pub fn discover() -> Result<Self> {
55        if let Ok(raw) = std::env::var("SYNTHESIST_DIR")
56            && !raw.is_empty()
57        {
58            return Self::open_explicit(Path::new(&raw));
59        }
60        let cwd = std::env::current_dir().context("cwd")?;
61        Self::discover_from(&cwd)
62    }
63
64    /// Open the store at an explicit path (`SYNTHESIST_DIR` or the
65    /// `--data-dir` flag). The path names the directory CONTAINING
66    /// `claims/`; this mirrors the `--data-dir` help text and the
67    /// v1.3.0 semantics the CHANGELOG promised.
68    ///
69    /// Fails loudly if the path doesn't exist or has no
70    /// `claims/genesis.amc`. Previously the caller silently fell
71    /// through to `Self::init_at` and created a fresh empty store,
72    /// which made typos and wrong paths indistinguishable from an
73    /// actually empty estate.
74    fn open_explicit(dir: &Path) -> Result<Self> {
75        if !dir.exists() {
76            bail!(
77                "SYNTHESIST_DIR / --data-dir points at `{}` which does not exist",
78                dir.display()
79            );
80        }
81        if !dir.is_dir() {
82            bail!(
83                "SYNTHESIST_DIR / --data-dir points at `{}` which is not a directory",
84                dir.display()
85            );
86        }
87        let claims = dir.join(CLAIMS_DIR);
88        if !claims.join("genesis.amc").is_file() {
89            return Err(anyhow!(
90                "SYNTHESIST_DIR / --data-dir points at `{}` but no `{}/genesis.amc` is present there. \
91                 Run `synthesist init` in that directory first, or unset the override to fall back \
92                 to parent-directory discovery from cwd.",
93                dir.display(),
94                CLAIMS_DIR
95            ));
96        }
97        Self::open_at(&claims)
98    }
99
100    /// Like [`Self::discover`] but starts from a given path.
101    ///
102    /// Walk-up order:
103    ///   1. Any ancestor with a `claims/genesis.amc` → open it.
104    ///   2. Otherwise, if any ancestor has a v1 `.synth/main.db`, bail
105    ///      with a prescriptive migration error. Silently creating an
106    ///      empty v2 estate alongside legacy data orphans the user's
107    ///      work; we refuse.
108    ///   3. Otherwise, initialize a fresh v2 estate at `start/claims/`.
109    pub fn discover_from(start: &Path) -> Result<Self> {
110        let mut cur = start.to_path_buf();
111        loop {
112            let candidate = cur.join(CLAIMS_DIR);
113            if candidate.join("genesis.amc").is_file() {
114                return Self::open_at(&candidate);
115            }
116            if !cur.pop() {
117                break;
118            }
119        }
120        if let Some(legacy) = find_legacy_v1_db(start) {
121            return Err(legacy_migration_error(&legacy));
122        }
123        Self::init_at(&start.join(CLAIMS_DIR))
124    }
125
126    /// Open at an explicit `claims/` directory. The directory must
127    /// already contain a `genesis.amc`.
128    pub fn open_at(claims_dir: &Path) -> Result<Self> {
129        let mut inner = ClaimStore::open(claims_dir)
130            .with_context(|| format!("open claim store at {}", claims_dir.display()))?;
131        let mut view = View::open(claims_dir)
132            .with_context(|| format!("open view at {}", claims_dir.display()))?;
133        view.sync(&mut inner).context("sync view on open")?;
134        Ok(Self {
135            inner,
136            view,
137            asserted_by: local_asserter(),
138            root: claims_dir.to_path_buf(),
139        })
140    }
141
142    /// Initialize a fresh Store at `claims_dir`.
143    pub fn init_at(claims_dir: &Path) -> Result<Self> {
144        let inner = ClaimStore::init(claims_dir)
145            .with_context(|| format!("init claim store at {}", claims_dir.display()))?;
146        let view = View::open(claims_dir)
147            .with_context(|| format!("open view at {}", claims_dir.display()))?;
148        Ok(Self {
149            inner,
150            view,
151            asserted_by: local_asserter(),
152            root: claims_dir.to_path_buf(),
153        })
154    }
155
156    /// Discover and scope the asserter with an optional session id.
157    ///
158    /// When a session id is given, subsequent appends assert as
159    /// `user:local:<user>:<session>` — giving us attribution without
160    /// cryptographic identity (see IDENTITY.md for the v0.2 roadmap).
161    pub fn discover_for(session: &Option<String>) -> Result<Self> {
162        let mut s = Self::discover()?;
163        if let Some(id) = session
164            && !id.is_empty()
165        {
166            s.asserted_by = format!("{}:{}", s.asserted_by, id);
167        }
168        Ok(s)
169    }
170
171    /// Override the asserter prefix. Reserved for tests and for future
172    /// callers that set asserter from a parsed Session claim (e.g.
173    /// beacon sync, rather than `$USER`).
174    #[allow(dead_code)]
175    pub fn with_asserter(mut self, asserted_by: impl Into<String>) -> Self {
176        self.asserted_by = asserted_by.into();
177        self
178    }
179
180    /// Append a typed claim and synchronize the SQLite view.
181    ///
182    /// Both the workflow layer and the substrate are type-agnostic
183    /// for validation purposes since claim 0.2.0 / workflow 0.2.0:
184    /// per-type schema validation is the consumer's responsibility,
185    /// applied at its API boundary (typically a CLI or library
186    /// entrypoint) BEFORE calling this method. Consumers compose
187    /// `nomograph_claim::validation` helpers with their own per-type
188    /// validators; see `synthesist::schema` for a worked example.
189    /// This method assumes the caller has already validated `props`
190    /// for the given `claim_type`.
191    pub fn append(
192        &mut self,
193        claim_type: ClaimType,
194        props: Value,
195        supersedes: Option<ClaimId>,
196    ) -> Result<ClaimId> {
197        let mut claim = Claim::new(claim_type, props, self.asserted_by.clone());
198        if let Some(prior) = supersedes {
199            claim = claim.with_supersedes(prior);
200        }
201        self.inner.append(&claim).context("append claim")?;
202        self.view
203            .sync(&mut self.inner)
204            .context("sync view after append")?;
205        Ok(claim.id)
206    }
207
208    /// Run a read-only SQL query over the SQLite view.
209    pub fn query(&self, sql: &str, params: &[&dyn rusqlite::ToSql]) -> Result<Vec<Value>> {
210        self.view.query(sql, params).context("view query")
211    }
212
213    /// The `claims/` directory backing this store.
214    pub fn root(&self) -> &Path {
215        &self.root
216    }
217
218    /// Force a view rebuild. Most callers don't need this; `append`
219    /// syncs automatically.
220    #[allow(dead_code)]
221    pub fn sync_view(&mut self) -> Result<()> {
222        self.view.sync(&mut self.inner).context("sync view")?;
223        Ok(())
224    }
225
226    /// Access the underlying claim store (for session handles,
227    /// compact, merge). Most callers should not need this.
228    pub fn inner(&mut self) -> &mut ClaimStore {
229        &mut self.inner
230    }
231}