use std::io;
use std::path::Path;
use hayson_engine::atomic::AtomicTemporaryFileError;
use hayson_engine::catalog::MigrateFormat1Error;
use hayson_engine::database::DatabaseError;
use hayson_engine::errors::{HaysonCipherError, HaysonIndexMismatchError, HaysonLogReplacedError};
use hayson_engine::lock::{DirLockError, LockedError};
use hayson_engine::table::TableError;
use hayson_engine::txn::TxnError;
use serde_json::{json, Map, Value};
pub const ENVELOPE_VERSION: u64 = 1;
pub mod code {
pub const VALIDATION: &str = "Validation";
pub const UNIQUE_VIOLATION: &str = "UniqueViolation";
pub const PENDING_CONFLICT: &str = "PendingConflict";
pub const NOT_FOUND: &str = "NotFound";
pub const SCHEMA: &str = "Schema";
pub const PARTITION: &str = "Partition";
pub const RELATION_VIOLATION: &str = "RelationViolation";
pub const RELATION_RESTRICT: &str = "RelationRestrict";
pub const CIPHER: &str = "Cipher";
pub const LOCKED: &str = "Locked";
pub const LOCK_LOST: &str = "LockLost";
pub const SUSPICIOUS_LOCK_FILE: &str = "SuspiciousLockFile";
pub const READ_ONLY: &str = "ReadOnly";
pub const FIELD_IN_USE: &str = "FieldInUse";
pub const CATALOG: &str = "Catalog";
pub const TXN: &str = "Txn";
pub const INDEX_MISMATCH: &str = "IndexMismatch";
pub const LOG_REPLACED: &str = "LogReplaced";
pub const TEMPORARY_FILE: &str = "TemporaryFile";
pub const IO: &str = "Io";
pub const INVALID_JSON: &str = "InvalidJson";
pub const CLOSED: &str = "Closed";
}
fn envelope(code: &str, message: String, fields: Map<String, Value>) -> Value {
let mut object = Map::new();
object.insert("envelopeVersion".to_string(), json!(ENVELOPE_VERSION));
object.insert("code".to_string(), json!(code));
object.insert("message".to_string(), json!(message));
for (key, value) in fields {
object.insert(key, value);
}
Value::Object(object)
}
fn no_fields() -> Map<String, Value> {
Map::new()
}
fn fields(pairs: Value) -> Map<String, Value> {
match pairs {
Value::Object(map) => map,
_ => Map::new(),
}
}
fn describe_cipher_error(error: &HaysonCipherError) -> Value {
envelope(
code::CIPHER,
error.to_string(),
fields(json!({
"table": error.table,
"field": error.field,
"reason": error.reason,
"keyId": error.key_id,
})),
)
}
fn describe_locked_error(error: &LockedError) -> Value {
let dir = error.subject.display().to_string();
let holder = error.holder.as_ref().map(|holder| {
json!({
"owner": holder.owner,
"pid": holder.pid,
"host": holder.host,
"startedAt": holder.started_at,
"heartbeatAt": holder.heartbeat_at,
})
});
envelope(
code::LOCKED,
error.to_string(),
fields(json!({ "dir": dir, "holder": holder, "waitedMs": error.waited_ms })),
)
}
pub fn describe_table_error(table: &str, error: &TableError) -> Value {
let message = error.to_string();
match error {
TableError::Validation(inner) => envelope(
code::VALIDATION,
message,
fields(json!({
"table": inner.table,
"issues": inner
.issues
.iter()
.map(|issue| json!({ "field": issue.field, "reason": issue.reason }))
.collect::<Vec<_>>(),
})),
),
TableError::Unique(inner) => envelope(
code::UNIQUE_VIOLATION,
message,
fields(json!({ "table": inner.table, "field": inner.field, "holder": inner.holder })),
),
TableError::Conflict(inner) => envelope(
code::PENDING_CONFLICT,
message,
fields(json!({ "table": inner.table, "key": inner.key, "fields": inner.fields })),
),
TableError::NotFound(inner) => envelope(
code::NOT_FOUND,
message,
fields(json!({ "table": inner.table, "key": inner.key })),
),
TableError::Schema(_) => envelope(code::SCHEMA, message, fields(json!({ "table": table }))),
TableError::Partition(inner) => envelope(
code::PARTITION,
message,
fields(json!({ "table": inner.table, "field": inner.field, "reason": inner.reason })),
),
TableError::Relation(inner) => envelope(
code::RELATION_VIOLATION,
message,
fields(json!({
"relation": inner.relation,
"table": inner.table,
"field": inner.field,
"value": inner.value,
})),
),
TableError::RelationRestrict(inner) => envelope(
code::RELATION_RESTRICT,
message,
fields(json!({
"relation": inner.relation,
"table": inner.table,
"key": inner.key,
"referencingTable": inner.referencing_table,
"referencingField": inner.referencing_field,
"referencing": inner.referencing,
})),
),
TableError::Cipher(inner) => describe_cipher_error(inner),
TableError::Io(inner) => describe_io_error(inner),
}
}
fn index_mismatch_of(error: &io::Error) -> Option<&HaysonIndexMismatchError> {
error.get_ref()?.downcast_ref::<HaysonIndexMismatchError>()
}
fn log_replaced_of(error: &io::Error) -> Option<&HaysonLogReplacedError> {
error.get_ref()?.downcast_ref::<HaysonLogReplacedError>()
}
fn temporary_file_of(error: &io::Error) -> Option<&AtomicTemporaryFileError> {
AtomicTemporaryFileError::downcast(error)
}
pub fn describe_io_error(error: &io::Error) -> Value {
if let Some(inner) = temporary_file_of(error) {
return envelope(
code::TEMPORARY_FILE,
error.to_string(),
fields(json!({
"path": inner.path.display().to_string(),
"reason": inner.reason,
})),
);
}
if let Some(inner) = log_replaced_of(error) {
return envelope(
code::LOG_REPLACED,
error.to_string(),
fields(json!({ "path": inner.path })),
);
}
if let Some(inner) = index_mismatch_of(error) {
return envelope(
code::INDEX_MISMATCH,
error.to_string(),
fields(json!({
"indexPath": inner.path,
"logPath": inner.log_path,
"watermark": inner.watermark,
"expectedHash": inner.expected_hash,
"actualHash": inner.actual_hash,
})),
);
}
envelope(
code::IO,
error.to_string(),
fields(json!({ "kind": format!("{:?}", error.kind()) })),
)
}
fn errno_name_of(error: &io::Error) -> Option<&'static str> {
match error.kind() {
io::ErrorKind::NotFound => Some("ENOENT"),
io::ErrorKind::PermissionDenied => Some("EACCES"),
_ => None,
}
}
fn describe_log_io_error(path: &Path, error: &io::Error) -> Value {
let mut described = fields(json!({
"kind": format!("{:?}", error.kind()),
"path": path.display().to_string(),
}));
if let Some(name) = errno_name_of(error) {
described.insert("errnoCode".to_string(), json!(name));
}
envelope(code::IO, format!("{error}, open \"{}\"", path.display()), described)
}
pub fn describe_txn_error(error: &TxnError) -> Value {
match error {
TxnError::Table(name, inner) => describe_table_error(name, inner),
TxnError::Io(inner) => describe_io_error(inner),
TxnError::UnknownTable(name) => envelope(code::NOT_FOUND, error.to_string(), fields(json!({ "table": name }))),
TxnError::AlreadyCommitted | TxnError::JournalPending(_) => envelope(code::TXN, error.to_string(), no_fields()),
}
}
pub fn describe_database_error(error: &DatabaseError) -> Value {
let message = error.to_string();
match error {
DatabaseError::Lock(DirLockError::Locked(inner)) => describe_locked_error(inner),
DatabaseError::Lock(DirLockError::Lost(path)) => envelope(
code::LOCK_LOST,
message,
fields(json!({ "lockPath": path.display().to_string() })),
),
DatabaseError::Lock(DirLockError::SuspiciousLockFile(path)) => envelope(
code::SUSPICIOUS_LOCK_FILE,
message,
fields(json!({ "lockPath": path.display().to_string() })),
),
DatabaseError::Lock(DirLockError::Io(inner)) => describe_io_error(inner),
DatabaseError::Schema(_) => envelope(code::SCHEMA, message, no_fields()),
DatabaseError::ReadOnly { dir, operation } => envelope(
code::READ_ONLY,
message,
fields(json!({ "dir": dir.display().to_string(), "operation": operation })),
),
DatabaseError::FieldInUse { table, field, records } => envelope(
code::FIELD_IN_USE,
message,
fields(json!({ "table": table, "field": field, "records": records })),
),
DatabaseError::Catalog(_) => envelope(code::CATALOG, message, no_fields()),
DatabaseError::Io(inner) => describe_io_error(inner),
DatabaseError::Log(path, inner) => describe_log_io_error(path, inner),
DatabaseError::Table(name, inner) => describe_table_error(name, inner),
DatabaseError::Txn(inner) => describe_txn_error(inner),
DatabaseError::Cipher(inner) => describe_cipher_error(inner),
}
}
pub fn describe_migrate_format1_error(error: &MigrateFormat1Error) -> Value {
let message = error.to_string();
match error {
MigrateFormat1Error::Schema(_) | MigrateFormat1Error::Write(_) => envelope(code::SCHEMA, message, no_fields()),
MigrateFormat1Error::Io(inner) => describe_io_error(inner),
MigrateFormat1Error::NotADirectory(path)
| MigrateFormat1Error::UnreadableMarker(path, _)
| MigrateFormat1Error::UnknownMarker(path, _)
| MigrateFormat1Error::NoSchemaFiles(path) => envelope(
code::SCHEMA,
message,
fields(json!({ "path": path.display().to_string() })),
),
}
}
pub fn describe_invalid_json(what: &str, error: &serde_json::Error) -> Value {
envelope(
code::INVALID_JSON,
format!("{what} is not valid JSON: {error}"),
fields(json!({ "argument": what })),
)
}
pub fn describe_unknown_table(table: &str, dir: &str) -> Value {
envelope(
code::NOT_FOUND,
format!("table \"{table}\" does not exist in \"{dir}\""),
fields(json!({ "table": table, "dir": dir })),
)
}
pub fn describe_closed(operation: &str) -> Value {
envelope(
code::CLOSED,
format!("{operation} was called after the database was closed"),
fields(json!({ "operation": operation })),
)
}
pub fn describe_schema(message: String) -> Value {
envelope(code::SCHEMA, message, no_fields())
}
pub fn describe_cipher_config(message: String) -> Value {
envelope(code::CIPHER, message, fields(json!({ "table": "", "field": "" })))
}
#[cfg(test)]
mod tests {
use hayson_engine::errors::{
HaysonNotFoundError, HaysonPartitionError, HaysonSchemaError, HaysonUniqueViolationError,
HaysonValidationError, ValidationIssue,
};
use hayson_engine::lock::LockHolder;
use std::path::PathBuf;
use super::*;
fn scratch_dir(prefix: &str) -> PathBuf {
let dir = std::env::temp_dir().join(format!(
"{prefix}{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
));
std::fs::create_dir_all(&dir).unwrap();
dir
}
fn code_of(value: &Value) -> &str {
value.get("code").and_then(Value::as_str).unwrap()
}
#[test]
fn a_validation_error_carries_every_issue_the_shim_needs_to_rebuild_it() {
let error = TableError::Validation(HaysonValidationError {
table: "users".into(),
issues: vec![ValidationIssue {
field: "name".into(),
reason: "is required".into(),
}],
});
let described = describe_table_error("users", &error);
assert_eq!(code_of(&described), code::VALIDATION);
assert_eq!(described["table"], json!("users"));
assert_eq!(
described["issues"],
json!([{ "field": "name", "reason": "is required" }])
);
assert_eq!(described["envelopeVersion"], json!(ENVELOPE_VERSION));
assert_eq!(described["message"], json!(error.to_string()));
}
#[test]
fn a_unique_violation_names_the_field_and_the_key_already_holding_it() {
let error = TableError::Unique(HaysonUniqueViolationError {
table: "users".into(),
field: "email".into(),
holder: "u1".into(),
});
let described = describe_table_error("users", &error);
assert_eq!(code_of(&described), code::UNIQUE_VIOLATION);
assert_eq!(described["field"], json!("email"));
assert_eq!(described["holder"], json!("u1"));
}
#[test]
fn a_not_found_error_keeps_the_table_and_key_apart_from_the_message() {
let error = TableError::NotFound(HaysonNotFoundError {
table: "users".into(),
key: "u9".into(),
});
let described = describe_table_error("users", &error);
assert_eq!(code_of(&described), code::NOT_FOUND);
assert_eq!(described["key"], json!("u9"));
}
#[test]
fn a_partition_error_is_tagged_apart_from_a_plain_schema_rejection() {
let partition = TableError::Partition(HaysonPartitionError::new("events", "day", "is empty"));
let schema = TableError::Schema(HaysonSchemaError("no".into()));
assert_eq!(code_of(&describe_table_error("events", &partition)), code::PARTITION);
assert_eq!(code_of(&describe_table_error("events", &schema)), code::SCHEMA);
}
#[test]
fn a_cipher_error_carries_the_key_id_it_could_not_use() {
let error = TableError::Cipher(Box::new(
HaysonCipherError::new("secrets", "token", "cannot be opened").with_key_id("k2"),
));
let described = describe_table_error("secrets", &error);
assert_eq!(code_of(&described), code::CIPHER);
assert_eq!(described["keyId"], json!("k2"));
assert_eq!(described["field"], json!("token"));
}
#[test]
fn a_locked_database_reports_the_directory_the_holder_and_how_long_we_waited() {
let error = DatabaseError::Lock(DirLockError::Locked(LockedError {
subject: PathBuf::from("/data/db"),
holder: Some(LockHolder {
owner: "0f1e2d3c".into(),
pid: Some(42),
host: Some("box".into()),
started_at: 10,
heartbeat_at: 20,
}),
waited_ms: 250,
}));
let described = describe_database_error(&error);
assert_eq!(code_of(&described), code::LOCKED);
assert_eq!(described["dir"], json!("/data/db"));
assert_eq!(described["waitedMs"], json!(250));
assert_eq!(described["holder"]["owner"], json!("0f1e2d3c"));
assert_eq!(described["holder"]["pid"], json!(42));
assert_eq!(described["holder"]["startedAt"], json!(10));
}
#[test]
fn a_lock_taken_over_by_another_writer_reports_the_lock_path_under_its_own_code() {
let error = DatabaseError::Lock(DirLockError::Lost(PathBuf::from("/data/db/.hayson.lock")));
let described = describe_database_error(&error);
assert_eq!(code_of(&described), code::LOCK_LOST);
assert_eq!(described["lockPath"], json!("/data/db/.hayson.lock"));
}
#[test]
fn a_symlink_planted_at_the_lock_path_reports_the_lock_path_under_its_own_code() {
let error = DatabaseError::Lock(DirLockError::SuspiciousLockFile(PathBuf::from("/data/db/.hayson.lock")));
let described = describe_database_error(&error);
assert_eq!(code_of(&described), code::SUSPICIOUS_LOCK_FILE);
assert_eq!(described["lockPath"], json!("/data/db/.hayson.lock"));
}
#[test]
fn a_readonly_refusal_is_told_apart_from_a_genuine_schema_error() {
let readonly = DatabaseError::ReadOnly {
dir: PathBuf::from("/data/db"),
operation: "createTable".into(),
};
let genuine = DatabaseError::Schema(HaysonSchemaError("field \"id\" has no type".into()));
let described = describe_database_error(&readonly);
assert_eq!(code_of(&described), code::READ_ONLY);
assert_eq!(described["operation"], json!("createTable"));
assert_eq!(described["dir"], json!("/data/db"));
assert_eq!(code_of(&describe_database_error(&genuine)), code::SCHEMA);
}
#[test]
fn a_residual_field_refusal_is_told_apart_from_a_plain_catalog_error() {
let in_use = DatabaseError::FieldInUse {
table: "users".into(),
field: "name".into(),
records: 3,
};
let plain = DatabaseError::Catalog("table \"ghost\" does not exist in \"/data/db\"".into());
let described = describe_database_error(&in_use);
assert_eq!(code_of(&described), code::FIELD_IN_USE);
assert_eq!(described["table"], json!("users"));
assert_eq!(described["field"], json!("name"));
assert_eq!(described["records"], json!(3));
assert_eq!(code_of(&describe_database_error(&plain)), code::CATALOG);
}
#[test]
fn a_table_scoped_database_error_is_described_as_its_inner_table_error() {
let error = DatabaseError::Table(
"users".into(),
TableError::NotFound(HaysonNotFoundError {
table: "users".into(),
key: "u9".into(),
}),
);
assert_eq!(code_of(&describe_database_error(&error)), code::NOT_FOUND);
}
#[test]
fn an_unknown_table_inside_a_unit_of_work_is_reported_as_not_found() {
let error = DatabaseError::Txn(TxnError::UnknownTable("ghost".into()));
let described = describe_database_error(&error);
assert_eq!(code_of(&described), code::NOT_FOUND);
assert_eq!(described["table"], json!("ghost"));
}
#[test]
fn an_io_failure_keeps_its_kind_so_the_shim_can_tell_missing_from_denied() {
let error = DatabaseError::Io(io::Error::new(io::ErrorKind::PermissionDenied, "nope"));
let described = describe_database_error(&error);
assert_eq!(code_of(&described), code::IO);
assert_eq!(described["kind"], json!("PermissionDenied"));
}
#[test]
fn an_io_failure_the_engine_could_name_a_file_for_carries_the_path_and_the_errno_name() {
let error = DatabaseError::Log(
PathBuf::from("/data/db/notes/note.jsonl"),
io::Error::from(io::ErrorKind::NotFound),
);
let described = describe_database_error(&error);
assert_eq!(code_of(&described), code::IO);
assert_eq!(described["path"], json!("/data/db/notes/note.jsonl"));
assert_eq!(described["errnoCode"], json!("ENOENT"));
}
#[test]
fn an_io_kind_with_no_portable_errno_name_leaves_the_field_out_rather_than_guessing() {
let error = DatabaseError::Log(
PathBuf::from("/data/db/notes.jsonl"),
io::Error::new(io::ErrorKind::InvalidData, "garbled"),
);
let described = describe_database_error(&error);
assert_eq!(described["path"], json!("/data/db/notes.jsonl"));
assert!(described.get("errnoCode").is_none(), "{described}");
}
#[test]
fn a_read_that_failed_to_decrypt_arrives_typed_with_the_key_id_the_flattened_text_used_to_lose() {
let error = TableError::Cipher(Box::new(
HaysonCipherError::new("secrets", "token", "was written with key \"k1\", which is not on hand")
.with_key_id("k1"),
));
let described = describe_table_error("secrets", &error);
assert_eq!(code_of(&described), code::CIPHER);
assert_eq!(described["table"], json!("secrets"));
assert_eq!(described["field"], json!("token"));
assert_eq!(described["keyId"], json!("k1"));
}
#[test]
fn an_io_failure_on_a_read_stays_an_io_failure_now_that_nothing_reads_its_text() {
let corrupt = TableError::Io(io::Error::new(
io::ErrorKind::InvalidData,
"log \"a.jsonl\" is corrupt at offset 12",
));
assert_eq!(code_of(&describe_table_error("notes", &corrupt)), code::IO);
}
#[test]
fn a_search_against_a_table_with_no_fulltext_field_arrives_as_a_typed_schema_refusal() {
let error = TableError::Schema(HaysonSchemaError(
"table \"users\" has no field marked \"fulltext\": true — search is not available".into(),
));
let described = describe_table_error("users", &error);
assert_eq!(code_of(&described), code::SCHEMA);
assert_eq!(described["table"], json!("users"));
}
#[test]
fn a_write_onto_a_replaced_log_travels_under_its_own_code_with_the_path() {
let error = TableError::Io(io::Error::other(HaysonLogReplacedError {
path: "/data/db/users.jsonl".into(),
}));
let described = describe_table_error("users", &error);
assert_eq!(code_of(&described), code::LOG_REPLACED);
assert_eq!(described["path"], json!("/data/db/users.jsonl"));
assert_eq!(described["message"], json!(error.to_string()));
}
#[test]
fn a_refused_index_snapshot_travels_under_its_own_code_with_both_paths_and_the_watermark() {
let error = DatabaseError::Io(io::Error::other(HaysonIndexMismatchError {
path: "/data/db/users.idx".into(),
log_path: "/data/db/users.jsonl".into(),
watermark: 397,
expected_hash: "9f2c000000000000".into(),
actual_hash: "4b1e000000000000".into(),
}));
let described = describe_database_error(&error);
assert_eq!(code_of(&described), code::INDEX_MISMATCH);
assert_eq!(described["indexPath"], json!("/data/db/users.idx"));
assert_eq!(described["logPath"], json!("/data/db/users.jsonl"));
assert_eq!(described["watermark"], json!(397));
assert_eq!(described["expectedHash"], json!("9f2c000000000000"));
assert_eq!(described["actualHash"], json!("4b1e000000000000"));
assert_eq!(described["message"], json!(error.to_string()));
}
#[test]
fn a_temporary_path_that_could_not_be_claimed_travels_under_its_own_code_with_path_and_reason() {
let dir = scratch_dir("hayson-envelope-temporary-");
let temporary = dir.join("hayson.json.tmp");
std::fs::create_dir(&temporary).unwrap();
let refusal = hayson_engine::atomic::open_temporary(&temporary, None).unwrap_err();
let message = refusal.to_string();
let error = DatabaseError::Io(refusal);
let described = describe_database_error(&error);
std::fs::remove_dir_all(&dir).unwrap();
assert_eq!(code_of(&described), code::TEMPORARY_FILE);
assert_eq!(described["path"], json!(temporary.display().to_string()));
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"));
assert_eq!(described["message"], json!(message));
}
#[test]
fn an_io_error_that_boxes_nothing_typed_is_still_reported_as_io() {
let error = io::Error::other("snapshot \"a.idx\" claims watermark and schema match the log");
assert_eq!(code_of(&describe_io_error(&error)), code::IO);
}
}