Contract-suite scaffold: alkstore-contract-suite crate + ADR-022 (suite layout decision), engine dev-dep edges (ADR-017 §4.2 discharged, ADR-012 §2 mirror)
This commit is contained in:
1 parent
eefee9ec1d
commit
621415cc47
15 files changed
+1028
-7
No files matched your search
Generated
+11
@@ -12,11 +12,21 @@ dependencies = [
|
|||||||
"tokio",
|
"tokio",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "alkstore-contract-suite"
|
||||||
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"alkstore",
|
||||||
|
"serde_json",
|
||||||
|
"tokio",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "alkstore-postgres"
|
name = "alkstore-postgres"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"alkstore",
|
"alkstore",
|
||||||
|
"alkstore-contract-suite",
|
||||||
"deadpool-postgres",
|
"deadpool-postgres",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tokio-postgres",
|
"tokio-postgres",
|
||||||
@@ -27,6 +37,7 @@ name = "alkstore-sqlite"
|
|||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"alkstore",
|
"alkstore",
|
||||||
|
"alkstore-contract-suite",
|
||||||
"rusqlite",
|
"rusqlite",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ members = [
|
|||||||
"alkstore",
|
"alkstore",
|
||||||
"alkstore-sqlite",
|
"alkstore-sqlite",
|
||||||
"alkstore-postgres",
|
"alkstore-postgres",
|
||||||
|
"alkstore-contract-suite",
|
||||||
]
|
]
|
||||||
resolver = "3"
|
resolver = "3"
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
[package]
|
||||||
|
name = "alkstore-contract-suite"
|
||||||
|
version.workspace = true
|
||||||
|
edition.workspace = true
|
||||||
|
license.workspace = true
|
||||||
|
repository.workspace = true
|
||||||
|
publish = false
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
alkstore = { version = "0.1", path = "../alkstore" }
|
||||||
|
serde_json = "1"
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
tokio = { version = "1", features = ["macros", "rt"] }
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
//! The factory abstraction the suite is parameterized over (ADR-022).
|
||||||
|
//!
|
||||||
|
//! One shape, deliberately minimal: produce a fresh store over an
|
||||||
|
//! isolated backing store, plus teardown. Engines implement this in
|
||||||
|
//! their dev-dependency wiring — a fresh file (SQLite) or fresh schema
|
||||||
|
//! (Postgres) per call, so properties never observe one another's
|
||||||
|
//! rows.
|
||||||
|
//!
|
||||||
|
//! The factory shape may evolve as engines adopt the suite (wave 3/4
|
||||||
|
//! feedback) — ADR-022 records the *layout* decision, not a frozen
|
||||||
|
//! API. Changes here are test-side non-events (ADR-017 §2 class 4:
|
||||||
|
//! the suite tests the contract, it is not contract surface).
|
||||||
|
|
||||||
|
use alkstore::{BoxedFuture, Result, Store};
|
||||||
|
|
||||||
|
/// Produces fresh, isolated [`Store`]s for the suite's properties.
|
||||||
|
///
|
||||||
|
/// Implementers — engine crates, in their dev-dependency wiring —
|
||||||
|
/// yield a [`Box<dyn Store>`](Store) over an isolated backing store (a
|
||||||
|
/// fresh file or fresh schema per call, so a property's rows never
|
||||||
|
/// outlive its run into another's) plus a teardown disposing of the
|
||||||
|
/// backing store. Implementations must be `Send + Sync` so a factory
|
||||||
|
/// can be shared across property runs.
|
||||||
|
///
|
||||||
|
/// Rows call [`StoreFactory::open`], drive their assertions, and call
|
||||||
|
/// [`StoreFactory::teardown`] before returning; a rollback-less early
|
||||||
|
/// exit (a failing assertion panics the row) is the caller's harness
|
||||||
|
/// concern, which is why teardown is also required to be safe to run
|
||||||
|
/// against an abandoned store (drop, close, delete).
|
||||||
|
///
|
||||||
|
/// The async surface rides the same boxed-future posture as the
|
||||||
|
/// contract traits (`BoxedFuture`), so implementers need no macros.
|
||||||
|
pub trait StoreFactory: Send + Sync {
|
||||||
|
/// Open a fresh store over an isolated backing store.
|
||||||
|
fn open(&self) -> BoxedFuture<'_, Result<Box<dyn Store>>>;
|
||||||
|
|
||||||
|
/// Tear down the backing store behind this factory instance.
|
||||||
|
///
|
||||||
|
/// Idempotent per instance. The engine owns what teardown means —
|
||||||
|
/// deleting a temp file, dropping a schema, closing a pool — and
|
||||||
|
/// owns the isolated-ness guarantee (no two `open` calls share
|
||||||
|
/// rows).
|
||||||
|
fn teardown(&self) -> BoxedFuture<'_, Result<()>>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
//! alkstore-contract-suite — the contract suite crate (ADR-022).
|
||||||
|
//!
|
||||||
|
//! The suite is the [verification
|
||||||
|
//! backlog](../../docs/architecture/core-contract.md) property set
|
||||||
|
//! parameterized over a [`StoreFactory`] — one normative owner per
|
||||||
|
//! property (the one-owner rule, ADR-012 §2 applied to test
|
||||||
|
//! artifacts); each engine crate consumes the suite as a
|
||||||
|
//! *dev*-dependency and drives its rows against its own factory. The
|
||||||
|
//! crate is internal and unpublished (`publish = false`, depends on
|
||||||
|
//! `alkstore` only); ADR-017 §4.2's "layout decided at implementation"
|
||||||
|
//! deferral is discharged by
|
||||||
|
//! [ADR-022](../../docs/architecture/decisions/022-contract-suite-layout.md).
|
||||||
|
//!
|
||||||
|
//! # Row structure
|
||||||
|
//!
|
||||||
|
//! Each backlog row is one named, independently-runnable property — a
|
||||||
|
//! public async function taking `&dyn StoreFactory` — with a
|
||||||
|
//! **version stamp** per the suite's [version-stamp
|
||||||
|
//! convention](version_stamp): a doc-comment line citing the contract
|
||||||
|
//! change (ADR §) that added the row (ADR-017 §4.2's no-silent-change
|
||||||
|
//! discipline applied to the suite itself). Wave 3/4 engine tasks run
|
||||||
|
//! the existing rows against their engines; wave 5 fills the
|
||||||
|
//! cross-engine equivalence rows.
|
||||||
|
//!
|
||||||
|
//! # Crate posture
|
||||||
|
//!
|
||||||
|
//! This is a test-side artifact (ADR-001 §4's posture), not contract
|
||||||
|
//! surface: rows assert with panics on violation — a failing row is a
|
||||||
|
//! test failure naming the entry point and expectation — so the
|
||||||
|
//! family-wide no-panic rule is relaxed here by the crate's purpose.
|
||||||
|
//! Suite-side changes are contract-non-events (ADR-017 §2 class 4:
|
||||||
|
//! the suite tests the contract, it is not the contract); a row added
|
||||||
|
//! for a *contract* addition is stamped with that addition's ADR.
|
||||||
|
|
||||||
|
pub mod factory;
|
||||||
|
pub mod properties;
|
||||||
|
pub mod version_stamp;
|
||||||
|
|
||||||
|
pub use factory::StoreFactory;
|
||||||
|
pub use properties::name_validation_rejects_empty_and_reserved;
|
||||||
@@ -0,0 +1,276 @@
|
|||||||
|
//! The suite's property rows — one named, independently-runnable
|
||||||
|
//! property per backlog row, version-stamped per the
|
||||||
|
//! [convention](crate::version_stamp).
|
||||||
|
//!
|
||||||
|
//! Assertion posture: rows panic with a label naming the entry point
|
||||||
|
//! and the broken expectation — a failing row is a test failure, which
|
||||||
|
//! is the suite's purpose as a test-side artifact (relaxing the
|
||||||
|
//! family-wide no-panic rule here only).
|
||||||
|
|
||||||
|
use serde_json::json;
|
||||||
|
|
||||||
|
use alkstore::{BoxedFuture, Error, RESERVED_PREFIX, Result, validate_shared_name};
|
||||||
|
|
||||||
|
use crate::factory::StoreFactory;
|
||||||
|
|
||||||
|
fn expect_invalid<T>(label: &str, out: alkstore::Result<T>) {
|
||||||
|
match out {
|
||||||
|
Err(Error::InvalidName { .. }) => {}
|
||||||
|
Err(other) => {
|
||||||
|
panic!("{label}: expected `InvalidName`, got {other:?} — entry-point validation broke")
|
||||||
|
}
|
||||||
|
Ok(_) => panic!("{label}: expected `InvalidName` rejection, got Ok"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn expect_reserved<T>(label: &str, out: alkstore::Result<T>) {
|
||||||
|
match out {
|
||||||
|
Err(Error::ReservedName { .. }) => {}
|
||||||
|
Err(other) => {
|
||||||
|
panic!("{label}: expected `ReservedName`, got {other:?} — entry-point validation broke")
|
||||||
|
}
|
||||||
|
Ok(_) => panic!("{label}: expected `ReservedName` rejection, got Ok"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn expect_ok<T>(label: &str, out: alkstore::Result<T>) {
|
||||||
|
if let Err(other) = out {
|
||||||
|
panic!("{label}: expected success (no validation error), got {other:?}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// **Exemplar row** — name validation at every entry point,
|
||||||
|
/// auto-commit and tx paths alike.
|
||||||
|
///
|
||||||
|
/// Every name-bearing entry point on the [`Store`](alkstore::Store)
|
||||||
|
/// surface — and every name-bearing `*_tx` twin on the
|
||||||
|
/// [`TxHandle`](alkstore::TxHandle) — rejects, before any engine round
|
||||||
|
/// trip: the empty (or whitespace-only) name with
|
||||||
|
/// [`Error::InvalidName`], and the reserved-prefix name
|
||||||
|
/// (`__alkstore_...`) with [`Error::ReservedName`], on the
|
||||||
|
/// shared-namespace kinds — channels, streams, queues, outboxes,
|
||||||
|
/// locks, schedule names, and `schedule()`'s queue argument.
|
||||||
|
/// Consumer-local identifiers take the non-empty rule only: `owner`
|
||||||
|
/// reserved-prefixed is legal, `owner` empty is not. The tx path is
|
||||||
|
/// exercised through `begin_tx` and `with_tx` (the provided wrapper):
|
||||||
|
/// validation must not be bypassable by the commit-atomic path.
|
||||||
|
///
|
||||||
|
/// Contract stamp: ADR-008 §4 (reserved namespace + entry-point
|
||||||
|
/// validation); ADR-021 §3 (the schedule queue argument); ADR-015 §2
|
||||||
|
/// (the keyed publish's non-empty-key rule).
|
||||||
|
///
|
||||||
|
/// Engine-independent, pure contract: runnable against any factory.
|
||||||
|
/// The happy-path spot-checks prove the rejections are the
|
||||||
|
/// validation's doing, not a store-wide failure — valid names
|
||||||
|
/// construct handles without a validation error, and a
|
||||||
|
/// reserved-prefixed *local* identifier is accepted.
|
||||||
|
pub async fn name_validation_rejects_empty_and_reserved(factory: &dyn StoreFactory) {
|
||||||
|
let store = factory.open().await.expect("factory opens a store");
|
||||||
|
|
||||||
|
// Auto-commit paths — empty names.
|
||||||
|
expect_invalid("notify(empty channel)", store.notify("", json!(null)).await);
|
||||||
|
expect_invalid("listen(empty channel)", store.listen("").await);
|
||||||
|
expect_invalid("stream(empty name)", store.stream("").await);
|
||||||
|
expect_invalid(
|
||||||
|
"queue(empty name)",
|
||||||
|
store.queue("", alkstore::QueueOpts::default()).await,
|
||||||
|
);
|
||||||
|
expect_invalid("outbox(empty name)", store.outbox("").await);
|
||||||
|
expect_invalid(
|
||||||
|
"try_lock(empty name)",
|
||||||
|
store.try_lock("", "worker", 60).await,
|
||||||
|
);
|
||||||
|
expect_invalid(
|
||||||
|
"try_lock(empty owner)",
|
||||||
|
store.try_lock("lock", "", 60).await,
|
||||||
|
);
|
||||||
|
expect_invalid(
|
||||||
|
"schedule(empty name)",
|
||||||
|
store
|
||||||
|
.schedule("", "@every 1s", "work", json!(null), Default::default())
|
||||||
|
.await,
|
||||||
|
);
|
||||||
|
expect_invalid(
|
||||||
|
"schedule(empty queue argument)",
|
||||||
|
store
|
||||||
|
.schedule("tick", "@every 1s", "", json!(null), Default::default())
|
||||||
|
.await,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Auto-commit paths — reserved-prefix names.
|
||||||
|
expect_reserved(
|
||||||
|
"notify(reserved channel)",
|
||||||
|
store.notify(RESERVED_PREFIX, json!(null)).await,
|
||||||
|
);
|
||||||
|
expect_reserved(
|
||||||
|
"listen(reserved channel)",
|
||||||
|
store.listen(RESERVED_PREFIX).await,
|
||||||
|
);
|
||||||
|
expect_reserved("stream(reserved name)", store.stream(RESERVED_PREFIX).await);
|
||||||
|
expect_reserved(
|
||||||
|
"queue(reserved name)",
|
||||||
|
store
|
||||||
|
.queue(RESERVED_PREFIX, alkstore::QueueOpts::default())
|
||||||
|
.await,
|
||||||
|
);
|
||||||
|
expect_reserved("outbox(reserved name)", store.outbox(RESERVED_PREFIX).await);
|
||||||
|
expect_reserved(
|
||||||
|
"try_lock(reserved name)",
|
||||||
|
store.try_lock(RESERVED_PREFIX, "worker", 60).await,
|
||||||
|
);
|
||||||
|
expect_reserved(
|
||||||
|
"schedule(reserved name)",
|
||||||
|
store
|
||||||
|
.schedule(
|
||||||
|
RESERVED_PREFIX,
|
||||||
|
"@every 1s",
|
||||||
|
"work",
|
||||||
|
json!(null),
|
||||||
|
Default::default(),
|
||||||
|
)
|
||||||
|
.await,
|
||||||
|
);
|
||||||
|
expect_reserved(
|
||||||
|
"schedule(reserved queue argument)",
|
||||||
|
store
|
||||||
|
.schedule(
|
||||||
|
"tick",
|
||||||
|
"@every 1s",
|
||||||
|
RESERVED_PREFIX,
|
||||||
|
json!(null),
|
||||||
|
Default::default(),
|
||||||
|
)
|
||||||
|
.await,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Happy-path spot-checks: valid names pass validation; a
|
||||||
|
// reserved-prefixed owner (consumer-local identifier) is legal.
|
||||||
|
expect_ok("stream(valid name)", store.stream("work").await);
|
||||||
|
expect_ok(
|
||||||
|
"queue(valid name)",
|
||||||
|
store.queue("work", alkstore::QueueOpts::default()).await,
|
||||||
|
);
|
||||||
|
expect_ok(
|
||||||
|
"try_lock(reserved-prefixed owner is legal)",
|
||||||
|
store.try_lock("lock", RESERVED_PREFIX, 60).await,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Tx paths — the twins validate identically (begin_tx route).
|
||||||
|
let mut tx = store.begin_tx().await.expect("begin_tx opens");
|
||||||
|
expect_invalid(
|
||||||
|
"enqueue_tx(empty queue)",
|
||||||
|
tx.enqueue_tx("", Default::default(), json!(null)).await,
|
||||||
|
);
|
||||||
|
expect_reserved(
|
||||||
|
"enqueue_tx(reserved queue)",
|
||||||
|
tx.enqueue_tx(RESERVED_PREFIX, Default::default(), json!(null))
|
||||||
|
.await,
|
||||||
|
);
|
||||||
|
expect_invalid(
|
||||||
|
"publish_tx(empty stream)",
|
||||||
|
tx.publish_tx("", json!(null)).await,
|
||||||
|
);
|
||||||
|
expect_reserved(
|
||||||
|
"publish_tx(reserved stream)",
|
||||||
|
tx.publish_tx(RESERVED_PREFIX, json!(null)).await,
|
||||||
|
);
|
||||||
|
expect_invalid(
|
||||||
|
"notify_tx(empty channel)",
|
||||||
|
tx.notify_tx("", json!(null)).await,
|
||||||
|
);
|
||||||
|
expect_reserved(
|
||||||
|
"notify_tx(reserved channel)",
|
||||||
|
tx.notify_tx(RESERVED_PREFIX, json!(null)).await,
|
||||||
|
);
|
||||||
|
expect_invalid(
|
||||||
|
"publish_with_key_tx(empty stream)",
|
||||||
|
tx.publish_with_key_tx("", None, json!(null)).await,
|
||||||
|
);
|
||||||
|
expect_reserved(
|
||||||
|
"publish_with_key_tx(reserved stream)",
|
||||||
|
tx.publish_with_key_tx(RESERVED_PREFIX, None, json!(null))
|
||||||
|
.await,
|
||||||
|
);
|
||||||
|
expect_invalid(
|
||||||
|
"publish_with_key_tx(empty key)",
|
||||||
|
tx.publish_with_key_tx("work", Some(String::new()), json!(null))
|
||||||
|
.await,
|
||||||
|
);
|
||||||
|
expect_invalid(
|
||||||
|
"publish_with_key_tx(whitespace key)",
|
||||||
|
tx.publish_with_key_tx("work", Some(" ".to_string()), json!(null))
|
||||||
|
.await,
|
||||||
|
);
|
||||||
|
expect_invalid(
|
||||||
|
"save_offset_tx(empty stream)",
|
||||||
|
tx.save_offset_tx("", "consumer", 0).await,
|
||||||
|
);
|
||||||
|
expect_reserved(
|
||||||
|
"save_offset_tx(reserved stream)",
|
||||||
|
tx.save_offset_tx(RESERVED_PREFIX, "consumer", 0).await,
|
||||||
|
);
|
||||||
|
expect_invalid(
|
||||||
|
"save_offset_tx(empty consumer)",
|
||||||
|
tx.save_offset_tx("work", "", 0).await,
|
||||||
|
);
|
||||||
|
expect_invalid("get_job_tx(empty queue)", tx.get_job_tx("", 1).await);
|
||||||
|
expect_reserved(
|
||||||
|
"get_job_tx(reserved queue)",
|
||||||
|
tx.get_job_tx(RESERVED_PREFIX, 1).await,
|
||||||
|
);
|
||||||
|
expect_invalid(
|
||||||
|
"get_offset_tx(empty stream)",
|
||||||
|
tx.get_offset_tx("", "consumer").await,
|
||||||
|
);
|
||||||
|
expect_reserved(
|
||||||
|
"get_offset_tx(reserved stream)",
|
||||||
|
tx.get_offset_tx(RESERVED_PREFIX, "consumer").await,
|
||||||
|
);
|
||||||
|
expect_invalid(
|
||||||
|
"read_since_tx(empty stream)",
|
||||||
|
tx.read_since_tx("", 0, 10).await,
|
||||||
|
);
|
||||||
|
expect_reserved(
|
||||||
|
"read_since_tx(reserved stream)",
|
||||||
|
tx.read_since_tx(RESERVED_PREFIX, 0, 10).await,
|
||||||
|
);
|
||||||
|
expect_invalid(
|
||||||
|
"read_from_consumer_tx(empty stream)",
|
||||||
|
tx.read_from_consumer_tx("", "consumer", 10).await,
|
||||||
|
);
|
||||||
|
expect_reserved(
|
||||||
|
"read_from_consumer_tx(reserved stream)",
|
||||||
|
tx.read_from_consumer_tx(RESERVED_PREFIX, "consumer", 10)
|
||||||
|
.await,
|
||||||
|
);
|
||||||
|
expect_invalid(
|
||||||
|
"read_from_consumer_tx(empty consumer)",
|
||||||
|
tx.read_from_consumer_tx("work", "", 10).await,
|
||||||
|
);
|
||||||
|
expect_invalid(
|
||||||
|
"outbox_enqueue_tx(empty outbox)",
|
||||||
|
tx.outbox_enqueue_tx("", Default::default(), json!(null))
|
||||||
|
.await,
|
||||||
|
);
|
||||||
|
expect_reserved(
|
||||||
|
"outbox_enqueue_tx(reserved outbox)",
|
||||||
|
tx.outbox_enqueue_tx(RESERVED_PREFIX, Default::default(), json!(null))
|
||||||
|
.await,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Tx path via `with_tx` (the provided wrapper): a validation error
|
||||||
|
// inside the closure propagates as the wrapper's `Err` (and the
|
||||||
|
// disposition rolls back) — the commit-atomic path cannot bypass
|
||||||
|
// validation either.
|
||||||
|
let result = store
|
||||||
|
.with_tx(Box::new(|tx| {
|
||||||
|
Box::pin(async move {
|
||||||
|
validate_shared_name("")?;
|
||||||
|
tx.notify_tx("work", json!(null)).await
|
||||||
|
}) as BoxedFuture<'_, Result<()>>
|
||||||
|
}))
|
||||||
|
.await;
|
||||||
|
expect_invalid("with_tx(inner empty-name rejection)", result);
|
||||||
|
|
||||||
|
factory.teardown().await.expect("factory teardown");
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
//! The version-stamp convention (ADR-017 §4.2, adopted here as the
|
||||||
|
//! suite's row discipline — ADR-022).
|
||||||
|
//!
|
||||||
|
//! Each property row is version-stamped with the contract change that
|
||||||
|
//! added it — ADR-017 §4.2's requirement that makes the suite
|
||||||
|
//! auditable against the core crate's changelog (whose entries cite
|
||||||
|
//! their ADRs by §2's no-silent-change rule).
|
||||||
|
//!
|
||||||
|
//! # The convention
|
||||||
|
//!
|
||||||
|
//! A row's stamp is a doc-comment list item beginning
|
||||||
|
//! **`Contract stamp:`** on the row's doc text, citing the ADR § (or
|
||||||
|
//! the backlog row) that pinned the behavior being tested:
|
||||||
|
//!
|
||||||
|
//! ```text
|
||||||
|
//! /// <one-line statement of the property>
|
||||||
|
//! ///
|
||||||
|
//! /// Contract stamp: ADR-008 §4 (reserved namespace, entry-point
|
||||||
|
//! /// validation); ADR-021 §3 (the schedule queue argument).
|
||||||
|
//! pub async fn row_name(factory: &dyn StoreFactory) { ... }
|
||||||
|
//! ```
|
||||||
|
//!
|
||||||
|
//! Rules:
|
||||||
|
//!
|
||||||
|
//! - The stamp cites the ADR § that pins the property — never a bare
|
||||||
|
//! "v1" or a date. A row may carry multiple stamps when it tests
|
||||||
|
//! several changes' behavior together (the exemplar row does).
|
||||||
|
//! - A row added to pin a new contract addition is stamped with that
|
||||||
|
//! addition's ADR; a row whose property predates the suite is
|
||||||
|
//! stamped with the ADR that pinned the behavior it tests.
|
||||||
|
//! - When a later contract change *amends* a row's property, the
|
||||||
|
//! row's stamp list gains the amending ADR (rows accumulate stamps;
|
||||||
|
//! the ADRs' own texts record the row history).
|
||||||
|
//! - `Contract stamp:` is greppable: `grep -r "Contract stamp:"
|
||||||
|
//! alkstore-contract-suite/` lists the suite's stamp inventory.
|
||||||
|
//!
|
||||||
|
//! Version 0 of the suite: this module *is* the convention's
|
||||||
|
//! normative statement; every later row cites its stamp against it.
|
||||||
|
|
||||||
|
/// The doc-comment marker introducing a row's version stamp.
|
||||||
|
pub const STAMP_MARKER: &str = "Contract stamp:";
|
||||||
@@ -0,0 +1,369 @@
|
|||||||
|
//! The harness: runs the suite's property rows against a supplied
|
||||||
|
//! [`StoreFactory`]. This test target proves the harness end-to-end
|
||||||
|
//! with a trivial in-crate mock store — the acceptance criterion's
|
||||||
|
//! "exemplar property green against a trivial in-crate mock" — and is
|
||||||
|
//! the shape each engine crate's dev-dependency wiring copies.
|
||||||
|
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use serde_json::json;
|
||||||
|
|
||||||
|
use alkstore::{
|
||||||
|
BoxedFuture, Delivery, EnqueueOpts, Error, EventReceiver, Job, JobHandle, Lock, Outbox, Queue,
|
||||||
|
QueueOpts, Result, Schedule, StopToken, Store, StreamEvent, StreamHandle, TxHandle, Wake,
|
||||||
|
WakeReceiver,
|
||||||
|
};
|
||||||
|
use alkstore_contract_suite::StoreFactory;
|
||||||
|
use alkstore_contract_suite::properties::name_validation_rejects_empty_and_reserved;
|
||||||
|
|
||||||
|
/// Trivial mock `TxHandle` — validation is the *engine's* job in real
|
||||||
|
/// engines; the mock leans on the shared helpers exactly as an engine
|
||||||
|
/// would, at each entry point.
|
||||||
|
struct MockTx;
|
||||||
|
|
||||||
|
impl TxHandle for MockTx {
|
||||||
|
fn enqueue_tx<'a>(
|
||||||
|
&'a mut self,
|
||||||
|
queue: &str,
|
||||||
|
_opts: EnqueueOpts,
|
||||||
|
_payload: serde_json::Value,
|
||||||
|
) -> BoxedFuture<'a, Result<i64>> {
|
||||||
|
let r = alkstore::validate_shared_name(queue).map(|_| 1);
|
||||||
|
Box::pin(std::future::ready(r))
|
||||||
|
}
|
||||||
|
fn publish_tx<'a>(
|
||||||
|
&'a mut self,
|
||||||
|
stream: &str,
|
||||||
|
_payload: serde_json::Value,
|
||||||
|
) -> BoxedFuture<'a, Result<i64>> {
|
||||||
|
let r = alkstore::validate_shared_name(stream).map(|_| 1);
|
||||||
|
Box::pin(std::future::ready(r))
|
||||||
|
}
|
||||||
|
fn publish_with_key_tx<'a>(
|
||||||
|
&'a mut self,
|
||||||
|
stream: &str,
|
||||||
|
key: Option<String>,
|
||||||
|
_payload: serde_json::Value,
|
||||||
|
) -> BoxedFuture<'a, Result<i64>> {
|
||||||
|
let r = alkstore::validate_shared_name(stream).and_then(|_| match key {
|
||||||
|
Some(k) if k.trim().is_empty() => Err(Error::InvalidName { name: k }),
|
||||||
|
_ => Ok(1),
|
||||||
|
});
|
||||||
|
Box::pin(std::future::ready(r))
|
||||||
|
}
|
||||||
|
fn notify_tx<'a>(
|
||||||
|
&'a mut self,
|
||||||
|
channel: &str,
|
||||||
|
_payload: serde_json::Value,
|
||||||
|
) -> BoxedFuture<'a, Result<()>> {
|
||||||
|
Box::pin(std::future::ready(alkstore::validate_shared_name(channel)))
|
||||||
|
}
|
||||||
|
fn save_offset_tx<'a>(
|
||||||
|
&'a mut self,
|
||||||
|
stream: &str,
|
||||||
|
consumer: &str,
|
||||||
|
_offset: i64,
|
||||||
|
) -> BoxedFuture<'a, Result<()>> {
|
||||||
|
let r = alkstore::validate_shared_name(stream)
|
||||||
|
.and_then(|_| alkstore::validate_local_name(consumer));
|
||||||
|
Box::pin(std::future::ready(r))
|
||||||
|
}
|
||||||
|
fn get_job_tx<'a>(
|
||||||
|
&'a mut self,
|
||||||
|
queue: &str,
|
||||||
|
_job_id: i64,
|
||||||
|
) -> BoxedFuture<'a, Result<Option<Job>>> {
|
||||||
|
let r = alkstore::validate_shared_name(queue).map(|_| None);
|
||||||
|
Box::pin(std::future::ready(r))
|
||||||
|
}
|
||||||
|
fn get_offset_tx<'a>(
|
||||||
|
&'a mut self,
|
||||||
|
stream: &str,
|
||||||
|
_consumer: &str,
|
||||||
|
) -> BoxedFuture<'a, Result<i64>> {
|
||||||
|
let r = alkstore::validate_shared_name(stream).map(|_| 0);
|
||||||
|
Box::pin(std::future::ready(r))
|
||||||
|
}
|
||||||
|
fn read_since_tx<'a>(
|
||||||
|
&'a mut self,
|
||||||
|
stream: &str,
|
||||||
|
_offset: i64,
|
||||||
|
_limit: i64,
|
||||||
|
) -> BoxedFuture<'a, Result<Vec<StreamEvent>>> {
|
||||||
|
let r = alkstore::validate_shared_name(stream).map(|_| Vec::new());
|
||||||
|
Box::pin(std::future::ready(r))
|
||||||
|
}
|
||||||
|
fn read_from_consumer_tx<'a>(
|
||||||
|
&'a mut self,
|
||||||
|
stream: &str,
|
||||||
|
consumer: &str,
|
||||||
|
_limit: i64,
|
||||||
|
) -> BoxedFuture<'a, Result<Vec<StreamEvent>>> {
|
||||||
|
let r = alkstore::validate_shared_name(stream)
|
||||||
|
.and_then(|_| alkstore::validate_local_name(consumer))
|
||||||
|
.map(|_| Vec::new());
|
||||||
|
Box::pin(std::future::ready(r))
|
||||||
|
}
|
||||||
|
fn outbox_enqueue_tx<'a>(
|
||||||
|
&'a mut self,
|
||||||
|
outbox: &str,
|
||||||
|
_opts: EnqueueOpts,
|
||||||
|
_payload: serde_json::Value,
|
||||||
|
) -> BoxedFuture<'a, Result<i64>> {
|
||||||
|
let r = alkstore::validate_shared_name(outbox).map(|_| 1);
|
||||||
|
Box::pin(std::future::ready(r))
|
||||||
|
}
|
||||||
|
fn commit(self: Box<Self>) -> BoxedFuture<'static, Result<()>> {
|
||||||
|
Box::pin(std::future::ready(Ok(())))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct MockQueue;
|
||||||
|
|
||||||
|
impl Queue for MockQueue {
|
||||||
|
fn name(&self) -> &str {
|
||||||
|
"mock"
|
||||||
|
}
|
||||||
|
fn enqueue<'a>(
|
||||||
|
&'a self,
|
||||||
|
_payload: serde_json::Value,
|
||||||
|
_opts: EnqueueOpts,
|
||||||
|
) -> BoxedFuture<'a, Result<i64>> {
|
||||||
|
Box::pin(std::future::ready(Ok(1)))
|
||||||
|
}
|
||||||
|
fn claim_one<'a>(
|
||||||
|
&'a self,
|
||||||
|
_worker_id: &str,
|
||||||
|
) -> BoxedFuture<'a, Result<Option<Box<dyn JobHandle>>>> {
|
||||||
|
Box::pin(std::future::ready(Ok(None)))
|
||||||
|
}
|
||||||
|
fn claim_batch<'a>(
|
||||||
|
&'a self,
|
||||||
|
_worker_id: &str,
|
||||||
|
_n: i64,
|
||||||
|
) -> BoxedFuture<'a, Result<Vec<Box<dyn JobHandle>>>> {
|
||||||
|
Box::pin(std::future::ready(Ok(Vec::new())))
|
||||||
|
}
|
||||||
|
fn ack_batch<'a>(&'a self, _ids: &[i64]) -> BoxedFuture<'a, Result<i64>> {
|
||||||
|
Box::pin(std::future::ready(Ok(0)))
|
||||||
|
}
|
||||||
|
fn cancel<'a>(&'a self, _job_id: i64) -> BoxedFuture<'a, Result<bool>> {
|
||||||
|
Box::pin(std::future::ready(Ok(false)))
|
||||||
|
}
|
||||||
|
fn get_job<'a>(&'a self, _job_id: i64) -> BoxedFuture<'a, Result<Option<Job>>> {
|
||||||
|
Box::pin(std::future::ready(Ok(None)))
|
||||||
|
}
|
||||||
|
fn sweep_expired<'a>(&'a self) -> BoxedFuture<'a, Result<i64>> {
|
||||||
|
Box::pin(std::future::ready(Ok(0)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct MockStream;
|
||||||
|
|
||||||
|
impl StreamHandle for MockStream {
|
||||||
|
fn name(&self) -> &str {
|
||||||
|
"mock"
|
||||||
|
}
|
||||||
|
fn publish<'a>(&'a self, _payload: serde_json::Value) -> BoxedFuture<'a, Result<i64>> {
|
||||||
|
Box::pin(std::future::ready(Ok(1)))
|
||||||
|
}
|
||||||
|
fn publish_with_key<'a>(
|
||||||
|
&'a self,
|
||||||
|
_key: Option<String>,
|
||||||
|
_payload: serde_json::Value,
|
||||||
|
) -> BoxedFuture<'a, Result<i64>> {
|
||||||
|
Box::pin(std::future::ready(Ok(1)))
|
||||||
|
}
|
||||||
|
fn read_since<'a>(
|
||||||
|
&'a self,
|
||||||
|
_offset: i64,
|
||||||
|
_limit: i64,
|
||||||
|
) -> BoxedFuture<'a, Result<Vec<StreamEvent>>> {
|
||||||
|
Box::pin(std::future::ready(Ok(Vec::new())))
|
||||||
|
}
|
||||||
|
fn read_from_consumer<'a>(
|
||||||
|
&'a self,
|
||||||
|
_consumer: &str,
|
||||||
|
_limit: i64,
|
||||||
|
) -> BoxedFuture<'a, Result<Vec<StreamEvent>>> {
|
||||||
|
Box::pin(std::future::ready(Ok(Vec::new())))
|
||||||
|
}
|
||||||
|
fn save_offset<'a>(&'a self, _consumer: &str, _offset: i64) -> BoxedFuture<'a, Result<()>> {
|
||||||
|
Box::pin(std::future::ready(Ok(())))
|
||||||
|
}
|
||||||
|
fn get_offset<'a>(&'a self, _consumer: &str) -> BoxedFuture<'a, Result<i64>> {
|
||||||
|
Box::pin(std::future::ready(Ok(0)))
|
||||||
|
}
|
||||||
|
fn trim_to<'a>(&'a self, _horizon: i64) -> BoxedFuture<'a, Result<i64>> {
|
||||||
|
Box::pin(std::future::ready(Ok(0)))
|
||||||
|
}
|
||||||
|
fn subscribe<'a>(&'a self, _consumer: &str) -> BoxedFuture<'a, Result<Box<dyn EventReceiver>>> {
|
||||||
|
Box::pin(std::future::ready(Err(Error::Database("mock".into()))))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct MockWakeReceiver;
|
||||||
|
|
||||||
|
impl WakeReceiver for MockWakeReceiver {
|
||||||
|
fn recv<'a>(&'a mut self) -> BoxedFuture<'a, Option<Wake>> {
|
||||||
|
Box::pin(std::future::ready(None))
|
||||||
|
}
|
||||||
|
fn try_recv(&mut self) -> Result<Option<Wake>> {
|
||||||
|
Ok(None)
|
||||||
|
}
|
||||||
|
fn recv_timeout<'a>(&'a mut self, _timeout: Duration) -> BoxedFuture<'a, Result<Option<Wake>>> {
|
||||||
|
Box::pin(std::future::ready(Ok(None)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `Schedule` is `#[non_exhaustive]` (ADR-017 §3) — the mock cannot
|
||||||
|
/// struct-construct it, so the happy-path `schedule()` spot-check is
|
||||||
|
/// delegated to core's own tests; the mock's `schedule()` returns a
|
||||||
|
/// `Database` stub and the row only asserts its *validation* outcomes
|
||||||
|
/// (which precede any construction).
|
||||||
|
struct MockStore;
|
||||||
|
|
||||||
|
impl Store for MockStore {
|
||||||
|
fn begin_tx(&self) -> BoxedFuture<'_, Result<Box<dyn TxHandle + Send>>> {
|
||||||
|
Box::pin(std::future::ready(Ok(
|
||||||
|
Box::new(MockTx) as Box<dyn TxHandle + Send>
|
||||||
|
)))
|
||||||
|
}
|
||||||
|
fn notify<'a>(
|
||||||
|
&'a self,
|
||||||
|
channel: &str,
|
||||||
|
_payload: serde_json::Value,
|
||||||
|
) -> BoxedFuture<'a, Result<()>> {
|
||||||
|
Box::pin(std::future::ready(alkstore::validate_shared_name(channel)))
|
||||||
|
}
|
||||||
|
fn listen<'a>(&'a self, channel: &str) -> BoxedFuture<'a, Result<Box<dyn WakeReceiver>>> {
|
||||||
|
let r = alkstore::validate_shared_name(channel)
|
||||||
|
.map(|_| Box::new(MockWakeReceiver) as Box<dyn WakeReceiver>);
|
||||||
|
Box::pin(std::future::ready(r))
|
||||||
|
}
|
||||||
|
fn stream<'a>(&'a self, name: &str) -> BoxedFuture<'a, Result<Box<dyn StreamHandle>>> {
|
||||||
|
let r = alkstore::validate_shared_name(name)
|
||||||
|
.map(|_| Box::new(MockStream) as Box<dyn StreamHandle>);
|
||||||
|
Box::pin(std::future::ready(r))
|
||||||
|
}
|
||||||
|
fn queue<'a>(
|
||||||
|
&'a self,
|
||||||
|
name: &str,
|
||||||
|
_opts: QueueOpts,
|
||||||
|
) -> BoxedFuture<'a, Result<Box<dyn Queue>>> {
|
||||||
|
let r = alkstore::validate_shared_name(name).map(|_| Box::new(MockQueue) as Box<dyn Queue>);
|
||||||
|
Box::pin(std::future::ready(r))
|
||||||
|
}
|
||||||
|
fn outbox<'a>(&'a self, name: &str) -> BoxedFuture<'a, Result<Box<dyn Outbox>>> {
|
||||||
|
let r =
|
||||||
|
alkstore::validate_shared_name(name).map(|_| Box::new(MockOutbox) as Box<dyn Outbox>);
|
||||||
|
Box::pin(std::future::ready(r))
|
||||||
|
}
|
||||||
|
fn try_lock<'a>(
|
||||||
|
&'a self,
|
||||||
|
name: &str,
|
||||||
|
owner: &str,
|
||||||
|
_ttl: i64,
|
||||||
|
) -> BoxedFuture<'a, Result<Option<Box<dyn Lock>>>> {
|
||||||
|
let r = alkstore::validate_shared_name(name)
|
||||||
|
.and_then(|_| alkstore::validate_local_name(owner))
|
||||||
|
.map(|_| None);
|
||||||
|
Box::pin(std::future::ready(r))
|
||||||
|
}
|
||||||
|
fn schedule<'a>(
|
||||||
|
&'a self,
|
||||||
|
name: &str,
|
||||||
|
_spec: &str,
|
||||||
|
queue: &str,
|
||||||
|
_payload: serde_json::Value,
|
||||||
|
_opts: alkstore::ScheduleOpts,
|
||||||
|
) -> BoxedFuture<'a, Result<Schedule>> {
|
||||||
|
let r = alkstore::validate_shared_name(name)
|
||||||
|
.and_then(|_| alkstore::validate_shared_name(queue))
|
||||||
|
.and_then(|_| {
|
||||||
|
Err(Error::Database(
|
||||||
|
"mock: schedule read-back not exercised".into(),
|
||||||
|
))
|
||||||
|
});
|
||||||
|
Box::pin(std::future::ready(r))
|
||||||
|
}
|
||||||
|
fn unschedule<'a>(&'a self, _name: &str) -> BoxedFuture<'a, Result<bool>> {
|
||||||
|
Box::pin(std::future::ready(Ok(false)))
|
||||||
|
}
|
||||||
|
fn run_schedules<'a>(&'a self, _stop: StopToken) -> BoxedFuture<'a, Result<()>> {
|
||||||
|
Box::pin(std::future::ready(Ok(())))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct MockOutbox;
|
||||||
|
|
||||||
|
impl Outbox for MockOutbox {
|
||||||
|
fn name(&self) -> &str {
|
||||||
|
"mock"
|
||||||
|
}
|
||||||
|
fn enqueue<'a>(
|
||||||
|
&'a self,
|
||||||
|
_payload: serde_json::Value,
|
||||||
|
_opts: EnqueueOpts,
|
||||||
|
) -> BoxedFuture<'a, Result<i64>> {
|
||||||
|
Box::pin(std::future::ready(Ok(1)))
|
||||||
|
}
|
||||||
|
fn run_once<'a>(
|
||||||
|
&'a mut self,
|
||||||
|
_worker_id: &str,
|
||||||
|
_delivery: &'a mut dyn Delivery,
|
||||||
|
) -> BoxedFuture<'a, Result<bool>> {
|
||||||
|
Box::pin(std::future::ready(Ok(false)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The mock factory: yields a fresh store over an isolated (in-memory,
|
||||||
|
/// stateless) backing store. Isolation is trivially true — the mock
|
||||||
|
/// holds no rows.
|
||||||
|
struct MockFactory;
|
||||||
|
|
||||||
|
impl StoreFactory for MockFactory {
|
||||||
|
fn open(&self) -> BoxedFuture<'_, Result<Box<dyn Store>>> {
|
||||||
|
Box::pin(std::future::ready(
|
||||||
|
Ok(Box::new(MockStore) as Box<dyn Store>),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
fn teardown(&self) -> BoxedFuture<'_, Result<()>> {
|
||||||
|
Box::pin(std::future::ready(Ok(())))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn exemplar_row_green_against_mock_factory() {
|
||||||
|
let factory = MockFactory;
|
||||||
|
name_validation_rejects_empty_and_reserved(&factory).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Happy-path spot-check behind the exemplar row: valid names construct
|
||||||
|
/// handles — proving the row's rejections are the validation's doing
|
||||||
|
/// (the mock's `schedule` happy path is a `Database` stub because
|
||||||
|
/// `Schedule` is `#[non_exhaustive]`; its validation outcomes are what
|
||||||
|
/// the row asserts).
|
||||||
|
#[tokio::test]
|
||||||
|
async fn mock_store_happy_paths_construct() {
|
||||||
|
let factory = MockFactory;
|
||||||
|
let store = factory.open().await.unwrap();
|
||||||
|
|
||||||
|
store.stream("work").await.expect("stream handle");
|
||||||
|
store
|
||||||
|
.queue("work", QueueOpts::default())
|
||||||
|
.await
|
||||||
|
.expect("queue handle");
|
||||||
|
store.outbox("work").await.expect("outbox handle");
|
||||||
|
|
||||||
|
factory.teardown().await.expect("teardown");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn stamp_marker_and_json_shape() {
|
||||||
|
assert_eq!(
|
||||||
|
alkstore_contract_suite::version_stamp::STAMP_MARKER,
|
||||||
|
"Contract stamp:"
|
||||||
|
);
|
||||||
|
let _ = json!(null);
|
||||||
|
}
|
||||||
@@ -9,4 +9,7 @@ repository.workspace = true
|
|||||||
alkstore = { version = "0.1", path = "../alkstore" }
|
alkstore = { version = "0.1", path = "../alkstore" }
|
||||||
tokio = { version = "1", features = ["rt-multi-thread", "macros"] }
|
tokio = { version = "1", features = ["rt-multi-thread", "macros"] }
|
||||||
tokio-postgres = "0.7"
|
tokio-postgres = "0.7"
|
||||||
deadpool-postgres = "0.14"
|
deadpool-postgres = "0.14"
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
alkstore-contract-suite = { path = "../alkstore-contract-suite" }
|
||||||
@@ -7,4 +7,7 @@ repository.workspace = true
|
|||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
alkstore = { version = "0.1", path = "../alkstore" }
|
alkstore = { version = "0.1", path = "../alkstore" }
|
||||||
rusqlite = { version = "0.40", features = ["bundled"] }
|
rusqlite = { version = "0.40", features = ["bundled"] }
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
alkstore-contract-suite = { path = "../alkstore-contract-suite" }
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
---
|
---
|
||||||
status: draft
|
status: draft
|
||||||
last_updated: 2026-10-07 (ADR-021 + third-round follow-through resolved)
|
last_updated: 2026-10-07 (ADR-022: contract-suite layout resolved)
|
||||||
---
|
---
|
||||||
|
|
||||||
# alkstore — Architecture
|
# alkstore — Architecture
|
||||||
@@ -56,6 +56,7 @@ pending architecture review and OQ resolution.
|
|||||||
| [019](decisions/019-mechanism-handle-surfaces.md) | Mechanism-handle surfaces — handle traits (`Queue`/`StreamHandle`/`Outbox`/`Lock`/`JobHandle`), `Job`/`Schedule` shapes, `worker_id` identity, core `StopToken` | Accepted |
|
| [019](decisions/019-mechanism-handle-surfaces.md) | Mechanism-handle surfaces — handle traits (`Queue`/`StreamHandle`/`Outbox`/`Lock`/`JobHandle`), `Job`/`Schedule` shapes, `worker_id` identity, core `StopToken` | Accepted |
|
||||||
| [020](decisions/020-enqueue-opt-semantics-and-bridges.md) | Enqueue-option semantics — delay/run_at precedence, relative `expires`, scheduler stamp source, serde_json payload encoding | Accepted |
|
| [020](decisions/020-enqueue-opt-semantics-and-bridges.md) | Enqueue-option semantics — delay/run_at precedence, relative `expires`, scheduler stamp source, serde_json payload encoding | Accepted |
|
||||||
| [021](decisions/021-tx-reads-and-value-shape-fixes.md) | Third review round — tx-read methods on `TxHandle`, `Job.claimed_at`, `schedule()` queue-argument validation, drop = rollback, receiver close/error arms | Accepted |
|
| [021](decisions/021-tx-reads-and-value-shape-fixes.md) | Third review round — tx-read methods on `TxHandle`, `Job.claimed_at`, `schedule()` queue-argument validation, drop = rollback, receiver close/error arms | Accepted |
|
||||||
|
| [022](decisions/022-contract-suite-layout.md) | Contract-suite layout — shared internal suite crate, properties parameterized over a store factory (discharges ADR-017 §4.2's deferral) | Accepted |
|
||||||
|
|
||||||
## Open Questions
|
## Open Questions
|
||||||
|
|
||||||
|
|||||||
@@ -211,6 +211,11 @@ not a runtime descriptor):
|
|||||||
shared crate, workspace test target, or per-engine module — is a
|
shared crate, workspace test target, or per-engine module — is a
|
||||||
test-side convenience decided at implementation
|
test-side convenience decided at implementation
|
||||||
([ADR-001](001-crate-split.md) §4's posture for test artifacts).
|
([ADR-001](001-crate-split.md) §4's posture for test artifacts).
|
||||||
|
*(Layout resolved 2026-10-07:
|
||||||
|
[ADR-022](022-contract-suite-layout.md) — the shared internal
|
||||||
|
`alkstore-contract-suite` crate; engines take it as a
|
||||||
|
dev-dependency and drive the rows against their own stores via a
|
||||||
|
`StoreFactory` the suite defines.)*
|
||||||
3. **Engine-crate docs**: each engine's docs state the contract
|
3. **Engine-crate docs**: each engine's docs state the contract
|
||||||
version(s) it implements — the same standing-statement altitude
|
version(s) it implements — the same standing-statement altitude
|
||||||
as its deployment posture (ADR-016 §3's carrier 2).
|
as its deployment posture (ADR-016 §3's carrier 2).
|
||||||
|
|||||||
@@ -0,0 +1,147 @@
|
|||||||
|
# ADR-022: Contract-suite layout — a shared internal suite crate, properties parameterized over a store factory
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
Accepted (2026-10-07, Phase 1 wave-1 — discharges
|
||||||
|
[ADR-017](017-contract-versioning.md) §4.2's "layout decided at
|
||||||
|
implementation" deferral)
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
[ADR-017](017-contract-versioning.md) §4.2 matured the verification
|
||||||
|
backlog ([core-contract.md](../core-contract.md)
|
||||||
|
§Verification backlog) into the *contract suite* — the pairing
|
||||||
|
instrument whose version-stamped rows make "this engine implements
|
||||||
|
contract vN" an auditable claim. What that ADR deliberately deferred
|
||||||
|
was the suite's *layout*: shared crate, workspace test target, or
|
||||||
|
per-engine module — "a test-side convenience decided at
|
||||||
|
implementation ([ADR-001](001-crate-split.md) §4's posture for test
|
||||||
|
artifacts)."
|
||||||
|
|
||||||
|
The implementation plan decoupled the decision from wave 5 (where the
|
||||||
|
suite fills with cross-engine equivalence rows) and pulled the
|
||||||
|
scaffold into wave 1, with the reasoning recorded in
|
||||||
|
[implementation.md](../../plans/implementation.md): the harness shape —
|
||||||
|
a `Store`-factory-parameterized property crate — is easier to grow row
|
||||||
|
by row as engines land than to retrofit onto two finished engines, and
|
||||||
|
the engine crates' dev-dependency edge existing early means wave 3/4
|
||||||
|
tasks *adopt* the harness rather than debate it.
|
||||||
|
|
||||||
|
The candidate layouts, at implementation:
|
||||||
|
|
||||||
|
- **(a) A shared internal suite crate** — `alkstore-contract-suite`,
|
||||||
|
`publish = false`, depending on core only; each engine crate takes
|
||||||
|
it as a dev-dependency and drives the rows against its own factory.
|
||||||
|
- **(b) A workspace test target** — the rows live as integration
|
||||||
|
tests inside one workspace member (core, or a dedicated tests-only
|
||||||
|
member), with engines contributing fixtures.
|
||||||
|
- **(c) Per-engine modules** — each engine crate carries its own copy
|
||||||
|
of the property set in its own test tree.
|
||||||
|
|
||||||
|
Shape facts that narrow the choice:
|
||||||
|
|
||||||
|
- The rows are parameterized over a *store factory*, not a store: each
|
||||||
|
property needs a fresh store over an isolated backing store (fresh
|
||||||
|
file / fresh schema) so rows never observe one another's state —
|
||||||
|
something a shared fixture alone does not give.
|
||||||
|
- The rows are *the same text* for both engines — that is the point of
|
||||||
|
the equivalence pinning ([ADR-012](012-forked-substrate-design.md)
|
||||||
|
§2: the engines' arithmetic is "tested into equivalence" by the
|
||||||
|
suite). Two copies of a property is the classic drift surface: one
|
||||||
|
copy gets strengthened and the other silently lags.
|
||||||
|
- The suite is a dev-side instrument; nothing in it belongs on any
|
||||||
|
published artifact's dependency graph (dev-dependency edges only).
|
||||||
|
- [ADR-017](017-contract-versioning.md) §4.2 also pinned *per-row
|
||||||
|
version stamps* (the contract change that added the row — the same
|
||||||
|
no-silent-change discipline §2), and the version-stamp convention
|
||||||
|
needed a normative owner decided now, since every later row cites it.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
**Option (a): the suite is a shared internal crate —
|
||||||
|
`alkstore-contract-suite`**, unpublished (`publish = false`), a
|
||||||
|
workspace member, depending on `alkstore` only (no driver deps, ever —
|
||||||
|
the mirroring of [ADR-001](001-crate-split.md) §2's core rule keeps the
|
||||||
|
suite compilable against any factory). Each engine crate takes it as a
|
||||||
|
**dev-dependency** and drives the rows in its own test target against
|
||||||
|
a factory it defines: a fresh store over an isolated backing store per
|
||||||
|
run, plus teardown.
|
||||||
|
|
||||||
|
- **One normative owner per property** —
|
||||||
|
[ADR-012](012-forked-substrate-design.md) §2's one-owner rule
|
||||||
|
applied to test artifacts: each property's text, assertion strength,
|
||||||
|
and version stamp live in exactly one place. Per-engine copies
|
||||||
|
(option (c)) double the owners and drift; the suite crate is the
|
||||||
|
single owner, engines are executors.
|
||||||
|
- **A crate, not a test target** (over option (b)): the suite's rows
|
||||||
|
must be *callable from each engine's* test binary — a dev-dependency
|
||||||
|
is the Cargo mechanism for exactly that shape. It also gives the
|
||||||
|
suite a compilation gate of its own and a `publish = false` marker
|
||||||
|
recording its internal posture in the manifest, where
|
||||||
|
[ADR-005](005-dependency-ownership.md)'s published-by-default
|
||||||
|
default is answered in the artifact itself.
|
||||||
|
- **The factory abstraction is the suite's API**: a minimal
|
||||||
|
`StoreFactory` trait (open/fresh, teardown) over the boxed-future
|
||||||
|
posture the contract traits use. The exact factory shape is
|
||||||
|
deliberately *not frozen* — it may evolve as engines adopt the
|
||||||
|
harness (wave 3/4 feedback); this ADR records the **layout**
|
||||||
|
decision. Suite-side changes (rows, assertion strength, factory
|
||||||
|
shape) are contract-non-events ([ADR-017](017-contract-versioning.md)
|
||||||
|
§2 class 4 — the suite tests the contract, it is not contract
|
||||||
|
surface); a suite row added to pin a *contract* addition carries
|
||||||
|
that addition's ADR as its version stamp, and the class-2/3 change
|
||||||
|
itself rides ADR-017's discipline on the core crate's side.
|
||||||
|
- **The version-stamp convention is the suite crate's, documented
|
||||||
|
there** ([version stamp module](../../../alkstore-contract-suite/src/version_stamp.rs)):
|
||||||
|
a row's doc comment carries one `Contract stamp:` item per contract
|
||||||
|
change that pinned the behavior tested, citing the ADR §. The
|
||||||
|
exemplar row (entry-point name validation — empty → `InvalidName`,
|
||||||
|
reserved prefix → `ReservedName`, auto-commit and tx paths alike)
|
||||||
|
is stamped `ADR-008 §4` (+ ADR-021 §3, ADR-015 §2 for the rows its
|
||||||
|
assertions encompass) and proves the harness end-to-end against a
|
||||||
|
trivial in-crate mock store.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
**Positive**
|
||||||
|
|
||||||
|
- The equivalence discipline has a mechanical home: one row text, run
|
||||||
|
on both engines by their factories — the no-ghosts, ordering, and
|
||||||
|
arithmetic-equality claims (wave 5's rows later) are one property
|
||||||
|
each, not one per engine.
|
||||||
|
- The engine/dev-dependency edge existing from wave 1 makes adoption
|
||||||
|
the default path for waves 3/4 (their backlog-column tasks run the
|
||||||
|
rows, not re-derive them) — the reason the scaffold was pulled
|
||||||
|
forward from wave 5.
|
||||||
|
- `publish = false` plus core-only deps keeps the artifact off every
|
||||||
|
published graph and compilable anywhere (A mem-shaped engine, if
|
||||||
|
wave 6 wants one ([ADR-001](001-crate-split.md) §4's deferral), can
|
||||||
|
implement the same `StoreFactory` and reuse the suite verbatim).
|
||||||
|
|
||||||
|
**Negative**
|
||||||
|
|
||||||
|
- A dev-dependency edge is graph-visible but Cargo-weak: the suite
|
||||||
|
cannot enforce that an engine actually *ran* it (dev-deps can be
|
||||||
|
ignored by a careless release). The enforcement is process-side —
|
||||||
|
the wave-3/4 review gates and [ADR-017](017-contract-versioning.md)
|
||||||
|
§4.2's release-time suite requirement, the same honesty posture the
|
||||||
|
rest of the pairing carriers ride.
|
||||||
|
- The factory shape's latitude is a small standing surface: engines
|
||||||
|
will each own a factory impl, and a shapes-drift (e.g. someone adds
|
||||||
|
a pool-reuse knob) needs review discipline to keep the two
|
||||||
|
implementations comparable. The ADR's stated rule — factory changes
|
||||||
|
are suite-crate changes first — bounds it.
|
||||||
|
|
||||||
|
## References
|
||||||
|
|
||||||
|
- [ADR-017](017-contract-versioning.md) §4.2 — the deferral this ADR
|
||||||
|
discharges; the version-stamp duty the convention implements; class
|
||||||
|
4's suite-is-not-contract boundary.
|
||||||
|
- [ADR-001](001-crate-split.md) §4 — the test-artifact posture the
|
||||||
|
layout defers by; the mem-engine note's possible suite reuse.
|
||||||
|
- [ADR-012](012-forked-substrate-design.md) §2 — the one-normative-
|
||||||
|
owner rule the layout mirrors; the "tested into equivalence" framing.
|
||||||
|
- [core-contract.md](../core-contract.md) §Verification backlog — the
|
||||||
|
rows the suite matures from.
|
||||||
|
- docs/plans/implementation.md "Decided points" — the wave-1 pull-
|
||||||
|
forward rationale and the option-(a) record this ADR formalizes.
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
---
|
---
|
||||||
status: draft
|
status: draft
|
||||||
last_updated: 2026-10-07 (ADR-021 — third review round: tx reads, value-shape fixes, schedule queue validation, drop=rollback, receiver arms)
|
last_updated: 2026-10-07 (ADR-022 — contract-suite layout: shared internal suite crate)
|
||||||
---
|
---
|
||||||
|
|
||||||
# alkstore — Overview
|
# alkstore — Overview
|
||||||
@@ -85,6 +85,7 @@ Per [ADR-002](decisions/002-feature-scope.md):
|
|||||||
| [019](decisions/019-mechanism-handle-surfaces.md) | Mechanism-handle surfaces (handle traits, `Job`/`Schedule` shapes, `worker_id`, `StopToken`) | Accepted |
|
| [019](decisions/019-mechanism-handle-surfaces.md) | Mechanism-handle surfaces (handle traits, `Job`/`Schedule` shapes, `worker_id`, `StopToken`) | Accepted |
|
||||||
| [020](decisions/020-enqueue-opt-semantics-and-bridges.md) | Enqueue-option semantics (delay/run_at, expires, scheduler stamp source, payload encoding) | Accepted |
|
| [020](decisions/020-enqueue-opt-semantics-and-bridges.md) | Enqueue-option semantics (delay/run_at, expires, scheduler stamp source, payload encoding) | Accepted |
|
||||||
| [021](decisions/021-tx-reads-and-value-shape-fixes.md) | Third review round (tx-read methods on `TxHandle`, `Job.claimed_at`, `schedule()` queue-argument validation, drop = rollback, receiver close/error arms) | Accepted |
|
| [021](decisions/021-tx-reads-and-value-shape-fixes.md) | Third review round (tx-read methods on `TxHandle`, `Job.claimed_at`, `schedule()` queue-argument validation, drop = rollback, receiver close/error arms) | Accepted |
|
||||||
|
| [022](decisions/022-contract-suite-layout.md) | Contract-suite layout (shared internal suite crate, store-factory parameterization) | Accepted |
|
||||||
|
|
||||||
## Non-goals
|
## Non-goals
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
---
|
---
|
||||||
id: contract-suite-scaffold
|
id: contract-suite-scaffold
|
||||||
name: Contract-suite crate scaffold + ADR-022 (suite layout decision)
|
name: Contract-suite crate scaffold + ADR-022 (suite layout decision)
|
||||||
status: pending
|
status: completed
|
||||||
depends_on: [core-trait-surface]
|
depends_on: [core-trait-surface]
|
||||||
scope: narrow
|
scope: narrow
|
||||||
risk: low
|
risk: low
|
||||||
@@ -69,8 +69,73 @@ The crate:
|
|||||||
|
|
||||||
## Notes
|
## Notes
|
||||||
|
|
||||||
> To be filled by implementation agent
|
- **Factory shape (the implementer's choice)**: a named trait,
|
||||||
|
`StoreFactory { open() -> BoxedFuture<Result<Box<dyn Store>>>;
|
||||||
|
teardown() -> BoxedFuture<Result<()>> }` — chosen over a function
|
||||||
|
alias because teardown needs an identity to be idempotent *per
|
||||||
|
instance* (a bare fn can't carry "this factory's backing store"),
|
||||||
|
and the async surface rides core's `BoxedFuture` posture so
|
||||||
|
implementers need no macros. One shape, documented in
|
||||||
|
`src/factory.rs`; the ADR records the layout, not a frozen API —
|
||||||
|
wave 3/4 feedback may evolve it.
|
||||||
|
- **Version-stamp convention (the "decided here" row convention)**:
|
||||||
|
a row's doc comment carries one `Contract stamp:` item per contract
|
||||||
|
change pinning the behavior tested, citing ADR § (never bare "v1"
|
||||||
|
or dates); stamps *accumulate* on amendment; greppable via the
|
||||||
|
exported `STAMP_MARKER` constant. Normative statement lives in
|
||||||
|
`src/version_stamp.rs` — every later row cites against it. The
|
||||||
|
exemplar row is stamped `ADR-008 §4` + `ADR-021 §3` + `ADR-015 §2`
|
||||||
|
(its assertions span all three changes' behavior).
|
||||||
|
- **Assertion posture**: rows panic on violation (labeled with the
|
||||||
|
entry point and expectation) — the test-side-artifact relaxation of
|
||||||
|
the family no-panic rule, documented in the crate docs as
|
||||||
|
deliberate.
|
||||||
|
- **Exemplar scope**: the property runs the full validation surface —
|
||||||
|
all four `Store` constructor kinds (`listen`/`stream`/`queue`/
|
||||||
|
`outbox`), `try_lock` (both name and owner), `schedule` (both name
|
||||||
|
and queue argument — ADR-021 §3), `notify`, and *every* name-bearing
|
||||||
|
`*_tx` twin on `TxHandle` (all eleven, incl. ADR-015 §2's
|
||||||
|
non-empty-key rule on `publish_with_key_tx`), plus a `with_tx`
|
||||||
|
spot-check proving the commit-atomic path can't bypass validation.
|
||||||
|
Happy-path spot-checks (`stream`/`queue`/`try_lock` with a
|
||||||
|
reserved-prefixed owner) prove the rejections are the validation's
|
||||||
|
doing, not store-wide failure.
|
||||||
|
- **`Schedule` is `#[non_exhaustive]`** (ADR-017 §3): the mock cannot
|
||||||
|
struct-construct it, so the mock's `schedule()` returns a `Database`
|
||||||
|
stub after running the row-validation; the row asserts only the
|
||||||
|
validation outcomes, and `Schedule` construction stays core's own
|
||||||
|
tests' business. Same for the mock's `subscribe` (never exercised by
|
||||||
|
the row). Noted so wave 3/4 factory impls copy the posture knowingly.
|
||||||
|
- **serde_json is a direct dep of the suite** (not just core's): rows
|
||||||
|
construct payload `Value`s as contract surface (ADR-020 §4's trait
|
||||||
|
crossing). No driver deps — the ADR-001 §2 mirror holds.
|
||||||
|
- **Dev-dep edges are path deps** (`path = "../alkstore-contract-suite"`)
|
||||||
|
with no version: correct for an unpublished internal crate — a
|
||||||
|
`version` key would be meaningless.
|
||||||
|
- ADR-017 §4.2's text gained a layout-resolution annotation (dated,
|
||||||
|
citing ADR-022) at the deferral site — the discharge is visible from
|
||||||
|
the deferring document itself.
|
||||||
|
|
||||||
## Summary
|
## Summary
|
||||||
|
|
||||||
> To be filled on completion
|
The `alkstore-contract-suite` crate is scaffolded per the option-(a)
|
||||||
|
layout decision: `publish = false`, workspace member, depends on
|
||||||
|
`alkstore` only (+ serde_json for payload construction). It defines
|
||||||
|
the `StoreFactory` trait (open/teardown over an isolated backing
|
||||||
|
store), the version-stamp convention (`Contract stamp:` doc-comment
|
||||||
|
items, `STAMP_MARKER` constant, normative statement in
|
||||||
|
version_stamp.rs), and the exemplar property
|
||||||
|
`name_validation_rejects_empty_and_reserved` — full auto-commit + tx
|
||||||
|
validation surface, stamped ADR-008 §4 / ADR-021 §3 / ADR-015 §2. A
|
||||||
|
`tests/suite_harness.rs` runs it green against a trivial in-crate mock
|
||||||
|
store + factory (3 tests: the exemplar row, happy-path construction,
|
||||||
|
stamp-marker check). Engine crates carry the dev-dependency edge
|
||||||
|
(path-dep). ADR-022 written
|
||||||
|
(docs/architecture/decisions/022-contract-suite-layout.md): option (a)
|
||||||
|
chosen, one-normative-owner-per-property rationale (ADR-012 §2
|
||||||
|
mirror), factory-shape latitude stated, ADR-017 §4.2 deferral
|
||||||
|
discharged (with an annotation added at the deferral site);
|
||||||
|
ADR index tables in docs/architecture/README.md and overview.md
|
||||||
|
updated. Workspace `cargo build`, `cargo test` (26 tests workspace
|
||||||
|
wide), `cargo clippy --all-targets -- -D warnings`, `cargo fmt
|
||||||
|
--check` all clean.
|
||||||
Reference in new issue
Block a user