//! `files::` Rhai bridge — collection-scoped handle pattern (v1.1.5). //! //! ```rhai //! let avatars = files::collection("avatars"); //! let id = avatars.create(#{ name: "a.jpg", content_type: "image/jpeg", data: blob }); //! let meta = avatars.head(id); // metadata map or () //! let bytes = avatars.get(id); // Blob or () //! avatars.update(id, #{ data: new_bytes }); //! let gone = avatars.delete(id); // bool (was-present) //! let page = avatars.list(); // #{ files: [...], next_cursor: () } //! ``` //! //! The `FilesHandle` custom Rhai type captures the collection name once //! and routes each call through the injected `Arc` //! with the per-call `Arc`. **The service derives `app_id` //! from `cx.app_id` — it never appears in any signature script-side, //! preserving cross-app isolation.** //! //! Error convention (per `docs/sdk-shape.md`): `create`/`update`/ //! `delete` throw on failure; `get`/`head` return `()` for a missing //! file; `delete` returns `bool` (was-present). The blob bytes are a //! Rhai `Blob` (byte array) in both directions. use std::sync::Arc; use picloud_shared::{ FileMeta, FileUpdate, FilesError, FilesService, NewFile, SdkCallCx, Services, }; use rhai::{Array, Dynamic, Engine as RhaiEngine, EvalAltResult, Map, Module}; use tokio::runtime::Handle as TokioHandle; /// Per-call handle captured by the Rhai SDK. Cheap to clone (two Arcs /// plus an owned string). #[derive(Clone)] pub struct FilesHandle { collection: String, service: Arc, cx: Arc, } pub(super) fn register(engine: &mut RhaiEngine, services: &Services, cx: Arc) { let files_service = services.files.clone(); let mut module = Module::new(); { let files_service = files_service.clone(); let cx = cx.clone(); module.set_native_fn( "collection", move |name: &str| -> Result> { if name.is_empty() { return Err("files::collection name must not be empty".into()); } Ok(FilesHandle { collection: name.to_string(), service: files_service.clone(), cx: cx.clone(), }) }, ); } engine.register_static_module("files", module.into()); engine.register_type_with_name::("FilesHandle"); register_create(engine); register_head(engine); register_get(engine); register_update(engine); register_delete(engine); register_list(engine); } fn register_create(engine: &mut RhaiEngine) { engine.register_fn( "create", |handle: &mut FilesHandle, meta: Map| -> Result> { let name = require_string(&meta, "name")?; let content_type = require_string(&meta, "content_type")?; let data = require_blob(&meta, "data")?; let h = handle.clone(); let new = NewFile { name, content_type, data, }; let id = block_on(async move { h.service.create(&h.cx, &h.collection, new).await })?; Ok(id.to_string()) }, ); } fn register_head(engine: &mut RhaiEngine) { engine.register_fn( "head", |handle: &mut FilesHandle, id: &str| -> Result> { let h = handle.clone(); let id = id.to_string(); let meta = block_on(async move { h.service.head(&h.cx, &h.collection, &id).await })?; Ok(meta.map_or(Dynamic::UNIT, |m| file_meta_to_map(&m).into())) }, ); } fn register_get(engine: &mut RhaiEngine) { engine.register_fn( "get", |handle: &mut FilesHandle, id: &str| -> Result> { let h = handle.clone(); let id = id.to_string(); let bytes = block_on(async move { h.service.get(&h.cx, &h.collection, &id).await })?; Ok(bytes.map_or(Dynamic::UNIT, Dynamic::from_blob)) }, ); } fn register_update(engine: &mut RhaiEngine) { engine.register_fn( "update", |handle: &mut FilesHandle, id: &str, meta: Map| -> Result<(), Box> { let data = require_blob(&meta, "data")?; let name = optional_string(&meta, "name")?; let content_type = optional_string(&meta, "content_type")?; let h = handle.clone(); let id = id.to_string(); let upd = FileUpdate { data, name, content_type, }; block_on(async move { h.service.update(&h.cx, &h.collection, &id, upd).await }) }, ); } fn register_delete(engine: &mut RhaiEngine) { engine.register_fn( "delete", |handle: &mut FilesHandle, id: &str| -> Result> { let h = handle.clone(); let id = id.to_string(); block_on(async move { h.service.delete(&h.cx, &h.collection, &id).await }) }, ); } fn register_list(engine: &mut RhaiEngine) { engine.register_fn( "list", |handle: &mut FilesHandle| -> Result> { list_call(handle, None, 0) }, ); engine.register_fn( "list", |handle: &mut FilesHandle, cursor: &str| -> Result> { list_call(handle, Some(cursor.to_string()), 0) }, ); engine.register_fn( "list", |handle: &mut FilesHandle, cursor: &str, limit: i64| -> Result> { let limit = u32::try_from(limit.max(0)).unwrap_or(0); list_call(handle, Some(cursor.to_string()), limit) }, ); // `list(#{ cursor, limit })` — the map form documented in the brief. engine.register_fn( "list", |handle: &mut FilesHandle, opts: Map| -> Result> { let cursor = match opts.get("cursor") { Some(v) if !v.is_unit() => { Some(v.clone().into_string().map_err(|_| -> Box { "files: list cursor must be a string".into() })?) } _ => None, }; let limit = match opts.get("limit") { Some(v) if !v.is_unit() => { u32::try_from(v.as_int().unwrap_or(0).max(0)).unwrap_or(0) } _ => 0, }; list_call(handle, cursor, limit) }, ); } fn list_call( handle: &FilesHandle, 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 files: Array = page .files .iter() .map(|meta| Dynamic::from(file_meta_to_map(meta))) .collect(); m.insert("files".into(), files.into()); m.insert( "next_cursor".into(), page.next_cursor.map_or(Dynamic::UNIT, Dynamic::from), ); Ok(m) } /// Render a `FileMeta` into the Rhai map shape scripts see from /// `head` / `list`. fn file_meta_to_map(meta: &FileMeta) -> Map { let mut m = Map::new(); m.insert("id".into(), meta.id.to_string().into()); m.insert("collection".into(), meta.collection.clone().into()); m.insert("name".into(), meta.name.clone().into()); m.insert("content_type".into(), meta.content_type.clone().into()); m.insert( "size".into(), i64::try_from(meta.size).unwrap_or(i64::MAX).into(), ); m.insert("checksum".into(), meta.checksum.clone().into()); m.insert("created_at".into(), meta.created_at.to_rfc3339().into()); m.insert("updated_at".into(), meta.updated_at.to_rfc3339().into()); m } /// Pull a required string field out of a Rhai map; throw naming the /// field if it's absent or not a string. fn require_string(meta: &Map, field: &'static str) -> Result> { match meta.get(field) { Some(v) if v.is_string() => Ok(v.clone().into_string().unwrap_or_default()), Some(_) => Err(format!("files::create: field '{field}' must be a string").into()), None => Err(format!("files::create: missing required field '{field}'").into()), } } /// Pull an optional string field; `None` when the key is absent or unit. fn optional_string(meta: &Map, field: &'static str) -> Result, Box> { match meta.get(field) { None => Ok(None), Some(v) if v.is_unit() => Ok(None), Some(v) if v.is_string() => Ok(Some(v.clone().into_string().unwrap_or_default())), Some(_) => Err(format!("files::update: field '{field}' must be a string").into()), } } /// Pull a required blob (`data`) out of a Rhai map; throw naming the /// field if it's absent or not a blob. fn require_blob(meta: &Map, field: &'static str) -> Result, Box> { match meta.get(field) { Some(v) if v.is_blob() => Ok(v.clone().into_blob().unwrap_or_default()), Some(_) => Err(format!("files: field '{field}' must be a Blob (byte array)").into()), None => Err(format!("files: missing required field '{field}'").into()), } } /// Run an async future inside the synchronous Rhai context. Mirrors /// `kv::block_on`; safe because `LocalExecutorClient` runs the script /// under `spawn_blocking`, so a runtime handle is reachable. 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!("files: no tokio runtime available: {e}").into(), rhai::Position::NONE, ) .into() })?; handle.block_on(fut).map_err(|err| -> Box { EvalAltResult::ErrorRuntime(format!("files: {err}").into(), rhai::Position::NONE).into() }) }