feat(v1.1.2-docs): Rhai docs:: SDK module + ctx.event.docs + bridge tests
The docs:: SDK bridge mirrors kv::'s collection-handle pattern: a
custom Rhai type DocsHandle captures (collection, service, cx) once
via docs::collection(name), and methods bind via engine.register_fn
so scripts use dot-notation (users.create(...), users.find(...),
etc.). app_id never appears in the script-visible call shape — the
service derives it from cx.app_id, preserving cross-app isolation.
Methods registered: create, get, find, find_one, update, delete,
list (zero-arg and one-arg map-shaped overloads). The find filter
goes through dynamic_to_json -> DocsService::find -> docs_filter
parser; unsupported operators surface to Rhai with the parser's
verbatim error message (including the v1.2 pointer).
The doc envelope per Decision D:
#{ id: "uuid", data: #{...user data...},
created_at: "ISO-8601", updated_at: "ISO-8601" }
engine.rs trigger_event_to_dynamic gains a Docs arm that builds
ctx.event.docs = #{ collection, id, data, prev_data } where data
and prev_data follow the variant's Option<Value> -> () | map shape.
15 bridge integration tests under tests/sdk_docs.rs exercise the
round-trip via tokio::task::spawn_blocking. Covers create/get/find/
find_one/update/delete/list semantics, $in + $gt operators, the
unsupported-operator throw with v1.2 pointer, invalid-UUID rejection
on get/update/delete, the doc envelope's shape (id is string, data
is map, timestamps are strings), and the load-bearing cross-app
isolation guarantee. sdk_kv.rs is updated to take the new docs
field on Services::new.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
255
crates/executor-core/src/sdk/docs.rs
Normal file
255
crates/executor-core/src/sdk/docs.rs
Normal file
@@ -0,0 +1,255 @@
|
||||
//! `docs::` Rhai bridge — collection-scoped handle pattern, v1.1.2.
|
||||
//!
|
||||
//! ```rhai
|
||||
//! let users = docs::collection("users");
|
||||
//! let id = users.create(#{ name: "Alice", tier: "gold" });
|
||||
//! let doc = users.get(id); // envelope or () if missing
|
||||
//! let golds = users.find(#{ tier: "gold" });
|
||||
//! let one = users.find_one(#{ tier: "gold" });
|
||||
//! users.update(id, #{ name: "Alice", tier: "platinum" });
|
||||
//! let removed = users.delete(id); // bool was-present
|
||||
//! let page = users.list(#{ cursor: (), limit: 100 });
|
||||
//! ```
|
||||
//!
|
||||
//! Mirrors `kv.rs`: `DocsHandle` captures the collection + service +
|
||||
//! per-call cx; methods bind via `engine.register_fn` so scripts call
|
||||
//! them with dot-notation. **The service derives `app_id` from
|
||||
//! `cx.app_id` — never from any closure argument.** Cross-app
|
||||
//! isolation boundary; same as KV.
|
||||
//!
|
||||
//! Doc shape returned by `get`/`find`/`find_one`/`list`: an envelope
|
||||
//! `#{ id, data: #{...}, created_at, updated_at }`. Decision D in the
|
||||
//! v1.1.2 plan — explicit metadata vs user-data separation.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use picloud_shared::{DocId, DocRow, DocsError, DocsService, SdkCallCx, Services};
|
||||
use rhai::{Array, Dynamic, Engine as RhaiEngine, EvalAltResult, Map, Module};
|
||||
use tokio::runtime::Handle as TokioHandle;
|
||||
use uuid::Uuid;
|
||||
|
||||
use super::bridge::{dynamic_to_json, json_to_dynamic};
|
||||
|
||||
/// Per-call handle captured by the Rhai SDK. Cheap to clone (two Arcs
|
||||
/// plus an owned string).
|
||||
#[derive(Clone)]
|
||||
pub struct DocsHandle {
|
||||
collection: String,
|
||||
service: Arc<dyn DocsService>,
|
||||
cx: Arc<SdkCallCx>,
|
||||
}
|
||||
|
||||
pub(super) fn register(engine: &mut RhaiEngine, services: &Services, cx: Arc<SdkCallCx>) {
|
||||
let docs_service = services.docs.clone();
|
||||
|
||||
let mut module = Module::new();
|
||||
{
|
||||
let docs_service = docs_service.clone();
|
||||
let cx = cx.clone();
|
||||
module.set_native_fn(
|
||||
"collection",
|
||||
move |name: &str| -> Result<DocsHandle, Box<EvalAltResult>> {
|
||||
if name.is_empty() {
|
||||
return Err("docs::collection name must not be empty".into());
|
||||
}
|
||||
Ok(DocsHandle {
|
||||
collection: name.to_string(),
|
||||
service: docs_service.clone(),
|
||||
cx: cx.clone(),
|
||||
})
|
||||
},
|
||||
);
|
||||
}
|
||||
engine.register_static_module("docs", module.into());
|
||||
|
||||
engine.register_type_with_name::<DocsHandle>("DocsHandle");
|
||||
|
||||
register_create(engine);
|
||||
register_get(engine);
|
||||
register_find(engine);
|
||||
register_find_one(engine);
|
||||
register_update(engine);
|
||||
register_delete(engine);
|
||||
register_list(engine);
|
||||
}
|
||||
|
||||
fn register_create(engine: &mut RhaiEngine) {
|
||||
engine.register_fn(
|
||||
"create",
|
||||
|handle: &mut DocsHandle, data: Map| -> Result<String, Box<EvalAltResult>> {
|
||||
let h = handle.clone();
|
||||
let json = dynamic_to_json(&Dynamic::from(data));
|
||||
let id = block_on(async move { h.service.create(&h.cx, &h.collection, json).await })?;
|
||||
Ok(id.to_string())
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
fn register_get(engine: &mut RhaiEngine) {
|
||||
engine.register_fn(
|
||||
"get",
|
||||
|handle: &mut DocsHandle, id: &str| -> Result<Dynamic, Box<EvalAltResult>> {
|
||||
let h = handle.clone();
|
||||
let parsed_id = parse_doc_id(id)?;
|
||||
let row =
|
||||
block_on(async move { h.service.get(&h.cx, &h.collection, parsed_id).await })?;
|
||||
Ok(row.map_or(Dynamic::UNIT, |d| Dynamic::from(doc_to_map(&d))))
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
fn register_find(engine: &mut RhaiEngine) {
|
||||
engine.register_fn(
|
||||
"find",
|
||||
|handle: &mut DocsHandle, filter: Map| -> Result<Array, Box<EvalAltResult>> {
|
||||
let h = handle.clone();
|
||||
let json = dynamic_to_json(&Dynamic::from(filter));
|
||||
let rows = block_on(async move { h.service.find(&h.cx, &h.collection, json).await })?;
|
||||
Ok(rows
|
||||
.iter()
|
||||
.map(|d| Dynamic::from(doc_to_map(d)))
|
||||
.collect::<Vec<Dynamic>>())
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
fn register_find_one(engine: &mut RhaiEngine) {
|
||||
engine.register_fn(
|
||||
"find_one",
|
||||
|handle: &mut DocsHandle, filter: Map| -> Result<Dynamic, Box<EvalAltResult>> {
|
||||
let h = handle.clone();
|
||||
let json = dynamic_to_json(&Dynamic::from(filter));
|
||||
let row =
|
||||
block_on(async move { h.service.find_one(&h.cx, &h.collection, json).await })?;
|
||||
Ok(row.map_or(Dynamic::UNIT, |d| Dynamic::from(doc_to_map(&d))))
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
fn register_update(engine: &mut RhaiEngine) {
|
||||
engine.register_fn(
|
||||
"update",
|
||||
|handle: &mut DocsHandle, id: &str, data: Map| -> Result<(), Box<EvalAltResult>> {
|
||||
let h = handle.clone();
|
||||
let parsed_id = parse_doc_id(id)?;
|
||||
let json = dynamic_to_json(&Dynamic::from(data));
|
||||
block_on(async move {
|
||||
h.service
|
||||
.update(&h.cx, &h.collection, parsed_id, json)
|
||||
.await
|
||||
})
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
fn register_delete(engine: &mut RhaiEngine) {
|
||||
engine.register_fn(
|
||||
"delete",
|
||||
|handle: &mut DocsHandle, id: &str| -> Result<bool, Box<EvalAltResult>> {
|
||||
let h = handle.clone();
|
||||
let parsed_id = parse_doc_id(id)?;
|
||||
block_on(async move { h.service.delete(&h.cx, &h.collection, parsed_id).await })
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
fn register_list(engine: &mut RhaiEngine) {
|
||||
// Zero-arg form: full page from the start.
|
||||
engine.register_fn(
|
||||
"list",
|
||||
|handle: &mut DocsHandle| -> Result<Map, Box<EvalAltResult>> { list_call(handle, None, 0) },
|
||||
);
|
||||
// One-arg form: pass `#{ cursor, limit }` map. Either field is
|
||||
// optional; missing/unit → defaults.
|
||||
engine.register_fn(
|
||||
"list",
|
||||
|handle: &mut DocsHandle, args: Map| -> Result<Map, Box<EvalAltResult>> {
|
||||
let cursor = match args.get("cursor") {
|
||||
Some(d) if !d.is_unit() => {
|
||||
Some(d.clone().into_string().map_err(|_| -> Box<EvalAltResult> {
|
||||
"docs::list: 'cursor' must be a string or ()".into()
|
||||
})?)
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
let limit = match args.get("limit") {
|
||||
Some(d) if !d.is_unit() => {
|
||||
let n = d.as_int().map_err(|_| -> Box<EvalAltResult> {
|
||||
"docs::list: 'limit' must be an integer".into()
|
||||
})?;
|
||||
u32::try_from(n.max(0)).unwrap_or(0)
|
||||
}
|
||||
_ => 0,
|
||||
};
|
||||
list_call(handle, cursor, limit)
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
fn list_call(
|
||||
handle: &DocsHandle,
|
||||
cursor: Option<String>,
|
||||
limit: u32,
|
||||
) -> Result<Map, Box<EvalAltResult>> {
|
||||
let h = handle.clone();
|
||||
let page = block_on(async move {
|
||||
h.service
|
||||
.list(&h.cx, &h.collection, cursor.as_deref(), limit)
|
||||
.await
|
||||
})?;
|
||||
let mut m = Map::new();
|
||||
let docs: Array = page
|
||||
.docs
|
||||
.iter()
|
||||
.map(|d| Dynamic::from(doc_to_map(d)))
|
||||
.collect();
|
||||
m.insert("docs".into(), docs.into());
|
||||
m.insert(
|
||||
"next_cursor".into(),
|
||||
page.next_cursor.map_or(Dynamic::UNIT, Dynamic::from),
|
||||
);
|
||||
Ok(m)
|
||||
}
|
||||
|
||||
/// Build the `{ id, data, created_at, updated_at }` envelope per
|
||||
/// Decision D. Scripts read user fields via `doc.data.<field>`; `id`
|
||||
/// and timestamps are direct children of the envelope.
|
||||
fn doc_to_map(doc: &DocRow) -> Map {
|
||||
let mut m = Map::new();
|
||||
m.insert("id".into(), doc.id.to_string().into());
|
||||
m.insert("data".into(), json_to_dynamic(doc.data.clone()));
|
||||
m.insert("created_at".into(), doc.created_at.to_rfc3339().into());
|
||||
m.insert("updated_at".into(), doc.updated_at.to_rfc3339().into());
|
||||
m
|
||||
}
|
||||
|
||||
fn parse_doc_id(id: &str) -> Result<DocId, Box<EvalAltResult>> {
|
||||
Uuid::parse_str(id).map_err(|e| -> Box<EvalAltResult> {
|
||||
EvalAltResult::ErrorRuntime(
|
||||
format!("docs: invalid id '{id}': {e}").into(),
|
||||
rhai::Position::NONE,
|
||||
)
|
||||
.into()
|
||||
})
|
||||
}
|
||||
|
||||
/// Mirrors `kv.rs::block_on` — Tokio runtime is reachable from inside
|
||||
/// the `spawn_blocking` wrapper that owns Rhai execution. Errors
|
||||
/// prefix with `"docs: "` so scripts see `docs: forbidden`,
|
||||
/// `docs: document not found`, `docs: unsupported operator: …`, etc.
|
||||
fn block_on<F, T>(fut: F) -> Result<T, Box<EvalAltResult>>
|
||||
where
|
||||
F: std::future::Future<Output = Result<T, DocsError>> + Send,
|
||||
T: Send,
|
||||
{
|
||||
let handle = TokioHandle::try_current().map_err(|e| -> Box<EvalAltResult> {
|
||||
EvalAltResult::ErrorRuntime(
|
||||
format!("docs: no tokio runtime available: {e}").into(),
|
||||
rhai::Position::NONE,
|
||||
)
|
||||
.into()
|
||||
})?;
|
||||
handle.block_on(fut).map_err(|err| -> Box<EvalAltResult> {
|
||||
EvalAltResult::ErrorRuntime(format!("docs: {err}").into(), rhai::Position::NONE).into()
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user