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.
219 lines
6.8 KiB
Rust
219 lines
6.8 KiB
Rust
//! 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)?,
|
|
})
|
|
}
|