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:
@@ -3,12 +3,15 @@ use std::path::Path;
|
||||
use sqlx::sqlite::{SqliteConnectOptions, SqlitePoolOptions};
|
||||
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.
|
||||
pub async fn init(db_path: &Path) -> Result<SqlitePool> {
|
||||
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()
|
||||
@@ -19,12 +22,18 @@ pub async fn init(db_path: &Path) -> Result<SqlitePool> {
|
||||
.max_connections(5)
|
||||
.connect_with(options)
|
||||
.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")
|
||||
.execute(&pool)
|
||||
.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?;
|
||||
|
||||
@@ -47,7 +56,10 @@ pub async fn init_readonly(db_path: &Path) -> Result<SqlitePool> {
|
||||
.max_connections(2)
|
||||
.connect_with(options)
|
||||
.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.
|
||||
@@ -82,10 +94,10 @@ pub async fn insert_transcript(
|
||||
params: &InsertTranscriptParams<'_>,
|
||||
) -> Result<()> {
|
||||
if !profile_exists(pool, params.profile_id).await? {
|
||||
return Err(MagnotiaError::StorageError(format!(
|
||||
"Insert transcript failed: unknown profile id '{}'",
|
||||
params.profile_id
|
||||
)));
|
||||
return Err(Error::InvalidReference {
|
||||
entity: Entity::Profile,
|
||||
reason: format!("unknown profile id '{}'", params.profile_id).into(),
|
||||
});
|
||||
}
|
||||
|
||||
sqlx::query(
|
||||
@@ -110,7 +122,10 @@ pub async fn insert_transcript(
|
||||
.bind(params.anti_hallucination)
|
||||
.execute(pool)
|
||||
.await
|
||||
.map_err(|e| MagnotiaError::StorageError(format!("Insert transcript failed: {e}")))?;
|
||||
.map_err(|source| Error::Query {
|
||||
operation: "insert_transcript".into(),
|
||||
source,
|
||||
})?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -121,7 +136,10 @@ pub async fn get_transcript(pool: &SqlitePool, id: &str) -> Result<Option<Transc
|
||||
.bind(id)
|
||||
.fetch_optional(pool)
|
||||
.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)))
|
||||
}
|
||||
@@ -144,7 +162,10 @@ pub async fn list_transcripts_paged(
|
||||
.bind(offset)
|
||||
.fetch_all(pool)
|
||||
.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())
|
||||
}
|
||||
@@ -154,7 +175,10 @@ pub async fn count_transcripts(pool: &SqlitePool) -> Result<i64> {
|
||||
let n: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM transcripts")
|
||||
.fetch_one(pool)
|
||||
.await
|
||||
.map_err(|e| MagnotiaError::StorageError(format!("Count transcripts failed: {e}")))?;
|
||||
.map_err(|source| Error::Query {
|
||||
operation: "count_transcripts".into(),
|
||||
source,
|
||||
})?;
|
||||
Ok(n)
|
||||
}
|
||||
|
||||
@@ -182,8 +206,9 @@ pub async fn update_transcript(
|
||||
.bind(id)
|
||||
.execute(pool)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
MagnotiaError::StorageError(format!("Update transcript failed: {e}"))
|
||||
.map_err(|source| Error::Query {
|
||||
operation: "update_transcript".into(),
|
||||
source,
|
||||
})?;
|
||||
Ok(res.rows_affected())
|
||||
}
|
||||
@@ -193,8 +218,9 @@ pub async fn update_transcript(
|
||||
.bind(id)
|
||||
.execute(pool)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
MagnotiaError::StorageError(format!("Update transcript failed: {e}"))
|
||||
.map_err(|source| Error::Query {
|
||||
operation: "update_transcript".into(),
|
||||
source,
|
||||
})?;
|
||||
Ok(res.rows_affected())
|
||||
}
|
||||
@@ -204,8 +230,9 @@ pub async fn update_transcript(
|
||||
.bind(id)
|
||||
.execute(pool)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
MagnotiaError::StorageError(format!("Update transcript failed: {e}"))
|
||||
.map_err(|source| Error::Query {
|
||||
operation: "update_transcript".into(),
|
||||
source,
|
||||
})?;
|
||||
Ok(res.rows_affected())
|
||||
}
|
||||
@@ -253,11 +280,17 @@ pub async fn update_transcript_meta(
|
||||
.bind(id)
|
||||
.execute(pool)
|
||||
.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(|| {
|
||||
MagnotiaError::StorageError(format!("update_transcript_meta: transcript {id} not found"))
|
||||
})
|
||||
get_transcript(pool, id)
|
||||
.await?
|
||||
.ok_or_else(|| Error::NotFound {
|
||||
entity: Entity::Transcript,
|
||||
key: id.to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn delete_transcript(pool: &SqlitePool, id: &str) -> Result<()> {
|
||||
@@ -265,7 +298,10 @@ pub async fn delete_transcript(pool: &SqlitePool, id: &str) -> Result<()> {
|
||||
.bind(id)
|
||||
.execute(pool)
|
||||
.await
|
||||
.map_err(|e| MagnotiaError::StorageError(format!("Delete transcript failed: {e}")))?;
|
||||
.map_err(|source| Error::Query {
|
||||
operation: "delete_transcript".into(),
|
||||
source,
|
||||
})?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -289,7 +325,10 @@ pub async fn search_transcripts(
|
||||
.bind(limit)
|
||||
.fetch_all(pool)
|
||||
.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())
|
||||
}
|
||||
@@ -327,7 +366,10 @@ pub async fn insert_task(
|
||||
.bind(energy)
|
||||
.execute(pool)
|
||||
.await
|
||||
.map_err(|e| MagnotiaError::StorageError(format!("Insert task failed: {e}")))?;
|
||||
.map_err(|source| Error::Query {
|
||||
operation: "insert_task".into(),
|
||||
source,
|
||||
})?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -339,7 +381,10 @@ pub async fn list_tasks(pool: &SqlitePool) -> Result<Vec<TaskRow>> {
|
||||
)
|
||||
.fetch_all(pool)
|
||||
.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())
|
||||
}
|
||||
@@ -352,7 +397,10 @@ pub async fn get_task_by_id(pool: &SqlitePool, id: &str) -> Result<Option<TaskRo
|
||||
.bind(id)
|
||||
.fetch_optional(pool)
|
||||
.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))
|
||||
}
|
||||
@@ -390,11 +438,17 @@ pub async fn update_task(
|
||||
.bind(id)
|
||||
.execute(pool)
|
||||
.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(|| {
|
||||
MagnotiaError::StorageError(format!("update_task: task {id} not found after update"))
|
||||
})
|
||||
get_task_by_id(pool, id)
|
||||
.await?
|
||||
.ok_or_else(|| Error::NotFound {
|
||||
entity: Entity::Task,
|
||||
key: id.to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Dedicated tri-state energy setter. Exists as its own function because
|
||||
@@ -411,11 +465,17 @@ pub async fn set_task_energy(pool: &SqlitePool, id: &str, energy: Option<&str>)
|
||||
.bind(id)
|
||||
.execute(pool)
|
||||
.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(|| {
|
||||
MagnotiaError::StorageError(format!("set_task_energy: task {id} not found after update"))
|
||||
})
|
||||
get_task_by_id(pool, id)
|
||||
.await?
|
||||
.ok_or_else(|| Error::NotFound {
|
||||
entity: Entity::Task,
|
||||
key: id.to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn insert_subtask(
|
||||
@@ -430,7 +490,10 @@ pub async fn insert_subtask(
|
||||
.bind(parent_task_id)
|
||||
.execute(pool)
|
||||
.await
|
||||
.map_err(|e| MagnotiaError::StorageError(format!("Insert subtask failed: {e}")))?;
|
||||
.map_err(|source| Error::Query {
|
||||
operation: "insert_subtask".into(),
|
||||
source,
|
||||
})?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -443,7 +506,10 @@ pub async fn list_subtasks(pool: &SqlitePool, parent_id: &str) -> Result<Vec<Tas
|
||||
.bind(parent_id)
|
||||
.fetch_all(pool)
|
||||
.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())
|
||||
}
|
||||
@@ -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.
|
||||
/// 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<()> {
|
||||
let mut tx = pool
|
||||
.begin()
|
||||
.await
|
||||
.map_err(|e| MagnotiaError::StorageError(format!("Begin transaction failed: {e}")))?;
|
||||
let mut tx = pool.begin().await.map_err(|source| Error::Query {
|
||||
operation: "complete_subtask_and_check_parent.begin_transaction".into(),
|
||||
source,
|
||||
})?;
|
||||
|
||||
sqlx::query("UPDATE tasks SET done = 1, done_at = datetime('now') WHERE id = ?")
|
||||
.bind(subtask_id)
|
||||
.execute(&mut *tx)
|
||||
.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> =
|
||||
sqlx::query_scalar("SELECT parent_task_id FROM tasks WHERE id = ?")
|
||||
.bind(subtask_id)
|
||||
.fetch_one(&mut *tx)
|
||||
.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 {
|
||||
let pending: i64 =
|
||||
@@ -475,8 +547,9 @@ pub async fn complete_subtask_and_check_parent(pool: &SqlitePool, subtask_id: &s
|
||||
.bind(&pid)
|
||||
.fetch_one(&mut *tx)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
MagnotiaError::StorageError(format!("Count pending subtasks failed: {e}"))
|
||||
.map_err(|source| Error::Query {
|
||||
operation: "complete_subtask_and_check_parent.count_pending_subtasks".into(),
|
||||
source,
|
||||
})?;
|
||||
|
||||
if pending == 0 {
|
||||
@@ -490,15 +563,17 @@ pub async fn complete_subtask_and_check_parent(pool: &SqlitePool, subtask_id: &s
|
||||
.bind(&pid)
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
MagnotiaError::StorageError(format!("Auto-complete parent failed: {e}"))
|
||||
.map_err(|source| Error::Query {
|
||||
operation: "complete_subtask_and_check_parent.auto_complete_parent".into(),
|
||||
source,
|
||||
})?;
|
||||
}
|
||||
}
|
||||
|
||||
tx.commit()
|
||||
.await
|
||||
.map_err(|e| MagnotiaError::StorageError(format!("Commit transaction failed: {e}")))?;
|
||||
tx.commit().await.map_err(|source| Error::Query {
|
||||
operation: "complete_subtask_and_check_parent.commit_transaction".into(),
|
||||
source,
|
||||
})?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -508,21 +583,27 @@ pub async fn complete_task(pool: &SqlitePool, id: &str) -> Result<()> {
|
||||
.bind(id)
|
||||
.execute(pool)
|
||||
.await
|
||||
.map_err(|e| MagnotiaError::StorageError(format!("Complete task failed: {e}")))?;
|
||||
.map_err(|source| Error::Query {
|
||||
operation: "complete_task".into(),
|
||||
source,
|
||||
})?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn uncomplete_task(pool: &SqlitePool, id: &str) -> Result<()> {
|
||||
let mut tx = pool
|
||||
.begin()
|
||||
.await
|
||||
.map_err(|e| MagnotiaError::StorageError(format!("Begin transaction failed: {e}")))?;
|
||||
let mut tx = pool.begin().await.map_err(|source| Error::Query {
|
||||
operation: "uncomplete_task.begin_transaction".into(),
|
||||
source,
|
||||
})?;
|
||||
|
||||
sqlx::query("UPDATE tasks SET done = 0, done_at = NULL, auto_completed = 0 WHERE id = ?")
|
||||
.bind(id)
|
||||
.execute(&mut *tx)
|
||||
.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
|
||||
// `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)
|
||||
.fetch_optional(&mut *tx)
|
||||
.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();
|
||||
|
||||
if let Some(pid) = parent_id {
|
||||
@@ -545,12 +629,16 @@ pub async fn uncomplete_task(pool: &SqlitePool, id: &str) -> Result<()> {
|
||||
.bind(&pid)
|
||||
.execute(&mut *tx)
|
||||
.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()
|
||||
.await
|
||||
.map_err(|e| MagnotiaError::StorageError(format!("Commit transaction failed: {e}")))?;
|
||||
tx.commit().await.map_err(|source| Error::Query {
|
||||
operation: "uncomplete_task.commit_transaction".into(),
|
||||
source,
|
||||
})?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -560,7 +648,10 @@ pub async fn delete_task(pool: &SqlitePool, id: &str) -> Result<()> {
|
||||
.bind(id)
|
||||
.execute(pool)
|
||||
.await
|
||||
.map_err(|e| MagnotiaError::StorageError(format!("Delete task failed: {e}")))?;
|
||||
.map_err(|source| Error::Query {
|
||||
operation: "delete_task".into(),
|
||||
source,
|
||||
})?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -607,7 +698,10 @@ pub async fn list_recent_completions(
|
||||
.bind(format!("-{} days", days - 1))
|
||||
.fetch_all(pool)
|
||||
.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
|
||||
.into_iter()
|
||||
@@ -618,7 +712,10 @@ pub async fn list_recent_completions(
|
||||
let today_row: (String,) = sqlx::query_as("SELECT DATE('now', 'localtime')")
|
||||
.fetch_one(pool)
|
||||
.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 mut series = Vec::with_capacity(days as usize);
|
||||
@@ -630,7 +727,10 @@ pub async fn list_recent_completions(
|
||||
.bind(format!("-{offset} days"))
|
||||
.fetch_one(pool)
|
||||
.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);
|
||||
series.push(DailyCompletionCount { day, count });
|
||||
}
|
||||
@@ -663,13 +763,17 @@ pub async fn insert_implementation_rule(
|
||||
.bind(last_fired_key)
|
||||
.execute(pool)
|
||||
.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(|| {
|
||||
MagnotiaError::StorageError(format!(
|
||||
"insert_implementation_rule: rule {id} not found after insert"
|
||||
))
|
||||
})
|
||||
get_implementation_rule(pool, id)
|
||||
.await?
|
||||
.ok_or_else(|| Error::NotFound {
|
||||
entity: Entity::ImplementationRule,
|
||||
key: id.to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn list_implementation_rules(pool: &SqlitePool) -> Result<Vec<ImplementationRuleRow>> {
|
||||
@@ -680,7 +784,10 @@ pub async fn list_implementation_rules(pool: &SqlitePool) -> Result<Vec<Implemen
|
||||
)
|
||||
.fetch_all(pool)
|
||||
.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())
|
||||
}
|
||||
@@ -697,7 +804,10 @@ pub async fn get_implementation_rule(
|
||||
.bind(id)
|
||||
.fetch_optional(pool)
|
||||
.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))
|
||||
}
|
||||
@@ -716,15 +826,17 @@ pub async fn set_implementation_rule_enabled(
|
||||
.bind(id)
|
||||
.execute(pool)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
MagnotiaError::StorageError(format!("Set implementation rule enabled failed: {e}"))
|
||||
.map_err(|source| Error::Query {
|
||||
operation: "set_implementation_rule_enabled".into(),
|
||||
source,
|
||||
})?;
|
||||
|
||||
get_implementation_rule(pool, id).await?.ok_or_else(|| {
|
||||
MagnotiaError::StorageError(format!(
|
||||
"set_implementation_rule_enabled: rule {id} not found after update"
|
||||
))
|
||||
})
|
||||
get_implementation_rule(pool, id)
|
||||
.await?
|
||||
.ok_or_else(|| Error::NotFound {
|
||||
entity: Entity::ImplementationRule,
|
||||
key: id.to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn mark_implementation_rule_fired(
|
||||
@@ -741,15 +853,17 @@ pub async fn mark_implementation_rule_fired(
|
||||
.bind(id)
|
||||
.execute(pool)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
MagnotiaError::StorageError(format!("Mark implementation rule fired failed: {e}"))
|
||||
.map_err(|source| Error::Query {
|
||||
operation: "mark_implementation_rule_fired".into(),
|
||||
source,
|
||||
})?;
|
||||
|
||||
get_implementation_rule(pool, id).await?.ok_or_else(|| {
|
||||
MagnotiaError::StorageError(format!(
|
||||
"mark_implementation_rule_fired: rule {id} not found after update"
|
||||
))
|
||||
})
|
||||
get_implementation_rule(pool, id)
|
||||
.await?
|
||||
.ok_or_else(|| Error::NotFound {
|
||||
entity: Entity::ImplementationRule,
|
||||
key: id.to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn delete_implementation_rule(pool: &SqlitePool, id: &str) -> Result<()> {
|
||||
@@ -757,8 +871,9 @@ pub async fn delete_implementation_rule(pool: &SqlitePool, id: &str) -> Result<(
|
||||
.bind(id)
|
||||
.execute(pool)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
MagnotiaError::StorageError(format!("Delete implementation rule failed: {e}"))
|
||||
.map_err(|source| Error::Query {
|
||||
operation: "delete_implementation_rule".into(),
|
||||
source,
|
||||
})?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -771,7 +886,10 @@ pub async fn set_setting(pool: &SqlitePool, key: &str, value: &str) -> Result<()
|
||||
.bind(value)
|
||||
.execute(pool)
|
||||
.await
|
||||
.map_err(|e| MagnotiaError::StorageError(format!("Set setting failed: {e}")))?;
|
||||
.map_err(|source| Error::Query {
|
||||
operation: "set_setting".into(),
|
||||
source,
|
||||
})?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -780,7 +898,10 @@ pub async fn get_setting(pool: &SqlitePool, key: &str) -> Result<Option<String>>
|
||||
.bind(key)
|
||||
.fetch_optional(pool)
|
||||
.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")))
|
||||
}
|
||||
|
||||
@@ -958,15 +1079,19 @@ fn implementation_rule_row_from(r: sqlx::sqlite::SqliteRow) -> ImplementationRul
|
||||
// 1. The DB triggers `trg_protect_default_profile_delete` /
|
||||
// `trg_protect_default_profile_rename` from migration v6.
|
||||
// 2. Rust-layer fail-fast checks below that short-circuit BEFORE the
|
||||
// query hits sqlite, so UI callers get a friendly MagnotiaError::StorageError
|
||||
// instead of an opaque `SQLITE_CONSTRAINT_TRIGGER` wrapped in text.
|
||||
// query hits sqlite, so UI callers get a friendly typed
|
||||
// `Error::InvalidReference` instead of an opaque
|
||||
// `SQLITE_CONSTRAINT_TRIGGER` wrapped in text.
|
||||
|
||||
pub async fn list_profiles(pool: &SqlitePool) -> Result<Vec<ProfileRow>> {
|
||||
let rows =
|
||||
sqlx::query("SELECT id, name, initial_prompt, created_at FROM profiles ORDER BY name ASC")
|
||||
.fetch_all(pool)
|
||||
.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())
|
||||
}
|
||||
|
||||
@@ -975,7 +1100,10 @@ pub async fn get_profile(pool: &SqlitePool, id: &str) -> Result<Option<ProfileRo
|
||||
.bind(id)
|
||||
.fetch_optional(pool)
|
||||
.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))
|
||||
}
|
||||
|
||||
@@ -995,7 +1123,10 @@ pub async fn create_profile(
|
||||
.bind(initial_prompt)
|
||||
.fetch_one(pool)
|
||||
.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))
|
||||
}
|
||||
|
||||
@@ -1013,14 +1144,16 @@ pub async fn update_profile(
|
||||
initial_prompt: &str,
|
||||
) -> Result<()> {
|
||||
if id == crate::DEFAULT_PROFILE_ID && name != "Default" {
|
||||
return Err(MagnotiaError::StorageError(
|
||||
"Default profile cannot be renamed".into(),
|
||||
));
|
||||
return Err(Error::InvalidReference {
|
||||
entity: Entity::Profile,
|
||||
reason: "Default profile cannot be renamed".into(),
|
||||
});
|
||||
}
|
||||
if id != crate::DEFAULT_PROFILE_ID && name == "Default" {
|
||||
return Err(MagnotiaError::StorageError(
|
||||
"Cannot rename another profile to 'Default'".into(),
|
||||
));
|
||||
return Err(Error::InvalidReference {
|
||||
entity: Entity::Profile,
|
||||
reason: "cannot rename another profile to 'Default'".into(),
|
||||
});
|
||||
}
|
||||
sqlx::query("UPDATE profiles SET name = ?, initial_prompt = ? WHERE id = ?")
|
||||
.bind(name)
|
||||
@@ -1028,7 +1161,10 @@ pub async fn update_profile(
|
||||
.bind(id)
|
||||
.execute(pool)
|
||||
.await
|
||||
.map_err(|e| MagnotiaError::StorageError(format!("Update profile failed: {e}")))?;
|
||||
.map_err(|source| Error::Query {
|
||||
operation: "update_profile".into(),
|
||||
source,
|
||||
})?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -1037,21 +1173,28 @@ pub async fn update_profile(
|
||||
/// ON DELETE CASCADE on `profile_terms.profile_id` cleans up children.
|
||||
pub async fn delete_profile(pool: &SqlitePool, id: &str) -> Result<()> {
|
||||
if id == crate::DEFAULT_PROFILE_ID {
|
||||
return Err(MagnotiaError::StorageError(
|
||||
"Default profile cannot be deleted".into(),
|
||||
));
|
||||
return Err(Error::InvalidReference {
|
||||
entity: Entity::Profile,
|
||||
reason: "Default profile cannot be deleted".into(),
|
||||
});
|
||||
}
|
||||
let transcript_count = transcript_count_for_profile(pool, id).await?;
|
||||
if transcript_count > 0 {
|
||||
return Err(MagnotiaError::StorageError(format!(
|
||||
"Cannot delete profile while {transcript_count} transcript(s) still reference it; reassign transcripts first"
|
||||
)));
|
||||
return Err(Error::InvalidReference {
|
||||
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 = ?")
|
||||
.bind(id)
|
||||
.execute(pool)
|
||||
.await
|
||||
.map_err(|e| MagnotiaError::StorageError(format!("Delete profile failed: {e}")))?;
|
||||
.map_err(|source| Error::Query {
|
||||
operation: "delete_profile".into(),
|
||||
source,
|
||||
})?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -1066,7 +1209,10 @@ pub async fn list_profile_terms(
|
||||
.bind(profile_id)
|
||||
.fetch_all(pool)
|
||||
.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())
|
||||
}
|
||||
|
||||
@@ -1092,7 +1238,10 @@ pub async fn add_profile_term(
|
||||
.bind(note)
|
||||
.fetch_one(pool)
|
||||
.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))
|
||||
}
|
||||
|
||||
@@ -1101,7 +1250,10 @@ pub async fn delete_profile_term(pool: &SqlitePool, id: &str) -> Result<()> {
|
||||
.bind(id)
|
||||
.execute(pool)
|
||||
.await
|
||||
.map_err(|e| MagnotiaError::StorageError(format!("Delete profile term failed: {e}")))?;
|
||||
.map_err(|source| Error::Query {
|
||||
operation: "delete_profile_term".into(),
|
||||
source,
|
||||
})?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -1110,7 +1262,10 @@ async fn profile_exists(pool: &SqlitePool, id: &str) -> Result<bool> {
|
||||
.bind(id)
|
||||
.fetch_optional(pool)
|
||||
.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())
|
||||
}
|
||||
|
||||
@@ -1119,7 +1274,10 @@ async fn transcript_count_for_profile(pool: &SqlitePool, id: &str) -> Result<i64
|
||||
.bind(id)
|
||||
.fetch_one(pool)
|
||||
.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 ---
|
||||
@@ -1153,7 +1311,10 @@ pub async fn log_error(
|
||||
.bind(metadata)
|
||||
.execute(pool)
|
||||
.await
|
||||
.map_err(|e| MagnotiaError::StorageError(format!("Error log failed: {e}")))?;
|
||||
.map_err(|source| Error::Query {
|
||||
operation: "log_error".into(),
|
||||
source,
|
||||
})?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -1185,7 +1346,10 @@ pub async fn prune_error_log(pool: &SqlitePool, keep_days: i64) -> Result<u64> {
|
||||
.bind(format!("-{keep_days} days"))
|
||||
.execute(pool)
|
||||
.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())
|
||||
}
|
||||
|
||||
@@ -1197,7 +1361,10 @@ pub async fn list_recent_errors(pool: &SqlitePool, limit: i64) -> Result<Vec<Err
|
||||
.bind(limit)
|
||||
.fetch_all(pool)
|
||||
.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
|
||||
.into_iter()
|
||||
@@ -1278,10 +1445,10 @@ pub struct FeedbackRow {
|
||||
|
||||
pub async fn record_feedback(pool: &SqlitePool, params: RecordFeedbackParams) -> Result<i64> {
|
||||
if !matches!(params.rating, -1..=1) {
|
||||
return Err(MagnotiaError::StorageError(format!(
|
||||
"invalid feedback rating {} (must be -1, 0, or 1)",
|
||||
params.rating
|
||||
)));
|
||||
return Err(Error::InvalidReference {
|
||||
entity: Entity::Feedback,
|
||||
reason: format!("invalid rating {} (must be -1, 0, or 1)", params.rating).into(),
|
||||
});
|
||||
}
|
||||
let profile_id = params
|
||||
.profile_id
|
||||
@@ -1302,7 +1469,10 @@ pub async fn record_feedback(pool: &SqlitePool, params: RecordFeedbackParams) ->
|
||||
.bind(profile_id)
|
||||
.fetch_one(pool)
|
||||
.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"))
|
||||
}
|
||||
|
||||
@@ -1339,7 +1509,10 @@ pub async fn list_feedback_examples(
|
||||
.bind(limit)
|
||||
.fetch_all(pool)
|
||||
.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
|
||||
.into_iter()
|
||||
|
||||
Reference in New Issue
Block a user