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:
118
crates/shared/src/dead_letters.rs
Normal file
118
crates/shared/src/dead_letters.rs
Normal 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(),
|
||||
))
|
||||
}
|
||||
}
|
||||
@@ -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
140
crates/shared/src/kv.rs
Normal 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),
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user