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,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);
|
||||
"#,
|
||||
},
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user