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),
|
||||
}
|
||||
Reference in New Issue
Block a user