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
16pub const ENVELOPE_VERSION: u64 = 1;
19
20pub 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 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
105pub 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
170fn 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
193fn temporary_file_of(error: &io::Error) -> Option<&AtomicTemporaryFileError> {
198 AtomicTemporaryFileError::downcast(error)
199}
200
201fn 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
269fn 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
281fn 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
295pub 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
382pub 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 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 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}