Step 3: SQLite State Store — Implementation Plan
For Claude: REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
Goal: Add SQLite persistence to the Nexus daemon. The daemon creates and initializes a database with the foundational schema (schema_meta + settings) on startup. Domain tables (VMs, workspaces, images, etc.) are added by later steps as needed. The CLI reports database status. A storage abstraction trait enables future backend swaps.
Architecture: rusqlite in nexus-lib behind a StateStore trait. The concrete SqliteStore handles connection management, schema initialization, and the pre-alpha migration strategy (detect schema mismatch, delete DB, recreate). nexusd initializes the store on startup and passes it to the API layer via axum State. nexusctl status queries a new /v1/health response field that includes DB info.
Tech Stack (additions to existing):
rusqlite0.34 — SQLite bindings withbundledfeature (statically links SQLite, no system dependency)
XDG Directory Layout (existing, used by step 3):
- State (database):
$XDG_STATE_HOME/nexus/(default:~/.local/state/nexus/) - Database file:
$XDG_STATE_HOME/nexus/nexus.db
Task 1: Add rusqlite Dependency to nexus-lib
Files:
- Modify:
nexus/nexus-lib/Cargo.toml
Step 1: Add rusqlite to dependencies
Update nexus/nexus-lib/Cargo.toml:
[package]
name = "nexus-lib"
version = "0.1.0"
edition = "2021"
[dependencies]
serde = { version = "1", features = ["derive"] }
serde_norway = "0.9"
dirs = "6"
reqwest = { version = "0.12", features = ["json"] }
rusqlite = { version = "0.34", features = ["bundled"] }
[dev-dependencies]
tokio = { version = "1", features = ["rt", "macros"] }
tempfile = "3"
Notes:
bundledfeature compiles SQLite from source — no systemlibsqlite3-devneeded.tempfileadded to dev-dependencies for tests that need temporary database files.
Step 2: Verify build
Run: cd /home/kazw/Work/WorkFort/nexus && cargo check -p nexus-lib
Expected: Compiles with no errors. First build will take longer due to SQLite compilation.
Step 3: Commit
git add nexus/nexus-lib/Cargo.toml
git commit -m "feat(nexus-lib): add rusqlite dependency with bundled SQLite"
Task 2: Schema SQL Constant
Files:
- Create:
nexus/nexus-lib/src/store/schema.rs - Create:
nexus/nexus-lib/src/store/mod.rs - Modify:
nexus/nexus-lib/src/lib.rs
Step 1: Create the store module directory
mkdir -p /home/kazw/Work/WorkFort/nexus/nexus-lib/src/store
Step 2: Write schema.rs with the initial schema as a constant
Only the tables needed for step 3 are created here: schema_meta for version tracking and settings for application configuration. Domain tables (VMs, workspaces, images, networking, etc.) will be added in their respective steps (4–10) by appending migrations to this module.
#![allow(unused)]
fn main() {
// nexus/nexus-lib/src/store/schema.rs
/// Schema version — increment when the schema changes.
/// Pre-alpha migration strategy: if the stored version doesn't match,
/// delete the DB and recreate.
pub const SCHEMA_VERSION: u32 = 1;
/// Initial database schema. Executed as a single batch on first start.
/// Domain tables are added by later steps — each step bumps SCHEMA_VERSION
/// and appends its tables here. Pre-alpha migration (delete + recreate)
/// means all tables are always created from this single constant.
pub const SCHEMA_SQL: &str = r#"
-- Schema version tracking
CREATE TABLE schema_meta (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
);
INSERT INTO schema_meta (key, value) VALUES ('version', '1');
-- Application settings (key-value store)
CREATE TABLE settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL,
type TEXT NOT NULL CHECK(type IN ('string', 'int', 'bool', 'json'))
);
"#;
}
Step 3: Write store/mod.rs
#![allow(unused)]
fn main() {
// nexus/nexus-lib/src/store/mod.rs
pub mod schema;
pub mod sqlite;
pub mod traits;
}
Step 4: Export the store module from lib.rs
#![allow(unused)]
fn main() {
// nexus/nexus-lib/src/lib.rs
pub mod client;
pub mod config;
pub mod store;
}
Step 5: Create placeholder files for sqlite.rs and traits.rs
#![allow(unused)]
fn main() {
// nexus/nexus-lib/src/store/traits.rs
// Filled in Task 3
}
#![allow(unused)]
fn main() {
// nexus/nexus-lib/src/store/sqlite.rs
// Filled in Task 4
}
Step 6: Verify build
Run: cd /home/kazw/Work/WorkFort/nexus && cargo check -p nexus-lib
Expected: Compiles with no errors.
Step 7: Commit
git add nexus/nexus-lib/src/store/ nexus/nexus-lib/src/lib.rs
git commit -m "feat(nexus-lib): add database schema constant from data model"
Task 3: Storage Abstraction Trait (StateStore)
Files:
- Modify:
nexus/nexus-lib/src/store/traits.rs
The trait defines the interface that any storage backend must implement. For step 3, only database status methods are needed. CRUD operations will be added in step 4.
Step 1: Write the failing test
#![allow(unused)]
fn main() {
// nexus/nexus-lib/src/store/traits.rs
use std::path::PathBuf;
/// Information about the database for status reporting.
#[derive(Debug, Clone)]
pub struct DbStatus {
/// Path to the database file (or connection string for non-file backends)
pub path: String,
/// Number of user tables in the database
pub table_count: usize,
/// Size of the database file in bytes (None if not applicable)
pub size_bytes: Option<u64>,
}
/// Errors from the state store.
#[derive(Debug)]
pub enum StoreError {
/// Database connection or initialization failed
Init(String),
/// Query execution failed
Query(String),
/// Schema migration required (version mismatch)
SchemaMismatch { expected: u32, found: u32 },
}
impl std::fmt::Display for StoreError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
StoreError::Init(e) => write!(f, "store initialization failed: {e}"),
StoreError::Query(e) => write!(f, "store query failed: {e}"),
StoreError::SchemaMismatch { expected, found } => {
write!(f, "schema version mismatch: expected {expected}, found {found}")
}
}
}
}
impl std::error::Error for StoreError {}
/// Storage abstraction trait for future backend swaps.
///
/// All state persistence goes through this trait. The pre-alpha
/// implementation is SQLite; this trait exists so the backend can
/// be swapped to Postgres or etcd for clustering later.
pub trait StateStore {
/// Initialize the store (create schema if needed, run migrations).
fn init(&self) -> Result<(), StoreError>;
/// Return database status information for health/status reporting.
fn status(&self) -> Result<DbStatus, StoreError>;
/// Close the store and release resources.
fn close(&self) -> Result<(), StoreError>;
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn db_status_fields_accessible() {
let status = DbStatus {
path: "/tmp/test.db".to_string(),
table_count: 5,
size_bytes: Some(4096),
};
assert_eq!(status.path, "/tmp/test.db");
assert_eq!(status.table_count, 5);
assert_eq!(status.size_bytes, Some(4096));
}
#[test]
fn store_error_display() {
let err = StoreError::Init("connection refused".to_string());
assert!(err.to_string().contains("connection refused"));
let err = StoreError::SchemaMismatch { expected: 2, found: 1 };
assert!(err.to_string().contains("expected 2"));
assert!(err.to_string().contains("found 1"));
}
}
}
Step 2: Run tests to verify they pass
Run: cd /home/kazw/Work/WorkFort/nexus && cargo test -p nexus-lib store::traits
Expected: Both tests PASS. (These are construction/display tests — they test the types, not a trait implementation.)
Step 3: Commit
git add nexus/nexus-lib/src/store/traits.rs
git commit -m "feat(nexus-lib): add StateStore trait and DbStatus/StoreError types"
Task 4: SqliteStore — Connection and Schema Initialization
Files:
- Modify:
nexus/nexus-lib/src/store/sqlite.rs
This is the main implementation task. SqliteStore opens a SQLite connection, checks schema version, and initializes or recreates the database as needed.
Step 1: Write the failing tests
#![allow(unused)]
fn main() {
// nexus/nexus-lib/src/store/sqlite.rs
use crate::store::schema::{SCHEMA_SQL, SCHEMA_VERSION};
use crate::store::traits::{DbStatus, StateStore, StoreError};
use rusqlite::Connection;
use std::path::{Path, PathBuf};
pub struct SqliteStore {
conn: std::sync::Mutex<Connection>,
db_path: PathBuf,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn open_creates_new_database() {
let dir = tempfile::tempdir().unwrap();
let db_path = dir.path().join("test.db");
let store = SqliteStore::open(&db_path).unwrap();
store.init().unwrap();
assert!(db_path.exists(), "database file should be created");
}
#[test]
fn init_creates_all_tables() {
let dir = tempfile::tempdir().unwrap();
let db_path = dir.path().join("test.db");
let store = SqliteStore::open(&db_path).unwrap();
store.init().unwrap();
let status = store.status().unwrap();
// Expected tables: schema_meta, settings = 2 tables
// Domain tables (vms, workspaces, etc.) are added by later steps.
assert_eq!(status.table_count, 2, "expected 2 tables, got {}", status.table_count);
}
#[test]
fn init_is_idempotent() {
let dir = tempfile::tempdir().unwrap();
let db_path = dir.path().join("test.db");
let store = SqliteStore::open(&db_path).unwrap();
store.init().unwrap();
// Second init should not fail
store.init().unwrap();
let status = store.status().unwrap();
assert_eq!(status.table_count, 2);
}
#[test]
fn status_reports_correct_path() {
let dir = tempfile::tempdir().unwrap();
let db_path = dir.path().join("test.db");
let store = SqliteStore::open(&db_path).unwrap();
store.init().unwrap();
let status = store.status().unwrap();
assert_eq!(status.path, db_path.to_string_lossy());
}
#[test]
fn status_reports_file_size() {
let dir = tempfile::tempdir().unwrap();
let db_path = dir.path().join("test.db");
let store = SqliteStore::open(&db_path).unwrap();
store.init().unwrap();
let status = store.status().unwrap();
assert!(status.size_bytes.unwrap() > 0, "database file should have non-zero size");
}
#[test]
fn schema_version_is_stored() {
let dir = tempfile::tempdir().unwrap();
let db_path = dir.path().join("test.db");
let store = SqliteStore::open(&db_path).unwrap();
store.init().unwrap();
let conn = store.conn.lock().unwrap();
let version: String = conn
.query_row(
"SELECT value FROM schema_meta WHERE key = 'version'",
[],
|row| row.get(0),
)
.unwrap();
assert_eq!(version, SCHEMA_VERSION.to_string());
}
#[test]
fn schema_mismatch_triggers_recreate() {
let dir = tempfile::tempdir().unwrap();
let db_path = dir.path().join("test.db");
// Create database with a fake old version
{
let conn = Connection::open(&db_path).unwrap();
conn.execute_batch(
"CREATE TABLE schema_meta (key TEXT PRIMARY KEY, value TEXT NOT NULL);
INSERT INTO schema_meta (key, value) VALUES ('version', '0');"
).unwrap();
}
// open_and_init handles mismatch: detects version "0", deletes DB, recreates
let store = SqliteStore::open_and_init(&db_path).unwrap();
let status = store.status().unwrap();
assert_eq!(status.table_count, 2, "should have all tables after recreate");
let conn = store.conn.lock().unwrap();
let version: String = conn
.query_row(
"SELECT value FROM schema_meta WHERE key = 'version'",
[],
|row| row.get(0),
)
.unwrap();
assert_eq!(version, SCHEMA_VERSION.to_string());
}
#[test]
fn foreign_keys_are_enabled() {
let dir = tempfile::tempdir().unwrap();
let db_path = dir.path().join("test.db");
let store = SqliteStore::open(&db_path).unwrap();
store.init().unwrap();
let conn = store.conn.lock().unwrap();
let fk_enabled: i32 = conn
.query_row("PRAGMA foreign_keys", [], |row| row.get(0))
.unwrap();
assert_eq!(fk_enabled, 1, "foreign keys should be enabled");
}
#[test]
fn wal_mode_is_enabled() {
let dir = tempfile::tempdir().unwrap();
let db_path = dir.path().join("test.db");
let store = SqliteStore::open(&db_path).unwrap();
store.init().unwrap();
let conn = store.conn.lock().unwrap();
let mode: String = conn
.query_row("PRAGMA journal_mode", [], |row| row.get(0))
.unwrap();
assert_eq!(mode, "wal", "WAL mode should be enabled");
}
}
}
Step 2: Run tests to verify they fail
Run: cd /home/kazw/Work/WorkFort/nexus && cargo test -p nexus-lib store::sqlite
Expected: FAIL — SqliteStore::open does not exist yet (only tests and struct definition).
Step 3: Implement SqliteStore
#![allow(unused)]
fn main() {
// nexus/nexus-lib/src/store/sqlite.rs
use crate::store::schema::{SCHEMA_SQL, SCHEMA_VERSION};
use crate::store::traits::{DbStatus, StateStore, StoreError};
use rusqlite::Connection;
use std::path::{Path, PathBuf};
pub struct SqliteStore {
conn: std::sync::Mutex<Connection>,
db_path: PathBuf,
}
impl SqliteStore {
/// Open a SQLite database at the given path.
/// Creates the parent directory if it doesn't exist.
/// Does NOT initialize the schema — call `init()` after opening.
pub fn open(path: &Path) -> Result<Self, StoreError> {
// Create parent directory if needed
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)
.map_err(|e| StoreError::Init(format!("cannot create directory {}: {e}", parent.display())))?;
}
let conn = Connection::open(path)
.map_err(|e| StoreError::Init(format!("cannot open database {}: {e}", path.display())))?;
// Enable WAL mode and verify it was applied
let mode: String = conn
.pragma_update_and_check(None, "journal_mode", "wal", |row| row.get(0))
.map_err(|e| StoreError::Init(format!("cannot set WAL mode: {e}")))?;
if mode != "wal" {
return Err(StoreError::Init(format!(
"failed to enable WAL mode: journal_mode is '{mode}'"
)));
}
// Enable foreign key enforcement
conn.pragma_update(None, "foreign_keys", "ON")
.map_err(|e| StoreError::Init(format!("cannot enable foreign keys: {e}")))?;
Ok(SqliteStore {
conn: std::sync::Mutex::new(conn),
db_path: path.to_path_buf(),
})
}
/// Check the stored schema version. Returns None if schema_meta doesn't exist.
fn stored_version(&self) -> Option<u32> {
let conn = self.conn.lock().unwrap();
let result: Result<String, _> = conn.query_row(
"SELECT value FROM schema_meta WHERE key = 'version'",
[],
|row| row.get(0),
);
match result {
Ok(v) => v.parse().ok(),
Err(_) => None,
}
}
/// Delete the database file and reopen the connection.
fn recreate(&self) -> Result<(), StoreError> {
// Lock and drop the current connection
{
let mut conn = self.conn.lock().unwrap();
let temp_conn = Connection::open_in_memory()
.map_err(|e| StoreError::Init(format!("cannot create temp connection: {e}")))?;
let old_conn = std::mem::replace(&mut *conn, temp_conn);
drop(old_conn);
}
// Delete the database file and WAL/SHM files
let _ = std::fs::remove_file(&self.db_path);
let _ = std::fs::remove_file(self.db_path.with_extension("db-wal"));
let _ = std::fs::remove_file(self.db_path.with_extension("db-shm"));
// Reopen
let new_conn = Connection::open(&self.db_path)
.map_err(|e| StoreError::Init(format!("cannot reopen database: {e}")))?;
let mode: String = new_conn
.pragma_update_and_check(None, "journal_mode", "wal", |row| row.get(0))
.map_err(|e| StoreError::Init(format!("cannot set WAL mode: {e}")))?;
if mode != "wal" {
return Err(StoreError::Init(format!(
"failed to enable WAL mode: journal_mode is '{mode}'"
)));
}
new_conn.pragma_update(None, "foreign_keys", "ON")
.map_err(|e| StoreError::Init(format!("cannot enable foreign keys: {e}")))?;
let mut conn = self.conn.lock().unwrap();
*conn = new_conn;
Ok(())
}
/// Count user tables (excludes sqlite_ internal tables).
fn table_count(&self) -> Result<usize, StoreError> {
let conn = self.conn.lock().unwrap();
let count: usize = conn
.query_row(
"SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%'",
[],
|row| row.get(0),
)
.map_err(|e| StoreError::Query(format!("cannot count tables: {e}")))?;
Ok(count)
}
}
impl StateStore for SqliteStore {
fn init(&self) -> Result<(), StoreError> {
// Check if schema already exists with correct version
if let Some(version) = self.stored_version() {
if version == SCHEMA_VERSION {
return Ok(());
}
// Version mismatch — for pre-alpha, we need to recreate.
// The mismatch case is handled by open_and_init() which
// calls recreate() then init() again.
return Err(StoreError::SchemaMismatch {
expected: SCHEMA_VERSION,
found: version,
});
}
// No schema_meta table — fresh database, create schema
let conn = self.conn.lock().unwrap();
conn.execute_batch(SCHEMA_SQL)
.map_err(|e| StoreError::Init(format!("cannot create schema: {e}")))?;
Ok(())
}
fn status(&self) -> Result<DbStatus, StoreError> {
let table_count = self.table_count()?;
let size_bytes = std::fs::metadata(&self.db_path)
.map(|m| m.len())
.ok();
Ok(DbStatus {
path: self.db_path.to_string_lossy().to_string(),
table_count,
size_bytes,
})
}
fn close(&self) -> Result<(), StoreError> {
// rusqlite closes the connection on drop. This method exists
// for the trait interface — other backends may need explicit cleanup.
Ok(())
}
}
impl SqliteStore {
/// Open the database, initialize the schema, and handle pre-alpha migration.
/// This is the primary entry point for production use.
pub fn open_and_init(path: &Path) -> Result<Self, StoreError> {
let store = Self::open(path)?;
match store.init() {
Ok(()) => Ok(store),
Err(StoreError::SchemaMismatch { expected, found }) => {
tracing::warn!(
expected,
found,
path = %path.display(),
"schema version mismatch, recreating database (pre-alpha migration)"
);
store.recreate()?;
store.init()?;
Ok(store)
}
Err(e) => Err(e),
}
}
}
}
Step 4: Add tracing dependency to nexus-lib
SqliteStore::open_and_init uses tracing::warn!. Add to nexus/nexus-lib/Cargo.toml:
[dependencies]
serde = { version = "1", features = ["derive"] }
serde_norway = "0.9"
dirs = "6"
reqwest = { version = "0.12", features = ["json"] }
rusqlite = { version = "0.34", features = ["bundled"] }
tracing = "0.1"
Step 5: Run tests to verify they pass
Run: cd /home/kazw/Work/WorkFort/nexus && cargo test -p nexus-lib store::sqlite
Expected: All 9 tests PASS.
Step 6: Commit
git add nexus/nexus-lib/src/store/sqlite.rs nexus/nexus-lib/Cargo.toml
git commit -m "feat(nexus-lib): implement SqliteStore with schema init and pre-alpha migration"
Task 5: XDG State Path Helper
Files:
- Modify:
nexus/nexus-lib/src/config.rs
Add a helper function for the default database path, following the existing default_config_path() pattern.
Step 1: Write the failing test
Add to the tests module in nexus/nexus-lib/src/config.rs:
#![allow(unused)]
fn main() {
#[test]
fn default_db_path_ends_with_nexus_db() {
let path = default_db_path();
assert!(path.ends_with("nexus/nexus.db"), "expected path ending with nexus/nexus.db, got: {}", path.display());
}
}
Step 2: Run test to verify it fails
Run: cd /home/kazw/Work/WorkFort/nexus && cargo test -p nexus-lib config::tests::default_db_path_ends_with_nexus_db
Expected: FAIL — default_db_path does not exist.
Step 3: Implement default_db_path
Add to nexus/nexus-lib/src/config.rs, after the default_config_path() function:
#![allow(unused)]
fn main() {
/// Returns the default database path: $XDG_STATE_HOME/nexus/nexus.db
pub fn default_db_path() -> PathBuf {
let state_dir = dirs::state_dir()
.expect("cannot determine XDG_STATE_HOME")
.join("nexus");
state_dir.join("nexus.db")
}
}
Step 4: Run test to verify it passes
Run: cd /home/kazw/Work/WorkFort/nexus && cargo test -p nexus-lib config::tests::default_db_path_ends_with_nexus_db
Expected: PASS.
Step 5: Commit
git add nexus/nexus-lib/src/config.rs
git commit -m "feat(nexus-lib): add default_db_path() for XDG state directory"
Task 6: Update Health Endpoint to Include DB Status
Files:
- Modify:
nexus/nexusd/src/api.rs
The health endpoint needs to return database information. This requires passing the SqliteStore to the handler via axum’s State extractor.
Step 1: Write the failing test
Replace the existing test in nexus/nexusd/src/api.rs and update the module:
#![allow(unused)]
fn main() {
// nexus/nexusd/src/api.rs
use axum::{Json, Router, routing::get};
use axum::extract::State;
use nexus_lib::store::traits::{DbStatus, StateStore};
use serde::Serialize;
use std::sync::Arc;
#[derive(Serialize)]
pub struct HealthResponse {
pub status: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub database: Option<DatabaseInfo>,
}
#[derive(Serialize)]
pub struct DatabaseInfo {
pub path: String,
pub table_count: usize,
pub size_bytes: Option<u64>,
}
impl From<DbStatus> for DatabaseInfo {
fn from(s: DbStatus) -> Self {
DatabaseInfo {
path: s.path,
table_count: s.table_count,
size_bytes: s.size_bytes,
}
}
}
/// Application state shared across handlers.
pub struct AppState {
pub store: Box<dyn StateStore + Send + Sync>,
}
async fn health(State(state): State<Arc<AppState>>) -> Json<HealthResponse> {
let database = state.store.status().ok().map(DatabaseInfo::from);
Json(HealthResponse {
status: "ok".to_string(),
database,
})
}
pub fn router(state: Arc<AppState>) -> Router {
Router::new()
.route("/v1/health", get(health))
.with_state(state)
}
#[cfg(test)]
mod tests {
use super::*;
use axum::body::Body;
use axum::http::{Request, StatusCode};
use nexus_lib::store::traits::StoreError;
use tower::ServiceExt;
/// A mock store for testing the health endpoint without SQLite.
struct MockStore;
impl StateStore for MockStore {
fn init(&self) -> Result<(), StoreError> { Ok(()) }
fn status(&self) -> Result<DbStatus, StoreError> {
Ok(DbStatus {
path: "/tmp/mock.db".to_string(),
table_count: 2,
size_bytes: Some(8192),
})
}
fn close(&self) -> Result<(), StoreError> { Ok(()) }
}
#[tokio::test]
async fn health_returns_ok_with_db_info() {
let state = Arc::new(AppState {
store: Box::new(MockStore),
});
let app = router(state);
let response = app
.oneshot(Request::get("/v1/health").body(Body::empty()).unwrap())
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.unwrap();
let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
assert_eq!(json["status"], "ok");
assert_eq!(json["database"]["path"], "/tmp/mock.db");
assert_eq!(json["database"]["table_count"], 2);
assert_eq!(json["database"]["size_bytes"], 8192);
}
}
}
Step 2: Run test to verify it fails
Run: cd /home/kazw/Work/WorkFort/nexus && cargo test -p nexusd api::tests::health_returns_ok_with_db_info
Expected: FAIL — the code changes haven’t been applied yet; the old health() handler doesn’t use State.
Step 3: Apply the implementation
Replace the entire contents of nexus/nexusd/src/api.rs with the code from Step 1 above (the test and implementation are written together in this case since the test uses a mock).
Step 4: Run test to verify it passes
Run: cd /home/kazw/Work/WorkFort/nexus && cargo test -p nexusd api::tests::health_returns_ok_with_db_info
Expected: PASS.
Step 5: Commit
git add nexus/nexusd/src/api.rs
git commit -m "feat(nexusd): extend health endpoint with database status info"
Task 7: Wire SqliteStore into nexusd Startup
Files:
- Modify:
nexus/nexusd/src/main.rs - Modify:
nexus/nexusd/src/server.rs
The daemon must initialize the database on startup and pass it to the API router.
Step 1: Update server.rs to accept AppState
#![allow(unused)]
fn main() {
// nexus/nexusd/src/server.rs
use crate::api::{self, AppState};
use nexus_lib::config::Config;
use tokio::net::TcpListener;
use tracing::info;
use std::sync::Arc;
pub async fn run(config: &Config, state: Arc<AppState>) -> Result<(), Box<dyn std::error::Error>> {
let app = api::router(state);
let listener = TcpListener::bind(&config.api.listen).await?;
info!(listen = %config.api.listen, "HTTP API ready");
axum::serve(listener, app)
.with_graceful_shutdown(shutdown_signal())
.await?;
info!("nexusd stopped");
Ok(())
}
async fn shutdown_signal() {
use tokio::signal::unix::{SignalKind, signal};
let mut sigterm = signal(SignalKind::terminate()).expect("failed to install SIGTERM handler");
let mut sigint = signal(SignalKind::interrupt()).expect("failed to install SIGINT handler");
tokio::select! {
_ = sigterm.recv() => info!("received SIGTERM, shutting down"),
_ = sigint.recv() => info!("received SIGINT, shutting down"),
}
}
}
Step 2: Update main.rs to initialize SqliteStore
// nexus/nexusd/src/main.rs
use clap::Parser;
use nexus_lib::config::{self, Config};
use nexus_lib::store::sqlite::SqliteStore;
use nexus_lib::store::traits::StateStore;
use tracing::{error, info};
use std::sync::Arc;
mod api;
mod logging;
mod server;
#[derive(Parser)]
#[command(name = "nexusd", about = "WorkFort Nexus daemon")]
struct Cli {
/// Path to configuration file
/// [default: $XDG_CONFIG_HOME/nexus/nexus.yaml]
#[arg(long)]
config: Option<String>,
/// Path to database file
/// [default: $XDG_STATE_HOME/nexus/nexus.db]
#[arg(long)]
db: Option<String>,
}
#[tokio::main]
async fn main() {
let cli = Cli::parse();
logging::init();
let config_path = cli
.config
.map(std::path::PathBuf::from)
.unwrap_or_else(config::default_config_path);
let config = match Config::load(&config_path) {
Ok(config) => {
info!(config_path = %config_path.display(), "loaded configuration");
config
}
Err(e) if e.is_not_found() => {
info!("no config file found, using defaults");
Config::default()
}
Err(e) => {
error!(error = %e, path = %config_path.display(), "invalid configuration file");
std::process::exit(1);
}
};
// Initialize SQLite state store
let db_path = cli
.db
.map(std::path::PathBuf::from)
.unwrap_or_else(config::default_db_path);
let store = match SqliteStore::open_and_init(&db_path) {
Ok(store) => {
info!(db_path = %db_path.display(), "database initialized");
store
}
Err(e) => {
error!(error = %e, db_path = %db_path.display(), "failed to initialize database");
std::process::exit(1);
}
};
let state = Arc::new(api::AppState {
store: Box::new(store),
});
info!("nexusd starting");
if let Err(e) = server::run(&config, state).await {
error!(error = %e, "daemon failed");
std::process::exit(1);
}
}
Step 3: Verify build
Run: cd /home/kazw/Work/WorkFort/nexus && cargo build -p nexusd
Expected: Compiles with no errors.
Step 4: Quick manual smoke test
Run: cd /home/kazw/Work/WorkFort/nexus && cargo run -p nexusd
Expected: Daemon starts, logs “database initialized” with db_path, logs “HTTP API ready”. In another terminal:
Run: curl -s http://127.0.0.1:9600/v1/health | python -m json.tool
Expected:
{
"status": "ok",
"database": {
"path": "/home/<user>/.local/state/nexus/nexus.db",
"table_count": 2,
"size_bytes": <some number>
}
}
Kill the daemon with Ctrl-C.
Step 5: Commit
git add nexus/nexusd/src/main.rs nexus/nexusd/src/server.rs
git commit -m "feat(nexusd): initialize SQLite database on startup"
Task 8: Update NexusClient and nexusctl status
Files:
- Modify:
nexus/nexus-lib/src/client.rs - Modify:
nexus/nexusctl/src/main.rs
Update the client to parse the new health response format with database info, and update nexusctl status to display it.
Step 1: Write the failing test in client.rs
Add a new test to nexus/nexus-lib/src/client.rs:
#![allow(unused)]
fn main() {
#[test]
fn health_response_with_database_deserializes() {
let json = r#"{"status":"ok","database":{"path":"/tmp/test.db","table_count":2,"size_bytes":8192}}"#;
let resp: HealthResponse = serde_json::from_str(json).unwrap();
assert_eq!(resp.status, "ok");
let db = resp.database.unwrap();
assert_eq!(db.path, "/tmp/test.db");
assert_eq!(db.table_count, 2);
assert_eq!(db.size_bytes, Some(8192));
}
#[test]
fn health_response_without_database_deserializes() {
let json = r#"{"status":"ok"}"#;
let resp: HealthResponse = serde_json::from_str(json).unwrap();
assert_eq!(resp.status, "ok");
assert!(resp.database.is_none());
}
}
Step 2: Run tests to verify they fail
Run: cd /home/kazw/Work/WorkFort/nexus && cargo test -p nexus-lib client::tests
Expected: FAIL — HealthResponse doesn’t have a database field yet.
Step 3: Update HealthResponse in client.rs
Add serde_json to nexus-lib dev-dependencies (for the deserialization test):
In nexus/nexus-lib/Cargo.toml:
[dev-dependencies]
tokio = { version = "1", features = ["rt", "macros"] }
tempfile = "3"
serde_json = "1"
Update the structs in nexus/nexus-lib/src/client.rs:
#![allow(unused)]
fn main() {
#[derive(Debug, Clone, Deserialize)]
pub struct HealthResponse {
pub status: String,
pub database: Option<DatabaseInfo>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct DatabaseInfo {
pub path: String,
pub table_count: usize,
pub size_bytes: Option<u64>,
}
}
Step 4: Run tests to verify they pass
Run: cd /home/kazw/Work/WorkFort/nexus && cargo test -p nexus-lib client::tests
Expected: All 5 tests PASS (3 existing + 2 new).
Step 5: Update nexusctl status command
Modify cmd_status in nexus/nexusctl/src/main.rs:
#![allow(unused)]
fn main() {
async fn cmd_status(daemon_addr: &str) -> ExitCode {
let client = NexusClient::new(daemon_addr);
match client.health().await {
Ok(resp) => {
println!("Daemon: {} ({})", resp.status, daemon_addr);
if let Some(db) = resp.database {
println!("Database: {}", db.path);
println!(" Tables: {}", db.table_count);
if let Some(size) = db.size_bytes {
println!(" Size: {} bytes", size);
}
}
ExitCode::SUCCESS
}
Err(e) if e.is_connect() => {
eprintln!(
"Error: cannot connect to Nexus daemon at {}\n \
The daemon does not appear to be running.\n\n \
Start it: systemctl --user start nexus.service",
daemon_addr
);
ExitCode::FAILURE
}
Err(e) => {
eprintln!("Error: {e}");
ExitCode::FAILURE
}
}
}
}
Step 6: Run full build to verify everything compiles
Run: cd /home/kazw/Work/WorkFort/nexus && cargo build
Expected: Compiles with no errors.
Step 7: Commit
git add nexus/nexus-lib/src/client.rs nexus/nexus-lib/Cargo.toml nexus/nexusctl/src/main.rs
git commit -m "feat(nexusctl): display database status in nexusctl status output"
Task 9: Update Integration Tests
Files:
- Modify:
nexus/nexusd/tests/daemon.rs - Modify:
nexus/nexusctl/tests/cli.rs
The integration tests need to account for the new --db flag and the updated health response.
Step 1: Update nexusd integration test
The daemon now creates a database file. The test should use a temporary database path to avoid polluting $XDG_STATE_HOME.
#![allow(unused)]
fn main() {
// nexus/nexusd/tests/daemon.rs
use nix::sys::signal::{self, Signal};
use nix::unistd::Pid;
use std::process::{Child, Command};
use std::time::Duration;
fn start_daemon(db_path: &std::path::Path) -> Child {
let binary = env!("CARGO_BIN_EXE_nexusd");
Command::new(binary)
.env("RUST_LOG", "info")
.arg("--db")
.arg(db_path)
.spawn()
.expect("failed to start nexusd")
}
fn stop_daemon(child: &Child) {
signal::kill(Pid::from_raw(child.id() as i32), Signal::SIGTERM)
.expect("failed to send SIGTERM");
}
#[tokio::test]
async fn daemon_starts_serves_health_and_stops() {
let tmp_dir = tempfile::tempdir().unwrap();
let db_path = tmp_dir.path().join("test.db");
let mut child = start_daemon(&db_path);
// Wait for the daemon to be ready
let client = reqwest::Client::new();
let mut ready = false;
for _ in 0..50 {
tokio::time::sleep(Duration::from_millis(100)).await;
if client
.get("http://127.0.0.1:9600/v1/health")
.send()
.await
.is_ok()
{
ready = true;
break;
}
}
assert!(ready, "daemon did not become ready within 5 seconds");
// Verify health endpoint includes database info
let resp = client
.get("http://127.0.0.1:9600/v1/health")
.send()
.await
.expect("health request failed");
assert_eq!(resp.status(), 200);
let body: serde_json::Value = resp.json().await.unwrap();
assert_eq!(body["status"], "ok");
assert!(body["database"]["path"].is_string(), "expected database path in response");
assert_eq!(body["database"]["table_count"], 2);
assert!(body["database"]["size_bytes"].is_number(), "expected database size in response");
// Verify database file was created
assert!(db_path.exists(), "database file should be created");
// Graceful shutdown
stop_daemon(&child);
let status = child.wait().expect("failed to wait on daemon");
assert!(
status.success(),
"daemon exited with non-zero status: {}",
status
);
}
}
Add tempfile to nexusd dev-dependencies. In nexus/nexusd/Cargo.toml:
[dev-dependencies]
tower = { version = "0.5", features = ["util"] }
reqwest = { version = "0.12", features = ["json"] }
nix = { version = "0.30", features = ["signal"] }
serde_json = "1"
tempfile = "3"
Step 2: Update nexusctl integration test
The status_when_daemon_running test should verify the database info is shown.
Update nexus/nexusctl/tests/cli.rs:
#![allow(unused)]
fn main() {
// nexus/nexusctl/tests/cli.rs
use nix::sys::signal::{self, Signal};
use nix::unistd::Pid;
use std::process::{Child, Command};
use std::time::Duration;
/// Address used for nexusctl integration tests.
/// Different from nexusd's integration test (port 9600) to avoid conflicts
/// when running `cargo test --workspace`.
const TEST_ADDR: &str = "127.0.0.1:9601";
fn target_dir() -> std::path::PathBuf {
let mut path = std::env::current_exe().expect("cannot get test binary path");
path.pop(); // remove cli-<hash>
path.pop(); // remove deps
path
}
fn start_daemon(db_path: &std::path::Path) -> Child {
let binary = target_dir().join("nexusd");
let config_yaml = format!("api:\n listen: \"{TEST_ADDR}\"");
let config_path = std::env::temp_dir().join("nexusctl-test-config.yaml");
std::fs::write(&config_path, config_yaml).expect("failed to write test config");
Command::new(binary)
.env("RUST_LOG", "info")
.arg("--config")
.arg(&config_path)
.arg("--db")
.arg(db_path)
.spawn()
.expect("failed to start nexusd")
}
fn stop_daemon(child: &Child) {
signal::kill(Pid::from_raw(child.id() as i32), Signal::SIGTERM)
.expect("failed to send SIGTERM");
}
#[tokio::test]
async fn status_when_daemon_running() {
let tmp_dir = tempfile::tempdir().unwrap();
let db_path = tmp_dir.path().join("test.db");
let mut child = start_daemon(&db_path);
// Wait for daemon to be ready
let client = reqwest::Client::new();
let mut ready = false;
for _ in 0..50 {
tokio::time::sleep(Duration::from_millis(100)).await;
if client
.get(format!("http://{TEST_ADDR}/v1/health"))
.send()
.await
.is_ok()
{
ready = true;
break;
}
}
assert!(ready, "daemon did not become ready within 5 seconds");
// Run nexusctl status
let output = Command::new(env!("CARGO_BIN_EXE_nexusctl"))
.args(["--daemon", TEST_ADDR, "status"])
.output()
.expect("failed to run nexusctl");
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(output.status.success(), "nexusctl status failed: {stdout}");
assert!(stdout.contains("ok"), "expected 'ok' in output: {stdout}");
assert!(stdout.contains(TEST_ADDR), "expected address in output: {stdout}");
assert!(stdout.contains("Database:"), "expected database path in output: {stdout}");
assert!(stdout.contains("Tables:"), "expected table count in output: {stdout}");
// Clean up
stop_daemon(&child);
child.wait().expect("failed to wait on daemon");
}
#[tokio::test]
async fn status_when_daemon_not_running() {
// Use a port where nothing is listening
let output = Command::new(env!("CARGO_BIN_EXE_nexusctl"))
.args(["--daemon", "127.0.0.1:19998", "status"])
.output()
.expect("failed to run nexusctl");
assert!(!output.status.success(), "expected non-zero exit code");
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
stderr.contains("cannot connect to Nexus daemon"),
"expected connection error in stderr: {stderr}"
);
assert!(
stderr.contains("19998"),
"expected port in error message: {stderr}"
);
assert!(
stderr.contains("systemctl --user start nexus.service"),
"expected actionable hint in stderr: {stderr}"
);
}
#[test]
fn version_prints_version() {
let output = Command::new(env!("CARGO_BIN_EXE_nexusctl"))
.args(["version"])
.output()
.expect("failed to run nexusctl");
assert!(output.status.success(), "nexusctl version failed");
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(
stdout.contains("nexusctl 0.1.0"),
"expected version string in output: {stdout}"
);
}
}
Add tempfile to nexusctl dev-dependencies. In nexus/nexusctl/Cargo.toml:
[dev-dependencies]
nix = { version = "0.30", features = ["signal"] }
tokio = { version = "1", features = ["full"] }
reqwest = { version = "0.12", features = ["json"] }
tempfile = "3"
Step 3: Run all integration tests
Run: cd /home/kazw/Work/WorkFort/nexus && cargo test --workspace
Expected: All tests PASS.
Step 4: Commit
git add nexus/nexusd/tests/daemon.rs nexus/nexusd/Cargo.toml nexus/nexusctl/tests/cli.rs nexus/nexusctl/Cargo.toml
git commit -m "test: update integration tests for database initialization and status reporting"
Task 10: Workspace-Wide Verification
Step 1: Full build
Run: cd /home/kazw/Work/WorkFort/nexus && mise run build
Expected: Compiles with no errors and no warnings.
Step 2: Full test suite
Run: cd /home/kazw/Work/WorkFort/nexus && mise run test
Expected: All tests pass — nexus-lib unit tests (config, client, store/traits, store/sqlite), nexusd unit tests (api), nexusd integration test, nexusctl unit tests, nexusctl integration tests.
Step 3: Clippy
Run: cd /home/kazw/Work/WorkFort/nexus && mise run clippy
Expected: No warnings.
Step 4: End-to-end smoke test
Terminal 1:
cd /home/kazw/Work/WorkFort/nexus && mise run run
Expected logs:
INFO nexusd: no config file found, using defaults
INFO nexusd: database initialized db_path="/home/<user>/.local/state/nexus/nexus.db"
INFO nexusd: nexusd starting
INFO nexusd::server: HTTP API ready listen=127.0.0.1:9600
Terminal 2:
# Health endpoint with database info
curl -s http://127.0.0.1:9600/v1/health | python -m json.tool
Expected:
{
"status": "ok",
"database": {
"path": "/home/<user>/.local/state/nexus/nexus.db",
"table_count": 2,
"size_bytes": <number>
}
}
# CLI status with database info
cd /home/kazw/Work/WorkFort/nexus && mise run run:nexusctl -- status
Expected:
Daemon: ok (127.0.0.1:9600)
Database: /home/<user>/.local/state/nexus/nexus.db
Tables: 2
Size: <number> bytes
Kill the daemon (Ctrl-C in terminal 1). Verify nexusctl status gives the usual error.
Step 5: Verify database file
ls -la ~/.local/state/nexus/nexus.db
sqlite3 ~/.local/state/nexus/nexus.db ".tables"
Expected: Database file exists. .tables lists schema_meta and settings.
Step 6: Verify pre-alpha migration
# Corrupt the schema version
sqlite3 ~/.local/state/nexus/nexus.db "UPDATE schema_meta SET value = '0' WHERE key = 'version'"
# Restart the daemon
cd /home/kazw/Work/WorkFort/nexus && mise run run
Expected: Log shows “schema version mismatch, recreating database (pre-alpha migration)”, then “database initialized”. Database is recreated with correct schema.
Kill the daemon.
Step 7: Verify custom –db flag
cd /home/kazw/Work/WorkFort/nexus && cargo run -p nexusd -- --db /tmp/nexus-test.db
Expected: Database created at /tmp/nexus-test.db.
Kill the daemon. Clean up:
rm -f /tmp/nexus-test.db /tmp/nexus-test.db-wal /tmp/nexus-test.db-shm
Step 8: Commit (if any final adjustments were needed)
git add nexus/
git commit -m "chore: final adjustments from step 3 verification"
Verification Checklist
After all tasks are complete, verify the following:
-
mise run buildsucceeds with no warnings -
mise run test— all tests pass -
mise run clippy— no warnings -
cargo run -p nexusd -- --help— shows--dbflag -
curl localhost:9600/v1/health— returns JSON withstatus,database.path,database.table_count,database.size_bytes -
nexusctl status— prints daemon status plus database path, table count, and size - Database created at
$XDG_STATE_HOME/nexus/nexus.dbby default -
--db /custom/path.db— overrides database location - Database has 2 tables (schema_meta, settings) and schema version 1
- Schema version mismatch triggers delete-and-recreate with warning log
- Second daemon start with existing correct DB is idempotent (no recreate)
- Foreign keys enabled (
PRAGMA foreign_keysreturns 1) - WAL mode enabled (
PRAGMA journal_modereturnswal) -
StateStoretrait is the only interface used bynexusd(no directrusqliteusage innexusd)