feat(modules): GroupDocsService + docs::shared_collection handle (§11.6 docs C3)
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>
This commit is contained in:
181
crates/shared/src/group_docs.rs
Normal file
181
crates/shared/src/group_docs.rs
Normal file
@@ -0,0 +1,181 @@
|
||||
//! `GroupDocsService` — the §11.6 shared group-collection DOCS contract.
|
||||
//!
|
||||
//! The cross-app-sharing counterpart to [`crate::DocsService`], analogous to
|
||||
//! [`crate::GroupKvService`]. A script reaches it via the explicit
|
||||
//! `docs::shared_collection("name")` handle. The owner of the data is not
|
||||
//! `cx.app_id`: the impl resolves the **owning group** by walking the calling
|
||||
//! app's ancestor chain for the nearest group that declares the collection
|
||||
//! shared with `kind='docs'`. That ancestry walk is the isolation boundary —
|
||||
//! the trait surface takes **no** group id (owner derivation stays in the impl).
|
||||
//!
|
||||
//! Trust model (§11.6): **reads are open** to any subtree script (anonymous
|
||||
//! public HTTP included); **writes require an authenticated principal** with
|
||||
//! editor+ on the owning group.
|
||||
|
||||
use async_trait::async_trait;
|
||||
use thiserror::Error;
|
||||
|
||||
use crate::{DocId, DocRow, DocsListPage, SdkCallCx};
|
||||
|
||||
#[async_trait]
|
||||
pub trait GroupDocsService: Send + Sync {
|
||||
async fn create(
|
||||
&self,
|
||||
cx: &SdkCallCx,
|
||||
collection: &str,
|
||||
data: serde_json::Value,
|
||||
) -> Result<DocId, GroupDocsError>;
|
||||
|
||||
async fn get(
|
||||
&self,
|
||||
cx: &SdkCallCx,
|
||||
collection: &str,
|
||||
id: DocId,
|
||||
) -> Result<Option<DocRow>, GroupDocsError>;
|
||||
|
||||
async fn find(
|
||||
&self,
|
||||
cx: &SdkCallCx,
|
||||
collection: &str,
|
||||
filter: serde_json::Value,
|
||||
) -> Result<Vec<DocRow>, GroupDocsError>;
|
||||
|
||||
async fn find_one(
|
||||
&self,
|
||||
cx: &SdkCallCx,
|
||||
collection: &str,
|
||||
filter: serde_json::Value,
|
||||
) -> Result<Option<DocRow>, GroupDocsError>;
|
||||
|
||||
async fn update(
|
||||
&self,
|
||||
cx: &SdkCallCx,
|
||||
collection: &str,
|
||||
id: DocId,
|
||||
data: serde_json::Value,
|
||||
) -> Result<(), GroupDocsError>;
|
||||
|
||||
async fn delete(
|
||||
&self,
|
||||
cx: &SdkCallCx,
|
||||
collection: &str,
|
||||
id: DocId,
|
||||
) -> Result<bool, GroupDocsError>;
|
||||
|
||||
async fn list(
|
||||
&self,
|
||||
cx: &SdkCallCx,
|
||||
collection: &str,
|
||||
cursor: Option<&str>,
|
||||
limit: u32,
|
||||
) -> Result<DocsListPage, GroupDocsError>;
|
||||
}
|
||||
|
||||
/// Stub for test bundles that don't exercise shared docs collections.
|
||||
#[derive(Debug, Default, Clone, Copy)]
|
||||
pub struct NoopGroupDocsService;
|
||||
|
||||
#[async_trait]
|
||||
impl GroupDocsService for NoopGroupDocsService {
|
||||
async fn create(
|
||||
&self,
|
||||
_cx: &SdkCallCx,
|
||||
_collection: &str,
|
||||
_data: serde_json::Value,
|
||||
) -> Result<DocId, GroupDocsError> {
|
||||
Err(GroupDocsError::Backend("group docs is not wired in".into()))
|
||||
}
|
||||
|
||||
async fn get(
|
||||
&self,
|
||||
_cx: &SdkCallCx,
|
||||
_collection: &str,
|
||||
_id: DocId,
|
||||
) -> Result<Option<DocRow>, GroupDocsError> {
|
||||
Err(GroupDocsError::Backend("group docs is not wired in".into()))
|
||||
}
|
||||
|
||||
async fn find(
|
||||
&self,
|
||||
_cx: &SdkCallCx,
|
||||
_collection: &str,
|
||||
_filter: serde_json::Value,
|
||||
) -> Result<Vec<DocRow>, GroupDocsError> {
|
||||
Err(GroupDocsError::Backend("group docs is not wired in".into()))
|
||||
}
|
||||
|
||||
async fn find_one(
|
||||
&self,
|
||||
_cx: &SdkCallCx,
|
||||
_collection: &str,
|
||||
_filter: serde_json::Value,
|
||||
) -> Result<Option<DocRow>, GroupDocsError> {
|
||||
Err(GroupDocsError::Backend("group docs is not wired in".into()))
|
||||
}
|
||||
|
||||
async fn update(
|
||||
&self,
|
||||
_cx: &SdkCallCx,
|
||||
_collection: &str,
|
||||
_id: DocId,
|
||||
_data: serde_json::Value,
|
||||
) -> Result<(), GroupDocsError> {
|
||||
Err(GroupDocsError::Backend("group docs is not wired in".into()))
|
||||
}
|
||||
|
||||
async fn delete(
|
||||
&self,
|
||||
_cx: &SdkCallCx,
|
||||
_collection: &str,
|
||||
_id: DocId,
|
||||
) -> Result<bool, GroupDocsError> {
|
||||
Err(GroupDocsError::Backend("group docs is not wired in".into()))
|
||||
}
|
||||
|
||||
async fn list(
|
||||
&self,
|
||||
_cx: &SdkCallCx,
|
||||
_collection: &str,
|
||||
_cursor: Option<&str>,
|
||||
_limit: u32,
|
||||
) -> Result<DocsListPage, GroupDocsError> {
|
||||
Err(GroupDocsError::Backend("group docs is not wired in".into()))
|
||||
}
|
||||
}
|
||||
|
||||
/// Failure modes surfaced to the Rhai bridge. Mirrors [`crate::DocsError`] plus
|
||||
/// the §11.6 `CollectionNotShared` boundary.
|
||||
#[derive(Debug, Error)]
|
||||
pub enum GroupDocsError {
|
||||
#[error("collection name must not be empty")]
|
||||
InvalidCollection,
|
||||
|
||||
/// No group on the calling app's ancestor chain declares this collection
|
||||
/// shared with `kind='docs'` — the structural "not shared with you"
|
||||
/// boundary (also the outcome for a foreign app).
|
||||
#[error("collection {0:?} is not a shared group docs collection for this app")]
|
||||
CollectionNotShared(String),
|
||||
|
||||
#[error("document data must be a JSON object")]
|
||||
InvalidData,
|
||||
|
||||
#[error("invalid filter: {0}")]
|
||||
InvalidFilter(String),
|
||||
|
||||
#[error("unsupported operator: {0}")]
|
||||
UnsupportedOperator(String),
|
||||
|
||||
#[error("document not found")]
|
||||
NotFound,
|
||||
|
||||
/// For reads this is only raised when `cx.principal.is_some()`; for WRITES
|
||||
/// it is also raised when the principal is absent (writes fail closed).
|
||||
#[error("forbidden")]
|
||||
Forbidden,
|
||||
|
||||
#[error("group docs: data too large ({actual} bytes; limit {limit})")]
|
||||
ValueTooLarge { limit: usize, actual: usize },
|
||||
|
||||
#[error("group docs backend error: {0}")]
|
||||
Backend(String),
|
||||
}
|
||||
@@ -16,6 +16,7 @@ pub mod exec_summary;
|
||||
pub mod execution_log;
|
||||
pub mod files;
|
||||
pub mod group;
|
||||
pub mod group_docs;
|
||||
pub mod group_kv;
|
||||
pub mod http;
|
||||
pub mod ids;
|
||||
@@ -66,6 +67,7 @@ pub use files::{
|
||||
SAFE_RENDER_FALLBACK,
|
||||
};
|
||||
pub use group::Group;
|
||||
pub use group_docs::{GroupDocsError, GroupDocsService, NoopGroupDocsService};
|
||||
pub use group_kv::{GroupKvError, GroupKvService, NoopGroupKvService};
|
||||
pub use http::{HttpError, HttpRequest, HttpResponse, HttpService, NoopHttpService};
|
||||
pub use ids::{
|
||||
|
||||
@@ -20,12 +20,12 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::{
|
||||
DeadLetterService, DocsService, EmailService, FilesService, GroupKvService, HttpService,
|
||||
InvokeService, KvService, ModuleSource, NoopDeadLetterService, NoopDocsService,
|
||||
NoopEmailService, NoopEventEmitter, NoopFilesService, NoopGroupKvService, NoopHttpService,
|
||||
NoopInvokeService, NoopKvService, NoopModuleSource, NoopPubsubService, NoopQueueService,
|
||||
NoopSecretsService, NoopUsersService, NoopVarsService, PubsubService, QueueService,
|
||||
SecretsService, ServiceEventEmitter, UsersService, VarsService,
|
||||
DeadLetterService, DocsService, EmailService, FilesService, GroupDocsService, GroupKvService,
|
||||
HttpService, InvokeService, KvService, ModuleSource, NoopDeadLetterService, NoopDocsService,
|
||||
NoopEmailService, NoopEventEmitter, NoopFilesService, NoopGroupDocsService, NoopGroupKvService,
|
||||
NoopHttpService, NoopInvokeService, NoopKvService, NoopModuleSource, NoopPubsubService,
|
||||
NoopQueueService, NoopSecretsService, NoopUsersService, NoopVarsService, PubsubService,
|
||||
QueueService, SecretsService, ServiceEventEmitter, UsersService, VarsService,
|
||||
};
|
||||
|
||||
/// SDK service bundle. See module docs for the lifecycle and the v1.1.x
|
||||
@@ -122,6 +122,11 @@ pub struct Services {
|
||||
/// `NoopGroupKvService` so test bundles that don't exercise sharing build
|
||||
/// unchanged.
|
||||
pub group_kv: Arc<dyn GroupKvService>,
|
||||
|
||||
/// Shared cross-app group DOCS collections (§11.6). Scripts get
|
||||
/// `docs::shared_collection(name)`. Wired via [`Services::with_group_docs`];
|
||||
/// defaults to `NoopGroupDocsService`.
|
||||
pub group_docs: Arc<dyn GroupDocsService>,
|
||||
}
|
||||
|
||||
impl Services {
|
||||
@@ -165,17 +170,25 @@ impl Services {
|
||||
// in via `with_group_kv`. Keeps the ~14 `Services::new` call sites
|
||||
// (mostly tests) unchanged — a field addition, not a param.
|
||||
group_kv: Arc::new(NoopGroupKvService),
|
||||
group_docs: Arc::new(NoopGroupDocsService),
|
||||
}
|
||||
}
|
||||
|
||||
/// Set the §11.6 shared group-collection service (the picloud binary wires
|
||||
/// the Postgres-backed impl; tests leave the noop default).
|
||||
/// Set the §11.6 shared group-KV service (the picloud binary wires the
|
||||
/// Postgres-backed impl; tests leave the noop default).
|
||||
#[must_use]
|
||||
pub fn with_group_kv(mut self, group_kv: Arc<dyn GroupKvService>) -> Self {
|
||||
self.group_kv = group_kv;
|
||||
self
|
||||
}
|
||||
|
||||
/// Set the §11.6 shared group-DOCS service (picloud binary; tests leave noop).
|
||||
#[must_use]
|
||||
pub fn with_group_docs(mut self, group_docs: Arc<dyn GroupDocsService>) -> Self {
|
||||
self.group_docs = group_docs;
|
||||
self
|
||||
}
|
||||
|
||||
/// All-noop bundle for tests that build an `Engine` but don't
|
||||
/// exercise the stateful services. Returns the same shape as
|
||||
/// `Services::new` so callers can't accidentally rely on a stub
|
||||
|
||||
Reference in New Issue
Block a user