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