agent: area A commit 1 — typed magnotia_storage::Error + boundary

Commit 1 of 2 for engine-slop residuals Area A.

Adds a typed `magnotia_storage::Error` enum and rewires every error
construction site in the storage crate to use it. The crate boundary
into `magnotia_core::error::MagnotiaError` is handled by a
`From<storage::Error> for MagnotiaError` impl that lives inside the
storage crate (not in core) to avoid a `core -> storage` dependency
cycle. The old `MagnotiaError::StorageError(String)` variant is kept
in this commit as an unused placeholder; commit 2 deletes it.

New types in crates/storage/src/error.rs:
- pub enum Error { DatabaseOpen, Migration, Query, NotFound,
  InvalidReference, Filesystem } with #[derive(thiserror::Error)]
- pub enum OpenOp { Connect, ReadOnlyConnect, ForeignKeysPragma }
- pub enum MigrationStep { SchemaVersionTableCreate, SchemaVersionQuery,
  TxBegin, Apply, RecordVersion, Commit }
- pub enum Entity { Transcript, Task, Profile, ImplementationRule,
  Feedback } — seeded only with entities that have a real
  NotFound/InvalidReference case in the codebase
- pub type Result<T> = std::result::Result<T, Error>
- impl Error { pub fn kind() -> StorageKind, pub fn
  operation_label() -> Cow<'static, str> }
- impl From<Error> for MagnotiaError

New types in crates/core/src/error.rs:
- MagnotiaError::Storage { kind: StorageKind, operation: String,
  detail: String } with #[error("{detail}")] so the boundary doesn't
  double-prefix Display output
- pub enum StorageKind { DatabaseOpen, Migration, Query, NotFound,
  InvalidReference, Filesystem } #[derive(Serialize)]
  #[serde(rename_all = "snake_case")] for future Area E

Storage crate dependency added: thiserror = "1"

Migration scope:
- migrations.rs: 6 sites → Error::Migration with structured step + version
- database.rs: 72 sites broken down as:
  * 3  → Error::DatabaseOpen (init / readonly / pragma)
  * 1  → Error::Filesystem (init's create_dir_all with path context)
  * ~58 → Error::Query with operation labels matching the prior message
        stem in snake_case; transaction sub-steps use dotted labels
        ("complete_subtask_and_check_parent.commit_transaction" etc.)
        so transaction internals are not collapsed
  * 5  → Error::NotFound (transcript / task×2 / implementation_rule×2)
  * 5  → Error::InvalidReference (insert_transcript unknown profile;
        Default profile rename/delete invariants ×3; feedback rating
        value validation)

Survey ambiguities resolved per the locked answers:
- Q1 (schema_version_query): Migration step, not Query
- Q2 (transaction sub-steps): preserved granularity with dotted operation
  labels; no collapsing
- Q3 (Entity cardinality): seeded with the 3 from the survey + 2 more
  discovered during migration (ImplementationRule, Feedback), per
  "add when an actual case needs it"
- Q4 (operation label type): Cow<'static, str>
- Q5 (Serialize): storage::Error is not serializable; flattens only at
  the MagnotiaError boundary
- Q6 (re-exports): pub use error::{Entity, Error, MigrationStep, OpenOp,
  Result} in storage::lib.rs; StorageKind belongs to magnotia_core

Two scope-discovered additions beyond the original 5 variants:
- Error::Filesystem { path, source } variant + matching StorageKind —
  required because init() now returns storage::Result<SqlitePool> and
  the create_dir_all call needs a typed path; doing this via
  From<io::Error> for storage::Error would have lost the path so it's
  explicit
- Entity::ImplementationRule and Entity::Feedback — two NotFound /
  InvalidReference sites the original survey missed in the rule-CRUD
  and feedback-validation areas

Verification:
- cargo fmt --all
- cargo check --workspace --all-targets — clean
- cargo test -p magnotia-storage — 60 passed, 0 failed
- cargo test --workspace — all green, ~330 tests
- rg 'StorageError\(' crates/ src-tauri/src/ — only hit is the variant
  declaration in core that commit 2 will delete
- rg 'Other\(String' crates/storage/src/ — zero
- rg 'format!\("[A-Z].* failed' crates/storage/src/ — zero

Commit 2 will delete MagnotiaError::StorageError(String) once this is
in. Pre-existing working-tree churn in crates/llm/, src/lib/pages/,
src/lib/utils/saveMarkdown.ts and the untracked phase10a dogfood notes
deliberately left unstaged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-12 22:57:17 +01:00
parent fdab77776c
commit 52565ea8b8
7 changed files with 550 additions and 137 deletions

1
Cargo.lock generated
View File

@@ -2924,6 +2924,7 @@ dependencies = [
"magnotia-core", "magnotia-core",
"serde", "serde",
"sqlx", "sqlx",
"thiserror 1.0.69",
"tokio", "tokio",
"uuid", "uuid",
] ]

View File

@@ -37,6 +37,16 @@ pub enum MagnotiaError {
#[error("storage error: {0}")] #[error("storage error: {0}")]
StorageError(String), StorageError(String),
/// Structured storage failure flowed up from `magnotia_storage::Error` via
/// its `From` impl. Display reads through to `detail` so the operation +
/// source context produced by the storage crate isn't double-prefixed.
#[error("{detail}")]
Storage {
kind: StorageKind,
operation: String,
detail: String,
},
#[error("provider not registered: {0}")] #[error("provider not registered: {0}")]
ProviderNotRegistered(String), ProviderNotRegistered(String),
@@ -48,6 +58,21 @@ pub enum MagnotiaError {
}, },
} }
/// Coarse discriminator for `MagnotiaError::Storage`. The storage crate maps
/// its full typed error onto one of these kinds at the boundary. Backend code
/// that wants finer-grained branching pattern-matches on
/// `magnotia_storage::Error` directly.
#[derive(Debug, Clone, Copy, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum StorageKind {
DatabaseOpen,
Migration,
Query,
NotFound,
InvalidReference,
Filesystem,
}
impl From<std::io::Error> for MagnotiaError { impl From<std::io::Error> for MagnotiaError {
fn from(err: std::io::Error) -> Self { fn from(err: std::io::Error) -> Self {
Self::Io { Self::Io {

View File

@@ -24,5 +24,8 @@ serde = { version = "1", features = ["derive"] }
# Logging # Logging
log = "0.4" log = "0.4"
# Structured error derivation for magnotia_storage::Error.
thiserror = "1"
# UUIDs for profile + profile_terms ids (v7 random). # UUIDs for profile + profile_terms ids (v7 random).
uuid = { version = "1", features = ["v4"] } uuid = { version = "1", features = ["v4"] }

View File

@@ -3,12 +3,15 @@ use std::path::Path;
use sqlx::sqlite::{SqliteConnectOptions, SqlitePoolOptions}; use sqlx::sqlite::{SqliteConnectOptions, SqlitePoolOptions};
use sqlx::{Row, SqlitePool}; use sqlx::{Row, SqlitePool};
use magnotia_core::error::{MagnotiaError, Result}; use crate::error::{Entity, Error, OpenOp, Result};
/// Initialise the SQLite database with connection pool and run migrations. /// Initialise the SQLite database with connection pool and run migrations.
pub async fn init(db_path: &Path) -> Result<SqlitePool> { pub async fn init(db_path: &Path) -> Result<SqlitePool> {
if let Some(parent) = db_path.parent() { if let Some(parent) = db_path.parent() {
std::fs::create_dir_all(parent)?; std::fs::create_dir_all(parent).map_err(|source| Error::Filesystem {
path: parent.to_path_buf(),
source,
})?;
} }
let options = SqliteConnectOptions::new() let options = SqliteConnectOptions::new()
@@ -19,12 +22,18 @@ pub async fn init(db_path: &Path) -> Result<SqlitePool> {
.max_connections(5) .max_connections(5)
.connect_with(options) .connect_with(options)
.await .await
.map_err(|e| MagnotiaError::StorageError(format!("Database connect failed: {e}")))?; .map_err(|source| Error::DatabaseOpen {
operation: OpenOp::Connect,
source,
})?;
sqlx::query("PRAGMA foreign_keys = ON") sqlx::query("PRAGMA foreign_keys = ON")
.execute(&pool) .execute(&pool)
.await .await
.map_err(|e| MagnotiaError::StorageError(format!("foreign_keys pragma failed: {e}")))?; .map_err(|source| Error::DatabaseOpen {
operation: OpenOp::ForeignKeysPragma,
source,
})?;
run_migrations(&pool).await?; run_migrations(&pool).await?;
@@ -47,7 +56,10 @@ pub async fn init_readonly(db_path: &Path) -> Result<SqlitePool> {
.max_connections(2) .max_connections(2)
.connect_with(options) .connect_with(options)
.await .await
.map_err(|e| MagnotiaError::StorageError(format!("Read-only connect failed: {e}"))) .map_err(|source| Error::DatabaseOpen {
operation: OpenOp::ReadOnlyConnect,
source,
})
} }
/// Run schema migrations via the versioned migration system. /// Run schema migrations via the versioned migration system.
@@ -82,10 +94,10 @@ pub async fn insert_transcript(
params: &InsertTranscriptParams<'_>, params: &InsertTranscriptParams<'_>,
) -> Result<()> { ) -> Result<()> {
if !profile_exists(pool, params.profile_id).await? { if !profile_exists(pool, params.profile_id).await? {
return Err(MagnotiaError::StorageError(format!( return Err(Error::InvalidReference {
"Insert transcript failed: unknown profile id '{}'", entity: Entity::Profile,
params.profile_id reason: format!("unknown profile id '{}'", params.profile_id).into(),
))); });
} }
sqlx::query( sqlx::query(
@@ -110,7 +122,10 @@ pub async fn insert_transcript(
.bind(params.anti_hallucination) .bind(params.anti_hallucination)
.execute(pool) .execute(pool)
.await .await
.map_err(|e| MagnotiaError::StorageError(format!("Insert transcript failed: {e}")))?; .map_err(|source| Error::Query {
operation: "insert_transcript".into(),
source,
})?;
Ok(()) Ok(())
} }
@@ -121,7 +136,10 @@ pub async fn get_transcript(pool: &SqlitePool, id: &str) -> Result<Option<Transc
.bind(id) .bind(id)
.fetch_optional(pool) .fetch_optional(pool)
.await .await
.map_err(|e| MagnotiaError::StorageError(format!("Get transcript failed: {e}")))?; .map_err(|source| Error::Query {
operation: "get_transcript".into(),
source,
})?;
Ok(row.map(|r| transcript_row_from(&r))) Ok(row.map(|r| transcript_row_from(&r)))
} }
@@ -144,7 +162,10 @@ pub async fn list_transcripts_paged(
.bind(offset) .bind(offset)
.fetch_all(pool) .fetch_all(pool)
.await .await
.map_err(|e| MagnotiaError::StorageError(format!("List transcripts failed: {e}")))?; .map_err(|source| Error::Query {
operation: "list_transcripts".into(),
source,
})?;
Ok(rows.iter().map(transcript_row_from).collect()) Ok(rows.iter().map(transcript_row_from).collect())
} }
@@ -154,7 +175,10 @@ pub async fn count_transcripts(pool: &SqlitePool) -> Result<i64> {
let n: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM transcripts") let n: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM transcripts")
.fetch_one(pool) .fetch_one(pool)
.await .await
.map_err(|e| MagnotiaError::StorageError(format!("Count transcripts failed: {e}")))?; .map_err(|source| Error::Query {
operation: "count_transcripts".into(),
source,
})?;
Ok(n) Ok(n)
} }
@@ -182,8 +206,9 @@ pub async fn update_transcript(
.bind(id) .bind(id)
.execute(pool) .execute(pool)
.await .await
.map_err(|e| { .map_err(|source| Error::Query {
MagnotiaError::StorageError(format!("Update transcript failed: {e}")) operation: "update_transcript".into(),
source,
})?; })?;
Ok(res.rows_affected()) Ok(res.rows_affected())
} }
@@ -193,8 +218,9 @@ pub async fn update_transcript(
.bind(id) .bind(id)
.execute(pool) .execute(pool)
.await .await
.map_err(|e| { .map_err(|source| Error::Query {
MagnotiaError::StorageError(format!("Update transcript failed: {e}")) operation: "update_transcript".into(),
source,
})?; })?;
Ok(res.rows_affected()) Ok(res.rows_affected())
} }
@@ -204,8 +230,9 @@ pub async fn update_transcript(
.bind(id) .bind(id)
.execute(pool) .execute(pool)
.await .await
.map_err(|e| { .map_err(|source| Error::Query {
MagnotiaError::StorageError(format!("Update transcript failed: {e}")) operation: "update_transcript".into(),
source,
})?; })?;
Ok(res.rows_affected()) Ok(res.rows_affected())
} }
@@ -253,10 +280,16 @@ pub async fn update_transcript_meta(
.bind(id) .bind(id)
.execute(pool) .execute(pool)
.await .await
.map_err(|e| MagnotiaError::StorageError(format!("update_transcript_meta: {e}")))?; .map_err(|source| Error::Query {
operation: "update_transcript_meta".into(),
source,
})?;
get_transcript(pool, id).await?.ok_or_else(|| { get_transcript(pool, id)
MagnotiaError::StorageError(format!("update_transcript_meta: transcript {id} not found")) .await?
.ok_or_else(|| Error::NotFound {
entity: Entity::Transcript,
key: id.to_string(),
}) })
} }
@@ -265,7 +298,10 @@ pub async fn delete_transcript(pool: &SqlitePool, id: &str) -> Result<()> {
.bind(id) .bind(id)
.execute(pool) .execute(pool)
.await .await
.map_err(|e| MagnotiaError::StorageError(format!("Delete transcript failed: {e}")))?; .map_err(|source| Error::Query {
operation: "delete_transcript".into(),
source,
})?;
Ok(()) Ok(())
} }
@@ -289,7 +325,10 @@ pub async fn search_transcripts(
.bind(limit) .bind(limit)
.fetch_all(pool) .fetch_all(pool)
.await .await
.map_err(|e| MagnotiaError::StorageError(format!("FTS search failed: {e}")))?; .map_err(|source| Error::Query {
operation: "search_transcripts".into(),
source,
})?;
Ok(rows.iter().map(transcript_row_from).collect()) Ok(rows.iter().map(transcript_row_from).collect())
} }
@@ -327,7 +366,10 @@ pub async fn insert_task(
.bind(energy) .bind(energy)
.execute(pool) .execute(pool)
.await .await
.map_err(|e| MagnotiaError::StorageError(format!("Insert task failed: {e}")))?; .map_err(|source| Error::Query {
operation: "insert_task".into(),
source,
})?;
Ok(()) Ok(())
} }
@@ -339,7 +381,10 @@ pub async fn list_tasks(pool: &SqlitePool) -> Result<Vec<TaskRow>> {
) )
.fetch_all(pool) .fetch_all(pool)
.await .await
.map_err(|e| MagnotiaError::StorageError(format!("List tasks failed: {e}")))?; .map_err(|source| Error::Query {
operation: "list_tasks".into(),
source,
})?;
Ok(rows.into_iter().map(task_row_from).collect()) Ok(rows.into_iter().map(task_row_from).collect())
} }
@@ -352,7 +397,10 @@ pub async fn get_task_by_id(pool: &SqlitePool, id: &str) -> Result<Option<TaskRo
.bind(id) .bind(id)
.fetch_optional(pool) .fetch_optional(pool)
.await .await
.map_err(|e| MagnotiaError::StorageError(format!("Get task failed: {e}")))?; .map_err(|source| Error::Query {
operation: "get_task_by_id".into(),
source,
})?;
Ok(row.map(task_row_from)) Ok(row.map(task_row_from))
} }
@@ -390,10 +438,16 @@ pub async fn update_task(
.bind(id) .bind(id)
.execute(pool) .execute(pool)
.await .await
.map_err(|e| MagnotiaError::StorageError(format!("Update task failed: {e}")))?; .map_err(|source| Error::Query {
operation: "update_task".into(),
source,
})?;
get_task_by_id(pool, id).await?.ok_or_else(|| { get_task_by_id(pool, id)
MagnotiaError::StorageError(format!("update_task: task {id} not found after update")) .await?
.ok_or_else(|| Error::NotFound {
entity: Entity::Task,
key: id.to_string(),
}) })
} }
@@ -411,10 +465,16 @@ pub async fn set_task_energy(pool: &SqlitePool, id: &str, energy: Option<&str>)
.bind(id) .bind(id)
.execute(pool) .execute(pool)
.await .await
.map_err(|e| MagnotiaError::StorageError(format!("set_task_energy failed: {e}")))?; .map_err(|source| Error::Query {
operation: "set_task_energy".into(),
source,
})?;
get_task_by_id(pool, id).await?.ok_or_else(|| { get_task_by_id(pool, id)
MagnotiaError::StorageError(format!("set_task_energy: task {id} not found after update")) .await?
.ok_or_else(|| Error::NotFound {
entity: Entity::Task,
key: id.to_string(),
}) })
} }
@@ -430,7 +490,10 @@ pub async fn insert_subtask(
.bind(parent_task_id) .bind(parent_task_id)
.execute(pool) .execute(pool)
.await .await
.map_err(|e| MagnotiaError::StorageError(format!("Insert subtask failed: {e}")))?; .map_err(|source| Error::Query {
operation: "insert_subtask".into(),
source,
})?;
Ok(()) Ok(())
} }
@@ -443,7 +506,10 @@ pub async fn list_subtasks(pool: &SqlitePool, parent_id: &str) -> Result<Vec<Tas
.bind(parent_id) .bind(parent_id)
.fetch_all(pool) .fetch_all(pool)
.await .await
.map_err(|e| MagnotiaError::StorageError(format!("List subtasks failed: {e}")))?; .map_err(|source| Error::Query {
operation: "list_subtasks".into(),
source,
})?;
Ok(rows.into_iter().map(task_row_from).collect()) Ok(rows.into_iter().map(task_row_from).collect())
} }
@@ -451,23 +517,29 @@ pub async fn list_subtasks(pool: &SqlitePool, parent_id: &str) -> Result<Vec<Tas
/// Mark a subtask done. If all siblings are now done, auto-complete the parent. /// Mark a subtask done. If all siblings are now done, auto-complete the parent.
/// Runs in a transaction so concurrent completions see consistent sibling counts. /// Runs in a transaction so concurrent completions see consistent sibling counts.
pub async fn complete_subtask_and_check_parent(pool: &SqlitePool, subtask_id: &str) -> Result<()> { pub async fn complete_subtask_and_check_parent(pool: &SqlitePool, subtask_id: &str) -> Result<()> {
let mut tx = pool let mut tx = pool.begin().await.map_err(|source| Error::Query {
.begin() operation: "complete_subtask_and_check_parent.begin_transaction".into(),
.await source,
.map_err(|e| MagnotiaError::StorageError(format!("Begin transaction failed: {e}")))?; })?;
sqlx::query("UPDATE tasks SET done = 1, done_at = datetime('now') WHERE id = ?") sqlx::query("UPDATE tasks SET done = 1, done_at = datetime('now') WHERE id = ?")
.bind(subtask_id) .bind(subtask_id)
.execute(&mut *tx) .execute(&mut *tx)
.await .await
.map_err(|e| MagnotiaError::StorageError(format!("Complete subtask failed: {e}")))?; .map_err(|source| Error::Query {
operation: "complete_subtask_and_check_parent.complete_subtask".into(),
source,
})?;
let parent_id: Option<String> = let parent_id: Option<String> =
sqlx::query_scalar("SELECT parent_task_id FROM tasks WHERE id = ?") sqlx::query_scalar("SELECT parent_task_id FROM tasks WHERE id = ?")
.bind(subtask_id) .bind(subtask_id)
.fetch_one(&mut *tx) .fetch_one(&mut *tx)
.await .await
.map_err(|e| MagnotiaError::StorageError(format!("Get parent_task_id failed: {e}")))?; .map_err(|source| Error::Query {
operation: "complete_subtask_and_check_parent.get_parent_task_id".into(),
source,
})?;
if let Some(pid) = parent_id { if let Some(pid) = parent_id {
let pending: i64 = let pending: i64 =
@@ -475,8 +547,9 @@ pub async fn complete_subtask_and_check_parent(pool: &SqlitePool, subtask_id: &s
.bind(&pid) .bind(&pid)
.fetch_one(&mut *tx) .fetch_one(&mut *tx)
.await .await
.map_err(|e| { .map_err(|source| Error::Query {
MagnotiaError::StorageError(format!("Count pending subtasks failed: {e}")) operation: "complete_subtask_and_check_parent.count_pending_subtasks".into(),
source,
})?; })?;
if pending == 0 { if pending == 0 {
@@ -490,15 +563,17 @@ pub async fn complete_subtask_and_check_parent(pool: &SqlitePool, subtask_id: &s
.bind(&pid) .bind(&pid)
.execute(&mut *tx) .execute(&mut *tx)
.await .await
.map_err(|e| { .map_err(|source| Error::Query {
MagnotiaError::StorageError(format!("Auto-complete parent failed: {e}")) operation: "complete_subtask_and_check_parent.auto_complete_parent".into(),
source,
})?; })?;
} }
} }
tx.commit() tx.commit().await.map_err(|source| Error::Query {
.await operation: "complete_subtask_and_check_parent.commit_transaction".into(),
.map_err(|e| MagnotiaError::StorageError(format!("Commit transaction failed: {e}")))?; source,
})?;
Ok(()) Ok(())
} }
@@ -508,21 +583,27 @@ pub async fn complete_task(pool: &SqlitePool, id: &str) -> Result<()> {
.bind(id) .bind(id)
.execute(pool) .execute(pool)
.await .await
.map_err(|e| MagnotiaError::StorageError(format!("Complete task failed: {e}")))?; .map_err(|source| Error::Query {
operation: "complete_task".into(),
source,
})?;
Ok(()) Ok(())
} }
pub async fn uncomplete_task(pool: &SqlitePool, id: &str) -> Result<()> { pub async fn uncomplete_task(pool: &SqlitePool, id: &str) -> Result<()> {
let mut tx = pool let mut tx = pool.begin().await.map_err(|source| Error::Query {
.begin() operation: "uncomplete_task.begin_transaction".into(),
.await source,
.map_err(|e| MagnotiaError::StorageError(format!("Begin transaction failed: {e}")))?; })?;
sqlx::query("UPDATE tasks SET done = 0, done_at = NULL, auto_completed = 0 WHERE id = ?") sqlx::query("UPDATE tasks SET done = 0, done_at = NULL, auto_completed = 0 WHERE id = ?")
.bind(id) .bind(id)
.execute(&mut *tx) .execute(&mut *tx)
.await .await
.map_err(|e| MagnotiaError::StorageError(format!("Uncomplete task failed: {e}")))?; .map_err(|source| Error::Query {
operation: "uncomplete_task.uncomplete_row".into(),
source,
})?;
// Mirror the auto-complete invariant from // Mirror the auto-complete invariant from
// `complete_subtask_and_check_parent`: a parent task is done iff // `complete_subtask_and_check_parent`: a parent task is done iff
@@ -534,7 +615,10 @@ pub async fn uncomplete_task(pool: &SqlitePool, id: &str) -> Result<()> {
.bind(id) .bind(id)
.fetch_optional(&mut *tx) .fetch_optional(&mut *tx)
.await .await
.map_err(|e| MagnotiaError::StorageError(format!("Get parent_task_id failed: {e}")))? .map_err(|source| Error::Query {
operation: "uncomplete_task.get_parent_task_id".into(),
source,
})?
.flatten(); .flatten();
if let Some(pid) = parent_id { if let Some(pid) = parent_id {
@@ -545,12 +629,16 @@ pub async fn uncomplete_task(pool: &SqlitePool, id: &str) -> Result<()> {
.bind(&pid) .bind(&pid)
.execute(&mut *tx) .execute(&mut *tx)
.await .await
.map_err(|e| MagnotiaError::StorageError(format!("Reopen parent failed: {e}")))?; .map_err(|source| Error::Query {
operation: "uncomplete_task.reopen_parent".into(),
source,
})?;
} }
tx.commit() tx.commit().await.map_err(|source| Error::Query {
.await operation: "uncomplete_task.commit_transaction".into(),
.map_err(|e| MagnotiaError::StorageError(format!("Commit transaction failed: {e}")))?; source,
})?;
Ok(()) Ok(())
} }
@@ -560,7 +648,10 @@ pub async fn delete_task(pool: &SqlitePool, id: &str) -> Result<()> {
.bind(id) .bind(id)
.execute(pool) .execute(pool)
.await .await
.map_err(|e| MagnotiaError::StorageError(format!("Delete task failed: {e}")))?; .map_err(|source| Error::Query {
operation: "delete_task".into(),
source,
})?;
Ok(()) Ok(())
} }
@@ -607,7 +698,10 @@ pub async fn list_recent_completions(
.bind(format!("-{} days", days - 1)) .bind(format!("-{} days", days - 1))
.fetch_all(pool) .fetch_all(pool)
.await .await
.map_err(|e| MagnotiaError::StorageError(format!("List recent completions failed: {e}")))?; .map_err(|source| Error::Query {
operation: "list_recent_completions.fetch_grouped".into(),
source,
})?;
let lookup: std::collections::HashMap<String, u32> = rows let lookup: std::collections::HashMap<String, u32> = rows
.into_iter() .into_iter()
@@ -618,7 +712,10 @@ pub async fn list_recent_completions(
let today_row: (String,) = sqlx::query_as("SELECT DATE('now', 'localtime')") let today_row: (String,) = sqlx::query_as("SELECT DATE('now', 'localtime')")
.fetch_one(pool) .fetch_one(pool)
.await .await
.map_err(|e| MagnotiaError::StorageError(format!("Get local today failed: {e}")))?; .map_err(|source| Error::Query {
operation: "list_recent_completions.get_local_today".into(),
source,
})?;
let today = today_row.0; let today = today_row.0;
let mut series = Vec::with_capacity(days as usize); let mut series = Vec::with_capacity(days as usize);
@@ -630,7 +727,10 @@ pub async fn list_recent_completions(
.bind(format!("-{offset} days")) .bind(format!("-{offset} days"))
.fetch_one(pool) .fetch_one(pool)
.await .await
.map_err(|e| MagnotiaError::StorageError(format!("Compute spine day failed: {e}")))?; .map_err(|source| Error::Query {
operation: "list_recent_completions.compute_spine_day".into(),
source,
})?;
let count = lookup.get(&day).copied().unwrap_or(0); let count = lookup.get(&day).copied().unwrap_or(0);
series.push(DailyCompletionCount { day, count }); series.push(DailyCompletionCount { day, count });
} }
@@ -663,12 +763,16 @@ pub async fn insert_implementation_rule(
.bind(last_fired_key) .bind(last_fired_key)
.execute(pool) .execute(pool)
.await .await
.map_err(|e| MagnotiaError::StorageError(format!("Insert implementation rule failed: {e}")))?; .map_err(|source| Error::Query {
operation: "insert_implementation_rule".into(),
source,
})?;
get_implementation_rule(pool, id).await?.ok_or_else(|| { get_implementation_rule(pool, id)
MagnotiaError::StorageError(format!( .await?
"insert_implementation_rule: rule {id} not found after insert" .ok_or_else(|| Error::NotFound {
)) entity: Entity::ImplementationRule,
key: id.to_string(),
}) })
} }
@@ -680,7 +784,10 @@ pub async fn list_implementation_rules(pool: &SqlitePool) -> Result<Vec<Implemen
) )
.fetch_all(pool) .fetch_all(pool)
.await .await
.map_err(|e| MagnotiaError::StorageError(format!("List implementation rules failed: {e}")))?; .map_err(|source| Error::Query {
operation: "list_implementation_rules".into(),
source,
})?;
Ok(rows.into_iter().map(implementation_rule_row_from).collect()) Ok(rows.into_iter().map(implementation_rule_row_from).collect())
} }
@@ -697,7 +804,10 @@ pub async fn get_implementation_rule(
.bind(id) .bind(id)
.fetch_optional(pool) .fetch_optional(pool)
.await .await
.map_err(|e| MagnotiaError::StorageError(format!("Get implementation rule failed: {e}")))?; .map_err(|source| Error::Query {
operation: "get_implementation_rule".into(),
source,
})?;
Ok(row.map(implementation_rule_row_from)) Ok(row.map(implementation_rule_row_from))
} }
@@ -716,14 +826,16 @@ pub async fn set_implementation_rule_enabled(
.bind(id) .bind(id)
.execute(pool) .execute(pool)
.await .await
.map_err(|e| { .map_err(|source| Error::Query {
MagnotiaError::StorageError(format!("Set implementation rule enabled failed: {e}")) operation: "set_implementation_rule_enabled".into(),
source,
})?; })?;
get_implementation_rule(pool, id).await?.ok_or_else(|| { get_implementation_rule(pool, id)
MagnotiaError::StorageError(format!( .await?
"set_implementation_rule_enabled: rule {id} not found after update" .ok_or_else(|| Error::NotFound {
)) entity: Entity::ImplementationRule,
key: id.to_string(),
}) })
} }
@@ -741,14 +853,16 @@ pub async fn mark_implementation_rule_fired(
.bind(id) .bind(id)
.execute(pool) .execute(pool)
.await .await
.map_err(|e| { .map_err(|source| Error::Query {
MagnotiaError::StorageError(format!("Mark implementation rule fired failed: {e}")) operation: "mark_implementation_rule_fired".into(),
source,
})?; })?;
get_implementation_rule(pool, id).await?.ok_or_else(|| { get_implementation_rule(pool, id)
MagnotiaError::StorageError(format!( .await?
"mark_implementation_rule_fired: rule {id} not found after update" .ok_or_else(|| Error::NotFound {
)) entity: Entity::ImplementationRule,
key: id.to_string(),
}) })
} }
@@ -757,8 +871,9 @@ pub async fn delete_implementation_rule(pool: &SqlitePool, id: &str) -> Result<(
.bind(id) .bind(id)
.execute(pool) .execute(pool)
.await .await
.map_err(|e| { .map_err(|source| Error::Query {
MagnotiaError::StorageError(format!("Delete implementation rule failed: {e}")) operation: "delete_implementation_rule".into(),
source,
})?; })?;
Ok(()) Ok(())
} }
@@ -771,7 +886,10 @@ pub async fn set_setting(pool: &SqlitePool, key: &str, value: &str) -> Result<()
.bind(value) .bind(value)
.execute(pool) .execute(pool)
.await .await
.map_err(|e| MagnotiaError::StorageError(format!("Set setting failed: {e}")))?; .map_err(|source| Error::Query {
operation: "set_setting".into(),
source,
})?;
Ok(()) Ok(())
} }
@@ -780,7 +898,10 @@ pub async fn get_setting(pool: &SqlitePool, key: &str) -> Result<Option<String>>
.bind(key) .bind(key)
.fetch_optional(pool) .fetch_optional(pool)
.await .await
.map_err(|e| MagnotiaError::StorageError(format!("Get setting failed: {e}")))?; .map_err(|source| Error::Query {
operation: "get_setting".into(),
source,
})?;
Ok(row.map(|r| r.get("value"))) Ok(row.map(|r| r.get("value")))
} }
@@ -958,15 +1079,19 @@ fn implementation_rule_row_from(r: sqlx::sqlite::SqliteRow) -> ImplementationRul
// 1. The DB triggers `trg_protect_default_profile_delete` / // 1. The DB triggers `trg_protect_default_profile_delete` /
// `trg_protect_default_profile_rename` from migration v6. // `trg_protect_default_profile_rename` from migration v6.
// 2. Rust-layer fail-fast checks below that short-circuit BEFORE the // 2. Rust-layer fail-fast checks below that short-circuit BEFORE the
// query hits sqlite, so UI callers get a friendly MagnotiaError::StorageError // query hits sqlite, so UI callers get a friendly typed
// instead of an opaque `SQLITE_CONSTRAINT_TRIGGER` wrapped in text. // `Error::InvalidReference` instead of an opaque
// `SQLITE_CONSTRAINT_TRIGGER` wrapped in text.
pub async fn list_profiles(pool: &SqlitePool) -> Result<Vec<ProfileRow>> { pub async fn list_profiles(pool: &SqlitePool) -> Result<Vec<ProfileRow>> {
let rows = let rows =
sqlx::query("SELECT id, name, initial_prompt, created_at FROM profiles ORDER BY name ASC") sqlx::query("SELECT id, name, initial_prompt, created_at FROM profiles ORDER BY name ASC")
.fetch_all(pool) .fetch_all(pool)
.await .await
.map_err(|e| MagnotiaError::StorageError(format!("List profiles failed: {e}")))?; .map_err(|source| Error::Query {
operation: "list_profiles".into(),
source,
})?;
Ok(rows.iter().map(profile_row_from).collect()) Ok(rows.iter().map(profile_row_from).collect())
} }
@@ -975,7 +1100,10 @@ pub async fn get_profile(pool: &SqlitePool, id: &str) -> Result<Option<ProfileRo
.bind(id) .bind(id)
.fetch_optional(pool) .fetch_optional(pool)
.await .await
.map_err(|e| MagnotiaError::StorageError(format!("Get profile failed: {e}")))?; .map_err(|source| Error::Query {
operation: "get_profile".into(),
source,
})?;
Ok(row.as_ref().map(profile_row_from)) Ok(row.as_ref().map(profile_row_from))
} }
@@ -995,7 +1123,10 @@ pub async fn create_profile(
.bind(initial_prompt) .bind(initial_prompt)
.fetch_one(pool) .fetch_one(pool)
.await .await
.map_err(|e| MagnotiaError::StorageError(format!("Create profile failed: {e}")))?; .map_err(|source| Error::Query {
operation: "create_profile".into(),
source,
})?;
Ok(profile_row_from(&row)) Ok(profile_row_from(&row))
} }
@@ -1013,14 +1144,16 @@ pub async fn update_profile(
initial_prompt: &str, initial_prompt: &str,
) -> Result<()> { ) -> Result<()> {
if id == crate::DEFAULT_PROFILE_ID && name != "Default" { if id == crate::DEFAULT_PROFILE_ID && name != "Default" {
return Err(MagnotiaError::StorageError( return Err(Error::InvalidReference {
"Default profile cannot be renamed".into(), entity: Entity::Profile,
)); reason: "Default profile cannot be renamed".into(),
});
} }
if id != crate::DEFAULT_PROFILE_ID && name == "Default" { if id != crate::DEFAULT_PROFILE_ID && name == "Default" {
return Err(MagnotiaError::StorageError( return Err(Error::InvalidReference {
"Cannot rename another profile to 'Default'".into(), entity: Entity::Profile,
)); reason: "cannot rename another profile to 'Default'".into(),
});
} }
sqlx::query("UPDATE profiles SET name = ?, initial_prompt = ? WHERE id = ?") sqlx::query("UPDATE profiles SET name = ?, initial_prompt = ? WHERE id = ?")
.bind(name) .bind(name)
@@ -1028,7 +1161,10 @@ pub async fn update_profile(
.bind(id) .bind(id)
.execute(pool) .execute(pool)
.await .await
.map_err(|e| MagnotiaError::StorageError(format!("Update profile failed: {e}")))?; .map_err(|source| Error::Query {
operation: "update_profile".into(),
source,
})?;
Ok(()) Ok(())
} }
@@ -1037,21 +1173,28 @@ pub async fn update_profile(
/// ON DELETE CASCADE on `profile_terms.profile_id` cleans up children. /// ON DELETE CASCADE on `profile_terms.profile_id` cleans up children.
pub async fn delete_profile(pool: &SqlitePool, id: &str) -> Result<()> { pub async fn delete_profile(pool: &SqlitePool, id: &str) -> Result<()> {
if id == crate::DEFAULT_PROFILE_ID { if id == crate::DEFAULT_PROFILE_ID {
return Err(MagnotiaError::StorageError( return Err(Error::InvalidReference {
"Default profile cannot be deleted".into(), entity: Entity::Profile,
)); reason: "Default profile cannot be deleted".into(),
});
} }
let transcript_count = transcript_count_for_profile(pool, id).await?; let transcript_count = transcript_count_for_profile(pool, id).await?;
if transcript_count > 0 { if transcript_count > 0 {
return Err(MagnotiaError::StorageError(format!( return Err(Error::InvalidReference {
"Cannot delete profile while {transcript_count} transcript(s) still reference it; reassign transcripts first" entity: Entity::Profile,
))); reason: format!(
"cannot delete profile while {transcript_count} transcript(s) still reference it; reassign transcripts first"
).into(),
});
} }
sqlx::query("DELETE FROM profiles WHERE id = ?") sqlx::query("DELETE FROM profiles WHERE id = ?")
.bind(id) .bind(id)
.execute(pool) .execute(pool)
.await .await
.map_err(|e| MagnotiaError::StorageError(format!("Delete profile failed: {e}")))?; .map_err(|source| Error::Query {
operation: "delete_profile".into(),
source,
})?;
Ok(()) Ok(())
} }
@@ -1066,7 +1209,10 @@ pub async fn list_profile_terms(
.bind(profile_id) .bind(profile_id)
.fetch_all(pool) .fetch_all(pool)
.await .await
.map_err(|e| MagnotiaError::StorageError(format!("List profile terms failed: {e}")))?; .map_err(|source| Error::Query {
operation: "list_profile_terms".into(),
source,
})?;
Ok(rows.iter().map(profile_term_row_from).collect()) Ok(rows.iter().map(profile_term_row_from).collect())
} }
@@ -1092,7 +1238,10 @@ pub async fn add_profile_term(
.bind(note) .bind(note)
.fetch_one(pool) .fetch_one(pool)
.await .await
.map_err(|e| MagnotiaError::StorageError(format!("Add profile term failed: {e}")))?; .map_err(|source| Error::Query {
operation: "add_profile_term".into(),
source,
})?;
Ok(profile_term_row_from(&row)) Ok(profile_term_row_from(&row))
} }
@@ -1101,7 +1250,10 @@ pub async fn delete_profile_term(pool: &SqlitePool, id: &str) -> Result<()> {
.bind(id) .bind(id)
.execute(pool) .execute(pool)
.await .await
.map_err(|e| MagnotiaError::StorageError(format!("Delete profile term failed: {e}")))?; .map_err(|source| Error::Query {
operation: "delete_profile_term".into(),
source,
})?;
Ok(()) Ok(())
} }
@@ -1110,7 +1262,10 @@ async fn profile_exists(pool: &SqlitePool, id: &str) -> Result<bool> {
.bind(id) .bind(id)
.fetch_optional(pool) .fetch_optional(pool)
.await .await
.map_err(|e| MagnotiaError::StorageError(format!("Profile existence check failed: {e}")))?; .map_err(|source| Error::Query {
operation: "profile_exists".into(),
source,
})?;
Ok(exists.is_some()) Ok(exists.is_some())
} }
@@ -1119,7 +1274,10 @@ async fn transcript_count_for_profile(pool: &SqlitePool, id: &str) -> Result<i64
.bind(id) .bind(id)
.fetch_one(pool) .fetch_one(pool)
.await .await
.map_err(|e| MagnotiaError::StorageError(format!("Profile transcript count failed: {e}"))) .map_err(|source| Error::Query {
operation: "transcript_count_for_profile".into(),
source,
})
} }
// --- Error Logging --- // --- Error Logging ---
@@ -1153,7 +1311,10 @@ pub async fn log_error(
.bind(metadata) .bind(metadata)
.execute(pool) .execute(pool)
.await .await
.map_err(|e| MagnotiaError::StorageError(format!("Error log failed: {e}")))?; .map_err(|source| Error::Query {
operation: "log_error".into(),
source,
})?;
Ok(()) Ok(())
} }
@@ -1185,7 +1346,10 @@ pub async fn prune_error_log(pool: &SqlitePool, keep_days: i64) -> Result<u64> {
.bind(format!("-{keep_days} days")) .bind(format!("-{keep_days} days"))
.execute(pool) .execute(pool)
.await .await
.map_err(|e| MagnotiaError::StorageError(format!("Prune error_log failed: {e}")))?; .map_err(|source| Error::Query {
operation: "prune_error_log".into(),
source,
})?;
Ok(result.rows_affected()) Ok(result.rows_affected())
} }
@@ -1197,7 +1361,10 @@ pub async fn list_recent_errors(pool: &SqlitePool, limit: i64) -> Result<Vec<Err
.bind(limit) .bind(limit)
.fetch_all(pool) .fetch_all(pool)
.await .await
.map_err(|e| MagnotiaError::StorageError(format!("Read error_log failed: {e}")))?; .map_err(|source| Error::Query {
operation: "list_recent_errors".into(),
source,
})?;
Ok(rows Ok(rows
.into_iter() .into_iter()
@@ -1278,10 +1445,10 @@ pub struct FeedbackRow {
pub async fn record_feedback(pool: &SqlitePool, params: RecordFeedbackParams) -> Result<i64> { pub async fn record_feedback(pool: &SqlitePool, params: RecordFeedbackParams) -> Result<i64> {
if !matches!(params.rating, -1..=1) { if !matches!(params.rating, -1..=1) {
return Err(MagnotiaError::StorageError(format!( return Err(Error::InvalidReference {
"invalid feedback rating {} (must be -1, 0, or 1)", entity: Entity::Feedback,
params.rating reason: format!("invalid rating {} (must be -1, 0, or 1)", params.rating).into(),
))); });
} }
let profile_id = params let profile_id = params
.profile_id .profile_id
@@ -1302,7 +1469,10 @@ pub async fn record_feedback(pool: &SqlitePool, params: RecordFeedbackParams) ->
.bind(profile_id) .bind(profile_id)
.fetch_one(pool) .fetch_one(pool)
.await .await
.map_err(|e| MagnotiaError::StorageError(format!("record_feedback failed: {e}")))?; .map_err(|source| Error::Query {
operation: "record_feedback".into(),
source,
})?;
Ok(row.get::<i64, _>("id")) Ok(row.get::<i64, _>("id"))
} }
@@ -1339,7 +1509,10 @@ pub async fn list_feedback_examples(
.bind(limit) .bind(limit)
.fetch_all(pool) .fetch_all(pool)
.await .await
.map_err(|e| MagnotiaError::StorageError(format!("list_feedback_examples failed: {e}")))?; .map_err(|source| Error::Query {
operation: "list_feedback_examples".into(),
source,
})?;
Ok(rows Ok(rows
.into_iter() .into_iter()

194
crates/storage/src/error.rs Normal file
View File

@@ -0,0 +1,194 @@
//! Storage-local typed error.
//!
//! Backend code that wants to branch on storage failure modes pattern-matches
//! on [`Error`] directly. The crate boundary into [`magnotia_core::error::MagnotiaError`]
//! flattens this into a [`MagnotiaError::Storage`] variant carrying a serde-friendly
//! `kind`, an `operation` label, and the Display output as `detail` — so the
//! frontend (which still receives stringified errors from Tauri commands today)
//! keeps working, and Area E can later expose the typed `kind` to the FE without
//! touching call sites.
//!
//! The `sqlx::Error` source is deliberately not serialized. Sources stay sources;
//! only the boundary shape is wire-friendly.
use std::borrow::Cow;
use std::path::PathBuf;
use magnotia_core::error::{MagnotiaError, StorageKind};
/// Kinds of database-open operation that can fail before the pool is ready.
#[derive(Debug, Clone, Copy)]
pub enum OpenOp {
Connect,
ReadOnlyConnect,
ForeignKeysPragma,
}
impl OpenOp {
fn label(self) -> &'static str {
match self {
OpenOp::Connect => "connect",
OpenOp::ReadOnlyConnect => "read_only_connect",
OpenOp::ForeignKeysPragma => "foreign_keys_pragma",
}
}
}
impl std::fmt::Display for OpenOp {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.label())
}
}
/// Per-step phase inside the migration framework.
#[derive(Debug, Clone, Copy)]
pub enum MigrationStep {
SchemaVersionTableCreate,
SchemaVersionQuery,
TxBegin,
Apply,
RecordVersion,
Commit,
}
impl MigrationStep {
fn label(self) -> &'static str {
match self {
MigrationStep::SchemaVersionTableCreate => "schema_version_table_create",
MigrationStep::SchemaVersionQuery => "schema_version_query",
MigrationStep::TxBegin => "tx_begin",
MigrationStep::Apply => "apply",
MigrationStep::RecordVersion => "record_version",
MigrationStep::Commit => "commit",
}
}
}
impl std::fmt::Display for MigrationStep {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.label())
}
}
/// Entities the storage layer can fail to find or validate.
///
/// Seeded with only the three real entities (Transcript, Task, Profile) — add
/// more here only when a typed `NotFound` / `InvalidReference` case actually
/// requires them.
#[derive(Debug, Clone, Copy)]
pub enum Entity {
Transcript,
Task,
Profile,
ImplementationRule,
Feedback,
}
impl Entity {
fn label(self) -> &'static str {
match self {
Entity::Transcript => "transcript",
Entity::Task => "task",
Entity::Profile => "profile",
Entity::ImplementationRule => "implementation_rule",
Entity::Feedback => "feedback",
}
}
}
impl std::fmt::Display for Entity {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.label())
}
}
/// Storage-crate-local typed error.
#[derive(Debug, thiserror::Error)]
pub enum Error {
#[error("database open failed during {operation}: {source}")]
DatabaseOpen {
operation: OpenOp,
#[source]
source: sqlx::Error,
},
#[error("migration step {step} (version {version:?}) failed: {source}")]
Migration {
version: Option<i64>,
step: MigrationStep,
#[source]
source: sqlx::Error,
},
#[error("query failed during {operation}: {source}")]
Query {
operation: Cow<'static, str>,
#[source]
source: sqlx::Error,
},
#[error("{entity} not found: '{key}'")]
NotFound { entity: Entity, key: String },
#[error("invalid {entity} reference: {reason}")]
InvalidReference {
entity: Entity,
reason: Cow<'static, str>,
},
#[error("filesystem operation failed at '{}': {source}", path.display())]
Filesystem {
path: PathBuf,
#[source]
source: std::io::Error,
},
}
impl Error {
/// Discriminator for the boundary conversion into [`MagnotiaError`].
pub fn kind(&self) -> StorageKind {
match self {
Error::DatabaseOpen { .. } => StorageKind::DatabaseOpen,
Error::Migration { .. } => StorageKind::Migration,
Error::Query { .. } => StorageKind::Query,
Error::NotFound { .. } => StorageKind::NotFound,
Error::InvalidReference { .. } => StorageKind::InvalidReference,
Error::Filesystem { .. } => StorageKind::Filesystem,
}
}
/// Operation label that flows into the [`MagnotiaError::Storage`] boundary
/// shape. For per-query failures this is the caller-provided label;
/// for the other variants it's the variant's intrinsic label.
pub fn operation_label(&self) -> Cow<'static, str> {
match self {
Error::DatabaseOpen { operation, .. } => Cow::Borrowed(operation.label()),
Error::Migration { step, .. } => Cow::Borrowed(step.label()),
Error::Query { operation, .. } => operation.clone(),
Error::NotFound { entity, .. } => Cow::Owned(format!("not_found:{}", entity.label())),
Error::InvalidReference { entity, .. } => {
Cow::Owned(format!("invalid_reference:{}", entity.label()))
}
Error::Filesystem { .. } => Cow::Borrowed("filesystem"),
}
}
}
/// Boundary conversion — flattens the typed error into the wire-friendly
/// [`MagnotiaError::Storage`] variant. Lives in the storage crate (not in core)
/// to avoid a `core -> storage` dependency cycle.
impl From<Error> for MagnotiaError {
fn from(error: Error) -> Self {
let kind = error.kind();
let operation = error.operation_label().into_owned();
let detail = error.to_string();
MagnotiaError::Storage {
kind,
operation,
detail,
}
}
}
/// `Result` alias for storage-crate functions.
pub type Result<T> = std::result::Result<T, Error>;

View File

@@ -1,7 +1,10 @@
pub mod database; pub mod database;
pub mod error;
pub mod file_storage; pub mod file_storage;
pub mod migrations; pub mod migrations;
pub use error::{Entity, Error, MigrationStep, OpenOp, Result};
/// Stable identifier for the seeded Default profile (see migration v6). /// Stable identifier for the seeded Default profile (see migration v6).
/// The Default profile cannot be renamed or deleted — guarded by SQLite triggers. /// The Default profile cannot be renamed or deleted — guarded by SQLite triggers.
pub const DEFAULT_PROFILE_ID: &str = "00000000-0000-0000-0000-000000000001"; pub const DEFAULT_PROFILE_ID: &str = "00000000-0000-0000-0000-000000000001";

View File

@@ -1,4 +1,4 @@
use magnotia_core::error::{MagnotiaError, Result}; use crate::error::{Error, MigrationStep, Result};
use sqlx::SqlitePool; use sqlx::SqlitePool;
/// Each migration is a (version, description, sql) tuple. /// Each migration is a (version, description, sql) tuple.
@@ -553,29 +553,39 @@ async fn run_migrations_slice(pool: &SqlitePool, migrations: &[(i64, &str, &str)
) )
.execute(pool) .execute(pool)
.await .await
.map_err(|e| { .map_err(|source| Error::Migration {
MagnotiaError::StorageError(format!("Schema version table creation failed: {e}")) version: None,
step: MigrationStep::SchemaVersionTableCreate,
source,
})?; })?;
let current: i64 = sqlx::query_scalar("SELECT COALESCE(MAX(version), 0) FROM schema_version") let current: i64 = sqlx::query_scalar("SELECT COALESCE(MAX(version), 0) FROM schema_version")
.fetch_one(pool) .fetch_one(pool)
.await .await
.map_err(|e| MagnotiaError::StorageError(format!("Schema version query failed: {e}")))?; .map_err(|source| Error::Migration {
version: None,
step: MigrationStep::SchemaVersionQuery,
source,
})?;
for (version, description, sql) in migrations { for (version, description, sql) in migrations {
if *version > current { if *version > current {
log::info!("Running migration {}: {}", version, description); log::info!("Running migration {}: {}", version, description);
let mut tx = pool.begin().await.map_err(|e| { let mut tx = pool.begin().await.map_err(|source| Error::Migration {
MagnotiaError::StorageError(format!("Migration {} tx begin failed: {e}", version)) version: Some(*version),
step: MigrationStep::TxBegin,
source,
})?; })?;
for statement in split_statements(sql) { for statement in split_statements(sql) {
sqlx::query(&statement) sqlx::query(&statement)
.execute(&mut *tx) .execute(&mut *tx)
.await .await
.map_err(|e| { .map_err(|source| Error::Migration {
MagnotiaError::StorageError(format!("Migration {} failed: {e}", version)) version: Some(*version),
step: MigrationStep::Apply,
source,
})?; })?;
} }
@@ -584,12 +594,16 @@ async fn run_migrations_slice(pool: &SqlitePool, migrations: &[(i64, &str, &str)
.bind(description) .bind(description)
.execute(&mut *tx) .execute(&mut *tx)
.await .await
.map_err(|e| { .map_err(|source| Error::Migration {
MagnotiaError::StorageError(format!("Migration version record failed: {e}")) version: Some(*version),
step: MigrationStep::RecordVersion,
source,
})?; })?;
tx.commit().await.map_err(|e| { tx.commit().await.map_err(|source| Error::Migration {
MagnotiaError::StorageError(format!("Migration {} commit failed: {e}", version)) version: Some(*version),
step: MigrationStep::Commit,
source,
})?; })?;
log::info!("Migration {} complete", version); log::info!("Migration {} complete", version);