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