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