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:
1
Cargo.lock
generated
1
Cargo.lock
generated
@@ -2924,6 +2924,7 @@ dependencies = [
|
|||||||
"magnotia-core",
|
"magnotia-core",
|
||||||
"serde",
|
"serde",
|
||||||
"sqlx",
|
"sqlx",
|
||||||
|
"thiserror 1.0.69",
|
||||||
"tokio",
|
"tokio",
|
||||||
"uuid",
|
"uuid",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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"] }
|
||||||
|
|||||||
@@ -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,11 +280,17 @@ 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(),
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn delete_transcript(pool: &SqlitePool, id: &str) -> Result<()> {
|
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)
|
.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,11 +438,17 @@ 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(),
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Dedicated tri-state energy setter. Exists as its own function because
|
/// 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)
|
.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(),
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn insert_subtask(
|
pub async fn insert_subtask(
|
||||||
@@ -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,13 +763,17 @@ 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(),
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn list_implementation_rules(pool: &SqlitePool) -> Result<Vec<ImplementationRuleRow>> {
|
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)
|
.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,15 +826,17 @@ 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(),
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn mark_implementation_rule_fired(
|
pub async fn mark_implementation_rule_fired(
|
||||||
@@ -741,15 +853,17 @@ 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(),
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn delete_implementation_rule(pool: &SqlitePool, id: &str) -> Result<()> {
|
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)
|
.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
194
crates/storage/src/error.rs
Normal 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>;
|
||||||
@@ -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";
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
Reference in New Issue
Block a user