use std::path::PathBuf;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum BridgeEligibility {
CatalogAuthenticated,
Unrecognized,
}
impl BridgeEligibility {
pub fn catalog_authenticates(self) -> bool {
matches!(self, Self::CatalogAuthenticated)
}
pub fn remedy_sentence(self) -> &'static str {
match self {
Self::CatalogAuthenticated => {
" This realm was last written before the 0.8.10 durable-state floor and its \
on-disk schema is one this binary recognizes, so run the explicit \
current-binary bridge once (`rkat --state-root <ROOT> --realm <REALM> storage \
migrate --apply --bridge-pre-0-8-10`), then retry the original command. Only \
the schema shape has been checked here: the bridge inspects the stored \
records themselves and prints any it cannot carry forward, leaving those \
records' bytes untouched"
}
Self::Unrecognized => {
" No source catalog the `--bridge-pre-0-8-10` bridge can recover matches this \
domain's on-disk shape, so running that bridge will not recover this domain. \
The file predates, or diverges from, every source catalog this binary can \
bridge here. Nothing is deleted or rewritten: keep the realm and open it with \
the version that wrote it, or start a new realm for this binary. The \
read-only `rkat --state-root <ROOT> --realm <REALM> storage migrate` dry run \
prints the per-domain detail; report that object list if you need this shape \
bridged"
}
}
}
}
#[derive(Debug, thiserror::Error)]
pub enum SqliteStoreError {
#[error("sqlite store io error: {0}")]
Io(#[from] std::io::Error),
#[error("sqlite error: {0}")]
Sqlite(#[from] rusqlite::Error),
#[error(
"schema for domain `{domain}` is from the future: file has version {found}, \
this binary supports up to {supported}"
)]
SchemaFromTheFuture {
domain: String,
found: i64,
supported: i64,
},
#[error(
"schema for domain `{domain}` is not a supported predecessor: file has version {found}, \
this binary supports version {supported} and accepts existing versions {allowed:?}"
)]
UnsupportedSchemaPredecessor {
domain: String,
found: i64,
supported: i64,
allowed: Vec<i64>,
},
#[error(
"schema fingerprint for domain `{domain}` version {version} does not match the \
allowed schema: {detail}"
)]
SchemaFingerprintMismatch {
domain: String,
version: i64,
detail: String,
},
#[error(
"schema domain `{domain}` has no ledger row but already owns objects {objects:?}; \
refusing to infer or stamp an unversioned schema.{}",
bridgeable.remedy_sentence()
)]
UnledgeredDomainObjects {
domain: String,
objects: Vec<String>,
bridgeable: BridgeEligibility,
},
#[error(
"unledgered schema domain `{domain}` does not match any authorized source catalog \
through version {target_version}; found owned objects {objects:?}"
)]
UnledgeredSchemaNoMatch {
domain: String,
target_version: i64,
objects: Vec<String>,
},
#[error(
"unledgered schema domain `{domain}` ambiguously matches source versions {matches:?} \
through requested target version {target_version}"
)]
UnledgeredSchemaAmbiguous {
domain: String,
target_version: i64,
matches: Vec<i64>,
},
#[error("migration {version} (`{name}`) for domain `{domain}` failed: {source}")]
MigrationFailed {
domain: String,
version: i64,
name: String,
#[source]
source: rusqlite::Error,
},
#[error(
"migration {version} (`{name}`) for domain `{domain}` ended the runner's transaction; \
migration bodies must not COMMIT or ROLLBACK"
)]
MigrationBrokeTransaction {
domain: String,
version: i64,
name: String,
},
#[error("meerkat_schema ledger is malformed: {detail}")]
LedgerMalformed { detail: String },
#[error("could not establish journal_mode=WAL on `{path}`: effective mode is `{actual}`")]
WalNotEstablished { path: PathBuf, actual: String },
#[error(
"could not establish journal_mode=WAL on `{path}`: the conversion stayed lock-contended \
for {waited_ms} ms"
)]
WalConversionContended {
path: PathBuf,
waited_ms: u64,
#[source]
source: rusqlite::Error,
},
#[error("domain `{domain}` registered an invalid migration list: {detail}")]
InvalidMigrationList { domain: String, detail: String },
#[error("cannot open `{path}` with profile {profile}: {detail}")]
OpenRefused {
path: PathBuf,
profile: &'static str,
detail: String,
},
#[error("maintenance fence is held for `{path}`; storage is under offline maintenance")]
MaintenanceFenceHeld { path: PathBuf },
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SqliteErrorClass {
Transient,
Corrupt,
Other,
}
pub fn classify_sqlite_error(error: &rusqlite::Error) -> SqliteErrorClass {
use rusqlite::ErrorCode;
match error {
rusqlite::Error::SqliteFailure(f, _) => match f.code {
ErrorCode::DatabaseBusy
| ErrorCode::DatabaseLocked
| ErrorCode::OperationInterrupted => SqliteErrorClass::Transient,
ErrorCode::DatabaseCorrupt | ErrorCode::NotADatabase => SqliteErrorClass::Corrupt,
_ => SqliteErrorClass::Other,
},
_ => SqliteErrorClass::Other,
}
}
pub fn is_busy_or_locked(error: &rusqlite::Error) -> bool {
use rusqlite::ErrorCode;
matches!(
error,
rusqlite::Error::SqliteFailure(f, _)
if matches!(f.code, ErrorCode::DatabaseBusy | ErrorCode::DatabaseLocked)
)
}
#[cfg(test)]
#[allow(clippy::expect_used, clippy::unwrap_used, clippy::panic)]
mod tests {
use super::*;
fn sqlite_failure(code: rusqlite::ErrorCode) -> rusqlite::Error {
rusqlite::Error::SqliteFailure(
rusqlite::ffi::Error {
code,
extended_code: 0,
},
None,
)
}
#[test]
fn busy_and_locked_classify_transient() {
for code in [
rusqlite::ErrorCode::DatabaseBusy,
rusqlite::ErrorCode::DatabaseLocked,
] {
let err = sqlite_failure(code);
assert_eq!(classify_sqlite_error(&err), SqliteErrorClass::Transient);
assert!(is_busy_or_locked(&err));
}
}
#[test]
fn corruption_classifies_corrupt() {
for code in [
rusqlite::ErrorCode::DatabaseCorrupt,
rusqlite::ErrorCode::NotADatabase,
] {
let err = sqlite_failure(code);
assert_eq!(classify_sqlite_error(&err), SqliteErrorClass::Corrupt);
assert!(!is_busy_or_locked(&err));
}
}
#[test]
fn constraint_violation_classifies_other() {
let err = sqlite_failure(rusqlite::ErrorCode::ConstraintViolation);
assert_eq!(classify_sqlite_error(&err), SqliteErrorClass::Other);
assert!(!is_busy_or_locked(&err));
}
#[test]
fn unledgered_domain_objects_carry_their_remedy_in_every_rendering() {
let authenticated = SqliteStoreError::UnledgeredDomainObjects {
domain: "session-store".to_string(),
objects: vec!["table:sessions (expected table)".to_string()],
bridgeable: BridgeEligibility::CatalogAuthenticated,
}
.to_string();
assert!(
authenticated.contains("--bridge-pre-0-8-10"),
"a bridgeable catalog must name the command that recovers it: {authenticated}"
);
let unrecognized = SqliteStoreError::UnledgeredDomainObjects {
domain: "session-store".to_string(),
objects: vec!["table:sessions (expected table)".to_string()],
bridgeable: BridgeEligibility::Unrecognized,
}
.to_string();
assert!(
!unrecognized.contains("--apply"),
"an unrecognized catalog must not be handed a runnable apply command: {unrecognized}"
);
assert!(
unrecognized.contains("will not recover this domain"),
"the refusal must say plainly that the bridge cannot help: {unrecognized}"
);
}
#[test]
fn remedy_sentences_carry_no_botched_line_continuation() {
for eligibility in [
BridgeEligibility::CatalogAuthenticated,
BridgeEligibility::Unrecognized,
] {
let sentence = eligibility.remedy_sentence();
assert!(
!sentence.contains(" "),
"{eligibility:?} remedy carries a run of spaces: {sentence:?}"
);
}
}
}