Core error taxonomy + name validation (ADR-008 §4/§5, ADR-017 §3); AGENTS.md updated to Phase 1 posture
This commit is contained in:
1 parent
34e0b9732d
commit
74105a16ae
8 files changed
+413
-84
No files matched your search
+4
-1
@@ -3,4 +3,7 @@ name = "alkstore"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
repository.workspace = true
|
||||
|
||||
[dependencies]
|
||||
thiserror = "2"
|
||||
@@ -0,0 +1,61 @@
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
#[non_exhaustive]
|
||||
pub enum Error {
|
||||
/// The payload exceeds the engine's notification payload limit.
|
||||
///
|
||||
/// Universal variant: the Postgres engine produces it (the
|
||||
/// client-side check on the serde_json serialization of the payload
|
||||
/// `Value` — the same byte string ADR-020 §4 pins as the stored row
|
||||
/// bytes); the SQLite engine never produces it (no limit). The
|
||||
/// documented workaround for large payloads on Postgres is the
|
||||
/// outbox shape — the id in the notification, the payload in a
|
||||
/// table row.
|
||||
#[error("payload exceeds the engine's notification payload limit (limit: {limit} bytes)")]
|
||||
PayloadTooLarge { limit: usize },
|
||||
/// A name carrying the reserved `__alkstore_` prefix was supplied
|
||||
/// to a shared-namespace entry point.
|
||||
#[error(
|
||||
"reserved name `{name}`: names carrying the reserved `__alkstore_` prefix are engine-owned"
|
||||
)]
|
||||
ReservedName { name: String },
|
||||
/// A name failed entry-point validation.
|
||||
#[error("invalid name `{name}`")]
|
||||
InvalidName { name: String },
|
||||
/// An operation against a closed receiver/source.
|
||||
///
|
||||
/// The `try_recv`/`recv_timeout` form of wake close (never
|
||||
/// returned by `recv_timeout`'s timeout expiry, which yields
|
||||
/// `Ok(None)`); never a timeout signal.
|
||||
#[error("the receiver or source is closed")]
|
||||
Closed,
|
||||
/// Payload serialization or deserialization failure.
|
||||
///
|
||||
/// `payload_as` on stream events and job payloads; wakes carry no
|
||||
/// payload, so no wake path produces this variant.
|
||||
#[error("payload codec failure: {0}")]
|
||||
Codec(String),
|
||||
/// The opaque fallback: everything else.
|
||||
///
|
||||
/// Driver/connection/SQL errors, opaque to the contract; engine
|
||||
/// detail is preserved via the error source chain (`#[source]`). No
|
||||
/// engine-specific variants are ever minted for its contents.
|
||||
#[error("database error")]
|
||||
Database(#[source] Box<dyn std::error::Error + Send + Sync>),
|
||||
}
|
||||
|
||||
impl Error {
|
||||
/// Convenience constructor for the opaque [`Error::Database`]
|
||||
/// fallback.
|
||||
pub fn database<E>(err: E) -> Self
|
||||
where
|
||||
E: Into<Box<dyn std::error::Error + Send + Sync>>,
|
||||
{
|
||||
Error::Database(err.into())
|
||||
}
|
||||
}
|
||||
|
||||
/// The crate's result type — every fallible operation in the contract
|
||||
/// surface returns this. `Ok` never carries a "null" sentinel: no-work
|
||||
/// claims and unlanded job/lock ops are values, not errors (ADR-008
|
||||
/// §5).
|
||||
pub type Result<T> = std::result::Result<T, Error>;
|
||||
@@ -1 +1,18 @@
|
||||
//! alkstore — the unified trait surface, value types, error model, and
|
||||
//! contract documentation of the store contract (ADR-001, ADR-008).
|
||||
//!
|
||||
//! This crate is the contract artifact: no driver dependencies, ever.
|
||||
//! Engine crates (`alkstore-sqlite`, `alkstore-postgres`) depend on
|
||||
//! this crate and implement its traits.
|
||||
|
||||
mod error;
|
||||
mod validation;
|
||||
|
||||
pub use error::{Error, Result};
|
||||
pub use validation::{
|
||||
NameKind, RESERVED_LISTENER_RECONNECTED, RESERVED_PREFIX, validate_local_name, validate_name,
|
||||
validate_shared_name,
|
||||
};
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -0,0 +1,131 @@
|
||||
use crate::validation::{
|
||||
NameKind, RESERVED_LISTENER_RECONNECTED, RESERVED_PREFIX, validate_local_name, validate_name,
|
||||
validate_shared_name,
|
||||
};
|
||||
use crate::{Error, Result};
|
||||
|
||||
fn assert_invalidname(r: Result<()>, expected: &str) {
|
||||
match r {
|
||||
Err(Error::InvalidName { name }) => assert_eq!(name, expected),
|
||||
other => panic!("expected InvalidName({expected}), got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
fn assert_reservedname(r: Result<()>, expected: &str) {
|
||||
match r {
|
||||
Err(Error::ReservedName { name }) => assert_eq!(name, expected),
|
||||
other => panic!("expected ReservedName({expected}), got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shared_name_accepts_legal_names() {
|
||||
for legal in [
|
||||
"orders",
|
||||
"a",
|
||||
"names with spaces",
|
||||
"__not_the_reserved_prefix",
|
||||
"_alkstore_x",
|
||||
"__alkstor_e_",
|
||||
"__ALKSTORE_lower",
|
||||
"uniße-名前",
|
||||
] {
|
||||
validate_shared_name(legal).unwrap_or_else(|e| panic!("legal {legal:?} rejected: {e}"));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shared_name_rejects_empty() {
|
||||
assert_invalidname(validate_shared_name(""), "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shared_name_rejects_whitespace_only() {
|
||||
for ws in [" ", "\t", "\n", " \t \n "] {
|
||||
assert_invalidname(validate_shared_name(ws), ws);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shared_name_rejects_reserved_prefix() {
|
||||
for reserved in [
|
||||
"__alkstore_",
|
||||
"__alkstore_outbox:foo",
|
||||
"__alkstore_listener_reconnected__",
|
||||
] {
|
||||
assert_reservedname(validate_shared_name(reserved), reserved);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_name_accepts_legal_names() {
|
||||
for legal in ["worker-1", "__alkstore_my_local_consumer"] {
|
||||
validate_local_name(legal).unwrap_or_else(|e| panic!("legal {legal:?} rejected: {e}"));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_name_rejects_empty_and_whitespace_only() {
|
||||
assert_invalidname(validate_local_name(""), "");
|
||||
for ws in [" ", "\t\n "] {
|
||||
assert_invalidname(validate_local_name(ws), ws);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn name_kind_dispatch_via_validate_name() {
|
||||
assert!(validate_name(NameKind::Shared, "__alkstore_x").is_err());
|
||||
assert!(validate_name(NameKind::Local, "__alkstore_x").is_ok());
|
||||
assert!(validate_name(NameKind::Shared, "ok").is_ok());
|
||||
assert!(validate_name(NameKind::Local, "ok").is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn error_display_texts() {
|
||||
let e = Error::PayloadTooLarge { limit: 8000 };
|
||||
assert!(e.to_string().contains("8000"));
|
||||
assert!(
|
||||
Error::ReservedName { name: "x".into() }
|
||||
.to_string()
|
||||
.contains("x")
|
||||
);
|
||||
assert!(
|
||||
Error::InvalidName { name: "y".into() }
|
||||
.to_string()
|
||||
.contains("y")
|
||||
);
|
||||
assert_eq!(
|
||||
Error::Closed.to_string(),
|
||||
"the receiver or source is closed"
|
||||
);
|
||||
assert!(
|
||||
Error::Codec("bad utf8 at 3".into())
|
||||
.to_string()
|
||||
.contains("bad utf8")
|
||||
);
|
||||
let db = std::io::Error::other("boom");
|
||||
let e = Error::Database(Box::new(db));
|
||||
assert!(e.to_string().contains("database"));
|
||||
let src = std::error::Error::source(&e).expect("Database carries #[source]");
|
||||
assert_eq!(src.to_string(), "boom");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn error_database_constructor() {
|
||||
let e = Error::database(std::io::Error::other("disk gone"));
|
||||
match e {
|
||||
Error::Database(source) => {
|
||||
assert_eq!(source.to_string(), "disk gone");
|
||||
}
|
||||
other => panic!("expected Database, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reserved_constants_exact() {
|
||||
assert_eq!(RESERVED_PREFIX, "__alkstore_");
|
||||
assert_eq!(
|
||||
RESERVED_LISTENER_RECONNECTED,
|
||||
"__alkstore_listener_reconnected__"
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
//! Entry-point name validation (ADR-008 §4) and the reserved
|
||||
//! namespace.
|
||||
//!
|
||||
//! Every name-bearing entry point validates before any engine round
|
||||
//! trip — on the auto-commit and `*_tx` paths alike — via these
|
||||
//! helpers.
|
||||
|
||||
/// The reserved namespace prefix (ADR-008 §4). Engine-derived names in
|
||||
/// consumer namespaces (e.g. an outbox's backing queue) carry it;
|
||||
/// consumer-supplied shared-namespace names carrying it are rejected
|
||||
/// with [`Error::ReservedName`](crate::Error::ReservedName).
|
||||
pub const RESERVED_PREFIX: &str = "__alkstore_";
|
||||
|
||||
/// The one v1-reserved string (ADR-008 §4): the Postgres engine's
|
||||
/// synthetic reconnect-wake channel, delivered as
|
||||
/// `Wake { channel: RESERVED_LISTENER_RECONNECTED }` on every
|
||||
/// subscriber's receiver after a watcher reconnect. Public in core so
|
||||
/// the pg engine and the contract suite reference the same constant.
|
||||
pub const RESERVED_LISTENER_RECONNECTED: &str = "__alkstore_listener_reconnected__";
|
||||
|
||||
/// Which namespace class a name belongs to (ADR-008 §4).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum NameKind {
|
||||
/// Shared-namespace kinds — channels, streams, queues, outboxes,
|
||||
/// locks, schedule names. Non-empty, and the reserved prefix is
|
||||
/// rejected.
|
||||
Shared,
|
||||
/// Consumer-local identifiers — stream-consumer names, `owner` on
|
||||
/// `try_lock`, `worker_id` on claims. Non-empty only: a leading
|
||||
/// `__alkstore_` is legal (they are not a shared engine namespace,
|
||||
/// so there is nothing to collide with).
|
||||
Local,
|
||||
}
|
||||
|
||||
/// Validate a name per its [`NameKind`].
|
||||
///
|
||||
/// - non-empty (after no trimming — whitespace-only counts as
|
||||
/// non-empty content but is nonetheless rejected below) →
|
||||
/// [`Error::InvalidName`] when empty
|
||||
/// - `NameKind::Shared` additionally rejects names carrying
|
||||
/// [`RESERVED_PREFIX`] with [`Error::ReservedName`]
|
||||
///
|
||||
/// Whitespace-only names are rejected as [`Error::InvalidName`] for
|
||||
/// both kinds: they are never a meaningful name on any engine.
|
||||
pub fn validate_name(kind: NameKind, name: &str) -> crate::Result<()> {
|
||||
if name.is_empty() || name.trim().is_empty() {
|
||||
return Err(crate::Error::InvalidName {
|
||||
name: name.to_string(),
|
||||
});
|
||||
}
|
||||
if kind == NameKind::Shared && name.starts_with(RESERVED_PREFIX) {
|
||||
return Err(crate::Error::ReservedName {
|
||||
name: name.to_string(),
|
||||
});
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Validate a shared-namespace name (channels, streams, queues,
|
||||
/// outboxes, locks, schedule names).
|
||||
pub fn validate_shared_name(name: &str) -> crate::Result<()> {
|
||||
validate_name(NameKind::Shared, name)
|
||||
}
|
||||
|
||||
/// Validate a consumer-local identifier (stream-consumer names, `owner`
|
||||
/// on `try_lock`, `worker_id` on claims): non-empty only.
|
||||
pub fn validate_local_name(name: &str) -> crate::Result<()> {
|
||||
validate_name(NameKind::Local, name)
|
||||
}
|
||||
Reference in new issue
Block a user