recall_echo/graph/
store.rs1use std::path::Path;
14use std::time::Duration;
15
16use surrealdb::engine::any::Any;
17use surrealdb::Surreal;
18
19use super::confidence::PRIOR_CONCENTRATION;
20use super::error::GraphError;
21
22pub type Db = Any;
23
24pub const SCHEMA_VERSION: i64 = 1;
30
31const META_RECORD: &str = "meta:schema";
33
34const LOCK_RETRY_ATTEMPTS: u32 = 8;
41const LOCK_RETRY_BASE: Duration = Duration::from_millis(15);
42
43#[derive(Clone)]
45pub struct ServerConfig {
46 pub url: String,
47 pub username: String,
48 pub password: String,
49 pub namespace: String,
50 pub database: String,
51}
52
53impl std::fmt::Debug for ServerConfig {
54 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
55 f.debug_struct("ServerConfig")
56 .field("url", &self.url)
57 .field("username", &self.username)
58 .field("password", &"[REDACTED]")
59 .field("namespace", &self.namespace)
60 .field("database", &self.database)
61 .finish()
62 }
63}
64
65fn is_lock_error(err: &surrealdb::Error) -> bool {
68 is_lock_message(&err.to_string().to_lowercase())
69}
70
71fn is_lock_message(msg: &str) -> bool {
72 msg.contains("lock") && (msg.contains("already") || msg.contains("held"))
73}
74
75pub async fn open(path: &Path) -> Result<Surreal<Db>, GraphError> {
80 let surreal_path = path.join("surreal");
81 std::fs::create_dir_all(&surreal_path)?;
82
83 let path_str = surreal_path.to_str().ok_or_else(|| {
84 GraphError::Io(std::io::Error::new(
85 std::io::ErrorKind::InvalidData,
86 "graph store path contains non-UTF8 characters",
87 ))
88 })?;
89
90 let endpoint = format!("surrealkv://{path_str}");
91 let mut attempt: u32 = 0;
92 let db: Surreal<Db> = loop {
93 match surrealdb::engine::any::connect(&endpoint).await {
94 Ok(db) => break db,
95 Err(e) if is_lock_error(&e) && attempt < LOCK_RETRY_ATTEMPTS => {
96 attempt += 1;
97 tokio::time::sleep(LOCK_RETRY_BASE * 2u32.pow(attempt - 1)).await;
98 }
99 Err(e) if is_lock_error(&e) => {
100 return Err(GraphError::Locked(format!(
101 "graph store at {} is locked by another process. The embedded \
102 backend allows one process at a time — retried {} times. \
103 Another recall-echo command (or the serve daemon) is using it; \
104 wait for it to finish, or use server mode to share the store.",
105 surreal_path.display(),
106 LOCK_RETRY_ATTEMPTS
107 )));
108 }
109 Err(e) => return Err(e.into()),
110 }
111 };
112 db.use_ns("recall").use_db("graph").await?;
113
114 Ok(db)
115}
116
117pub async fn connect(config: &ServerConfig) -> Result<Surreal<Db>, GraphError> {
119 let db = surrealdb::engine::any::connect(&config.url).await?;
120 db.signin(surrealdb::opt::auth::Database {
121 namespace: config.namespace.clone(),
122 database: config.database.clone(),
123 username: config.username.clone(),
124 password: config.password.clone(),
125 })
126 .await?;
127 db.use_ns(&config.namespace)
128 .use_db(&config.database)
129 .await?;
130
131 Ok(db)
132}
133
134pub async fn init_schema(db: &Surreal<Db>) -> Result<MigrationReport, GraphError> {
137 define_schema(db).await?;
138 migrate(db).await
139}
140
141async fn define_schema(db: &Surreal<Db>) -> Result<(), GraphError> {
143 db.query(
144 r#"
145 DEFINE TABLE IF NOT EXISTS entity SCHEMAFULL;
146 DEFINE FIELD IF NOT EXISTS name ON entity TYPE string;
147 DEFINE FIELD IF NOT EXISTS entity_type ON entity TYPE string;
148 DEFINE FIELD IF NOT EXISTS abstract ON entity TYPE string;
149 DEFINE FIELD IF NOT EXISTS overview ON entity TYPE string;
150 DEFINE FIELD IF NOT EXISTS content ON entity TYPE option<string>;
151 DEFINE FIELD IF NOT EXISTS attributes ON entity TYPE option<object> FLEXIBLE;
152 DEFINE FIELD IF NOT EXISTS embedding ON entity TYPE option<array<float>>;
153 DEFINE FIELD IF NOT EXISTS mutable ON entity TYPE bool DEFAULT true;
154 DEFINE FIELD IF NOT EXISTS access_count ON entity TYPE int DEFAULT 0;
155 DEFINE FIELD IF NOT EXISTS utility_score ON entity TYPE float DEFAULT 0.5;
156 DEFINE FIELD IF NOT EXISTS utility_updates ON entity TYPE int DEFAULT 0;
157 DEFINE FIELD IF NOT EXISTS created_at ON entity TYPE datetime DEFAULT time::now();
158 DEFINE FIELD IF NOT EXISTS updated_at ON entity TYPE datetime DEFAULT time::now();
159 DEFINE FIELD IF NOT EXISTS source ON entity TYPE option<string>;
160
161 DEFINE INDEX IF NOT EXISTS entity_name ON entity FIELDS name;
162 DEFINE INDEX IF NOT EXISTS entity_type ON entity FIELDS entity_type;
163 DEFINE INDEX IF NOT EXISTS entity_vector ON entity FIELDS embedding HNSW DIMENSION 384 DIST COSINE;
164
165 -- Pipeline attribute indexes
166 DEFINE INDEX IF NOT EXISTS entity_pipeline_stage ON entity FIELDS attributes.pipeline_stage;
167 DEFINE INDEX IF NOT EXISTS entity_pipeline_status ON entity FIELDS attributes.pipeline_status;
168
169 DEFINE TABLE IF NOT EXISTS relates_to SCHEMAFULL TYPE RELATION;
170 DEFINE FIELD IF NOT EXISTS rel_type ON relates_to TYPE string;
171 DEFINE FIELD IF NOT EXISTS description ON relates_to TYPE option<string>;
172 DEFINE FIELD IF NOT EXISTS valid_from ON relates_to TYPE datetime DEFAULT time::now();
173 DEFINE FIELD IF NOT EXISTS valid_until ON relates_to TYPE option<datetime>;
174 DEFINE FIELD IF NOT EXISTS confidence ON relates_to TYPE float DEFAULT 1.0;
175 -- Persisted Beta evidence. `option` because edges written before
176 -- schema version 1 have none until the backfill reaches them.
177 DEFINE FIELD IF NOT EXISTS alpha ON relates_to TYPE option<float>;
178 DEFINE FIELD IF NOT EXISTS beta ON relates_to TYPE option<float>;
179 DEFINE FIELD IF NOT EXISTS self_reinforcements ON relates_to TYPE option<int>;
180 DEFINE FIELD IF NOT EXISTS last_reinforced ON relates_to TYPE option<datetime>;
181 DEFINE FIELD IF NOT EXISTS source ON relates_to TYPE option<string>;
182
183 DEFINE INDEX IF NOT EXISTS rel_type_idx ON relates_to FIELDS rel_type;
184
185 DEFINE TABLE IF NOT EXISTS episode SCHEMAFULL;
186 DEFINE FIELD IF NOT EXISTS session_id ON episode TYPE string;
187 DEFINE FIELD IF NOT EXISTS timestamp ON episode TYPE datetime DEFAULT time::now();
188 DEFINE FIELD IF NOT EXISTS abstract ON episode TYPE string;
189 DEFINE FIELD IF NOT EXISTS overview ON episode TYPE option<string>;
190 DEFINE FIELD IF NOT EXISTS content ON episode TYPE option<string>;
191 DEFINE FIELD IF NOT EXISTS embedding ON episode TYPE option<array<float>>;
192 DEFINE FIELD IF NOT EXISTS log_number ON episode TYPE option<int>;
193 DEFINE FIELD IF NOT EXISTS extracted ON episode TYPE bool DEFAULT false;
194 -- How many times retrieval has returned this episode. Absent on
195 -- episodes written before the field existed; read paths resolve that
196 -- to zero, which is also what it means. No backfill, no version bump.
197 DEFINE FIELD IF NOT EXISTS access_count ON episode TYPE option<int>;
198 -- Authorship class: 'external' | 'user' | 'self'. `option` with no
199 -- default on purpose — an absent value means an episode written
200 -- before provenance existed, and reads resolve that to 'self'. No
201 -- backfill, so no schema version bump: the absent case is already
202 -- the conservative one.
203 DEFINE FIELD IF NOT EXISTS provenance ON episode TYPE option<string>;
204
205 DEFINE INDEX IF NOT EXISTS episode_session ON episode FIELDS session_id;
206 DEFINE INDEX IF NOT EXISTS episode_time ON episode FIELDS timestamp;
207 DEFINE INDEX IF NOT EXISTS episode_vector ON episode FIELDS embedding HNSW DIMENSION 384 DIST COSINE;
208
209 DEFINE TABLE IF NOT EXISTS contributed_to SCHEMAFULL TYPE RELATION;
210 DEFINE FIELD IF NOT EXISTS outcome_result ON contributed_to TYPE string;
211 DEFINE FIELD IF NOT EXISTS was_used ON contributed_to TYPE bool DEFAULT true;
212 DEFINE FIELD IF NOT EXISTS session_id ON contributed_to TYPE string;
213 DEFINE FIELD IF NOT EXISTS timestamp ON contributed_to TYPE datetime DEFAULT time::now();
214
215 DEFINE INDEX IF NOT EXISTS ct_session ON contributed_to FIELDS session_id;
216
217 DEFINE TABLE IF NOT EXISTS meta SCHEMAFULL;
218 DEFINE FIELD IF NOT EXISTS schema_version ON meta TYPE int DEFAULT 0;
219 "#,
220 )
221 .await?
222 .check()?;
223
224 Ok(())
225}
226
227#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
230pub struct MigrationReport {
231 pub from_version: i64,
233 pub to_version: i64,
235 pub edges_backfilled: u64,
237}
238
239impl MigrationReport {
240 #[must_use]
242 pub fn ran(&self) -> bool {
243 self.from_version < self.to_version
244 }
245}
246
247async fn migrate(db: &Surreal<Db>) -> Result<MigrationReport, GraphError> {
254 let from_version = read_schema_version(db).await?;
255 if from_version >= SCHEMA_VERSION {
256 return Ok(MigrationReport {
257 from_version,
258 to_version: from_version,
259 edges_backfilled: 0,
260 });
261 }
262
263 let edges_backfilled = backfill_edge_evidence(db).await?;
264 write_schema_version(db, SCHEMA_VERSION).await?;
265
266 Ok(MigrationReport {
267 from_version,
268 to_version: SCHEMA_VERSION,
269 edges_backfilled,
270 })
271}
272
273async fn backfill_edge_evidence(db: &Surreal<Db>) -> Result<u64, GraphError> {
282 let mut response = db
283 .query(
284 r#"
285 UPDATE relates_to SET
286 alpha = confidence * $concentration,
287 beta = (1 - confidence) * $concentration,
288 self_reinforcements = 0
289 WHERE alpha IS NONE
290 RETURN id
291 "#,
292 )
293 .bind(("concentration", PRIOR_CONCENTRATION))
294 .await?;
295
296 let updated: Vec<serde_json::Value> = super::deserialize_take(&mut response, 0)?;
297 Ok(updated.len() as u64)
298}
299
300async fn read_schema_version(db: &Surreal<Db>) -> Result<i64, GraphError> {
303 let mut response = db
304 .query("SELECT schema_version FROM type::record($id)")
305 .bind(("id", META_RECORD.to_string()))
306 .await?;
307
308 #[derive(serde::Deserialize)]
309 struct VersionRow {
310 schema_version: i64,
311 }
312
313 let rows: Vec<VersionRow> = super::deserialize_take(&mut response, 0)?;
314 Ok(rows.first().map(|r| r.schema_version).unwrap_or(0))
315}
316
317async fn write_schema_version(db: &Surreal<Db>, version: i64) -> Result<(), GraphError> {
318 db.query("UPSERT type::record($id) SET schema_version = $version")
319 .bind(("id", META_RECORD.to_string()))
320 .bind(("version", version))
321 .await?
322 .check()?;
323 Ok(())
324}
325
326#[cfg(test)]
327mod tests {
328 use super::*;
329
330 #[test]
331 fn lock_message_detected() {
332 assert!(is_lock_message(
333 "database: the database at /x/surreal/lock is already locked by another process"
334 ));
335 assert!(is_lock_message("file lock held by another process"));
336 }
337
338 #[test]
339 fn non_lock_messages_pass_through() {
340 assert!(!is_lock_message("connection refused"));
341 assert!(!is_lock_message("lockstep protocol mismatch")); assert!(!is_lock_message("table entity already exists"));
343 }
344}