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:
@@ -278,6 +278,27 @@ fn trigger_event_to_dynamic(event: &TriggerEvent) -> Dynamic {
|
|||||||
);
|
);
|
||||||
m.insert("kv".into(), kv_map.into());
|
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 {
|
TriggerEvent::DeadLetter {
|
||||||
dead_letter_id,
|
dead_letter_id,
|
||||||
original,
|
original,
|
||||||
|
|||||||
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()
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -14,6 +14,7 @@
|
|||||||
pub mod bridge;
|
pub mod bridge;
|
||||||
pub mod cx;
|
pub mod cx;
|
||||||
pub mod dead_letters;
|
pub mod dead_letters;
|
||||||
|
pub mod docs;
|
||||||
pub mod kv;
|
pub mod kv;
|
||||||
pub mod stdlib;
|
pub mod stdlib;
|
||||||
|
|
||||||
@@ -33,5 +34,6 @@ use rhai::Engine as RhaiEngine;
|
|||||||
/// single `<service>::register(...)` line per service.
|
/// single `<service>::register(...)` line per service.
|
||||||
pub fn register_all(engine: &mut RhaiEngine, services: &Services, cx: Arc<SdkCallCx>) {
|
pub fn register_all(engine: &mut RhaiEngine, services: &Services, cx: Arc<SdkCallCx>) {
|
||||||
kv::register(engine, services, cx.clone());
|
kv::register(engine, services, cx.clone());
|
||||||
|
docs::register(engine, services, cx.clone());
|
||||||
dead_letters::register(engine, services, cx);
|
dead_letters::register(engine, services, cx);
|
||||||
}
|
}
|
||||||
|
|||||||
519
crates/executor-core/tests/sdk_docs.rs
Normal file
519
crates/executor-core/tests/sdk_docs.rs
Normal file
@@ -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<HashMap<(AppId, String, DocId), DocRow>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl DocsService for InMemoryDocs {
|
||||||
|
async fn create(
|
||||||
|
&self,
|
||||||
|
cx: &SdkCallCx,
|
||||||
|
collection: &str,
|
||||||
|
data: Value,
|
||||||
|
) -> Result<DocId, DocsError> {
|
||||||
|
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<Option<DocRow>, 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<Vec<DocRow>, 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<DocRow> = 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<Option<DocRow>, 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<bool, DocsError> {
|
||||||
|
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<DocsListPage, DocsError> {
|
||||||
|
let mut docs: Vec<DocRow> = 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<String, Value>) -> 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<String, Value>) -> 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<Engine> {
|
||||||
|
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<Engine>, 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<SdkCallCx>` 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"));
|
||||||
|
}
|
||||||
@@ -10,8 +10,8 @@ use std::sync::Arc;
|
|||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use picloud_executor_core::{Engine, ExecRequest, InvocationType, Limits};
|
use picloud_executor_core::{Engine, ExecRequest, InvocationType, Limits};
|
||||||
use picloud_shared::{
|
use picloud_shared::{
|
||||||
AppId, ExecutionId, KvError, KvListPage, KvService, NoopDeadLetterService, NoopEventEmitter,
|
AppId, ExecutionId, KvError, KvListPage, KvService, NoopDeadLetterService, NoopDocsService,
|
||||||
RequestId, ScriptId, ScriptSandbox, SdkCallCx, Services,
|
NoopEventEmitter, RequestId, ScriptId, ScriptSandbox, SdkCallCx, Services,
|
||||||
};
|
};
|
||||||
use serde_json::{json, Value};
|
use serde_json::{json, Value};
|
||||||
use tokio::sync::Mutex;
|
use tokio::sync::Mutex;
|
||||||
@@ -101,6 +101,7 @@ impl KvService for InMemoryKv {
|
|||||||
fn make_engine() -> Arc<Engine> {
|
fn make_engine() -> Arc<Engine> {
|
||||||
let services = Services::new(
|
let services = Services::new(
|
||||||
Arc::new(InMemoryKv::default()),
|
Arc::new(InMemoryKv::default()),
|
||||||
|
Arc::new(NoopDocsService),
|
||||||
Arc::new(NoopDeadLetterService),
|
Arc::new(NoopDeadLetterService),
|
||||||
Arc::new(NoopEventEmitter),
|
Arc::new(NoopEventEmitter),
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user