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