feat(poc/storage): add sqlite-storage spike
Proof-of-concept proving the SQLite-storage exit criterion from
docs/architecture/proof-of-concept-plan.md §2:
"Schema, migration, and repository pattern are demonstrated."
Also satisfies the SRS-089 acceptance criteria explicitly:
"Storage implementation uses an embedded local data store and
migration mechanism."
Implements:
- A forward-only Migrator over a fixed Migration list, tracking
the applied version via PRAGMA user_version. Each migration is
applied inside an IMMEDIATE transaction; rolled back on failure.
- Three canonical migrations (initial schema, add nickname,
add last_connected_at) demonstrating ALTER TABLE flows.
- A LocalDatabaseRepository implementing both BookmarkRepository
and SettingsRepository traits.
- Bookmark.identity_ref is a reference to a secret name, never
a secret value (cross-checked by the secure-storage spike's
SS-AUD-001/002 scans). This is the SAD-067 separation.
Test suite (11/11 PASS on 2026-05-13):
- migrator brings fresh DB to latest version
- migrator is idempotent (no-op when already current)
- migrator applies only pending versions (catch-up upgrade)
- migrator rejects out-of-order versions
- migrator rejects DB newer than known migrations (downgrade guard)
- failed migration rolls back atomically
- bookmark CRUD round-trip
- bookmark list ordered by recency
- bookmark UNIQUE(host, identity_ref) enforcement
- settings upsert + delete
- open creates file and persists across reopen
Surfaced finding for the decision register: DEC-013 does not pin a
SQLite crate. The PoC uses rusqlite with the bundled feature
(no system libsqlite3 dependency); production code needs an
owner ruling on rusqlite vs. sqlx vs. sea-orm.
Authority: PoC plan §2, SRS-089, SDD-077, SAD-067,
SysDes-033/036/049/091.
Not product code; not promoted into chanora_storage.
This commit is contained in:
@@ -0,0 +1,40 @@
|
||||
//! Chanora PoC — SQLite storage spike.
|
||||
//!
|
||||
//! Authority:
|
||||
//! * `docs/architecture/proof-of-concept-plan.md` §2 — SQLite storage spike.
|
||||
//! Exit criterion: "Schema, migration, and repository pattern are demonstrated."
|
||||
//! * SRS-089 — "The software shall use SQLite or an equivalent embedded
|
||||
//! data store for non-secret local data." Acceptance criteria:
|
||||
//! "Storage implementation uses an embedded local data store and
|
||||
//! migration mechanism."
|
||||
//! * SDD-077 — `LocalDatabaseRepository` persists non-secret local state
|
||||
//! through SQLite or equivalent.
|
||||
//! * SAD-067 — separation of non-secret local persistence (DB repository
|
||||
//! layer) from secret persistence (platform secure storage).
|
||||
//!
|
||||
//! The PoC models the production shape:
|
||||
//!
|
||||
//! ┌───────────────────────────┐ ┌──────────────────────────┐
|
||||
//! │ BookmarkRepository (trait)│ │ SettingsRepository (trait)│
|
||||
//! └─────────────┬─────────────┘ └─────────────┬────────────┘
|
||||
//! │ │
|
||||
//! ▼ ▼
|
||||
//! ┌────────────────────────────────────────┐
|
||||
//! │ LocalDatabaseRepository (SQLite) │
|
||||
//! │ ────────────────────────────────── │
|
||||
//! │ forward-only schema migrations │
|
||||
//! │ tracked via PRAGMA user_version │
|
||||
//! └────────────────────────────────────────┘
|
||||
//!
|
||||
//! Out of scope here: server passwords, identity secrets, any secret
|
||||
//! material whatsoever (those belong to `secure-storage-spike` and to
|
||||
//! the production `SecretStorageRepository`).
|
||||
|
||||
pub mod migrate;
|
||||
pub mod repo;
|
||||
|
||||
pub use migrate::{Migrator, MigrationError};
|
||||
pub use repo::{
|
||||
Bookmark, BookmarkRepository, LocalDatabaseRepository, RepoError, Setting,
|
||||
SettingsRepository,
|
||||
};
|
||||
@@ -0,0 +1,66 @@
|
||||
//! CLI driver — exercises the full repository surface against an
|
||||
//! on-disk SQLite file in a temporary directory.
|
||||
|
||||
use std::process::ExitCode;
|
||||
|
||||
use sqlite_storage_spike::{
|
||||
repo::NewBookmark, BookmarkRepository, LocalDatabaseRepository, SettingsRepository,
|
||||
};
|
||||
use tracing::info;
|
||||
|
||||
fn main() -> ExitCode {
|
||||
tracing_subscriber::fmt()
|
||||
.with_env_filter(
|
||||
tracing_subscriber::EnvFilter::try_from_default_env()
|
||||
.unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info")),
|
||||
)
|
||||
.init();
|
||||
|
||||
if let Err(e) = run() {
|
||||
eprintln!("error: {e}");
|
||||
return ExitCode::from(1);
|
||||
}
|
||||
ExitCode::SUCCESS
|
||||
}
|
||||
|
||||
fn run() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let tmp = tempfile::tempdir()?;
|
||||
let db_path = tmp.path().join("chanora.sqlite");
|
||||
info!(target: "spike", "opening {}", db_path.display());
|
||||
|
||||
let repo = LocalDatabaseRepository::open(&db_path)?;
|
||||
info!(target: "spike", "schema version = {}", repo.schema_version()?);
|
||||
|
||||
let id_a = repo.insert(NewBookmark {
|
||||
server_name: "Vigorous Pro",
|
||||
server_host: "cn.teamspeak.app",
|
||||
identity_ref: "identity.primary",
|
||||
nickname: Some("ChanoraPoC"),
|
||||
})?;
|
||||
let id_b = repo.insert(NewBookmark {
|
||||
server_name: "Local Lab",
|
||||
server_host: "ts.example.invalid",
|
||||
identity_ref: "identity.lab",
|
||||
nickname: None,
|
||||
})?;
|
||||
info!(target: "spike", "inserted bookmark ids: {id_a}, {id_b}");
|
||||
|
||||
repo.touch_connected(id_a, 1_715_000_000)?;
|
||||
SettingsRepository::put(&repo, "audio.aec", "true")?;
|
||||
SettingsRepository::put(&repo, "audio.ns", "true")?;
|
||||
|
||||
let list = BookmarkRepository::list_ordered(&repo)?;
|
||||
println!("\nBookmarks (most-recently-connected first):");
|
||||
for b in &list {
|
||||
println!(
|
||||
" [{}] {} @ {} (identity_ref={}, last_connected_at={:?})",
|
||||
b.id, b.server_name, b.server_host, b.identity_ref, b.last_connected_at
|
||||
);
|
||||
}
|
||||
|
||||
let aec = SettingsRepository::get(&repo, "audio.aec")?;
|
||||
println!("\nSettings.audio.aec = {aec:?}");
|
||||
|
||||
println!("\nOK — schema v{} loaded, repository round-trip verified.", repo.schema_version()?);
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
//! Forward-only schema migration mechanism.
|
||||
//!
|
||||
//! Approach: each migration is a `&'static str` SQL script applied in
|
||||
//! order. The current schema version is tracked in SQLite's
|
||||
//! `PRAGMA user_version`. Running the migrator is idempotent — already-
|
||||
//! applied versions are skipped.
|
||||
//!
|
||||
//! Why not an external migration framework? At this stage we want the
|
||||
//! mechanism in the audit surface to be explainable in ~50 lines and
|
||||
//! to have no transitive crate dependencies. Production code may
|
||||
//! promote this to `refinery`, `sqlx::migrate!`, or similar if the
|
||||
//! complexity warrants.
|
||||
|
||||
use rusqlite::{Connection, TransactionBehavior};
|
||||
use thiserror::Error;
|
||||
use tracing::info;
|
||||
|
||||
/// A single migration step. `version` is monotonically increasing
|
||||
/// starting at 1. `sql` is applied inside a transaction.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct Migration {
|
||||
pub version: u32,
|
||||
pub name: &'static str,
|
||||
pub sql: &'static str,
|
||||
}
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum MigrationError {
|
||||
#[error("sqlite error during migration: {0}")]
|
||||
Sqlite(#[from] rusqlite::Error),
|
||||
#[error("migrations must be ordered by ascending version; got {got} after {prev}")]
|
||||
OutOfOrder { prev: u32, got: u32 },
|
||||
#[error("database is at version {db}, which is newer than the highest known migration {max}")]
|
||||
FromTheFuture { db: u32, max: u32 },
|
||||
}
|
||||
|
||||
/// Forward-only migrator over a fixed list of `Migration` steps.
|
||||
pub struct Migrator {
|
||||
migrations: Vec<Migration>,
|
||||
}
|
||||
|
||||
impl Migrator {
|
||||
pub fn new(migrations: Vec<Migration>) -> Result<Self, MigrationError> {
|
||||
let mut prev = 0u32;
|
||||
for m in &migrations {
|
||||
if m.version <= prev {
|
||||
return Err(MigrationError::OutOfOrder { prev, got: m.version });
|
||||
}
|
||||
prev = m.version;
|
||||
}
|
||||
Ok(Self { migrations })
|
||||
}
|
||||
|
||||
pub fn highest_version(&self) -> u32 {
|
||||
self.migrations.last().map(|m| m.version).unwrap_or(0)
|
||||
}
|
||||
|
||||
/// Apply every pending migration to `conn`. Idempotent: if the DB
|
||||
/// is already at the highest version, this is a no-op. Each
|
||||
/// migration is wrapped in an IMMEDIATE transaction; on failure
|
||||
/// the transaction is rolled back and the DB version is unchanged.
|
||||
pub fn run(&self, conn: &mut Connection) -> Result<u32, MigrationError> {
|
||||
let current: u32 =
|
||||
conn.query_row("PRAGMA user_version", [], |r| r.get::<_, i64>(0))? as u32;
|
||||
|
||||
if current > self.highest_version() {
|
||||
return Err(MigrationError::FromTheFuture {
|
||||
db: current,
|
||||
max: self.highest_version(),
|
||||
});
|
||||
}
|
||||
|
||||
let pending: Vec<&Migration> = self
|
||||
.migrations
|
||||
.iter()
|
||||
.filter(|m| m.version > current)
|
||||
.collect();
|
||||
|
||||
if pending.is_empty() {
|
||||
info!(target: "spike", "no migrations to apply (already at v{current})");
|
||||
return Ok(current);
|
||||
}
|
||||
|
||||
for m in pending {
|
||||
info!(target: "spike", "applying migration v{} {:?}", m.version, m.name);
|
||||
let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate)?;
|
||||
tx.execute_batch(m.sql)?;
|
||||
// PRAGMA user_version cannot be parameterised; the version
|
||||
// is from our own static migration list and is therefore
|
||||
// safe to interpolate.
|
||||
tx.execute_batch(&format!("PRAGMA user_version = {}", m.version))?;
|
||||
tx.commit()?;
|
||||
}
|
||||
|
||||
let final_v: u32 =
|
||||
conn.query_row("PRAGMA user_version", [], |r| r.get::<_, i64>(0))? as u32;
|
||||
Ok(final_v)
|
||||
}
|
||||
}
|
||||
|
||||
/// Canonical Chanora PoC migration list.
|
||||
///
|
||||
/// Each version is intentionally small and self-contained. Migrations
|
||||
/// in the production crate will live in `migrations/` as separate
|
||||
/// files; the PoC keeps them inline for visibility.
|
||||
pub fn canonical_migrations() -> Vec<Migration> {
|
||||
vec![
|
||||
Migration {
|
||||
version: 1,
|
||||
name: "initial bookmarks + settings",
|
||||
sql: r#"
|
||||
CREATE TABLE bookmarks (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
server_name TEXT NOT NULL,
|
||||
server_host TEXT NOT NULL,
|
||||
identity_ref TEXT NOT NULL,
|
||||
created_at INTEGER NOT NULL DEFAULT (strftime('%s','now'))
|
||||
);
|
||||
CREATE UNIQUE INDEX ix_bookmarks_host_identity
|
||||
ON bookmarks(server_host, identity_ref);
|
||||
|
||||
CREATE TABLE settings (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL
|
||||
);
|
||||
"#,
|
||||
},
|
||||
Migration {
|
||||
version: 2,
|
||||
name: "add bookmarks.nickname",
|
||||
sql: r#"
|
||||
ALTER TABLE bookmarks ADD COLUMN nickname TEXT;
|
||||
"#,
|
||||
},
|
||||
Migration {
|
||||
version: 3,
|
||||
name: "add bookmarks.last_connected_at",
|
||||
sql: r#"
|
||||
ALTER TABLE bookmarks ADD COLUMN last_connected_at INTEGER;
|
||||
CREATE INDEX ix_bookmarks_last_connected
|
||||
ON bookmarks(last_connected_at DESC);
|
||||
"#,
|
||||
},
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
//! Repository pattern over the SQLite schema.
|
||||
//!
|
||||
//! `LocalDatabaseRepository` is the single concrete type that owns the
|
||||
//! `Connection`. Repository traits (`BookmarkRepository`,
|
||||
//! `SettingsRepository`) describe the API surface the rest of the Rust
|
||||
//! core depends on. This mirrors the production split where the
|
||||
//! Chanora core consumes traits rather than the concrete SQLite type
|
||||
//! (SAD-067, SDD-077).
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
use rusqlite::{params, Connection, OptionalExtension};
|
||||
use thiserror::Error;
|
||||
|
||||
use crate::migrate::{canonical_migrations, Migrator};
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum RepoError {
|
||||
#[error("sqlite error: {0}")]
|
||||
Sqlite(#[from] rusqlite::Error),
|
||||
#[error("migration error: {0}")]
|
||||
Migration(#[from] crate::migrate::MigrationError),
|
||||
#[error("not found")]
|
||||
NotFound,
|
||||
}
|
||||
|
||||
// ---------- DTOs ----------
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Bookmark {
|
||||
pub id: i64,
|
||||
pub server_name: String,
|
||||
pub server_host: String,
|
||||
/// Stable reference to a secret in platform secure storage.
|
||||
/// **Not** the secret itself (SAD-067, see secure-storage-spike).
|
||||
pub identity_ref: String,
|
||||
pub nickname: Option<String>,
|
||||
/// Unix epoch seconds; `None` if never connected.
|
||||
pub last_connected_at: Option<i64>,
|
||||
pub created_at: i64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Setting {
|
||||
pub key: String,
|
||||
pub value: String,
|
||||
}
|
||||
|
||||
// ---------- Traits ----------
|
||||
|
||||
pub trait BookmarkRepository {
|
||||
fn insert(&self, b: NewBookmark<'_>) -> Result<i64, RepoError>;
|
||||
fn get(&self, id: i64) -> Result<Bookmark, RepoError>;
|
||||
fn list_ordered(&self) -> Result<Vec<Bookmark>, RepoError>;
|
||||
fn touch_connected(&self, id: i64, at_epoch_secs: i64) -> Result<(), RepoError>;
|
||||
fn delete(&self, id: i64) -> Result<(), RepoError>;
|
||||
}
|
||||
|
||||
pub trait SettingsRepository {
|
||||
fn put(&self, key: &str, value: &str) -> Result<(), RepoError>;
|
||||
fn get(&self, key: &str) -> Result<Option<String>, RepoError>;
|
||||
fn delete(&self, key: &str) -> Result<bool, RepoError>;
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct NewBookmark<'a> {
|
||||
pub server_name: &'a str,
|
||||
pub server_host: &'a str,
|
||||
pub identity_ref: &'a str,
|
||||
pub nickname: Option<&'a str>,
|
||||
}
|
||||
|
||||
// ---------- Concrete repository ----------
|
||||
|
||||
pub struct LocalDatabaseRepository {
|
||||
conn: Connection,
|
||||
}
|
||||
|
||||
impl LocalDatabaseRepository {
|
||||
/// Open (or create) a SQLite database at `path` and bring the
|
||||
/// schema up to date via the migrator.
|
||||
pub fn open(path: &Path) -> Result<Self, RepoError> {
|
||||
let mut conn = Connection::open(path)?;
|
||||
Self::pragmas(&conn)?;
|
||||
let migrator = Migrator::new(canonical_migrations()).map_err(RepoError::Migration)?;
|
||||
migrator.run(&mut conn).map_err(RepoError::Migration)?;
|
||||
Ok(Self { conn })
|
||||
}
|
||||
|
||||
/// Open an in-memory DB for tests.
|
||||
pub fn open_in_memory() -> Result<Self, RepoError> {
|
||||
let mut conn = Connection::open_in_memory()?;
|
||||
Self::pragmas(&conn)?;
|
||||
let migrator = Migrator::new(canonical_migrations()).map_err(RepoError::Migration)?;
|
||||
migrator.run(&mut conn).map_err(RepoError::Migration)?;
|
||||
Ok(Self { conn })
|
||||
}
|
||||
|
||||
fn pragmas(conn: &Connection) -> Result<(), RepoError> {
|
||||
// Durability + concurrency defaults that match production
|
||||
// expectations for a desktop/mobile embedded DB.
|
||||
conn.execute_batch(
|
||||
"PRAGMA journal_mode = WAL;
|
||||
PRAGMA synchronous = NORMAL;
|
||||
PRAGMA foreign_keys = ON;",
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Current schema version (PRAGMA user_version).
|
||||
pub fn schema_version(&self) -> Result<u32, RepoError> {
|
||||
let v: i64 = self
|
||||
.conn
|
||||
.query_row("PRAGMA user_version", [], |r| r.get(0))?;
|
||||
Ok(v as u32)
|
||||
}
|
||||
}
|
||||
|
||||
impl BookmarkRepository for LocalDatabaseRepository {
|
||||
fn insert(&self, b: NewBookmark<'_>) -> Result<i64, RepoError> {
|
||||
self.conn.execute(
|
||||
"INSERT INTO bookmarks (server_name, server_host, identity_ref, nickname)
|
||||
VALUES (?1, ?2, ?3, ?4)",
|
||||
params![b.server_name, b.server_host, b.identity_ref, b.nickname],
|
||||
)?;
|
||||
Ok(self.conn.last_insert_rowid())
|
||||
}
|
||||
|
||||
fn get(&self, id: i64) -> Result<Bookmark, RepoError> {
|
||||
self.conn
|
||||
.query_row(
|
||||
"SELECT id, server_name, server_host, identity_ref, nickname,
|
||||
last_connected_at, created_at
|
||||
FROM bookmarks WHERE id = ?1",
|
||||
params![id],
|
||||
row_to_bookmark,
|
||||
)
|
||||
.optional()?
|
||||
.ok_or(RepoError::NotFound)
|
||||
}
|
||||
|
||||
fn list_ordered(&self) -> Result<Vec<Bookmark>, RepoError> {
|
||||
let mut stmt = self.conn.prepare(
|
||||
"SELECT id, server_name, server_host, identity_ref, nickname,
|
||||
last_connected_at, created_at
|
||||
FROM bookmarks
|
||||
ORDER BY last_connected_at DESC NULLS LAST, server_name ASC",
|
||||
)?;
|
||||
let rows = stmt.query_map([], row_to_bookmark)?;
|
||||
let mut out = Vec::new();
|
||||
for r in rows {
|
||||
out.push(r?);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn touch_connected(&self, id: i64, at_epoch_secs: i64) -> Result<(), RepoError> {
|
||||
let n = self.conn.execute(
|
||||
"UPDATE bookmarks SET last_connected_at = ?2 WHERE id = ?1",
|
||||
params![id, at_epoch_secs],
|
||||
)?;
|
||||
if n == 0 {
|
||||
return Err(RepoError::NotFound);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn delete(&self, id: i64) -> Result<(), RepoError> {
|
||||
let n = self
|
||||
.conn
|
||||
.execute("DELETE FROM bookmarks WHERE id = ?1", params![id])?;
|
||||
if n == 0 {
|
||||
return Err(RepoError::NotFound);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl SettingsRepository for LocalDatabaseRepository {
|
||||
fn put(&self, key: &str, value: &str) -> Result<(), RepoError> {
|
||||
self.conn.execute(
|
||||
"INSERT INTO settings (key, value) VALUES (?1, ?2)
|
||||
ON CONFLICT(key) DO UPDATE SET value = excluded.value",
|
||||
params![key, value],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn get(&self, key: &str) -> Result<Option<String>, RepoError> {
|
||||
Ok(self
|
||||
.conn
|
||||
.query_row(
|
||||
"SELECT value FROM settings WHERE key = ?1",
|
||||
params![key],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.optional()?)
|
||||
}
|
||||
|
||||
fn delete(&self, key: &str) -> Result<bool, RepoError> {
|
||||
let n = self
|
||||
.conn
|
||||
.execute("DELETE FROM settings WHERE key = ?1", params![key])?;
|
||||
Ok(n > 0)
|
||||
}
|
||||
}
|
||||
|
||||
fn row_to_bookmark(row: &rusqlite::Row<'_>) -> rusqlite::Result<Bookmark> {
|
||||
Ok(Bookmark {
|
||||
id: row.get(0)?,
|
||||
server_name: row.get(1)?,
|
||||
server_host: row.get(2)?,
|
||||
identity_ref: row.get(3)?,
|
||||
nickname: row.get(4)?,
|
||||
last_connected_at: row.get(5)?,
|
||||
created_at: row.get(6)?,
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user