//! `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, cx: Arc, } pub(super) fn register(engine: &mut RhaiEngine, services: &Services, cx: Arc) { 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> { 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"); 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> { 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> { 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> { 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::>()) }, ); } fn register_find_one(engine: &mut RhaiEngine) { engine.register_fn( "find_one", |handle: &mut DocsHandle, filter: Map| -> Result> { 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> { 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> { 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> { 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> { let cursor = match args.get("cursor") { Some(d) if !d.is_unit() => { Some(d.clone().into_string().map_err(|_| -> Box { "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 { "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, limit: u32, ) -> Result> { 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.`; `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> { Uuid::parse_str(id).map_err(|e| -> Box { 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(fut: F) -> Result> where F: std::future::Future> + Send, T: Send, { let handle = TokioHandle::try_current().map_err(|e| -> Box { EvalAltResult::ErrorRuntime( format!("docs: no tokio runtime available: {e}").into(), rhai::Position::NONE, ) .into() })?; handle.block_on(fut).map_err(|err| -> Box { EvalAltResult::ErrorRuntime(format!("docs: {err}").into(), rhai::Position::NONE).into() }) }