First v1.1.1 commit. Adds the KV store the design notes commit to: `(app_id, collection, key)` identity with JSONB value and a per-app index. Trait lives in `picloud-shared` so the executor-core Rhai bridge (next commit), the Postgres impl, and tests all depend on the same surface without coupling crates. The `Services` bundle grows from empty to three fields: `kv`, `dead_letters` (NoopDeadLetterService stub — replaced by the Postgres impl in commit 8), and `events` (NoopEventEmitter until the outbox emitter lands with the dispatcher). Tests use `Services::default()` for an all-noop bundle. New capabilities `AppKvRead` / `AppKvWrite` join the Capability enum. They map onto the existing seven-value `Scope` (script:read / script:write) — the scope vocabulary stays locked per the `docs/versioning.md` commitment. Script-as-gate semantics in `KvServiceImpl`: capability check runs when `cx.principal.is_some()`, skipped when None (public HTTP). Cross-app isolation is enforced independently by deriving every row's `app_id` from `cx.app_id` rather than a script-passed argument. In-memory `KvRepo` impl + unit tests cover the round-trips, the cross-app isolation property, empty-collection rejection, script-as-gate behaviour for both anonymous and authed contexts, and cursor-style pagination. Postgres impl exists; integration testing waits for a real DB harness (see HANDBACK). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
119 lines
3.3 KiB
Rust
119 lines
3.3 KiB
Rust
//! `DeadLetterService` — Rhai SDK contract for replaying and resolving
|
|
//! dead letters. Surface kept intentionally narrow for v1.1.1 (no
|
|
//! `list` — deferred to v1.2 per `docs/v1.1.x-design-notes.md` §4).
|
|
//!
|
|
//! Both methods are gated by `Capability::AppDeadLetterManage(AppId)`
|
|
//! evaluated inside the impl. Public-HTTP scripts running with
|
|
//! `cx.principal = None` will fail the check, which matches the
|
|
//! design's expectation (managing dead letters is an admin act).
|
|
|
|
use async_trait::async_trait;
|
|
use serde::{Deserialize, Serialize};
|
|
use thiserror::Error;
|
|
use uuid::Uuid;
|
|
|
|
use crate::SdkCallCx;
|
|
|
|
/// Opaque identifier for a `dead_letters` row.
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
|
#[serde(transparent)]
|
|
pub struct DeadLetterId(pub Uuid);
|
|
|
|
impl DeadLetterId {
|
|
#[must_use]
|
|
pub fn new() -> Self {
|
|
Self(Uuid::new_v4())
|
|
}
|
|
|
|
#[must_use]
|
|
pub fn into_inner(self) -> Uuid {
|
|
self.0
|
|
}
|
|
}
|
|
|
|
impl Default for DeadLetterId {
|
|
fn default() -> Self {
|
|
Self::new()
|
|
}
|
|
}
|
|
|
|
impl From<Uuid> for DeadLetterId {
|
|
fn from(u: Uuid) -> Self {
|
|
Self(u)
|
|
}
|
|
}
|
|
|
|
impl From<DeadLetterId> for Uuid {
|
|
fn from(id: DeadLetterId) -> Self {
|
|
id.0
|
|
}
|
|
}
|
|
|
|
impl std::fmt::Display for DeadLetterId {
|
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
self.0.fmt(f)
|
|
}
|
|
}
|
|
|
|
#[async_trait]
|
|
pub trait DeadLetterService: Send + Sync {
|
|
/// Re-enqueue the original event into the outbox. The dead-letter
|
|
/// row is marked `resolution = 'replayed'` regardless of whether
|
|
/// the retry ultimately succeeds.
|
|
async fn replay(&self, cx: &SdkCallCx, id: DeadLetterId) -> Result<(), DeadLetterError>;
|
|
|
|
/// Mark the row resolved with the given reason (typically
|
|
/// `"ignored"` from the dashboard or `"handled_by_script"` from
|
|
/// inside a `dead_letter` trigger handler).
|
|
async fn resolve(
|
|
&self,
|
|
cx: &SdkCallCx,
|
|
id: DeadLetterId,
|
|
reason: &str,
|
|
) -> Result<(), DeadLetterError>;
|
|
}
|
|
|
|
#[derive(Debug, Error)]
|
|
pub enum DeadLetterError {
|
|
#[error("dead-letter row not found")]
|
|
NotFound,
|
|
|
|
#[error("forbidden")]
|
|
Forbidden,
|
|
|
|
#[error("invalid resolution reason: {0}")]
|
|
InvalidResolution(String),
|
|
|
|
#[error("dead-letter backend error: {0}")]
|
|
Backend(String),
|
|
}
|
|
|
|
/// Stub used to bootstrap the `Services` bundle before the real
|
|
/// Postgres-backed implementation lands. Behaves like
|
|
/// `NoopEventEmitter` — every call returns `Backend("...")` so scripts
|
|
/// see a clear "not yet implemented" error rather than silently
|
|
/// no-op'ing. Replaced by `PostgresDeadLetterService` in the v1.1.1
|
|
/// dead-letter PR.
|
|
#[derive(Debug, Default, Clone, Copy)]
|
|
pub struct NoopDeadLetterService;
|
|
|
|
#[async_trait]
|
|
impl DeadLetterService for NoopDeadLetterService {
|
|
async fn replay(&self, _cx: &SdkCallCx, _id: DeadLetterId) -> Result<(), DeadLetterError> {
|
|
Err(DeadLetterError::Backend(
|
|
"dead_letters::replay is not yet wired in".into(),
|
|
))
|
|
}
|
|
|
|
async fn resolve(
|
|
&self,
|
|
_cx: &SdkCallCx,
|
|
_id: DeadLetterId,
|
|
_reason: &str,
|
|
) -> Result<(), DeadLetterError> {
|
|
Err(DeadLetterError::Backend(
|
|
"dead_letters::resolve is not yet wired in".into(),
|
|
))
|
|
}
|
|
}
|