M4: a before-interceptor returning #{ allowed: true, data: ... } rewrites the
value actually written (threaded through the chain; each hook sees the prior
transform), size-capped at MAX_JSON_MATERIALIZE_BYTES. Delete never transforms.
M3: an 'after' phase interceptor runs once the write has committed, with the
write's result in its payload. After-hooks observe/deny but CANNOT roll back —
a deny surfaces as an operation error while the write persists (documented at
the call site).
Adds phase authoring end to end: a [[interceptors]] entry gains phase =
before|after (default before), threaded through the plan wire, BundleInterceptor,
validate (phase in {before,after}), the reconcile diff/insert/prune key
(service/op/phase), and the repo (insert/delete/list_for_owner/list_on_app_chain,
+ the marker's phase). run_before now returns the transformed value; run_after is
new; both share one fail-closed per-entry runner. Pinned by two journeys: a
before-hook transforms the stored value, and an after-delete hook sees
result==true, denies, yet the key stays deleted.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
483 lines
16 KiB
Rust
483 lines
16 KiB
Rust
//! `kv::` Rhai bridge — collection-scoped handle pattern.
|
|
//!
|
|
//! ```rhai
|
|
//! let widgets = kv::collection("widgets");
|
|
//! widgets.set("k", #{ n: 1 });
|
|
//! let v = widgets.get("k"); // value or () if absent
|
|
//! if widgets.has("k") { ... }
|
|
//! widgets.set_if("k", (), #{ n: 0 }); // insert only if ABSENT -> bool
|
|
//! widgets.set_if("k", old, new); // swap only if current == old -> bool
|
|
//! widgets.delete("k"); // bool (was-present)
|
|
//! let page = widgets.list(); // returns #{ keys: [...], next_cursor: () }
|
|
//! ```
|
|
//!
|
|
//! The `KvHandle` custom Rhai type captures the collection name once
|
|
//! and routes each call through the injected `Arc<dyn KvService>` with
|
|
//! the per-call `Arc<SdkCallCx>`. **The service derives `app_id` from
|
|
//! `cx.app_id` — `app_id` never appears in any function signature
|
|
//! script-side, preserving cross-app isolation.**
|
|
//!
|
|
//! Sync↔async bridge: Rhai is synchronous; the underlying service is
|
|
//! async. Closures wrap each call in `Handle::current().block_on(...)`
|
|
//! — safe because `LocalExecutorClient` runs the script under
|
|
//! `spawn_blocking`, so a runtime handle is reachable and blocking on
|
|
//! it doesn't park an async worker.
|
|
//!
|
|
//! Error convention (per `docs/sdk-shape.md`):
|
|
//! - throw on failure (Rhai runtime error string)
|
|
//! - `()` for absent values (`get` on a missing key)
|
|
//! - `bool` for predicates (`has`; also `delete` returns was-present)
|
|
|
|
use std::sync::Arc;
|
|
|
|
use picloud_shared::{GroupKvService, KvService, SdkCallCx, Services};
|
|
use rhai::{Array, Dynamic, Engine as RhaiEngine, EvalAltResult, Map, Module};
|
|
|
|
use super::bridge::{
|
|
block_on, dynamic_to_json_capped, json_to_dynamic, JsonSizeError, MAX_JSON_MATERIALIZE_BYTES,
|
|
};
|
|
use super::interceptor::InterceptorCtx;
|
|
|
|
/// Per-call handle captured by the Rhai SDK. Cheap to clone (a few Arcs plus an
|
|
/// owned string). Carries the §9.4 interceptor ctx so `set`/`delete` can run a
|
|
/// before-op allow/deny hook (the resolver + the `invoke()` re-entry engine).
|
|
#[derive(Clone)]
|
|
pub struct KvHandle {
|
|
collection: String,
|
|
service: Arc<dyn KvService>,
|
|
cx: Arc<SdkCallCx>,
|
|
ictx: InterceptorCtx,
|
|
}
|
|
|
|
/// §11.6 shared-collection handle, returned by `kv::shared_collection(name)`. A distinct
|
|
/// Rhai type from `KvHandle` so the method set can diverge (e.g. shared
|
|
/// collections never grow triggers) and a script's choice of private-vs-shared
|
|
/// scope is explicit. Routes through the `GroupKvService`, which resolves the
|
|
/// owning group from `cx.app_id` (the script never names a group). Carries the
|
|
/// §9.4 interceptor ctx too so a `(kv, set/delete)` guard covers shared-
|
|
/// collection writes — otherwise the shared handle would be a silent bypass.
|
|
#[derive(Clone)]
|
|
pub struct GroupKvHandle {
|
|
collection: String,
|
|
service: Arc<dyn GroupKvService>,
|
|
cx: Arc<SdkCallCx>,
|
|
ictx: InterceptorCtx,
|
|
}
|
|
|
|
pub(super) fn register(
|
|
engine: &mut RhaiEngine,
|
|
services: &Services,
|
|
cx: Arc<SdkCallCx>,
|
|
ictx: InterceptorCtx,
|
|
) {
|
|
let kv_service = services.kv.clone();
|
|
let group_kv_service = services.group_kv.clone();
|
|
|
|
// `kv::collection(name)` / `kv::shared_collection(name)` — both constructors live in
|
|
// the `kv` static module so the script-visible calls are `kv::collection`
|
|
// and `kv::shared_collection` (`shared` alone is a Rhai reserved word).
|
|
let mut module = Module::new();
|
|
{
|
|
let kv_service = kv_service.clone();
|
|
let cx = cx.clone();
|
|
let ictx = ictx.clone();
|
|
module.set_native_fn(
|
|
"collection",
|
|
move |name: &str| -> Result<KvHandle, Box<EvalAltResult>> {
|
|
if name.is_empty() {
|
|
return Err("kv::collection name must not be empty".into());
|
|
}
|
|
Ok(KvHandle {
|
|
collection: name.to_string(),
|
|
service: kv_service.clone(),
|
|
cx: cx.clone(),
|
|
ictx: ictx.clone(),
|
|
})
|
|
},
|
|
);
|
|
}
|
|
{
|
|
let group_kv_service = group_kv_service.clone();
|
|
let cx = cx.clone();
|
|
let ictx = ictx.clone();
|
|
module.set_native_fn(
|
|
"shared_collection",
|
|
move |name: &str| -> Result<GroupKvHandle, Box<EvalAltResult>> {
|
|
if name.is_empty() {
|
|
return Err("kv::shared_collection name must not be empty".into());
|
|
}
|
|
Ok(GroupKvHandle {
|
|
collection: name.to_string(),
|
|
service: group_kv_service.clone(),
|
|
cx: cx.clone(),
|
|
ictx: ictx.clone(),
|
|
})
|
|
},
|
|
);
|
|
}
|
|
engine.register_static_module("kv", module.into());
|
|
|
|
// Methods on KvHandle — `register_fn` with `&mut KvHandle` first
|
|
// argument lets Rhai dispatch them as `handle.get(k)` /
|
|
// `handle.set(k, v)` / etc. through the dot-notation.
|
|
engine.register_type_with_name::<KvHandle>("KvHandle");
|
|
|
|
register_get(engine);
|
|
register_set(engine);
|
|
register_set_if(engine);
|
|
register_has(engine);
|
|
register_delete(engine);
|
|
register_list(engine);
|
|
|
|
// Same method names on GroupKvHandle — Rhai dispatches by receiver type.
|
|
engine.register_type_with_name::<GroupKvHandle>("GroupKvHandle");
|
|
|
|
register_group_get(engine);
|
|
register_group_set(engine);
|
|
register_group_set_if(engine);
|
|
register_group_has(engine);
|
|
register_group_delete(engine);
|
|
register_group_list(engine);
|
|
}
|
|
|
|
/// Map a Rhai `expected` argument to the CAS precondition: Rhai unit `()` means
|
|
/// "expected ABSENT" (insert-if-absent); any other value is the expected current
|
|
/// value.
|
|
fn expected_from_dynamic(expected: &Dynamic) -> Result<Option<serde_json::Value>, JsonSizeError> {
|
|
if expected.is_unit() {
|
|
Ok(None)
|
|
} else {
|
|
Ok(Some(dynamic_to_json_capped(
|
|
expected,
|
|
MAX_JSON_MATERIALIZE_BYTES,
|
|
)?))
|
|
}
|
|
}
|
|
|
|
fn register_get(engine: &mut RhaiEngine) {
|
|
engine.register_fn(
|
|
"get",
|
|
|handle: &mut KvHandle, key: &str| -> Result<Dynamic, Box<EvalAltResult>> {
|
|
let h = handle.clone();
|
|
block_on("kv", async move {
|
|
h.service.get(&h.cx, &h.collection, key).await
|
|
})
|
|
.map(|opt| opt.map_or(Dynamic::UNIT, json_to_dynamic))
|
|
},
|
|
);
|
|
}
|
|
|
|
fn register_set(engine: &mut RhaiEngine) {
|
|
engine.register_fn(
|
|
"set",
|
|
|handle: &mut KvHandle, key: &str, value: Dynamic| -> Result<(), Box<EvalAltResult>> {
|
|
let json = dynamic_to_json_capped(&value, MAX_JSON_MATERIALIZE_BYTES)?;
|
|
// §9.4 before-op interceptor (allow/deny + M4 data-transform). A
|
|
// denial errors here; a returned `data` rewrites the written value.
|
|
let write_val = super::interceptor::run_before(
|
|
&handle.ictx,
|
|
&handle.cx,
|
|
"kv",
|
|
"set",
|
|
&handle.collection,
|
|
key,
|
|
Some(&json),
|
|
)?
|
|
.unwrap_or(json);
|
|
let written = write_val.clone();
|
|
let h = handle.clone();
|
|
block_on("kv", async move {
|
|
h.service.set(&h.cx, &h.collection, key, write_val).await
|
|
})?;
|
|
// §9.4 M3 after-hook (observe/audit; the write already committed).
|
|
super::interceptor::run_after(
|
|
&handle.ictx,
|
|
&handle.cx,
|
|
"kv",
|
|
"set",
|
|
&handle.collection,
|
|
key,
|
|
Some(&written),
|
|
serde_json::Value::Null,
|
|
)
|
|
},
|
|
);
|
|
}
|
|
|
|
fn register_set_if(engine: &mut RhaiEngine) {
|
|
// `handle.set_if(key, expected, new)` — CAS. `expected = ()` means "only if
|
|
// absent". Returns `true` if the swap happened, `false` if the precondition
|
|
// failed.
|
|
engine.register_fn(
|
|
"set_if",
|
|
|handle: &mut KvHandle,
|
|
key: &str,
|
|
expected: Dynamic,
|
|
new: Dynamic|
|
|
-> Result<bool, Box<EvalAltResult>> {
|
|
let h = handle.clone();
|
|
let exp = expected_from_dynamic(&expected)?;
|
|
let new = dynamic_to_json_capped(&new, MAX_JSON_MATERIALIZE_BYTES)?;
|
|
block_on("kv", async move {
|
|
h.service.set_if(&h.cx, &h.collection, key, exp, new).await
|
|
})
|
|
},
|
|
);
|
|
}
|
|
|
|
fn register_has(engine: &mut RhaiEngine) {
|
|
engine.register_fn(
|
|
"has",
|
|
|handle: &mut KvHandle, key: &str| -> Result<bool, Box<EvalAltResult>> {
|
|
let h = handle.clone();
|
|
block_on("kv", async move {
|
|
h.service.has(&h.cx, &h.collection, key).await
|
|
})
|
|
},
|
|
);
|
|
}
|
|
|
|
fn register_delete(engine: &mut RhaiEngine) {
|
|
engine.register_fn(
|
|
"delete",
|
|
|handle: &mut KvHandle, key: &str| -> Result<bool, Box<EvalAltResult>> {
|
|
// Delete carries no value, so the before-hook never transforms.
|
|
super::interceptor::run_before(
|
|
&handle.ictx,
|
|
&handle.cx,
|
|
"kv",
|
|
"delete",
|
|
&handle.collection,
|
|
key,
|
|
None,
|
|
)?;
|
|
let h = handle.clone();
|
|
let was_present = block_on("kv", async move {
|
|
h.service.delete(&h.cx, &h.collection, key).await
|
|
})?;
|
|
super::interceptor::run_after(
|
|
&handle.ictx,
|
|
&handle.cx,
|
|
"kv",
|
|
"delete",
|
|
&handle.collection,
|
|
key,
|
|
None,
|
|
serde_json::Value::Bool(was_present),
|
|
)?;
|
|
Ok(was_present)
|
|
},
|
|
);
|
|
}
|
|
|
|
/// §9.4: run the before-op interceptor for a shared-collection write, so a
|
|
/// `(kv, set/delete)` guard covers the shared handle too (not just private KV).
|
|
fn group_run_before(
|
|
handle: &GroupKvHandle,
|
|
op: &'static str,
|
|
key: &str,
|
|
value: Option<&serde_json::Value>,
|
|
) -> Result<Option<serde_json::Value>, Box<EvalAltResult>> {
|
|
super::interceptor::run_before(
|
|
&handle.ictx,
|
|
&handle.cx,
|
|
"kv",
|
|
op,
|
|
&handle.collection,
|
|
key,
|
|
value,
|
|
)
|
|
}
|
|
|
|
fn group_run_after(
|
|
handle: &GroupKvHandle,
|
|
op: &'static str,
|
|
key: &str,
|
|
value: Option<&serde_json::Value>,
|
|
result: serde_json::Value,
|
|
) -> Result<(), Box<EvalAltResult>> {
|
|
super::interceptor::run_after(
|
|
&handle.ictx,
|
|
&handle.cx,
|
|
"kv",
|
|
op,
|
|
&handle.collection,
|
|
key,
|
|
value,
|
|
result,
|
|
)
|
|
}
|
|
|
|
fn register_list(engine: &mut RhaiEngine) {
|
|
// Zero-arg form — full page, no cursor.
|
|
engine.register_fn(
|
|
"list",
|
|
|handle: &mut KvHandle| -> Result<Map, Box<EvalAltResult>> { list_call(handle, None, 0) },
|
|
);
|
|
|
|
// One-arg form — cursor only.
|
|
engine.register_fn(
|
|
"list",
|
|
|handle: &mut KvHandle, cursor: &str| -> Result<Map, Box<EvalAltResult>> {
|
|
list_call(handle, Some(cursor.to_string()), 0)
|
|
},
|
|
);
|
|
|
|
// Two-arg form — cursor + limit.
|
|
engine.register_fn(
|
|
"list",
|
|
|handle: &mut KvHandle, cursor: &str, limit: i64| -> Result<Map, Box<EvalAltResult>> {
|
|
let limit = u32::try_from(limit.max(0)).unwrap_or(0);
|
|
list_call(handle, Some(cursor.to_string()), limit)
|
|
},
|
|
);
|
|
}
|
|
|
|
fn list_call(
|
|
handle: &KvHandle,
|
|
cursor: Option<String>,
|
|
limit: u32,
|
|
) -> Result<Map, Box<EvalAltResult>> {
|
|
let h = handle.clone();
|
|
let page = block_on("kv", async move {
|
|
h.service
|
|
.list(&h.cx, &h.collection, cursor.as_deref(), limit)
|
|
.await
|
|
})?;
|
|
let mut m = Map::new();
|
|
let keys: Array = page.keys.into_iter().map(Dynamic::from).collect();
|
|
m.insert("keys".into(), keys.into());
|
|
m.insert(
|
|
"next_cursor".into(),
|
|
page.next_cursor.map_or(Dynamic::UNIT, Dynamic::from),
|
|
);
|
|
Ok(m)
|
|
}
|
|
|
|
// --- GroupKvHandle methods (§11.6 shared collections) ----------------------
|
|
|
|
fn register_group_get(engine: &mut RhaiEngine) {
|
|
engine.register_fn(
|
|
"get",
|
|
|handle: &mut GroupKvHandle, key: &str| -> Result<Dynamic, Box<EvalAltResult>> {
|
|
let h = handle.clone();
|
|
block_on("kv", async move {
|
|
h.service.get(&h.cx, &h.collection, key).await
|
|
})
|
|
.map(|opt| opt.map_or(Dynamic::UNIT, json_to_dynamic))
|
|
},
|
|
);
|
|
}
|
|
|
|
fn register_group_set(engine: &mut RhaiEngine) {
|
|
engine.register_fn(
|
|
"set",
|
|
|handle: &mut GroupKvHandle, key: &str, value: Dynamic| -> Result<(), Box<EvalAltResult>> {
|
|
let json = dynamic_to_json_capped(&value, MAX_JSON_MATERIALIZE_BYTES)?;
|
|
// §9.4 before-op interceptor also guards shared-collection writes
|
|
// (allow/deny + M4 data-transform).
|
|
let write_val = group_run_before(handle, "set", key, Some(&json))?.unwrap_or(json);
|
|
let written = write_val.clone();
|
|
let h = handle.clone();
|
|
block_on("kv", async move {
|
|
h.service.set(&h.cx, &h.collection, key, write_val).await
|
|
})?;
|
|
group_run_after(handle, "set", key, Some(&written), serde_json::Value::Null)
|
|
},
|
|
);
|
|
}
|
|
|
|
fn register_group_set_if(engine: &mut RhaiEngine) {
|
|
engine.register_fn(
|
|
"set_if",
|
|
|handle: &mut GroupKvHandle,
|
|
key: &str,
|
|
expected: Dynamic,
|
|
new: Dynamic|
|
|
-> Result<bool, Box<EvalAltResult>> {
|
|
let h = handle.clone();
|
|
let exp = expected_from_dynamic(&expected)?;
|
|
let new = dynamic_to_json_capped(&new, MAX_JSON_MATERIALIZE_BYTES)?;
|
|
block_on("kv", async move {
|
|
h.service.set_if(&h.cx, &h.collection, key, exp, new).await
|
|
})
|
|
},
|
|
);
|
|
}
|
|
|
|
fn register_group_has(engine: &mut RhaiEngine) {
|
|
engine.register_fn(
|
|
"has",
|
|
|handle: &mut GroupKvHandle, key: &str| -> Result<bool, Box<EvalAltResult>> {
|
|
let h = handle.clone();
|
|
block_on("kv", async move {
|
|
h.service.has(&h.cx, &h.collection, key).await
|
|
})
|
|
},
|
|
);
|
|
}
|
|
|
|
fn register_group_delete(engine: &mut RhaiEngine) {
|
|
engine.register_fn(
|
|
"delete",
|
|
|handle: &mut GroupKvHandle, key: &str| -> Result<bool, Box<EvalAltResult>> {
|
|
group_run_before(handle, "delete", key, None)?;
|
|
let h = handle.clone();
|
|
let was_present = block_on("kv", async move {
|
|
h.service.delete(&h.cx, &h.collection, key).await
|
|
})?;
|
|
group_run_after(
|
|
handle,
|
|
"delete",
|
|
key,
|
|
None,
|
|
serde_json::Value::Bool(was_present),
|
|
)?;
|
|
Ok(was_present)
|
|
},
|
|
);
|
|
}
|
|
|
|
fn register_group_list(engine: &mut RhaiEngine) {
|
|
engine.register_fn(
|
|
"list",
|
|
|handle: &mut GroupKvHandle| -> Result<Map, Box<EvalAltResult>> {
|
|
group_list_call(handle, None, 0)
|
|
},
|
|
);
|
|
engine.register_fn(
|
|
"list",
|
|
|handle: &mut GroupKvHandle, cursor: &str| -> Result<Map, Box<EvalAltResult>> {
|
|
group_list_call(handle, Some(cursor.to_string()), 0)
|
|
},
|
|
);
|
|
engine.register_fn(
|
|
"list",
|
|
|handle: &mut GroupKvHandle, cursor: &str, limit: i64| -> Result<Map, Box<EvalAltResult>> {
|
|
let limit = u32::try_from(limit.max(0)).unwrap_or(0);
|
|
group_list_call(handle, Some(cursor.to_string()), limit)
|
|
},
|
|
);
|
|
}
|
|
|
|
fn group_list_call(
|
|
handle: &GroupKvHandle,
|
|
cursor: Option<String>,
|
|
limit: u32,
|
|
) -> Result<Map, Box<EvalAltResult>> {
|
|
let h = handle.clone();
|
|
let page = block_on("kv", async move {
|
|
h.service
|
|
.list(&h.cx, &h.collection, cursor.as_deref(), limit)
|
|
.await
|
|
})?;
|
|
let mut m = Map::new();
|
|
let keys: Array = page.keys.into_iter().map(Dynamic::from).collect();
|
|
m.insert("keys".into(), keys.into());
|
|
m.insert(
|
|
"next_cursor".into(),
|
|
page.next_cursor.map_or(Dynamic::UNIT, Dynamic::from),
|
|
);
|
|
Ok(m)
|
|
}
|