Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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):

  • rusqlite 0.34 — SQLite bindings with bundled feature (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:

  • bundled feature compiles SQLite from source — no system libsqlite3-dev needed.
  • tempfile added 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 build succeeds with no warnings
  • mise run test — all tests pass
  • mise run clippy — no warnings
  • cargo run -p nexusd -- --help — shows --db flag
  • curl localhost:9600/v1/health — returns JSON with status, 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.db by 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_keys returns 1)
  • WAL mode enabled (PRAGMA journal_mode returns wal)
  • StateStore trait is the only interface used by nexusd (no direct rusqlite usage in nexusd)