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>
This commit is contained in:
MechaCat02
2026-06-01 21:29:59 +02:00
parent 1efb350b54
commit 434fb63cd2
14 changed files with 1143 additions and 42 deletions

View File

@@ -0,0 +1,118 @@
//! `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(),
))
}
}

View File

@@ -53,3 +53,4 @@ id_type!(RequestId);
id_type!(AdminUserId);
id_type!(AppId);
id_type!(ApiKeyId);
id_type!(TriggerId);

140
crates/shared/src/kv.rs Normal file
View File

@@ -0,0 +1,140 @@
//! `KvService` — the v1.1.1 key-value store contract.
//!
//! Lives in `picloud-shared` (not `executor-core`) so the Rhai bridge,
//! the manager-core Postgres impl, and any future in-memory test impl
//! can all depend on the same trait without dragging
//! `executor-core` into `manager-core`'s dep graph.
//!
//! Implementations MUST derive every storage `app_id` from `cx.app_id`
//! — never from a script-passed argument. That is the cross-app
//! isolation boundary; see `docs/sdk-shape.md`.
use async_trait::async_trait;
use thiserror::Error;
use crate::SdkCallCx;
/// `KvService` is collection-scoped. Scripts get a handle via
/// `kv::collection(name)` and call `get`/`set`/`has`/`delete`/`list`
/// on it. The trait surface accepts the collection by name so the
/// Postgres impl can avoid an extra round-trip to materialize the
/// collection (collections are namespaces, not first-class rows).
#[async_trait]
pub trait KvService: Send + Sync {
async fn get(
&self,
cx: &SdkCallCx,
collection: &str,
key: &str,
) -> Result<Option<serde_json::Value>, KvError>;
async fn set(
&self,
cx: &SdkCallCx,
collection: &str,
key: &str,
value: serde_json::Value,
) -> Result<(), KvError>;
async fn delete(&self, cx: &SdkCallCx, collection: &str, key: &str) -> Result<bool, KvError>;
async fn has(&self, cx: &SdkCallCx, collection: &str, key: &str) -> Result<bool, KvError>;
/// Cursor-style pagination. `cursor` is opaque to the caller;
/// implementations encode the resume key inside. `None` cursor
/// starts from the beginning. Implementations cap `limit` at a
/// reasonable ceiling internally (script can't request an unbounded
/// page).
async fn list(
&self,
cx: &SdkCallCx,
collection: &str,
cursor: Option<&str>,
limit: u32,
) -> Result<KvListPage, KvError>;
}
/// One page of keys from `KvService::list`. `next_cursor` is `Some`
/// when more pages exist, `None` when exhausted. The cursor encoding
/// is implementation-defined (the Postgres impl base64-encodes the
/// last key).
#[derive(Debug, Clone)]
pub struct KvListPage {
pub keys: Vec<String>,
pub next_cursor: Option<String>,
}
/// Stub used by the test harness so executor-core integration tests
/// (which don't touch KV) can construct a `Services` bundle without
/// spinning up Postgres. Every call returns
/// `KvError::Backend("...")` so accidental KV use surfaces clearly.
#[derive(Debug, Default, Clone, Copy)]
pub struct NoopKvService;
#[async_trait]
impl KvService for NoopKvService {
async fn get(
&self,
_cx: &SdkCallCx,
_collection: &str,
_key: &str,
) -> Result<Option<serde_json::Value>, KvError> {
Err(KvError::Backend("kv is not wired in".into()))
}
async fn set(
&self,
_cx: &SdkCallCx,
_collection: &str,
_key: &str,
_value: serde_json::Value,
) -> Result<(), KvError> {
Err(KvError::Backend("kv is not wired in".into()))
}
async fn delete(
&self,
_cx: &SdkCallCx,
_collection: &str,
_key: &str,
) -> Result<bool, KvError> {
Err(KvError::Backend("kv is not wired in".into()))
}
async fn has(&self, _cx: &SdkCallCx, _collection: &str, _key: &str) -> Result<bool, KvError> {
Err(KvError::Backend("kv is not wired in".into()))
}
async fn list(
&self,
_cx: &SdkCallCx,
_collection: &str,
_cursor: Option<&str>,
_limit: u32,
) -> Result<KvListPage, KvError> {
Err(KvError::Backend("kv is not wired in".into()))
}
}
/// Failure modes surfaced to the Rhai bridge. The bridge converts each
/// to a Rhai runtime error string; the discriminants exist so internal
/// callers (admin endpoints, tests, GC) can react more precisely.
#[derive(Debug, Error)]
pub enum KvError {
/// Empty collection name; rejected at the SDK boundary per
/// `docs/sdk-shape.md`.
#[error("collection name must not be empty")]
InvalidCollection,
/// Caller principal lacked the required capability. Only raised
/// when `cx.principal.is_some()` — scripts running with
/// `principal: None` (public HTTP) operate under script-as-gate
/// semantics and skip the capability check.
#[error("forbidden")]
Forbidden,
/// Anything else — Postgres unavailable, serialization failure,
/// etc. The string is safe to surface to a script.
#[error("kv backend error: {0}")]
Backend(String),
}

View File

@@ -6,10 +6,12 @@
pub mod app;
pub mod auth;
pub mod dead_letters;
pub mod error;
pub mod events;
pub mod execution_log;
pub mod ids;
pub mod kv;
pub mod log_sink;
pub mod route;
pub mod sandbox;
@@ -21,10 +23,12 @@ pub mod version;
pub use app::{App, AppDomain, DomainShape};
pub use auth::{AppRole, InstanceRole, Principal, Scope, UserId};
pub use dead_letters::{DeadLetterError, DeadLetterId, DeadLetterService, NoopDeadLetterService};
pub use error::Error;
pub use events::{EmitError, NoopEventEmitter, ServiceEvent, ServiceEventEmitter};
pub use execution_log::{ExecutionLog, ExecutionStatus};
pub use ids::{AdminUserId, ApiKeyId, AppId, ExecutionId, RequestId, ScriptId};
pub use ids::{AdminUserId, ApiKeyId, AppId, ExecutionId, RequestId, ScriptId, TriggerId};
pub use kv::{KvError, KvListPage, KvService, NoopKvService};
pub use log_sink::{ExecutionLogSink, LogSinkError};
pub use route::{HostKind, PathKind, Route};
pub use sandbox::ScriptSandbox;

View File

@@ -1,38 +1,81 @@
//! `Services` — bundle of stateful SDK service handles plumbed from the
//! host binary into every Rhai execution.
//!
//! v1.1.0 ships this struct empty. Subsequent PRs in the v1.1.x series
//! add one field per service:
//! Constructed once at startup in the picloud binary; cloned (cheap —
//! every field is an `Arc`) into the per-call sdk bridge so script
//! invocations don't need to re-resolve dependencies. The bundle is
//! handed to `executor-core::sdk::register_all` alongside an
//! `SdkCallCx` to wire each `::` namespace.
//!
//! ```ignore
//! pub kv: Arc<dyn KvService>, // v1.1.1
//! pub docs: Arc<dyn DocsService>, // v1.1.2
//! pub http: Arc<dyn HttpService>, // v1.1.4
//! // …
//! ```
//!
//! The bundle is cheap to clone (`Arc` per service) and is constructed
//! once at startup in the picloud binary. The executor takes it by
//! reference per invocation, hands it (alongside an `SdkCallCx`) to
//! `executor-core::sdk::register_all`, which wires the corresponding
//! Rhai `::` namespace per service.
//! v1.1.0 shipped this empty; v1.1.1 adds the first two service fields
//! (`kv`, `dead_letters`) plus the `events` emitter that bound services
//! use to publish events into the triggers outbox.
//!
//! `#[non_exhaustive]` so adding fields is a non-breaking change for
//! consumers that only *pattern-match* a `&Services`; only crates that
//! *construct* a `Services` (in practice, just the picloud binary) need
//! to update their constructor when new services land.
//! *construct* a `Services` (the picloud binary and tests) update.
use std::sync::Arc;
use crate::{
DeadLetterService, KvService, NoopDeadLetterService, NoopEventEmitter, NoopKvService,
ServiceEventEmitter,
};
/// SDK service bundle. See module docs for the lifecycle and the v1.1.x
/// expansion plan.
#[non_exhaustive]
#[derive(Default)]
pub struct Services {}
pub struct Services {
/// KV store (v1.1.1). Backed by Postgres in the picloud binary;
/// in-memory in tests.
pub kv: Arc<dyn KvService>,
/// Dead-letter management (v1.1.1). Scripts get
/// `dead_letters::replay(id)` and `dead_letters::resolve(id, reason)`.
pub dead_letters: Arc<dyn DeadLetterService>,
/// Event emitter for the triggers outbox. Mutating service methods
/// (`KvService::set/delete`, future `docs::*`, `files::*`, etc.)
/// call `events.emit(cx, event)` after the write succeeds. The
/// outbox-backed impl in `manager-core::outbox_event_emitter`
/// replaces v1.1.0's `NoopEventEmitter`.
pub events: Arc<dyn ServiceEventEmitter>,
}
impl Services {
/// Construct an empty bundle. Replaced by a fielded `::new(...)`
/// once the first service (KV, v1.1.1) lands.
/// Construct a bundle from already-constructed `Arc<dyn …>` handles.
/// The picloud binary's `main` wires this up after the DB pool is
/// open; tests build it from in-memory fakes.
#[must_use]
pub fn new() -> Self {
Self {}
pub fn new(
kv: Arc<dyn KvService>,
dead_letters: Arc<dyn DeadLetterService>,
events: Arc<dyn ServiceEventEmitter>,
) -> Self {
Self {
kv,
dead_letters,
events,
}
}
/// All-noop bundle for tests that build an `Engine` but don't
/// exercise the stateful services. Returns the same shape as
/// `Services::new` so callers can't accidentally rely on a stub
/// silently doing the right thing — every call into a noop
/// service surfaces an explicit error.
#[must_use]
pub fn with_noop_services() -> Self {
Self::new(
Arc::new(NoopKvService),
Arc::new(NoopDeadLetterService),
Arc::new(NoopEventEmitter),
)
}
}
impl Default for Services {
fn default() -> Self {
Self::with_noop_services()
}
}