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,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