Files
PiCloud/crates/shared/src/dead_letters.rs
MechaCat02 434fb63cd2 feat(v1.1.1-kv): migrations + KvService trait + Postgres impl
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>
2026-06-01 21:29:59 +02:00

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(),
))
}
}