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