feat(v1.1.1-dead-letters): service + Rhai SDK + admin endpoints

`PostgresDeadLetterService` lands as the real `DeadLetterService`
impl, replacing `NoopDeadLetterService` in the picloud binary's
`Services` bundle. Both methods are gated by
`Capability::AppDeadLetterManage(AppId)` — public-HTTP scripts with
`principal: None` fail the check, per design notes §4.

- `dead_letters::replay(id)` (Rhai SDK + admin endpoint): re-inserts
  the original event payload into the outbox with attempt_count=0,
  reply_to=None. The DL row is marked `resolution='replayed'`.
- `dead_letters::resolve(id, reason)` (Rhai SDK + admin endpoint):
  closes the row with `resolved_at = NOW()` and the given reason.
  CHECK constraint on the column enforces the 4-value vocabulary.
- `dead_letters::list(filter)` is intentionally NOT shipped —
  design notes §4 defers it to v1.2 to align with the eventual
  `docs::find()` query DSL.

Admin endpoints under `/api/v1/admin/apps/{id}/dead_letters/*`:
- `GET    /` (with `?unresolved=true`) → list view
- `GET    /count`                       → unresolved-count badge
- `GET    /{dl_id}`                     → row detail (full payload + error)
- `POST   /{dl_id}/replay`              → re-enqueue
- `POST   /{dl_id}/resolve` body `{reason}` → close out
All cross-app-aware: the row's `app_id` is compared against the path
param so a caller with rights on app A cannot manipulate app B's
dead letters by id alone.

The Rhai bridge for `dead_letters::*` follows the same sync↔async
pattern as the `kv::` bridge (`Handle::current().block_on(...)`
inside the spawn_blocking-wrapped Rhai engine).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-06-01 22:17:25 +02:00
parent 77b2cb58bb
commit 20f1b5e64d
6 changed files with 553 additions and 22 deletions

View File

@@ -0,0 +1,84 @@
//! `dead_letters::` Rhai bridge.
//!
//! ```rhai
//! dead_letters::replay("01234567-..."); // re-enqueue + mark replayed
//! dead_letters::resolve("01234567-...", "ignored"); // close out the row
//! ```
//!
//! Sync↔async via `Handle::current().block_on(...)` — same pattern as
//! the `kv::` bridge (works because `LocalExecutorClient` runs the
//! script under `spawn_blocking`).
//!
//! `dead_letters::list(filter)` is intentionally NOT shipped — design
//! notes §4 defers it to v1.2 to align with the `docs::find()` query
//! DSL.
use std::str::FromStr;
use std::sync::Arc;
use picloud_shared::{DeadLetterError, DeadLetterId, SdkCallCx, Services};
use rhai::{Engine as RhaiEngine, EvalAltResult, Module};
use tokio::runtime::Handle as TokioHandle;
use uuid::Uuid;
pub(super) fn register(engine: &mut RhaiEngine, services: &Services, cx: Arc<SdkCallCx>) {
let svc = services.dead_letters.clone();
let mut module = Module::new();
{
let svc = svc.clone();
let cx = cx.clone();
module.set_native_fn(
"replay",
move |id: &str| -> Result<(), Box<EvalAltResult>> {
let dl_id = parse_dl_id(id)?;
let svc = svc.clone();
let cx = cx.clone();
block_on(async move { svc.replay(&cx, dl_id).await })
},
);
}
{
let svc = svc.clone();
let cx = cx.clone();
module.set_native_fn(
"resolve",
move |id: &str, reason: &str| -> Result<(), Box<EvalAltResult>> {
let dl_id = parse_dl_id(id)?;
let reason = reason.to_string();
let svc = svc.clone();
let cx = cx.clone();
block_on(async move { svc.resolve(&cx, dl_id, &reason).await })
},
);
}
engine.register_static_module("dead_letters", module.into());
}
fn parse_dl_id(s: &str) -> Result<DeadLetterId, Box<EvalAltResult>> {
Uuid::from_str(s)
.map(DeadLetterId::from)
.map_err(|e| -> Box<EvalAltResult> {
EvalAltResult::ErrorRuntime(
format!("dead_letters: invalid id {s:?}: {e}").into(),
rhai::Position::NONE,
)
.into()
})
}
fn block_on<F>(fut: F) -> Result<(), Box<EvalAltResult>>
where
F: std::future::Future<Output = Result<(), DeadLetterError>> + Send,
{
let handle = TokioHandle::try_current().map_err(|e| -> Box<EvalAltResult> {
EvalAltResult::ErrorRuntime(
format!("dead_letters: no tokio runtime available: {e}").into(),
rhai::Position::NONE,
)
.into()
})?;
handle.block_on(fut).map_err(|err| -> Box<EvalAltResult> {
EvalAltResult::ErrorRuntime(format!("dead_letters: {err}").into(), rhai::Position::NONE)
.into()
})
}

View File

@@ -13,6 +13,7 @@
pub mod bridge;
pub mod cx;
pub mod dead_letters;
pub mod kv;
pub mod stdlib;
@@ -32,6 +33,5 @@ use rhai::Engine as RhaiEngine;
/// single `<service>::register(...)` line per service.
pub fn register_all(engine: &mut RhaiEngine, services: &Services, cx: Arc<SdkCallCx>) {
kv::register(engine, services, cx.clone());
// v1.1.1 commit 8: dead_letters::register(engine, services, cx.clone());
let _ = cx;
dead_letters::register(engine, services, cx);
}