Skip to main content

hayson_binding_shared/
errors.rs

1use std::io;
2use std::path::Path;
3
4use hayson_engine::atomic::AtomicTemporaryFileError;
5use hayson_engine::catalog::MigrateFormat1Error;
6use hayson_engine::database::DatabaseError;
7use hayson_engine::errors::{HaysonCipherError, HaysonIndexMismatchError};
8use hayson_engine::lock::{DirLockError, LockedError};
9use hayson_engine::table::TableError;
10use hayson_engine::txn::TxnError;
11use serde_json::{json, Map, Value};
12
13/// Envelope version of the JSON carried in every thrown `Error.message`. Bumped only if the shape
14/// below changes incompatibly — the TS shim reads it before trusting `code`.
15pub const ENVELOPE_VERSION: u64 = 1;
16
17/// Stable, machine-readable tags a TS shim switches on to re-throw the matching `HaysonXxxError`.
18/// Every `DatabaseError`/`TableError` variant maps onto exactly one of these.
19pub mod code {
20    pub const VALIDATION: &str = "Validation";
21    pub const UNIQUE_VIOLATION: &str = "UniqueViolation";
22    pub const PENDING_CONFLICT: &str = "PendingConflict";
23    pub const NOT_FOUND: &str = "NotFound";
24    pub const SCHEMA: &str = "Schema";
25    pub const PARTITION: &str = "Partition";
26    pub const CIPHER: &str = "Cipher";
27    pub const LOCKED: &str = "Locked";
28    pub const LOCK_LOST: &str = "LockLost";
29    pub const SUSPICIOUS_LOCK_FILE: &str = "SuspiciousLockFile";
30    pub const READ_ONLY: &str = "ReadOnly";
31    pub const FIELD_IN_USE: &str = "FieldInUse";
32    pub const CATALOG: &str = "Catalog";
33    pub const TXN: &str = "Txn";
34    pub const INDEX_MISMATCH: &str = "IndexMismatch";
35    pub const TEMPORARY_FILE: &str = "TemporaryFile";
36    pub const IO: &str = "Io";
37    pub const INVALID_JSON: &str = "InvalidJson";
38    pub const CLOSED: &str = "Closed";
39}
40
41fn envelope(code: &str, message: String, fields: Map<String, Value>) -> Value {
42    let mut object = Map::new();
43    object.insert("envelopeVersion".to_string(), json!(ENVELOPE_VERSION));
44    object.insert("code".to_string(), json!(code));
45    object.insert("message".to_string(), json!(message));
46    for (key, value) in fields {
47        object.insert(key, value);
48    }
49    Value::Object(object)
50}
51
52fn no_fields() -> Map<String, Value> {
53    Map::new()
54}
55
56fn fields(pairs: Value) -> Map<String, Value> {
57    match pairs {
58        Value::Object(map) => map,
59        _ => Map::new(),
60    }
61}
62
63fn describe_cipher_error(error: &HaysonCipherError) -> Value {
64    envelope(
65        code::CIPHER,
66        error.to_string(),
67        fields(json!({
68            "table": error.table,
69            "field": error.field,
70            "reason": error.reason,
71            "keyId": error.key_id,
72        })),
73    )
74}
75
76fn describe_locked_error(error: &LockedError) -> Value {
77    // NOTE: `subject` is the database directory, not the lock file — `DirLock::acquire` takes the
78    // parent of the path it was given before building this error.
79    let dir = error.subject.display().to_string();
80    let holder = error.holder.as_ref().map(|holder| {
81        json!({
82            "owner": holder.owner,
83            "pid": holder.pid,
84            "host": holder.host,
85            "startedAt": holder.started_at,
86            "heartbeatAt": holder.heartbeat_at,
87        })
88    });
89    envelope(
90        code::LOCKED,
91        error.to_string(),
92        fields(json!({ "dir": dir, "holder": holder, "waitedMs": error.waited_ms })),
93    )
94}
95
96/// `table` is the name the call was made against — `TableError` itself carries one only on the
97/// variants built from a `HaysonXxxError`, and even then not on `Io`.
98pub fn describe_table_error(table: &str, error: &TableError) -> Value {
99    let message = error.to_string();
100    match error {
101        TableError::Validation(inner) => envelope(
102            code::VALIDATION,
103            message,
104            fields(json!({
105                "table": inner.table,
106                "issues": inner
107                    .issues
108                    .iter()
109                    .map(|issue| json!({ "field": issue.field, "reason": issue.reason }))
110                    .collect::<Vec<_>>(),
111            })),
112        ),
113        TableError::Unique(inner) => envelope(
114            code::UNIQUE_VIOLATION,
115            message,
116            fields(json!({ "table": inner.table, "field": inner.field, "holder": inner.holder })),
117        ),
118        TableError::Conflict(inner) => envelope(
119            code::PENDING_CONFLICT,
120            message,
121            fields(json!({ "table": inner.table, "key": inner.key, "fields": inner.fields })),
122        ),
123        TableError::NotFound(inner) => envelope(
124            code::NOT_FOUND,
125            message,
126            fields(json!({ "table": inner.table, "key": inner.key })),
127        ),
128        TableError::Schema(_) => envelope(code::SCHEMA, message, fields(json!({ "table": table }))),
129        TableError::Partition(inner) => envelope(
130            code::PARTITION,
131            message,
132            fields(json!({ "table": inner.table, "field": inner.field, "reason": inner.reason })),
133        ),
134        TableError::Cipher(inner) => describe_cipher_error(inner),
135        TableError::Io(inner) => describe_io_error(inner),
136    }
137}
138
139/// `checkpoint::restore_or_rebuild` reports a snapshot paired with a different log through
140/// `io::Error::other`, so the typed refusal survives only as the boxed source of that io error.
141/// Without this the shim sees `Io` and cannot tell the index-mismatch refusal from a disk failure.
142fn index_mismatch_of(error: &io::Error) -> Option<&HaysonIndexMismatchError> {
143    error.get_ref()?.downcast_ref::<HaysonIndexMismatchError>()
144}
145
146/// `atomic::open_temporary` refuses a `.tmp` path it could not claim through an `io::Error` that
147/// boxes the typed refusal, so the path and the reason survive only as that boxed source. Without
148/// this the shim sees `Io` — or, on a catalog write, `Catalog` — and the caller loses the one file
149/// it has to remove before retrying.
150fn temporary_file_of(error: &io::Error) -> Option<&AtomicTemporaryFileError> {
151    AtomicTemporaryFileError::downcast(error)
152}
153
154pub fn describe_io_error(error: &io::Error) -> Value {
155    if let Some(inner) = temporary_file_of(error) {
156        return envelope(
157            code::TEMPORARY_FILE,
158            error.to_string(),
159            fields(json!({
160                "path": inner.path.display().to_string(),
161                "reason": inner.reason,
162            })),
163        );
164    }
165    if let Some(inner) = index_mismatch_of(error) {
166        return envelope(
167            code::INDEX_MISMATCH,
168            error.to_string(),
169            fields(json!({
170                "indexPath": inner.path,
171                "logPath": inner.log_path,
172                "watermark": inner.watermark,
173                "expectedHash": inner.expected_hash,
174                "actualHash": inner.actual_hash,
175            })),
176        );
177    }
178    envelope(
179        code::IO,
180        error.to_string(),
181        fields(json!({ "kind": format!("{:?}", error.kind()) })),
182    )
183}
184
185/// The errno name a host raised in a language whose IO errors carry one — Node reports it as
186/// `error.code`, and a caller that means to tell "this file is gone" from "this file is unreadable"
187/// has nothing else to go on. `None` for a kind with no single portable name, in which case the
188/// envelope simply omits it rather than guessing.
189fn errno_name_of(error: &io::Error) -> Option<&'static str> {
190    match error.kind() {
191        io::ErrorKind::NotFound => Some("ENOENT"),
192        io::ErrorKind::PermissionDenied => Some("EACCES"),
193        _ => None,
194    }
195}
196
197/// A [`describe_io_error`] that knows which file failed. `io::Error` carries no path, so only a
198/// call site that kept one can produce this — today `DatabaseError::Log`, raised by the readonly
199/// open probing the logs it is about to take on.
200fn describe_log_io_error(path: &Path, error: &io::Error) -> Value {
201    let mut described = fields(json!({
202        "kind": format!("{:?}", error.kind()),
203        "path": path.display().to_string(),
204    }));
205    if let Some(name) = errno_name_of(error) {
206        described.insert("errnoCode".to_string(), json!(name));
207    }
208    envelope(code::IO, format!("{error}, open \"{}\"", path.display()), described)
209}
210
211/// Public because a binding raises `TxnError::AlreadyCommitted` itself: a unit of work handed to
212/// the engine is consumed there, so a second call on the same host-side handle never reaches the
213/// engine to be refused by it.
214pub fn describe_txn_error(error: &TxnError) -> Value {
215    match error {
216        TxnError::Table(name, inner) => describe_table_error(name, inner),
217        TxnError::Io(inner) => describe_io_error(inner),
218        TxnError::UnknownTable(name) => envelope(code::NOT_FOUND, error.to_string(), fields(json!({ "table": name }))),
219        TxnError::AlreadyCommitted | TxnError::JournalPending(_) => envelope(code::TXN, error.to_string(), no_fields()),
220    }
221}
222
223pub fn describe_database_error(error: &DatabaseError) -> Value {
224    let message = error.to_string();
225    match error {
226        DatabaseError::Lock(DirLockError::Locked(inner)) => describe_locked_error(inner),
227        DatabaseError::Lock(DirLockError::Lost(path)) => envelope(
228            code::LOCK_LOST,
229            message,
230            fields(json!({ "lockPath": path.display().to_string() })),
231        ),
232        DatabaseError::Lock(DirLockError::SuspiciousLockFile(path)) => envelope(
233            code::SUSPICIOUS_LOCK_FILE,
234            message,
235            fields(json!({ "lockPath": path.display().to_string() })),
236        ),
237        DatabaseError::Lock(DirLockError::Io(inner)) => describe_io_error(inner),
238        DatabaseError::Schema(_) => envelope(code::SCHEMA, message, no_fields()),
239        DatabaseError::ReadOnly { dir, operation } => envelope(
240            code::READ_ONLY,
241            message,
242            fields(json!({ "dir": dir.display().to_string(), "operation": operation })),
243        ),
244        DatabaseError::FieldInUse { table, field, records } => envelope(
245            code::FIELD_IN_USE,
246            message,
247            fields(json!({ "table": table, "field": field, "records": records })),
248        ),
249        DatabaseError::Catalog(_) => envelope(code::CATALOG, message, no_fields()),
250        DatabaseError::Io(inner) => describe_io_error(inner),
251        DatabaseError::Log(path, inner) => describe_log_io_error(path, inner),
252        DatabaseError::Table(name, inner) => describe_table_error(name, inner),
253        DatabaseError::Txn(inner) => describe_txn_error(inner),
254        DatabaseError::Cipher(inner) => describe_cipher_error(inner),
255    }
256}
257
258pub fn describe_migrate_format1_error(error: &MigrateFormat1Error) -> Value {
259    let message = error.to_string();
260    match error {
261        MigrateFormat1Error::Schema(_) | MigrateFormat1Error::Write(_) => envelope(code::SCHEMA, message, no_fields()),
262        MigrateFormat1Error::Io(inner) => describe_io_error(inner),
263        MigrateFormat1Error::NotADirectory(path)
264        | MigrateFormat1Error::UnreadableMarker(path, _)
265        | MigrateFormat1Error::UnknownMarker(path, _)
266        | MigrateFormat1Error::NoSchemaFiles(path) => envelope(
267            code::SCHEMA,
268            message,
269            fields(json!({ "path": path.display().to_string() })),
270        ),
271    }
272}
273
274pub fn describe_invalid_json(what: &str, error: &serde_json::Error) -> Value {
275    envelope(
276        code::INVALID_JSON,
277        format!("{what} is not valid JSON: {error}"),
278        fields(json!({ "argument": what })),
279    )
280}
281
282pub fn describe_unknown_table(table: &str, dir: &str) -> Value {
283    envelope(
284        code::NOT_FOUND,
285        format!("table \"{table}\" does not exist in \"{dir}\""),
286        fields(json!({ "table": table, "dir": dir })),
287    )
288}
289
290pub fn describe_closed(operation: &str) -> Value {
291    envelope(
292        code::CLOSED,
293        format!("{operation} was called after the database was closed"),
294        fields(json!({ "operation": operation })),
295    )
296}
297
298/// Free-form refusals raised by this binding itself (a malformed `open` option, an unusable key).
299pub fn describe_schema(message: String) -> Value {
300    envelope(code::SCHEMA, message, no_fields())
301}
302
303pub fn describe_cipher_config(message: String) -> Value {
304    envelope(code::CIPHER, message, fields(json!({ "table": "", "field": "" })))
305}
306
307#[cfg(test)]
308mod tests {
309    use hayson_engine::errors::{
310        HaysonNotFoundError, HaysonPartitionError, HaysonSchemaError, HaysonUniqueViolationError,
311        HaysonValidationError, ValidationIssue,
312    };
313    use hayson_engine::lock::LockHolder;
314    use std::path::PathBuf;
315
316    use super::*;
317
318    fn scratch_dir(prefix: &str) -> PathBuf {
319        let dir = std::env::temp_dir().join(format!(
320            "{prefix}{}-{}",
321            std::process::id(),
322            std::time::SystemTime::now()
323                .duration_since(std::time::UNIX_EPOCH)
324                .unwrap()
325                .as_nanos()
326        ));
327        std::fs::create_dir_all(&dir).unwrap();
328        dir
329    }
330
331    fn code_of(value: &Value) -> &str {
332        value.get("code").and_then(Value::as_str).unwrap()
333    }
334
335    #[test]
336    fn a_validation_error_carries_every_issue_the_shim_needs_to_rebuild_it() {
337        let error = TableError::Validation(HaysonValidationError {
338            table: "users".into(),
339            issues: vec![ValidationIssue {
340                field: "name".into(),
341                reason: "is required".into(),
342            }],
343        });
344
345        let described = describe_table_error("users", &error);
346
347        assert_eq!(code_of(&described), code::VALIDATION);
348        assert_eq!(described["table"], json!("users"));
349        assert_eq!(
350            described["issues"],
351            json!([{ "field": "name", "reason": "is required" }])
352        );
353        assert_eq!(described["envelopeVersion"], json!(ENVELOPE_VERSION));
354        assert_eq!(described["message"], json!(error.to_string()));
355    }
356
357    #[test]
358    fn a_unique_violation_names_the_field_and_the_key_already_holding_it() {
359        let error = TableError::Unique(HaysonUniqueViolationError {
360            table: "users".into(),
361            field: "email".into(),
362            holder: "u1".into(),
363        });
364
365        let described = describe_table_error("users", &error);
366
367        assert_eq!(code_of(&described), code::UNIQUE_VIOLATION);
368        assert_eq!(described["field"], json!("email"));
369        assert_eq!(described["holder"], json!("u1"));
370    }
371
372    #[test]
373    fn a_not_found_error_keeps_the_table_and_key_apart_from_the_message() {
374        let error = TableError::NotFound(HaysonNotFoundError {
375            table: "users".into(),
376            key: "u9".into(),
377        });
378
379        let described = describe_table_error("users", &error);
380
381        assert_eq!(code_of(&described), code::NOT_FOUND);
382        assert_eq!(described["key"], json!("u9"));
383    }
384
385    #[test]
386    fn a_partition_error_is_tagged_apart_from_a_plain_schema_rejection() {
387        let partition = TableError::Partition(HaysonPartitionError::new("events", "day", "is empty"));
388        let schema = TableError::Schema(HaysonSchemaError("no".into()));
389
390        assert_eq!(code_of(&describe_table_error("events", &partition)), code::PARTITION);
391        assert_eq!(code_of(&describe_table_error("events", &schema)), code::SCHEMA);
392    }
393
394    #[test]
395    fn a_cipher_error_carries_the_key_id_it_could_not_use() {
396        let error = TableError::Cipher(Box::new(
397            HaysonCipherError::new("secrets", "token", "cannot be opened").with_key_id("k2"),
398        ));
399
400        let described = describe_table_error("secrets", &error);
401
402        assert_eq!(code_of(&described), code::CIPHER);
403        assert_eq!(described["keyId"], json!("k2"));
404        assert_eq!(described["field"], json!("token"));
405    }
406
407    #[test]
408    fn a_locked_database_reports_the_directory_the_holder_and_how_long_we_waited() {
409        let error = DatabaseError::Lock(DirLockError::Locked(LockedError {
410            // `DirLock::acquire` stores the directory here, not the `.hayson.lock` inside it.
411            subject: PathBuf::from("/data/db"),
412            holder: Some(LockHolder {
413                owner: "0f1e2d3c".into(),
414                pid: Some(42),
415                host: Some("box".into()),
416                started_at: 10,
417                heartbeat_at: 20,
418            }),
419            waited_ms: 250,
420        }));
421
422        let described = describe_database_error(&error);
423
424        assert_eq!(code_of(&described), code::LOCKED);
425        assert_eq!(described["dir"], json!("/data/db"));
426        assert_eq!(described["waitedMs"], json!(250));
427        assert_eq!(described["holder"]["owner"], json!("0f1e2d3c"));
428        assert_eq!(described["holder"]["pid"], json!(42));
429        assert_eq!(described["holder"]["startedAt"], json!(10));
430    }
431
432    #[test]
433    fn a_lock_taken_over_by_another_writer_reports_the_lock_path_under_its_own_code() {
434        let error = DatabaseError::Lock(DirLockError::Lost(PathBuf::from("/data/db/.hayson.lock")));
435
436        let described = describe_database_error(&error);
437
438        assert_eq!(code_of(&described), code::LOCK_LOST);
439        assert_eq!(described["lockPath"], json!("/data/db/.hayson.lock"));
440    }
441
442    #[test]
443    fn a_symlink_planted_at_the_lock_path_reports_the_lock_path_under_its_own_code() {
444        let error = DatabaseError::Lock(DirLockError::SuspiciousLockFile(PathBuf::from("/data/db/.hayson.lock")));
445
446        let described = describe_database_error(&error);
447
448        assert_eq!(code_of(&described), code::SUSPICIOUS_LOCK_FILE);
449        assert_eq!(described["lockPath"], json!("/data/db/.hayson.lock"));
450    }
451
452    #[test]
453    fn a_readonly_refusal_is_told_apart_from_a_genuine_schema_error() {
454        let readonly = DatabaseError::ReadOnly {
455            dir: PathBuf::from("/data/db"),
456            operation: "createTable".into(),
457        };
458        let genuine = DatabaseError::Schema(HaysonSchemaError("field \"id\" has no type".into()));
459
460        let described = describe_database_error(&readonly);
461        assert_eq!(code_of(&described), code::READ_ONLY);
462        assert_eq!(described["operation"], json!("createTable"));
463        assert_eq!(described["dir"], json!("/data/db"));
464
465        assert_eq!(code_of(&describe_database_error(&genuine)), code::SCHEMA);
466    }
467
468    #[test]
469    fn a_residual_field_refusal_is_told_apart_from_a_plain_catalog_error() {
470        let in_use = DatabaseError::FieldInUse {
471            table: "users".into(),
472            field: "name".into(),
473            records: 3,
474        };
475        let plain = DatabaseError::Catalog("table \"ghost\" does not exist in \"/data/db\"".into());
476
477        let described = describe_database_error(&in_use);
478        assert_eq!(code_of(&described), code::FIELD_IN_USE);
479        assert_eq!(described["table"], json!("users"));
480        assert_eq!(described["field"], json!("name"));
481        assert_eq!(described["records"], json!(3));
482
483        assert_eq!(code_of(&describe_database_error(&plain)), code::CATALOG);
484    }
485
486    #[test]
487    fn a_table_scoped_database_error_is_described_as_its_inner_table_error() {
488        let error = DatabaseError::Table(
489            "users".into(),
490            TableError::NotFound(HaysonNotFoundError {
491                table: "users".into(),
492                key: "u9".into(),
493            }),
494        );
495
496        assert_eq!(code_of(&describe_database_error(&error)), code::NOT_FOUND);
497    }
498
499    #[test]
500    fn an_unknown_table_inside_a_unit_of_work_is_reported_as_not_found() {
501        let error = DatabaseError::Txn(TxnError::UnknownTable("ghost".into()));
502
503        let described = describe_database_error(&error);
504
505        assert_eq!(code_of(&described), code::NOT_FOUND);
506        assert_eq!(described["table"], json!("ghost"));
507    }
508
509    #[test]
510    fn an_io_failure_keeps_its_kind_so_the_shim_can_tell_missing_from_denied() {
511        let error = DatabaseError::Io(io::Error::new(io::ErrorKind::PermissionDenied, "nope"));
512
513        let described = describe_database_error(&error);
514
515        assert_eq!(code_of(&described), code::IO);
516        assert_eq!(described["kind"], json!("PermissionDenied"));
517    }
518
519    #[test]
520    fn an_io_failure_the_engine_could_name_a_file_for_carries_the_path_and_the_errno_name() {
521        let error = DatabaseError::Log(
522            PathBuf::from("/data/db/notes/note.jsonl"),
523            io::Error::from(io::ErrorKind::NotFound),
524        );
525
526        let described = describe_database_error(&error);
527
528        assert_eq!(code_of(&described), code::IO);
529        assert_eq!(described["path"], json!("/data/db/notes/note.jsonl"));
530        assert_eq!(described["errnoCode"], json!("ENOENT"));
531    }
532
533    #[test]
534    fn an_io_kind_with_no_portable_errno_name_leaves_the_field_out_rather_than_guessing() {
535        let error = DatabaseError::Log(
536            PathBuf::from("/data/db/notes.jsonl"),
537            io::Error::new(io::ErrorKind::InvalidData, "garbled"),
538        );
539
540        let described = describe_database_error(&error);
541
542        assert_eq!(described["path"], json!("/data/db/notes.jsonl"));
543        assert!(described.get("errnoCode").is_none(), "{described}");
544    }
545
546    #[test]
547    fn a_read_that_failed_to_decrypt_arrives_typed_with_the_key_id_the_flattened_text_used_to_lose() {
548        let error = TableError::Cipher(Box::new(
549            HaysonCipherError::new("secrets", "token", "was written with key \"k1\", which is not on hand")
550                .with_key_id("k1"),
551        ));
552
553        let described = describe_table_error("secrets", &error);
554
555        assert_eq!(code_of(&described), code::CIPHER);
556        assert_eq!(described["table"], json!("secrets"));
557        assert_eq!(described["field"], json!("token"));
558        assert_eq!(described["keyId"], json!("k1"));
559    }
560
561    #[test]
562    fn an_io_failure_on_a_read_stays_an_io_failure_now_that_nothing_reads_its_text() {
563        let corrupt = TableError::Io(io::Error::new(
564            io::ErrorKind::InvalidData,
565            "log \"a.jsonl\" is corrupt at offset 12",
566        ));
567
568        assert_eq!(code_of(&describe_table_error("notes", &corrupt)), code::IO);
569    }
570
571    #[test]
572    fn a_search_against_a_table_with_no_fulltext_field_arrives_as_a_typed_schema_refusal() {
573        let error = TableError::Schema(HaysonSchemaError(
574            "table \"users\" has no field marked \"fulltext\": true — search is not available".into(),
575        ));
576
577        let described = describe_table_error("users", &error);
578
579        assert_eq!(code_of(&described), code::SCHEMA);
580        assert_eq!(described["table"], json!("users"));
581    }
582
583    #[test]
584    fn a_refused_index_snapshot_travels_under_its_own_code_with_both_paths_and_the_watermark() {
585        let error = DatabaseError::Io(io::Error::other(HaysonIndexMismatchError {
586            path: "/data/db/users.idx".into(),
587            log_path: "/data/db/users.jsonl".into(),
588            watermark: 397,
589            expected_hash: "9f2c000000000000".into(),
590            actual_hash: "4b1e000000000000".into(),
591        }));
592
593        let described = describe_database_error(&error);
594
595        assert_eq!(code_of(&described), code::INDEX_MISMATCH);
596        assert_eq!(described["indexPath"], json!("/data/db/users.idx"));
597        assert_eq!(described["logPath"], json!("/data/db/users.jsonl"));
598        assert_eq!(described["watermark"], json!(397));
599        assert_eq!(described["expectedHash"], json!("9f2c000000000000"));
600        assert_eq!(described["actualHash"], json!("4b1e000000000000"));
601        assert_eq!(described["message"], json!(error.to_string()));
602    }
603
604    #[test]
605    fn a_temporary_path_that_could_not_be_claimed_travels_under_its_own_code_with_path_and_reason() {
606        let dir = scratch_dir("hayson-envelope-temporary-");
607        // A directory on the .tmp path: open_exclusive reports AlreadyExists and remove_file cannot
608        // clear it, which is the shape of the refusal a catalog write hits on EISDIR.
609        let temporary = dir.join("hayson.json.tmp");
610        std::fs::create_dir(&temporary).unwrap();
611        let refusal = hayson_engine::atomic::open_temporary(&temporary, None).unwrap_err();
612        let message = refusal.to_string();
613        let error = DatabaseError::Io(refusal);
614
615        let described = describe_database_error(&error);
616        std::fs::remove_dir_all(&dir).unwrap();
617
618        assert_eq!(code_of(&described), code::TEMPORARY_FILE);
619        assert_eq!(described["path"], json!(temporary.display().to_string()));
620        assert_eq!(described["reason"], json!("is in the way and could not be cleared, so nothing on disk was changed — remove whatever holds that path and retry"));
621        assert_eq!(described["message"], json!(message));
622    }
623
624    #[test]
625    fn an_io_error_that_boxes_nothing_typed_is_still_reported_as_io() {
626        let error = io::Error::other("snapshot \"a.idx\" claims watermark and schema match the log");
627
628        assert_eq!(code_of(&describe_io_error(&error)), code::IO);
629    }
630}