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