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