alkstore 0.1.0

The alkstore contract crate: one reactive store trait surface — notify, streams, durable queues, TTL locks, outbox, scheduler — implemented by the alkstore-sqlite/-postgres/-mem engine crates; no driver dependencies
Documentation
//! The `Store` trait — the unified engine-agnostic surface (ADR-008
//! §1/§6, ADR-019 §1, ADR-009 §1).
//!
//! Constructors and their option structs live in the engine crates
//! (ADR-008 §6): the engine's `open` returns this trait, and a store
//! handed out by an engine crate honors the full v1 surface. Consumer
//! code never branches on engine type. (The mem engine's engine-native
//! constructor — `MemStore::new()`, no connection string — is the one
//! documented exception to the open-prose, ADR-024 §4; the engine
//! choice remains a dependency-graph fact made before any store
//! exists.)
//!
//! # Numeric-argument domains (ADR-023 §2)
//!
//! Caller-supplied numeric arguments are classified by argument kind,
//! uniform on every engine (validation at the trait-impl entry point):
//! **extents** (`claim_batch`, the stream `read` forms, on the direct
//! and tx paths alike) clamp to the empty value result on non-positive
//! values — a value, not an error; **durations** (`try_lock`/
//! `ttl`, `renew`/`ttl`) reject non-positive values (`Err`) — never a
//! silent immediately-expired lease; **boundaries/offsets**
//! (`trim_to`'s horizon; enqueue `delay`/`run_at`/`expires`) are
//! total — any `i64` is well-defined. Queue/schedule opts-stamp fields
//! are not in the domain table (trusted as given; deployment.md's
//! consumer-obligation notes carry the per-field symptoms).

use serde_json::Value;

use crate::Result;
use crate::future::BoxedFuture;
use crate::lock::Lock;
use crate::opts::{QueueOpts, ScheduleOpts};
use crate::outbox::Outbox;
use crate::queue::Queue;
use crate::schedule::Schedule;
use crate::stop_token::StopToken;
use crate::stream_handle::StreamHandle;
use crate::tx::TxHandle;
use crate::wake_receiver::WakeReceiver;

/// The pinned closure shape `with_tx` takes (ADR-007): a
/// `FnOnce(&mut dyn TxHandle) -> BoxedFuture<Result<()>>` — dyn-encoded
/// for object safety, `Send` because the transaction's future must be.
pub type WithTxClosure<'a> =
    Box<dyn FnOnce(&mut dyn TxHandle) -> BoxedFuture<'_, Result<()>> + Send + 'a>;

/// The store handle: one object, every mechanism behind it.
///
/// # Entry-point validation obligation
///
/// Every name-bearing method validates its names at the entry point —
/// before any engine round trip, on auto-commit paths (and the tx
/// path via [`TxHandle`], whose docs carry the same obligation):
/// non-empty otherwise `InvalidName`; reserved-prefix (`__alkstore_`)
/// rejected with `ReservedName` for the shared-namespace kinds
/// (channels, streams, queues, outboxes, locks, schedule names, and
/// `schedule()`'s queue argument). Consumer-local identifiers
/// (`try_lock`'s `owner`, stream consumers, `worker_id`) take the
/// non-empty rule only. Implemented engines share the helpers from
/// `validation`.
pub trait Store: Send + Sync {
    /// Begin a transaction — the caller-held [`TxHandle`] whose `*_tx`
    /// methods are commit-atomic with a business write. Dropping the
    /// handle without `commit` rolls it back (ADR-021 §4).
    fn begin_tx(&self) -> BoxedFuture<'_, Result<Box<dyn TxHandle + Send>>>;

    /// Fire-and-forget notify on `channel`. No durability, no replay;
    /// a listener attached after a commit never sees it. Payload limit
    /// pg-side (8000 bytes, client-side checked — `PayloadTooLarge`
    /// before any round trip); no limit on SQLite.
    fn notify<'a>(&'a self, channel: &str, payload: Value) -> BoxedFuture<'a, Result<()>>;

    /// Listen on `channel` — starts from "now" (no replay); delivers
    /// opaque [`Wake`](crate::Wake) hints (channel only, never
    /// payloads).
    fn listen<'a>(&'a self, channel: &str) -> BoxedFuture<'a, Result<Box<dyn WakeReceiver>>>;

    /// Take the stream-scoped handle for `name`.
    fn stream<'a>(&'a self, name: &str) -> BoxedFuture<'a, Result<Box<dyn StreamHandle>>>;

    /// Take the queue-scoped handle for `name`, with the `QueueOpts`
    /// stamps future enqueues resolve over (ADR-010 §3a).
    fn queue<'a>(&'a self, name: &str, opts: QueueOpts) -> BoxedFuture<'a, Result<Box<dyn Queue>>>;

    /// Take the outbox-scoped handle for `name` (backing queue derived
    /// engine-side under the reserved prefix).
    fn outbox<'a>(&'a self, name: &str) -> BoxedFuture<'a, Result<Box<dyn Outbox>>>;

    /// Acquire a named TTL lock, or fail: `None` = someone holds it
    /// (no-work is a value, not an error). `owner` is consumer-local
    /// (non-empty only); `ttl` is seconds — a duration argument
    /// (ADR-023 §2): `ttl <= 0` is a rejection (opaque `Err`, never a
    /// silent immediately-expired lease). Exclusion lapses silently
    /// at TTL expiry — holders renew within TTL (ADR-008 §7).
    fn try_lock<'a>(
        &'a self,
        name: &str,
        owner: &str,
        ttl: i64,
    ) -> BoxedFuture<'a, Result<Option<Box<dyn Lock>>>>;

    /// Register (upsert by name) a schedule firing into `queue`.
    /// Both the schedule name and the queue argument validate as
    /// shared-namespace names at the entry point (ADR-021 §3 — a
    /// schedule cannot target a reserved name). `spec` grammar v1:
    /// `@every <n><unit>` only (s|m|h|d) — else `InvalidSpec`
    /// (ADR-009 §2), rejected before storage. Schedules never fire
    /// without a consumer running [`Store::run_schedules`].
    fn schedule<'a>(
        &'a self,
        name: &str,
        spec: &str,
        queue: &str,
        payload: Value,
        opts: ScheduleOpts,
    ) -> BoxedFuture<'a, Result<Schedule>>;

    /// Remove a schedule registration. `true` = a row was removed;
    /// `false` = no such schedule (a value, not an error). `name` is a
    /// name-bearing entry point (ADR-009 §1) — validated like the
    /// schedule name: non-empty (`InvalidName`), reserved-prefix
    /// rejected (`ReservedName`).
    fn unschedule<'a>(&'a self, name: &str) -> BoxedFuture<'a, Result<bool>>;

    /// The opt-in scheduler runner: acquire the leadership lock (the
    /// reserved name `__alkstore_scheduler`), loop {renew; fire due
    /// boundaries; sleep until the next boundary or `stop`}. A
    /// cancelled [`StopToken`] ends the sleep and returns the clean
    /// `Ok(())`; leadership loss returns `Err(LeadershipLost)` — the
    /// caller's recipe is to respawn (ADR-009 §1). A *failed* renewal
    /// is not a loss and never returns `LeadershipLost`: a closed
    /// store (respawning a leader against a dead store is misdirected
    /// — the engine-wide closed-store posture) and storage failure
    /// both surface the opaque `Err(Database)` fallback with the
    /// engine detail preserved via the source chain (annotated
    /// 2026-10-11 — review 004 item 9; ADR-017 §2 class 1).
    fn run_schedules<'a>(&'a self, stop: StopToken) -> BoxedFuture<'a, Result<()>>;

    /// Closure-scoped transaction — a wrapper over [`Store::begin_tx`]
    /// (provided method; ADR-007, signature pinned 2026-10-07).
    ///
    /// Deliberately non-generic (a `Store` trait object must stay
    /// object-safe — ADR-008 §2): the pinned `FnOnce(&mut dyn TxHandle)
    /// -> BoxedFuture<Result<()>>` shape crosses dyn-encoded — as
    /// `Box<dyn FnOnce ...>` — with values returning through the
    /// closure's captured state; no handle escapes the closure.
    ///
    /// Disposition: `Ok` ⇒ commit; `Err`, drop, or panic across the
    /// closure ⇒ rollback (ADR-021 §4's drop = rollback). `with_tx`
    /// is the convenience; `begin_tx` is the seam.
    fn with_tx<'a>(&'a self, f: WithTxClosure<'a>) -> BoxedFuture<'a, Result<()>> {
        Box::pin(async move {
            let mut handle = self.begin_tx().await?;
            match Box::pin(f(&mut *handle)).await {
                Ok(()) => handle.commit().await,
                Err(e) => {
                    drop(handle);
                    Err(e)
                }
            }
        })
    }
}