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:
MechaCat02
2026-06-02 19:55:43 +02:00
parent ef5930910b
commit a66d4af34f
5 changed files with 800 additions and 2 deletions

View File

@@ -278,6 +278,27 @@ fn trigger_event_to_dynamic(event: &TriggerEvent) -> Dynamic {
);
m.insert("kv".into(), kv_map.into());
}
TriggerEvent::Docs {
op,
collection,
id,
data,
prev_data,
} => {
m.insert("op".into(), op.as_str().into());
let mut docs_map = Map::new();
docs_map.insert("collection".into(), collection.clone().into());
docs_map.insert("id".into(), id.clone().into());
docs_map.insert(
"data".into(),
data.clone().map_or(Dynamic::UNIT, json_to_dynamic),
);
docs_map.insert(
"prev_data".into(),
prev_data.clone().map_or(Dynamic::UNIT, json_to_dynamic),
);
m.insert("docs".into(), docs_map.into());
}
TriggerEvent::DeadLetter {
dead_letter_id,
original,

View 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()
})
}

View File

@@ -14,6 +14,7 @@
pub mod bridge;
pub mod cx;
pub mod dead_letters;
pub mod docs;
pub mod kv;
pub mod stdlib;
@@ -33,5 +34,6 @@ use rhai::Engine as RhaiEngine;
/// single `<service>::register(...)` line per service.
pub fn register_all(engine: &mut RhaiEngine, services: &Services, cx: Arc<SdkCallCx>) {
kv::register(engine, services, cx.clone());
docs::register(engine, services, cx.clone());
dead_letters::register(engine, services, cx);
}