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};
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 at `<cwd ancestor>/claims/`.
42    ///
43    /// Walks upward from cwd; initializes in cwd if none found AND no
44    /// v1 `.synth/main.db` exists in scope.
45    pub fn discover() -> Result<Self> {
46        let cwd = std::env::current_dir().context("cwd")?;
47        Self::discover_from(&cwd)
48    }
49
50    /// Like [`discover`] but starts from a given path.
51    ///
52    /// Walk-up order:
53    ///   1. Any ancestor with a `claims/genesis.amc` → open it.
54    ///   2. Otherwise, if any ancestor has a v1 `.synth/main.db`, bail
55    ///      with a prescriptive migration error. Silently creating an
56    ///      empty v2 estate alongside legacy data orphans the user's
57    ///      work; we refuse.
58    ///   3. Otherwise, initialize a fresh v2 estate at `start/claims/`.
59    pub fn discover_from(start: &Path) -> Result<Self> {
60        let mut cur = start.to_path_buf();
61        loop {
62            let candidate = cur.join(CLAIMS_DIR);
63            if candidate.join("genesis.amc").is_file() {
64                return Self::open_at(&candidate);
65            }
66            if !cur.pop() {
67                break;
68            }
69        }
70        if let Some(legacy) = find_legacy_v1_db(start) {
71            return Err(legacy_migration_error(&legacy));
72        }
73        Self::init_at(&start.join(CLAIMS_DIR))
74    }
75
76    /// Open at an explicit `claims/` directory. The directory must
77    /// already contain a `genesis.amc`.
78    pub fn open_at(claims_dir: &Path) -> Result<Self> {
79        let mut inner = ClaimStore::open(claims_dir)
80            .with_context(|| format!("open claim store at {}", claims_dir.display()))?;
81        let mut view = View::open(claims_dir)
82            .with_context(|| format!("open view at {}", claims_dir.display()))?;
83        view.sync(&mut inner).context("sync view on open")?;
84        Ok(Self {
85            inner,
86            view,
87            asserted_by: local_asserter(),
88            root: claims_dir.to_path_buf(),
89        })
90    }
91
92    /// Initialize a fresh Store at `claims_dir`.
93    pub fn init_at(claims_dir: &Path) -> Result<Self> {
94        let inner = ClaimStore::init(claims_dir)
95            .with_context(|| format!("init claim store at {}", claims_dir.display()))?;
96        let view = View::open(claims_dir)
97            .with_context(|| format!("open view at {}", claims_dir.display()))?;
98        Ok(Self {
99            inner,
100            view,
101            asserted_by: local_asserter(),
102            root: claims_dir.to_path_buf(),
103        })
104    }
105
106    /// Discover and scope the asserter with an optional session id.
107    ///
108    /// When a session id is given, subsequent appends assert as
109    /// `user:local:<user>:<session>` — giving us attribution without
110    /// cryptographic identity (see IDENTITY.md for the v0.2 roadmap).
111    pub fn discover_for(session: &Option<String>) -> Result<Self> {
112        let mut s = Self::discover()?;
113        if let Some(id) = session
114            && !id.is_empty()
115        {
116            s.asserted_by = format!("{}:{}", s.asserted_by, id);
117        }
118        Ok(s)
119    }
120
121    /// Override the asserter prefix. Reserved for tests and for future
122    /// callers that set asserter from a parsed Session claim (e.g.
123    /// beacon sync, rather than `$USER`).
124    #[allow(dead_code)]
125    pub fn with_asserter(mut self, asserted_by: impl Into<String>) -> Self {
126        self.asserted_by = asserted_by.into();
127        self
128    }
129
130    /// Append a typed claim. Validates props against the per-type
131    /// schema BEFORE the substrate write, so a bad caller gets a
132    /// prescriptive error without polluting the claim log. The claim
133    /// substrate itself intentionally does not validate (see
134    /// claim/src/schema.rs module docs: "validation at the boundary").
135    pub fn append(
136        &mut self,
137        claim_type: ClaimType,
138        props: Value,
139        supersedes: Option<ClaimId>,
140    ) -> Result<ClaimId> {
141        let mut claim = Claim::new(claim_type, props, self.asserted_by.clone());
142        if let Some(prior) = supersedes {
143            claim = claim.with_supersedes(prior);
144        }
145        nomograph_claim::schema::validate_claim(&claim).context("validate claim before append")?;
146        self.inner.append(&claim).context("append claim")?;
147        self.view
148            .sync(&mut self.inner)
149            .context("sync view after append")?;
150        Ok(claim.id)
151    }
152
153    /// Run a read-only SQL query over the SQLite view.
154    pub fn query(&self, sql: &str, params: &[&dyn rusqlite::ToSql]) -> Result<Vec<Value>> {
155        self.view.query(sql, params).context("view query")
156    }
157
158    /// The `claims/` directory backing this store.
159    pub fn root(&self) -> &Path {
160        &self.root
161    }
162
163    /// Force a view rebuild. Most callers don't need this; `append`
164    /// syncs automatically.
165    #[allow(dead_code)]
166    pub fn sync_view(&mut self) -> Result<()> {
167        self.view.sync(&mut self.inner).context("sync view")?;
168        Ok(())
169    }
170
171    /// Access the underlying claim store (for session handles,
172    /// compact, merge). Most callers should not need this.
173    pub fn inner(&mut self) -> &mut ClaimStore {
174        &mut self.inner
175    }
176}