The runtime for shared group docs collections, mirroring the group-KV vertical with the docs surface (filter parsing, JSON-object validation, value-size cap). - shared: GroupDocsService trait + GroupDocsError (+ CollectionNotShared) + NoopGroupDocsService; group_docs field on Services + with_group_docs(). - manager-core: GroupDocsServiceImpl — owning_group resolves kind='docs' from cx.app_id's chain (CollectionNotShared off-chain); reads-open (script_gate GroupDocsRead) / writes-authed (script_gate_require_principal GroupDocsWrite); reuses parse_filter + docs value-size cap; no events. Unit tests cover off-chain CollectionNotShared, anon-read-OK/anon-write-Forbidden, non-object data rejected. - executor-core: GroupDocsHandle + docs::shared_collection constructor + the create/get/find/find_one/update/delete/list methods (dispatch by receiver type), reusing doc_to_map/parse_doc_id. - picloud: wire PostgresGroupDocsRepo + GroupDocsServiceImpl. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
429 lines
14 KiB
Rust
429 lines
14 KiB
Rust
//! `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, DocsService, GroupDocsService, SdkCallCx, Services};
|
|
use rhai::{Array, Dynamic, Engine as RhaiEngine, EvalAltResult, Map, Module};
|
|
use uuid::Uuid;
|
|
|
|
use super::bridge::{block_on, 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>,
|
|
}
|
|
|
|
/// §11.6 shared-collection handle, returned by `docs::shared_collection(name)`.
|
|
/// A distinct Rhai type from `DocsHandle` so a script's choice of
|
|
/// private-vs-shared scope is explicit. Routes through `GroupDocsService`, which
|
|
/// resolves the owning group from `cx.app_id` (the script never names a group).
|
|
#[derive(Clone)]
|
|
pub struct GroupDocsHandle {
|
|
collection: String,
|
|
service: Arc<dyn GroupDocsService>,
|
|
cx: Arc<SdkCallCx>,
|
|
}
|
|
|
|
pub(super) fn register(engine: &mut RhaiEngine, services: &Services, cx: Arc<SdkCallCx>) {
|
|
let docs_service = services.docs.clone();
|
|
let group_docs_service = services.group_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(),
|
|
})
|
|
},
|
|
);
|
|
}
|
|
{
|
|
let group_docs_service = group_docs_service.clone();
|
|
let cx = cx.clone();
|
|
module.set_native_fn(
|
|
"shared_collection",
|
|
move |name: &str| -> Result<GroupDocsHandle, Box<EvalAltResult>> {
|
|
if name.is_empty() {
|
|
return Err("docs::shared_collection name must not be empty".into());
|
|
}
|
|
Ok(GroupDocsHandle {
|
|
collection: name.to_string(),
|
|
service: group_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);
|
|
|
|
// Same method names on GroupDocsHandle — Rhai dispatches by receiver type.
|
|
engine.register_type_with_name::<GroupDocsHandle>("GroupDocsHandle");
|
|
|
|
register_group_create(engine);
|
|
register_group_get(engine);
|
|
register_group_find(engine);
|
|
register_group_find_one(engine);
|
|
register_group_update(engine);
|
|
register_group_delete(engine);
|
|
register_group_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("docs", 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("docs", 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("docs", 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("docs", 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("docs", 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("docs", 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("docs", 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)
|
|
}
|
|
|
|
// --- GroupDocsHandle methods (§11.6 shared docs collections) ---------------
|
|
|
|
fn register_group_create(engine: &mut RhaiEngine) {
|
|
engine.register_fn(
|
|
"create",
|
|
|handle: &mut GroupDocsHandle, data: Map| -> Result<String, Box<EvalAltResult>> {
|
|
let h = handle.clone();
|
|
let json = dynamic_to_json(&Dynamic::from(data));
|
|
let id = block_on("docs", async move {
|
|
h.service.create(&h.cx, &h.collection, json).await
|
|
})?;
|
|
Ok(id.to_string())
|
|
},
|
|
);
|
|
}
|
|
|
|
fn register_group_get(engine: &mut RhaiEngine) {
|
|
engine.register_fn(
|
|
"get",
|
|
|handle: &mut GroupDocsHandle, id: &str| -> Result<Dynamic, Box<EvalAltResult>> {
|
|
let h = handle.clone();
|
|
let parsed_id = parse_doc_id(id)?;
|
|
let row = block_on("docs", 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_group_find(engine: &mut RhaiEngine) {
|
|
engine.register_fn(
|
|
"find",
|
|
|handle: &mut GroupDocsHandle, filter: Map| -> Result<Array, Box<EvalAltResult>> {
|
|
let h = handle.clone();
|
|
let json = dynamic_to_json(&Dynamic::from(filter));
|
|
let rows = block_on("docs", 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_group_find_one(engine: &mut RhaiEngine) {
|
|
engine.register_fn(
|
|
"find_one",
|
|
|handle: &mut GroupDocsHandle, filter: Map| -> Result<Dynamic, Box<EvalAltResult>> {
|
|
let h = handle.clone();
|
|
let json = dynamic_to_json(&Dynamic::from(filter));
|
|
let row = block_on("docs", 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_group_update(engine: &mut RhaiEngine) {
|
|
engine.register_fn(
|
|
"update",
|
|
|handle: &mut GroupDocsHandle, 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("docs", async move {
|
|
h.service
|
|
.update(&h.cx, &h.collection, parsed_id, json)
|
|
.await
|
|
})
|
|
},
|
|
);
|
|
}
|
|
|
|
fn register_group_delete(engine: &mut RhaiEngine) {
|
|
engine.register_fn(
|
|
"delete",
|
|
|handle: &mut GroupDocsHandle, id: &str| -> Result<bool, Box<EvalAltResult>> {
|
|
let h = handle.clone();
|
|
let parsed_id = parse_doc_id(id)?;
|
|
block_on("docs", async move {
|
|
h.service.delete(&h.cx, &h.collection, parsed_id).await
|
|
})
|
|
},
|
|
);
|
|
}
|
|
|
|
fn register_group_list(engine: &mut RhaiEngine) {
|
|
engine.register_fn(
|
|
"list",
|
|
|handle: &mut GroupDocsHandle| -> Result<Map, Box<EvalAltResult>> {
|
|
group_list_call(handle, None, 0)
|
|
},
|
|
);
|
|
engine.register_fn(
|
|
"list",
|
|
|handle: &mut GroupDocsHandle, 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,
|
|
};
|
|
group_list_call(handle, cursor, limit)
|
|
},
|
|
);
|
|
}
|
|
|
|
fn group_list_call(
|
|
handle: &GroupDocsHandle,
|
|
cursor: Option<String>,
|
|
limit: u32,
|
|
) -> Result<Map, Box<EvalAltResult>> {
|
|
let h = handle.clone();
|
|
let page = block_on("docs", 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()
|
|
})
|
|
}
|