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