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 [`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. Validates props against the per-type
181 /// schema BEFORE the substrate write, so a bad caller gets a
182 /// prescriptive error without polluting the claim log. The claim
183 /// substrate itself intentionally does not validate (see
184 /// claim/src/schema.rs module docs: "validation at the boundary").
185 pub fn append(
186 &mut self,
187 claim_type: ClaimType,
188 props: Value,
189 supersedes: Option<ClaimId>,
190 ) -> Result<ClaimId> {
191 let mut claim = Claim::new(claim_type, props, self.asserted_by.clone());
192 if let Some(prior) = supersedes {
193 claim = claim.with_supersedes(prior);
194 }
195 nomograph_claim::schema::validate_claim(&claim).context("validate claim before append")?;
196 self.inner.append(&claim).context("append claim")?;
197 self.view
198 .sync(&mut self.inner)
199 .context("sync view after append")?;
200 Ok(claim.id)
201 }
202
203 /// Run a read-only SQL query over the SQLite view.
204 pub fn query(&self, sql: &str, params: &[&dyn rusqlite::ToSql]) -> Result<Vec<Value>> {
205 self.view.query(sql, params).context("view query")
206 }
207
208 /// The `claims/` directory backing this store.
209 pub fn root(&self) -> &Path {
210 &self.root
211 }
212
213 /// Force a view rebuild. Most callers don't need this; `append`
214 /// syncs automatically.
215 #[allow(dead_code)]
216 pub fn sync_view(&mut self) -> Result<()> {
217 self.view.sync(&mut self.inner).context("sync view")?;
218 Ok(())
219 }
220
221 /// Access the underlying claim store (for session handles,
222 /// compact, merge). Most callers should not need this.
223 pub fn inner(&mut self) -> &mut ClaimStore {
224 &mut self.inner
225 }
226}