use std::fmt::Write as _;
use serde_json::{Value as JsonValue, json};
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct LixError {
pub code: String,
pub message: String,
pub hint: Option<String>,
pub details: Option<Box<JsonValue>>,
}
impl LixError {
pub const CODE_UNKNOWN: &'static str = "LIX_ERROR_UNKNOWN";
pub const CODE_PARSE_ERROR: &'static str = "LIX_PARSE_ERROR";
pub const CODE_UDF_NOT_FOUND: &'static str = "LIX_UDF_NOT_FOUND";
pub const CODE_TYPE_MISMATCH: &'static str = "LIX_TYPE_MISMATCH";
pub const CODE_INVALID_JSON_PATH: &'static str = "LIX_INVALID_JSON_PATH";
pub const CODE_DIALECT_UNSUPPORTED: &'static str = "LIX_DIALECT_UNSUPPORTED";
pub const CODE_BINDING_ERROR: &'static str = "LIX_BINDING_ERROR";
pub const CODE_INVALID_PARAM: &'static str = "LIX_INVALID_PARAM";
pub const CODE_TABLE_NOT_FOUND: &'static str = "LIX_TABLE_NOT_FOUND";
pub const CODE_COLUMN_NOT_FOUND: &'static str = "LIX_COLUMN_NOT_FOUND";
pub const CODE_CONSTRAINT_VIOLATION: &'static str = "LIX_CONSTRAINT_VIOLATION";
pub const CODE_READ_ONLY: &'static str = "LIX_ERROR_READ_ONLY";
pub const CODE_PARTIAL_REPLICA_SCOPE_UNSUPPORTED: &'static str =
"LIX_PARTIAL_REPLICA_SCOPE_UNSUPPORTED";
pub const CODE_UNSUPPORTED_SQL: &'static str = "LIX_UNSUPPORTED_SQL";
pub const CODE_UNSUPPORTED_SQL_RUNTIME_PLAN: &'static str = "LIX_UNSUPPORTED_SQL_RUNTIME_PLAN";
pub const CODE_STORAGE_IN_USE: &'static str = "LIX_STORAGE_IN_USE";
pub const CODE_STORAGE_ERROR: &'static str = "LIX_STORAGE_ERROR";
pub const CODE_STORAGE_IO_UNAVAILABLE: &'static str = "LIX_STORAGE_IO_UNAVAILABLE";
pub const CODE_STORAGE_CORRUPTION: &'static str = "LIX_STORAGE_CORRUPTION";
pub const CODE_STORAGE_READ_EXPIRED: &'static str = "LIX_STORAGE_READ_EXPIRED";
pub const CODE_STORAGE_DURABILITY_UNAVAILABLE: &'static str =
"LIX_STORAGE_DURABILITY_UNAVAILABLE";
pub const CODE_INVALID_SNAPSHOT: &'static str = "LIX_INVALID_SNAPSHOT";
pub const CODE_SNAPSHOT_IO: &'static str = "LIX_SNAPSHOT_IO";
pub const CODE_STORAGE_FENCED: &'static str = "LIX_STORAGE_FENCED";
pub const CODE_STORAGE_CLOSED: &'static str = "LIX_STORAGE_CLOSED";
pub const CODE_STORAGE_COMMIT_OUTCOME_UNKNOWN: &'static str =
"LIX_STORAGE_COMMIT_OUTCOME_UNKNOWN";
pub const CODE_IDEMPOTENCY_KEY_REQUIRED: &'static str = "LIX_IDEMPOTENCY_KEY_REQUIRED";
pub const CODE_IDEMPOTENCY_KEY_REUSED: &'static str = "LIX_IDEMPOTENCY_KEY_REUSED";
pub const CODE_IDEMPOTENCY_RESPONSE_TOO_LARGE: &'static str =
"LIX_IDEMPOTENCY_RESPONSE_TOO_LARGE";
pub const CODE_TRANSACTION_CONFLICT: &'static str = "LIX_TRANSACTION_CONFLICT";
pub const CODE_INTERNAL_ERROR: &'static str = "LIX_INTERNAL_ERROR";
pub const CODE_INVALID_PLUGIN: &'static str = "LIX_ERROR_INVALID_PLUGIN";
pub const CODE_PLUGIN_UNAVAILABLE: &'static str = "LIX_ERROR_PLUGIN_UNAVAILABLE";
pub const CODE_PLUGIN_OBSERVATION_STALE: &'static str = "LIX_ERROR_PLUGIN_OBSERVATION_STALE";
pub const CODE_PLUGIN_RESOURCE_LIMIT: &'static str = "LIX_ERROR_PLUGIN_RESOURCE_LIMIT";
pub const CODE_SCHEMA_VALIDATION: &'static str = "LIX_ERROR_SCHEMA_VALIDATION";
pub const CODE_FOREIGN_KEY: &'static str = "LIX_ERROR_FOREIGN_KEY";
pub const CODE_FILE_NOT_FOUND: &'static str = "LIX_ERROR_FILE_NOT_FOUND";
pub const CODE_UNIQUE: &'static str = "LIX_ERROR_UNIQUE";
pub const CODE_UNSUPPORTED_WRITE_EXPRESSION: &'static str =
"LIX_ERROR_UNSUPPORTED_WRITE_EXPRESSION";
pub const CODE_SCHEMA_DEFINITION: &'static str = "LIX_ERROR_SCHEMA_DEFINITION";
pub const CODE_RESERVED_SCHEMA_NAMESPACE: &'static str = "LIX_RESERVED_SCHEMA_NAMESPACE";
pub const CODE_CLOSED: &'static str = "LIX_ERROR_CLOSED";
pub const CODE_INVALID_SESSION_STATE: &'static str = "LIX_INVALID_SESSION_STATE";
pub const CODE_INVALID_TRANSACTION_STATE: &'static str = "LIX_INVALID_TRANSACTION_STATE";
pub const CODE_MERGE_CONFLICT: &'static str = "LIX_MERGE_CONFLICT";
pub const CODE_BRANCH_NOT_FOUND: &'static str = "LIX_BRANCH_NOT_FOUND";
pub const CODE_COMMIT_NOT_FOUND: &'static str = "LIX_COMMIT_NOT_FOUND";
pub const CODE_INVALID_STORAGE_SCOPE: &'static str = "LIX_ERROR_INVALID_STORAGE_SCOPE";
pub const CODE_AMBIGUOUS_MERGE_BASE: &'static str = "LIX_AMBIGUOUS_MERGE_BASE";
pub const CODE_INVALID_MERGE: &'static str = "LIX_INVALID_MERGE";
pub const CODE_NOTHING_TO_UNDO: &'static str = "LIX_NOTHING_TO_UNDO";
pub const CODE_NOTHING_TO_REDO: &'static str = "LIX_NOTHING_TO_REDO";
pub(crate) fn automatic_retry_is_forbidden(&self) -> bool {
self.code == Self::CODE_STORAGE_COMMIT_OUTCOME_UNKNOWN
|| ["nonRetryableAfterCommit", "nonRetryableAfterExecution"]
.iter()
.any(|key| {
self.details
.as_ref()
.and_then(|details| details.get(key))
.and_then(JsonValue::as_bool)
== Some(true)
})
}
pub fn new(code: impl Into<String>, message: impl Into<String>) -> Self {
Self {
code: code.into(),
message: message.into(),
hint: None,
details: None,
}
}
pub fn unknown(message: impl Into<String>) -> Self {
Self::new("LIX_ERROR_UNKNOWN", message)
}
pub fn branch_not_found(
branch_id: impl Into<String>,
operation: impl Into<String>,
role: impl Into<String>,
) -> Self {
let branch_id = branch_id.into();
let operation = operation.into();
let role = role.into();
Self::new(
Self::CODE_BRANCH_NOT_FOUND,
format!("branch '{branch_id}' was not found"),
)
.with_details(json!({
"branch_id": branch_id,
"operation": operation,
"role": role,
}))
}
pub fn commit_not_found(
commit_id: impl Into<String>,
operation: impl Into<String>,
role: impl Into<String>,
) -> Self {
let commit_id = commit_id.into();
let operation = operation.into();
let role = role.into();
Self::new(
Self::CODE_COMMIT_NOT_FOUND,
format!("commit '{commit_id}' was not found"),
)
.with_details(json!({
"commit_id": commit_id,
"operation": operation,
"role": role,
}))
}
pub fn schema_not_visible(
schema_key: impl Into<String>,
entity_commit_id: Option<impl Into<String>>,
base_commit_id: Option<impl Into<String>>,
branch_id: impl Into<String>,
untracked: bool,
) -> Self {
let schema_key = schema_key.into();
let entity_commit_id = entity_commit_id.map(Into::into);
let base_commit_id = base_commit_id.map(Into::into);
let branch_id = branch_id.into();
let scope = format!("branch:{branch_id}");
let hint = match (entity_commit_id.as_deref(), base_commit_id.as_deref()) {
(Some(entity_commit_id), Some(base_commit_id))
if entity_commit_id != base_commit_id =>
{
format!(
"The entity comes from commit {entity_commit_id}, but schema '{schema_key}' is not visible from this transaction's base commit {base_commit_id} in {scope} ({} lane). This usually indicates that the entity commit is not an ancestor of the transaction base, or that the schema was registered in a different branch or durability scope.",
if untracked { "untracked" } else { "tracked" }
)
}
(_, Some(base_commit_id)) => format!(
"Schema '{schema_key}' is not visible from this transaction's base commit {base_commit_id} in {scope} ({} lane). This usually indicates that the schema was registered in a different branch or durability scope.",
if untracked { "untracked" } else { "tracked" }
),
_ => format!(
"Schema '{schema_key}' is not visible in {scope} ({} lane). This usually indicates that the schema was registered in a different branch or durability scope.",
if untracked { "untracked" } else { "tracked" }
),
};
let mut details = serde_json::Map::from_iter([
(
"schema_key".to_string(),
JsonValue::String(schema_key.clone()),
),
("scope".to_string(), JsonValue::String(scope)),
(
"durability".to_string(),
JsonValue::String(if untracked { "untracked" } else { "tracked" }.to_string()),
),
]);
if let Some(entity_commit_id) = entity_commit_id {
details.insert(
"entity_commit_id".to_string(),
JsonValue::String(entity_commit_id),
);
}
if let Some(base_commit_id) = base_commit_id {
details.insert(
"base_commit_id".to_string(),
JsonValue::String(base_commit_id),
);
}
Self::new(
Self::CODE_SCHEMA_DEFINITION,
format!("schema '{schema_key}' is not visible to this transaction"),
)
.with_details(JsonValue::Object(details))
.with_hint(hint)
}
pub fn internal_invariant(message: impl Into<String>, entities: JsonValue) -> Self {
Self::new(Self::CODE_INTERNAL_ERROR, message).with_details(entities)
}
pub fn ambiguous_merge_base(
left_commit_id: impl Into<String>,
right_commit_id: impl Into<String>,
candidates: Vec<String>,
) -> Self {
let left_commit_id = left_commit_id.into();
let right_commit_id = right_commit_id.into();
Self::new(
Self::CODE_AMBIGUOUS_MERGE_BASE,
format!("ambiguous merge base between '{left_commit_id}' and '{right_commit_id}'"),
)
.with_details(json!({
"left_commit_id": left_commit_id,
"right_commit_id": right_commit_id,
"candidates": candidates,
}))
}
pub fn invalid_self_merge(branch_id: impl Into<String>) -> Self {
let branch_id = branch_id.into();
Self::new(
Self::CODE_INVALID_MERGE,
format!("cannot merge branch '{branch_id}' into itself"),
)
.with_details(json!({
"operation": "merge_branch",
"target_branch_id": branch_id,
"source_branch_id": branch_id,
}))
}
pub fn with_hint(mut self, hint: impl Into<String>) -> Self {
self.hint = Some(hint.into());
self
}
pub fn with_details(mut self, details: JsonValue) -> Self {
self.details = Some(Box::new(details));
self
}
pub fn hint(&self) -> Option<&str> {
self.hint.as_deref()
}
pub fn details(&self) -> Option<&JsonValue> {
self.details.as_deref()
}
pub fn details_mut(&mut self) -> Option<&mut JsonValue> {
self.details.as_deref_mut()
}
pub fn format(&self) -> String {
let mut s = format!("code: {}\nmessage: {}", self.code, self.message);
if let Some(hint) = &self.hint {
let _ = write!(s, "\nhint: {hint}");
}
s
}
}
impl From<crate::storage_adapter::StorageError> for LixError {
fn from(error: crate::storage_adapter::StorageError) -> Self {
match error {
crate::storage_adapter::StorageError::InUse => Self::new(
Self::CODE_STORAGE_IN_USE,
"storage is already open by another owner",
)
.with_hint("Use the existing repository owner, or close it before reopening."),
crate::storage_adapter::StorageError::WriteConflict
| crate::storage_adapter::StorageError::PreconditionFailed(_) => Self::new(
Self::CODE_TRANSACTION_CONFLICT,
"transaction snapshot is stale because tracked state changed before commit",
)
.with_hint("Retry the transaction against the latest committed state.")
.with_details(json!({
"retryable": true,
"reason": "commitRaced",
})),
crate::storage_adapter::StorageError::Fenced => Self::new(
Self::CODE_STORAGE_FENCED,
"the storage writer was fenced by a newer client",
)
.with_hint(
"Do not automatically retry this request; a mutation may still have completed.",
)
.with_details(json!({
"retryable": false,
"outcome": "unknown",
})),
crate::storage_adapter::StorageError::Closed(_) => Self::new(
Self::CODE_STORAGE_CLOSED,
"the storage instance closed and must be reopened",
)
.with_hint(
"Do not automatically retry this request; a mutation may still have completed.",
)
.with_details(json!({
"retryable": false,
"outcome": "unknown",
})),
crate::storage_adapter::StorageError::CommitOutcomeUnknown(message) => Self::new(
Self::CODE_STORAGE_COMMIT_OUTCOME_UNKNOWN,
format!("the storage commit outcome is unknown: {message}"),
)
.with_hint(
"Do not automatically retry this request; a mutation may still have completed.",
)
.with_details(json!({
"retryable": false,
"outcome": "unknown",
})),
crate::storage_adapter::StorageError::Durability => Self::new(
Self::CODE_STORAGE_DURABILITY_UNAVAILABLE,
"the storage backend cannot prove the requested durability boundary",
),
crate::storage_adapter::StorageError::ReadExpired => Self::new(
Self::CODE_STORAGE_READ_EXPIRED,
"the coherent storage read was invalidated by a concurrent commit",
)
.with_details(json!({
"retryable": true,
})),
crate::storage_adapter::StorageError::Unavailable(message) => Self::new(
Self::CODE_STORAGE_IO_UNAVAILABLE,
format!("storage unavailable: {message}"),
),
crate::storage_adapter::StorageError::Corruption(message) => Self::new(
Self::CODE_STORAGE_CORRUPTION,
format!("storage corruption: {message}"),
),
error => Self::new(Self::CODE_STORAGE_ERROR, error.to_string()),
}
}
}
impl From<crate::storage_adapter::StorageWriteSetError> for LixError {
fn from(error: crate::storage_adapter::StorageWriteSetError) -> Self {
match error {
crate::storage_adapter::StorageWriteSetError::Storage(error) => error.into(),
error => Self::new(Self::CODE_STORAGE_ERROR, error.to_string()),
}
}
}
impl std::fmt::Display for LixError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.format())
}
}
impl std::error::Error for LixError {}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn automatic_retry_respects_completion_markers_before_error_category() {
for code in [
LixError::CODE_TRANSACTION_CONFLICT,
LixError::CODE_STORAGE_READ_EXPIRED,
LixError::CODE_STORAGE_COMMIT_OUTCOME_UNKNOWN,
"typed-native-demand",
] {
let error = LixError::new(code, "original diagnostic");
assert_eq!(
error.automatic_retry_is_forbidden(),
code == LixError::CODE_STORAGE_COMMIT_OUTCOME_UNKNOWN
);
for marker in ["nonRetryableAfterCommit", "nonRetryableAfterExecution"] {
for value in [json!(true), json!(false), json!(null), json!("true")] {
let marked = error.clone().with_details(json!({marker: value}));
assert_eq!(
marked.automatic_retry_is_forbidden(),
value == json!(true)
|| code == LixError::CODE_STORAGE_COMMIT_OUTCOME_UNKNOWN
);
}
}
}
}
#[test]
fn format_without_hint_omits_hint_line() {
let err = LixError::new("LIX_ERROR_FOO", "something went wrong");
assert_eq!(
err.format(),
"code: LIX_ERROR_FOO\nmessage: something went wrong"
);
assert!(err.hint.is_none());
}
#[test]
fn format_with_hint_appends_hint_line() {
let err = LixError::new("LIX_ERROR_FOO", "something went wrong").with_hint("try the fix");
assert_eq!(
err.format(),
"code: LIX_ERROR_FOO\nmessage: something went wrong\nhint: try the fix"
);
}
#[test]
fn with_hint_is_chainable_and_replaces_prior_hint() {
let err = LixError::new("LIX_ERROR_FOO", "desc")
.with_hint("first")
.with_hint("second");
assert_eq!(err.hint.as_deref(), Some("second"));
}
#[test]
fn new_defaults_hint_to_none() {
let err = LixError::new("CODE", "desc");
assert_eq!(err.hint, None);
}
#[test]
fn unknown_defaults_hint_to_none() {
let err = LixError::unknown("desc");
assert_eq!(err.code, "LIX_ERROR_UNKNOWN");
assert_eq!(err.hint, None);
}
#[test]
fn fenced_storage_error_is_terminal_and_not_retryable() {
let error = LixError::from(crate::storage::StorageError::Fenced);
assert_eq!(error.code, LixError::CODE_STORAGE_FENCED);
assert_eq!(
error.details(),
Some(&serde_json::json!({
"retryable": false,
"outcome": "unknown",
}))
);
}
#[test]
fn unknown_commit_outcome_is_not_retryable() {
let error = LixError::from(crate::storage::StorageError::CommitOutcomeUnknown(
"storage reply was lost after commit".to_string(),
));
assert_eq!(error.code, LixError::CODE_STORAGE_COMMIT_OUTCOME_UNKNOWN);
assert_eq!(
error.details(),
Some(&serde_json::json!({
"retryable": false,
"outcome": "unknown",
}))
);
assert_eq!(
error.hint(),
Some("Do not automatically retry this request; a mutation may still have completed.")
);
}
#[test]
fn fenced_storage_write_set_error_preserves_the_terminal_code() {
let error = LixError::from(crate::storage_adapter::StorageWriteSetError::Storage(
crate::storage::StorageError::Fenced,
));
assert_eq!(error.code, LixError::CODE_STORAGE_FENCED);
}
#[test]
fn closed_storage_error_is_terminal_and_not_retryable() {
let error = LixError::from(crate::storage::StorageError::Closed(
"background worker panicked".to_string(),
));
assert_eq!(error.code, LixError::CODE_STORAGE_CLOSED);
assert_eq!(
error.details(),
Some(&serde_json::json!({
"retryable": false,
"outcome": "unknown",
}))
);
}
#[test]
fn storage_io_and_corruption_keep_distinct_sync_error_codes() {
let io = LixError::from(crate::storage::StorageError::Unavailable(
"object store PUT failed".to_string(),
));
assert_eq!(io.code, LixError::CODE_STORAGE_IO_UNAVAILABLE);
assert!(!io.automatic_retry_is_forbidden());
let deterministic_io = LixError::from(crate::storage::StorageError::Io(
"read discovery byte limit exceeded".to_string(),
));
assert_eq!(deterministic_io.code, LixError::CODE_STORAGE_ERROR);
let corruption = LixError::from(crate::storage::StorageError::Corruption(
"segment hash mismatch".to_string(),
));
assert_eq!(corruption.code, LixError::CODE_STORAGE_CORRUPTION);
}
}