8000 feat: structured error codes foundation (MinigrafError, ErrorCategory) — #277 (1/6) by adityamukho · Pull Request #357 · project-minigraf/minigraf · GitHub
[go: up one dir, main page]

Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
68cde9c
docs: fix test count in foundation plan Task 11 (8 not 4 new integrat…
adityamukho Aug 25, 2026
f176d07
feat: add ErrorCategory/ErrorCode/REGISTRY/format_template for #277
adityamukho Aug 25, 2026
311f456
docs: fix Task 4 plan text to upgrade Task 1's lib.rs mod declaration
adityamukho Aug 25, 2026
09cffe7
feat: add CodedError and bail_coded!/err_coded! macros for #277
adityamukho Aug 25, 2026
c9d89a8
feat: add MinigrafError and anyhow::Error boundary conversion for #277
adityamukho Aug 25, 2026
f28000b
feat: export MinigrafError/ErrorCategory; document INT-000; sync test
adityamukho Aug 25, 2026
f575a19
fix: silence expected dead-code warnings on unused bail_coded!/err_co…
adityamukho Aug 25, 2026
73ee5b4
feat: migrate Minigraf::open/in_memory family to MinigrafError
adityamukho Aug 25, 2026
8085484
feat: migrate Minigraf::execute to MinigrafError
adityamukho Aug 25, 2026
bac7f58
feat: migrate Minigraf::begin_write/checkpoint to MinigrafError
adityamukho Aug 25, 2026
395ef3f
feat: migrate Minigraf::prepare/register_aggregate/register_predicate
adityamukho Aug 25, 2026
1dbfff1
feat: migrate WriteTransaction::execute/commit to MinigrafError
adityamukho Aug 25, 2026
5150eac
fix: remove unnecessary mut from WriteTransaction::commit wrapper
adityamukho Aug 25, 2026
3db6465
feat: migrate PreparedQuery::execute to MinigrafError
adityamukho Aug 25, 2026
3599eb5
fix: remove unused re-exports and apply formatting
adityamukho Aug 25, 2026
48ba182
revert: keep pre-existing storage/backend/mod.rs warning out of #277'…
adityamukho Aug 25, 2026
3b6dacd
docs: add CHANGELOG entry for the MinigrafError breaking change
adityamukho Aug 25, 2026
4b6f944
docs: fix ERROR_REFERENCE.md self-contradiction on error surface type
adityamukho Aug 25, 2026
ca78d16
fix: route MinigrafError's anyhow fallback through the INT-000 regist…
adityamukho Aug 25, 2026
98bbae7
feat: mark ErrorCategory #[non_exhaustive] for future-proof matching
adityamukho Aug 25, 2026
e048f95
docs: note Display format change and fix .code() signature typo in CH…
adityamukho Aug 25, 2026
d669b32
docs: surface the INT-000 transitional caveat before the file-an-issu…
adityamukho Aug 25, 2026
d3c1433
docs+test: correct registry_entry's safety comment; add exhaustive Er…
adityamukho Aug 25, 2026
314208c
test: cover format_template's arg-count mismatch and a two-hop contex…
adityamukho Aug 25, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
create-then-lock ordering now creates before it can know the lock will be
refused. Harmless — the next open sees `is_new` and initialises normally —
but worth knowing if you inspect the filesystem after a failed open.
- **`Minigraf`'s, `WriteTransaction`'s, and `PreparedQuery`'s public methods now
return `Result<T, MinigrafError>` instead of `anyhow::Result<T>`** (#277).
`MinigrafError` implements `std::error::Error` (so `?` still works against
`anyhow::Result`/`Box<dyn Error>` callers) and adds `.category() -> ErrorCategory`
and `.code() -> &'static str` for structured error matching. This lands the
foundation for issue #277; every error currently surfaces as the generic
`INT-000` code until each error category's follow-up PR wires up its real
`docs/ERROR_REFERENCE.md` codes.
Every error's `Display`/`to_string()` output now also carries a `[CODE] `
prefix (e.g. `[INT-000] unclassified internal error: ...`) that it did not
have before — a behavior change beyond the type signature. Anything that
string-matches error output is affected, including the interactive REPL's
printed error messages and all 7 language-binding repos (Python, Node,
WASM, Java, Android, Swift, C).

### Bug fixes

Expand Down
36 changes: 32 additions & 4 deletions docs/ERROR_REFERENCE.md
Original file line number Diff line number Diff line change
@@ -1,12 +1,15 @@
# Minigraf Error Reference

This document covers every user-facing error produced by the core Minigraf Rust library.
Errors surface as `anyhow::Error` values returned from `db.execute()`, `db.prepare()`,
Errors surface as `MinigrafError` values returned from `db.execute()`, `db.prepare()`,
and related API methods.

**Reference codes** (e.g. `PRS-001`) are documentation-only identifiers. They do not
appear in runtime output today — runtime codes are tracked in
[#277](https://github.com/project-minigraf/minigraf/issues/277).
**Reference codes** (e.g. `PRS-001`) appear in runtime error output as
`[CODE] message`, and programmatically via `MinigrafError::code()` /
`MinigrafError::category()`. As of the [#277](https://github.com/project-minigraf/minigraf/issues/277)
foundation PR, every runtime error carries a code — but until each
category's follow-up migration PR lands, most still surface generically as
`INT-000` rather than their documented code below.

**Out of scope**: FFI/binding errors from `minigraf-python`, `minigraf-node`, and
`minigraf-wasm` are handled in those repos.
Expand Down Expand Up @@ -147,6 +150,7 @@ appear in runtime output today — runtime codes are tracked in
| API-007 | Only query commands can be prepared (rule) | Database API |
| API-008 | Function registry lock poisoned | Database API |
| API-009 | WAL not initialized | Database API |
| INT-000 | Unclassified internal error | Internal |

---

Expand Down Expand Up @@ -2155,6 +2159,30 @@ db.execute("(rule [(ancestor ?x ?y) [?x :parent ?y]])")?;

---

## INT — Internal Errors

`INT-000` is the generic catch-all every runtime error falls back to until its
call site is migrated to a specific structured code — see
[#277](https://github.com/project-minigraf/minigraf/issues/277). Internal
errors are invariant violations that should not be reachable through normal
use of the public API. If you see one of these in practice, it likely
indicates a bug in Minigraf itself; please
[file an issue](https://github.com/project-minigraf/minigraf/issues).

### INT-000 Unclassified internal error

**Error text**: `unclassified internal error: {}`

**Cause**: A `bail!`/`anyhow!` call site has not yet been migrated to a specific structured error code, or the error genuinely is an unreachable internal invariant violation.

**Resolution**:
- Read the wrapped message text (the `{}` above) for the underlying cause.
- Match on message content rather than this code if you need long-term programmatic stability — `INT-000` is expected to cover fewer cases over time as call sites gain specific codes.

**Scenario**: Any error not yet covered by a migrated `PRS-`/`STG-`/`WAL-`/`QRY-`/`API-` code.

---

## Appendix: Internal Errors

The following error strings indicate a bug in the Minigraf library itself, not a
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -478,12 +478,20 @@ EOF

- [ ] **Step 1: Export the new types from `lib.rs`**

In `src/lib.rs`, add `pub mod error;` next to the other `pub mod` declarations (near line 77's `pub mod db;`), and add to the `pub use` block (near line 91-92):
Task 1's implementer already added `pub(crate) mod error;` to `src/lib.rs` — necessary for `cargo test --lib error::` to compile/run at all in Task 1, since a source file isn't part of the crate tree until some reachable `mod` declares it; the original brief didn't anticipate this. Ruling recorded in the SDD ledger (Task 1 review). Do not insert a second `mod error;` line — that's a duplicate-declaration compile error. Instead, change the existing line's visibility:

```rust
pub(crate) mod error;
```

to:

```rust
pub mod error;
```

And add to the `pub use` block (near line 91-92):

```rust
pub use error::{ErrorCategory, MinigrafError};
```
Expand Down Expand Up @@ -1328,7 +1336,7 @@ Expected: no warnings. Fix anything flagged (likely candidates: an unused `Resul
- [ ] **Step 3: Full test suite**

Run: `cargo test`
Expected: PASS, same or greater test count than before this plan started (`cargo test 2>&1 | tail -5` — compare against the pre-change baseline of 1023 tests noted in `CLAUDE.md`; the 4 new tests in `error_codes_foundation_test.rs` plus 12 in `src/error.rs`'s own module bring the count up by 16).
Expected: PASS, same or greater test count than before this plan started (`cargo test 2>&1 | tail -5` — compare against the pre-change baseline of 1023 tests noted in `CLAUDE.md`; the 8 new tests in `error_codes_foundation_test.rs` plus 12 in `src/error.rs`'s own module bring the count up by 20).

- [ ] **Step 4: WASM target build check (best-effort)**

Expand Down
8BD0
Original file line number Diff line number Diff line change
Expand Up @@ -212,6 +212,36 @@ becomes the umbrella tracking issue and splits into:
2. **PRS sub-issue/PR** — migrate all parser call sites to `bail_coded!`
with their real PRS-0xx codes; add regression tests asserting the
specific code comes back from `db.execute()` for each.

**Blocker discovered during the Foundation PR's final review, must be
resolved before this PR is planned in detail**: `src/query/datalog/
parser.rs` is NOT `anyhow`-based — every parser function, including
`pub fn parse_datalog_command(input: &str) -> Result<DatalogCommand,
String>` (118 `Err(...)` returns across the file), returns
`Result<_, String>`. `bail_coded!`/`err_coded!` expand to
`Err(anyhow::Error::new(CodedError::new(...)))`, which is a type error
inside any `Result<_, String>`-returning function — they cannot be used
as a drop-in replacement in `parser.rs` as originally assumed ("a
drop-in replacement for existing `bail!(...)`/`anyhow!(...)` call
sites" in the Call-site macros section above holds for every other
module, but not this one). PRS is 79 of the 130 documented codes —
the largest category and the very next PR, so this must be decided
before that PR's plan is written, not discovered mid-implementation.
Compounding it: `Minigraf::execute`/`WriteTransaction::execute`/
`Minigraf::prepare` in `src/db.rs` each call
`parse_datalog_command(input).map_err(|e| anyhow::anyhow!("{}", e))?`
— even if the parser produced a `CodedError` some other way, this
`map_err` stringifies it via `Display`, destroying the error chain at
exactly the boundary the code-detection in `MinigrafError::from`
depends on (the chain-walk would find nothing and silently fall back
to `INT-000`, defeating the whole PR). Two options to resolve before
PRS is planned: (a) change `parser.rs`'s error type to `anyhow::Error`
(or a new `CodedError`-carrying type) and drop the three stringifying
`map_err` calls in `db.rs`, or (b) add a parallel `String`-flavored
code-carrying mechanism for `parser.rs` specifically. (a) is simpler
and keeps one mechanism crate-wide; (b) avoids touching parser.rs's
error type but permanently duplicates the tagging machinery. No
decision made yet — flag for brainstorming when PRS is picked up.
3. **STG sub-issue/PR** — storage/file-format call sites, STG-0xx.
4. **WAL sub-issue/PR** — WAL call sites, WAL-0xx.
5. **QRY sub-issue/PR** — query executor call sites, QRY-0xx.
Expand Down
85 changes: 74 additions & 11 deletions src/db.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ use crate::graph::types::{Fact, TxId, VALID_TIME_FOREVER};
/// in any practical context, avoiding the collision that `0` would have with the Unix
/// epoch (1970-01-01T00:00:00Z), which is a legitimate `valid_from` value.
pub(crate) const VALID_FROM_USE_TX_TIME: i64 = i64::MIN;
use crate::error::MinigrafError;
use crate::graph::FactStorage;
use crate::graph::types::Value;
use crate::query::datalog::evaluator::DEFAULT_MAX_DERIVED_FACTS;
Expand Down Expand Up @@ -218,7 +219,7 @@ impl OpenOptions {
/// # Errors
///
/// Returns an error if the in-memory storage backend fails to initialise.
pub fn open_memory(self) -> Result<Minigraf> {
pub fn open_memory(self) -> Result<Minigraf, MinigrafError> {
Minigraf::in_memory_with_options(self)
}
}
Expand All @@ -238,7 +239,7 @@ impl OpenOptionsWithPath {
///
/// Returns an error if the file cannot be opened, the header is corrupt,
/// or WAL replay fails.
pub fn open(self) -> Result<Minigraf> {
pub fn open(self) -> Result<Minigraf, MinigrafError> {
Minigraf::open_with_options(self.path, self.opts)
}
}
Expand Down Expand Up @@ -359,7 +360,7 @@ impl Minigraf {
/// Returns an error if the file cannot be opened, the header is corrupt,
/// or WAL replay fails.
#[cfg(not(target_arch = "wasm32"))]
pub fn open(path: impl AsRef<Path>) -> Result<Self> {
pub fn open(path: impl AsRef<Path>) -> Result<Self, MinigrafError> {
Self::open_with_options(path, OpenOptions::default())
}

Expand All @@ -370,7 +371,15 @@ impl Minigraf {
/// Returns an error if the file cannot be opened, the header is corrupt,
/// or WAL replay fails.
#[cfg(not(target_arch = "wasm32"))]
pub fn open_with_options(path: impl AsRef<Path>, opts: OpenOptions) -> Result<Self> {
pub fn open_with_options(
path: impl AsRef<Path>,
opts: OpenOptions,
) -> Result<Self, MinigrafError> {
Self::open_with_options_inner(path, opts).map_err(MinigrafError::from)
}

#[cfg(not(target_arch = "wasm32"))]
fn open_with_options_inner(path: impl AsRef<Path>, opts: OpenOptions) -> Result<Self> {
let db_path = path.as_ref().to_path_buf();

// Open the main .graph file
Expand Down Expand Up @@ -417,7 +426,7 @@ impl Minigraf {
/// # Errors
///
/// Returns an error if the in-memory storage backend fails to initialise.
pub fn in_memory() -> Result<Self> {
pub fn in_memory() -> Result<Self, MinigrafError> {
Self::in_memory_with_options(OpenOptions::default())
}

Expand All @@ -429,7 +438,11 @@ impl Minigraf {
/// # Errors
///
/// Returns an error if the in-memory storage backend fails to initialise.
pub fn in_memory_with_options(opts: OpenOptions) -> Result<Self> {
pub fn in_memory_with_options(opts: OpenOptions) -> Result<Self, MinigrafError> {
Self::in_memory_with_options_inner(opts).map_err(MinigrafError::from)
}

fn in_memory_with_options_inner(opts: OpenOptions) -> Result<Self> {
let backend = MemoryBackend::new();
let pfs = PersistentFactStorage::new(backend, memory_page_cache_capacity(&opts))?;
let fact_storage = pfs.storage().clone();
Expand Down Expand Up @@ -512,7 +525,11 @@ impl Minigraf {
/// - Parsing fails.
/// - Execution fails.
/// - WAL write fails (file-backed databases).
pub fn execute(&self, input: &str) -> Result<QueryResult> {
pub fn execute(&self, input: &str) -> Result<QueryResult, MinigrafError> {
self.execute_inner(input).map_err(MinigrafError::from)
}

fn execute_inner(&self, input: &str) -> Result<QueryResult> {
// Detect same-thread reentrant write (would deadlock on the Mutex).
if is_write_tx_active() {
bail!(
Expand Down Expand Up @@ -623,7 +640,11 @@ impl Minigraf {
/// # Errors
///
/// Returns an error if a `WriteTransaction` is already active on **this thread**.
pub fn begin_write(&self) -> Result<WriteTransaction<'_>> {
pub fn begin_write(&self) -> Result<WriteTransaction<'_>, MinigrafError> {
self.begin_write_inner().map_err(MinigrafError::from)
}

fn begin_write_inner(&self) -> Result<WriteTransaction<'_>> {
if is_write_tx_active() {
bail!(
"a WriteTransaction is already in progress on this thread; use tx.execute() instead"
Expand Down Expand Up @@ -654,7 +675,11 @@ impl Minigraf {
/// # Errors
///
/// Returns an error if the write lock is poisoned or the checkpoint I/O fails.
pub fn checkpoint(&self) -> Result<()> {
pub fn checkpoint(&self) -> Result<(), MinigrafError> {
self.checkpoint_inner().map_err(MinigrafError::from)
}

fn checkpoint_inner(&self) -> Result<()> {
let mut ctx = self.inner.write_lock.lock().map_err(|_| {
anyhow::anyhow!("write lock is poisoned; database may be in an inconsistent state")
})?;
Expand Down Expand Up @@ -740,6 +765,13 @@ impl Minigraf {
pub fn prepare(
&self,
query_str: &str,
) -> Result<crate::query::datalog::prepared::PreparedQuery, MinigrafError> {
self.prepare_inner(query_str).map_err(MinigrafError::from)
}

fn prepare_inner(
&self,
query_str: &str,
) -> Result<crate::query::datalog::prepared::PreparedQuery> {
use crate::query::datalog::prepared::prepare_query;

Expand Down Expand Up @@ -898,6 +930,20 @@ impl Minigraf {
init: impl Fn() -> Acc + Send + Sync + 'static,
step: impl Fn(&mut Acc, &Value) + Send + Sync + 'static,
finalise: impl Fn(&Acc, usize) -> Value + Send + Sync + 'static,
) -> Result<(), MinigrafError>
where
Acc: Any + Send + 'static,
{
self.register_aggregate_inner(name, init, step, finalise)
.map_err(MinigrafError::from)
}

fn register_aggregate_inner<Acc>(
&self,
name: &str,
init: impl Fn() -> Acc + Send + Sync + 'static,
step: impl Fn(&mut Acc, &Value) + Send + Sync + 'static,
finalise: impl Fn(&Acc, usize) -> Value + Send + Sync + 'static,
) -> Result<()>
where
Acc: Any + Send + 'static,
Expand Down Expand Up @@ -954,6 +1000,15 @@ impl Minigraf {
&self,
name: &str,
f: impl Fn(&Value) -> bool + Send + Sync + 'static,
) -> Result<(), MinigrafError> {
self.register_predicate_inner(name, f)
.map_err(MinigrafError::from)
}

fn register_predicate_inner(
&self,
name: &str,
f: impl Fn(&Value) -> bool + Send + Sync + 'static,
) -> Result<()> {
let desc = PredicateDesc {
f: Arc::new(f),
Expand Down Expand Up @@ -1008,7 +1063,11 @@ impl<'a> WriteTransaction<'a> {
/// # Errors
///
/// Returns an error if parsing or execution fails.
pub fn execute(&mut self, input: &str) -> Result<QueryResult> {
pub fn execute(&mut self, input: &str) -> Result<QueryResult, MinigrafError> {
self.execute_inner(input).map_err(MinigrafError::from)
}

fn execute_inner(&mut self, input: &str) -> Result<QueryResult> {
let cmd = parse_datalog_command(input).map_err(|e| anyhow::anyhow!("{}", e))?;

match cmd {
Expand Down Expand Up @@ -1094,7 +1153,11 @@ impl<'a> WriteTransaction<'a> {
/// # Errors
///
/// Returns an error if the WAL write or fact application fails.
pub fn commit(mut self) -> Result<()> {
pub fn commit(self) -> Result<(), MinigrafError> {
self.commit_inner().map_err(MinigrafError::from)
}

fn commit_inner(mut self) -> Result<()> {
let facts_to_commit = std::mem::take(&mut self.pending_facts);

if !facts_to_commit.is_empty() {
Expand Down
Loading
Loading
0