From a66d4af34fe562d854422de234c673d84bbda525 Mon Sep 17 00:00:00 2001 From: MechaCat02 Date: Tue, 2 Jun 2026 19:55:43 +0200 Subject: [PATCH] feat(v1.1.2-docs): Rhai docs:: SDK module + ctx.event.docs + bridge tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 -> () | 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) --- crates/executor-core/src/engine.rs | 21 + crates/executor-core/src/sdk/docs.rs | 255 ++++++++++++ crates/executor-core/src/sdk/mod.rs | 2 + crates/executor-core/tests/sdk_docs.rs | 519 +++++++++++++++++++++++++ crates/executor-core/tests/sdk_kv.rs | 5 +- 5 files changed, 800 insertions(+), 2 deletions(-) create mode 100644 crates/executor-core/src/sdk/docs.rs create mode 100644 crates/executor-core/tests/sdk_docs.rs diff --git a/crates/executor-core/src/engine.rs b/crates/executor-core/src/engine.rs index 71477ed..da27ffa 100644 --- a/crates/executor-core/src/engine.rs +++ b/crates/executor-core/src/engine.rs @@ -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, diff --git a/crates/executor-core/src/sdk/docs.rs b/crates/executor-core/src/sdk/docs.rs new file mode 100644 index 0000000..e5bb07d --- /dev/null +++ b/crates/executor-core/src/sdk/docs.rs @@ -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, + 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() + }) +} diff --git a/crates/executor-core/src/sdk/mod.rs b/crates/executor-core/src/sdk/mod.rs index 56f6d47..574bd3f 100644 --- a/crates/executor-core/src/sdk/mod.rs +++ b/crates/executor-core/src/sdk/mod.rs @@ -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 `::register(...)` line per service. pub fn register_all(engine: &mut RhaiEngine, services: &Services, cx: Arc) { kv::register(engine, services, cx.clone()); + docs::register(engine, services, cx.clone()); dead_letters::register(engine, services, cx); } diff --git a/crates/executor-core/tests/sdk_docs.rs b/crates/executor-core/tests/sdk_docs.rs new file mode 100644 index 0000000..42582c3 --- /dev/null +++ b/crates/executor-core/tests/sdk_docs.rs @@ -0,0 +1,519 @@ +//! `docs::` SDK bridge integration tests — runs a real Rhai engine +//! against an in-memory `DocsService` impl. Mirrors `tests/sdk_kv.rs`: +//! `tokio::task::spawn_blocking` so the bridge's `block_on` has a +//! reachable runtime. + +use std::collections::{BTreeMap, HashMap}; +use std::sync::Arc; + +use async_trait::async_trait; +use chrono::Utc; +use picloud_executor_core::{Engine, ExecRequest, InvocationType, Limits}; +use picloud_shared::{ + AppId, DocId, DocRow, DocsError, DocsListPage, DocsService, ExecutionId, NoopDeadLetterService, + NoopEventEmitter, NoopKvService, RequestId, ScriptId, ScriptSandbox, SdkCallCx, Services, +}; +use serde_json::{json, Value}; +use tokio::sync::Mutex; +use uuid::Uuid; + +#[derive(Default)] +struct InMemoryDocs { + data: Mutex>, +} + +#[async_trait] +impl DocsService for InMemoryDocs { + async fn create( + &self, + cx: &SdkCallCx, + collection: &str, + data: Value, + ) -> Result { + if !data.is_object() { + return Err(DocsError::InvalidData); + } + let id = Uuid::new_v4(); + let now = Utc::now(); + let row = DocRow { + id, + data, + created_at: now, + updated_at: now, + }; + self.data + .lock() + .await + .insert((cx.app_id, collection.to_string(), id), row); + Ok(id) + } + + async fn get( + &self, + cx: &SdkCallCx, + collection: &str, + id: DocId, + ) -> Result, DocsError> { + Ok(self + .data + .lock() + .await + .get(&(cx.app_id, collection.to_string(), id)) + .cloned()) + } + + async fn find( + &self, + cx: &SdkCallCx, + collection: &str, + filter: Value, + ) -> Result, DocsError> { + // Tiny eval: extract top-level equalities + $in arrays + $gt + // (text lex) so the bridge tests can run end-to-end against a + // fake. This fake mirrors the real service's reject-unsupported + // contract so the v1.2-pointer-error test goes through the + // bridge's error-propagation path. + let map = self.data.lock().await; + let obj = filter + .as_object() + .ok_or_else(|| DocsError::InvalidFilter("filter must be a map/object".into()))?; + reject_unsupported_operators(obj)?; + let mut out: Vec = map + .iter() + .filter(|((a, c, _), _)| *a == cx.app_id && c == collection) + .map(|(_, v)| v.clone()) + .filter(|row| matches_simple(&row.data, obj)) + .collect(); + if let Some(limit) = obj.get("$limit").and_then(Value::as_u64) { + out.truncate(usize::try_from(limit).unwrap_or(usize::MAX)); + } + Ok(out) + } + + async fn find_one( + &self, + cx: &SdkCallCx, + collection: &str, + filter: Value, + ) -> Result, DocsError> { + Ok(self.find(cx, collection, filter).await?.into_iter().next()) + } + + async fn update( + &self, + cx: &SdkCallCx, + collection: &str, + id: DocId, + data: Value, + ) -> Result<(), DocsError> { + if !data.is_object() { + return Err(DocsError::InvalidData); + } + let mut map = self.data.lock().await; + let key = (cx.app_id, collection.to_string(), id); + let Some(row) = map.get_mut(&key) else { + return Err(DocsError::NotFound); + }; + row.data = data; + row.updated_at = Utc::now(); + Ok(()) + } + + async fn delete(&self, cx: &SdkCallCx, collection: &str, id: DocId) -> Result { + Ok(self + .data + .lock() + .await + .remove(&(cx.app_id, collection.to_string(), id)) + .is_some()) + } + + async fn list( + &self, + cx: &SdkCallCx, + collection: &str, + _cursor: Option<&str>, + _limit: u32, + ) -> Result { + let mut docs: Vec = self + .data + .lock() + .await + .iter() + .filter(|((a, c, _), _)| *a == cx.app_id && c == collection) + .map(|(_, v)| v.clone()) + .collect(); + docs.sort_by_key(|d| d.id); + Ok(DocsListPage { + docs, + next_cursor: None, + }) + } +} + +/// Scan an operator object for any `$xxx` key not in the v1.1.2 +/// allowlist and return the same shape of error the real parser +/// emits. Top-level `$limit` is the only allowed modifier the fake +/// engages with; the unsupported test passes `$regex`. +fn reject_unsupported_operators(obj: &serde_json::Map) -> Result<(), DocsError> { + const SUPPORTED_TOP_LEVEL: &[&str] = &["$limit", "$sort"]; + const SUPPORTED_NESTED: &[&str] = &["$eq", "$ne", "$gt", "$gte", "$lt", "$lte", "$in"]; + for (key, value) in obj { + if let Some(stripped) = key.strip_prefix('$') { + if !SUPPORTED_TOP_LEVEL.contains(&key.as_str()) { + return Err(DocsError::UnsupportedOperator(format!( + "docs::find: top-level modifier '${stripped}' is not supported in v1.1.2; planned for v1.2 advanced query" + ))); + } + continue; + } + if let Some(inner) = value.as_object() { + for op_key in inner.keys() { + if op_key.starts_with('$') && !SUPPORTED_NESTED.contains(&op_key.as_str()) { + return Err(DocsError::UnsupportedOperator(format!( + "docs::find: operator '{op_key}' is not supported in v1.1.2; planned for v1.2 advanced query" + ))); + } + } + } + } + Ok(()) +} + +fn matches_simple(data: &Value, filter: &serde_json::Map) -> bool { + for (key, want) in filter { + if key.starts_with('$') { + // $limit handled in the find body. + continue; + } + let actual = data.get(key); + if let Some(obj) = want.as_object() { + // operator object — handle $in and $gt only (enough for + // the bridge tests to exercise the round-trip). + if let Some(arr) = obj.get("$in").and_then(Value::as_array) { + let Some(actual) = actual else { + return false; + }; + if !arr.iter().any(|v| v == actual) { + return false; + } + continue; + } + if let Some(gt) = obj.get("$gt") { + let Some(actual) = actual else { + return false; + }; + let a = actual.as_str().unwrap_or(""); + let b = gt.as_str().unwrap_or(""); + if a <= b { + return false; + } + continue; + } + return false; + } + if Some(want) != actual { + return false; + } + } + true +} + +fn make_engine() -> Arc { + let services = Services::new( + Arc::new(NoopKvService), + Arc::new(InMemoryDocs::default()), + Arc::new(NoopDeadLetterService), + Arc::new(NoopEventEmitter), + ); + Arc::new(Engine::new(Limits::default(), services)) +} + +fn baseline_request(app_id: AppId) -> ExecRequest { + let execution_id = ExecutionId::new(); + ExecRequest { + execution_id, + request_id: RequestId::new(), + script_id: ScriptId::new(), + script_name: "docs-test".into(), + invocation_type: InvocationType::Http, + path: "/docs-test".into(), + headers: BTreeMap::new(), + body: Value::Null, + params: BTreeMap::new(), + query: BTreeMap::new(), + rest: String::new(), + sandbox_overrides: ScriptSandbox::default(), + app_id, + principal: None, + trigger_depth: 0, + root_execution_id: execution_id, + is_dead_letter_handler: false, + event: None, + } +} + +async fn run_script(engine: Arc, src: &str, req: ExecRequest) -> Value { + let src = src.to_string(); + tokio::task::spawn_blocking(move || engine.execute(&src, req)) + .await + .expect("spawn_blocking should not panic") + .expect("script execution should succeed") + .body +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn docs_create_then_get_round_trip() { + let engine = make_engine(); + let app = AppId::new(); + let src = r#" + let users = docs::collection("users"); + let id = users.create(#{ name: "Alice", tier: "gold" }); + let doc = users.get(id); + #{ id_matches: doc.id == id, data_name: doc.data.name } + "#; + let body = run_script(engine, src, baseline_request(app)).await; + let obj = body.as_object().unwrap(); + assert_eq!(obj["id_matches"], json!(true)); + assert_eq!(obj["data_name"], json!("Alice")); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn docs_get_missing_returns_unit() { + let engine = make_engine(); + let app = AppId::new(); + let src = r#" + let c = docs::collection("users"); + let v = c.get("00000000-0000-0000-0000-000000000000"); + v == () + "#; + let body = run_script(engine, src, baseline_request(app)).await; + assert_eq!(body, json!(true)); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn docs_get_with_invalid_uuid_throws() { + let engine = make_engine(); + let app = AppId::new(); + let src = r#"docs::collection("users").get("not-a-uuid")"#; + let req = baseline_request(app); + let err = tokio::task::spawn_blocking(move || engine.execute(src, req)) + .await + .unwrap() + .expect_err("invalid uuid should throw"); + assert!(format!("{err:?}").contains("invalid id")); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn docs_find_equality_returns_matches() { + let engine = make_engine(); + let app = AppId::new(); + let src = r#" + let c = docs::collection("users"); + c.create(#{ tier: "gold" }); + c.create(#{ tier: "silver" }); + c.create(#{ tier: "gold" }); + let golds = c.find(#{ tier: "gold" }); + golds.len() + "#; + let body = run_script(engine, src, baseline_request(app)).await; + assert_eq!(body, json!(2)); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn docs_find_with_in_operator() { + let engine = make_engine(); + let app = AppId::new(); + let src = r#" + let c = docs::collection("users"); + c.create(#{ tier: "gold" }); + c.create(#{ tier: "silver" }); + c.create(#{ tier: "platinum" }); + let hits = c.find(#{ tier: #{ "$in": ["gold", "platinum"] } }); + hits.len() + "#; + let body = run_script(engine, src, baseline_request(app)).await; + assert_eq!(body, json!(2)); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn docs_find_with_gt_comparison() { + let engine = make_engine(); + let app = AppId::new(); + let src = r#" + let c = docs::collection("events"); + c.create(#{ when: "2026-01-15" }); + c.create(#{ when: "2026-03-15" }); + c.create(#{ when: "2026-05-15" }); + let recent = c.find(#{ when: #{ "$gt": "2026-02-01" } }); + recent.len() + "#; + let body = run_script(engine, src, baseline_request(app)).await; + assert_eq!(body, json!(2)); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn docs_find_one_returns_envelope_or_unit() { + let engine = make_engine(); + let app = AppId::new(); + let src = r#" + let c = docs::collection("users"); + c.create(#{ tier: "gold" }); + let hit = c.find_one(#{ tier: "gold" }); + let miss = c.find_one(#{ tier: "platinum" }); + #{ hit_has_data: hit.data.tier == "gold", miss_is_unit: miss == () } + "#; + let body = run_script(engine, src, baseline_request(app)).await; + let obj = body.as_object().unwrap(); + assert_eq!(obj["hit_has_data"], json!(true)); + assert_eq!(obj["miss_is_unit"], json!(true)); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn docs_update_then_get_reflects_change() { + let engine = make_engine(); + let app = AppId::new(); + let src = r#" + let c = docs::collection("users"); + let id = c.create(#{ name: "Alice", tier: "gold" }); + c.update(id, #{ name: "Alice", tier: "platinum" }); + c.get(id).data.tier + "#; + let body = run_script(engine, src, baseline_request(app)).await; + assert_eq!(body, json!("platinum")); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn docs_update_missing_throws() { + let engine = make_engine(); + let app = AppId::new(); + let src = r#" + let c = docs::collection("users"); + c.update("00000000-0000-0000-0000-000000000000", #{ x: 1 }) + "#; + let req = baseline_request(app); + let err = tokio::task::spawn_blocking(move || engine.execute(src, req)) + .await + .unwrap() + .expect_err("update missing should throw"); + assert!(format!("{err:?}").contains("not found")); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn docs_delete_returns_was_present() { + let engine = make_engine(); + let app = AppId::new(); + let src = r#" + let c = docs::collection("users"); + let nope = c.delete("00000000-0000-0000-0000-000000000000"); + let id = c.create(#{ x: 1 }); + let yep = c.delete(id); + #{ nope: nope, yep: yep } + "#; + let body = run_script(engine, src, baseline_request(app)).await; + assert_eq!(body, json!({ "nope": false, "yep": true })); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn docs_unsupported_operator_throws_with_v1_2_pointer() { + let engine = make_engine(); + let app = AppId::new(); + let src = r#" + let c = docs::collection("users"); + c.find(#{ name: #{ "$regex": "^A" } }) + "#; + let req = baseline_request(app); + let err = tokio::task::spawn_blocking(move || engine.execute(src, req)) + .await + .unwrap() + .expect_err("unsupported operator should throw"); + let msg = format!("{err:?}"); + assert!(msg.contains("$regex"), "msg: {msg}"); + assert!(msg.contains("v1.2"), "msg: {msg}"); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn docs_empty_collection_name_throws() { + let engine = make_engine(); + let app = AppId::new(); + let src = r#"docs::collection("")"#; + let req = baseline_request(app); + let err = tokio::task::spawn_blocking(move || engine.execute(src, req)) + .await + .unwrap() + .expect_err("empty collection should throw"); + assert!(format!("{err:?}").contains("docs::collection")); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn docs_list_returns_docs_array() { + let engine = make_engine(); + let app = AppId::new(); + let src = r#" + let c = docs::collection("users"); + c.create(#{ a: 1 }); + c.create(#{ a: 2 }); + let page = c.list(); + page.docs.len() + "#; + let body = run_script(engine, src, baseline_request(app)).await; + assert_eq!(body, json!(2)); +} + +/// Cross-app isolation through the bridge — script with `app_id = A` +/// must NOT see documents written from `app_id = B` even when the +/// (collection, id) tuple is shared. The bridge captures `cx.app_id` +/// via `Arc` and the service derives storage `app_id` from +/// it (never from a script arg). +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn docs_bridge_preserves_cross_app_isolation() { + let engine = make_engine(); + let app_a = AppId::new(); + let app_b = AppId::new(); + + let writer = r#" + let c = docs::collection("shared"); + let id = c.create(#{ from: "a" }); + id + "#; + let id_a = run_script(engine.clone(), writer, baseline_request(app_a)).await; + let id_a_str = id_a.as_str().unwrap().to_string(); + + // App B looks up the same id under the same collection — should + // see nothing because the service keyed it by app_id = A. + let reader_src = format!( + r#" + let c = docs::collection("shared"); + let v = c.get("{id_a_str}"); + v == () + "# + ); + let body = run_script(engine, &reader_src, baseline_request(app_b)).await; + assert_eq!(body, json!(true)); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn docs_envelope_has_id_data_created_at_updated_at() { + let engine = make_engine(); + let app = AppId::new(); + let src = r#" + let c = docs::collection("users"); + let id = c.create(#{ name: "Alice" }); + let doc = c.get(id); + // Probe each envelope field is present + correctly typed. + #{ + has_id: type_of(doc.id) == "string", + has_data: type_of(doc.data) == "map", + has_created_at: type_of(doc.created_at) == "string", + has_updated_at: type_of(doc.updated_at) == "string", + user_field: doc.data.name + } + "#; + let body = run_script(engine, src, baseline_request(app)).await; + let obj = body.as_object().unwrap(); + assert_eq!(obj["has_id"], json!(true)); + assert_eq!(obj["has_data"], json!(true)); + assert_eq!(obj["has_created_at"], json!(true)); + assert_eq!(obj["has_updated_at"], json!(true)); + assert_eq!(obj["user_field"], json!("Alice")); +} diff --git a/crates/executor-core/tests/sdk_kv.rs b/crates/executor-core/tests/sdk_kv.rs index 462e407..df24c0a 100644 --- a/crates/executor-core/tests/sdk_kv.rs +++ b/crates/executor-core/tests/sdk_kv.rs @@ -10,8 +10,8 @@ use std::sync::Arc; use async_trait::async_trait; use picloud_executor_core::{Engine, ExecRequest, InvocationType, Limits}; use picloud_shared::{ - AppId, ExecutionId, KvError, KvListPage, KvService, NoopDeadLetterService, NoopEventEmitter, - RequestId, ScriptId, ScriptSandbox, SdkCallCx, Services, + AppId, ExecutionId, KvError, KvListPage, KvService, NoopDeadLetterService, NoopDocsService, + NoopEventEmitter, RequestId, ScriptId, ScriptSandbox, SdkCallCx, Services, }; use serde_json::{json, Value}; use tokio::sync::Mutex; @@ -101,6 +101,7 @@ impl KvService for InMemoryKv { fn make_engine() -> Arc { let services = Services::new( Arc::new(InMemoryKv::default()), + Arc::new(NoopDocsService), Arc::new(NoopDeadLetterService), Arc::new(NoopEventEmitter), );