Skip to main content

stackless_core/state/
store.rs

1//! The SQL state store (ARCHITECTURE.md §2).
2//!
3//! Two backends behind one sync `Store` surface:
4//!
5//! - **Local** — `rusqlite` (bundled SQLite, WAL, busy timeout): the
6//!   default per-user file. (The `turso` crate was the intended local
7//!   engine but cannot share a database file across processes — see the
8//!   §2 note.)
9//! - **Remote** — the `libsql` crate's remote mode (synchronous-feeling
10//!   over HTTP to a Turso Cloud primary): the opt-in **fleet plane**
11//!   where multiple operator machines share one store, name uniqueness
12//!   becomes a real `UNIQUE` constraint, and lock/lease claims are
13//!   single-statement compare-and-swap against the primary.
14//!
15//! The `libsql` API is async; `Store` is sync (called from sync CLI
16//! paths and from inside the daemon, sometimes within a Tokio task). The
17//! remote backend therefore owns a dedicated worker thread running a
18//! current-thread runtime: helpers ship a job to it and block the
19//! *calling* thread on a reply channel. We never call `block_on` on the
20//! caller's thread, so the store is safe to use from inside an async
21//! context (the reaper's tick opens it mid-`async fn`).
22//!
23//! Every existing query site goes through the [`Store::execute`] /
24//! [`Store::query_row`] / [`Store::query_map`] helpers and the internal
25//! [`Value`]/[`Row`] bridge, so `instance.rs`/`lease.rs`/`lock.rs`/
26//! `journal.rs`/`reaper.rs` are driver-agnostic.
27
28use std::path::{Path, PathBuf};
29use std::time::Duration;
30
31use rusqlite::Connection;
32
33use crate::paths::Paths;
34
35use super::error::StateError;
36use super::remote::{RemoteDb, from_libsql, rusqlite_shim, to_libsql_params};
37use super::row::Row;
38use super::value::Value;
39
40const MIGRATIONS: &[&str] = &[
41    include_str!("migrations/001_init.sql"),
42    include_str!("migrations/002_definition_dir.sql"),
43    include_str!("migrations/003_reaper.sql"),
44    include_str!("migrations/004_lock_host.sql"),
45    include_str!("migrations/005_dirty.sql"),
46];
47
48const MIGRATION_001_TABLES: &[&str] = &["instances", "leases", "op_locks", "checkpoints"];
49
50/// Turso Cloud makes `PRAGMA user_version` read-only; the remote backend
51/// tracks schema version in this table instead (see Turso limitations doc).
52const REMOTE_SCHEMA_VERSION_BOOTSTRAP: &str = r"
53CREATE TABLE IF NOT EXISTS _stackless_schema_version (
54    version INTEGER NOT NULL
55);
56";
57
58enum Backend {
59    Local(Connection),
60    Remote(RemoteDb),
61}
62
63pub struct Store {
64    backend: Backend,
65}
66
67impl std::fmt::Debug for Store {
68    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
69        f.debug_struct("Store").finish_non_exhaustive()
70    }
71}
72
73impl Store {
74    /// Open (creating and migrating as needed) a local file store.
75    pub fn open(path: &Path) -> Result<Self, StateError> {
76        if let Some(dir) = path.parent() {
77            std::fs::create_dir_all(dir).map_err(|source| StateError::StateDir {
78                path: dir.display().to_string(),
79                source,
80            })?;
81        }
82        let conn = Connection::open(path).map_err(|source| StateError::Open {
83            path: path.display().to_string(),
84            source,
85        })?;
86        conn.busy_timeout(Duration::from_secs(5))?;
87        conn.pragma_update(None, "journal_mode", "wal")?;
88        conn.pragma_update(None, "foreign_keys", "on")?;
89        let store = Self {
90            backend: Backend::Local(conn),
91        };
92        store.migrate()?;
93        Ok(store)
94    }
95
96    /// Open against a remote libsql primary (the fleet plane).
97    pub fn open_remote(url: &str, token: &str) -> Result<Self, StateError> {
98        let store = Self {
99            backend: Backend::Remote(RemoteDb::open_remote(url.to_owned(), token.to_owned())?),
100        };
101        store.migrate()?;
102        Ok(store)
103    }
104
105    /// Open against a local libsql database through the async driver
106    /// (`:memory:` or a file path). Exercises the whole remote helper
107    /// layer and migrations without a Turso Cloud account.
108    #[doc(hidden)]
109    pub fn open_libsql_local(path: &str) -> Result<Self, StateError> {
110        let store = Self {
111            backend: Backend::Remote(RemoteDb::open_local(path.to_owned())?),
112        };
113        store.migrate()?;
114        Ok(store)
115    }
116
117    /// Route by config: `STACKLESS_STATE_URL` (+ `STACKLESS_STATE_TOKEN`)
118    /// selects the remote fleet plane; absent, the local default file.
119    pub fn open_configured() -> Result<Self, StateError> {
120        Self::open_with_paths(&Paths::from_env())
121    }
122
123    /// Like [`Self::open_configured`], but uses `paths.db_path()` for the
124    /// local backend when no remote URL is set.
125    pub fn open_with_paths(paths: &Paths) -> Result<Self, StateError> {
126        match std::env::var("STACKLESS_STATE_URL") {
127            Ok(url) if !url.is_empty() => {
128                let token = std::env::var("STACKLESS_STATE_TOKEN").unwrap_or_default();
129                Self::open_remote(&url, &token)
130            }
131            _ => Self::open(&paths.db_path()),
132        }
133    }
134
135    /// `$XDG_STATE_HOME/stackless`, falling back to `~/.local/state/stackless`.
136    pub fn state_dir() -> PathBuf {
137        Paths::from_env().state_dir().to_path_buf()
138    }
139
140    /// The default per-user location: `$XDG_STATE_HOME/stackless/state.db`,
141    /// falling back to `~/.local/state/stackless/state.db`.
142    pub fn default_path() -> PathBuf {
143        Paths::from_env().db_path()
144    }
145
146    fn migrate(&self) -> Result<(), StateError> {
147        if let Backend::Remote(db) = &self.backend {
148            self.repair_remote_migration_001()?;
149            self.reconcile_remote_schema_version(db)?;
150        }
151        let version = self.user_version()?;
152        for (index, sql) in MIGRATIONS.iter().enumerate() {
153            let target = index as i64 + 1;
154            if version >= target {
155                continue;
156            }
157            self.run_migration(sql, target)?;
158        }
159        Ok(())
160    }
161
162    /// Fill in any migration-001 tables missing after a partial remote apply.
163    fn repair_remote_migration_001(&self) -> Result<(), StateError> {
164        let Backend::Remote(db) = &self.backend else {
165            return Ok(());
166        };
167        if self.migration_001_complete()? || !self.migration_001_started()? {
168            return Ok(());
169        }
170        db.run(|conn, rt| {
171            rt.block_on(async {
172                conn.execute_batch(include_str!("migrations/001_init_repair.sql"))
173                    .await
174                    .map_err(|e| StateError::Migrate {
175                        source: rusqlite_shim(e),
176                    })?;
177                Ok(())
178            })
179        })
180    }
181
182    fn migration_001_complete(&self) -> Result<bool, StateError> {
183        for table in MIGRATION_001_TABLES {
184            if !self.table_exists(table)? {
185                return Ok(false);
186            }
187        }
188        Ok(true)
189    }
190
191    fn migration_001_started(&self) -> Result<bool, StateError> {
192        for table in MIGRATION_001_TABLES {
193            if self.table_exists(table)? {
194                return Ok(true);
195            }
196        }
197        Ok(false)
198    }
199
200    /// Recover from a partial remote migration (e.g. Turso rejected the old
201    /// `PRAGMA user_version` bump after DDL succeeded). Introspect the live
202    /// schema and advance the recorded version to match.
203    fn reconcile_remote_schema_version(&self, db: &RemoteDb) -> Result<(), StateError> {
204        let recorded = Self::remote_user_version(db)?;
205        let actual = self.detect_migration_level()?;
206        if actual > recorded {
207            Self::remote_set_user_version(db, actual)?;
208        }
209        Ok(())
210    }
211
212    fn detect_migration_level(&self) -> Result<i64, StateError> {
213        let mut level = 0;
214        if self.migration_001_complete()? {
215            level = 1;
216            if self.column_exists("instances", "definition_dir")? {
217                level = 2;
218            }
219            if self.table_exists("reap_attempts")? {
220                level = 3;
221            }
222            if self.column_exists("op_locks", "holder_host")? {
223                level = 4;
224            }
225            if self.column_exists("instances", "dirty")? {
226                level = 5;
227            }
228        }
229        Ok(level)
230    }
231
232    fn table_exists(&self, name: &str) -> Result<bool, StateError> {
233        Ok(self
234            .query_first(
235                "SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?1",
236                &[name.into()],
237            )?
238            .is_some())
239    }
240
241    fn column_exists(&self, table: &str, column: &str) -> Result<bool, StateError> {
242        Ok(self
243            .query_first(
244                &format!("SELECT 1 FROM pragma_table_info('{table}') WHERE name = ?1"),
245                &[column.into()],
246            )?
247            .is_some())
248    }
249
250    fn user_version(&self) -> Result<i64, StateError> {
251        match &self.backend {
252            Backend::Local(conn) => conn
253                .pragma_query_value(None, "user_version", |row| row.get(0))
254                .map_err(Into::into),
255            Backend::Remote(db) => Self::remote_user_version(db),
256        }
257    }
258
259    fn ensure_remote_schema_version_table(db: &RemoteDb) -> Result<(), StateError> {
260        db.run(|conn, rt| {
261            rt.block_on(async {
262                conn.execute_batch(REMOTE_SCHEMA_VERSION_BOOTSTRAP)
263                    .await
264                    .map_err(|e| StateError::Migrate {
265                        source: rusqlite_shim(e),
266                    })?;
267                conn.execute(
268                    "INSERT INTO _stackless_schema_version (version)
269                     SELECT 0 WHERE NOT EXISTS (SELECT 1 FROM _stackless_schema_version)",
270                    (),
271                )
272                .await
273                .map_err(|e| StateError::Migrate {
274                    source: rusqlite_shim(e),
275                })?;
276                Ok(())
277            })
278        })
279    }
280
281    fn remote_user_version(db: &RemoteDb) -> Result<i64, StateError> {
282        Self::ensure_remote_schema_version_table(db)?;
283        db.run(|conn, rt| {
284            rt.block_on(async {
285                let mut rows = conn
286                    .query(
287                        "SELECT COALESCE(MAX(version), 0) FROM _stackless_schema_version",
288                        (),
289                    )
290                    .await
291                    .map_err(StateError::remote_query)?;
292                match rows.next().await.map_err(StateError::remote_query)? {
293                    Some(row) => row.get::<i64>(0).map_err(StateError::remote_query),
294                    None => Ok(0),
295                }
296            })
297        })
298    }
299
300    fn remote_set_user_version(db: &RemoteDb, target: i64) -> Result<(), StateError> {
301        Self::ensure_remote_schema_version_table(db)?;
302        db.run(move |conn, rt| {
303            rt.block_on(async {
304                let changed = conn
305                    .execute(
306                        "UPDATE _stackless_schema_version SET version = ?1",
307                        [libsql::Value::Integer(target)],
308                    )
309                    .await
310                    .map_err(|e| StateError::Migrate {
311                        source: rusqlite_shim(e),
312                    })?;
313                if changed == 0 {
314                    // SQLite reports zero changes when the version is already
315                    // current — only insert into an empty table.
316                    let mut rows = conn
317                        .query("SELECT 1 FROM _stackless_schema_version LIMIT 1", ())
318                        .await
319                        .map_err(|e| StateError::Migrate {
320                            source: rusqlite_shim(e),
321                        })?;
322                    if rows
323                        .next()
324                        .await
325                        .map_err(|e| StateError::Migrate {
326                            source: rusqlite_shim(e),
327                        })?
328                        .is_none()
329                    {
330                        conn.execute(
331                            "INSERT INTO _stackless_schema_version (version) VALUES (?1)",
332                            [libsql::Value::Integer(target)],
333                        )
334                        .await
335                        .map_err(|e| StateError::Migrate {
336                            source: rusqlite_shim(e),
337                        })?;
338                    }
339                }
340                Ok(())
341            })
342        })
343    }
344
345    fn run_migration(&self, sql: &str, target: i64) -> Result<(), StateError> {
346        match &self.backend {
347            Backend::Local(conn) => conn
348                .execute_batch(&format!(
349                    "BEGIN; {sql} ; PRAGMA user_version = {target}; COMMIT;"
350                ))
351                .map_err(|source| StateError::Migrate { source }),
352            Backend::Remote(db) => {
353                // Turso Cloud rejects `PRAGMA user_version = N` (read-only).
354                // Track version in `_stackless_schema_version` instead; each
355                // migration's DDL is individually durable and the version gate
356                // makes re-runs idempotent.
357                let sql = sql.to_owned();
358                db.run(move |conn, rt| {
359                    rt.block_on(async {
360                        conn.execute_batch(&sql)
361                            .await
362                            .map_err(|e| StateError::Migrate {
363                                source: rusqlite_shim(e),
364                            })?;
365                        Ok(())
366                    })
367                })?;
368                Self::remote_set_user_version(db, target)
369            }
370        }
371    }
372
373    // ── the driver-agnostic helper layer ──────────────────────────────
374
375    /// Run a statement, returning the number of rows changed.
376    pub(super) fn execute(&self, sql: &str, params: &[Value]) -> Result<u64, StateError> {
377        match &self.backend {
378            Backend::Local(conn) => conn
379                .execute(
380                    sql,
381                    rusqlite::params_from_iter(params.iter().map(to_rusqlite)),
382                )
383                .map(|n| n as u64)
384                .map_err(Into::into),
385            Backend::Remote(db) => {
386                let sql = sql.to_owned();
387                let params = to_libsql_params(params);
388                db.run(move |conn, rt| {
389                    rt.block_on(async {
390                        conn.execute(&sql, params)
391                            .await
392                            .map_err(StateError::remote_query)
393                    })
394                })
395            }
396        }
397    }
398
399    /// Query a single row, mapping it to `T`. `None` only when no row
400    /// matched — driver and mapper errors propagate (the `.optional()`
401    /// contract the callers rely on).
402    pub(super) fn query_row<T, F>(
403        &self,
404        sql: &str,
405        params: &[Value],
406        map: F,
407    ) -> Result<Option<T>, StateError>
408    where
409        F: FnOnce(&Row) -> Result<T, StateError>,
410    {
411        match self.query_first(sql, params)? {
412            Some(row) => map(&row).map(Some),
413            None => Ok(None),
414        }
415    }
416
417    /// Query many rows, mapping each to `T`.
418    pub(super) fn query_map<T, F>(
419        &self,
420        sql: &str,
421        params: &[Value],
422        map: F,
423    ) -> Result<Vec<T>, StateError>
424    where
425        F: FnMut(&Row) -> Result<T, StateError>,
426    {
427        let rows = self.collect_rows(sql, params, usize::MAX)?;
428        rows.iter().map(map).collect()
429    }
430
431    /// First row, materialized into the driver-agnostic [`Row`].
432    fn query_first(&self, sql: &str, params: &[Value]) -> Result<Option<Row>, StateError> {
433        Ok(self.collect_rows(sql, params, 1)?.into_iter().next())
434    }
435
436    /// Collect up to `limit` rows into driver-agnostic [`Row`]s.
437    fn collect_rows(
438        &self,
439        sql: &str,
440        params: &[Value],
441        limit: usize,
442    ) -> Result<Vec<Row>, StateError> {
443        match &self.backend {
444            Backend::Local(conn) => {
445                let mut stmt = conn.prepare(sql)?;
446                let col_count = stmt.column_count();
447                let mut out = Vec::new();
448                let mut rows =
449                    stmt.query(rusqlite::params_from_iter(params.iter().map(to_rusqlite)))?;
450                while let Some(row) = rows.next()? {
451                    let mut columns = Vec::with_capacity(col_count);
452                    for i in 0..col_count {
453                        columns.push(from_rusqlite(row, i)?);
454                    }
455                    out.push(Row::from_columns(columns));
456                    if out.len() >= limit {
457                        break;
458                    }
459                }
460                Ok(out)
461            }
462            Backend::Remote(db) => {
463                let sql = sql.to_owned();
464                let params = to_libsql_params(params);
465                db.run(move |conn, rt| {
466                    rt.block_on(async {
467                        let mut rows = conn
468                            .query(&sql, params)
469                            .await
470                            .map_err(StateError::remote_query)?;
471                        let column_count = rows.column_count();
472                        let mut out = Vec::new();
473                        while let Some(row) = rows.next().await.map_err(StateError::remote_query)? {
474                            out.push(from_libsql(&row, column_count)?);
475                            if out.len() >= limit {
476                                break;
477                            }
478                        }
479                        Ok(out)
480                    })
481                })
482            }
483        }
484    }
485
486    /// Raw connection escape hatch for tests that need to corrupt state
487    /// deliberately. Only available on the local backend. Not API.
488    #[doc(hidden)]
489    pub fn conn_for_tests(&self) -> &Connection {
490        match &self.backend {
491            Backend::Local(conn) => conn,
492            Backend::Remote(_) => unreachable!("conn_for_tests is local-only"),
493        }
494    }
495
496    /// Run an arbitrary statement against either backend — the test
497    /// counterpart to `conn_for_tests` for the remote path (inject a
498    /// foreign holder_host, advance acquired_at, …).
499    #[doc(hidden)]
500    pub fn execute_for_tests(&self, sql: &str, params: &[&str]) -> Result<u64, StateError> {
501        let owned: Vec<Value> = params
502            .iter()
503            .map(|s| Value::Text((*s).to_owned()))
504            .collect();
505        self.execute(sql, &owned)
506    }
507
508    /// This machine's hostname — the fleet-mode lock ownership tag.
509    /// Empty only if the OS refuses to report one.
510    pub(super) fn hostname() -> String {
511        sysinfo::System::host_name().unwrap_or_default()
512    }
513
514    /// Unix seconds; the one clock all state rows share.
515    pub(super) fn now() -> i64 {
516        std::time::SystemTime::now()
517            .duration_since(std::time::UNIX_EPOCH)
518            .map(|d| d.as_secs() as i64)
519            .unwrap_or(0)
520    }
521
522    /// The shared clock, for callers outside the store (the reaper's
523    /// tick). Same value [`Store::now`] writes into rows.
524    pub fn now_secs() -> i64 {
525        Self::now()
526    }
527}
528
529// ── local driver bridges ──────────────────────────────────────────────
530
531fn to_rusqlite(v: &Value) -> Box<dyn rusqlite::types::ToSql> {
532    match v {
533        Value::Text(s) => Box::new(s.clone()),
534        Value::Int(i) => Box::new(*i),
535        Value::Null => Box::new(rusqlite::types::Null),
536    }
537}
538
539fn from_rusqlite(row: &rusqlite::Row<'_>, idx: usize) -> Result<Value, StateError> {
540    use rusqlite::types::ValueRef;
541    Ok(match row.get_ref(idx)? {
542        ValueRef::Null => Value::Null,
543        ValueRef::Integer(i) => Value::Int(i),
544        ValueRef::Text(t) => Value::Text(String::from_utf8_lossy(t).into_owned()),
545        ValueRef::Real(_) => {
546            return Err(StateError::row_type(idx, "int|text|null (got real)"));
547        }
548        ValueRef::Blob(_) => {
549            return Err(StateError::row_type(idx, "int|text|null (got blob)"));
550        }
551    })
552}