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:
@@ -3,7 +3,9 @@ use std::sync::{Arc, Mutex};
|
||||
use std::time::Instant;
|
||||
|
||||
use chrono::Utc;
|
||||
use picloud_shared::{ScriptValidator, SdkCallCx, Services, ValidationError, SDK_VERSION};
|
||||
use picloud_shared::{
|
||||
ScriptValidator, SdkCallCx, Services, TriggerEvent, ValidationError, SDK_VERSION,
|
||||
};
|
||||
use rhai::{Dynamic, Engine as RhaiEngine, EvalAltResult, Map, Module, Scope};
|
||||
use serde_json::Value as Json;
|
||||
|
||||
@@ -75,6 +77,8 @@ impl Engine {
|
||||
request_id: req.request_id,
|
||||
trigger_depth: req.trigger_depth,
|
||||
root_execution_id: req.root_execution_id,
|
||||
is_dead_letter_handler: req.is_dead_letter_handler,
|
||||
event: req.event.clone(),
|
||||
});
|
||||
sdk::register_all(&mut engine, &self.services, cx);
|
||||
|
||||
@@ -239,9 +243,82 @@ fn build_ctx_map(req: &ExecRequest) -> Map {
|
||||
request.insert("rest".into(), req.rest.clone().into());
|
||||
|
||||
ctx.insert("request".into(), request.into());
|
||||
|
||||
// Triggered invocations: surface the originating event as
|
||||
// `ctx.event`. Direct ingress (HTTP request, manual run) leaves
|
||||
// the key absent so scripts can test `if "event" in ctx`.
|
||||
if let Some(event) = req.event.as_ref() {
|
||||
ctx.insert("event".into(), trigger_event_to_dynamic(event));
|
||||
}
|
||||
|
||||
ctx
|
||||
}
|
||||
|
||||
/// Convert a `TriggerEvent` into the `ctx.event` Rhai shape defined in
|
||||
/// `docs/v1.1.x-design-notes.md` §4 (the dead-letter sub-shape) and
|
||||
/// §2/blueprint §9 (KV). Each variant becomes a Rhai map with a
|
||||
/// `source` discriminant plus per-source fields.
|
||||
fn trigger_event_to_dynamic(event: &TriggerEvent) -> Dynamic {
|
||||
let mut m = Map::new();
|
||||
m.insert("source".into(), event.source().into());
|
||||
match event {
|
||||
TriggerEvent::Kv {
|
||||
op,
|
||||
collection,
|
||||
key,
|
||||
value,
|
||||
} => {
|
||||
m.insert("op".into(), op.as_str().into());
|
||||
let mut kv_map = Map::new();
|
||||
kv_map.insert("collection".into(), collection.clone().into());
|
||||
kv_map.insert("key".into(), key.clone().into());
|
||||
kv_map.insert(
|
||||
"value".into(),
|
||||
value.clone().map_or(Dynamic::UNIT, json_to_dynamic),
|
||||
);
|
||||
m.insert("kv".into(), kv_map.into());
|
||||
}
|
||||
TriggerEvent::DeadLetter {
|
||||
dead_letter_id,
|
||||
original,
|
||||
attempts,
|
||||
last_error,
|
||||
trigger_id,
|
||||
script_id,
|
||||
first_attempt_at,
|
||||
last_attempt_at,
|
||||
} => {
|
||||
let mut dl = Map::new();
|
||||
dl.insert("id".into(), dead_letter_id.to_string().into());
|
||||
dl.insert("original".into(), trigger_event_to_dynamic(original));
|
||||
dl.insert("attempts".into(), i64::from(*attempts).into());
|
||||
dl.insert("last_error".into(), last_error.clone().into());
|
||||
dl.insert(
|
||||
"trigger_id".into(),
|
||||
trigger_id
|
||||
.map(|id| Dynamic::from(id.to_string()))
|
||||
.unwrap_or(Dynamic::UNIT),
|
||||
);
|
||||
dl.insert(
|
||||
"script_id".into(),
|
||||
script_id
|
||||
.map(|id| Dynamic::from(id.to_string()))
|
||||
.unwrap_or(Dynamic::UNIT),
|
||||
);
|
||||
dl.insert(
|
||||
"first_attempt_at".into(),
|
||||
first_attempt_at.to_rfc3339().into(),
|
||||
);
|
||||
dl.insert(
|
||||
"last_attempt_at".into(),
|
||||
last_attempt_at.to_rfc3339().into(),
|
||||
);
|
||||
m.insert("dead_letter".into(), dl.into());
|
||||
}
|
||||
}
|
||||
m.into()
|
||||
}
|
||||
|
||||
fn invocation_type_str(it: InvocationType) -> &'static str {
|
||||
match it {
|
||||
InvocationType::Http => "http",
|
||||
|
||||
Reference in New Issue
Block a user