use crate::errors::CoreError;
use rusqlite::Connection;
#[derive(Debug, Clone)]
pub struct Migration {
pub version: i64,
pub description: &'static str,
pub sql: &'static str,
}
pub const MIGRATIONS: &[Migration] = &[Migration {
version: 1,
description: "initial: bookmarks + tags",
sql: MIGRATION_001_SQL,
}];
pub const MIGRATION_001_SQL: &str = r#"
CREATE TABLE IF NOT EXISTS schema_migrations (
version INTEGER PRIMARY KEY,
applied_at INTEGER NOT NULL,
description TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS bookmarks (
id TEXT PRIMARY KEY,
original_url TEXT NOT NULL,
canonical_url TEXT NOT NULL,
title TEXT NOT NULL DEFAULT '',
description TEXT,
collection TEXT,
source_kind TEXT NOT NULL,
source_id TEXT,
external_id TEXT,
added_at INTEGER NOT NULL,
last_seen_at INTEGER NOT NULL,
raw TEXT,
archived INTEGER NOT NULL DEFAULT 0
);
CREATE UNIQUE INDEX IF NOT EXISTS bookmarks_canonical_url_uidx
ON bookmarks (canonical_url) WHERE archived = 0;
CREATE INDEX IF NOT EXISTS bookmarks_last_seen_idx ON bookmarks (last_seen_at DESC);
CREATE INDEX IF NOT EXISTS bookmarks_collection_idx ON bookmarks (collection);
CREATE TABLE IF NOT EXISTS tags (
bookmark_id TEXT NOT NULL,
tag TEXT NOT NULL,
PRIMARY KEY (bookmark_id, tag),
FOREIGN KEY (bookmark_id) REFERENCES bookmarks(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS tags_tag_idx ON tags (tag);
"#;
pub const MAX_SUPPORTED_VERSION: i64 = 1;
pub fn migrate(conn: &Connection) -> Result<usize, CoreError> {
let current = current_user_version(conn)?;
if current > MAX_SUPPORTED_VERSION {
return Err(CoreError::Storage(format!(
"newer schema detected: db version {current} > supported {MAX_SUPPORTED_VERSION}. \
upgrade LinkMarks before opening this store"
)));
}
let mut applied = 0usize;
let tx = conn
.unchecked_transaction()
.map_err(|e| CoreError::Storage(format!("begin tx: {e}")))?;
for mig in MIGRATIONS {
if mig.version <= current {
continue;
}
tx.execute_batch(mig.sql)
.map_err(|e| CoreError::Storage(format!("migration v{}: {e}", mig.version)))?;
let applied_at = unix_now_secs();
tx.execute(
"INSERT OR IGNORE INTO schema_migrations (version, applied_at, description) \
VALUES (?1, ?2, ?3)",
rusqlite::params![mig.version, applied_at, mig.description],
)
.map_err(|e| CoreError::Storage(format!("record migration v{}: {e}", mig.version)))?;
applied += 1;
}
if applied > 0 {
let final_version = MIGRATIONS.last().expect("non-empty MIGRATIONS").version;
tx.pragma_update(None, "user_version", final_version)
.map_err(|e| CoreError::Storage(format!("set user_version: {e}")))?;
}
tx.commit()
.map_err(|e| CoreError::Storage(format!("commit migrations: {e}")))?;
Ok(applied)
}
pub fn current_user_version(conn: &Connection) -> Result<i64, CoreError> {
conn.query_row("PRAGMA user_version", [], |row| row.get::<_, i64>(0))
.map_err(|e| CoreError::Storage(format!("read user_version: {e}")))
}
pub fn applied_versions(conn: &Connection) -> Result<Vec<i64>, CoreError> {
let mut stmt = conn
.prepare("SELECT version FROM schema_migrations ORDER BY version ASC")
.map_err(|e| CoreError::Storage(format!("prepare applied_versions: {e}")))?;
let rows = stmt
.query_map([], |row| row.get::<_, i64>(0))
.map_err(|e| CoreError::Storage(format!("query applied_versions: {e}")))?;
let mut out = Vec::new();
for r in rows {
out.push(r.map_err(|e| CoreError::Storage(format!("decode version: {e}")))?);
}
Ok(out)
}
#[inline]
fn unix_now_secs() -> i64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs() as i64)
.unwrap_or(0)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::storage;
#[test]
fn fresh_db_runs_migration_001() {
let conn = storage::open_in_memory().unwrap();
assert_eq!(current_user_version(&conn).unwrap(), 0);
let applied = migrate(&conn).unwrap();
assert_eq!(applied, 1);
assert_eq!(current_user_version(&conn).unwrap(), 1);
assert_eq!(applied_versions(&conn).unwrap(), vec![1]);
}
#[test]
fn migrate_is_idempotent_on_up_to_date_db() {
let conn = storage::open_in_memory().unwrap();
migrate(&conn).unwrap();
let applied = migrate(&conn).unwrap();
assert_eq!(applied, 0, "second run must be a no-op");
assert_eq!(current_user_version(&conn).unwrap(), 1);
}
#[test]
fn reject_db_newer_than_supported() {
let conn = storage::open_in_memory().unwrap();
conn.pragma_update(None, "user_version", MAX_SUPPORTED_VERSION + 5)
.unwrap();
let err = migrate(&conn).unwrap_err();
match err {
CoreError::Storage(msg) => assert!(
msg.contains("newer schema"),
"expected 'newer schema' error, got: {msg}"
),
other => panic!("unexpected error variant: {other:?}"),
}
}
}