Files
chanora/poc/sqlite-storage-spike/src/repo.rs
T
EdisonJwa 52e8d43f69 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.
2026-05-14 12:26:36 +08:00

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)?,
})
}