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:
EdisonJwa
2026-05-14 12:26:36 +08:00
parent 50c95b61ad
commit 52e8d43f69
9 changed files with 1658 additions and 0 deletions
+40
View File
@@ -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,
};
+66
View File
@@ -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(())
}
+145
View File
@@ -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);
"#,
},
]
}
+218
View File
@@ -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)?,
})
}