feat(v1.1.1-kv): Rhai kv:: SDK module + ctx.event wiring

Wires the KV store into Rhai scripts via the handle pattern:

    let widgets = kv::collection("widgets");
    widgets.set("k", #{ n: 1 });
    let v = widgets.get("k");          // value or () if absent
    widgets.has("k") / widgets.delete("k")
    let page = widgets.list();          // cursor-style pagination

`KvHandle` is a custom Rhai type holding `Arc<dyn KvService>` + the
per-call `Arc<SdkCallCx>`. Methods route async service calls through
`tokio::Handle::current().block_on(...)` — works because
`LocalExecutorClient` runs the script under `spawn_blocking` so a
runtime is reachable. The bridge surfaces `app_id` exclusively
through `cx.app_id`; no public-facing argument can spoof an app.

`TriggerEvent` lands in `picloud-shared` as the wire shape the
dispatcher will emit (KV + DeadLetter variants — KV exercised now,
DL hooks up with the dispatcher in commit 5/8). `SdkCallCx` and
`ExecRequest` grow `is_dead_letter_handler: bool` and
`event: Option<TriggerEvent>`. `engine.rs::build_ctx_map` flattens
the event into `ctx.event` for triggered handlers; direct ingress
leaves the key absent so scripts can `if "event" in ctx`.

Tests:
- 7 `sdk_kv.rs` integration tests covering the full Rhai surface
  (round-trip, missing-key unit, has bool, delete was-present,
  empty-collection rejection, cursor pagination, cross-app
  isolation through the bridge).
- 3 new `engine.rs` tests pinning `ctx.event` shape per
  design notes §4 (KV insert with value, delete with unit value,
  direct invocations have no `event` key).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-06-01 21:38:41 +02:00
parent 434fb63cd2
commit 6b99f74c48
15 changed files with 767 additions and 13 deletions

View File

@@ -18,6 +18,7 @@ pub mod sandbox;
pub mod script;
pub mod sdk_cx;
pub mod services;
pub mod trigger_event;
pub mod validator;
pub mod version;
@@ -35,5 +36,6 @@ pub use sandbox::ScriptSandbox;
pub use script::Script;
pub use sdk_cx::SdkCallCx;
pub use services::Services;
pub use trigger_event::{DeadLetterEventDetail, KvEventOp, TriggerEvent};
pub use validator::{ScriptValidator, ValidationError};
pub use version::{API_VERSION, PRODUCT_VERSION, SDK_VERSION, WIRE_VERSION};

View File

@@ -12,7 +12,7 @@
//! the cx in is shared by both sides. Pure value type — no handles, no
//! DB pool references, no allocations beyond what's in `Principal`.
use crate::{AppId, ExecutionId, Principal, RequestId};
use crate::{AppId, ExecutionId, Principal, RequestId, TriggerEvent};
/// Per-invocation context for every stateful SDK service call.
///
@@ -51,4 +51,19 @@ pub struct SdkCallCx {
/// `execution_id` of the original ingress execution. Lets the audit
/// log group every fan-out execution under the originating event.
pub root_execution_id: ExecutionId,
/// `true` only when this invocation is a `dead_letter` trigger
/// handler. Set by the dispatcher when it picks an outbox row
/// whose trigger has `kind = 'dead_letter'`. The retry / dead-
/// letter machinery short-circuits when this is set: handlers
/// execute once, with no retry, and a failed run can NEVER be
/// dead-lettered itself (design notes §4 recursion-stop rule).
/// `false` for every other invocation, including the script
/// being used as a non-DL trigger handler.
pub is_dead_letter_handler: bool,
/// The event that fired this script, when it's a triggered
/// invocation. `None` for direct ingress (HTTP request, manual
/// run). Surfaced to scripts as `ctx.event`.
pub event: Option<TriggerEvent>,
}

View File

@@ -0,0 +1,105 @@
//! `TriggerEvent` — the description of the event that fired a script.
//!
//! Built by the dispatcher (in `manager-core`) from the outbox row and
//! attached to the `ExecRequest` that's handed to `executor-core`. The
//! Rhai bridge in `executor-core::engine::build_ctx_map` flattens this
//! into `ctx.event` for the script.
//!
//! Living in `picloud-shared` so the dispatcher and the executor agree
//! on the wire shape. Serializable so cluster mode (v1.3+) can ship
//! ExecRequests over HTTP without rewriting this type.
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use crate::{DeadLetterId, ScriptId, TriggerId};
/// Operations a KV trigger can fire on. Stored as a lowercase string
/// in `kv_trigger_details.ops` (Postgres `text[]`).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum KvEventOp {
Insert,
Update,
Delete,
}
impl KvEventOp {
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::Insert => "insert",
Self::Update => "update",
Self::Delete => "delete",
}
}
#[must_use]
pub fn from_wire(s: &str) -> Option<Self> {
match s {
"insert" => Some(Self::Insert),
"update" => Some(Self::Update),
"delete" => Some(Self::Delete),
_ => None,
}
}
}
/// Discriminated description of a triggering event. Lifted from the
/// outbox row's payload at dispatch time. Each variant carries the
/// fields the corresponding `ctx.event` shape exposes to the script.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "source", rename_all = "snake_case")]
pub enum TriggerEvent {
/// A KV insert / update / delete fired this handler.
Kv {
op: KvEventOp,
collection: String,
key: String,
/// Present on `insert` and `update`. Absent on `delete`.
#[serde(default, skip_serializing_if = "Option::is_none")]
value: Option<serde_json::Value>,
},
/// A dead-letter row fired this handler. The original event is
/// nested verbatim plus the dead-letter metadata the design notes
/// §4 require.
DeadLetter {
dead_letter_id: DeadLetterId,
original: Box<TriggerEvent>,
attempts: u32,
last_error: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
trigger_id: Option<TriggerId>,
#[serde(default, skip_serializing_if = "Option::is_none")]
script_id: Option<ScriptId>,
first_attempt_at: DateTime<Utc>,
last_attempt_at: DateTime<Utc>,
},
}
impl TriggerEvent {
/// The `source` discriminant the script sees on `ctx.event.source`.
#[must_use]
pub const fn source(&self) -> &'static str {
match self {
Self::Kv { .. } => "kv",
Self::DeadLetter { .. } => "dead_letter",
}
}
}
/// Convenience accessor on the dead-letter variant for places that
/// already know they're handling a DL event. Pulled out so the
/// dispatcher and the dashboard don't have to repeat the match.
#[derive(Debug, Clone)]
pub struct DeadLetterEventDetail {
pub dead_letter_id: DeadLetterId,
pub original: TriggerEvent,
pub attempts: u32,
pub last_error: String,
pub trigger_id: Option<TriggerId>,
pub script_id: Option<ScriptId>,
pub first_attempt_at: DateTime<Utc>,
pub last_attempt_at: DateTime<Utc>,
}