Compare commits
10 Commits
feat/owner
...
aa2831da90
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
aa2831da90 | ||
|
|
22d6c33d0b | ||
|
|
c04c684a2e | ||
|
|
7c17d6e363 | ||
|
|
4d2eed4e81 | ||
|
|
0396866698 | ||
|
|
193336a8a6 | ||
|
|
c18ce7c2c4 | ||
|
|
dc8c69ac6f | ||
|
|
4dcb8d1136 |
5
.gitignore
vendored
5
.gitignore
vendored
@@ -26,11 +26,8 @@ docker-compose.override.yml
|
||||
config.local.toml
|
||||
/data
|
||||
# Files-root blob storage created when integration tests run build_app
|
||||
# from a crate dir (PICLOUD_FILES_ROOT default ./data). The CLI journey
|
||||
# harness spawns the server from the picloud-cli dir, so it lands there too
|
||||
# (the §11.6 group-files journey is the first CLI test to write blobs).
|
||||
# from the picloud crate dir (PICLOUD_FILES_ROOT default ./data).
|
||||
/crates/picloud/data
|
||||
/crates/picloud-cli/data
|
||||
/postgres-data
|
||||
|
||||
# Dashboard
|
||||
|
||||
@@ -14,6 +14,14 @@
|
||||
//! (a leaf can't shadow them — the trust boundary), while every SDK call
|
||||
//! *inside* a module still scopes to the inheriting app via `cx.app_id`.
|
||||
//!
|
||||
//! **Extension points (§5.5).** A node may opt a module name OUT of sealing
|
||||
//! by declaring it an extension point: an importer on that node's chain then
|
||||
//! resolves the name against the *inheriting app's* effective view
|
||||
//! (`App(cx.app_id)`), with the declared default body as fallback — so each
|
||||
//! descendant app supplies its own impl. The "is this an extension point"
|
||||
//! check keys on the importer's `origin` (trusted), so only the declaring
|
||||
//! parent can open the inversion; a descendant can't hijack a sealed import.
|
||||
//!
|
||||
//! Three runtime invariants are enforced:
|
||||
//!
|
||||
//! 1. **Lexical isolation** — `ModuleSource::resolve` is keyed by the
|
||||
@@ -377,40 +385,53 @@ impl ModuleResolver for PicloudModuleResolver {
|
||||
let origin = importer_source
|
||||
.and_then(decode_origin)
|
||||
.unwrap_or(self.default_origin);
|
||||
// §5.5 policy resolution: the source decides lexical (seal to `origin`)
|
||||
// vs dynamic (an extension point resolves against the inheriting app,
|
||||
// `cx.app_id`). The result is already the concrete module to bind, or a
|
||||
// terminal NoProvider/NotFound.
|
||||
let lookup_result: Result<picloud_shared::ModuleResolution, ModuleSourceError> =
|
||||
tokio::task::block_in_place(|| {
|
||||
handle.block_on(self.source.resolve_policy(origin, self.cx.app_id, path))
|
||||
});
|
||||
|
||||
// Extension-point inversion (§5.5): if `path`'s nearest declaration on
|
||||
// the importer's chain is an extension point (not a concrete module),
|
||||
// resolve it against the INHERITING app's effective view instead of the
|
||||
// sealed lexical chain — each descendant app supplies its own impl,
|
||||
// falling back to the declared default body. The decision keys on
|
||||
// `origin` (the trusted importer node), so only a declaration on the
|
||||
// importer's own ancestry can open this; a descendant can never make a
|
||||
// parent's sealed `import` dynamic, and a non-ext-point import never
|
||||
// consults `App(cx.app_id)`. All branches funnel any backend error into
|
||||
// the single redaction arm below.
|
||||
let lookup_result: Result<Option<picloud_shared::ModuleScript>, ModuleSourceError> =
|
||||
(|| {
|
||||
let ep = tokio::task::block_in_place(|| {
|
||||
handle.block_on(self.source.resolve_extension_point(origin, path))
|
||||
})?;
|
||||
let Some(ep) = ep else {
|
||||
// Sealed lexical resolution (the default, unchanged).
|
||||
return tokio::task::block_in_place(|| {
|
||||
handle.block_on(self.source.resolve(origin, path))
|
||||
});
|
||||
};
|
||||
let app_origin = ScriptOwner::App(self.cx.app_id);
|
||||
if let Some(m) = tokio::task::block_in_place(|| {
|
||||
handle.block_on(self.source.resolve(app_origin, path))
|
||||
})? {
|
||||
return Ok(Some(m));
|
||||
}
|
||||
match ep.default_script_id {
|
||||
// The default body is a module declared at the node that
|
||||
// opened the ext point, i.e. on the importer's (`origin`)
|
||||
// chain — resolve it there, not against the app.
|
||||
Some(id) => tokio::task::block_in_place(|| {
|
||||
handle.block_on(self.source.resolve_by_id(origin, id))
|
||||
}),
|
||||
None => Ok(None),
|
||||
}
|
||||
})();
|
||||
|
||||
let module_row = match lookup_result {
|
||||
Ok(picloud_shared::ModuleResolution::Module(m)) => m,
|
||||
Ok(picloud_shared::ModuleResolution::NotFound) => {
|
||||
Ok(Some(m)) => m,
|
||||
Ok(None) => {
|
||||
return Err(Box::new(EvalAltResult::ErrorModuleNotFound(
|
||||
path.to_string(),
|
||||
pos,
|
||||
)));
|
||||
}
|
||||
Ok(picloud_shared::ModuleResolution::NoProvider) => {
|
||||
// §5.5: an extension point with no provider for this app is a
|
||||
// hard failure (the app must supply the module or a default
|
||||
// body must exist up-chain). Distinct, actionable message.
|
||||
return Err(Box::new(EvalAltResult::ErrorInModule(
|
||||
path.to_string(),
|
||||
Box::new(EvalAltResult::ErrorRuntime(
|
||||
format!(
|
||||
"extension point {path:?} has no provider for this app — \
|
||||
define a module named {path:?} or a default body up-chain"
|
||||
)
|
||||
.into(),
|
||||
pos,
|
||||
)),
|
||||
pos,
|
||||
)));
|
||||
}
|
||||
Err(e) => {
|
||||
// v1.1.4 §10a: redact the backend error before it
|
||||
// reaches a script. In public-HTTP context (principal:
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use picloud_shared::{DocId, DocRow, DocsService, GroupDocsService, SdkCallCx, Services};
|
||||
use picloud_shared::{DocId, DocRow, DocsService, SdkCallCx, Services};
|
||||
use rhai::{Array, Dynamic, Engine as RhaiEngine, EvalAltResult, Map, Module};
|
||||
use uuid::Uuid;
|
||||
|
||||
@@ -38,20 +38,8 @@ pub struct DocsHandle {
|
||||
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();
|
||||
{
|
||||
@@ -71,23 +59,6 @@ pub(super) fn register(engine: &mut RhaiEngine, services: &Services, cx: Arc<Sdk
|
||||
},
|
||||
);
|
||||
}
|
||||
{
|
||||
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");
|
||||
@@ -99,17 +70,6 @@ pub(super) fn register(engine: &mut RhaiEngine, services: &Services, cx: Arc<Sdk
|
||||
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) {
|
||||
@@ -258,153 +218,6 @@ fn list_call(
|
||||
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.
|
||||
|
||||
@@ -24,9 +24,7 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use super::bridge::block_on;
|
||||
use picloud_shared::{
|
||||
FileMeta, FileUpdate, FilesService, GroupFilesService, NewFile, SdkCallCx, Services,
|
||||
};
|
||||
use picloud_shared::{FileMeta, FileUpdate, FilesService, NewFile, SdkCallCx, Services};
|
||||
use rhai::{Array, Dynamic, Engine as RhaiEngine, EvalAltResult, Map, Module};
|
||||
|
||||
/// Per-call handle captured by the Rhai SDK. Cheap to clone (two Arcs
|
||||
@@ -38,20 +36,8 @@ pub struct FilesHandle {
|
||||
cx: Arc<SdkCallCx>,
|
||||
}
|
||||
|
||||
/// §11.6 shared-collection handle, returned by `files::shared_collection(name)`.
|
||||
/// Same method surface as `FilesHandle`, but routes through the
|
||||
/// `GroupFilesService` — the owning group is resolved from `cx.app_id`'s
|
||||
/// ancestor chain inside the service (the isolation boundary).
|
||||
#[derive(Clone)]
|
||||
pub struct GroupFilesHandle {
|
||||
collection: String,
|
||||
service: Arc<dyn GroupFilesService>,
|
||||
cx: Arc<SdkCallCx>,
|
||||
}
|
||||
|
||||
pub(super) fn register(engine: &mut RhaiEngine, services: &Services, cx: Arc<SdkCallCx>) {
|
||||
let files_service = services.files.clone();
|
||||
let group_files_service = services.group_files.clone();
|
||||
|
||||
let mut module = Module::new();
|
||||
{
|
||||
@@ -71,23 +57,6 @@ pub(super) fn register(engine: &mut RhaiEngine, services: &Services, cx: Arc<Sdk
|
||||
},
|
||||
);
|
||||
}
|
||||
{
|
||||
let group_files_service = group_files_service.clone();
|
||||
let cx = cx.clone();
|
||||
module.set_native_fn(
|
||||
"shared_collection",
|
||||
move |name: &str| -> Result<GroupFilesHandle, Box<EvalAltResult>> {
|
||||
if name.is_empty() {
|
||||
return Err("files::shared_collection name must not be empty".into());
|
||||
}
|
||||
Ok(GroupFilesHandle {
|
||||
collection: name.to_string(),
|
||||
service: group_files_service.clone(),
|
||||
cx: cx.clone(),
|
||||
})
|
||||
},
|
||||
);
|
||||
}
|
||||
engine.register_static_module("files", module.into());
|
||||
|
||||
engine.register_type_with_name::<FilesHandle>("FilesHandle");
|
||||
@@ -98,15 +67,6 @@ pub(super) fn register(engine: &mut RhaiEngine, services: &Services, cx: Arc<Sdk
|
||||
register_update(engine);
|
||||
register_delete(engine);
|
||||
register_list(engine);
|
||||
|
||||
// Same method names on GroupFilesHandle — Rhai dispatches by receiver type.
|
||||
engine.register_type_with_name::<GroupFilesHandle>("GroupFilesHandle");
|
||||
register_group_create(engine);
|
||||
register_group_head(engine);
|
||||
register_group_get(engine);
|
||||
register_group_update(engine);
|
||||
register_group_delete(engine);
|
||||
register_group_list(engine);
|
||||
}
|
||||
|
||||
fn register_create(engine: &mut RhaiEngine) {
|
||||
@@ -260,163 +220,6 @@ fn list_call(
|
||||
Ok(m)
|
||||
}
|
||||
|
||||
// --- GroupFilesHandle methods (§11.6 shared files collections) -------------
|
||||
// Bodies mirror the app handle's; only the receiver type and service differ
|
||||
// (Rhai dispatches `create`/`head`/... by the handle's concrete type).
|
||||
|
||||
fn register_group_create(engine: &mut RhaiEngine) {
|
||||
engine.register_fn(
|
||||
"create",
|
||||
|handle: &mut GroupFilesHandle, meta: Map| -> Result<String, Box<EvalAltResult>> {
|
||||
let name = require_string(&meta, "name")?;
|
||||
let content_type = require_string(&meta, "content_type")?;
|
||||
let data = require_blob(&meta, "data")?;
|
||||
let h = handle.clone();
|
||||
let new = NewFile {
|
||||
name,
|
||||
content_type,
|
||||
data,
|
||||
};
|
||||
let id = block_on("files", async move {
|
||||
h.service.create(&h.cx, &h.collection, new).await
|
||||
})?;
|
||||
Ok(id.to_string())
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
fn register_group_head(engine: &mut RhaiEngine) {
|
||||
engine.register_fn(
|
||||
"head",
|
||||
|handle: &mut GroupFilesHandle, id: &str| -> Result<Dynamic, Box<EvalAltResult>> {
|
||||
let h = handle.clone();
|
||||
let id = id.to_string();
|
||||
let meta = block_on("files", async move {
|
||||
h.service.head(&h.cx, &h.collection, &id).await
|
||||
})?;
|
||||
Ok(meta.map_or(Dynamic::UNIT, |m| file_meta_to_map(&m).into()))
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
fn register_group_get(engine: &mut RhaiEngine) {
|
||||
engine.register_fn(
|
||||
"get",
|
||||
|handle: &mut GroupFilesHandle, id: &str| -> Result<Dynamic, Box<EvalAltResult>> {
|
||||
let h = handle.clone();
|
||||
let id = id.to_string();
|
||||
let bytes = block_on("files", async move {
|
||||
h.service.get(&h.cx, &h.collection, &id).await
|
||||
})?;
|
||||
Ok(bytes.map_or(Dynamic::UNIT, Dynamic::from_blob))
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
fn register_group_update(engine: &mut RhaiEngine) {
|
||||
engine.register_fn(
|
||||
"update",
|
||||
|handle: &mut GroupFilesHandle, id: &str, meta: Map| -> Result<(), Box<EvalAltResult>> {
|
||||
let data = require_blob(&meta, "data")?;
|
||||
let name = optional_string(&meta, "name")?;
|
||||
let content_type = optional_string(&meta, "content_type")?;
|
||||
let h = handle.clone();
|
||||
let id = id.to_string();
|
||||
let upd = FileUpdate {
|
||||
data,
|
||||
name,
|
||||
content_type,
|
||||
};
|
||||
block_on("files", async move {
|
||||
h.service.update(&h.cx, &h.collection, &id, upd).await
|
||||
})
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
fn register_group_delete(engine: &mut RhaiEngine) {
|
||||
engine.register_fn(
|
||||
"delete",
|
||||
|handle: &mut GroupFilesHandle, id: &str| -> Result<bool, Box<EvalAltResult>> {
|
||||
let h = handle.clone();
|
||||
let id = id.to_string();
|
||||
block_on("files", async move {
|
||||
h.service.delete(&h.cx, &h.collection, &id).await
|
||||
})
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
fn register_group_list(engine: &mut RhaiEngine) {
|
||||
engine.register_fn(
|
||||
"list",
|
||||
|handle: &mut GroupFilesHandle| -> Result<Map, Box<EvalAltResult>> {
|
||||
group_list_call(handle, None, 0)
|
||||
},
|
||||
);
|
||||
engine.register_fn(
|
||||
"list",
|
||||
|handle: &mut GroupFilesHandle, cursor: &str| -> Result<Map, Box<EvalAltResult>> {
|
||||
group_list_call(handle, Some(cursor.to_string()), 0)
|
||||
},
|
||||
);
|
||||
engine.register_fn(
|
||||
"list",
|
||||
|handle: &mut GroupFilesHandle,
|
||||
cursor: &str,
|
||||
limit: i64|
|
||||
-> Result<Map, Box<EvalAltResult>> {
|
||||
let limit = u32::try_from(limit.max(0)).unwrap_or(0);
|
||||
group_list_call(handle, Some(cursor.to_string()), limit)
|
||||
},
|
||||
);
|
||||
engine.register_fn(
|
||||
"list",
|
||||
|handle: &mut GroupFilesHandle, opts: Map| -> Result<Map, Box<EvalAltResult>> {
|
||||
let cursor = match opts.get("cursor") {
|
||||
Some(v) if !v.is_unit() => {
|
||||
Some(v.clone().into_string().map_err(|_| -> Box<EvalAltResult> {
|
||||
"files: list cursor must be a string".into()
|
||||
})?)
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
let limit = match opts.get("limit") {
|
||||
Some(v) if !v.is_unit() => {
|
||||
u32::try_from(v.as_int().unwrap_or(0).max(0)).unwrap_or(0)
|
||||
}
|
||||
_ => 0,
|
||||
};
|
||||
group_list_call(handle, cursor, limit)
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
fn group_list_call(
|
||||
handle: &GroupFilesHandle,
|
||||
cursor: Option<String>,
|
||||
limit: u32,
|
||||
) -> Result<Map, Box<EvalAltResult>> {
|
||||
let h = handle.clone();
|
||||
let page = block_on("files", async move {
|
||||
h.service
|
||||
.list(&h.cx, &h.collection, cursor.as_deref(), limit)
|
||||
.await
|
||||
})?;
|
||||
let mut m = Map::new();
|
||||
let files: Array = page
|
||||
.files
|
||||
.iter()
|
||||
.map(|meta| Dynamic::from(file_meta_to_map(meta)))
|
||||
.collect();
|
||||
m.insert("files".into(), files.into());
|
||||
m.insert(
|
||||
"next_cursor".into(),
|
||||
page.next_cursor.map_or(Dynamic::UNIT, Dynamic::from),
|
||||
);
|
||||
Ok(m)
|
||||
}
|
||||
|
||||
/// Render a `FileMeta` into the Rhai map shape scripts see from
|
||||
/// `head` / `list`.
|
||||
fn file_meta_to_map(meta: &FileMeta) -> Map {
|
||||
|
||||
@@ -28,7 +28,7 @@
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use picloud_shared::{GroupKvService, KvService, SdkCallCx, Services};
|
||||
use picloud_shared::{KvService, SdkCallCx, Services};
|
||||
use rhai::{Array, Dynamic, Engine as RhaiEngine, EvalAltResult, Map, Module};
|
||||
|
||||
use super::bridge::{block_on, dynamic_to_json, json_to_dynamic};
|
||||
@@ -42,25 +42,11 @@ pub struct KvHandle {
|
||||
cx: Arc<SdkCallCx>,
|
||||
}
|
||||
|
||||
/// §11.6 shared-collection handle, returned by `kv::shared_collection(name)`. A distinct
|
||||
/// Rhai type from `KvHandle` so the method set can diverge (e.g. shared
|
||||
/// collections never grow triggers) and a script's choice of private-vs-shared
|
||||
/// scope is explicit. Routes through the `GroupKvService`, which resolves the
|
||||
/// owning group from `cx.app_id` (the script never names a group).
|
||||
#[derive(Clone)]
|
||||
pub struct GroupKvHandle {
|
||||
collection: String,
|
||||
service: Arc<dyn GroupKvService>,
|
||||
cx: Arc<SdkCallCx>,
|
||||
}
|
||||
|
||||
pub(super) fn register(engine: &mut RhaiEngine, services: &Services, cx: Arc<SdkCallCx>) {
|
||||
let kv_service = services.kv.clone();
|
||||
let group_kv_service = services.group_kv.clone();
|
||||
|
||||
// `kv::collection(name)` / `kv::shared_collection(name)` — both constructors live in
|
||||
// the `kv` static module so the script-visible calls are `kv::collection`
|
||||
// and `kv::shared`.
|
||||
// `kv::collection(name)` — handle constructor lives in the `kv`
|
||||
// static module so the script-visible call is `kv::collection(...)`.
|
||||
let mut module = Module::new();
|
||||
{
|
||||
let kv_service = kv_service.clone();
|
||||
@@ -79,23 +65,6 @@ pub(super) fn register(engine: &mut RhaiEngine, services: &Services, cx: Arc<Sdk
|
||||
},
|
||||
);
|
||||
}
|
||||
{
|
||||
let group_kv_service = group_kv_service.clone();
|
||||
let cx = cx.clone();
|
||||
module.set_native_fn(
|
||||
"shared_collection",
|
||||
move |name: &str| -> Result<GroupKvHandle, Box<EvalAltResult>> {
|
||||
if name.is_empty() {
|
||||
return Err("kv::shared_collection name must not be empty".into());
|
||||
}
|
||||
Ok(GroupKvHandle {
|
||||
collection: name.to_string(),
|
||||
service: group_kv_service.clone(),
|
||||
cx: cx.clone(),
|
||||
})
|
||||
},
|
||||
);
|
||||
}
|
||||
engine.register_static_module("kv", module.into());
|
||||
|
||||
// Methods on KvHandle — `register_fn` with `&mut KvHandle` first
|
||||
@@ -108,15 +77,6 @@ pub(super) fn register(engine: &mut RhaiEngine, services: &Services, cx: Arc<Sdk
|
||||
register_has(engine);
|
||||
register_delete(engine);
|
||||
register_list(engine);
|
||||
|
||||
// Same method names on GroupKvHandle — Rhai dispatches by receiver type.
|
||||
engine.register_type_with_name::<GroupKvHandle>("GroupKvHandle");
|
||||
|
||||
register_group_get(engine);
|
||||
register_group_set(engine);
|
||||
register_group_has(engine);
|
||||
register_group_delete(engine);
|
||||
register_group_list(engine);
|
||||
}
|
||||
|
||||
fn register_get(engine: &mut RhaiEngine) {
|
||||
@@ -214,98 +174,3 @@ fn list_call(
|
||||
);
|
||||
Ok(m)
|
||||
}
|
||||
|
||||
// --- GroupKvHandle methods (§11.6 shared collections) ----------------------
|
||||
|
||||
fn register_group_get(engine: &mut RhaiEngine) {
|
||||
engine.register_fn(
|
||||
"get",
|
||||
|handle: &mut GroupKvHandle, key: &str| -> Result<Dynamic, Box<EvalAltResult>> {
|
||||
let h = handle.clone();
|
||||
block_on("kv", async move {
|
||||
h.service.get(&h.cx, &h.collection, key).await
|
||||
})
|
||||
.map(|opt| opt.map_or(Dynamic::UNIT, json_to_dynamic))
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
fn register_group_set(engine: &mut RhaiEngine) {
|
||||
engine.register_fn(
|
||||
"set",
|
||||
|handle: &mut GroupKvHandle, key: &str, value: Dynamic| -> Result<(), Box<EvalAltResult>> {
|
||||
let h = handle.clone();
|
||||
let json = dynamic_to_json(&value);
|
||||
block_on("kv", async move {
|
||||
h.service.set(&h.cx, &h.collection, key, json).await
|
||||
})
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
fn register_group_has(engine: &mut RhaiEngine) {
|
||||
engine.register_fn(
|
||||
"has",
|
||||
|handle: &mut GroupKvHandle, key: &str| -> Result<bool, Box<EvalAltResult>> {
|
||||
let h = handle.clone();
|
||||
block_on("kv", async move {
|
||||
h.service.has(&h.cx, &h.collection, key).await
|
||||
})
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
fn register_group_delete(engine: &mut RhaiEngine) {
|
||||
engine.register_fn(
|
||||
"delete",
|
||||
|handle: &mut GroupKvHandle, key: &str| -> Result<bool, Box<EvalAltResult>> {
|
||||
let h = handle.clone();
|
||||
block_on("kv", async move {
|
||||
h.service.delete(&h.cx, &h.collection, key).await
|
||||
})
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
fn register_group_list(engine: &mut RhaiEngine) {
|
||||
engine.register_fn(
|
||||
"list",
|
||||
|handle: &mut GroupKvHandle| -> Result<Map, Box<EvalAltResult>> {
|
||||
group_list_call(handle, None, 0)
|
||||
},
|
||||
);
|
||||
engine.register_fn(
|
||||
"list",
|
||||
|handle: &mut GroupKvHandle, cursor: &str| -> Result<Map, Box<EvalAltResult>> {
|
||||
group_list_call(handle, Some(cursor.to_string()), 0)
|
||||
},
|
||||
);
|
||||
engine.register_fn(
|
||||
"list",
|
||||
|handle: &mut GroupKvHandle, cursor: &str, limit: i64| -> Result<Map, Box<EvalAltResult>> {
|
||||
let limit = u32::try_from(limit.max(0)).unwrap_or(0);
|
||||
group_list_call(handle, Some(cursor.to_string()), limit)
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
fn group_list_call(
|
||||
handle: &GroupKvHandle,
|
||||
cursor: Option<String>,
|
||||
limit: u32,
|
||||
) -> Result<Map, Box<EvalAltResult>> {
|
||||
let h = handle.clone();
|
||||
let page = block_on("kv", async move {
|
||||
h.service
|
||||
.list(&h.cx, &h.collection, cursor.as_deref(), limit)
|
||||
.await
|
||||
})?;
|
||||
let mut m = Map::new();
|
||||
let keys: Array = page.keys.into_iter().map(Dynamic::from).collect();
|
||||
m.insert("keys".into(), keys.into());
|
||||
m.insert(
|
||||
"next_cursor".into(),
|
||||
page.next_cursor.map_or(Dynamic::UNIT, Dynamic::from),
|
||||
);
|
||||
Ok(m)
|
||||
}
|
||||
|
||||
@@ -69,71 +69,7 @@ pub(super) fn register(engine: &mut RhaiEngine, services: &Services, cx: Arc<Sdk
|
||||
},
|
||||
);
|
||||
}
|
||||
// §11.6 D2: `pubsub::shared_topic("name")` → a handle whose `.publish(...)`
|
||||
// publishes to the group's shared topic namespace (fans out to `shared`
|
||||
// group pubsub triggers on the owning group). The owning group resolves from
|
||||
// `cx.app_id` inside the service — never a script arg.
|
||||
{
|
||||
let group_svc = services.group_pubsub.clone();
|
||||
let cx = cx.clone();
|
||||
module.set_native_fn(
|
||||
"shared_topic",
|
||||
move |name: &str| -> Result<GroupTopicHandle, Box<EvalAltResult>> {
|
||||
if name.is_empty() {
|
||||
return Err("pubsub::shared_topic name must not be empty".into());
|
||||
}
|
||||
Ok(GroupTopicHandle {
|
||||
namespace: name.to_string(),
|
||||
service: group_svc.clone(),
|
||||
cx: cx.clone(),
|
||||
})
|
||||
},
|
||||
);
|
||||
}
|
||||
engine.register_static_module("pubsub", module.into());
|
||||
engine.register_type_with_name::<GroupTopicHandle>("GroupTopicHandle");
|
||||
register_shared_publish(engine);
|
||||
}
|
||||
|
||||
/// §11.6 D2 handle returned by `pubsub::shared_topic("name")`. `.publish(sub,
|
||||
/// msg)` publishes `name.sub` to the group shared topic; `.publish(msg)`
|
||||
/// publishes to `name` directly.
|
||||
#[derive(Clone)]
|
||||
pub struct GroupTopicHandle {
|
||||
namespace: String,
|
||||
service: Arc<dyn picloud_shared::GroupPubsubService>,
|
||||
cx: Arc<SdkCallCx>,
|
||||
}
|
||||
|
||||
fn shared_publish(
|
||||
h: &mut GroupTopicHandle,
|
||||
subtopic: &str,
|
||||
message: Dynamic,
|
||||
) -> Result<(), Box<EvalAltResult>> {
|
||||
let json = message_to_json(&message);
|
||||
let service = h.service.clone();
|
||||
let cx = h.cx.clone();
|
||||
let namespace = h.namespace.clone();
|
||||
let subtopic = subtopic.to_string();
|
||||
block_on("pubsub", async move {
|
||||
service.publish(&cx, &namespace, &subtopic, json).await
|
||||
})
|
||||
// The fan-out count isn't surfaced to scripts (fire-and-forget, like the
|
||||
// per-app publish).
|
||||
.map(|_n: u32| ())
|
||||
}
|
||||
|
||||
fn register_shared_publish(engine: &mut RhaiEngine) {
|
||||
engine.register_fn(
|
||||
"publish",
|
||||
|h: &mut GroupTopicHandle, subtopic: &str, message: Dynamic| {
|
||||
shared_publish(h, subtopic, message)
|
||||
},
|
||||
);
|
||||
// `.publish(msg)` with no subtopic → publish to the namespace directly.
|
||||
engine.register_fn("publish", |h: &mut GroupTopicHandle, message: Dynamic| {
|
||||
shared_publish(h, "", message)
|
||||
});
|
||||
}
|
||||
|
||||
/// Interpret the optional `ttl` argument: `()` → use the default,
|
||||
|
||||
@@ -20,9 +20,7 @@ use std::sync::Arc;
|
||||
|
||||
use base64::engine::general_purpose::STANDARD;
|
||||
use base64::Engine as _;
|
||||
use picloud_shared::{
|
||||
EnqueueOpts, GroupQueueService, QueueError, QueueService, SdkCallCx, Services,
|
||||
};
|
||||
use picloud_shared::{EnqueueOpts, QueueError, QueueService, SdkCallCx, Services};
|
||||
use rhai::{Array, Dynamic, Engine as RhaiEngine, EvalAltResult, Map, Module};
|
||||
use serde_json::Value as Json;
|
||||
use tokio::runtime::Handle as TokioHandle;
|
||||
@@ -89,106 +87,7 @@ pub(super) fn register(engine: &mut RhaiEngine, services: &Services, cx: Arc<Sdk
|
||||
);
|
||||
}
|
||||
|
||||
// §11.6 D3: `queue::shared_collection("name")` → a handle over the group
|
||||
// shared queue (any subtree app enqueues into one group-owned store;
|
||||
// competing per-descendant consumers drain it). The owning group resolves
|
||||
// from `cx.app_id` inside the service — never a script arg.
|
||||
{
|
||||
let group_svc = services.group_queue.clone();
|
||||
let cx = cx.clone();
|
||||
module.set_native_fn(
|
||||
"shared_collection",
|
||||
move |name: &str| -> Result<GroupQueueHandle, Box<EvalAltResult>> {
|
||||
if name.is_empty() {
|
||||
return Err("queue::shared_collection name must not be empty".into());
|
||||
}
|
||||
Ok(GroupQueueHandle {
|
||||
collection: name.to_string(),
|
||||
service: group_svc.clone(),
|
||||
cx: cx.clone(),
|
||||
})
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
engine.register_static_module("queue", module.into());
|
||||
engine.register_type_with_name::<GroupQueueHandle>("GroupQueueHandle");
|
||||
register_shared_queue_methods(engine);
|
||||
}
|
||||
|
||||
/// §11.6 D3 handle returned by `queue::shared_collection("name")`.
|
||||
#[derive(Clone)]
|
||||
pub struct GroupQueueHandle {
|
||||
collection: String,
|
||||
service: Arc<dyn GroupQueueService>,
|
||||
cx: Arc<SdkCallCx>,
|
||||
}
|
||||
|
||||
fn group_enqueue(
|
||||
h: &mut GroupQueueHandle,
|
||||
message: Dynamic,
|
||||
opts: EnqueueOpts,
|
||||
) -> Result<(), Box<EvalAltResult>> {
|
||||
let json = message_to_json(&message)?;
|
||||
let handle = TokioHandle::try_current().map_err(|e| -> Box<EvalAltResult> {
|
||||
EvalAltResult::ErrorRuntime(
|
||||
format!("queue: no tokio runtime available: {e}").into(),
|
||||
rhai::Position::NONE,
|
||||
)
|
||||
.into()
|
||||
})?;
|
||||
let service = h.service.clone();
|
||||
let cx = h.cx.clone();
|
||||
let collection = h.collection.clone();
|
||||
handle
|
||||
.block_on(async move { service.enqueue(&cx, &collection, json, opts).await })
|
||||
.map(|_id| ())
|
||||
.map_err(|err| -> Box<EvalAltResult> {
|
||||
EvalAltResult::ErrorRuntime(format!("queue: {err}").into(), rhai::Position::NONE).into()
|
||||
})
|
||||
}
|
||||
|
||||
fn group_depth<F, Fut>(h: &mut GroupQueueHandle, f: F) -> Result<i64, Box<EvalAltResult>>
|
||||
where
|
||||
F: FnOnce(Arc<dyn GroupQueueService>, Arc<SdkCallCx>, String) -> Fut,
|
||||
Fut: std::future::Future<Output = Result<u64, picloud_shared::GroupQueueError>>,
|
||||
{
|
||||
let handle = TokioHandle::try_current().map_err(|e| -> Box<EvalAltResult> {
|
||||
EvalAltResult::ErrorRuntime(
|
||||
format!("queue: no tokio runtime available: {e}").into(),
|
||||
rhai::Position::NONE,
|
||||
)
|
||||
.into()
|
||||
})?;
|
||||
let fut = f(h.service.clone(), h.cx.clone(), h.collection.clone());
|
||||
let n = handle.block_on(fut).map_err(|err| -> Box<EvalAltResult> {
|
||||
EvalAltResult::ErrorRuntime(format!("queue: {err}").into(), rhai::Position::NONE).into()
|
||||
})?;
|
||||
Ok(i64::try_from(n).unwrap_or(i64::MAX))
|
||||
}
|
||||
|
||||
fn register_shared_queue_methods(engine: &mut RhaiEngine) {
|
||||
engine.register_fn("enqueue", |h: &mut GroupQueueHandle, message: Dynamic| {
|
||||
group_enqueue(h, message, EnqueueOpts::default())
|
||||
});
|
||||
engine.register_fn(
|
||||
"enqueue",
|
||||
|h: &mut GroupQueueHandle, message: Dynamic, opts: Map| {
|
||||
let opts = parse_opts(&opts)?;
|
||||
group_enqueue(h, message, opts)
|
||||
},
|
||||
);
|
||||
engine.register_fn("depth", |h: &mut GroupQueueHandle| {
|
||||
group_depth(
|
||||
h,
|
||||
|svc, cx, coll| async move { svc.depth(&cx, &coll).await },
|
||||
)
|
||||
});
|
||||
engine.register_fn("depth_pending", |h: &mut GroupQueueHandle| {
|
||||
group_depth(h, |svc, cx, coll| async move {
|
||||
svc.depth_pending(&cx, &coll).await
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
/// Run the async enqueue inside the synchronous Rhai context. Mirrors
|
||||
|
||||
@@ -34,6 +34,22 @@ impl ModuleSource for FailingSource {
|
||||
) -> Result<Option<ModuleScript>, ModuleSourceError> {
|
||||
Err(ModuleSourceError::Backend(SENTINEL.to_string()))
|
||||
}
|
||||
|
||||
async fn resolve_extension_point(
|
||||
&self,
|
||||
_origin: ScriptOwner,
|
||||
_name: &str,
|
||||
) -> Result<Option<picloud_shared::ExtPointResolution>, ModuleSourceError> {
|
||||
Err(ModuleSourceError::Backend(SENTINEL.to_string()))
|
||||
}
|
||||
|
||||
async fn resolve_by_id(
|
||||
&self,
|
||||
_origin: ScriptOwner,
|
||||
_script_id: picloud_shared::ScriptId,
|
||||
) -> Result<Option<ModuleScript>, ModuleSourceError> {
|
||||
Err(ModuleSourceError::Backend(SENTINEL.to_string()))
|
||||
}
|
||||
}
|
||||
|
||||
/// `MakeWriter` that appends to a shared buffer.
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
//! verify the same code path the `picloud` binary runs at request
|
||||
//! time.
|
||||
|
||||
use std::collections::{BTreeMap, HashMap, HashSet};
|
||||
use std::collections::{BTreeMap, HashMap};
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
use std::sync::Arc;
|
||||
|
||||
@@ -95,6 +95,30 @@ impl ModuleSource for CountingModuleSource {
|
||||
.get(&(app_id, name.to_string()))
|
||||
.cloned())
|
||||
}
|
||||
|
||||
async fn resolve_extension_point(
|
||||
&self,
|
||||
_origin: ScriptOwner,
|
||||
_name: &str,
|
||||
) -> Result<Option<picloud_shared::ExtPointResolution>, ModuleSourceError> {
|
||||
// This flat fake has no extension points; the inversion is covered by
|
||||
// `LexicalModuleSource` below and the Postgres journey tests.
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
async fn resolve_by_id(
|
||||
&self,
|
||||
_origin: ScriptOwner,
|
||||
script_id: ScriptId,
|
||||
) -> Result<Option<ModuleScript>, ModuleSourceError> {
|
||||
Ok(self
|
||||
.table
|
||||
.lock()
|
||||
.await
|
||||
.values()
|
||||
.find(|m| m.script_id == script_id)
|
||||
.cloned())
|
||||
}
|
||||
}
|
||||
|
||||
fn services_with(modules: Arc<dyn ModuleSource>) -> Services {
|
||||
@@ -623,6 +647,10 @@ fn validate_endpoint_skips_dynamic_imports_in_imports_list() {
|
||||
#[derive(Default)]
|
||||
struct LexicalModuleSource {
|
||||
table: Mutex<HashMap<(ScriptOwner, String), ModuleScript>>,
|
||||
/// Extension-point declarations, keyed by exact declaring owner →
|
||||
/// optional default module id. Flat (exact-owner) — the chain-walk +
|
||||
/// shadowing tie-break is covered by the Postgres journey tests.
|
||||
ext_points: Mutex<HashMap<(ScriptOwner, String), Option<ScriptId>>>,
|
||||
}
|
||||
|
||||
impl LexicalModuleSource {
|
||||
@@ -630,15 +658,16 @@ impl LexicalModuleSource {
|
||||
Arc::new(Self::default())
|
||||
}
|
||||
|
||||
async fn put(self: &Arc<Self>, owner: ScriptOwner, name: &str, source: &str) {
|
||||
async fn put(self: &Arc<Self>, owner: ScriptOwner, name: &str, source: &str) -> ScriptId {
|
||||
let (app_id, group_id) = match owner {
|
||||
ScriptOwner::App(a) => (Some(a), None),
|
||||
ScriptOwner::Group(g) => (None, Some(g)),
|
||||
};
|
||||
let script_id = ScriptId::new();
|
||||
self.table.lock().await.insert(
|
||||
(owner, name.to_string()),
|
||||
ModuleScript {
|
||||
script_id: ScriptId::new(),
|
||||
script_id,
|
||||
app_id,
|
||||
group_id,
|
||||
name: name.to_string(),
|
||||
@@ -646,6 +675,21 @@ impl LexicalModuleSource {
|
||||
updated_at: Utc::now(),
|
||||
},
|
||||
);
|
||||
script_id
|
||||
}
|
||||
|
||||
/// Declare `name` an extension point at `owner`, with an optional default
|
||||
/// module body id.
|
||||
async fn put_ext_point(
|
||||
self: &Arc<Self>,
|
||||
owner: ScriptOwner,
|
||||
name: &str,
|
||||
default_script_id: Option<ScriptId>,
|
||||
) {
|
||||
self.ext_points
|
||||
.lock()
|
||||
.await
|
||||
.insert((owner, name.to_string()), default_script_id);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -663,6 +707,35 @@ impl ModuleSource for LexicalModuleSource {
|
||||
.get(&(origin, name.to_string()))
|
||||
.cloned())
|
||||
}
|
||||
|
||||
async fn resolve_extension_point(
|
||||
&self,
|
||||
origin: ScriptOwner,
|
||||
name: &str,
|
||||
) -> Result<Option<picloud_shared::ExtPointResolution>, ModuleSourceError> {
|
||||
Ok(self
|
||||
.ext_points
|
||||
.lock()
|
||||
.await
|
||||
.get(&(origin, name.to_string()))
|
||||
.map(|default_script_id| picloud_shared::ExtPointResolution {
|
||||
default_script_id: *default_script_id,
|
||||
}))
|
||||
}
|
||||
|
||||
async fn resolve_by_id(
|
||||
&self,
|
||||
_origin: ScriptOwner,
|
||||
script_id: ScriptId,
|
||||
) -> Result<Option<ModuleScript>, ModuleSourceError> {
|
||||
Ok(self
|
||||
.table
|
||||
.lock()
|
||||
.await
|
||||
.values()
|
||||
.find(|m| m.script_id == script_id)
|
||||
.cloned())
|
||||
}
|
||||
}
|
||||
|
||||
fn req_with_owner(app_id: AppId, owner: ScriptOwner) -> ExecRequest {
|
||||
@@ -738,148 +811,172 @@ async fn lexical_entry_origin_selects_app_module() {
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// §5.5 extension points — resolver handling of the policy outcomes.
|
||||
//
|
||||
// `PolicyModuleSource` is a flat fake that overrides `resolve_policy`: an EP
|
||||
// name resolves dynamically against the inheriting app; a non-EP name resolves
|
||||
// lexically from the importing origin. This verifies the resolver maps
|
||||
// Module/NoProvider/NotFound correctly. The full chain semantics (default body
|
||||
// up-chain, nearest-declaration tie) are covered by the CLI journey tests.
|
||||
// Extension points (§5.5) — the opt-in resolution inversion. A group declares
|
||||
// a module name an extension point; an importer on the group's chain resolves
|
||||
// it against the INHERITING APP, not the sealed lexical chain.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Default)]
|
||||
struct PolicyModuleSource {
|
||||
modules: Mutex<HashMap<(ScriptOwner, String), ModuleScript>>,
|
||||
eps: Mutex<HashSet<String>>,
|
||||
}
|
||||
|
||||
impl PolicyModuleSource {
|
||||
fn new() -> Arc<Self> {
|
||||
Arc::new(Self::default())
|
||||
}
|
||||
async fn put(self: &Arc<Self>, owner: ScriptOwner, name: &str, source: &str) {
|
||||
let (app_id, group_id) = match owner {
|
||||
ScriptOwner::App(a) => (Some(a), None),
|
||||
ScriptOwner::Group(g) => (None, Some(g)),
|
||||
};
|
||||
self.modules.lock().await.insert(
|
||||
(owner, name.to_string()),
|
||||
ModuleScript {
|
||||
script_id: ScriptId::new(),
|
||||
app_id,
|
||||
group_id,
|
||||
name: name.to_string(),
|
||||
source: source.to_string(),
|
||||
updated_at: Utc::now(),
|
||||
},
|
||||
);
|
||||
}
|
||||
async fn mark_ep(self: &Arc<Self>, name: &str) {
|
||||
self.eps.lock().await.insert(name.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl ModuleSource for PolicyModuleSource {
|
||||
async fn resolve(
|
||||
&self,
|
||||
origin: ScriptOwner,
|
||||
name: &str,
|
||||
) -> Result<Option<ModuleScript>, ModuleSourceError> {
|
||||
Ok(self
|
||||
.modules
|
||||
.lock()
|
||||
.await
|
||||
.get(&(origin, name.to_string()))
|
||||
.cloned())
|
||||
}
|
||||
|
||||
async fn resolve_policy(
|
||||
&self,
|
||||
origin: ScriptOwner,
|
||||
inheriting_app: AppId,
|
||||
name: &str,
|
||||
) -> Result<picloud_shared::ModuleResolution, ModuleSourceError> {
|
||||
use picloud_shared::ModuleResolution;
|
||||
if self.eps.lock().await.contains(name) {
|
||||
// Extension point → dynamic, resolved against the inheriting app.
|
||||
return Ok(
|
||||
match self.resolve(ScriptOwner::App(inheriting_app), name).await? {
|
||||
Some(m) => ModuleResolution::Module(m),
|
||||
None => ModuleResolution::NoProvider,
|
||||
},
|
||||
);
|
||||
}
|
||||
Ok(match self.resolve(origin, name).await? {
|
||||
Some(m) => ModuleResolution::Module(m),
|
||||
None => ModuleResolution::NotFound,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// An extension-point import binds the inheriting APP's module even though the
|
||||
/// importing script's defining node is the group (dynamic override — the
|
||||
/// inverse of the Phase 4b sealed/lexical import).
|
||||
/// A group script importing a declared extension point resolves it against the
|
||||
/// executing app's own module — each tenant supplies its own body.
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn extension_point_resolves_app_override() {
|
||||
let source = PolicyModuleSource::new();
|
||||
async fn ext_point_resolves_to_app_module() {
|
||||
let source = LexicalModuleSource::new();
|
||||
let app = AppId::new();
|
||||
let group = GroupId::new();
|
||||
source.mark_ep("theme").await;
|
||||
// App provides its own `theme`; group has none.
|
||||
// The group opens "theme" as an extension point (no default).
|
||||
source
|
||||
.put(ScriptOwner::App(app), "theme", r#"fn color() { "red" }"#)
|
||||
.put_ext_point(ScriptOwner::Group(group), "theme", None)
|
||||
.await;
|
||||
// The app provides its own "theme".
|
||||
source
|
||||
.put(
|
||||
ScriptOwner::App(app),
|
||||
"theme",
|
||||
r#"fn name() { "app-theme" }"#,
|
||||
)
|
||||
.await;
|
||||
|
||||
let engine = engine_with(source.clone());
|
||||
// Entry runs as the inherited GROUP endpoint importing the EP `theme`.
|
||||
// Entry runs as the inherited GROUP script (default_origin = group), but
|
||||
// the executing app is `app` (cx.app_id).
|
||||
let resp = engine
|
||||
.execute(
|
||||
r#"import "theme" as t; t::color()"#,
|
||||
r#"import "theme" as t; t::name()"#,
|
||||
req_with_owner(app, ScriptOwner::Group(group)),
|
||||
)
|
||||
.expect("should execute");
|
||||
assert_eq!(resp.body, serde_json::json!("red"));
|
||||
assert_eq!(
|
||||
resp.body,
|
||||
serde_json::json!("app-theme"),
|
||||
"extension point must resolve to the inheriting app's module"
|
||||
);
|
||||
}
|
||||
|
||||
/// An extension point with no provider for the app is a hard error.
|
||||
/// When the app provides no module for the extension point, the declared
|
||||
/// default body is used.
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn extension_point_without_provider_errors() {
|
||||
let source = PolicyModuleSource::new();
|
||||
async fn ext_point_falls_back_to_default() {
|
||||
let source = LexicalModuleSource::new();
|
||||
let app = AppId::new();
|
||||
let group = GroupId::new();
|
||||
source.mark_ep("theme").await; // declared, but no module anywhere.
|
||||
// The group's default "theme" body, registered as a group module.
|
||||
let default_id = source
|
||||
.put(
|
||||
ScriptOwner::Group(group),
|
||||
"default-theme",
|
||||
r#"fn name() { "default-theme" }"#,
|
||||
)
|
||||
.await;
|
||||
source
|
||||
.put_ext_point(ScriptOwner::Group(group), "theme", Some(default_id))
|
||||
.await;
|
||||
// The app provides NO "theme".
|
||||
|
||||
let engine = engine_with(source.clone());
|
||||
let resp = engine
|
||||
.execute(
|
||||
r#"import "theme" as t; t::name()"#,
|
||||
req_with_owner(app, ScriptOwner::Group(group)),
|
||||
)
|
||||
.expect("should execute");
|
||||
assert_eq!(resp.body, serde_json::json!("default-theme"));
|
||||
}
|
||||
|
||||
/// An extension point with neither an app provider nor a default is a clean
|
||||
/// module-not-found at import time.
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn ext_point_no_provider_no_default_errors() {
|
||||
let source = LexicalModuleSource::new();
|
||||
let app = AppId::new();
|
||||
let group = GroupId::new();
|
||||
source
|
||||
.put_ext_point(ScriptOwner::Group(group), "theme", None)
|
||||
.await;
|
||||
|
||||
let engine = engine_with(source.clone());
|
||||
let err = engine
|
||||
.execute(
|
||||
r#"import "theme" as t; t::color()"#,
|
||||
r#"import "theme" as t; t::name()"#,
|
||||
req_with_owner(app, ScriptOwner::Group(group)),
|
||||
)
|
||||
.expect_err("missing provider must error");
|
||||
.expect_err("ext point with no provider/default should fail");
|
||||
let msg = format!("{err:?}").to_lowercase();
|
||||
assert!(
|
||||
msg.contains("no provider") || msg.contains("extension point"),
|
||||
"expected a no-provider error, got {msg}"
|
||||
msg.contains("module") || msg.contains("not found") || msg.contains("theme"),
|
||||
"expected module-not-found-flavoured error, got {msg}"
|
||||
);
|
||||
}
|
||||
|
||||
/// A non-extension-point name still resolves lexically from the origin.
|
||||
/// A NORMAL (non-extension-point) group import is NOT hijacked by a leaf app's
|
||||
/// module of the same name — the inversion only fires for declared extension
|
||||
/// points, so the trust boundary holds.
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn non_extension_point_stays_lexical() {
|
||||
let source = PolicyModuleSource::new();
|
||||
async fn sealed_import_not_hijacked_by_leaf_when_not_ext_point() {
|
||||
let source = LexicalModuleSource::new();
|
||||
let app = AppId::new();
|
||||
let group = GroupId::new();
|
||||
// "auth" is a concrete group module — NOT an extension point.
|
||||
source
|
||||
.put(ScriptOwner::Group(group), "util", r#"fn v() { 7 }"#)
|
||||
.put(
|
||||
ScriptOwner::Group(group),
|
||||
"auth",
|
||||
r#"fn who() { "group-auth" }"#,
|
||||
)
|
||||
.await;
|
||||
// The app has a trap "auth" that must NOT be selected.
|
||||
source
|
||||
.put(ScriptOwner::App(app), "auth", r#"fn who() { "leaf-trap" }"#)
|
||||
.await;
|
||||
|
||||
let engine = engine_with(source.clone());
|
||||
let resp = engine
|
||||
.execute(
|
||||
r#"import "util" as u; u::v()"#,
|
||||
r#"import "auth" as a; a::who()"#,
|
||||
req_with_owner(app, ScriptOwner::Group(group)),
|
||||
)
|
||||
.expect("should execute");
|
||||
assert_eq!(resp.body, serde_json::json!(7));
|
||||
assert_eq!(
|
||||
resp.body,
|
||||
serde_json::json!("group-auth"),
|
||||
"a non-ext-point import must seal to the group, never the leaf's trap"
|
||||
);
|
||||
}
|
||||
|
||||
/// Two apps inheriting the same extension point get their OWN bodies — the
|
||||
/// id-keyed module cache does not bleed one tenant's body into another.
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn ext_point_two_apps_get_distinct_bodies() {
|
||||
let source = LexicalModuleSource::new();
|
||||
let app1 = AppId::new();
|
||||
let app2 = AppId::new();
|
||||
let group = GroupId::new();
|
||||
source
|
||||
.put_ext_point(ScriptOwner::Group(group), "theme", None)
|
||||
.await;
|
||||
source
|
||||
.put(ScriptOwner::App(app1), "theme", r#"fn name() { "one" }"#)
|
||||
.await;
|
||||
source
|
||||
.put(ScriptOwner::App(app2), "theme", r#"fn name() { "two" }"#)
|
||||
.await;
|
||||
|
||||
let engine = engine_with(source.clone());
|
||||
let r1 = engine
|
||||
.execute(
|
||||
r#"import "theme" as t; t::name()"#,
|
||||
req_with_owner(app1, ScriptOwner::Group(group)),
|
||||
)
|
||||
.expect("app1 executes");
|
||||
let r2 = engine
|
||||
.execute(
|
||||
r#"import "theme" as t; t::name()"#,
|
||||
req_with_owner(app2, ScriptOwner::Group(group)),
|
||||
)
|
||||
.expect("app2 executes");
|
||||
assert_eq!(r1.body, serde_json::json!("one"));
|
||||
assert_eq!(
|
||||
r2.body,
|
||||
serde_json::json!("two"),
|
||||
"no cross-tenant cache bleed"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,40 +1,49 @@
|
||||
-- §5.5 (v1.2 Hierarchies): extension-point markers — opt-in module polymorphism.
|
||||
-- Phase 4b+1 (v1.2 Hierarchies): extension points (§5.5).
|
||||
--
|
||||
-- An extension point marks a module NAME at a node (app or group) as one
|
||||
-- descendants are *expected* to provide or override. Imports of an EP name
|
||||
-- resolve DYNAMICALLY against the inheriting app's view (the app can supply
|
||||
-- its own module), instead of the Phase 4b lexical default (sealed to the
|
||||
-- importing script's defining node). See docs §5.5.
|
||||
-- An extension point opts a MODULE NAME into dynamic, per-tenant resolution.
|
||||
-- Normally a group/parent script's `import "x"` resolves sealed-lexically
|
||||
-- against the importer's own defining node — a leaf app cannot shadow it
|
||||
-- (the trust boundary). When a node declares `x` an extension point, an
|
||||
-- importer whose defining node is on that node's chain instead resolves `x`
|
||||
-- against the INHERITING app's effective view, so each descendant app may
|
||||
-- supply its own `x`. Only the declaring (parent) node can open the hole; a
|
||||
-- descendant cannot make one of its parent's sealed imports dynamic, because
|
||||
-- the declaration must live on the importer's own ancestry.
|
||||
--
|
||||
-- This table holds only the MARKER (owner, name) — it is pure declaration,
|
||||
-- structurally identical to a `secrets` name. The optional DEFAULT BODY is
|
||||
-- just a co-located `kind = 'module'` script of the same name at this node
|
||||
-- (Phase 4b already stores/resolves/caches those); there is no body column
|
||||
-- here. So a marker is config, not code → ON DELETE CASCADE (unlike the
|
||||
-- module body's RESTRICT in 0050).
|
||||
--
|
||||
-- Ownership is polymorphic (mirrors vars/secrets/scripts): exactly one of
|
||||
-- (app_id, group_id) is set, with per-owner partial-unique LOWER(name)
|
||||
-- indexes for case-insensitive uniqueness.
|
||||
-- Owner-polymorphic exactly like vars (0048): exactly one of group_id/app_id.
|
||||
-- Optional `default_script_id` is the fallback module body used when the
|
||||
-- inheriting app provides no module of `name`.
|
||||
|
||||
CREATE TABLE extension_points (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
group_id UUID REFERENCES groups(id) ON DELETE CASCADE,
|
||||
app_id UUID REFERENCES apps(id) ON DELETE CASCADE,
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
-- Polymorphic owner: exactly one FK set (real FKs so ON DELETE CASCADE
|
||||
-- works per owner kind and a dangling owner is impossible).
|
||||
group_id UUID REFERENCES groups(id) ON DELETE CASCADE,
|
||||
app_id UUID REFERENCES apps(id) ON DELETE CASCADE,
|
||||
CONSTRAINT extension_points_owner_exactly_one
|
||||
CHECK ((group_id IS NULL) <> (app_id IS NULL)),
|
||||
name TEXT NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
name TEXT NOT NULL,
|
||||
-- Optional fallback module (a module owned by the declaring node). SET
|
||||
-- NULL on delete so deleting the module can never block — the fallback is
|
||||
-- then absent and a non-providing app errors at import time (the next plan
|
||||
-- shows the now-null default as an Update). A valid manifest apply can't
|
||||
-- reach this: validation rejects a kept ext point whose default names a
|
||||
-- module not in the bundle.
|
||||
default_script_id UUID REFERENCES scripts(id) ON DELETE SET NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- One marker per (owner, name), case-insensitive. Partial because the owner
|
||||
-- is split across two nullable columns.
|
||||
-- One declaration per (owner, name), case-insensitive to match the module
|
||||
-- name indexes (0050). Partial because the owner is split across two columns.
|
||||
CREATE UNIQUE INDEX extension_points_group_uidx
|
||||
ON extension_points (group_id, LOWER(name)) WHERE group_id IS NOT NULL;
|
||||
CREATE UNIQUE INDEX extension_points_app_uidx
|
||||
ON extension_points (app_id, LOWER(name)) WHERE app_id IS NOT NULL;
|
||||
|
||||
-- Lookup indexes for the resolver's chain join + list-by-owner.
|
||||
CREATE INDEX extension_points_group_id_idx ON extension_points (group_id) WHERE group_id IS NOT NULL;
|
||||
CREATE INDEX extension_points_app_id_idx ON extension_points (app_id) WHERE app_id IS NOT NULL;
|
||||
CREATE INDEX extension_points_group_id_idx
|
||||
ON extension_points (group_id) WHERE group_id IS NOT NULL;
|
||||
CREATE INDEX extension_points_app_id_idx
|
||||
ON extension_points (app_id) WHERE app_id IS NOT NULL;
|
||||
CREATE INDEX extension_points_default_script_idx
|
||||
ON extension_points (default_script_id) WHERE default_script_id IS NOT NULL;
|
||||
|
||||
@@ -1,47 +0,0 @@
|
||||
-- §11.6 (v1.2 Hierarchies): group-collection registry — shared cross-app data.
|
||||
--
|
||||
-- A row marks a collection NAME at a node as group-SHARED: every app in the
|
||||
-- owning group's subtree may read it (and, with an authenticated editor+,
|
||||
-- write it) via the explicit `kv::shared_collection("name")` SDK handle. Resolution is
|
||||
-- nearest-ancestor-group-wins, walking the reading app's chain — that ancestry
|
||||
-- walk is the isolation boundary (a foreign app's chain never contains the
|
||||
-- owning group, so the name simply does not resolve). See docs §11.6.
|
||||
--
|
||||
-- This table holds only the MARKER (owner, name, kind) — the data itself lives
|
||||
-- in a per-kind storage table (`group_kv_entries`, 0053, for kind='kv'). It is
|
||||
-- pure declaration, structurally identical to an `extension_points` marker
|
||||
-- (0051). A marker is config, not code → ON DELETE CASCADE.
|
||||
--
|
||||
-- Ownership is polymorphic (mirrors vars/secrets/scripts/extension_points):
|
||||
-- exactly one of (app_id, group_id) is set. MVP authoring is restricted to
|
||||
-- GROUP owners at the manifest layer (an app-declared shared collection is
|
||||
-- degenerate — only that app would read it); the polymorphic shape keeps the
|
||||
-- owner-generic reconcile path uniform with the other inheritable kinds.
|
||||
--
|
||||
-- `kind` is the storage discriminator. Only 'kv' ships in the MVP; the column
|
||||
-- + CHECK exist now so docs/files/topics/queue slot in later without a
|
||||
-- migration, and so a future `docs` collection of the same name is a distinct
|
||||
-- row (the unique index covers `kind`).
|
||||
|
||||
CREATE TABLE group_collections (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
group_id UUID REFERENCES groups(id) ON DELETE CASCADE,
|
||||
app_id UUID REFERENCES apps(id) ON DELETE CASCADE,
|
||||
CONSTRAINT group_collections_owner_exactly_one
|
||||
CHECK ((group_id IS NULL) <> (app_id IS NULL)),
|
||||
name TEXT NOT NULL,
|
||||
kind TEXT NOT NULL DEFAULT 'kv' CHECK (kind IN ('kv')),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- One marker per (owner, name, kind), case-insensitive. Partial because the
|
||||
-- owner is split across two nullable columns.
|
||||
CREATE UNIQUE INDEX group_collections_group_uidx
|
||||
ON group_collections (group_id, LOWER(name), kind) WHERE group_id IS NOT NULL;
|
||||
CREATE UNIQUE INDEX group_collections_app_uidx
|
||||
ON group_collections (app_id, LOWER(name), kind) WHERE app_id IS NOT NULL;
|
||||
|
||||
-- Lookup indexes for the resolver's chain join + list-by-owner.
|
||||
CREATE INDEX group_collections_group_id_idx ON group_collections (group_id) WHERE group_id IS NOT NULL;
|
||||
CREATE INDEX group_collections_app_id_idx ON group_collections (app_id) WHERE app_id IS NOT NULL;
|
||||
@@ -27,7 +27,7 @@ CREATE TABLE projects (
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- Promote the inert `groups.owner_project` (0047) to a real FK now that the
|
||||
-- Promote the inert `groups.owner_project` (0047:38) to a real FK now that the
|
||||
-- referent exists. Existing rows are all NULL (no projects yet), so this adds
|
||||
-- no validation burden.
|
||||
ALTER TABLE groups
|
||||
@@ -1,30 +0,0 @@
|
||||
-- §11.6 (v1.2 Hierarchies): group-KV storage — the shared read/write data for
|
||||
-- a collection declared group-shared in `group_collections` (0052, kind='kv').
|
||||
--
|
||||
-- Identity tuple `(group_id, collection, key)`. Unlike per-app `kv_entries`
|
||||
-- (0007) this is keyed by the OWNING GROUP, not by app — the whole point is
|
||||
-- that many apps in the group's subtree share one store. There is no `app_id`
|
||||
-- column: a shared row belongs to the group, not to whichever app wrote it.
|
||||
--
|
||||
-- `value` is JSONB, mirroring `kv_entries`. The reading/writing app is resolved
|
||||
-- to its owning group at the service layer (walking the app's ancestor chain);
|
||||
-- this table only ever sees a concrete `group_id`.
|
||||
--
|
||||
-- ON DELETE CASCADE on group_id: the shared store is DATA, so it dies with its
|
||||
-- owning group — like `vars`/`secrets` config CASCADE, deliberately UNLIKE the
|
||||
-- `scripts` (0050) RESTRICT (code is not data). Deleting an app does NOT touch
|
||||
-- this table (the data is the group's, and survives the app's departure).
|
||||
|
||||
CREATE TABLE group_kv_entries (
|
||||
group_id UUID NOT NULL REFERENCES groups(id) ON DELETE CASCADE,
|
||||
collection TEXT NOT NULL,
|
||||
key TEXT NOT NULL,
|
||||
value JSONB NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
PRIMARY KEY (group_id, collection, key)
|
||||
);
|
||||
|
||||
-- Supports list-by-collection (keyset pagination); the PK already covers
|
||||
-- (group_id, collection) as a prefix but the explicit index makes intent clear.
|
||||
CREATE INDEX idx_group_kv_entries_group_collection ON group_kv_entries (group_id, collection);
|
||||
49
crates/manager-core/migrations/0053_templates.sql
Normal file
49
crates/manager-core/migrations/0053_templates.sql
Normal file
@@ -0,0 +1,49 @@
|
||||
-- Phase 5 / M4a (v1.2 Hierarchies): ROUTE templates (§4.5).
|
||||
--
|
||||
-- Triggers and routes are app-scoped (never group-inherited — §5.1). At high
|
||||
-- tenant cardinality that means 100 apps × N routes = 100N hand declarations.
|
||||
-- A TEMPLATE fixes that: declare a route ONCE on a group, and the apply engine
|
||||
-- fans it out into one concrete per-`app_id` row for every descendant app. This
|
||||
-- is INSTANTIATION, not inheritance — expanded rows stay app-owned (the
|
||||
-- isolation boundary is unchanged); the template is just a stamp.
|
||||
--
|
||||
-- Placeholders in template fields are a small, inert, documented set resolved
|
||||
-- per descendant at expansion: `{app_slug}`, `{env}`, `{var:NAME}` (the app's
|
||||
-- effective var). Provenance: each expanded `routes` row carries `from_template`
|
||||
-- (the template id it was stamped from), so re-apply diffs idempotently and
|
||||
-- `--prune` removes expansions WITHOUT touching a hand-declared route of the
|
||||
-- same identity.
|
||||
--
|
||||
-- (Trigger templates reuse this engine in a follow-up: `trigger_templates` +
|
||||
-- `triggers.from_template`.)
|
||||
|
||||
-- Route templates — one per (group, name). Mirrors the `routes` column shape
|
||||
-- minus `app_id` (filled per descendant) plus `script_name` (resolved to the
|
||||
-- nearest inherited endpoint at expansion, like declarative route bindings).
|
||||
CREATE TABLE route_templates (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
group_id UUID NOT NULL REFERENCES groups(id) ON DELETE CASCADE,
|
||||
-- Explicit identity/upsert key, unique per group (case-insensitive).
|
||||
name TEXT NOT NULL,
|
||||
script_name TEXT NOT NULL,
|
||||
method TEXT,
|
||||
host_kind TEXT NOT NULL CHECK (host_kind IN ('any', 'strict', 'wildcard')),
|
||||
host TEXT NOT NULL DEFAULT '',
|
||||
host_param_name TEXT,
|
||||
path_kind TEXT NOT NULL CHECK (path_kind IN ('exact', 'prefix', 'param')),
|
||||
path TEXT NOT NULL,
|
||||
dispatch_mode TEXT NOT NULL DEFAULT 'sync' CHECK (dispatch_mode IN ('sync', 'async')),
|
||||
enabled BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
CREATE UNIQUE INDEX route_templates_group_name_idx
|
||||
ON route_templates (group_id, LOWER(name));
|
||||
|
||||
-- Provenance on the expanded rows. NULL = hand-declared (the app's own
|
||||
-- manifest); non-NULL = stamped from this template, managed by template
|
||||
-- expansion/prune. No FK: the apply engine owns the expansion lifecycle (it
|
||||
-- deletes orphaned expansions explicitly), and a dangling id after a raw
|
||||
-- template delete is harmless (treated as "template gone → prune the row").
|
||||
ALTER TABLE routes ADD COLUMN from_template UUID;
|
||||
CREATE INDEX routes_from_template_idx ON routes (app_id, from_template);
|
||||
@@ -1,33 +0,0 @@
|
||||
-- §11.6 (cont., v1.2 Hierarchies): group-shared DOCS collections.
|
||||
--
|
||||
-- Extends the shared-collection machinery (0052 registry + 0053 group_kv_entries)
|
||||
-- from KV to the queryable-JSON `docs` store. The registry's `kind`
|
||||
-- discriminator was built for exactly this — widen its CHECK to admit 'docs',
|
||||
-- and add a group-keyed storage table mirroring `docs` (0013).
|
||||
|
||||
-- Widen the marker kind allow-list. The constraint was an inline column CHECK,
|
||||
-- so Postgres auto-named it `group_collections_kind_check`.
|
||||
ALTER TABLE group_collections
|
||||
DROP CONSTRAINT group_collections_kind_check,
|
||||
ADD CONSTRAINT group_collections_kind_check CHECK (kind IN ('kv', 'docs'));
|
||||
|
||||
-- Group-keyed docs store. Identity tuple `(group_id, collection, id)` — keyed
|
||||
-- by the OWNING GROUP, not an app (the shared rows belong to the group). `id`
|
||||
-- is a server-generated UUID, as in `docs`. CASCADE on group delete (data dies
|
||||
-- with its group; an app delete leaves it), matching group_kv_entries (0053).
|
||||
CREATE TABLE group_docs (
|
||||
group_id UUID NOT NULL REFERENCES groups(id) ON DELETE CASCADE,
|
||||
collection TEXT NOT NULL,
|
||||
id UUID NOT NULL,
|
||||
data JSONB NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
PRIMARY KEY (group_id, collection, id)
|
||||
);
|
||||
|
||||
-- "all docs in group X / collection Y" — mirrors idx_docs_app_collection (0013).
|
||||
CREATE INDEX idx_group_docs_group_collection ON group_docs (group_id, collection);
|
||||
|
||||
-- GIN on JSONB (jsonb_path_ops) for the find DSL's equality/containment, same
|
||||
-- as idx_docs_data_gin (0013).
|
||||
CREATE INDEX idx_group_docs_data_gin ON group_docs USING GIN (data jsonb_path_ops);
|
||||
33
crates/manager-core/migrations/0054_trigger_templates.sql
Normal file
33
crates/manager-core/migrations/0054_trigger_templates.sql
Normal file
@@ -0,0 +1,33 @@
|
||||
-- Phase 5 / M4b (v1.2 Hierarchies): TRIGGER templates (§4.5).
|
||||
--
|
||||
-- The trigger half of templates (M4a shipped routes). A `[group]` manifest
|
||||
-- declares `[[trigger_templates.<kind>]]`; the apply fans each one out into a
|
||||
-- concrete per-`app_id` trigger on every descendant app, reusing the same
|
||||
-- expansion engine, placeholder set (`{app_slug}`/`{env}`/`{var:NAME}`), and
|
||||
-- `from_template` provenance as routes.
|
||||
--
|
||||
-- A trigger's parameters vary by kind (collection_glob/ops, schedule/timezone,
|
||||
-- topic_pattern, queue_name, inbound_secret_ref, …), so the template stores the
|
||||
-- whole `BundleTrigger` wire object as `spec` JSONB (kind tag included). The
|
||||
-- expansion resolves placeholders in the spec's string leaves, then rebuilds the
|
||||
-- typed trigger and inserts it through the normal trigger path (email secrets
|
||||
-- are resolved + re-sealed per app, exactly like a hand-declared email trigger).
|
||||
|
||||
CREATE TABLE trigger_templates (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
group_id UUID NOT NULL REFERENCES groups(id) ON DELETE CASCADE,
|
||||
-- Identity/upsert key, unique per group (case-insensitive).
|
||||
name TEXT NOT NULL,
|
||||
-- The full `BundleTrigger` wire object (internally tagged by `kind`), with
|
||||
-- placeholders left unresolved. Rebuilt + resolved per descendant at apply.
|
||||
spec JSONB NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
CREATE UNIQUE INDEX trigger_templates_group_name_idx
|
||||
ON trigger_templates (group_id, LOWER(name));
|
||||
|
||||
-- Provenance on expanded triggers (mirrors routes.from_template, 0053). NULL =
|
||||
-- hand-declared; non-NULL = stamped from this template, managed by expansion.
|
||||
ALTER TABLE triggers ADD COLUMN from_template UUID;
|
||||
CREATE INDEX triggers_from_template_idx ON triggers (app_id, from_template);
|
||||
@@ -1,37 +0,0 @@
|
||||
-- §11.6 (cont., v1.2 Hierarchies): group-shared FILES collections.
|
||||
--
|
||||
-- Extends the shared-collection machinery (0052 registry + the per-kind stores
|
||||
-- 0053 group_kv_entries / 0054 group_docs) from KV/docs to the filesystem-backed
|
||||
-- `files` blob store. The registry's `kind` discriminator was built for exactly
|
||||
-- this — widen its CHECK to admit 'files', and add a group-keyed metadata table
|
||||
-- mirroring `files` (0018).
|
||||
|
||||
-- Widen the marker kind allow-list (auto-named `group_collections_kind_check`,
|
||||
-- per 0052's inline column CHECK; 0054 widened it to ('kv','docs')).
|
||||
ALTER TABLE group_collections
|
||||
DROP CONSTRAINT group_collections_kind_check,
|
||||
ADD CONSTRAINT group_collections_kind_check CHECK (kind IN ('kv', 'docs', 'files'));
|
||||
|
||||
-- Group-keyed file metadata. Identity tuple `(group_id, collection, id)` — keyed
|
||||
-- by the OWNING GROUP, not an app (the shared blobs belong to the group). The
|
||||
-- bytes live on disk at
|
||||
-- <PICLOUD_FILES_ROOT>/files/groups/<group_id>/<collection>/<id[0:2]>/<id>
|
||||
-- (a `groups/` infix disjoint from the per-app `files/<app_id>/...` subtree, so
|
||||
-- the orphan sweeper covers both with one walk). CASCADE on group delete (the
|
||||
-- metadata dies with its group; an app delete leaves it), matching 0053/0054.
|
||||
CREATE TABLE group_files (
|
||||
group_id UUID NOT NULL REFERENCES groups(id) ON DELETE CASCADE,
|
||||
collection TEXT NOT NULL,
|
||||
id UUID NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
content_type TEXT NOT NULL,
|
||||
size_bytes BIGINT NOT NULL,
|
||||
checksum_sha256 TEXT NOT NULL, -- hex, 64 chars, lowercase
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
PRIMARY KEY (group_id, collection, id)
|
||||
);
|
||||
|
||||
-- List + cursor pagination scans by (group_id, collection) — mirrors
|
||||
-- idx_files_app_collection (0018).
|
||||
CREATE INDEX idx_group_files_group_collection ON group_files (group_id, collection);
|
||||
@@ -1,49 +0,0 @@
|
||||
-- §11 tail (v1.2 Hierarchies): group TRIGGER templates.
|
||||
--
|
||||
-- Until now every trigger was owned by exactly one app (`app_id NOT NULL`).
|
||||
-- A group-owned trigger is a TEMPLATE: it is never dispatched directly, but
|
||||
-- the dispatcher's hot-path match queries UNION it in for every descendant app
|
||||
-- via the ancestor-chain CTE (live resolution, like vars/secrets/scripts and
|
||||
-- §11.6 collections — no per-app materialized rows). It binds a group-owned
|
||||
-- handler script and fires under each firing app's `app_id`.
|
||||
--
|
||||
-- Scope: EVENT kinds only (kv/docs/files/pubsub) — those are stateless at
|
||||
-- dispatch. cron/queue/email carry per-instance state (last_fired_at, the
|
||||
-- one-consumer advisory lock, a sealed inbound secret) and would need
|
||||
-- materialization; they are rejected on a [group] at the manifest layer and
|
||||
-- deferred. The CHECK here is deliberately NOT narrowed to event kinds: the
|
||||
-- column allows any kind for forward-compat, the authoring gate is upstream.
|
||||
--
|
||||
-- Reshape mirrors 0050_group_scripts exactly (RESTRICT, not CASCADE — a
|
||||
-- trigger template references a group script; a group can't be deleted out
|
||||
-- from under it):
|
||||
-- * `group_id` — nullable FK→groups, ON DELETE RESTRICT.
|
||||
-- * `app_id` — made nullable; the exactly-one CHECK enforces app XOR group.
|
||||
-- * per-app unique name index + dispatch index become per-owner partials.
|
||||
|
||||
ALTER TABLE triggers
|
||||
ADD COLUMN group_id UUID REFERENCES groups(id) ON DELETE RESTRICT;
|
||||
|
||||
ALTER TABLE triggers
|
||||
ALTER COLUMN app_id DROP NOT NULL;
|
||||
|
||||
ALTER TABLE triggers
|
||||
ADD CONSTRAINT triggers_owner_exactly_one
|
||||
CHECK ((group_id IS NULL) <> (app_id IS NULL));
|
||||
|
||||
-- Per-owner name uniqueness (partial so each owner column only constrains its
|
||||
-- own rows). Existing app rows keep the exact (app_id, name) uniqueness.
|
||||
DROP INDEX triggers_app_name_uniq;
|
||||
CREATE UNIQUE INDEX triggers_app_name_uniq
|
||||
ON triggers (app_id, name) WHERE app_id IS NOT NULL;
|
||||
CREATE UNIQUE INDEX triggers_group_name_uniq
|
||||
ON triggers (group_id, name) WHERE group_id IS NOT NULL;
|
||||
|
||||
-- Per-owner dispatch hot-path index ("all enabled triggers of kind Y for
|
||||
-- owner X"). The app index keeps its name + shape (now owner-partial); a
|
||||
-- parallel group index serves the chain-union's group-template lookups.
|
||||
DROP INDEX idx_triggers_app_kind_enabled;
|
||||
CREATE INDEX idx_triggers_app_kind_enabled
|
||||
ON triggers (app_id, kind) WHERE enabled = TRUE AND app_id IS NOT NULL;
|
||||
CREATE INDEX idx_triggers_group_kind_enabled
|
||||
ON triggers (group_id, kind) WHERE enabled = TRUE AND group_id IS NOT NULL;
|
||||
@@ -1,48 +0,0 @@
|
||||
-- §11 tail (v1.2 Hierarchies): group ROUTE templates.
|
||||
--
|
||||
-- Until now every route was owned by exactly one app (`app_id NOT NULL`).
|
||||
-- A group-owned route is a TEMPLATE: it is never served directly, but the
|
||||
-- in-memory RouteTable rebuild EXPANDS it into every descendant app's slice
|
||||
-- via the ancestor chain (live resolution, like vars/secrets/scripts, §11.6
|
||||
-- collections, and §11 trigger templates — no per-app materialized rows). It
|
||||
-- binds a group-owned handler script and runs under each firing app's
|
||||
-- `app_id`.
|
||||
--
|
||||
-- Unlike triggers (a SQL-per-event dispatch), routes are served from an
|
||||
-- in-memory cache, so the inheritance is resolved at table-rebuild time, not
|
||||
-- per request. The reshape, however, is identical: a polymorphic owner column.
|
||||
--
|
||||
-- Reshape mirrors 0056_group_triggers exactly (RESTRICT, not CASCADE — a route
|
||||
-- template references a group script; a group can't be deleted out from under
|
||||
-- it):
|
||||
-- * `group_id` — nullable FK→groups, ON DELETE RESTRICT.
|
||||
-- * `app_id` — made nullable; the exactly-one CHECK enforces app XOR group.
|
||||
-- * the per-app unique binding index becomes per-owner partials.
|
||||
|
||||
ALTER TABLE routes
|
||||
ADD COLUMN group_id UUID REFERENCES groups(id) ON DELETE RESTRICT;
|
||||
|
||||
ALTER TABLE routes
|
||||
ALTER COLUMN app_id DROP NOT NULL;
|
||||
|
||||
ALTER TABLE routes
|
||||
ADD CONSTRAINT routes_owner_exactly_one
|
||||
CHECK ((group_id IS NULL) <> (app_id IS NULL));
|
||||
|
||||
-- Per-owner binding uniqueness (partial so each owner column only constrains
|
||||
-- its own rows). Existing app rows keep the exact binding-tuple uniqueness; a
|
||||
-- group can't declare two identical templates. An app declaring a binding
|
||||
-- identical to an inherited group template is a deliberate SHADOW, resolved
|
||||
-- at rebuild (nearest-owner-wins) — not a DB conflict.
|
||||
DROP INDEX routes_unique_binding_idx;
|
||||
CREATE UNIQUE INDEX routes_unique_binding_idx
|
||||
ON routes (app_id, host_kind, host, path_kind, path, COALESCE(method, ''))
|
||||
WHERE app_id IS NOT NULL;
|
||||
CREATE UNIQUE INDEX routes_group_binding_idx
|
||||
ON routes (group_id, host_kind, host, path_kind, path, COALESCE(method, ''))
|
||||
WHERE group_id IS NOT NULL;
|
||||
|
||||
-- The expansion join matches routes by owner (`r.app_id = ac.owner_app OR
|
||||
-- r.group_id = ac.owner_group`). The app side already has routes_app_id_idx;
|
||||
-- add the parallel group lookup index.
|
||||
CREATE INDEX routes_group_id_idx ON routes (group_id) WHERE group_id IS NOT NULL;
|
||||
@@ -1,36 +0,0 @@
|
||||
-- §11 tail (v1.2 Hierarchies): per-app opt-out of inherited group templates.
|
||||
--
|
||||
-- A group TRIGGER or ROUTE template inherits to every descendant app. Until now
|
||||
-- a descendant could SHADOW a template (declare its own identical binding) but
|
||||
-- not DECLINE one. This marker records that a specific app opts OUT of a
|
||||
-- specific inherited template — the template then stops resolving for that app
|
||||
-- (and only that app; a sibling subtree is unaffected).
|
||||
--
|
||||
-- Coarse by REFERENCE (not row id — template ids churn on re-apply): a
|
||||
-- suppression names a handler SCRIPT NAME (target_kind='trigger') or a PATH
|
||||
-- (target_kind='route'). A reference may decline more than one inherited
|
||||
-- template (a group that bound several to the same script/path). The dispatch
|
||||
-- filters gate to group-owned rows, so an app can only decline what it
|
||||
-- INHERITS — never its own trigger/route, never a sibling's.
|
||||
--
|
||||
-- App-only: only an app suppresses (a group would just not declare the
|
||||
-- template). So `app_id NOT NULL` — no polymorphic owner. It is pure app config
|
||||
-- (like `extension_points`), so ON DELETE CASCADE (not the code-owner RESTRICT).
|
||||
|
||||
CREATE TABLE template_suppressions (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
app_id UUID NOT NULL REFERENCES apps(id) ON DELETE CASCADE,
|
||||
target_kind TEXT NOT NULL CHECK (target_kind IN ('trigger', 'route')),
|
||||
-- A handler script name (trigger) or a path (route). Matched
|
||||
-- case-insensitively for triggers (as script-name resolution is
|
||||
-- elsewhere) and exactly for routes; stored as authored.
|
||||
reference TEXT NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- One marker per (app, kind, reference) — re-apply is a NoOp.
|
||||
CREATE UNIQUE INDEX template_suppressions_uidx
|
||||
ON template_suppressions (app_id, target_kind, reference);
|
||||
|
||||
-- The trigger anti-join / route rebuild both look up by app_id.
|
||||
CREATE INDEX template_suppressions_app_idx ON template_suppressions (app_id);
|
||||
@@ -1,19 +0,0 @@
|
||||
-- §11 tail (v1.2 Hierarchies): `sealed` (mandatory) group templates.
|
||||
--
|
||||
-- Per-app opt-out (0058_template_suppressions) made an inherited group TRIGGER
|
||||
-- or ROUTE template advisory-by-default: it runs on a descendant *unless* that
|
||||
-- descendant declares a `[suppress]` declining it. That is a compliance footgun
|
||||
-- — a group's audit / security / rate-limit template can be silently opted out
|
||||
-- of by the very tenant it is meant to police.
|
||||
--
|
||||
-- A `sealed` template closes the gap: the two suppression filters skip a sealed
|
||||
-- row, so it fires / serves on every descendant regardless of any suppression.
|
||||
-- The column is only meaningful on a group-owned template (`group_id IS NOT
|
||||
-- NULL`); an app-owned row is never inherited, so sealing it is meaningless and
|
||||
-- rejected at apply. Existing rows default to unsealed (the prior behaviour).
|
||||
--
|
||||
-- * triggers.sealed — a sealed group trigger template is never suppressible.
|
||||
-- * routes.sealed — a sealed group route template is never suppressible.
|
||||
|
||||
ALTER TABLE triggers ADD COLUMN sealed BOOLEAN NOT NULL DEFAULT FALSE;
|
||||
ALTER TABLE routes ADD COLUMN sealed BOOLEAN NOT NULL DEFAULT FALSE;
|
||||
@@ -1,38 +0,0 @@
|
||||
-- §11 tail (v1.2 Hierarchies): GROUP-level template suppression.
|
||||
--
|
||||
-- 0058 let an APP decline an inherited group template (for that app only). A
|
||||
-- group operator often wants the same opt-out for a whole subtree: "no
|
||||
-- descendant of this group runs the ancestor's `audit` template." Until now
|
||||
-- only an app could suppress; a group could not.
|
||||
--
|
||||
-- Reshape `template_suppressions` to a POLYMORPHIC owner, exactly like the
|
||||
-- group triggers/routes reshape (0056/0057) and the config markers (0051/0052):
|
||||
-- * `group_id` — nullable FK→groups, ON DELETE CASCADE (config, not code).
|
||||
-- * `app_id` — made nullable; the exactly-one CHECK enforces app XOR group.
|
||||
-- * the per-app unique index becomes per-owner partials.
|
||||
--
|
||||
-- A GROUP suppression declines a template the group INHERITS from a higher
|
||||
-- ancestor, for the group's whole subtree. Inheritance-only still holds: the
|
||||
-- dispatch filters gate to group-owned rows, and a suppression only matches on
|
||||
-- the suppressing owner's ancestor chain — a group can decline what it
|
||||
-- inherits, never a sibling subtree's, never its own descendants' own rows.
|
||||
|
||||
ALTER TABLE template_suppressions
|
||||
ADD COLUMN group_id UUID REFERENCES groups(id) ON DELETE CASCADE;
|
||||
|
||||
ALTER TABLE template_suppressions
|
||||
ALTER COLUMN app_id DROP NOT NULL;
|
||||
|
||||
ALTER TABLE template_suppressions
|
||||
ADD CONSTRAINT template_suppressions_owner_exactly_one
|
||||
CHECK ((group_id IS NULL) <> (app_id IS NULL));
|
||||
|
||||
DROP INDEX template_suppressions_uidx;
|
||||
CREATE UNIQUE INDEX template_suppressions_app_uidx
|
||||
ON template_suppressions (app_id, target_kind, reference) WHERE app_id IS NOT NULL;
|
||||
CREATE UNIQUE INDEX template_suppressions_group_uidx
|
||||
ON template_suppressions (group_id, target_kind, reference) WHERE group_id IS NOT NULL;
|
||||
|
||||
-- The trigger anti-join / route rebuild look up by both owners now.
|
||||
CREATE INDEX template_suppressions_group_idx
|
||||
ON template_suppressions (group_id) WHERE group_id IS NOT NULL;
|
||||
@@ -1,21 +0,0 @@
|
||||
-- §11.6 / §4.5: SHARED-collection triggers.
|
||||
--
|
||||
-- A §11.6 shared collection is group-owned and written by any subtree app via
|
||||
-- the `kv::shared_collection(...)` handles. Until now such writes fired NO
|
||||
-- trigger (the "group trigger has no app to watch" deferral): the per-app
|
||||
-- trigger dispatch keys on the writer app's OWN collections, and a shared
|
||||
-- collection lives in a distinct group-keyed store.
|
||||
--
|
||||
-- A SHARED trigger closes that gap: a group-owned trigger template marked
|
||||
-- `shared = true` watches the group's shared collection (not per-app
|
||||
-- collections). On a shared-collection write the emitter matches these triggers
|
||||
-- by the OWNING GROUP's chain and runs the handler under the WRITER app
|
||||
-- (`cx.app_id`) — the same "group template runs under the firing app" model.
|
||||
--
|
||||
-- The `shared` marker is the namespace boundary: a `shared = false` trigger
|
||||
-- (the default, incl. every existing row) watches per-app collections exactly
|
||||
-- as before; a `shared = true` trigger watches shared collections only. The two
|
||||
-- never cross (the per-app match queries add `AND t.shared = FALSE`, the shared
|
||||
-- match queries `AND t.shared = TRUE`).
|
||||
|
||||
ALTER TABLE triggers ADD COLUMN shared BOOLEAN NOT NULL DEFAULT FALSE;
|
||||
@@ -1,25 +0,0 @@
|
||||
-- §4.5 M5: STATEFUL group trigger templates (cron / queue / email) via
|
||||
-- per-descendant MATERIALIZATION.
|
||||
--
|
||||
-- The event kinds (kv/docs/files/pubsub) resolve a group template LIVE at
|
||||
-- dispatch via the chain CTE — no per-app rows. The stateful kinds can't:
|
||||
-- * cron needs a per-app `last_fired_at` the scheduler advances;
|
||||
-- * queue needs the one-consumer-per-(app_id, queue_name) advisory lock;
|
||||
-- * email needs a per-app SEALED inbound secret.
|
||||
-- So a group stateful template is instead MATERIALIZED into an app-owned
|
||||
-- trigger row per descendant app (created/removed as the tree changes), and the
|
||||
-- unchanged dispatch paths (scheduler, queue consumer, email webhook) only ever
|
||||
-- see app-owned rows.
|
||||
--
|
||||
-- `materialized_from` links a managed app-owned row back to the group template
|
||||
-- it was expanded from: NULL = a hand-authored trigger; set = a reconciler-owned
|
||||
-- copy (dropped + re-created as descendants come and go). ON DELETE CASCADE so
|
||||
-- deleting the group template (RESTRICT-guarded elsewhere) or the group cleans
|
||||
-- up the copies; a plain app delete CASCADEs the row away via `triggers.app_id`.
|
||||
|
||||
ALTER TABLE triggers
|
||||
ADD COLUMN materialized_from UUID REFERENCES triggers(id) ON DELETE CASCADE;
|
||||
|
||||
-- The reconciler looks up existing copies by their source template.
|
||||
CREATE INDEX idx_triggers_materialized_from
|
||||
ON triggers (materialized_from) WHERE materialized_from IS NOT NULL;
|
||||
@@ -1,12 +0,0 @@
|
||||
-- §4.5 M5: exactly one materialized copy per (descendant app, source template).
|
||||
--
|
||||
-- The materialization reconciler (materialize::rematerialize_stateful_templates)
|
||||
-- runs on every tree/apply mutation, and two of them can run concurrently (e.g.
|
||||
-- two apps created in parallel each trigger a full reconcile). Both compute the
|
||||
-- same "missing" (app, template) pair and both try to insert the copy — without
|
||||
-- a constraint that races into a DUPLICATE. This partial unique index makes the
|
||||
-- second insert a no-op (the reconciler uses `ON CONFLICT DO NOTHING`), so a
|
||||
-- copy is idempotent under concurrency.
|
||||
|
||||
CREATE UNIQUE INDEX triggers_materialized_uidx
|
||||
ON triggers (app_id, materialized_from) WHERE materialized_from IS NOT NULL;
|
||||
@@ -1,17 +0,0 @@
|
||||
-- §11.6 (cont., v1.2 Hierarchies): admit 'topic' and 'queue' as shared-
|
||||
-- collection kinds.
|
||||
--
|
||||
-- D2 (shared topics) + D3 (shared queues) extend the group shared-collection
|
||||
-- machinery from data stores (kv/docs/files) to the two event/messaging kinds.
|
||||
-- A shared TOPIC is storeless — it is a group-scoped publish NAMESPACE watched
|
||||
-- by `shared = true` group pubsub triggers (no per-kind storage table). A shared
|
||||
-- QUEUE gets its group-keyed message store in 0065 (D3). This migration only
|
||||
-- widens the marker allow-list; both kinds route through the same owner-generic
|
||||
-- reconcile + `resolve_owning_group` path as the data kinds.
|
||||
|
||||
-- Widen the marker kind allow-list (auto-named `group_collections_kind_check`,
|
||||
-- an inline column CHECK), matching 0054/0055.
|
||||
ALTER TABLE group_collections
|
||||
DROP CONSTRAINT group_collections_kind_check,
|
||||
ADD CONSTRAINT group_collections_kind_check
|
||||
CHECK (kind IN ('kv', 'docs', 'files', 'topic', 'queue'));
|
||||
@@ -1,46 +0,0 @@
|
||||
-- §11.6 D3: group-shared durable queues.
|
||||
--
|
||||
-- The queue counterpart to the group_kv/docs/files shared stores (0053-0055).
|
||||
-- A group declares a `queue` shared collection (kind admitted in 0064); any
|
||||
-- subtree app enqueues into ONE group-owned store via
|
||||
-- `queue::shared_collection(name).enqueue(...)`. Consumption is by COMPETING
|
||||
-- CONSUMERS: a group `[[triggers.queue]] shared = true` template materializes a
|
||||
-- consumer copy per descendant app (like the M5 stateful templates), and all
|
||||
-- copies claim from this shared store with FOR UPDATE SKIP LOCKED — each message
|
||||
-- delivered at-most-once across the whole subtree, scaling horizontally.
|
||||
--
|
||||
-- Identity tuple: (group_id, collection). Keyed by the OWNING GROUP, not an app
|
||||
-- (the shared rows belong to the group). CASCADE on group delete (data dies with
|
||||
-- its group; an app delete leaves it), matching the other group stores. Mirrors
|
||||
-- queue_messages (0034) column-for-column, swapping (app_id, queue_name) for
|
||||
-- (group_id, collection).
|
||||
|
||||
CREATE TABLE group_queue_messages (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
group_id UUID NOT NULL REFERENCES groups(id) ON DELETE CASCADE,
|
||||
collection TEXT NOT NULL,
|
||||
payload JSONB NOT NULL,
|
||||
enqueued_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
deliver_after TIMESTAMPTZ NULL,
|
||||
claim_token UUID NULL,
|
||||
claimed_at TIMESTAMPTZ NULL,
|
||||
attempt INT NOT NULL DEFAULT 0,
|
||||
max_attempts INT NOT NULL DEFAULT 3,
|
||||
enqueued_by_principal UUID NULL
|
||||
);
|
||||
|
||||
-- Dispatch hot path: scan for unclaimed messages in (group_id, collection)
|
||||
-- order by enqueued_at (the NOW() deliver_after filter is applied on the small
|
||||
-- partial result, as in 0034).
|
||||
CREATE INDEX idx_group_queue_messages_dispatch
|
||||
ON group_queue_messages (group_id, collection, enqueued_at)
|
||||
WHERE claim_token IS NULL;
|
||||
|
||||
-- depth() / depth_pending() helpers.
|
||||
CREATE INDEX idx_group_queue_messages_group_collection
|
||||
ON group_queue_messages (group_id, collection);
|
||||
|
||||
-- Reclaim-task scan: currently-leased messages past their visibility timeout.
|
||||
CREATE INDEX idx_group_queue_messages_claimed
|
||||
ON group_queue_messages (claimed_at)
|
||||
WHERE claim_token IS NOT NULL;
|
||||
@@ -6,7 +6,7 @@
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use picloud_shared::{App, AppId, HostKind, PathKind, ScriptOwner};
|
||||
use picloud_shared::{App, AppId, HostKind, PathKind};
|
||||
|
||||
use crate::app_repo::AppRepository;
|
||||
use crate::repo::{NewScript, ScriptRepository, ScriptRepositoryError};
|
||||
@@ -76,7 +76,7 @@ async fn seed_into(
|
||||
|
||||
routes
|
||||
.create(NewRoute {
|
||||
owner: ScriptOwner::App(default.id),
|
||||
app_id: default.id,
|
||||
script_id: script.id,
|
||||
host_kind: HostKind::Any,
|
||||
host: String::new(),
|
||||
@@ -88,7 +88,7 @@ async fn seed_into(
|
||||
method: None,
|
||||
dispatch_mode: picloud_shared::DispatchMode::Sync,
|
||||
enabled: true,
|
||||
sealed: false,
|
||||
from_template: None,
|
||||
})
|
||||
.await?;
|
||||
|
||||
|
||||
@@ -17,9 +17,8 @@ use serde_json::json;
|
||||
|
||||
use crate::app_repo::AppRepository;
|
||||
use crate::apply_service::{
|
||||
ApplyError, ApplyOwner, ApplyReport, ApplyService, Bundle, BundleTrigger, CollectionInfo,
|
||||
ExtensionPointInfo, NodeKind, PlanResult, RouteTemplateInfo, SuppressionInfo, TreeBundle,
|
||||
TreePlanResult, TriggerTemplateInfo,
|
||||
ApplyError, ApplyOwner, ApplyReport, ApplyService, Bundle, BundleTrigger, ExtPointView,
|
||||
NodeKind, PlanResult, TreeBundle, TreePlanResult,
|
||||
};
|
||||
use crate::authz::{require, AuthzDenied, Capability};
|
||||
use crate::group_repo::GroupRepository;
|
||||
@@ -31,48 +30,39 @@ pub fn apply_router(service: ApplyService) -> Router {
|
||||
.route("/apps/{id}/apply", post(apply_handler))
|
||||
.route("/groups/{id}/plan", post(group_plan_handler))
|
||||
.route("/groups/{id}/apply", post(group_apply_handler))
|
||||
.route("/tree/plan", post(tree_plan_handler))
|
||||
.route("/tree/apply", post(tree_apply_handler))
|
||||
.route(
|
||||
"/apps/{id}/extension-points",
|
||||
get(app_extension_points_handler),
|
||||
)
|
||||
.route("/apps/{id}/extension-points", get(app_ext_points_handler))
|
||||
.route(
|
||||
"/groups/{id}/extension-points",
|
||||
get(group_extension_points_handler),
|
||||
get(group_ext_points_handler),
|
||||
)
|
||||
.route("/groups/{id}/collections", get(group_collections_handler))
|
||||
.route("/groups/{id}/triggers", get(group_triggers_handler))
|
||||
.route("/groups/{id}/routes", get(group_routes_handler))
|
||||
.route("/apps/{id}/suppressions", get(app_suppressions_handler))
|
||||
.route("/groups/{id}/suppressions", get(group_suppressions_handler))
|
||||
.route("/tree/plan", post(tree_plan_handler))
|
||||
.route("/tree/apply", post(tree_apply_handler))
|
||||
.with_state(service)
|
||||
}
|
||||
|
||||
/// Read-only §11 tail suppression report for an app: the inherited templates it
|
||||
/// declines (`target_kind`, `reference`). Viewer-tier `AppRead`. Backs
|
||||
/// `pic suppress ls --app`.
|
||||
async fn app_suppressions_handler(
|
||||
/// `GET .../apps/{id}/extension-points` — list the app's OWN extension-point
|
||||
/// declarations (for `pic pull`). Read-only; viewer-tier (`AppRead`).
|
||||
async fn app_ext_points_handler(
|
||||
State(svc): State<ApplyService>,
|
||||
Extension(principal): Extension<Principal>,
|
||||
Path(id_or_slug): Path<String>,
|
||||
) -> Result<Json<Vec<SuppressionInfo>>, ApplyError> {
|
||||
) -> Result<Json<Vec<ExtPointView>>, ApplyError> {
|
||||
let app_id = resolve_app_id(svc.apps.as_ref(), &id_or_slug).await?;
|
||||
require(svc.authz.as_ref(), &principal, Capability::AppRead(app_id))
|
||||
.await
|
||||
.map_err(map_authz)?;
|
||||
let report = svc.suppression_report(ApplyOwner::App(app_id)).await?;
|
||||
Ok(Json(report))
|
||||
Ok(Json(
|
||||
svc.list_extension_points(ApplyOwner::App(app_id)).await?,
|
||||
))
|
||||
}
|
||||
|
||||
/// Read-only §11 tail M1 suppression report for a group: the inherited templates
|
||||
/// it declines for its whole subtree (`target_kind`, `reference`). Viewer-tier
|
||||
/// `GroupScriptsRead`. Backs `pic suppress ls --group`.
|
||||
async fn group_suppressions_handler(
|
||||
/// `GET .../groups/{id}/extension-points` — list the group's OWN extension-point
|
||||
/// declarations. Read-only; viewer-tier (`GroupScriptsRead`).
|
||||
async fn group_ext_points_handler(
|
||||
State(svc): State<ApplyService>,
|
||||
Extension(principal): Extension<Principal>,
|
||||
Path(id_or_slug): Path<String>,
|
||||
) -> Result<Json<Vec<SuppressionInfo>>, ApplyError> {
|
||||
) -> Result<Json<Vec<ExtPointView>>, ApplyError> {
|
||||
let group_id = resolve_group_id(svc.groups.as_ref(), &id_or_slug).await?;
|
||||
require(
|
||||
svc.authz.as_ref(),
|
||||
@@ -81,102 +71,10 @@ async fn group_suppressions_handler(
|
||||
)
|
||||
.await
|
||||
.map_err(map_authz)?;
|
||||
let report = svc.suppression_report(ApplyOwner::Group(group_id)).await?;
|
||||
Ok(Json(report))
|
||||
}
|
||||
|
||||
/// Read-only §11 tail route-template report for a group: its own declared route
|
||||
/// templates (method, host, path, handler script, dispatch, enabled). Viewer-
|
||||
/// tier read. Backs `pic routes ls --group`.
|
||||
async fn group_routes_handler(
|
||||
State(svc): State<ApplyService>,
|
||||
Extension(principal): Extension<Principal>,
|
||||
Path(id_or_slug): Path<String>,
|
||||
) -> Result<Json<Vec<RouteTemplateInfo>>, ApplyError> {
|
||||
let group_id = resolve_group_id(svc.groups.as_ref(), &id_or_slug).await?;
|
||||
require(
|
||||
svc.authz.as_ref(),
|
||||
&principal,
|
||||
Capability::GroupScriptsRead(group_id),
|
||||
)
|
||||
.await
|
||||
.map_err(map_authz)?;
|
||||
let report = svc.route_report(ApplyOwner::Group(group_id)).await?;
|
||||
Ok(Json(report))
|
||||
}
|
||||
|
||||
/// Read-only §11 tail trigger-template report for a group: its own declared
|
||||
/// event trigger templates (kind, target, handler script, enabled). Viewer-tier
|
||||
/// read. Backs `pic triggers ls --group`.
|
||||
async fn group_triggers_handler(
|
||||
State(svc): State<ApplyService>,
|
||||
Extension(principal): Extension<Principal>,
|
||||
Path(id_or_slug): Path<String>,
|
||||
) -> Result<Json<Vec<TriggerTemplateInfo>>, ApplyError> {
|
||||
let group_id = resolve_group_id(svc.groups.as_ref(), &id_or_slug).await?;
|
||||
require(
|
||||
svc.authz.as_ref(),
|
||||
&principal,
|
||||
Capability::GroupScriptsRead(group_id),
|
||||
)
|
||||
.await
|
||||
.map_err(map_authz)?;
|
||||
let report = svc.trigger_report(ApplyOwner::Group(group_id)).await?;
|
||||
Ok(Json(report))
|
||||
}
|
||||
|
||||
/// Read-only §11.6 shared-collection report for a group: its own declared
|
||||
/// shared KV collection names. Viewer-tier read.
|
||||
async fn group_collections_handler(
|
||||
State(svc): State<ApplyService>,
|
||||
Extension(principal): Extension<Principal>,
|
||||
Path(id_or_slug): Path<String>,
|
||||
) -> Result<Json<Vec<CollectionInfo>>, ApplyError> {
|
||||
let group_id = resolve_group_id(svc.groups.as_ref(), &id_or_slug).await?;
|
||||
require(
|
||||
svc.authz.as_ref(),
|
||||
&principal,
|
||||
Capability::GroupScriptsRead(group_id),
|
||||
)
|
||||
.await
|
||||
.map_err(map_authz)?;
|
||||
let report = svc.collection_report(ApplyOwner::Group(group_id)).await?;
|
||||
Ok(Json(report))
|
||||
}
|
||||
|
||||
/// Read-only §5.5 extension-point report for an app: every EP visible on its
|
||||
/// chain, each with `declared_here` + resolved `provider`. Viewer-tier read.
|
||||
async fn app_extension_points_handler(
|
||||
State(svc): State<ApplyService>,
|
||||
Extension(principal): Extension<Principal>,
|
||||
Path(id_or_slug): Path<String>,
|
||||
) -> Result<Json<Vec<ExtensionPointInfo>>, ApplyError> {
|
||||
let app_id = resolve_app_id(svc.apps.as_ref(), &id_or_slug).await?;
|
||||
require(svc.authz.as_ref(), &principal, Capability::AppRead(app_id))
|
||||
.await
|
||||
.map_err(map_authz)?;
|
||||
let report = svc.extension_point_report(ApplyOwner::App(app_id)).await?;
|
||||
Ok(Json(report))
|
||||
}
|
||||
|
||||
/// Read-only §5.5 extension-point report for a group: its own declared names.
|
||||
async fn group_extension_points_handler(
|
||||
State(svc): State<ApplyService>,
|
||||
Extension(principal): Extension<Principal>,
|
||||
Path(id_or_slug): Path<String>,
|
||||
) -> Result<Json<Vec<ExtensionPointInfo>>, ApplyError> {
|
||||
let group_id = resolve_group_id(svc.groups.as_ref(), &id_or_slug).await?;
|
||||
require(
|
||||
svc.authz.as_ref(),
|
||||
&principal,
|
||||
Capability::GroupScriptsRead(group_id),
|
||||
)
|
||||
.await
|
||||
.map_err(map_authz)?;
|
||||
let report = svc
|
||||
.extension_point_report(ApplyOwner::Group(group_id))
|
||||
.await?;
|
||||
Ok(Json(report))
|
||||
Ok(Json(
|
||||
svc.list_extension_points(ApplyOwner::Group(group_id))
|
||||
.await?,
|
||||
))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
@@ -297,15 +195,6 @@ struct TreeApplyRequest {
|
||||
prune: bool,
|
||||
#[serde(default)]
|
||||
expected_token: Option<String>,
|
||||
/// Selected environment (the overlay applied). Drives per-env approval
|
||||
/// gating (§4.2, M5).
|
||||
#[serde(default)]
|
||||
env: Option<String>,
|
||||
/// Environments the actor explicitly approved this apply for (§4.2, M5). A
|
||||
/// confirm-required env (per the bundle's `[project]` policy) is refused
|
||||
/// unless it appears here; `--yes` alone does NOT add it.
|
||||
#[serde(default)]
|
||||
approved_envs: Vec<String>,
|
||||
/// Stable project key (§7, M3): the apply claims unclaimed declared groups
|
||||
/// for this project and refuses ones another project owns.
|
||||
#[serde(default)]
|
||||
@@ -313,6 +202,15 @@ struct TreeApplyRequest {
|
||||
/// Take over declared groups owned by another project (group-admin gated).
|
||||
#[serde(default)]
|
||||
allow_takeover: bool,
|
||||
/// Selected environment (§4.5, M4a): the value substituted for the `{env}`
|
||||
/// placeholder when expanding route templates. The CLI passes `--env`.
|
||||
#[serde(default)]
|
||||
env: Option<String>,
|
||||
/// Environments the actor explicitly approved this apply for (§4.2, M5).
|
||||
/// A confirm-required env (per the bundle's `[project]` policy) is refused
|
||||
/// unless it appears here; `--yes` alone does NOT add it.
|
||||
#[serde(default)]
|
||||
approved_envs: Vec<String>,
|
||||
}
|
||||
|
||||
async fn tree_plan_handler(
|
||||
@@ -356,19 +254,211 @@ async fn tree_apply_handler(
|
||||
req.expected_token.as_deref(),
|
||||
req.project_key.as_deref(),
|
||||
req.allow_takeover,
|
||||
req.env.as_deref(),
|
||||
)
|
||||
.await?;
|
||||
Ok(Json(report))
|
||||
}
|
||||
|
||||
/// Per-node capability check for a tree plan/apply. `prune` widens an apply's
|
||||
/// write requirement to every kind. A plan (`prune` ignored, no writes) needs
|
||||
/// only the per-node read cap.
|
||||
#[allow(clippy::too_many_lines)]
|
||||
async fn authz_tree(
|
||||
svc: &ApplyService,
|
||||
principal: &Principal,
|
||||
bundle: &TreeBundle,
|
||||
apply_prune: Option<bool>,
|
||||
allow_takeover: bool,
|
||||
) -> Result<(), ApplyError> {
|
||||
for node in &bundle.nodes {
|
||||
match node.kind {
|
||||
NodeKind::App => {
|
||||
let app_id = resolve_app_id(svc.apps.as_ref(), &node.slug).await?;
|
||||
match apply_prune {
|
||||
Some(prune) => {
|
||||
require_app_node_writes(svc, principal, app_id, &node.bundle, prune)
|
||||
.await?;
|
||||
// Template expansion (§4.5) writes routes/triggers INTO
|
||||
// this app, so the actor needs the matching write cap
|
||||
// when the app's chain carries templates — even if the
|
||||
// app itself declares none. Without this, expanding into
|
||||
// an app a principal can't write would slip the gate.
|
||||
let recv = app_receives_template_expansions(svc, app_id, bundle).await?;
|
||||
if recv.routes {
|
||||
require(
|
||||
svc.authz.as_ref(),
|
||||
principal,
|
||||
Capability::AppWriteRoute(app_id),
|
||||
)
|
||||
.await
|
||||
.map_err(map_authz)?;
|
||||
}
|
||||
if recv.triggers {
|
||||
require(
|
||||
svc.authz.as_ref(),
|
||||
principal,
|
||||
Capability::AppManageTriggers(app_id),
|
||||
)
|
||||
.await
|
||||
.map_err(map_authz)?;
|
||||
}
|
||||
// Email-trigger expansion resolves + seals the recipient
|
||||
// app's secret server-side — same `AppSecretsRead` gate a
|
||||
// hand-declared email trigger requires.
|
||||
if recv.email {
|
||||
require(
|
||||
svc.authz.as_ref(),
|
||||
principal,
|
||||
Capability::AppSecretsRead(app_id),
|
||||
)
|
||||
.await
|
||||
.map_err(map_authz)?;
|
||||
}
|
||||
}
|
||||
None => {
|
||||
require(svc.authz.as_ref(), principal, Capability::AppRead(app_id))
|
||||
.await
|
||||
.map_err(map_authz)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
NodeKind::Group => {
|
||||
let existing = svc
|
||||
.groups
|
||||
.get_by_slug(&node.slug)
|
||||
.await
|
||||
.map_err(|e| ApplyError::Backend(e.to_string()))?;
|
||||
let Some(g) = existing else {
|
||||
// To-create group (M2): mirror the interactive create gate
|
||||
// (`groups_api::create_group`). A root-level group (no
|
||||
// resolvable parent) needs `InstanceCreateGroup`; a subgroup
|
||||
// under an EXISTING parent needs only `GroupAdmin(parent)`.
|
||||
// A parent that is itself to-create has no id yet → fall
|
||||
// back to `InstanceCreateGroup`.
|
||||
let parent = match &node.parent_slug {
|
||||
Some(p) => svc
|
||||
.groups
|
||||
.get_by_slug(p)
|
||||
.await
|
||||
.map_err(|e| ApplyError::Backend(e.to_string()))?,
|
||||
None => None,
|
||||
};
|
||||
match parent {
|
||||
Some(pg) => {
|
||||
require(svc.authz.as_ref(), principal, Capability::GroupAdmin(pg.id))
|
||||
.await
|
||||
.map_err(map_authz)?;
|
||||
}
|
||||
None => {
|
||||
require(
|
||||
svc.authz.as_ref(),
|
||||
principal,
|
||||
Capability::InstanceCreateGroup,
|
||||
)
|
||||
.await
|
||||
.map_err(map_authz)?;
|
||||
}
|
||||
}
|
||||
continue;
|
||||
};
|
||||
let group_id = g.id;
|
||||
match apply_prune {
|
||||
Some(prune) => {
|
||||
require_group_node_writes(svc, principal, group_id, &node.bundle, prune)
|
||||
.await?;
|
||||
// Takeover gate (§7.4): seizing a group that another
|
||||
// project owns needs GroupAdmin specifically — a second,
|
||||
// independent gate on top of the write caps. We gate it
|
||||
// for any already-claimed node under `--takeover` (a
|
||||
// superset of genuinely-contested, never laxer); claiming
|
||||
// an unclaimed node, or applying without `--takeover`,
|
||||
// does not require admin. The authoritative owner rewrite
|
||||
// happens in-tx in `apply_tree` (which knows our project).
|
||||
if allow_takeover
|
||||
&& crate::group_repo::get_group_owner(&svc.pool, group_id)
|
||||
.await
|
||||
.map_err(|e| ApplyError::Backend(e.to_string()))?
|
||||
.is_some()
|
||||
{
|
||||
require(
|
||||
svc.authz.as_ref(),
|
||||
principal,
|
||||
Capability::GroupAdmin(group_id),
|
||||
)
|
||||
.await
|
||||
.map_err(map_authz)?;
|
||||
}
|
||||
// Reparent: an explicit parent differing from the live
|
||||
// one needs GroupAdmin at the group, the SOURCE parent,
|
||||
// and the DESTINATION parent — mirroring the interactive
|
||||
// `reparent_group` (§5.6 triply-gated).
|
||||
if let Some(parent) = &node.parent_slug {
|
||||
if let Some(pg) = svc
|
||||
.groups
|
||||
.get_by_slug(parent)
|
||||
.await
|
||||
.map_err(|e| ApplyError::Backend(e.to_string()))?
|
||||
{
|
||||
if Some(pg.id) != g.parent_id {
|
||||
require(
|
||||
svc.authz.as_ref(),
|
||||
principal,
|
||||
Capability::GroupAdmin(group_id),
|
||||
)
|
||||
.await
|
||||
.map_err(map_authz)?;
|
||||
if let Some(src) = g.parent_id {
|
||||
require(
|
||||
svc.authz.as_ref(),
|
||||
principal,
|
||||
Capability::GroupAdmin(src),
|
||||
)
|
||||
.await
|
||||
.map_err(map_authz)?;
|
||||
}
|
||||
require(
|
||||
svc.authz.as_ref(),
|
||||
principal,
|
||||
Capability::GroupAdmin(pg.id),
|
||||
)
|
||||
.await
|
||||
.map_err(map_authz)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
None => {
|
||||
require(
|
||||
svc.authz.as_ref(),
|
||||
principal,
|
||||
Capability::GroupScriptsRead(group_id),
|
||||
)
|
||||
.await
|
||||
.map_err(map_authz)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// NOTE (§4.5, M7): templates also fan out to descendant apps NOT declared in
|
||||
// this bundle (the cross-repo subtree). Those per-recipient write caps are
|
||||
// gated AUTHORITATIVELY IN-TRANSACTION in `apply_tree` Phase B2 — against the
|
||||
// post-reparent app set, each app already locked — so the checked set is by
|
||||
// construction the written set (no pre-tx TOCTOU, and a reparent that moves
|
||||
// apps under a templated group in the same apply can't slip the gate).
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Per-env approval gate (§4.2, §6, M5). If the apply selects a confirm-required
|
||||
/// environment (per the bundle's `[project]` policy, re-derived here — the CLI's
|
||||
/// enforcement is convenience, this is authoritative), it must be explicitly
|
||||
/// approved via `approved_envs` (`pic apply --approve <env>`); a blanket `--yes`
|
||||
/// does NOT satisfy it. An approved gated apply additionally requires ADMIN
|
||||
/// authority on every declared node — the "override a gate" capability (§4.2), a
|
||||
/// deliberate step up from the editor-level write caps an ordinary apply needs —
|
||||
/// and is audited.
|
||||
/// authority on every declared node — the "override a gate" capability (§4.2),
|
||||
/// a deliberate step up from the editor-level write caps an ordinary apply
|
||||
/// needs — and is audited.
|
||||
async fn enforce_env_approval(
|
||||
svc: &ApplyService,
|
||||
principal: &Principal,
|
||||
@@ -395,20 +485,18 @@ async fn enforce_env_approval(
|
||||
.map_err(map_authz)?;
|
||||
}
|
||||
NodeKind::Group => {
|
||||
// Resolve UUID-or-slug and FAIL CLOSED on miss — mirror
|
||||
// `authz_tree`/`prepare_tree`. A bare `get_by_slug` skips this
|
||||
// admin gate when the node addresses the group by its UUID (which
|
||||
// `resolve_group`/`resolve_group_id` still resolve for the apply),
|
||||
// letting a group editor apply to a confirm-required env without
|
||||
// the admin authority M5 requires.
|
||||
let group_id = resolve_group_id(svc.groups.as_ref(), &node.slug).await?;
|
||||
require(
|
||||
svc.authz.as_ref(),
|
||||
principal,
|
||||
Capability::GroupAdmin(group_id),
|
||||
)
|
||||
.await
|
||||
.map_err(map_authz)?;
|
||||
// A to-create group has no id yet; its creation already required
|
||||
// InstanceCreateGroup / GroupAdmin(parent) in `authz_tree`.
|
||||
if let Some(g) = svc
|
||||
.groups
|
||||
.get_by_slug(&node.slug)
|
||||
.await
|
||||
.map_err(|e| ApplyError::Backend(e.to_string()))?
|
||||
{
|
||||
require(svc.authz.as_ref(), principal, Capability::GroupAdmin(g.id))
|
||||
.await
|
||||
.map_err(map_authz)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -421,77 +509,6 @@ async fn enforce_env_approval(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Per-node capability check for a tree plan/apply. `prune` widens an apply's
|
||||
/// write requirement to every kind. A plan (`prune` ignored, no writes) needs
|
||||
/// only the per-node read cap.
|
||||
async fn authz_tree(
|
||||
svc: &ApplyService,
|
||||
principal: &Principal,
|
||||
bundle: &TreeBundle,
|
||||
apply_prune: Option<bool>,
|
||||
allow_takeover: bool,
|
||||
) -> Result<(), ApplyError> {
|
||||
for node in &bundle.nodes {
|
||||
match node.kind {
|
||||
NodeKind::App => {
|
||||
let app_id = resolve_app_id(svc.apps.as_ref(), &node.slug).await?;
|
||||
match apply_prune {
|
||||
Some(prune) => {
|
||||
require_app_node_writes(svc, principal, app_id, &node.bundle, prune)
|
||||
.await?;
|
||||
}
|
||||
None => {
|
||||
require(svc.authz.as_ref(), principal, Capability::AppRead(app_id))
|
||||
.await
|
||||
.map_err(map_authz)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
NodeKind::Group => {
|
||||
let group_id = resolve_group_id(svc.groups.as_ref(), &node.slug).await?;
|
||||
match apply_prune {
|
||||
Some(prune) => {
|
||||
require_group_node_writes(svc, principal, group_id, &node.bundle, prune)
|
||||
.await?;
|
||||
// Takeover gate (§7.4): seizing a group another project
|
||||
// owns needs GroupAdmin specifically — a second, independent
|
||||
// gate on top of the write caps. We gate it for any
|
||||
// already-claimed node under `--takeover` (a superset of
|
||||
// genuinely-contested, never laxer); claiming an unclaimed
|
||||
// node, or applying without `--takeover`, does not require
|
||||
// admin. The authoritative owner rewrite happens in-tx in
|
||||
// `apply_tree` (which re-checks under the tx lock).
|
||||
if allow_takeover
|
||||
&& crate::group_repo::get_group_owner(&svc.pool, group_id)
|
||||
.await
|
||||
.map_err(|e| ApplyError::Backend(e.to_string()))?
|
||||
.is_some()
|
||||
{
|
||||
require(
|
||||
svc.authz.as_ref(),
|
||||
principal,
|
||||
Capability::GroupAdmin(group_id),
|
||||
)
|
||||
.await
|
||||
.map_err(map_authz)?;
|
||||
}
|
||||
}
|
||||
None => {
|
||||
require(
|
||||
svc.authz.as_ref(),
|
||||
principal,
|
||||
Capability::GroupScriptsRead(group_id),
|
||||
)
|
||||
.await
|
||||
.map_err(map_authz)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// App-node write caps: per resource kind the bundle touches, widened to all
|
||||
/// kinds when `prune` (which deletes empty-section resources + cascades). Read
|
||||
/// is always needed. Shared by the single-app and tree apply paths.
|
||||
@@ -505,7 +522,11 @@ async fn require_app_node_writes(
|
||||
require(svc.authz.as_ref(), principal, Capability::AppRead(app_id))
|
||||
.await
|
||||
.map_err(map_authz)?;
|
||||
if prune || !bundle.scripts.is_empty() {
|
||||
// Extension points are module-resolution declarations, so they gate on the
|
||||
// same script-write tier as modules — without this, an ext-point-only
|
||||
// bundle (no `default`, so no script) would pass with viewer-tier `AppRead`
|
||||
// and let a reader open the §5.5 import trust boundary.
|
||||
if prune || !bundle.scripts.is_empty() || !bundle.extension_points.is_empty() {
|
||||
require(
|
||||
svc.authz.as_ref(),
|
||||
principal,
|
||||
@@ -565,19 +586,22 @@ async fn require_group_node_writes(
|
||||
bundle: &Bundle,
|
||||
prune: bool,
|
||||
) -> Result<(), ApplyError> {
|
||||
// Baseline (§7.4, M3): applying a group node CLAIMS its ownership
|
||||
// (`owner_project`) in-tx — a management write — even when the bundle has no
|
||||
// scripts/vars to reconcile. Require an editor-level group write cap floor so
|
||||
// an EMPTY `[group]` node can't be claimed capability-free (Ownership ⟂ RBAC:
|
||||
// owning the manifest never grants write, and claiming still needs write).
|
||||
// Subsumes the per-resource GroupScriptsWrite below.
|
||||
require(
|
||||
svc.authz.as_ref(),
|
||||
principal,
|
||||
Capability::GroupScriptsWrite(group_id),
|
||||
)
|
||||
.await
|
||||
.map_err(map_authz)?;
|
||||
// Extension points and route/trigger templates gate on the script-write
|
||||
// tier (see the app variant) — all are code-binding declarations.
|
||||
if prune
|
||||
|| !bundle.scripts.is_empty()
|
||||
|| !bundle.extension_points.is_empty()
|
||||
|| !bundle.route_templates.is_empty()
|
||||
|| !bundle.trigger_templates.is_empty()
|
||||
{
|
||||
require(
|
||||
svc.authz.as_ref(),
|
||||
principal,
|
||||
Capability::GroupScriptsWrite(group_id),
|
||||
)
|
||||
.await
|
||||
.map_err(map_authz)?;
|
||||
}
|
||||
if prune || !bundle.vars.is_empty() {
|
||||
require(
|
||||
svc.authz.as_ref(),
|
||||
@@ -590,6 +614,95 @@ async fn require_group_node_writes(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// What template expansion (§4.5) will write into `app_id` during this tree
|
||||
/// apply. Returns `Recipient { routes, triggers, email }` — whether the app's
|
||||
/// ancestor chain carries a route / trigger / EMAIL-trigger template (committed,
|
||||
/// or declared by a group node in THIS bundle). Drives the `AppWriteRoute` /
|
||||
/// `AppManageTriggers` / `AppSecretsRead` gates for recipients (email expansion
|
||||
/// resolves + seals the recipient app's secret server-side, like a hand-declared
|
||||
/// email trigger).
|
||||
#[derive(Default)]
|
||||
struct Recipient {
|
||||
routes: bool,
|
||||
triggers: bool,
|
||||
email: bool,
|
||||
}
|
||||
|
||||
fn spec_is_email(spec: &serde_json::Value) -> bool {
|
||||
spec.get("kind").and_then(serde_json::Value::as_str) == Some("email")
|
||||
}
|
||||
|
||||
async fn app_receives_template_expansions(
|
||||
svc: &ApplyService,
|
||||
app_id: AppId,
|
||||
bundle: &TreeBundle,
|
||||
) -> Result<Recipient, ApplyError> {
|
||||
let Some(app) = svc
|
||||
.apps
|
||||
.get_by_id(app_id)
|
||||
.await
|
||||
.map_err(|e| ApplyError::Backend(e.to_string()))?
|
||||
else {
|
||||
return Ok(Recipient::default());
|
||||
};
|
||||
let ancestors = svc
|
||||
.groups
|
||||
.ancestors(app.group_id)
|
||||
.await
|
||||
.map_err(|e| ApplyError::Backend(e.to_string()))?;
|
||||
let ancestor_ids: std::collections::HashSet<GroupId> = ancestors.iter().map(|g| g.id).collect();
|
||||
|
||||
let mut r = Recipient::default();
|
||||
// Committed templates on any ancestor group.
|
||||
for gid in &ancestor_ids {
|
||||
if !r.routes
|
||||
&& !crate::template_repo::list_for_group(&svc.pool, *gid)
|
||||
.await
|
||||
.map_err(|e| ApplyError::Backend(e.to_string()))?
|
||||
.is_empty()
|
||||
{
|
||||
r.routes = true;
|
||||
}
|
||||
for tt in crate::template_repo::list_triggers_for_group(&svc.pool, *gid)
|
||||
.await
|
||||
.map_err(|e| ApplyError::Backend(e.to_string()))?
|
||||
{
|
||||
r.triggers = true;
|
||||
r.email = r.email || spec_is_email(&tt.spec);
|
||||
}
|
||||
}
|
||||
// Templates newly declared by a group node in this bundle that is an ancestor
|
||||
// of the app (existing groups only — a to-create group has no apps).
|
||||
for node in &bundle.nodes {
|
||||
if node.kind != NodeKind::Group {
|
||||
continue;
|
||||
}
|
||||
let declares_routes = !node.bundle.route_templates.is_empty();
|
||||
let declares_triggers = !node.bundle.trigger_templates.is_empty();
|
||||
if !declares_routes && !declares_triggers {
|
||||
continue;
|
||||
}
|
||||
if let Some(g) = svc
|
||||
.groups
|
||||
.get_by_slug(&node.slug)
|
||||
.await
|
||||
.map_err(|e| ApplyError::Backend(e.to_string()))?
|
||||
{
|
||||
if ancestor_ids.contains(&g.id) {
|
||||
r.routes = r.routes || declares_routes;
|
||||
r.triggers = r.triggers || declares_triggers;
|
||||
r.email = r.email
|
||||
|| node
|
||||
.bundle
|
||||
.trigger_templates
|
||||
.iter()
|
||||
.any(|t| spec_is_email(&t.spec));
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(r)
|
||||
}
|
||||
|
||||
/// Resolve a slug-or-id path param to a `GroupId`, mapping miss → 404.
|
||||
async fn resolve_group_id(
|
||||
groups: &dyn GroupRepository,
|
||||
@@ -636,7 +749,8 @@ impl IntoResponse for ApplyError {
|
||||
StatusCode::UNPROCESSABLE_ENTITY,
|
||||
json!({ "error": self.to_string() }),
|
||||
),
|
||||
Self::StateMoved | Self::ApprovalRequired(_) | Self::OwnershipConflict(_) => {
|
||||
Self::StateMoved => (StatusCode::CONFLICT, json!({ "error": self.to_string() })),
|
||||
Self::OwnershipConflict(_) | Self::ApprovalRequired(_) => {
|
||||
(StatusCode::CONFLICT, json!({ "error": self.to_string() }))
|
||||
}
|
||||
Self::Forbidden => (StatusCode::FORBIDDEN, json!({ "error": self.to_string() })),
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -16,7 +16,7 @@ use axum::http::StatusCode;
|
||||
use axum::response::{IntoResponse, Json, Response};
|
||||
use axum::routing::{delete, get, post};
|
||||
use axum::{Extension, Router};
|
||||
use picloud_orchestrator_core::routing::{pattern, AppDomainTable, CompiledAppDomain, RouteTable};
|
||||
use picloud_orchestrator_core::routing::{pattern, AppDomainTable, CompiledAppDomain};
|
||||
use picloud_shared::{App, AppDomain, AppId, AppRole, InstanceRole, Principal};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::json;
|
||||
@@ -43,19 +43,11 @@ pub struct AppsState {
|
||||
/// Cached host → app_id lookup; replaced after every domain CRUD
|
||||
/// operation so the orchestrator sees changes immediately.
|
||||
pub domain_table: Arc<AppDomainTable>,
|
||||
/// §11 tail: the route match snapshot. Creating/deleting an app changes
|
||||
/// which group route TEMPLATES are inherited, so it is rebuilt here
|
||||
/// (full-live invalidation) — mirroring the domain_table refresh.
|
||||
pub route_table: Arc<RouteTable>,
|
||||
/// Capability gate — Phase 3.5.
|
||||
pub authz: Arc<dyn AuthzRepo>,
|
||||
/// Group tree — resolves an app's parent group at create time
|
||||
/// (defaults to the instance root).
|
||||
pub groups: Arc<dyn GroupRepository>,
|
||||
/// §4.5 M5: creating/deleting an app changes which ancestor-group stateful
|
||||
/// templates it inherits, so its materialized cron/queue copies are
|
||||
/// reconciled here (full-live, mirroring the route_table rebuild).
|
||||
pub pool: sqlx::PgPool,
|
||||
}
|
||||
|
||||
pub fn apps_router(state: AppsState) -> Router {
|
||||
@@ -234,9 +226,6 @@ async fn create_app(
|
||||
)
|
||||
.await?
|
||||
};
|
||||
// The new app may sit under a group that owns route TEMPLATES — make them
|
||||
// servable immediately (full-live invalidation).
|
||||
refresh_route_cache(&s).await;
|
||||
Ok((StatusCode::CREATED, Json(created)))
|
||||
}
|
||||
|
||||
@@ -379,8 +368,6 @@ async fn delete_app(
|
||||
s.apps.delete(app.id).await?;
|
||||
}
|
||||
refresh_domain_cache(&s).await?;
|
||||
// Drop the deleted app's slice (and any routes it owned) from the snapshot.
|
||||
refresh_route_cache(&s).await;
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
@@ -558,24 +545,6 @@ fn validate_slug(slug: &str) -> Result<(), AppsApiError> {
|
||||
|
||||
/// Rebuild the in-memory host → app_id cache used by the orchestrator.
|
||||
/// Called after every domain CRUD operation.
|
||||
/// §11 tail: rebuild the route match snapshot after a tree mutation that
|
||||
/// changes route inheritance (an app gained/lost its place in the group tree,
|
||||
/// so the set of ancestor-group route TEMPLATES it serves changed). Best-effort
|
||||
/// to mirror the apply path — a failure leaves a stale-but-self-healing table
|
||||
/// rather than failing the committed mutation.
|
||||
async fn refresh_route_cache(state: &AppsState) {
|
||||
if let Err(e) =
|
||||
crate::route_admin::rebuild_route_table(state.routes.as_ref(), &state.route_table).await
|
||||
{
|
||||
tracing::warn!(error = %e, "apps: route table refresh failed; it will self-heal");
|
||||
}
|
||||
// §4.5 M5: reconcile materialized stateful-template copies for the changed
|
||||
// app set (best-effort; self-heals on the next mutation).
|
||||
if let Err(e) = crate::materialize::rematerialize_stateful_templates(&state.pool).await {
|
||||
tracing::warn!(error = %e, "apps: stateful-template materialization failed; it will self-heal");
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn refresh_domain_cache(state: &AppsState) -> Result<(), AppsApiError> {
|
||||
let all = state.domains.list_all().await?;
|
||||
let compiled = all
|
||||
|
||||
@@ -142,38 +142,6 @@ pub enum Capability {
|
||||
/// the group; maps to `script:write` — the same tier as `AppWriteScript`
|
||||
/// for an app, lifted to the group owner.
|
||||
GroupScriptsWrite(GroupId),
|
||||
/// Read a group-owned shared KV collection (§11.6). Resolved via the group
|
||||
/// ancestor walk; viewer+ on the owning group. Maps to `script:read`. The
|
||||
/// reads-open trust model means a script with no principal (anonymous
|
||||
/// public HTTP) bypasses this check — the structural subtree boundary
|
||||
/// (the collection only resolves for apps under the owning group) is the
|
||||
/// hard isolation; this cap refines access for authenticated callers.
|
||||
GroupKvRead(GroupId),
|
||||
/// Write a group-owned shared KV collection (§11.6). editor+ on the owning
|
||||
/// group; maps to `script:write`. Unlike the read cap, a write FAILS CLOSED
|
||||
/// for an anonymous principal — mutation of shared data always requires an
|
||||
/// authenticated caller (enforced at the service layer, not by skipping).
|
||||
GroupKvWrite(GroupId),
|
||||
/// Read a group-owned shared DOCS collection (§11.6). Viewer+ on the owning
|
||||
/// group; same reads-open trust model as `GroupKvRead`.
|
||||
GroupDocsRead(GroupId),
|
||||
/// Write a group-owned shared DOCS collection (§11.6). editor+ on the owning
|
||||
/// group; fails closed for an anonymous principal, like `GroupKvWrite`.
|
||||
GroupDocsWrite(GroupId),
|
||||
/// Read a group-owned shared FILES collection (§11.6). Viewer+ on the owning
|
||||
/// group; same reads-open trust model as `GroupKvRead`.
|
||||
GroupFilesRead(GroupId),
|
||||
/// Write a group-owned shared FILES collection (§11.6). editor+ on the owning
|
||||
/// group; fails closed for an anonymous principal, like `GroupKvWrite`.
|
||||
GroupFilesWrite(GroupId),
|
||||
/// Publish to a group-owned shared TOPIC (§11.6 D2). editor+ on the owning
|
||||
/// group; fails closed for an anonymous principal, like `GroupKvWrite` — a
|
||||
/// publish is a write to shared state.
|
||||
GroupPubsubPublish(GroupId),
|
||||
/// Enqueue into a group-owned shared QUEUE (§11.6 D3). editor+ on the owning
|
||||
/// group; fails closed for an anonymous principal, like `GroupKvWrite` — an
|
||||
/// enqueue is a write to shared state.
|
||||
GroupQueueEnqueue(GroupId),
|
||||
/// Send an outbound email from a script in this app (v1.1.7). Maps
|
||||
/// to `script:write` on API keys (sending mail is an outbound
|
||||
/// side-effect like an HTTP request). Granted to `editor`+.
|
||||
@@ -240,15 +208,7 @@ impl Capability {
|
||||
| Self::GroupSecretsRead(_)
|
||||
| Self::GroupSecretsWrite(_)
|
||||
| Self::GroupScriptsRead(_)
|
||||
| Self::GroupScriptsWrite(_)
|
||||
| Self::GroupKvRead(_)
|
||||
| Self::GroupKvWrite(_)
|
||||
| Self::GroupDocsRead(_)
|
||||
| Self::GroupDocsWrite(_)
|
||||
| Self::GroupFilesRead(_)
|
||||
| Self::GroupFilesWrite(_)
|
||||
| Self::GroupPubsubPublish(_)
|
||||
| Self::GroupQueueEnqueue(_) => None,
|
||||
| Self::GroupScriptsWrite(_) => None,
|
||||
Self::AppRead(id)
|
||||
| Self::AppWriteScript(id)
|
||||
| Self::AppWriteRoute(id)
|
||||
@@ -300,10 +260,7 @@ impl Capability {
|
||||
| Self::AppVarsRead(_)
|
||||
| Self::GroupRead(_)
|
||||
| Self::GroupVarsRead(_)
|
||||
| Self::GroupScriptsRead(_)
|
||||
| Self::GroupKvRead(_)
|
||||
| Self::GroupDocsRead(_)
|
||||
| Self::GroupFilesRead(_) => Scope::ScriptRead,
|
||||
| Self::GroupScriptsRead(_) => Scope::ScriptRead,
|
||||
Self::AppWriteScript(_)
|
||||
| Self::AppKvWrite(_)
|
||||
| Self::AppDocsWrite(_)
|
||||
@@ -322,11 +279,6 @@ impl Capability {
|
||||
// the admin tier below.
|
||||
| Self::GroupSecretsWrite(_)
|
||||
| Self::GroupScriptsWrite(_)
|
||||
| Self::GroupKvWrite(_)
|
||||
| Self::GroupDocsWrite(_)
|
||||
| Self::GroupFilesWrite(_)
|
||||
| Self::GroupPubsubPublish(_)
|
||||
| Self::GroupQueueEnqueue(_)
|
||||
| Self::AppInvoke(_) => Scope::ScriptWrite,
|
||||
Self::AppWriteRoute(_) => Scope::RouteWrite,
|
||||
Self::AppManageDomains(_) => Scope::DomainManage,
|
||||
@@ -489,34 +441,6 @@ pub async fn script_gate<E>(
|
||||
}
|
||||
}
|
||||
|
||||
/// Like [`script_gate`], but **fails closed on an anonymous principal**: a
|
||||
/// script with `cx.principal == None` is rejected with `forbidden()` rather
|
||||
/// than skipped. Used for actions that must always be performed by an
|
||||
/// authenticated caller even though the surrounding service skips authz for
|
||||
/// public scripts — e.g. §11.6 group-collection WRITES (reads stay open via
|
||||
/// `script_gate`, writes require an authenticated editor+ on the owning group).
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns `forbidden()` when the principal is absent or the capability is
|
||||
/// denied, or `backend(repo_err.to_string())` on a repo error.
|
||||
pub async fn script_gate_require_principal<E>(
|
||||
repo: &dyn AuthzRepo,
|
||||
cx: &picloud_shared::SdkCallCx,
|
||||
cap: Capability,
|
||||
forbidden: impl FnOnce() -> E,
|
||||
backend: impl FnOnce(String) -> E,
|
||||
) -> Result<(), E> {
|
||||
let Some(principal) = cx.principal.as_ref() else {
|
||||
return Err(forbidden());
|
||||
};
|
||||
match require(repo, principal, cap).await {
|
||||
Ok(()) => Ok(()),
|
||||
Err(AuthzDenied::Denied) => Err(forbidden()),
|
||||
Err(AuthzDenied::Repo(e)) => Err(backend(e.to_string())),
|
||||
}
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// Layer 1: role-derived grant
|
||||
// ----------------------------------------------------------------------------
|
||||
@@ -542,15 +466,7 @@ async fn role_grants(
|
||||
| Capability::GroupSecretsRead(g)
|
||||
| Capability::GroupSecretsWrite(g)
|
||||
| Capability::GroupScriptsRead(g)
|
||||
| Capability::GroupScriptsWrite(g)
|
||||
| Capability::GroupKvRead(g)
|
||||
| Capability::GroupKvWrite(g)
|
||||
| Capability::GroupDocsRead(g)
|
||||
| Capability::GroupDocsWrite(g)
|
||||
| Capability::GroupFilesRead(g)
|
||||
| Capability::GroupFilesWrite(g)
|
||||
| Capability::GroupPubsubPublish(g)
|
||||
| Capability::GroupQueueEnqueue(g) => {
|
||||
| Capability::GroupScriptsWrite(g) => {
|
||||
group_member_grants(repo, principal.user_id, cap, g).await
|
||||
}
|
||||
// Creating a root-level group is an instance act — members
|
||||
@@ -610,23 +526,15 @@ async fn group_member_grants(
|
||||
/// viewer→read, editor→write, group_admin(=AppAdmin)→admin.
|
||||
const fn group_role_satisfies(role: AppRole, cap: Capability) -> bool {
|
||||
match cap {
|
||||
// viewer+ reads group metadata, config vars, scripts, and shared KV.
|
||||
// viewer+ reads group metadata, config vars, and scripts.
|
||||
Capability::GroupRead(_)
|
||||
| Capability::GroupVarsRead(_)
|
||||
| Capability::GroupScriptsRead(_)
|
||||
| Capability::GroupKvRead(_)
|
||||
| Capability::GroupDocsRead(_)
|
||||
| Capability::GroupFilesRead(_) => true,
|
||||
// editor+ writes config vars/secrets/scripts and shared KV.
|
||||
| Capability::GroupScriptsRead(_) => true,
|
||||
// editor+ writes config vars/secrets/scripts.
|
||||
Capability::GroupWrite(_)
|
||||
| Capability::GroupVarsWrite(_)
|
||||
| Capability::GroupSecretsWrite(_)
|
||||
| Capability::GroupScriptsWrite(_)
|
||||
| Capability::GroupKvWrite(_)
|
||||
| Capability::GroupDocsWrite(_)
|
||||
| Capability::GroupFilesWrite(_)
|
||||
| Capability::GroupPubsubPublish(_)
|
||||
| Capability::GroupQueueEnqueue(_) => {
|
||||
| Capability::GroupScriptsWrite(_) => {
|
||||
matches!(role, AppRole::Editor | AppRole::AppAdmin)
|
||||
}
|
||||
// group_admin manages the group + reads secret VALUES (the
|
||||
@@ -1307,147 +1215,6 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn group_kv_caps_resolve_by_role_up_the_chain() {
|
||||
// §11.6: shared-KV read is viewer+, write is editor+, both resolved via
|
||||
// the group ancestor walk; an outsider gets nothing.
|
||||
let repo = InMemoryAuthzRepo::default();
|
||||
let root = GroupId::new();
|
||||
let team = GroupId::new();
|
||||
repo.add_group(root, None).await;
|
||||
repo.add_group(team, Some(root)).await;
|
||||
|
||||
// A viewer at root can READ a descendant group's shared KV but not write.
|
||||
let viewer = principal(InstanceRole::Member);
|
||||
repo.grant_group(viewer.user_id, root, AppRole::Viewer)
|
||||
.await;
|
||||
assert!(can(&repo, &viewer, Capability::GroupKvRead(team))
|
||||
.await
|
||||
.unwrap()
|
||||
.is_allow());
|
||||
assert_eq!(
|
||||
can(&repo, &viewer, Capability::GroupKvWrite(team))
|
||||
.await
|
||||
.unwrap(),
|
||||
Decision::Deny
|
||||
);
|
||||
|
||||
// An editor at root can WRITE it.
|
||||
let editor = principal(InstanceRole::Member);
|
||||
repo.grant_group(editor.user_id, root, AppRole::Editor)
|
||||
.await;
|
||||
assert!(can(&repo, &editor, Capability::GroupKvWrite(team))
|
||||
.await
|
||||
.unwrap()
|
||||
.is_allow());
|
||||
|
||||
// An unrelated member gets neither.
|
||||
let outsider = principal(InstanceRole::Member);
|
||||
assert_eq!(
|
||||
can(&repo, &outsider, Capability::GroupKvRead(team))
|
||||
.await
|
||||
.unwrap(),
|
||||
Decision::Deny
|
||||
);
|
||||
|
||||
// Group caps carry no app_id, so a bound key is denied at the binding
|
||||
// layer regardless of role.
|
||||
let bound = Principal {
|
||||
user_id: AdminUserId::new(),
|
||||
instance_role: InstanceRole::Owner,
|
||||
scopes: Some(vec![Scope::ScriptWrite]),
|
||||
app_binding: Some(AppId::new()),
|
||||
};
|
||||
assert_eq!(
|
||||
can(&repo, &bound, Capability::GroupKvWrite(team))
|
||||
.await
|
||||
.unwrap(),
|
||||
Decision::Deny
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn group_docs_caps_resolve_by_role_up_the_chain() {
|
||||
// §11.6 docs: same trust shape as shared KV — read is viewer+, write is
|
||||
// editor+, resolved via the group ancestor walk.
|
||||
let repo = InMemoryAuthzRepo::default();
|
||||
let root = GroupId::new();
|
||||
let team = GroupId::new();
|
||||
repo.add_group(root, None).await;
|
||||
repo.add_group(team, Some(root)).await;
|
||||
|
||||
let viewer = principal(InstanceRole::Member);
|
||||
repo.grant_group(viewer.user_id, root, AppRole::Viewer)
|
||||
.await;
|
||||
assert!(can(&repo, &viewer, Capability::GroupDocsRead(team))
|
||||
.await
|
||||
.unwrap()
|
||||
.is_allow());
|
||||
assert_eq!(
|
||||
can(&repo, &viewer, Capability::GroupDocsWrite(team))
|
||||
.await
|
||||
.unwrap(),
|
||||
Decision::Deny
|
||||
);
|
||||
|
||||
let editor = principal(InstanceRole::Member);
|
||||
repo.grant_group(editor.user_id, root, AppRole::Editor)
|
||||
.await;
|
||||
assert!(can(&repo, &editor, Capability::GroupDocsWrite(team))
|
||||
.await
|
||||
.unwrap()
|
||||
.is_allow());
|
||||
|
||||
let outsider = principal(InstanceRole::Member);
|
||||
assert_eq!(
|
||||
can(&repo, &outsider, Capability::GroupDocsRead(team))
|
||||
.await
|
||||
.unwrap(),
|
||||
Decision::Deny
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn group_files_caps_resolve_by_role_up_the_chain() {
|
||||
// §11.6 files: same trust shape as shared KV/docs — read is viewer+,
|
||||
// write is editor+, resolved via the group ancestor walk.
|
||||
let repo = InMemoryAuthzRepo::default();
|
||||
let root = GroupId::new();
|
||||
let team = GroupId::new();
|
||||
repo.add_group(root, None).await;
|
||||
repo.add_group(team, Some(root)).await;
|
||||
|
||||
let viewer = principal(InstanceRole::Member);
|
||||
repo.grant_group(viewer.user_id, root, AppRole::Viewer)
|
||||
.await;
|
||||
assert!(can(&repo, &viewer, Capability::GroupFilesRead(team))
|
||||
.await
|
||||
.unwrap()
|
||||
.is_allow());
|
||||
assert_eq!(
|
||||
can(&repo, &viewer, Capability::GroupFilesWrite(team))
|
||||
.await
|
||||
.unwrap(),
|
||||
Decision::Deny
|
||||
);
|
||||
|
||||
let editor = principal(InstanceRole::Member);
|
||||
repo.grant_group(editor.user_id, root, AppRole::Editor)
|
||||
.await;
|
||||
assert!(can(&repo, &editor, Capability::GroupFilesWrite(team))
|
||||
.await
|
||||
.unwrap()
|
||||
.is_allow());
|
||||
|
||||
let outsider = principal(InstanceRole::Member);
|
||||
assert_eq!(
|
||||
can(&repo, &outsider, Capability::GroupFilesRead(team))
|
||||
.await
|
||||
.unwrap(),
|
||||
Decision::Deny
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn admin_implicitly_manages_the_whole_group_tree() {
|
||||
let repo = InMemoryAuthzRepo::default();
|
||||
|
||||
@@ -226,17 +226,7 @@ pub async fn fetch_var_candidates(
|
||||
pool: &PgPool,
|
||||
app_id: AppId,
|
||||
) -> Result<Vec<Candidate>, sqlx::Error> {
|
||||
let sql = format!(
|
||||
"{CHAIN_LEVELS_CTE} \
|
||||
SELECT c.depth, \
|
||||
CASE WHEN v.app_id IS NOT NULL THEN 'app' ELSE 'group' END AS owner_kind, \
|
||||
COALESCE(v.app_id, v.group_id) AS owner_id, \
|
||||
v.environment_scope, v.key, v.value, v.is_tombstone \
|
||||
FROM chain c \
|
||||
JOIN vars v ON (v.app_id = c.app_owner OR v.group_id = c.group_owner) \
|
||||
WHERE v.environment_scope = '*' OR v.environment_scope = c.app_env \
|
||||
ORDER BY c.depth ASC"
|
||||
);
|
||||
let sql = format!("{CHAIN_LEVELS_CTE} {VAR_CANDIDATES_TAIL}");
|
||||
let rows = sqlx::query_as::<_, VarCandidateRow>(&sql)
|
||||
.bind(app_id.into_inner())
|
||||
.fetch_all(pool)
|
||||
@@ -244,6 +234,39 @@ pub async fn fetch_var_candidates(
|
||||
Ok(rows.into_iter().map(Into::into).collect())
|
||||
}
|
||||
|
||||
/// Transaction-scoped twin of [`fetch_var_candidates`]: runs the identical
|
||||
/// `CHAIN_LEVELS_CTE` query against an open transaction, so it sees vars
|
||||
/// **written earlier in the same transaction** (not just committed state).
|
||||
/// Used by template expansion so a `{var:NAME}` placeholder resolves against
|
||||
/// a var set in the *same* `pic apply` rather than landing one apply late.
|
||||
///
|
||||
/// # Errors
|
||||
/// Propagates sqlx errors.
|
||||
pub async fn fetch_var_candidates_tx(
|
||||
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
|
||||
app_id: AppId,
|
||||
) -> Result<Vec<Candidate>, sqlx::Error> {
|
||||
let sql = format!("{CHAIN_LEVELS_CTE} {VAR_CANDIDATES_TAIL}");
|
||||
let rows = sqlx::query_as::<_, VarCandidateRow>(&sql)
|
||||
.bind(app_id.into_inner())
|
||||
.fetch_all(&mut **tx)
|
||||
.await?;
|
||||
Ok(rows.into_iter().map(Into::into).collect())
|
||||
}
|
||||
|
||||
/// The `SELECT … FROM chain …` tail appended after [`CHAIN_LEVELS_CTE`] to
|
||||
/// pull env-eligible `vars` candidates, nearest-first. Shared by the pool and
|
||||
/// transaction variants so the two can't drift.
|
||||
const VAR_CANDIDATES_TAIL: &str = "\
|
||||
SELECT c.depth, \
|
||||
CASE WHEN v.app_id IS NOT NULL THEN 'app' ELSE 'group' END AS owner_kind, \
|
||||
COALESCE(v.app_id, v.group_id) AS owner_id, \
|
||||
v.environment_scope, v.key, v.value, v.is_tombstone \
|
||||
FROM chain c \
|
||||
JOIN vars v ON (v.app_id = c.app_owner OR v.group_id = c.group_owner) \
|
||||
WHERE v.environment_scope = '*' OR v.environment_scope = c.app_env \
|
||||
ORDER BY c.depth ASC";
|
||||
|
||||
/// The masked, resolved view of one inherited secret for `config/effective`:
|
||||
/// which owner/level/scope supplies it — **never** the value.
|
||||
#[derive(Debug, Clone)]
|
||||
|
||||
@@ -119,7 +119,7 @@ async fn tick(pool: &PgPool, now: DateTime<Utc>) -> Result<usize, sqlx::Error> {
|
||||
d.schedule, d.timezone, d.last_fired_at \
|
||||
FROM cron_trigger_details d \
|
||||
JOIN triggers t ON t.id = d.trigger_id \
|
||||
WHERE t.enabled = TRUE AND t.app_id IS NOT NULL \
|
||||
WHERE t.enabled = TRUE \
|
||||
FOR UPDATE OF d SKIP LOCKED",
|
||||
)
|
||||
.fetch_all(&mut *tx)
|
||||
|
||||
@@ -31,8 +31,7 @@ use picloud_orchestrator_core::{ExecutionGate, ExecutorClient};
|
||||
use picloud_shared::{
|
||||
AppId, DeadLetterId, ExecResponseSummary, ExecutionId, ExecutionLogSink, ExecutionSource,
|
||||
HttpDispatchPayload, InboxDeliveryOutcome, InboxFailureKind, InboxResolver, InboxResult,
|
||||
QueueMessageId, RequestId, Script, ScriptId, ScriptOwner, ScriptSandbox, TriggerEvent,
|
||||
TriggerId,
|
||||
RequestId, Script, ScriptId, ScriptOwner, ScriptSandbox, TriggerEvent,
|
||||
};
|
||||
use rand::Rng;
|
||||
use uuid::Uuid;
|
||||
@@ -66,10 +65,6 @@ pub struct Dispatcher {
|
||||
/// v1.1.9. Reads `queue_messages` for the queue arm + the reclaim
|
||||
/// task. None in tests / harnesses that don't exercise queues.
|
||||
pub queue: Arc<dyn QueueRepo>,
|
||||
/// §11.6 D3. The group shared-queue store. A materialized consumer of a
|
||||
/// SHARED group queue template (`consumer.shared_group.is_some()`) claims
|
||||
/// from here instead of the per-app `queue`.
|
||||
pub group_queue: Arc<dyn crate::group_queue_repo::GroupQueueRepo>,
|
||||
pub config: TriggerConfig,
|
||||
/// Stable id for this dispatcher instance — written into
|
||||
/// `outbox.claimed_by` for forensics. In MVP this is the host's
|
||||
@@ -230,7 +225,6 @@ impl Dispatcher {
|
||||
// Reclaim task: independent cadence (default 30s) so it doesn't
|
||||
// contend with the per-100ms dispatcher tick.
|
||||
let reclaim_queue = self.queue.clone();
|
||||
let reclaim_group_queue = self.group_queue.clone();
|
||||
let reclaim_interval =
|
||||
Duration::from_millis(u64::from(self.config.queue_reclaim_interval_ms));
|
||||
tokio::spawn(async move {
|
||||
@@ -243,14 +237,6 @@ impl Dispatcher {
|
||||
Ok(n) => tracing::info!(reclaimed = n, "queue visibility-timeout reclaim"),
|
||||
Err(e) => tracing::warn!(?e, "queue reclaim task errored"),
|
||||
}
|
||||
// §11.6 D3: the group shared-queue store has the same reclaim.
|
||||
match reclaim_group_queue.reclaim_visibility_timeouts().await {
|
||||
Ok(0) => {}
|
||||
Ok(n) => {
|
||||
tracing::info!(reclaimed = n, "group-queue visibility-timeout reclaim");
|
||||
}
|
||||
Err(e) => tracing::warn!(?e, "group-queue reclaim task errored"),
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -327,126 +313,6 @@ impl Dispatcher {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// §11.6 D3: route the four queue store operations to the per-app store or
|
||||
// the group shared store, based on whether this consumer was materialized
|
||||
// from a SHARED group queue template (`consumer.shared_group`). A group
|
||||
// claim is normalized to a `ClaimedMessage` under the CONSUMING app so the
|
||||
// rest of the queue arm (handler dispatch, event, logging) is unchanged.
|
||||
async fn q_claim(&self, c: &ActiveQueueConsumer) -> Result<Option<ClaimedMessage>, String> {
|
||||
match c.shared_group {
|
||||
Some(g) => Ok(self
|
||||
.group_queue
|
||||
.claim(g, &c.queue_name)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?
|
||||
.map(|m| ClaimedMessage {
|
||||
id: m.id,
|
||||
app_id: c.app_id,
|
||||
queue_name: m.collection,
|
||||
payload: m.payload,
|
||||
enqueued_at: m.enqueued_at,
|
||||
attempt: m.attempt,
|
||||
max_attempts: m.max_attempts,
|
||||
claim_token: m.claim_token,
|
||||
})),
|
||||
None => self
|
||||
.queue
|
||||
.claim(c.app_id, &c.queue_name)
|
||||
.await
|
||||
.map_err(|e| e.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
async fn q_ack(
|
||||
&self,
|
||||
c: &ActiveQueueConsumer,
|
||||
id: QueueMessageId,
|
||||
token: uuid::Uuid,
|
||||
) -> Result<bool, String> {
|
||||
match c.shared_group {
|
||||
Some(_) => self
|
||||
.group_queue
|
||||
.ack(id, token)
|
||||
.await
|
||||
.map_err(|e| e.to_string()),
|
||||
None => self.queue.ack(id, token).await.map_err(|e| e.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
async fn q_nack(
|
||||
&self,
|
||||
c: &ActiveQueueConsumer,
|
||||
id: QueueMessageId,
|
||||
token: uuid::Uuid,
|
||||
delay: chrono::Duration,
|
||||
) -> Result<bool, String> {
|
||||
match c.shared_group {
|
||||
Some(_) => self
|
||||
.group_queue
|
||||
.nack(id, token, delay)
|
||||
.await
|
||||
.map_err(|e| e.to_string()),
|
||||
None => self
|
||||
.queue
|
||||
.nack(id, token, delay)
|
||||
.await
|
||||
.map_err(|e| e.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Terminal disposition of a message that can't be processed (script
|
||||
/// missing / cross-app / exhausted). Per-app → dead-letter (+ the caller
|
||||
/// fans out `dead_letter` triggers). Group shared queue → drop the row
|
||||
/// (no group dead-letter store yet — documented D3 deferral) + warn.
|
||||
/// Returns the dead-letter id only for the per-app path (drives fan-out).
|
||||
async fn q_terminal(
|
||||
&self,
|
||||
c: &ActiveQueueConsumer,
|
||||
claimed: &ClaimedMessage,
|
||||
trigger_id: Option<TriggerId>,
|
||||
script_id: Option<ScriptId>,
|
||||
reason: &str,
|
||||
) -> Option<DeadLetterId> {
|
||||
match c.shared_group {
|
||||
Some(_) => {
|
||||
if let Err(e) = self
|
||||
.group_queue
|
||||
.drop_exhausted(claimed.id, claimed.claim_token)
|
||||
.await
|
||||
{
|
||||
tracing::warn!(?e, "shared-queue drop failed");
|
||||
}
|
||||
tracing::warn!(
|
||||
reason,
|
||||
queue = %claimed.queue_name,
|
||||
"shared-queue message dropped (no group dead-letter store yet)"
|
||||
);
|
||||
None
|
||||
}
|
||||
None => match self
|
||||
.queue
|
||||
.dead_letter(
|
||||
claimed.id,
|
||||
claimed.claim_token,
|
||||
claimed.app_id,
|
||||
&claimed.queue_name,
|
||||
trigger_id,
|
||||
script_id,
|
||||
claimed.attempt,
|
||||
claimed.enqueued_at,
|
||||
reason,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(dl_id) => Some(dl_id),
|
||||
Err(e) => {
|
||||
tracing::error!(?e, "queue dead-letter write failed");
|
||||
None
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_lines)]
|
||||
async fn dispatch_one_queue(
|
||||
&self,
|
||||
@@ -454,9 +320,10 @@ impl Dispatcher {
|
||||
) -> Result<(), DispatcherError> {
|
||||
// Atomic claim — None → nothing pending right now for this queue.
|
||||
let Some(claimed) = self
|
||||
.q_claim(consumer)
|
||||
.queue
|
||||
.claim(consumer.app_id, &consumer.queue_name)
|
||||
.await
|
||||
.map_err(DispatcherError::Outbox)?
|
||||
.map_err(|e| DispatcherError::Outbox(e.to_string()))?
|
||||
else {
|
||||
return Ok(());
|
||||
};
|
||||
@@ -466,8 +333,8 @@ impl Dispatcher {
|
||||
// outbox arm.
|
||||
let Ok(permit) = self.gate.try_acquire() else {
|
||||
let _ = self
|
||||
.q_nack(
|
||||
consumer,
|
||||
.queue
|
||||
.nack(
|
||||
claimed.id,
|
||||
claimed.claim_token,
|
||||
chrono::Duration::milliseconds(100),
|
||||
@@ -492,11 +359,16 @@ impl Dispatcher {
|
||||
Ok(None) => {
|
||||
tracing::warn!(script_id = %consumer.script_id, "queue trigger script missing; dead-lettering");
|
||||
let _ = self
|
||||
.q_terminal(
|
||||
consumer,
|
||||
&claimed,
|
||||
.queue
|
||||
.dead_letter(
|
||||
claimed.id,
|
||||
claimed.claim_token,
|
||||
claimed.app_id,
|
||||
&claimed.queue_name,
|
||||
Some(consumer.trigger_id),
|
||||
Some(consumer.script_id),
|
||||
claimed.attempt,
|
||||
claimed.enqueued_at,
|
||||
"queue trigger script not found",
|
||||
)
|
||||
.await;
|
||||
@@ -526,11 +398,16 @@ impl Dispatcher {
|
||||
"queue consumer script belongs to a different app; dead-lettering"
|
||||
);
|
||||
let _ = self
|
||||
.q_terminal(
|
||||
consumer,
|
||||
&claimed,
|
||||
.queue
|
||||
.dead_letter(
|
||||
claimed.id,
|
||||
claimed.claim_token,
|
||||
claimed.app_id,
|
||||
&claimed.queue_name,
|
||||
Some(consumer.trigger_id),
|
||||
Some(consumer.script_id),
|
||||
claimed.attempt,
|
||||
claimed.enqueued_at,
|
||||
"queue consumer target belongs to a different app",
|
||||
)
|
||||
.await;
|
||||
@@ -557,8 +434,8 @@ impl Dispatcher {
|
||||
"queue consumer script disabled at fire time; releasing claim"
|
||||
);
|
||||
if let Err(e) = self
|
||||
.q_nack(
|
||||
consumer,
|
||||
.queue
|
||||
.nack(
|
||||
claimed.id,
|
||||
claimed.claim_token,
|
||||
chrono::Duration::seconds(1),
|
||||
@@ -638,7 +515,7 @@ impl Dispatcher {
|
||||
match outcome {
|
||||
Ok(_) => {
|
||||
// Auto-ack on success.
|
||||
if let Err(e) = self.q_ack(consumer, claimed.id, claimed.claim_token).await {
|
||||
if let Err(e) = self.queue.ack(claimed.id, claimed.claim_token).await {
|
||||
tracing::warn!(?e, "queue ack failed");
|
||||
}
|
||||
}
|
||||
@@ -665,7 +542,8 @@ impl Dispatcher {
|
||||
);
|
||||
let delay = chrono::Duration::milliseconds(i64::from(delay_ms));
|
||||
if let Err(e) = self
|
||||
.q_nack(consumer, claimed.id, claimed.claim_token, delay)
|
||||
.queue
|
||||
.nack(claimed.id, claimed.claim_token, delay)
|
||||
.await
|
||||
{
|
||||
tracing::warn!(?e, "queue nack failed");
|
||||
@@ -680,18 +558,27 @@ impl Dispatcher {
|
||||
// same way here.
|
||||
let now = Utc::now();
|
||||
let last_error = err.to_string();
|
||||
// Per-app → dead-letter (+ fan out below). Shared group queue → dropped
|
||||
// inside q_terminal (no group dead-letter store yet), returns None so
|
||||
// the fan-out is skipped.
|
||||
let dl_id = self
|
||||
.q_terminal(
|
||||
consumer,
|
||||
claimed,
|
||||
let dl_id = match self
|
||||
.queue
|
||||
.dead_letter(
|
||||
claimed.id,
|
||||
claimed.claim_token,
|
||||
claimed.app_id,
|
||||
&claimed.queue_name,
|
||||
Some(consumer.trigger_id),
|
||||
Some(consumer.script_id),
|
||||
claimed.attempt,
|
||||
claimed.enqueued_at,
|
||||
&last_error,
|
||||
)
|
||||
.await;
|
||||
.await
|
||||
{
|
||||
Ok(dl_id) => Some(dl_id),
|
||||
Err(e) => {
|
||||
tracing::error!(?e, "queue dead-letter write failed");
|
||||
None
|
||||
}
|
||||
};
|
||||
if let Some(dead_letter_id) = dl_id {
|
||||
let original = TriggerEvent::Queue {
|
||||
queue_name: claimed.queue_name.clone(),
|
||||
@@ -1625,72 +1512,6 @@ fn apply_jitter(raw: u32, pct: u32) -> u32 {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// §11.6 D3: a do-nothing group-queue store for dispatcher tests that don't
|
||||
/// exercise SHARED queues (every consumer has `shared_group: None`, so these
|
||||
/// methods are never reached — they just satisfy the struct field).
|
||||
struct NoopGroupQueue;
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl crate::group_queue_repo::GroupQueueRepo for NoopGroupQueue {
|
||||
async fn enqueue(
|
||||
&self,
|
||||
_msg: crate::group_queue_repo::NewGroupQueueMessage,
|
||||
) -> Result<QueueMessageId, crate::group_queue_repo::GroupQueueRepoError> {
|
||||
unreachable!("shared queue not exercised")
|
||||
}
|
||||
async fn claim(
|
||||
&self,
|
||||
_group_id: picloud_shared::GroupId,
|
||||
_collection: &str,
|
||||
) -> Result<
|
||||
Option<crate::group_queue_repo::ClaimedGroupMessage>,
|
||||
crate::group_queue_repo::GroupQueueRepoError,
|
||||
> {
|
||||
Ok(None)
|
||||
}
|
||||
async fn ack(
|
||||
&self,
|
||||
_id: QueueMessageId,
|
||||
_token: Uuid,
|
||||
) -> Result<bool, crate::group_queue_repo::GroupQueueRepoError> {
|
||||
Ok(false)
|
||||
}
|
||||
async fn nack(
|
||||
&self,
|
||||
_id: QueueMessageId,
|
||||
_token: Uuid,
|
||||
_delay: chrono::Duration,
|
||||
) -> Result<bool, crate::group_queue_repo::GroupQueueRepoError> {
|
||||
Ok(false)
|
||||
}
|
||||
async fn drop_exhausted(
|
||||
&self,
|
||||
_id: QueueMessageId,
|
||||
_token: Uuid,
|
||||
) -> Result<bool, crate::group_queue_repo::GroupQueueRepoError> {
|
||||
Ok(false)
|
||||
}
|
||||
async fn reclaim_visibility_timeouts(
|
||||
&self,
|
||||
) -> Result<u64, crate::group_queue_repo::GroupQueueRepoError> {
|
||||
Ok(0)
|
||||
}
|
||||
async fn depth(
|
||||
&self,
|
||||
_group_id: picloud_shared::GroupId,
|
||||
_collection: &str,
|
||||
) -> Result<u64, crate::group_queue_repo::GroupQueueRepoError> {
|
||||
Ok(0)
|
||||
}
|
||||
async fn depth_pending(
|
||||
&self,
|
||||
_group_id: picloud_shared::GroupId,
|
||||
_collection: &str,
|
||||
) -> Result<u64, crate::group_queue_repo::GroupQueueRepoError> {
|
||||
Ok(0)
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exponential_backoff_doubles_per_attempt() {
|
||||
// No jitter (pct=0) for a deterministic check.
|
||||
@@ -1957,14 +1778,14 @@ mod tests {
|
||||
}
|
||||
async fn ack(
|
||||
&self,
|
||||
_message_id: QueueMessageId,
|
||||
_message_id: picloud_shared::QueueMessageId,
|
||||
_claim_token: Uuid,
|
||||
) -> Result<bool, crate::queue_repo::QueueRepoError> {
|
||||
unimplemented!("not used by this test")
|
||||
}
|
||||
async fn nack(
|
||||
&self,
|
||||
_message_id: QueueMessageId,
|
||||
_message_id: picloud_shared::QueueMessageId,
|
||||
_claim_token: Uuid,
|
||||
_retry_delay: chrono::Duration,
|
||||
) -> Result<bool, crate::queue_repo::QueueRepoError> {
|
||||
@@ -1999,7 +1820,7 @@ mod tests {
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
async fn dead_letter(
|
||||
&self,
|
||||
_message_id: QueueMessageId,
|
||||
_message_id: picloud_shared::QueueMessageId,
|
||||
_claim_token: Uuid,
|
||||
_app_id: AppId,
|
||||
_queue_name: &str,
|
||||
@@ -2384,7 +2205,6 @@ mod tests {
|
||||
retry_backoff: BackoffShape::Exponential,
|
||||
retry_base_ms: 1000,
|
||||
registered_by_principal: AdminUserId::new(),
|
||||
shared_group: None,
|
||||
};
|
||||
|
||||
let nacked = Arc::new(AtomicBool::new(false));
|
||||
@@ -2409,7 +2229,6 @@ mod tests {
|
||||
claimed,
|
||||
nacked: nacked.clone(),
|
||||
}),
|
||||
group_queue: Arc::new(NoopGroupQueue),
|
||||
config: TriggerConfig::from_env(),
|
||||
instance_id: "test-instance".into(),
|
||||
};
|
||||
@@ -2491,14 +2310,14 @@ mod tests {
|
||||
}
|
||||
async fn ack(
|
||||
&self,
|
||||
_message_id: QueueMessageId,
|
||||
_message_id: picloud_shared::QueueMessageId,
|
||||
_claim_token: Uuid,
|
||||
) -> Result<bool, crate::queue_repo::QueueRepoError> {
|
||||
unimplemented!("not used by this test")
|
||||
}
|
||||
async fn nack(
|
||||
&self,
|
||||
_message_id: QueueMessageId,
|
||||
_message_id: picloud_shared::QueueMessageId,
|
||||
_claim_token: Uuid,
|
||||
_retry_delay: chrono::Duration,
|
||||
) -> Result<bool, crate::queue_repo::QueueRepoError> {
|
||||
@@ -2535,7 +2354,7 @@ mod tests {
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
async fn dead_letter(
|
||||
&self,
|
||||
_message_id: QueueMessageId,
|
||||
_message_id: picloud_shared::QueueMessageId,
|
||||
_claim_token: Uuid,
|
||||
_app_id: AppId,
|
||||
_queue_name: &str,
|
||||
@@ -2642,7 +2461,6 @@ mod tests {
|
||||
log_sink: Arc::new(UnusedLogSink),
|
||||
inbox: Arc::new(UnusedInbox),
|
||||
queue: Arc::new(UnusedQueue),
|
||||
group_queue: Arc::new(NoopGroupQueue),
|
||||
config: TriggerConfig::from_env(),
|
||||
instance_id: "test-instance".into(),
|
||||
};
|
||||
|
||||
@@ -167,7 +167,7 @@ impl DocsRepo for PostgresDocsRepo {
|
||||
collection: &str,
|
||||
filter: &DocsFilter,
|
||||
) -> Result<Vec<DocRow>, DocsRepoError> {
|
||||
let mut qb = build_find_query("docs", "app_id", app_id.into_inner(), collection, filter);
|
||||
let mut qb = build_find_query(app_id, collection, filter);
|
||||
let rows = qb.build().fetch_all(&self.pool).await?;
|
||||
rows.into_iter().map(row_to_doc).collect()
|
||||
}
|
||||
@@ -281,7 +281,7 @@ impl DocsRepo for PostgresDocsRepo {
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn row_to_doc(row: PgRow) -> Result<DocRow, DocsRepoError> {
|
||||
fn row_to_doc(row: PgRow) -> Result<DocRow, DocsRepoError> {
|
||||
Ok(DocRow {
|
||||
id: row.try_get("id")?,
|
||||
data: row.try_get("data")?,
|
||||
@@ -316,22 +316,14 @@ fn decode_cursor(cursor: &str) -> Result<Uuid, DocsRepoError> {
|
||||
// **No user input ever lands in the SQL text unparameterized.**
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
/// Build the `find` query for a docs store. `table` and `owner_col` are
|
||||
/// compile-time literals (`"docs"`/`"app_id"` for app docs, `"group_docs"`/
|
||||
/// `"group_id"` for §11.6 group-shared docs) — never user input, so
|
||||
/// interpolating them is injection-safe. `pub(crate)` so the group-docs repo
|
||||
/// reuses this single source for the (security-sensitive) query SQL.
|
||||
pub(crate) fn build_find_query<'a>(
|
||||
table: &'static str,
|
||||
owner_col: &'static str,
|
||||
owner_id: uuid::Uuid,
|
||||
fn build_find_query<'a>(
|
||||
app_id: AppId,
|
||||
collection: &'a str,
|
||||
filter: &'a DocsFilter,
|
||||
) -> QueryBuilder<'a, Postgres> {
|
||||
let mut qb = QueryBuilder::new(format!(
|
||||
"SELECT id, data, created_at, updated_at FROM {table} WHERE {owner_col} = "
|
||||
));
|
||||
qb.push_bind(owner_id);
|
||||
let mut qb =
|
||||
QueryBuilder::new("SELECT id, data, created_at, updated_at FROM docs WHERE app_id = ");
|
||||
qb.push_bind(app_id.into_inner());
|
||||
qb.push(" AND collection = ");
|
||||
qb.push_bind(collection);
|
||||
|
||||
@@ -456,13 +448,7 @@ mod sql_shape_tests {
|
||||
|
||||
fn sql_for(filter_json: serde_json::Value) -> String {
|
||||
let filter = parse_filter(&filter_json).unwrap();
|
||||
let qb = build_find_query(
|
||||
"docs",
|
||||
"app_id",
|
||||
AppId::new().into_inner(),
|
||||
"users",
|
||||
&filter,
|
||||
);
|
||||
let qb = build_find_query(AppId::new(), "users", &filter);
|
||||
qb.sql().to_string()
|
||||
}
|
||||
|
||||
|
||||
@@ -1,117 +0,0 @@
|
||||
//! Extension-point markers (§5.5) — the `extension_points` table (0051).
|
||||
//!
|
||||
//! An extension point is a pure marker `(owner, name)` declaring that a module
|
||||
//! name is one descendants may provide/override (the import resolver then
|
||||
//! resolves it dynamically against the inheriting app — see
|
||||
//! [`crate::module_source`]). This module holds the read + transactional-write
|
||||
//! helpers; it mirrors the var/secret tx-function style (free functions over a
|
||||
//! `&PgPool` / `&mut Transaction`), keyed by the shared [`ScriptOwner`].
|
||||
|
||||
use picloud_shared::{AppId, ScriptOwner};
|
||||
use sqlx::{PgPool, Postgres, Transaction};
|
||||
|
||||
use crate::config_resolver::CHAIN_LEVELS_CTE;
|
||||
|
||||
/// List the EP names declared **directly at** `owner` (not inherited),
|
||||
/// case-insensitively sorted. Used by `load_current` (apply diff), the
|
||||
/// no-provider check, and the read-only `extension-points ls`.
|
||||
pub async fn list_for_owner(pool: &PgPool, owner: ScriptOwner) -> Result<Vec<String>, sqlx::Error> {
|
||||
let rows: Vec<(String,)> = match owner {
|
||||
ScriptOwner::App(a) => {
|
||||
sqlx::query_as(
|
||||
"SELECT name FROM extension_points WHERE app_id = $1 ORDER BY LOWER(name)",
|
||||
)
|
||||
.bind(a.into_inner())
|
||||
.fetch_all(pool)
|
||||
.await?
|
||||
}
|
||||
ScriptOwner::Group(g) => {
|
||||
sqlx::query_as(
|
||||
"SELECT name FROM extension_points WHERE group_id = $1 ORDER BY LOWER(name)",
|
||||
)
|
||||
.bind(g.into_inner())
|
||||
.fetch_all(pool)
|
||||
.await?
|
||||
}
|
||||
};
|
||||
Ok(rows.into_iter().map(|(n,)| n).collect())
|
||||
}
|
||||
|
||||
/// All distinct EP names **visible to an app** — declared at the app or any
|
||||
/// ancestor group, walking `apps.group_id → groups.parent_id`. Used by the
|
||||
/// no-provider plan check and the read-only `extension-points ls --app`.
|
||||
pub async fn list_on_app_chain(pool: &PgPool, app_id: AppId) -> Result<Vec<String>, sqlx::Error> {
|
||||
let rows: Vec<(String,)> = sqlx::query_as(&format!(
|
||||
"{CHAIN_LEVELS_CTE} \
|
||||
SELECT DISTINCT e.name FROM extension_points e \
|
||||
JOIN chain c ON (e.app_id = c.app_owner OR e.group_id = c.group_owner) \
|
||||
ORDER BY e.name",
|
||||
))
|
||||
.bind(app_id.into_inner())
|
||||
.fetch_all(pool)
|
||||
.await?;
|
||||
Ok(rows.into_iter().map(|(n,)| n).collect())
|
||||
}
|
||||
|
||||
/// Insert an EP marker at `owner`, in the apply transaction. Idempotent: a
|
||||
/// re-apply of an already-declared name is a no-op (`ON CONFLICT DO NOTHING`),
|
||||
/// so the marker survives without a spurious version bump.
|
||||
pub async fn insert_extension_point_tx(
|
||||
tx: &mut Transaction<'_, Postgres>,
|
||||
owner: ScriptOwner,
|
||||
name: &str,
|
||||
) -> Result<(), sqlx::Error> {
|
||||
match owner {
|
||||
ScriptOwner::App(a) => {
|
||||
sqlx::query(
|
||||
"INSERT INTO extension_points (app_id, name) VALUES ($1, $2) \
|
||||
ON CONFLICT (app_id, LOWER(name)) WHERE app_id IS NOT NULL DO NOTHING",
|
||||
)
|
||||
.bind(a.into_inner())
|
||||
.bind(name)
|
||||
.execute(&mut **tx)
|
||||
.await?;
|
||||
}
|
||||
ScriptOwner::Group(g) => {
|
||||
sqlx::query(
|
||||
"INSERT INTO extension_points (group_id, name) VALUES ($1, $2) \
|
||||
ON CONFLICT (group_id, LOWER(name)) WHERE group_id IS NOT NULL DO NOTHING",
|
||||
)
|
||||
.bind(g.into_inner())
|
||||
.bind(name)
|
||||
.execute(&mut **tx)
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Delete an EP marker at `owner` (case-insensitive), in the apply transaction.
|
||||
/// Used by `--prune` when the manifest stops declaring a name.
|
||||
pub async fn delete_extension_point_tx(
|
||||
tx: &mut Transaction<'_, Postgres>,
|
||||
owner: ScriptOwner,
|
||||
name: &str,
|
||||
) -> Result<(), sqlx::Error> {
|
||||
match owner {
|
||||
ScriptOwner::App(a) => {
|
||||
sqlx::query(
|
||||
"DELETE FROM extension_points WHERE app_id = $1 AND LOWER(name) = LOWER($2)",
|
||||
)
|
||||
.bind(a.into_inner())
|
||||
.bind(name)
|
||||
.execute(&mut **tx)
|
||||
.await?;
|
||||
}
|
||||
ScriptOwner::Group(g) => {
|
||||
sqlx::query(
|
||||
"DELETE FROM extension_points WHERE group_id = $1 AND LOWER(name) = LOWER($2)",
|
||||
)
|
||||
.bind(g.into_inner())
|
||||
.bind(name)
|
||||
.execute(&mut **tx)
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -211,7 +211,7 @@ impl FsFilesRepo {
|
||||
}
|
||||
|
||||
fn final_path(&self, app_id: AppId, collection: &str, id: Uuid) -> PathBuf {
|
||||
final_path_at(&self.config.root, &app_owner_dir(app_id), collection, id)
|
||||
final_path_at(&self.config.root, app_id, collection, id)
|
||||
}
|
||||
|
||||
fn write_atomic(
|
||||
@@ -221,53 +221,29 @@ impl FsFilesRepo {
|
||||
id: Uuid,
|
||||
bytes: &[u8],
|
||||
) -> Result<String, FilesRepoError> {
|
||||
write_atomic_at(
|
||||
&self.config.root,
|
||||
&app_owner_dir(app_id),
|
||||
collection,
|
||||
id,
|
||||
bytes,
|
||||
)
|
||||
write_atomic_at(&self.config.root, app_id, collection, id, bytes)
|
||||
}
|
||||
}
|
||||
|
||||
/// The owner-relative subdirectory of an **app**'s blobs under `<root>/files/`:
|
||||
/// just `<app_id>`. Group-shared blobs (§11.6) shard at `groups/<group_id>`
|
||||
/// instead (see `group_files_repo::group_owner_dir`) — a disjoint subtree under
|
||||
/// the same `files/` base, so the orphan sweeper covers both with one walk.
|
||||
pub(crate) fn app_owner_dir(app_id: AppId) -> PathBuf {
|
||||
PathBuf::from(app_id.into_inner().to_string())
|
||||
}
|
||||
|
||||
/// `<root>/files/<owner_rel>/<collection>/<id[0:2]>/`. `owner_rel` is built
|
||||
/// by the caller from a server-generated id (`<app_id>` or `groups/<group_id>`)
|
||||
/// — never user input — and `collection` is path-guarded one layer up, so no
|
||||
/// component can escape the `files/` base.
|
||||
pub(crate) fn shard_dir_at(
|
||||
root: &Path,
|
||||
owner_rel: &Path,
|
||||
collection: &str,
|
||||
id_str: &str,
|
||||
) -> PathBuf {
|
||||
fn shard_dir_at(root: &Path, app_id: AppId, collection: &str, id_str: &str) -> PathBuf {
|
||||
root.join("files")
|
||||
.join(owner_rel)
|
||||
.join(app_id.into_inner().to_string())
|
||||
.join(collection)
|
||||
.join(&id_str[..2])
|
||||
}
|
||||
|
||||
pub(crate) fn final_path_at(root: &Path, owner_rel: &Path, collection: &str, id: Uuid) -> PathBuf {
|
||||
fn final_path_at(root: &Path, app_id: AppId, collection: &str, id: Uuid) -> PathBuf {
|
||||
let id_str = id.to_string();
|
||||
shard_dir_at(root, owner_rel, collection, &id_str).join(&id_str)
|
||||
shard_dir_at(root, app_id, collection, &id_str).join(&id_str)
|
||||
}
|
||||
|
||||
/// Steps 2–6 of the atomic-write protocol. Returns the lowercase hex
|
||||
/// SHA-256 of the bytes (computed in a single pass over the in-memory
|
||||
/// buffer — the file is never re-read). `pub(crate)` + owner-relative so the
|
||||
/// app and group-shared (§11.6) repos share this single source for the
|
||||
/// security-sensitive write+checksum mechanics, unit-testable without a pool.
|
||||
pub(crate) fn write_atomic_at(
|
||||
/// buffer — the file is never re-read). Free function so the fs
|
||||
/// mechanics are unit-testable without a Postgres pool.
|
||||
fn write_atomic_at(
|
||||
root: &Path,
|
||||
owner_rel: &Path,
|
||||
app_id: AppId,
|
||||
collection: &str,
|
||||
id: Uuid,
|
||||
bytes: &[u8],
|
||||
@@ -275,7 +251,7 @@ pub(crate) fn write_atomic_at(
|
||||
use std::io::Write as _;
|
||||
|
||||
let id_str = id.to_string();
|
||||
let dir = shard_dir_at(root, owner_rel, collection, &id_str);
|
||||
let dir = shard_dir_at(root, app_id, collection, &id_str);
|
||||
create_dir_all_secure(&dir)?;
|
||||
|
||||
// Single-pass checksum over the in-memory buffer.
|
||||
@@ -302,16 +278,15 @@ pub(crate) fn write_atomic_at(
|
||||
|
||||
/// Read + checksum-verify the bytes at the given path-set. Free
|
||||
/// function mirror of the `get` read path. Returns `Corrupted` when the
|
||||
/// bytes are missing or don't match `expected_checksum`. `pub(crate)` +
|
||||
/// owner-relative — shared with the group-files (§11.6) repo.
|
||||
pub(crate) fn read_verify_at(
|
||||
/// bytes are missing or don't match `expected_checksum`.
|
||||
fn read_verify_at(
|
||||
root: &Path,
|
||||
owner_rel: &Path,
|
||||
app_id: AppId,
|
||||
collection: &str,
|
||||
id: Uuid,
|
||||
expected_checksum: &str,
|
||||
) -> Result<Vec<u8>, FilesRepoError> {
|
||||
let path = final_path_at(root, owner_rel, collection, id);
|
||||
let path = final_path_at(root, app_id, collection, id);
|
||||
let bytes = match std::fs::read(&path) {
|
||||
Ok(b) => b,
|
||||
Err(e) => {
|
||||
@@ -408,13 +383,7 @@ impl FilesRepo for FsFilesRepo {
|
||||
let Some((stored_checksum,)) = row else {
|
||||
return Ok(None);
|
||||
};
|
||||
let bytes = read_verify_at(
|
||||
&self.config.root,
|
||||
&app_owner_dir(app_id),
|
||||
collection,
|
||||
id,
|
||||
&stored_checksum,
|
||||
)?;
|
||||
let bytes = read_verify_at(&self.config.root, app_id, collection, id, &stored_checksum)?;
|
||||
Ok(Some(bytes))
|
||||
}
|
||||
|
||||
@@ -586,11 +555,11 @@ fn hex_lower(bytes: &[u8]) -> String {
|
||||
s
|
||||
}
|
||||
|
||||
pub(crate) fn encode_cursor(last_id: Uuid) -> String {
|
||||
fn encode_cursor(last_id: Uuid) -> String {
|
||||
URL_SAFE_NO_PAD.encode(last_id.to_string().as_bytes())
|
||||
}
|
||||
|
||||
pub(crate) fn decode_cursor(cursor: &str) -> Result<Uuid, FilesRepoError> {
|
||||
fn decode_cursor(cursor: &str) -> Result<Uuid, FilesRepoError> {
|
||||
let bytes = URL_SAFE_NO_PAD
|
||||
.decode(cursor)
|
||||
.map_err(|_| FilesRepoError::InvalidCursor)?;
|
||||
@@ -703,13 +672,13 @@ mod tests {
|
||||
let id = Uuid::new_v4();
|
||||
let bytes = b"hello picloud files".to_vec();
|
||||
|
||||
let checksum = write_atomic_at(&root, &app_owner_dir(app), "avatars", id, &bytes).unwrap();
|
||||
let checksum = write_atomic_at(&root, app, "avatars", id, &bytes).unwrap();
|
||||
// Single-pass checksum matches an independent hash of the bytes.
|
||||
let mut h = Sha256::new();
|
||||
h.update(&bytes);
|
||||
assert_eq!(checksum, hex_lower(&h.finalize()));
|
||||
|
||||
let read = read_verify_at(&root, &app_owner_dir(app), "avatars", id, &checksum).unwrap();
|
||||
let read = read_verify_at(&root, app, "avatars", id, &checksum).unwrap();
|
||||
assert_eq!(read, bytes);
|
||||
|
||||
std::fs::remove_dir_all(&root).ok();
|
||||
@@ -720,13 +689,13 @@ mod tests {
|
||||
let root = unique_tmp_root();
|
||||
let app = AppId::new();
|
||||
let id = Uuid::new_v4();
|
||||
let checksum = write_atomic_at(&root, &app_owner_dir(app), "c", id, b"original").unwrap();
|
||||
let checksum = write_atomic_at(&root, app, "c", id, b"original").unwrap();
|
||||
|
||||
// Mutate the bytes behind the repo's back.
|
||||
let path = final_path_at(&root, &app_owner_dir(app), "c", id);
|
||||
let path = final_path_at(&root, app, "c", id);
|
||||
std::fs::write(&path, b"tampered").unwrap();
|
||||
|
||||
let err = read_verify_at(&root, &app_owner_dir(app), "c", id, &checksum).unwrap_err();
|
||||
let err = read_verify_at(&root, app, "c", id, &checksum).unwrap_err();
|
||||
assert!(matches!(err, FilesRepoError::Corrupted));
|
||||
|
||||
std::fs::remove_dir_all(&root).ok();
|
||||
@@ -738,7 +707,7 @@ mod tests {
|
||||
let app = AppId::new();
|
||||
let id = Uuid::new_v4();
|
||||
// No write — the file never existed.
|
||||
let err = read_verify_at(&root, &app_owner_dir(app), "c", id, "deadbeef").unwrap_err();
|
||||
let err = read_verify_at(&root, app, "c", id, "deadbeef").unwrap_err();
|
||||
assert!(matches!(err, FilesRepoError::Corrupted));
|
||||
std::fs::remove_dir_all(&root).ok();
|
||||
}
|
||||
@@ -748,10 +717,10 @@ mod tests {
|
||||
let root = unique_tmp_root();
|
||||
let app = AppId::new();
|
||||
let id = Uuid::new_v4();
|
||||
write_atomic_at(&root, &app_owner_dir(app), "c", id, b"data").unwrap();
|
||||
write_atomic_at(&root, app, "c", id, b"data").unwrap();
|
||||
|
||||
let id_str = id.to_string();
|
||||
let dir = shard_dir_at(&root, &app_owner_dir(app), "c", &id_str);
|
||||
let dir = shard_dir_at(&root, app, "c", &id_str);
|
||||
let entries: Vec<_> = std::fs::read_dir(&dir)
|
||||
.unwrap()
|
||||
.filter_map(Result::ok)
|
||||
@@ -770,7 +739,7 @@ mod tests {
|
||||
let app = AppId::new();
|
||||
let id = Uuid::new_v4();
|
||||
let id_str = id.to_string();
|
||||
let path = final_path_at(&root, &app_owner_dir(app), "col", id);
|
||||
let path = final_path_at(&root, app, "col", id);
|
||||
let shard = &id_str[..2];
|
||||
assert!(path
|
||||
.to_string_lossy()
|
||||
@@ -784,9 +753,9 @@ mod tests {
|
||||
let root = unique_tmp_root();
|
||||
let app = AppId::new();
|
||||
let id = Uuid::new_v4();
|
||||
write_atomic_at(&root, &app_owner_dir(app), "c", id, b"data").unwrap();
|
||||
write_atomic_at(&root, app, "c", id, b"data").unwrap();
|
||||
let id_str = id.to_string();
|
||||
let dir = shard_dir_at(&root, &app_owner_dir(app), "c", &id_str);
|
||||
let dir = shard_dir_at(&root, app, "c", &id_str);
|
||||
let mode = std::fs::metadata(&dir).unwrap().permissions().mode();
|
||||
assert_eq!(mode & 0o777, 0o700, "shard dir should be 0o700");
|
||||
std::fs::remove_dir_all(&root).ok();
|
||||
|
||||
@@ -165,23 +165,6 @@ mod tests {
|
||||
assert!(tmp.exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sweeps_group_shared_tmp_files() {
|
||||
// §11.6 group-shared files shard under `files/groups/<group_id>/...`,
|
||||
// a disjoint subtree from the per-app `files/<app_id>/...`. The walk is
|
||||
// recursive from `<root>/files/`, so an orphan there is reaped without
|
||||
// any group-specific sweeper change — this pins that "covered for free"
|
||||
// guarantee against a future refactor.
|
||||
let root = tmp_root();
|
||||
let group_shard = root.join("files/groups/6f693a33-9ae5-485b-804e-191e9fd33524/assets/ab");
|
||||
std::fs::create_dir_all(&group_shard).unwrap();
|
||||
let tmp = group_shard.join("uuid.tmp.123-0");
|
||||
touch(&tmp);
|
||||
let stats = sweep_orphan_tmp_files(&root, Duration::ZERO);
|
||||
assert_eq!(stats.files_deleted, 1, "group-shared orphan must be reaped");
|
||||
assert!(!tmp.exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keeps_non_tmp_files() {
|
||||
let root = tmp_root();
|
||||
|
||||
@@ -1,313 +0,0 @@
|
||||
//! `/api/v1/admin/groups/{id}/{kv,docs,files}*` — read-only operator inspection
|
||||
//! of a group's §11.6 SHARED collections (M4). Mirrors the per-app `kv_api` /
|
||||
//! `files_api` admin surface for groups so `pic {kv,docs,files} ls --group` (and
|
||||
//! a future dashboard tab) can browse shared data without a script.
|
||||
//!
|
||||
//! **Read-only by design** — shared writes go through the SDK
|
||||
//! (`kv::shared_collection(...)` etc.), which run the reads-open / writes-authed
|
||||
//! authz + fire `shared = true` triggers; an admin write would bypass both. The
|
||||
//! deferral (operator write/delete on shared blobs) stands.
|
||||
//!
|
||||
//! Capabilities: `GroupKvRead` / `GroupDocsRead` / `GroupFilesRead`, resolved
|
||||
//! against the group loaded from the path (the same tier the SDK read path uses).
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use axum::extract::{Path, Query, State};
|
||||
use axum::http::header::{CONTENT_LENGTH, CONTENT_TYPE};
|
||||
use axum::response::{IntoResponse, Json, Response};
|
||||
use axum::routing::get;
|
||||
use axum::{Extension, Router};
|
||||
use picloud_shared::{GroupId, Principal};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::json;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::authz::{require, AuthzDenied, AuthzRepo, Capability};
|
||||
use crate::group_docs_repo::GroupDocsRepo;
|
||||
use crate::group_files_repo::GroupFilesRepo;
|
||||
use crate::group_kv_repo::GroupKvRepo;
|
||||
use crate::group_repo::GroupRepository;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct GroupBlobsState {
|
||||
pub kv: Arc<dyn GroupKvRepo>,
|
||||
pub docs: Arc<dyn GroupDocsRepo>,
|
||||
pub files: Arc<dyn GroupFilesRepo>,
|
||||
pub groups: Arc<dyn GroupRepository>,
|
||||
pub authz: Arc<dyn AuthzRepo>,
|
||||
}
|
||||
|
||||
pub fn group_blobs_router(state: GroupBlobsState) -> Router {
|
||||
Router::new()
|
||||
.route("/groups/{id}/kv", get(list_kv))
|
||||
.route("/groups/{id}/kv/{collection}/{key}", get(get_kv))
|
||||
.route("/groups/{id}/docs", get(list_docs))
|
||||
.route("/groups/{id}/docs/{collection}/{doc_id}", get(get_doc))
|
||||
.route("/groups/{id}/files", get(list_files))
|
||||
.route("/groups/{id}/files/{collection}/{file_id}", get(get_file))
|
||||
.with_state(state)
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct ListQuery {
|
||||
pub collection: String,
|
||||
#[serde(default)]
|
||||
pub cursor: Option<String>,
|
||||
#[serde(default)]
|
||||
pub limit: Option<u32>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct ListKeysResponse {
|
||||
keys: Vec<String>,
|
||||
next_cursor: Option<String>,
|
||||
}
|
||||
|
||||
async fn list_kv(
|
||||
State(s): State<GroupBlobsState>,
|
||||
Extension(principal): Extension<Principal>,
|
||||
Path(ident): Path<String>,
|
||||
Query(q): Query<ListQuery>,
|
||||
) -> Result<Json<ListKeysResponse>, GroupBlobsError> {
|
||||
let group_id = resolve_group(&*s.groups, &ident).await?;
|
||||
require(
|
||||
s.authz.as_ref(),
|
||||
&principal,
|
||||
Capability::GroupKvRead(group_id),
|
||||
)
|
||||
.await?;
|
||||
let page =
|
||||
s.kv.list(
|
||||
group_id,
|
||||
&q.collection,
|
||||
q.cursor.as_deref(),
|
||||
q.limit.unwrap_or(0),
|
||||
)
|
||||
.await
|
||||
.map_err(|e| GroupBlobsError::Backend(e.to_string()))?;
|
||||
Ok(Json(ListKeysResponse {
|
||||
keys: page.keys,
|
||||
next_cursor: page.next_cursor,
|
||||
}))
|
||||
}
|
||||
|
||||
async fn get_kv(
|
||||
State(s): State<GroupBlobsState>,
|
||||
Extension(principal): Extension<Principal>,
|
||||
Path((ident, collection, key)): Path<(String, String, String)>,
|
||||
) -> Result<Json<serde_json::Value>, GroupBlobsError> {
|
||||
let group_id = resolve_group(&*s.groups, &ident).await?;
|
||||
require(
|
||||
s.authz.as_ref(),
|
||||
&principal,
|
||||
Capability::GroupKvRead(group_id),
|
||||
)
|
||||
.await?;
|
||||
let value =
|
||||
s.kv.get(group_id, &collection, &key)
|
||||
.await
|
||||
.map_err(|e| GroupBlobsError::Backend(e.to_string()))?
|
||||
.ok_or(GroupBlobsError::NotFound)?;
|
||||
Ok(Json(json!({ "value": value })))
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct DocEntry {
|
||||
id: String,
|
||||
data: serde_json::Value,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct ListDocsResponse {
|
||||
docs: Vec<DocEntry>,
|
||||
next_cursor: Option<String>,
|
||||
}
|
||||
|
||||
async fn list_docs(
|
||||
State(s): State<GroupBlobsState>,
|
||||
Extension(principal): Extension<Principal>,
|
||||
Path(ident): Path<String>,
|
||||
Query(q): Query<ListQuery>,
|
||||
) -> Result<Json<ListDocsResponse>, GroupBlobsError> {
|
||||
let group_id = resolve_group(&*s.groups, &ident).await?;
|
||||
require(
|
||||
s.authz.as_ref(),
|
||||
&principal,
|
||||
Capability::GroupDocsRead(group_id),
|
||||
)
|
||||
.await?;
|
||||
let page = s
|
||||
.docs
|
||||
.list(
|
||||
group_id,
|
||||
&q.collection,
|
||||
q.cursor.as_deref(),
|
||||
q.limit.unwrap_or(0),
|
||||
)
|
||||
.await
|
||||
.map_err(|e| GroupBlobsError::Backend(e.to_string()))?;
|
||||
Ok(Json(ListDocsResponse {
|
||||
docs: page
|
||||
.docs
|
||||
.into_iter()
|
||||
.map(|d| DocEntry {
|
||||
id: d.id.to_string(),
|
||||
data: d.data,
|
||||
})
|
||||
.collect(),
|
||||
next_cursor: page.next_cursor,
|
||||
}))
|
||||
}
|
||||
|
||||
async fn get_doc(
|
||||
State(s): State<GroupBlobsState>,
|
||||
Extension(principal): Extension<Principal>,
|
||||
Path((ident, collection, doc_id)): Path<(String, String, String)>,
|
||||
) -> Result<Json<serde_json::Value>, GroupBlobsError> {
|
||||
let group_id = resolve_group(&*s.groups, &ident).await?;
|
||||
require(
|
||||
s.authz.as_ref(),
|
||||
&principal,
|
||||
Capability::GroupDocsRead(group_id),
|
||||
)
|
||||
.await?;
|
||||
let id = doc_id
|
||||
.parse::<Uuid>()
|
||||
.map_err(|_| GroupBlobsError::NotFound)?;
|
||||
let row = s
|
||||
.docs
|
||||
.get(group_id, &collection, id)
|
||||
.await
|
||||
.map_err(|e| GroupBlobsError::Backend(e.to_string()))?
|
||||
.ok_or(GroupBlobsError::NotFound)?;
|
||||
Ok(Json(json!({ "id": row.id.to_string(), "data": row.data })))
|
||||
}
|
||||
|
||||
async fn list_files(
|
||||
State(s): State<GroupBlobsState>,
|
||||
Extension(principal): Extension<Principal>,
|
||||
Path(ident): Path<String>,
|
||||
Query(q): Query<ListQuery>,
|
||||
) -> Result<Json<serde_json::Value>, GroupBlobsError> {
|
||||
let group_id = resolve_group(&*s.groups, &ident).await?;
|
||||
require(
|
||||
s.authz.as_ref(),
|
||||
&principal,
|
||||
Capability::GroupFilesRead(group_id),
|
||||
)
|
||||
.await?;
|
||||
let page = s
|
||||
.files
|
||||
.list(
|
||||
group_id,
|
||||
&q.collection,
|
||||
q.cursor.as_deref(),
|
||||
q.limit.unwrap_or(0),
|
||||
)
|
||||
.await
|
||||
.map_err(|e| GroupBlobsError::Backend(e.to_string()))?;
|
||||
// FileMeta is Serialize; return the metadata list + cursor.
|
||||
Ok(Json(json!({
|
||||
"files": page.files,
|
||||
"next_cursor": page.next_cursor,
|
||||
})))
|
||||
}
|
||||
|
||||
async fn get_file(
|
||||
State(s): State<GroupBlobsState>,
|
||||
Extension(principal): Extension<Principal>,
|
||||
Path((ident, collection, file_id)): Path<(String, String, String)>,
|
||||
) -> Result<Response, GroupBlobsError> {
|
||||
let group_id = resolve_group(&*s.groups, &ident).await?;
|
||||
require(
|
||||
s.authz.as_ref(),
|
||||
&principal,
|
||||
Capability::GroupFilesRead(group_id),
|
||||
)
|
||||
.await?;
|
||||
let id = file_id
|
||||
.parse::<Uuid>()
|
||||
.map_err(|_| GroupBlobsError::NotFound)?;
|
||||
let meta = s
|
||||
.files
|
||||
.head(group_id, &collection, id)
|
||||
.await
|
||||
.map_err(|e| GroupBlobsError::Backend(e.to_string()))?
|
||||
.ok_or(GroupBlobsError::NotFound)?;
|
||||
let bytes = s
|
||||
.files
|
||||
.get(group_id, &collection, id)
|
||||
.await
|
||||
.map_err(|e| GroupBlobsError::Backend(e.to_string()))?
|
||||
.ok_or(GroupBlobsError::NotFound)?;
|
||||
Ok((
|
||||
[
|
||||
(CONTENT_TYPE, meta.content_type),
|
||||
(CONTENT_LENGTH, bytes.len().to_string()),
|
||||
],
|
||||
bytes,
|
||||
)
|
||||
.into_response())
|
||||
}
|
||||
|
||||
async fn resolve_group(
|
||||
groups: &dyn GroupRepository,
|
||||
ident: &str,
|
||||
) -> Result<GroupId, GroupBlobsError> {
|
||||
let found = if let Ok(uuid) = ident.parse::<Uuid>() {
|
||||
groups
|
||||
.get_by_id(uuid.into())
|
||||
.await
|
||||
.map_err(|e| GroupBlobsError::Backend(e.to_string()))?
|
||||
} else {
|
||||
groups
|
||||
.get_by_slug(ident)
|
||||
.await
|
||||
.map_err(|e| GroupBlobsError::Backend(e.to_string()))?
|
||||
};
|
||||
found.map(|g| g.id).ok_or(GroupBlobsError::GroupNotFound)
|
||||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum GroupBlobsError {
|
||||
#[error("group not found")]
|
||||
GroupNotFound,
|
||||
#[error("not found")]
|
||||
NotFound,
|
||||
#[error("forbidden")]
|
||||
Forbidden,
|
||||
#[error("authorization repo error: {0}")]
|
||||
AuthzRepo(String),
|
||||
#[error("backend: {0}")]
|
||||
Backend(String),
|
||||
}
|
||||
|
||||
impl From<AuthzDenied> for GroupBlobsError {
|
||||
fn from(d: AuthzDenied) -> Self {
|
||||
match d {
|
||||
AuthzDenied::Denied => Self::Forbidden,
|
||||
AuthzDenied::Repo(e) => Self::AuthzRepo(e.to_string()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl IntoResponse for GroupBlobsError {
|
||||
fn into_response(self) -> Response {
|
||||
use axum::http::StatusCode;
|
||||
let (status, body) = match &self {
|
||||
Self::GroupNotFound | Self::NotFound => {
|
||||
(StatusCode::NOT_FOUND, json!({ "error": self.to_string() }))
|
||||
}
|
||||
Self::Forbidden => (StatusCode::FORBIDDEN, json!({ "error": self.to_string() })),
|
||||
Self::AuthzRepo(e) | Self::Backend(e) => {
|
||||
tracing::error!(error = %e, "group blobs admin error");
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
json!({ "error": "internal error" }),
|
||||
)
|
||||
}
|
||||
};
|
||||
(status, Json(body)).into_response()
|
||||
}
|
||||
}
|
||||
@@ -1,189 +0,0 @@
|
||||
//! Group-collection markers (§11.6) — the `group_collections` table (0052).
|
||||
//!
|
||||
//! A marker `(owner, name, kind)` declares that a collection name is
|
||||
//! group-shared. `kind` selects the storage backend: `'kv'` →
|
||||
//! `group_kv_entries` (0053), `'docs'` → `group_docs` (0054). This module holds
|
||||
//! the read + transactional-write helpers, the runtime resolver, and the
|
||||
//! injectable [`GroupCollectionResolver`] trait the per-kind services consume.
|
||||
//!
|
||||
//! The load-bearing function is [`resolve_owning_group`]: it walks the reading
|
||||
//! app's ancestor chain ([`CHAIN_LEVELS_CTE`]) for the nearest group declaring
|
||||
//! the collection of the requested `kind`. That walk **is** the isolation
|
||||
//! boundary — a foreign app's chain never contains the owning group, so the
|
||||
//! name does not resolve.
|
||||
|
||||
use async_trait::async_trait;
|
||||
use picloud_shared::{AppId, GroupId, ScriptOwner};
|
||||
use sqlx::{PgPool, Postgres, Transaction};
|
||||
|
||||
use crate::config_resolver::CHAIN_LEVELS_CTE;
|
||||
|
||||
/// List **all** shared-collection declarations at `owner` as `(name, kind)`
|
||||
/// pairs, sorted. Used by the kind-aware apply diff and `collections ls`.
|
||||
pub async fn list_all_for_owner(
|
||||
pool: &PgPool,
|
||||
owner: ScriptOwner,
|
||||
) -> Result<Vec<(String, String)>, sqlx::Error> {
|
||||
let rows: Vec<(String, String)> = match owner {
|
||||
ScriptOwner::App(a) => {
|
||||
sqlx::query_as(
|
||||
"SELECT name, kind FROM group_collections \
|
||||
WHERE app_id = $1 ORDER BY kind, LOWER(name)",
|
||||
)
|
||||
.bind(a.into_inner())
|
||||
.fetch_all(pool)
|
||||
.await?
|
||||
}
|
||||
ScriptOwner::Group(g) => {
|
||||
sqlx::query_as(
|
||||
"SELECT name, kind FROM group_collections \
|
||||
WHERE group_id = $1 ORDER BY kind, LOWER(name)",
|
||||
)
|
||||
.bind(g.into_inner())
|
||||
.fetch_all(pool)
|
||||
.await?
|
||||
}
|
||||
};
|
||||
Ok(rows)
|
||||
}
|
||||
|
||||
/// Resolve the group that OWNS the shared collection `(name, kind)` for a
|
||||
/// reading app: the nearest ancestor group on the app's chain that declares it.
|
||||
/// Returns `None` when no group on the chain shares that `(name, kind)` — the
|
||||
/// structural "not shared with you" boundary. Nearest-wins (CoW shadowing) is
|
||||
/// enforced by `ORDER BY depth ASC LIMIT 1` and is security-relevant.
|
||||
///
|
||||
/// The join is on `group_owner` only: an app-declared marker (the degenerate
|
||||
/// case) never makes a collection visible to *other* apps — sharing is a group
|
||||
/// property.
|
||||
pub async fn resolve_owning_group(
|
||||
pool: &PgPool,
|
||||
app_id: AppId,
|
||||
name: &str,
|
||||
kind: &str,
|
||||
) -> Result<Option<GroupId>, sqlx::Error> {
|
||||
let row: Option<(uuid::Uuid,)> = sqlx::query_as(&format!(
|
||||
"{CHAIN_LEVELS_CTE} \
|
||||
SELECT gc.group_id FROM group_collections gc \
|
||||
JOIN chain c ON gc.group_id = c.group_owner \
|
||||
WHERE LOWER(gc.name) = LOWER($2) AND gc.kind = $3 \
|
||||
ORDER BY c.depth ASC LIMIT 1",
|
||||
))
|
||||
.bind(app_id.into_inner())
|
||||
.bind(name)
|
||||
.bind(kind)
|
||||
.fetch_optional(pool)
|
||||
.await?;
|
||||
Ok(row.map(|(id,)| GroupId::from(id)))
|
||||
}
|
||||
|
||||
/// Insert a `(name, kind)` marker at `owner`, in the apply transaction.
|
||||
/// Idempotent: a re-apply of an already-declared marker is a no-op
|
||||
/// (`ON CONFLICT DO NOTHING`), so it survives without a spurious version bump.
|
||||
pub async fn insert_collection_tx(
|
||||
tx: &mut Transaction<'_, Postgres>,
|
||||
owner: ScriptOwner,
|
||||
name: &str,
|
||||
kind: &str,
|
||||
) -> Result<(), sqlx::Error> {
|
||||
match owner {
|
||||
ScriptOwner::App(a) => {
|
||||
sqlx::query(
|
||||
"INSERT INTO group_collections (app_id, name, kind) VALUES ($1, $2, $3) \
|
||||
ON CONFLICT (app_id, LOWER(name), kind) WHERE app_id IS NOT NULL DO NOTHING",
|
||||
)
|
||||
.bind(a.into_inner())
|
||||
.bind(name)
|
||||
.bind(kind)
|
||||
.execute(&mut **tx)
|
||||
.await?;
|
||||
}
|
||||
ScriptOwner::Group(g) => {
|
||||
sqlx::query(
|
||||
"INSERT INTO group_collections (group_id, name, kind) VALUES ($1, $2, $3) \
|
||||
ON CONFLICT (group_id, LOWER(name), kind) WHERE group_id IS NOT NULL DO NOTHING",
|
||||
)
|
||||
.bind(g.into_inner())
|
||||
.bind(name)
|
||||
.bind(kind)
|
||||
.execute(&mut **tx)
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Delete a `(name, kind)` marker at `owner` (case-insensitive), in the apply
|
||||
/// transaction. Used by `--prune`. The storage data is NOT dropped here —
|
||||
/// pruning a marker hides the store but leaves the data until the owning group
|
||||
/// is deleted.
|
||||
pub async fn delete_collection_tx(
|
||||
tx: &mut Transaction<'_, Postgres>,
|
||||
owner: ScriptOwner,
|
||||
name: &str,
|
||||
kind: &str,
|
||||
) -> Result<(), sqlx::Error> {
|
||||
match owner {
|
||||
ScriptOwner::App(a) => {
|
||||
sqlx::query(
|
||||
"DELETE FROM group_collections \
|
||||
WHERE app_id = $1 AND LOWER(name) = LOWER($2) AND kind = $3",
|
||||
)
|
||||
.bind(a.into_inner())
|
||||
.bind(name)
|
||||
.bind(kind)
|
||||
.execute(&mut **tx)
|
||||
.await?;
|
||||
}
|
||||
ScriptOwner::Group(g) => {
|
||||
sqlx::query(
|
||||
"DELETE FROM group_collections \
|
||||
WHERE group_id = $1 AND LOWER(name) = LOWER($2) AND kind = $3",
|
||||
)
|
||||
.bind(g.into_inner())
|
||||
.bind(name)
|
||||
.bind(kind)
|
||||
.execute(&mut **tx)
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Resolves a shared-collection name+kind to its owning group for a calling app
|
||||
/// (nearest ancestor group on the app's chain that declares it). Behind a trait
|
||||
/// so the per-kind services (`GroupKvServiceImpl`, `GroupDocsServiceImpl`) can
|
||||
/// inject a fake in unit tests without Postgres.
|
||||
#[async_trait]
|
||||
pub trait GroupCollectionResolver: Send + Sync {
|
||||
async fn resolve_owning_group(
|
||||
&self,
|
||||
app_id: AppId,
|
||||
name: &str,
|
||||
kind: &str,
|
||||
) -> Result<Option<GroupId>, sqlx::Error>;
|
||||
}
|
||||
|
||||
/// Postgres-backed resolver — delegates to [`resolve_owning_group`].
|
||||
pub struct PostgresGroupCollectionResolver {
|
||||
pool: PgPool,
|
||||
}
|
||||
|
||||
impl PostgresGroupCollectionResolver {
|
||||
#[must_use]
|
||||
pub fn new(pool: PgPool) -> Self {
|
||||
Self { pool }
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl GroupCollectionResolver for PostgresGroupCollectionResolver {
|
||||
async fn resolve_owning_group(
|
||||
&self,
|
||||
app_id: AppId,
|
||||
name: &str,
|
||||
kind: &str,
|
||||
) -> Result<Option<GroupId>, sqlx::Error> {
|
||||
resolve_owning_group(&self.pool, app_id, name, kind).await
|
||||
}
|
||||
}
|
||||
@@ -1,299 +0,0 @@
|
||||
//! Low-level Postgres CRUD over `group_docs` (§11.6). A near-clone of
|
||||
//! [`crate::docs_repo`] keyed by the owning `group_id` instead of `app_id`.
|
||||
//! The `find` query is built by the shared [`crate::docs_repo::build_find_query`]
|
||||
//! (parameterized on table + owner column), so the security-sensitive filter
|
||||
//! SQL has a single source. Authorization, group resolution, value validation,
|
||||
//! and event policy live one layer up in `GroupDocsServiceImpl`.
|
||||
|
||||
use async_trait::async_trait;
|
||||
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
|
||||
use base64::Engine as _;
|
||||
use chrono::{DateTime, Utc};
|
||||
use picloud_shared::{DocId, DocRow, DocsListPage, GroupId};
|
||||
use serde_json::Value;
|
||||
use sqlx::PgPool;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::docs_filter::DocsFilter;
|
||||
use crate::docs_repo::{build_find_query, row_to_doc, DocsRepoError};
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum GroupDocsRepoError {
|
||||
#[error("database error: {0}")]
|
||||
Db(#[from] sqlx::Error),
|
||||
|
||||
#[error("invalid pagination cursor")]
|
||||
InvalidCursor,
|
||||
}
|
||||
|
||||
impl From<DocsRepoError> for GroupDocsRepoError {
|
||||
fn from(e: DocsRepoError) -> Self {
|
||||
match e {
|
||||
DocsRepoError::Db(e) => Self::Db(e),
|
||||
DocsRepoError::InvalidCursor => Self::InvalidCursor,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
pub trait GroupDocsRepo: Send + Sync {
|
||||
async fn create(
|
||||
&self,
|
||||
group_id: GroupId,
|
||||
collection: &str,
|
||||
data: Value,
|
||||
) -> Result<DocRow, GroupDocsRepoError>;
|
||||
|
||||
async fn get(
|
||||
&self,
|
||||
group_id: GroupId,
|
||||
collection: &str,
|
||||
id: DocId,
|
||||
) -> Result<Option<DocRow>, GroupDocsRepoError>;
|
||||
|
||||
async fn find(
|
||||
&self,
|
||||
group_id: GroupId,
|
||||
collection: &str,
|
||||
filter: &DocsFilter,
|
||||
) -> Result<Vec<DocRow>, GroupDocsRepoError>;
|
||||
|
||||
/// Returns the previous data (for the would-be event), `None` if missing.
|
||||
async fn update(
|
||||
&self,
|
||||
group_id: GroupId,
|
||||
collection: &str,
|
||||
id: DocId,
|
||||
data: Value,
|
||||
) -> Result<Option<Value>, GroupDocsRepoError>;
|
||||
|
||||
async fn delete(
|
||||
&self,
|
||||
group_id: GroupId,
|
||||
collection: &str,
|
||||
id: DocId,
|
||||
) -> Result<Option<Value>, GroupDocsRepoError>;
|
||||
|
||||
async fn list(
|
||||
&self,
|
||||
group_id: GroupId,
|
||||
collection: &str,
|
||||
cursor: Option<&str>,
|
||||
limit: u32,
|
||||
) -> Result<DocsListPage, GroupDocsRepoError>;
|
||||
|
||||
/// §11.6 quota: total doc count across the group's shared-docs collections.
|
||||
/// Default `Ok(0)` so non-Postgres impls skip the quota check.
|
||||
async fn count_rows(&self, group_id: GroupId) -> Result<u64, GroupDocsRepoError> {
|
||||
let _ = group_id;
|
||||
Ok(0)
|
||||
}
|
||||
}
|
||||
|
||||
pub struct PostgresGroupDocsRepo {
|
||||
pool: PgPool,
|
||||
}
|
||||
|
||||
impl PostgresGroupDocsRepo {
|
||||
#[must_use]
|
||||
pub fn new(pool: PgPool) -> Self {
|
||||
Self { pool }
|
||||
}
|
||||
}
|
||||
|
||||
const DOCS_LIST_MAX_LIMIT: u32 = 1_000;
|
||||
const DOCS_LIST_DEFAULT_LIMIT: u32 = 100;
|
||||
|
||||
#[async_trait]
|
||||
impl GroupDocsRepo for PostgresGroupDocsRepo {
|
||||
async fn create(
|
||||
&self,
|
||||
group_id: GroupId,
|
||||
collection: &str,
|
||||
data: Value,
|
||||
) -> Result<DocRow, GroupDocsRepoError> {
|
||||
let id = Uuid::new_v4();
|
||||
let row: (DateTime<Utc>, DateTime<Utc>) = sqlx::query_as(
|
||||
"INSERT INTO group_docs (group_id, collection, id, data) \
|
||||
VALUES ($1, $2, $3, $4) \
|
||||
RETURNING created_at, updated_at",
|
||||
)
|
||||
.bind(group_id.into_inner())
|
||||
.bind(collection)
|
||||
.bind(id)
|
||||
.bind(&data)
|
||||
.fetch_one(&self.pool)
|
||||
.await?;
|
||||
Ok(DocRow {
|
||||
id,
|
||||
data,
|
||||
created_at: row.0,
|
||||
updated_at: row.1,
|
||||
})
|
||||
}
|
||||
|
||||
async fn get(
|
||||
&self,
|
||||
group_id: GroupId,
|
||||
collection: &str,
|
||||
id: DocId,
|
||||
) -> Result<Option<DocRow>, GroupDocsRepoError> {
|
||||
let row: Option<(Value, DateTime<Utc>, DateTime<Utc>)> = sqlx::query_as(
|
||||
"SELECT data, created_at, updated_at FROM group_docs \
|
||||
WHERE group_id = $1 AND collection = $2 AND id = $3",
|
||||
)
|
||||
.bind(group_id.into_inner())
|
||||
.bind(collection)
|
||||
.bind(id)
|
||||
.fetch_optional(&self.pool)
|
||||
.await?;
|
||||
Ok(row.map(|(data, created_at, updated_at)| DocRow {
|
||||
id,
|
||||
data,
|
||||
created_at,
|
||||
updated_at,
|
||||
}))
|
||||
}
|
||||
|
||||
async fn find(
|
||||
&self,
|
||||
group_id: GroupId,
|
||||
collection: &str,
|
||||
filter: &DocsFilter,
|
||||
) -> Result<Vec<DocRow>, GroupDocsRepoError> {
|
||||
let mut qb = build_find_query(
|
||||
"group_docs",
|
||||
"group_id",
|
||||
group_id.into_inner(),
|
||||
collection,
|
||||
filter,
|
||||
);
|
||||
let rows = qb.build().fetch_all(&self.pool).await?;
|
||||
rows.into_iter()
|
||||
.map(row_to_doc)
|
||||
.collect::<Result<Vec<_>, _>>()
|
||||
.map_err(Into::into)
|
||||
}
|
||||
|
||||
async fn update(
|
||||
&self,
|
||||
group_id: GroupId,
|
||||
collection: &str,
|
||||
id: DocId,
|
||||
data: Value,
|
||||
) -> Result<Option<Value>, GroupDocsRepoError> {
|
||||
let row: Option<(Option<Value>,)> = sqlx::query_as(
|
||||
"WITH prev AS ( \
|
||||
SELECT data FROM group_docs \
|
||||
WHERE group_id = $1 AND collection = $2 AND id = $3 \
|
||||
), \
|
||||
updated AS ( \
|
||||
UPDATE group_docs SET data = $4, updated_at = NOW() \
|
||||
WHERE group_id = $1 AND collection = $2 AND id = $3 \
|
||||
RETURNING 1 \
|
||||
) \
|
||||
SELECT (SELECT data FROM prev) FROM updated",
|
||||
)
|
||||
.bind(group_id.into_inner())
|
||||
.bind(collection)
|
||||
.bind(id)
|
||||
.bind(&data)
|
||||
.fetch_optional(&self.pool)
|
||||
.await?;
|
||||
Ok(row.and_then(|(v,)| v))
|
||||
}
|
||||
|
||||
async fn delete(
|
||||
&self,
|
||||
group_id: GroupId,
|
||||
collection: &str,
|
||||
id: DocId,
|
||||
) -> Result<Option<Value>, GroupDocsRepoError> {
|
||||
let row: Option<(Value,)> = sqlx::query_as(
|
||||
"DELETE FROM group_docs \
|
||||
WHERE group_id = $1 AND collection = $2 AND id = $3 \
|
||||
RETURNING data",
|
||||
)
|
||||
.bind(group_id.into_inner())
|
||||
.bind(collection)
|
||||
.bind(id)
|
||||
.fetch_optional(&self.pool)
|
||||
.await?;
|
||||
Ok(row.map(|(v,)| v))
|
||||
}
|
||||
|
||||
async fn list(
|
||||
&self,
|
||||
group_id: GroupId,
|
||||
collection: &str,
|
||||
cursor: Option<&str>,
|
||||
limit: u32,
|
||||
) -> Result<DocsListPage, GroupDocsRepoError> {
|
||||
let limit = if limit == 0 {
|
||||
DOCS_LIST_DEFAULT_LIMIT
|
||||
} else {
|
||||
limit.min(DOCS_LIST_MAX_LIMIT)
|
||||
};
|
||||
|
||||
let last_id = match cursor {
|
||||
Some(c) => Some(decode_cursor(c)?),
|
||||
None => None,
|
||||
};
|
||||
|
||||
let take = i64::from(limit) + 1;
|
||||
let rows: Vec<(Uuid, Value, DateTime<Utc>, DateTime<Utc>)> = sqlx::query_as(
|
||||
"SELECT id, data, created_at, updated_at FROM group_docs \
|
||||
WHERE group_id = $1 AND collection = $2 \
|
||||
AND ($3::uuid IS NULL OR id > $3) \
|
||||
ORDER BY id ASC \
|
||||
LIMIT $4",
|
||||
)
|
||||
.bind(group_id.into_inner())
|
||||
.bind(collection)
|
||||
.bind(last_id)
|
||||
.bind(take)
|
||||
.fetch_all(&self.pool)
|
||||
.await?;
|
||||
|
||||
let mut docs: Vec<DocRow> = rows
|
||||
.into_iter()
|
||||
.map(|(id, data, created_at, updated_at)| DocRow {
|
||||
id,
|
||||
data,
|
||||
created_at,
|
||||
updated_at,
|
||||
})
|
||||
.collect();
|
||||
let next_cursor = if docs.len() > limit as usize {
|
||||
docs.truncate(limit as usize);
|
||||
docs.last().map(|d| encode_cursor(&d.id))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
Ok(DocsListPage { docs, next_cursor })
|
||||
}
|
||||
|
||||
async fn count_rows(&self, group_id: GroupId) -> Result<u64, GroupDocsRepoError> {
|
||||
let (n,): (i64,) = sqlx::query_as("SELECT COUNT(*) FROM group_docs WHERE group_id = $1")
|
||||
.bind(group_id.into_inner())
|
||||
.fetch_one(&self.pool)
|
||||
.await?;
|
||||
Ok(u64::try_from(n).unwrap_or(0))
|
||||
}
|
||||
}
|
||||
|
||||
fn encode_cursor(last_id: &Uuid) -> String {
|
||||
URL_SAFE_NO_PAD.encode(last_id.as_bytes())
|
||||
}
|
||||
|
||||
fn decode_cursor(cursor: &str) -> Result<Uuid, GroupDocsRepoError> {
|
||||
let bytes = URL_SAFE_NO_PAD
|
||||
.decode(cursor)
|
||||
.map_err(|_| GroupDocsRepoError::InvalidCursor)?;
|
||||
let arr: [u8; 16] = bytes
|
||||
.as_slice()
|
||||
.try_into()
|
||||
.map_err(|_| GroupDocsRepoError::InvalidCursor)?;
|
||||
Ok(Uuid::from_bytes(arr))
|
||||
}
|
||||
@@ -1,581 +0,0 @@
|
||||
//! `GroupDocsServiceImpl` — wires `GroupDocsRepo` + the group-collection
|
||||
//! registry underneath the `picloud_shared::GroupDocsService` trait that scripts
|
||||
//! reach via the `docs::shared_collection("name")` Rhai handle (§11.6).
|
||||
//!
|
||||
//! Combines the group-KV service pattern (owner resolution from `cx.app_id`,
|
||||
//! reads-open / writes-authed authz) with the docs surface (filter parsing,
|
||||
//! JSON-object validation, value-size cap). No event emission in the MVP (the
|
||||
//! "group trigger has no app to watch" deferral).
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use picloud_shared::{
|
||||
DocId, DocRow, DocsListPage, GroupDocsError, GroupDocsService, GroupId, NoopEventEmitter,
|
||||
SdkCallCx, ServiceEvent, ServiceEventEmitter,
|
||||
};
|
||||
|
||||
use crate::authz::{self, AuthzRepo, Capability};
|
||||
use crate::docs_filter::{parse_filter, FilterParseError};
|
||||
use crate::docs_service::docs_max_value_bytes_from_env;
|
||||
use crate::group_collection_repo::GroupCollectionResolver;
|
||||
use crate::group_docs_repo::{GroupDocsRepo, GroupDocsRepoError};
|
||||
|
||||
/// The registry `kind` this service resolves.
|
||||
const KIND_DOCS: &str = "docs";
|
||||
|
||||
pub struct GroupDocsServiceImpl {
|
||||
repo: Arc<dyn GroupDocsRepo>,
|
||||
resolver: Arc<dyn GroupCollectionResolver>,
|
||||
authz: Arc<dyn AuthzRepo>,
|
||||
max_value_bytes: usize,
|
||||
/// §11.6 per-group quota: max total docs across the group's shared-docs
|
||||
/// collections (`PICLOUD_GROUP_DOCS_MAX_ROWS`).
|
||||
max_rows: u64,
|
||||
/// §11.6: fires `shared = true` docs triggers on a shared-collection write.
|
||||
events: Arc<dyn ServiceEventEmitter>,
|
||||
}
|
||||
|
||||
impl GroupDocsServiceImpl {
|
||||
#[must_use]
|
||||
pub fn new(
|
||||
repo: Arc<dyn GroupDocsRepo>,
|
||||
resolver: Arc<dyn GroupCollectionResolver>,
|
||||
authz: Arc<dyn AuthzRepo>,
|
||||
) -> Self {
|
||||
Self::with_max_value_bytes(repo, resolver, authz, docs_max_value_bytes_from_env())
|
||||
}
|
||||
|
||||
/// Wire the event emitter (§11.6 shared-collection triggers).
|
||||
#[must_use]
|
||||
pub fn with_events(mut self, events: Arc<dyn ServiceEventEmitter>) -> Self {
|
||||
self.events = events;
|
||||
self
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn with_max_value_bytes(
|
||||
repo: Arc<dyn GroupDocsRepo>,
|
||||
resolver: Arc<dyn GroupCollectionResolver>,
|
||||
authz: Arc<dyn AuthzRepo>,
|
||||
max_value_bytes: usize,
|
||||
) -> Self {
|
||||
Self {
|
||||
events: Arc::new(NoopEventEmitter),
|
||||
max_rows: crate::group_quota::group_docs_max_rows_from_env(),
|
||||
repo,
|
||||
resolver,
|
||||
authz,
|
||||
max_value_bytes,
|
||||
}
|
||||
}
|
||||
|
||||
/// §11.6: fire `shared = true` docs triggers on the owning group.
|
||||
/// Best-effort; an emit failure is logged, never surfaced.
|
||||
#[allow(clippy::too_many_arguments)] // op/collection/id + new + old payloads
|
||||
async fn emit_shared(
|
||||
&self,
|
||||
cx: &SdkCallCx,
|
||||
group_id: GroupId,
|
||||
op: &'static str,
|
||||
collection: &str,
|
||||
id: &str,
|
||||
payload: Option<serde_json::Value>,
|
||||
old_payload: Option<serde_json::Value>,
|
||||
) {
|
||||
if let Err(e) = self
|
||||
.events
|
||||
.emit_shared(
|
||||
cx,
|
||||
group_id,
|
||||
ServiceEvent {
|
||||
source: "docs",
|
||||
op,
|
||||
collection: Some(collection.to_string()),
|
||||
key: Some(id.to_string()),
|
||||
payload,
|
||||
old_payload,
|
||||
},
|
||||
)
|
||||
.await
|
||||
{
|
||||
tracing::error!(error = %e, source = "docs", op, event_emit_failure = true, "shared event emit failed");
|
||||
}
|
||||
}
|
||||
|
||||
/// The structural boundary: resolve the collection to its owning group
|
||||
/// (kind=`docs`) on the calling app's chain. `CollectionNotShared` when no
|
||||
/// ancestor group declares it.
|
||||
async fn owning_group(
|
||||
&self,
|
||||
cx: &SdkCallCx,
|
||||
collection: &str,
|
||||
) -> Result<GroupId, GroupDocsError> {
|
||||
if collection.is_empty() {
|
||||
return Err(GroupDocsError::InvalidCollection);
|
||||
}
|
||||
self.resolver
|
||||
.resolve_owning_group(cx.app_id, collection, KIND_DOCS)
|
||||
.await
|
||||
.map_err(|e| GroupDocsError::Backend(e.to_string()))?
|
||||
.ok_or_else(|| GroupDocsError::CollectionNotShared(collection.to_string()))
|
||||
}
|
||||
|
||||
fn check_data_size(&self, data: &serde_json::Value) -> Result<(), GroupDocsError> {
|
||||
let encoded_len = serde_json::to_vec(data)
|
||||
.map(|v| v.len())
|
||||
.map_err(|e| GroupDocsError::Backend(format!("encode doc data: {e}")))?;
|
||||
if encoded_len > self.max_value_bytes {
|
||||
return Err(GroupDocsError::ValueTooLarge {
|
||||
limit: self.max_value_bytes,
|
||||
actual: encoded_len,
|
||||
});
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn check_read(&self, cx: &SdkCallCx, group_id: GroupId) -> Result<(), GroupDocsError> {
|
||||
authz::script_gate(
|
||||
&*self.authz,
|
||||
cx,
|
||||
Capability::GroupDocsRead(group_id),
|
||||
|| GroupDocsError::Forbidden,
|
||||
GroupDocsError::Backend,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn check_write(&self, cx: &SdkCallCx, group_id: GroupId) -> Result<(), GroupDocsError> {
|
||||
// Fails closed for an anonymous principal — shared mutation always needs
|
||||
// an authenticated editor+ on the owning group.
|
||||
authz::script_gate_require_principal(
|
||||
&*self.authz,
|
||||
cx,
|
||||
Capability::GroupDocsWrite(group_id),
|
||||
|| GroupDocsError::Forbidden,
|
||||
GroupDocsError::Backend,
|
||||
)
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_data(data: &serde_json::Value) -> Result<(), GroupDocsError> {
|
||||
if !data.is_object() {
|
||||
return Err(GroupDocsError::InvalidData);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
impl From<GroupDocsRepoError> for GroupDocsError {
|
||||
fn from(e: GroupDocsRepoError) -> Self {
|
||||
Self::Backend(e.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
impl From<FilterParseError> for GroupDocsError {
|
||||
fn from(e: FilterParseError) -> Self {
|
||||
match e {
|
||||
FilterParseError::InvalidFilter(s) => Self::InvalidFilter(s),
|
||||
FilterParseError::UnsupportedOperator(s) => Self::UnsupportedOperator(s),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl GroupDocsService for GroupDocsServiceImpl {
|
||||
async fn create(
|
||||
&self,
|
||||
cx: &SdkCallCx,
|
||||
collection: &str,
|
||||
data: serde_json::Value,
|
||||
) -> Result<DocId, GroupDocsError> {
|
||||
let group_id = self.owning_group(cx, collection).await?;
|
||||
validate_data(&data)?;
|
||||
self.check_data_size(&data)?;
|
||||
self.check_write(cx, group_id).await?;
|
||||
// §11.6 quota: a new doc must fit under the group's row ceiling.
|
||||
let count = self.repo.count_rows(group_id).await?;
|
||||
if count >= self.max_rows {
|
||||
return Err(GroupDocsError::QuotaExceeded {
|
||||
limit: usize::try_from(self.max_rows).unwrap_or(usize::MAX),
|
||||
actual: usize::try_from(count).unwrap_or(usize::MAX),
|
||||
});
|
||||
}
|
||||
let created = self.repo.create(group_id, collection, data.clone()).await?;
|
||||
self.emit_shared(
|
||||
cx,
|
||||
group_id,
|
||||
"create",
|
||||
collection,
|
||||
&created.id.to_string(),
|
||||
Some(data),
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
Ok(created.id)
|
||||
}
|
||||
|
||||
async fn get(
|
||||
&self,
|
||||
cx: &SdkCallCx,
|
||||
collection: &str,
|
||||
id: DocId,
|
||||
) -> Result<Option<DocRow>, GroupDocsError> {
|
||||
let group_id = self.owning_group(cx, collection).await?;
|
||||
self.check_read(cx, group_id).await?;
|
||||
Ok(self.repo.get(group_id, collection, id).await?)
|
||||
}
|
||||
|
||||
async fn find(
|
||||
&self,
|
||||
cx: &SdkCallCx,
|
||||
collection: &str,
|
||||
filter: serde_json::Value,
|
||||
) -> Result<Vec<DocRow>, GroupDocsError> {
|
||||
let group_id = self.owning_group(cx, collection).await?;
|
||||
self.check_read(cx, group_id).await?;
|
||||
let parsed = parse_filter(&filter)?;
|
||||
Ok(self.repo.find(group_id, collection, &parsed).await?)
|
||||
}
|
||||
|
||||
async fn find_one(
|
||||
&self,
|
||||
cx: &SdkCallCx,
|
||||
collection: &str,
|
||||
filter: serde_json::Value,
|
||||
) -> Result<Option<DocRow>, GroupDocsError> {
|
||||
let group_id = self.owning_group(cx, collection).await?;
|
||||
self.check_read(cx, group_id).await?;
|
||||
let mut parsed = parse_filter(&filter)?;
|
||||
if parsed.limit.is_none() {
|
||||
parsed.limit = Some(1);
|
||||
}
|
||||
let rows = self.repo.find(group_id, collection, &parsed).await?;
|
||||
Ok(rows.into_iter().next())
|
||||
}
|
||||
|
||||
async fn update(
|
||||
&self,
|
||||
cx: &SdkCallCx,
|
||||
collection: &str,
|
||||
id: DocId,
|
||||
data: serde_json::Value,
|
||||
) -> Result<(), GroupDocsError> {
|
||||
let group_id = self.owning_group(cx, collection).await?;
|
||||
validate_data(&data)?;
|
||||
self.check_data_size(&data)?;
|
||||
self.check_write(cx, group_id).await?;
|
||||
match self
|
||||
.repo
|
||||
.update(group_id, collection, id, data.clone())
|
||||
.await?
|
||||
{
|
||||
Some(prev) => {
|
||||
self.emit_shared(
|
||||
cx,
|
||||
group_id,
|
||||
"update",
|
||||
collection,
|
||||
&id.to_string(),
|
||||
Some(data),
|
||||
Some(prev),
|
||||
)
|
||||
.await;
|
||||
Ok(())
|
||||
}
|
||||
None => Err(GroupDocsError::NotFound),
|
||||
}
|
||||
}
|
||||
|
||||
async fn delete(
|
||||
&self,
|
||||
cx: &SdkCallCx,
|
||||
collection: &str,
|
||||
id: DocId,
|
||||
) -> Result<bool, GroupDocsError> {
|
||||
let group_id = self.owning_group(cx, collection).await?;
|
||||
self.check_write(cx, group_id).await?;
|
||||
match self.repo.delete(group_id, collection, id).await? {
|
||||
Some(prev) => {
|
||||
self.emit_shared(
|
||||
cx,
|
||||
group_id,
|
||||
"delete",
|
||||
collection,
|
||||
&id.to_string(),
|
||||
None,
|
||||
Some(prev),
|
||||
)
|
||||
.await;
|
||||
Ok(true)
|
||||
}
|
||||
None => Ok(false),
|
||||
}
|
||||
}
|
||||
|
||||
async fn list(
|
||||
&self,
|
||||
cx: &SdkCallCx,
|
||||
collection: &str,
|
||||
cursor: Option<&str>,
|
||||
limit: u32,
|
||||
) -> Result<DocsListPage, GroupDocsError> {
|
||||
let group_id = self.owning_group(cx, collection).await?;
|
||||
self.check_read(cx, group_id).await?;
|
||||
Ok(self.repo.list(group_id, collection, cursor, limit).await?)
|
||||
}
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// Tests — in-memory repo + a fake resolver so unit tests don't need Postgres.
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::authz::{AuthzError, AuthzRepo};
|
||||
use crate::docs_filter::DocsFilter;
|
||||
use chrono::Utc;
|
||||
use picloud_shared::{
|
||||
AdminUserId, AppId, AppRole, ExecutionId, InstanceRole, Principal, RequestId, ScriptId,
|
||||
UserId,
|
||||
};
|
||||
use std::collections::HashMap;
|
||||
use tokio::sync::Mutex;
|
||||
use uuid::Uuid;
|
||||
|
||||
#[derive(Default)]
|
||||
struct InMemoryGroupDocsRepo {
|
||||
// (group, collection) -> id -> data
|
||||
data: Mutex<HashMap<(GroupId, String), HashMap<Uuid, serde_json::Value>>>,
|
||||
}
|
||||
|
||||
fn row(id: Uuid, data: serde_json::Value) -> DocRow {
|
||||
DocRow {
|
||||
id,
|
||||
data,
|
||||
created_at: Utc::now(),
|
||||
updated_at: Utc::now(),
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl GroupDocsRepo for InMemoryGroupDocsRepo {
|
||||
async fn create(
|
||||
&self,
|
||||
group_id: GroupId,
|
||||
collection: &str,
|
||||
data: serde_json::Value,
|
||||
) -> Result<DocRow, GroupDocsRepoError> {
|
||||
let id = Uuid::new_v4();
|
||||
self.data
|
||||
.lock()
|
||||
.await
|
||||
.entry((group_id, collection.to_string()))
|
||||
.or_default()
|
||||
.insert(id, data.clone());
|
||||
Ok(row(id, data))
|
||||
}
|
||||
|
||||
async fn get(
|
||||
&self,
|
||||
group_id: GroupId,
|
||||
collection: &str,
|
||||
id: DocId,
|
||||
) -> Result<Option<DocRow>, GroupDocsRepoError> {
|
||||
Ok(self
|
||||
.data
|
||||
.lock()
|
||||
.await
|
||||
.get(&(group_id, collection.to_string()))
|
||||
.and_then(|m| m.get(&id).cloned())
|
||||
.map(|d| row(id, d)))
|
||||
}
|
||||
|
||||
// The fake ignores the filter (filter SQL is covered by docs_repo tests
|
||||
// + the journey); returns all docs in the collection.
|
||||
async fn find(
|
||||
&self,
|
||||
group_id: GroupId,
|
||||
collection: &str,
|
||||
_filter: &DocsFilter,
|
||||
) -> Result<Vec<DocRow>, GroupDocsRepoError> {
|
||||
Ok(self
|
||||
.data
|
||||
.lock()
|
||||
.await
|
||||
.get(&(group_id, collection.to_string()))
|
||||
.map(|m| m.iter().map(|(id, d)| row(*id, d.clone())).collect())
|
||||
.unwrap_or_default())
|
||||
}
|
||||
|
||||
async fn update(
|
||||
&self,
|
||||
group_id: GroupId,
|
||||
collection: &str,
|
||||
id: DocId,
|
||||
data: serde_json::Value,
|
||||
) -> Result<Option<serde_json::Value>, GroupDocsRepoError> {
|
||||
let mut guard = self.data.lock().await;
|
||||
let coll = guard.entry((group_id, collection.to_string())).or_default();
|
||||
Ok(coll.insert(id, data))
|
||||
}
|
||||
|
||||
async fn delete(
|
||||
&self,
|
||||
group_id: GroupId,
|
||||
collection: &str,
|
||||
id: DocId,
|
||||
) -> Result<Option<serde_json::Value>, GroupDocsRepoError> {
|
||||
Ok(self
|
||||
.data
|
||||
.lock()
|
||||
.await
|
||||
.get_mut(&(group_id, collection.to_string()))
|
||||
.and_then(|m| m.remove(&id)))
|
||||
}
|
||||
|
||||
async fn list(
|
||||
&self,
|
||||
_group_id: GroupId,
|
||||
_collection: &str,
|
||||
_cursor: Option<&str>,
|
||||
_limit: u32,
|
||||
) -> Result<DocsListPage, GroupDocsRepoError> {
|
||||
Ok(DocsListPage {
|
||||
docs: vec![],
|
||||
next_cursor: None,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct FakeResolver {
|
||||
map: HashMap<(AppId, String), GroupId>,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl GroupCollectionResolver for FakeResolver {
|
||||
async fn resolve_owning_group(
|
||||
&self,
|
||||
app_id: AppId,
|
||||
name: &str,
|
||||
_kind: &str,
|
||||
) -> Result<Option<GroupId>, sqlx::Error> {
|
||||
Ok(self.map.get(&(app_id, name.to_lowercase())).copied())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct DenyingAuthzRepo;
|
||||
|
||||
#[async_trait]
|
||||
impl AuthzRepo for DenyingAuthzRepo {
|
||||
async fn membership(
|
||||
&self,
|
||||
_user_id: UserId,
|
||||
_app_id: AppId,
|
||||
) -> Result<Option<AppRole>, AuthzError> {
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
|
||||
fn cx_with(app_id: AppId, principal: Option<Principal>) -> SdkCallCx {
|
||||
SdkCallCx {
|
||||
app_id,
|
||||
script_id: ScriptId::new(),
|
||||
principal,
|
||||
execution_id: ExecutionId::new(),
|
||||
request_id: RequestId::new(),
|
||||
trigger_depth: 0,
|
||||
root_execution_id: ExecutionId::new(),
|
||||
is_dead_letter_handler: false,
|
||||
event: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn owner() -> Principal {
|
||||
Principal {
|
||||
user_id: AdminUserId::new(),
|
||||
instance_role: InstanceRole::Owner,
|
||||
scopes: None,
|
||||
app_binding: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn svc(resolver: FakeResolver) -> GroupDocsServiceImpl {
|
||||
GroupDocsServiceImpl::new(
|
||||
Arc::new(InMemoryGroupDocsRepo::default()),
|
||||
Arc::new(resolver),
|
||||
Arc::new(DenyingAuthzRepo),
|
||||
)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn unrelated_app_gets_collection_not_shared() {
|
||||
let app_a = AppId::new();
|
||||
let app_b = AppId::new();
|
||||
let group = GroupId::new();
|
||||
let mut resolver = FakeResolver::default();
|
||||
resolver.map.insert((app_a, "articles".into()), group);
|
||||
let docs = svc(resolver);
|
||||
|
||||
let cx_a = cx_with(app_a, Some(owner()));
|
||||
let id = docs
|
||||
.create(&cx_a, "articles", serde_json::json!({"t": "hi"}))
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(docs.get(&cx_a, "articles", id).await.unwrap().is_some());
|
||||
|
||||
let cx_b = cx_with(app_b, Some(owner()));
|
||||
let err = docs
|
||||
.find(&cx_b, "articles", serde_json::json!({}))
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(matches!(err, GroupDocsError::CollectionNotShared(c) if c == "articles"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn reads_open_writes_require_auth() {
|
||||
let app = AppId::new();
|
||||
let group = GroupId::new();
|
||||
let mut resolver = FakeResolver::default();
|
||||
resolver.map.insert((app, "articles".into()), group);
|
||||
let docs = svc(resolver);
|
||||
|
||||
let owner_cx = cx_with(app, Some(owner()));
|
||||
docs.create(&owner_cx, "articles", serde_json::json!({"t": "hi"}))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Anonymous READ (find) is allowed.
|
||||
let anon = cx_with(app, None);
|
||||
let hits = docs
|
||||
.find(&anon, "articles", serde_json::json!({}))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(hits.len(), 1);
|
||||
|
||||
// Anonymous WRITE (create) fails closed.
|
||||
let err = docs
|
||||
.create(&anon, "articles", serde_json::json!({"t": "x"}))
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(matches!(err, GroupDocsError::Forbidden));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn non_object_data_rejected() {
|
||||
let app = AppId::new();
|
||||
let group = GroupId::new();
|
||||
let mut resolver = FakeResolver::default();
|
||||
resolver.map.insert((app, "articles".into()), group);
|
||||
let docs = svc(resolver);
|
||||
let cx = cx_with(app, Some(owner()));
|
||||
let err = docs
|
||||
.create(&cx, "articles", serde_json::json!("not-an-object"))
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(matches!(err, GroupDocsError::InvalidData));
|
||||
}
|
||||
}
|
||||
@@ -1,416 +0,0 @@
|
||||
//! Low-level metadata (Postgres `group_files`) + blob bytes (filesystem) storage
|
||||
//! for §11.6 group-shared FILES collections. A near-clone of [`crate::files_repo`]
|
||||
//! keyed by the owning `group_id` instead of `app_id`.
|
||||
//!
|
||||
//! The security-sensitive disk mechanics — the atomic write+checksum protocol and
|
||||
//! checksum-on-read — are **not** duplicated: they come from the owner-relative
|
||||
//! free functions in [`crate::files_repo`] (`write_atomic_at` / `read_verify_at` /
|
||||
//! `final_path_at`), called with this repo's owner sub-path. Group blobs shard at
|
||||
//! `<root>/files/groups/<group_id>/<collection>/<id[0:2]>/<id>` — a `groups/` infix
|
||||
//! disjoint from the per-app `files/<app_id>/...` subtree, so the orphan sweeper
|
||||
//! ([crate::files_sweep]) covers both with one walk. Authorization, group
|
||||
//! resolution, value validation, and content-type sanitization live one layer up
|
||||
//! in `GroupFilesServiceImpl`.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use async_trait::async_trait;
|
||||
use chrono::{DateTime, Utc};
|
||||
use picloud_shared::{FileMeta, FileUpdate, FilesListPage, GroupId, NewFile};
|
||||
use sqlx::PgPool;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::files_repo::{
|
||||
decode_cursor, encode_cursor, final_path_at, read_verify_at, write_atomic_at, FileUpdated,
|
||||
FilesRepoError,
|
||||
};
|
||||
|
||||
const FILES_LIST_MAX_LIMIT: u32 = 1_000;
|
||||
const FILES_LIST_DEFAULT_LIMIT: u32 = 100;
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum GroupFilesRepoError {
|
||||
#[error("database error: {0}")]
|
||||
Db(#[from] sqlx::Error),
|
||||
|
||||
#[error("filesystem error: {0}")]
|
||||
Io(String),
|
||||
|
||||
#[error("invalid collection name: {0}")]
|
||||
InvalidCollection(String),
|
||||
|
||||
/// Bytes on disk no longer match the stored checksum (or are missing).
|
||||
#[error("file content corrupted (checksum mismatch)")]
|
||||
Corrupted,
|
||||
|
||||
#[error("invalid pagination cursor")]
|
||||
InvalidCursor,
|
||||
}
|
||||
|
||||
impl From<FilesRepoError> for GroupFilesRepoError {
|
||||
fn from(e: FilesRepoError) -> Self {
|
||||
match e {
|
||||
FilesRepoError::Db(e) => Self::Db(e),
|
||||
FilesRepoError::Io(s) => Self::Io(s),
|
||||
FilesRepoError::InvalidCollection(c) => Self::InvalidCollection(c),
|
||||
FilesRepoError::Corrupted => Self::Corrupted,
|
||||
FilesRepoError::InvalidCursor => Self::InvalidCursor,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The owner-relative subdirectory of a **group**'s shared blobs under
|
||||
/// `<root>/files/`: `groups/<group_id>`. A UUID app-dir can never equal the
|
||||
/// literal `groups`, so app and group blob trees can't collide.
|
||||
pub(crate) fn group_owner_dir(group_id: GroupId) -> PathBuf {
|
||||
Path::new("groups").join(group_id.into_inner().to_string())
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
pub trait GroupFilesRepo: Send + Sync {
|
||||
async fn create(
|
||||
&self,
|
||||
group_id: GroupId,
|
||||
collection: &str,
|
||||
new: NewFile,
|
||||
) -> Result<FileMeta, GroupFilesRepoError>;
|
||||
|
||||
async fn head(
|
||||
&self,
|
||||
group_id: GroupId,
|
||||
collection: &str,
|
||||
id: Uuid,
|
||||
) -> Result<Option<FileMeta>, GroupFilesRepoError>;
|
||||
|
||||
/// Reads + checksum-verifies the bytes. `Ok(None)` when no row exists;
|
||||
/// `Err(Corrupted)` when the row exists but the bytes are missing/mismatched.
|
||||
async fn get(
|
||||
&self,
|
||||
group_id: GroupId,
|
||||
collection: &str,
|
||||
id: Uuid,
|
||||
) -> Result<Option<Vec<u8>>, GroupFilesRepoError>;
|
||||
|
||||
/// `Ok(None)` when no row exists (the service maps that to `NotFound`).
|
||||
async fn update(
|
||||
&self,
|
||||
group_id: GroupId,
|
||||
collection: &str,
|
||||
id: Uuid,
|
||||
upd: FileUpdate,
|
||||
) -> Result<Option<FileUpdated>, GroupFilesRepoError>;
|
||||
|
||||
async fn delete(
|
||||
&self,
|
||||
group_id: GroupId,
|
||||
collection: &str,
|
||||
id: Uuid,
|
||||
) -> Result<Option<FileMeta>, GroupFilesRepoError>;
|
||||
|
||||
async fn list(
|
||||
&self,
|
||||
group_id: GroupId,
|
||||
collection: &str,
|
||||
cursor: Option<&str>,
|
||||
limit: u32,
|
||||
) -> Result<FilesListPage, GroupFilesRepoError>;
|
||||
|
||||
/// §11.6 quota: total stored bytes across the group's shared-files
|
||||
/// collections. Default `Ok(0)` so non-Postgres impls skip the check.
|
||||
async fn total_bytes(&self, group_id: GroupId) -> Result<u64, GroupFilesRepoError> {
|
||||
let _ = group_id;
|
||||
Ok(0)
|
||||
}
|
||||
}
|
||||
|
||||
/// Filesystem-bytes + Postgres-metadata repo for group-shared files.
|
||||
pub struct FsGroupFilesRepo {
|
||||
pool: PgPool,
|
||||
root: PathBuf,
|
||||
}
|
||||
|
||||
impl FsGroupFilesRepo {
|
||||
#[must_use]
|
||||
pub fn new(pool: PgPool, root: PathBuf) -> Self {
|
||||
Self { pool, root }
|
||||
}
|
||||
|
||||
/// Belt-and-suspenders path guard (the service validates at the SDK
|
||||
/// boundary). Mirrors `FsFilesRepo::guard_collection`.
|
||||
fn guard_collection(collection: &str) -> Result<(), GroupFilesRepoError> {
|
||||
if collection.is_empty()
|
||||
|| collection.contains('/')
|
||||
|| collection.contains('\\')
|
||||
|| collection.contains("..")
|
||||
|| collection.contains('\0')
|
||||
{
|
||||
return Err(GroupFilesRepoError::InvalidCollection(
|
||||
collection.to_string(),
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn write_atomic(
|
||||
&self,
|
||||
group_id: GroupId,
|
||||
collection: &str,
|
||||
id: Uuid,
|
||||
bytes: &[u8],
|
||||
) -> Result<String, GroupFilesRepoError> {
|
||||
Ok(write_atomic_at(
|
||||
&self.root,
|
||||
&group_owner_dir(group_id),
|
||||
collection,
|
||||
id,
|
||||
bytes,
|
||||
)?)
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl GroupFilesRepo for FsGroupFilesRepo {
|
||||
async fn create(
|
||||
&self,
|
||||
group_id: GroupId,
|
||||
collection: &str,
|
||||
new: NewFile,
|
||||
) -> Result<FileMeta, GroupFilesRepoError> {
|
||||
Self::guard_collection(collection)?;
|
||||
let id = Uuid::new_v4();
|
||||
let size = i64::try_from(new.data.len()).unwrap_or(i64::MAX);
|
||||
let checksum = self.write_atomic(group_id, collection, id, &new.data)?;
|
||||
|
||||
let row: GroupFileRow = sqlx::query_as(
|
||||
"INSERT INTO group_files \
|
||||
(group_id, collection, id, name, content_type, size_bytes, checksum_sha256) \
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7) \
|
||||
RETURNING id, collection, name, content_type, size_bytes, \
|
||||
checksum_sha256, created_at, updated_at",
|
||||
)
|
||||
.bind(group_id.into_inner())
|
||||
.bind(collection)
|
||||
.bind(id)
|
||||
.bind(&new.name)
|
||||
.bind(&new.content_type)
|
||||
.bind(size)
|
||||
.bind(&checksum)
|
||||
.fetch_one(&self.pool)
|
||||
.await?;
|
||||
Ok(row.into_meta())
|
||||
}
|
||||
|
||||
async fn head(
|
||||
&self,
|
||||
group_id: GroupId,
|
||||
collection: &str,
|
||||
id: Uuid,
|
||||
) -> Result<Option<FileMeta>, GroupFilesRepoError> {
|
||||
Self::guard_collection(collection)?;
|
||||
let row: Option<GroupFileRow> = sqlx::query_as(
|
||||
"SELECT id, collection, name, content_type, size_bytes, \
|
||||
checksum_sha256, created_at, updated_at \
|
||||
FROM group_files WHERE group_id = $1 AND collection = $2 AND id = $3",
|
||||
)
|
||||
.bind(group_id.into_inner())
|
||||
.bind(collection)
|
||||
.bind(id)
|
||||
.fetch_optional(&self.pool)
|
||||
.await?;
|
||||
Ok(row.map(GroupFileRow::into_meta))
|
||||
}
|
||||
|
||||
async fn get(
|
||||
&self,
|
||||
group_id: GroupId,
|
||||
collection: &str,
|
||||
id: Uuid,
|
||||
) -> Result<Option<Vec<u8>>, GroupFilesRepoError> {
|
||||
Self::guard_collection(collection)?;
|
||||
let row: Option<(String,)> = sqlx::query_as(
|
||||
"SELECT checksum_sha256 FROM group_files \
|
||||
WHERE group_id = $1 AND collection = $2 AND id = $3",
|
||||
)
|
||||
.bind(group_id.into_inner())
|
||||
.bind(collection)
|
||||
.bind(id)
|
||||
.fetch_optional(&self.pool)
|
||||
.await?;
|
||||
let Some((stored_checksum,)) = row else {
|
||||
return Ok(None);
|
||||
};
|
||||
let bytes = read_verify_at(
|
||||
&self.root,
|
||||
&group_owner_dir(group_id),
|
||||
collection,
|
||||
id,
|
||||
&stored_checksum,
|
||||
)?;
|
||||
Ok(Some(bytes))
|
||||
}
|
||||
|
||||
async fn update(
|
||||
&self,
|
||||
group_id: GroupId,
|
||||
collection: &str,
|
||||
id: Uuid,
|
||||
upd: FileUpdate,
|
||||
) -> Result<Option<FileUpdated>, GroupFilesRepoError> {
|
||||
Self::guard_collection(collection)?;
|
||||
let Some(prev) = self.head(group_id, collection, id).await? else {
|
||||
return Ok(None);
|
||||
};
|
||||
let size = i64::try_from(upd.data.len()).unwrap_or(i64::MAX);
|
||||
let checksum = self.write_atomic(group_id, collection, id, &upd.data)?;
|
||||
|
||||
let row: GroupFileRow = sqlx::query_as(
|
||||
"UPDATE group_files SET \
|
||||
name = COALESCE($4, name), \
|
||||
content_type = COALESCE($5, content_type), \
|
||||
size_bytes = $6, \
|
||||
checksum_sha256 = $7, \
|
||||
updated_at = NOW() \
|
||||
WHERE group_id = $1 AND collection = $2 AND id = $3 \
|
||||
RETURNING id, collection, name, content_type, size_bytes, \
|
||||
checksum_sha256, created_at, updated_at",
|
||||
)
|
||||
.bind(group_id.into_inner())
|
||||
.bind(collection)
|
||||
.bind(id)
|
||||
.bind(upd.name.as_deref())
|
||||
.bind(upd.content_type.as_deref())
|
||||
.bind(size)
|
||||
.bind(&checksum)
|
||||
.fetch_one(&self.pool)
|
||||
.await?;
|
||||
Ok(Some(FileUpdated {
|
||||
new: row.into_meta(),
|
||||
prev,
|
||||
}))
|
||||
}
|
||||
|
||||
async fn delete(
|
||||
&self,
|
||||
group_id: GroupId,
|
||||
collection: &str,
|
||||
id: Uuid,
|
||||
) -> Result<Option<FileMeta>, GroupFilesRepoError> {
|
||||
Self::guard_collection(collection)?;
|
||||
let mut tx = self.pool.begin().await?;
|
||||
let row: Option<GroupFileRow> = sqlx::query_as(
|
||||
"SELECT id, collection, name, content_type, size_bytes, \
|
||||
checksum_sha256, created_at, updated_at \
|
||||
FROM group_files WHERE group_id = $1 AND collection = $2 AND id = $3 \
|
||||
FOR UPDATE",
|
||||
)
|
||||
.bind(group_id.into_inner())
|
||||
.bind(collection)
|
||||
.bind(id)
|
||||
.fetch_optional(&mut *tx)
|
||||
.await?;
|
||||
|
||||
let Some(row) = row else {
|
||||
tx.rollback().await?;
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
sqlx::query("DELETE FROM group_files WHERE group_id = $1 AND collection = $2 AND id = $3")
|
||||
.bind(group_id.into_inner())
|
||||
.bind(collection)
|
||||
.bind(id)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
tx.commit().await?;
|
||||
|
||||
// Row gone; unlink the bytes. A failure here leaves an orphan file
|
||||
// (reclaimed by the sweep) — not fatal.
|
||||
let path = final_path_at(&self.root, &group_owner_dir(group_id), collection, id);
|
||||
if let Err(e) = std::fs::remove_file(&path) {
|
||||
if e.kind() != std::io::ErrorKind::NotFound {
|
||||
tracing::warn!(path = %path.display(), error = %e, "group files: unlink after delete failed (orphan)");
|
||||
}
|
||||
}
|
||||
Ok(Some(row.into_meta()))
|
||||
}
|
||||
|
||||
async fn list(
|
||||
&self,
|
||||
group_id: GroupId,
|
||||
collection: &str,
|
||||
cursor: Option<&str>,
|
||||
limit: u32,
|
||||
) -> Result<FilesListPage, GroupFilesRepoError> {
|
||||
Self::guard_collection(collection)?;
|
||||
let limit = if limit == 0 {
|
||||
FILES_LIST_DEFAULT_LIMIT
|
||||
} else {
|
||||
limit.min(FILES_LIST_MAX_LIMIT)
|
||||
};
|
||||
let last_id = match cursor {
|
||||
Some(c) => Some(decode_cursor(c)?),
|
||||
None => None,
|
||||
};
|
||||
let take = i64::from(limit) + 1;
|
||||
let rows: Vec<GroupFileRow> = sqlx::query_as(
|
||||
"SELECT id, collection, name, content_type, size_bytes, \
|
||||
checksum_sha256, created_at, updated_at \
|
||||
FROM group_files \
|
||||
WHERE group_id = $1 AND collection = $2 \
|
||||
AND ($3::uuid IS NULL OR id > $3) \
|
||||
ORDER BY id ASC \
|
||||
LIMIT $4",
|
||||
)
|
||||
.bind(group_id.into_inner())
|
||||
.bind(collection)
|
||||
.bind(last_id)
|
||||
.bind(take)
|
||||
.fetch_all(&self.pool)
|
||||
.await?;
|
||||
|
||||
let mut files: Vec<FileMeta> = rows.into_iter().map(GroupFileRow::into_meta).collect();
|
||||
let next_cursor = if files.len() > limit as usize {
|
||||
files.truncate(limit as usize);
|
||||
files.last().map(|m| encode_cursor(m.id))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
Ok(FilesListPage { files, next_cursor })
|
||||
}
|
||||
|
||||
async fn total_bytes(&self, group_id: GroupId) -> Result<u64, GroupFilesRepoError> {
|
||||
let (n,): (i64,) = sqlx::query_as(
|
||||
"SELECT COALESCE(SUM(size_bytes), 0)::bigint FROM group_files WHERE group_id = $1",
|
||||
)
|
||||
.bind(group_id.into_inner())
|
||||
.fetch_one(&self.pool)
|
||||
.await?;
|
||||
Ok(u64::try_from(n).unwrap_or(0))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(sqlx::FromRow)]
|
||||
struct GroupFileRow {
|
||||
id: Uuid,
|
||||
collection: String,
|
||||
name: String,
|
||||
content_type: String,
|
||||
size_bytes: i64,
|
||||
checksum_sha256: String,
|
||||
created_at: DateTime<Utc>,
|
||||
updated_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
impl GroupFileRow {
|
||||
fn into_meta(self) -> FileMeta {
|
||||
FileMeta {
|
||||
id: self.id,
|
||||
collection: self.collection,
|
||||
name: self.name,
|
||||
content_type: self.content_type,
|
||||
size: u64::try_from(self.size_bytes).unwrap_or(0),
|
||||
checksum: self.checksum_sha256,
|
||||
created_at: self.created_at,
|
||||
updated_at: self.updated_at,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,557 +0,0 @@
|
||||
//! `GroupFilesServiceImpl` — wires `GroupFilesRepo` + the group-collection
|
||||
//! registry underneath the `picloud_shared::GroupFilesService` trait that scripts
|
||||
//! reach via the `files::shared_collection("name")` Rhai handle (§11.6).
|
||||
//!
|
||||
//! Combines the group-KV/docs service pattern (owner resolution from `cx.app_id`,
|
||||
//! reads-open / writes-authed authz) with the files surface (collection
|
||||
//! path-validation, field + size-cap validation, content-type sanitization). No
|
||||
//! event emission in the MVP (the "group trigger has no app to watch" deferral).
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use picloud_shared::{
|
||||
sanitize_stored_content_type, validate_files_collection, FileMeta, FileUpdate, FilesListPage,
|
||||
GroupFilesError, GroupFilesService, GroupId, NewFile, NoopEventEmitter, SdkCallCx,
|
||||
ServiceEvent, ServiceEventEmitter,
|
||||
};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::authz::{self, AuthzRepo, Capability};
|
||||
use crate::files_repo::FileUpdated;
|
||||
use crate::group_collection_repo::GroupCollectionResolver;
|
||||
use crate::group_files_repo::{GroupFilesRepo, GroupFilesRepoError};
|
||||
|
||||
/// The registry `kind` this service resolves.
|
||||
const KIND_FILES: &str = "files";
|
||||
|
||||
pub struct GroupFilesServiceImpl {
|
||||
repo: Arc<dyn GroupFilesRepo>,
|
||||
resolver: Arc<dyn GroupCollectionResolver>,
|
||||
authz: Arc<dyn AuthzRepo>,
|
||||
max_file_size_bytes: usize,
|
||||
/// §11.6 per-group quota: max total stored bytes across the group's
|
||||
/// shared-files collections (`PICLOUD_GROUP_FILES_MAX_TOTAL_BYTES`).
|
||||
max_total_bytes: u64,
|
||||
/// §11.6: fires `shared = true` files triggers on a shared-collection write.
|
||||
events: Arc<dyn ServiceEventEmitter>,
|
||||
}
|
||||
|
||||
impl GroupFilesServiceImpl {
|
||||
#[must_use]
|
||||
pub fn new(
|
||||
repo: Arc<dyn GroupFilesRepo>,
|
||||
resolver: Arc<dyn GroupCollectionResolver>,
|
||||
authz: Arc<dyn AuthzRepo>,
|
||||
max_file_size_bytes: usize,
|
||||
) -> Self {
|
||||
Self {
|
||||
repo,
|
||||
resolver,
|
||||
authz,
|
||||
max_file_size_bytes,
|
||||
max_total_bytes: crate::group_quota::group_files_max_total_bytes_from_env(),
|
||||
events: Arc::new(NoopEventEmitter),
|
||||
}
|
||||
}
|
||||
|
||||
/// Wire the event emitter (§11.6 shared-collection triggers).
|
||||
#[must_use]
|
||||
pub fn with_events(mut self, events: Arc<dyn ServiceEventEmitter>) -> Self {
|
||||
self.events = events;
|
||||
self
|
||||
}
|
||||
|
||||
/// §11.6: fire `shared = true` files triggers on the owning group.
|
||||
/// Best-effort; an emit failure is logged, never surfaced.
|
||||
async fn emit_shared(
|
||||
&self,
|
||||
cx: &SdkCallCx,
|
||||
group_id: GroupId,
|
||||
op: &'static str,
|
||||
collection: &str,
|
||||
meta: &FileMeta,
|
||||
old: Option<&FileMeta>,
|
||||
) {
|
||||
if let Err(e) = self
|
||||
.events
|
||||
.emit_shared(
|
||||
cx,
|
||||
group_id,
|
||||
ServiceEvent {
|
||||
source: "files",
|
||||
op,
|
||||
collection: Some(collection.to_string()),
|
||||
key: Some(meta.id.to_string()),
|
||||
payload: serde_json::to_value(meta).ok(),
|
||||
old_payload: old.and_then(|m| serde_json::to_value(m).ok()),
|
||||
},
|
||||
)
|
||||
.await
|
||||
{
|
||||
tracing::error!(error = %e, source = "files", op, event_emit_failure = true, "shared event emit failed");
|
||||
}
|
||||
}
|
||||
|
||||
/// The structural boundary: resolve the collection to its owning group
|
||||
/// (kind=`files`) on the calling app's chain. `CollectionNotShared` when no
|
||||
/// ancestor group declares it.
|
||||
async fn owning_group(
|
||||
&self,
|
||||
cx: &SdkCallCx,
|
||||
collection: &str,
|
||||
) -> Result<GroupId, GroupFilesError> {
|
||||
self.resolver
|
||||
.resolve_owning_group(cx.app_id, collection, KIND_FILES)
|
||||
.await
|
||||
.map_err(|e| GroupFilesError::Backend(e.to_string()))?
|
||||
.ok_or_else(|| GroupFilesError::CollectionNotShared(collection.to_string()))
|
||||
}
|
||||
|
||||
async fn check_read(&self, cx: &SdkCallCx, group_id: GroupId) -> Result<(), GroupFilesError> {
|
||||
authz::script_gate(
|
||||
&*self.authz,
|
||||
cx,
|
||||
Capability::GroupFilesRead(group_id),
|
||||
|| GroupFilesError::Forbidden,
|
||||
GroupFilesError::Backend,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn check_write(&self, cx: &SdkCallCx, group_id: GroupId) -> Result<(), GroupFilesError> {
|
||||
// Fails closed for an anonymous principal — shared mutation always needs
|
||||
// an authenticated editor+ on the owning group.
|
||||
authz::script_gate_require_principal(
|
||||
&*self.authz,
|
||||
cx,
|
||||
Capability::GroupFilesWrite(group_id),
|
||||
|| GroupFilesError::Forbidden,
|
||||
GroupFilesError::Backend,
|
||||
)
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
||||
/// Invalid UUIDs aren't an error shape the SDK exposes — for reads/deletes they
|
||||
/// simply mean "no such file" (mirrors `files_service::parse_id`).
|
||||
fn parse_id(id: &str) -> Option<Uuid> {
|
||||
Uuid::parse_str(id).ok()
|
||||
}
|
||||
|
||||
impl From<GroupFilesRepoError> for GroupFilesError {
|
||||
fn from(e: GroupFilesRepoError) -> Self {
|
||||
match e {
|
||||
GroupFilesRepoError::Corrupted => Self::Corrupted,
|
||||
GroupFilesRepoError::InvalidCollection(c) => Self::InvalidCollection(c),
|
||||
other => Self::Backend(other.to_string()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl GroupFilesService for GroupFilesServiceImpl {
|
||||
async fn create(
|
||||
&self,
|
||||
cx: &SdkCallCx,
|
||||
collection: &str,
|
||||
mut new: NewFile,
|
||||
) -> Result<Uuid, GroupFilesError> {
|
||||
validate_files_collection(collection)?;
|
||||
let group_id = self.owning_group(cx, collection).await?;
|
||||
new.validate(self.max_file_size_bytes)?;
|
||||
// Coerce dangerous render types to application/octet-stream after the
|
||||
// shape checks pass (same as app files, audit 2026-06-11 C-2).
|
||||
new.content_type = sanitize_stored_content_type(&new.content_type);
|
||||
self.check_write(cx, group_id).await?;
|
||||
// §11.6 quota: the new file's bytes must fit under the group's total.
|
||||
let used = self.repo.total_bytes(group_id).await?;
|
||||
let incoming = new.data.len() as u64;
|
||||
if used.saturating_add(incoming) > self.max_total_bytes {
|
||||
return Err(GroupFilesError::QuotaExceeded {
|
||||
limit: self.max_total_bytes,
|
||||
actual: used.saturating_add(incoming),
|
||||
});
|
||||
}
|
||||
let meta = self.repo.create(group_id, collection, new).await?;
|
||||
self.emit_shared(cx, group_id, "create", collection, &meta, None)
|
||||
.await;
|
||||
Ok(meta.id)
|
||||
}
|
||||
|
||||
async fn head(
|
||||
&self,
|
||||
cx: &SdkCallCx,
|
||||
collection: &str,
|
||||
id: &str,
|
||||
) -> Result<Option<FileMeta>, GroupFilesError> {
|
||||
validate_files_collection(collection)?;
|
||||
let group_id = self.owning_group(cx, collection).await?;
|
||||
self.check_read(cx, group_id).await?;
|
||||
let Some(uuid) = parse_id(id) else {
|
||||
return Ok(None);
|
||||
};
|
||||
Ok(self.repo.head(group_id, collection, uuid).await?)
|
||||
}
|
||||
|
||||
async fn get(
|
||||
&self,
|
||||
cx: &SdkCallCx,
|
||||
collection: &str,
|
||||
id: &str,
|
||||
) -> Result<Option<Vec<u8>>, GroupFilesError> {
|
||||
validate_files_collection(collection)?;
|
||||
let group_id = self.owning_group(cx, collection).await?;
|
||||
self.check_read(cx, group_id).await?;
|
||||
let Some(uuid) = parse_id(id) else {
|
||||
return Ok(None);
|
||||
};
|
||||
Ok(self.repo.get(group_id, collection, uuid).await?)
|
||||
}
|
||||
|
||||
async fn update(
|
||||
&self,
|
||||
cx: &SdkCallCx,
|
||||
collection: &str,
|
||||
id: &str,
|
||||
mut upd: FileUpdate,
|
||||
) -> Result<(), GroupFilesError> {
|
||||
validate_files_collection(collection)?;
|
||||
let group_id = self.owning_group(cx, collection).await?;
|
||||
upd.validate(self.max_file_size_bytes)?;
|
||||
if let Some(ct) = upd.content_type.as_deref() {
|
||||
upd.content_type = Some(sanitize_stored_content_type(ct));
|
||||
}
|
||||
self.check_write(cx, group_id).await?;
|
||||
let Some(uuid) = parse_id(id) else {
|
||||
return Err(GroupFilesError::NotFound);
|
||||
};
|
||||
match self.repo.update(group_id, collection, uuid, upd).await? {
|
||||
Some(FileUpdated { new, prev }) => {
|
||||
self.emit_shared(cx, group_id, "update", collection, &new, Some(&prev))
|
||||
.await;
|
||||
Ok(())
|
||||
}
|
||||
None => Err(GroupFilesError::NotFound),
|
||||
}
|
||||
}
|
||||
|
||||
async fn delete(
|
||||
&self,
|
||||
cx: &SdkCallCx,
|
||||
collection: &str,
|
||||
id: &str,
|
||||
) -> Result<bool, GroupFilesError> {
|
||||
validate_files_collection(collection)?;
|
||||
let group_id = self.owning_group(cx, collection).await?;
|
||||
self.check_write(cx, group_id).await?;
|
||||
let Some(uuid) = parse_id(id) else {
|
||||
return Ok(false);
|
||||
};
|
||||
match self.repo.delete(group_id, collection, uuid).await? {
|
||||
Some(meta) => {
|
||||
self.emit_shared(cx, group_id, "delete", collection, &meta, Some(&meta))
|
||||
.await;
|
||||
Ok(true)
|
||||
}
|
||||
None => Ok(false),
|
||||
}
|
||||
}
|
||||
|
||||
async fn list(
|
||||
&self,
|
||||
cx: &SdkCallCx,
|
||||
collection: &str,
|
||||
cursor: Option<&str>,
|
||||
limit: u32,
|
||||
) -> Result<FilesListPage, GroupFilesError> {
|
||||
validate_files_collection(collection)?;
|
||||
let group_id = self.owning_group(cx, collection).await?;
|
||||
self.check_read(cx, group_id).await?;
|
||||
Ok(self.repo.list(group_id, collection, cursor, limit).await?)
|
||||
}
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// Tests — in-memory repo + a fake resolver so unit tests don't need Postgres or
|
||||
// a filesystem. The on-disk atomic-write/checksum mechanics are covered by the
|
||||
// tempdir tests in `files_repo.rs`.
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::authz::{AuthzError, AuthzRepo};
|
||||
use crate::group_files_repo::GroupFilesRepoError;
|
||||
use chrono::Utc;
|
||||
use picloud_shared::{
|
||||
AdminUserId, AppId, AppRole, ExecutionId, InstanceRole, Principal, RequestId, ScriptId,
|
||||
UserId,
|
||||
};
|
||||
use std::collections::HashMap;
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
#[derive(Default)]
|
||||
struct InMemoryGroupFilesRepo {
|
||||
#[allow(clippy::type_complexity)]
|
||||
data: Mutex<HashMap<(GroupId, String, Uuid), (FileMeta, Vec<u8>)>>,
|
||||
}
|
||||
|
||||
fn meta(id: Uuid, collection: &str, new: &NewFile) -> FileMeta {
|
||||
FileMeta {
|
||||
id,
|
||||
collection: collection.to_string(),
|
||||
name: new.name.clone(),
|
||||
content_type: new.content_type.clone(),
|
||||
size: new.data.len() as u64,
|
||||
checksum: String::new(),
|
||||
created_at: Utc::now(),
|
||||
updated_at: Utc::now(),
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl GroupFilesRepo for InMemoryGroupFilesRepo {
|
||||
async fn create(
|
||||
&self,
|
||||
group_id: GroupId,
|
||||
collection: &str,
|
||||
new: NewFile,
|
||||
) -> Result<FileMeta, GroupFilesRepoError> {
|
||||
let id = Uuid::new_v4();
|
||||
let m = meta(id, collection, &new);
|
||||
self.data.lock().await.insert(
|
||||
(group_id, collection.to_string(), id),
|
||||
(m.clone(), new.data),
|
||||
);
|
||||
Ok(m)
|
||||
}
|
||||
|
||||
async fn head(
|
||||
&self,
|
||||
group_id: GroupId,
|
||||
collection: &str,
|
||||
id: Uuid,
|
||||
) -> Result<Option<FileMeta>, GroupFilesRepoError> {
|
||||
Ok(self
|
||||
.data
|
||||
.lock()
|
||||
.await
|
||||
.get(&(group_id, collection.to_string(), id))
|
||||
.map(|(m, _)| m.clone()))
|
||||
}
|
||||
|
||||
async fn get(
|
||||
&self,
|
||||
group_id: GroupId,
|
||||
collection: &str,
|
||||
id: Uuid,
|
||||
) -> Result<Option<Vec<u8>>, GroupFilesRepoError> {
|
||||
Ok(self
|
||||
.data
|
||||
.lock()
|
||||
.await
|
||||
.get(&(group_id, collection.to_string(), id))
|
||||
.map(|(_, b)| b.clone()))
|
||||
}
|
||||
|
||||
async fn update(
|
||||
&self,
|
||||
group_id: GroupId,
|
||||
collection: &str,
|
||||
id: Uuid,
|
||||
upd: FileUpdate,
|
||||
) -> Result<Option<FileUpdated>, GroupFilesRepoError> {
|
||||
let mut data = self.data.lock().await;
|
||||
let key = (group_id, collection.to_string(), id);
|
||||
let Some((prev, _)) = data.get(&key).cloned() else {
|
||||
return Ok(None);
|
||||
};
|
||||
let mut m = prev.clone();
|
||||
m.size = upd.data.len() as u64;
|
||||
if let Some(n) = &upd.name {
|
||||
m.name = n.clone();
|
||||
}
|
||||
data.insert(key, (m.clone(), upd.data));
|
||||
Ok(Some(FileUpdated { new: m, prev }))
|
||||
}
|
||||
|
||||
async fn delete(
|
||||
&self,
|
||||
group_id: GroupId,
|
||||
collection: &str,
|
||||
id: Uuid,
|
||||
) -> Result<Option<FileMeta>, GroupFilesRepoError> {
|
||||
Ok(self
|
||||
.data
|
||||
.lock()
|
||||
.await
|
||||
.remove(&(group_id, collection.to_string(), id))
|
||||
.map(|(m, _)| m))
|
||||
}
|
||||
|
||||
async fn list(
|
||||
&self,
|
||||
group_id: GroupId,
|
||||
collection: &str,
|
||||
_cursor: Option<&str>,
|
||||
_limit: u32,
|
||||
) -> Result<FilesListPage, GroupFilesRepoError> {
|
||||
let files = self
|
||||
.data
|
||||
.lock()
|
||||
.await
|
||||
.iter()
|
||||
.filter(|((g, c, _), _)| *g == group_id && c == collection)
|
||||
.map(|(_, (m, _))| m.clone())
|
||||
.collect();
|
||||
Ok(FilesListPage {
|
||||
files,
|
||||
next_cursor: None,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct FakeResolver {
|
||||
map: HashMap<(AppId, String), GroupId>,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl GroupCollectionResolver for FakeResolver {
|
||||
async fn resolve_owning_group(
|
||||
&self,
|
||||
app_id: AppId,
|
||||
name: &str,
|
||||
_kind: &str,
|
||||
) -> Result<Option<GroupId>, sqlx::Error> {
|
||||
Ok(self.map.get(&(app_id, name.to_lowercase())).copied())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct DenyingAuthzRepo;
|
||||
|
||||
#[async_trait]
|
||||
impl AuthzRepo for DenyingAuthzRepo {
|
||||
async fn membership(
|
||||
&self,
|
||||
_user_id: UserId,
|
||||
_app_id: AppId,
|
||||
) -> Result<Option<AppRole>, AuthzError> {
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
|
||||
fn cx_with(app_id: AppId, principal: Option<Principal>) -> SdkCallCx {
|
||||
SdkCallCx {
|
||||
app_id,
|
||||
script_id: ScriptId::new(),
|
||||
principal,
|
||||
execution_id: ExecutionId::new(),
|
||||
request_id: RequestId::new(),
|
||||
trigger_depth: 0,
|
||||
root_execution_id: ExecutionId::new(),
|
||||
is_dead_letter_handler: false,
|
||||
event: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn owner() -> Principal {
|
||||
Principal {
|
||||
user_id: AdminUserId::new(),
|
||||
instance_role: InstanceRole::Owner,
|
||||
scopes: None,
|
||||
app_binding: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn svc(resolver: FakeResolver) -> GroupFilesServiceImpl {
|
||||
GroupFilesServiceImpl::new(
|
||||
Arc::new(InMemoryGroupFilesRepo::default()),
|
||||
Arc::new(resolver),
|
||||
Arc::new(DenyingAuthzRepo),
|
||||
10 * 1024 * 1024,
|
||||
)
|
||||
}
|
||||
|
||||
fn new_file(name: &str, data: &[u8]) -> NewFile {
|
||||
NewFile {
|
||||
name: name.to_string(),
|
||||
content_type: "application/octet-stream".to_string(),
|
||||
data: data.to_vec(),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn unrelated_app_gets_collection_not_shared() {
|
||||
let app_a = AppId::new();
|
||||
let app_b = AppId::new();
|
||||
let group = GroupId::new();
|
||||
let mut resolver = FakeResolver::default();
|
||||
resolver.map.insert((app_a, "assets".into()), group);
|
||||
let files = svc(resolver);
|
||||
|
||||
let cx_a = cx_with(app_a, Some(owner()));
|
||||
let id = files
|
||||
.create(&cx_a, "assets", new_file("a.txt", b"hi"))
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(files
|
||||
.get(&cx_a, "assets", &id.to_string())
|
||||
.await
|
||||
.unwrap()
|
||||
.is_some());
|
||||
|
||||
let cx_b = cx_with(app_b, Some(owner()));
|
||||
let err = files.list(&cx_b, "assets", None, 100).await.unwrap_err();
|
||||
assert!(matches!(err, GroupFilesError::CollectionNotShared(c) if c == "assets"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn reads_open_writes_require_auth() {
|
||||
let app = AppId::new();
|
||||
let group = GroupId::new();
|
||||
let mut resolver = FakeResolver::default();
|
||||
resolver.map.insert((app, "assets".into()), group);
|
||||
let files = svc(resolver);
|
||||
|
||||
let owner_cx = cx_with(app, Some(owner()));
|
||||
let id = files
|
||||
.create(&owner_cx, "assets", new_file("a.txt", b"hi"))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Anonymous READ (get) is allowed.
|
||||
let anon = cx_with(app, None);
|
||||
let bytes = files.get(&anon, "assets", &id.to_string()).await.unwrap();
|
||||
assert_eq!(bytes, Some(b"hi".to_vec()));
|
||||
|
||||
// Anonymous WRITE (create) fails closed.
|
||||
let err = files
|
||||
.create(&anon, "assets", new_file("x.txt", b"x"))
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(matches!(err, GroupFilesError::Forbidden));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn oversize_blob_rejected() {
|
||||
let app = AppId::new();
|
||||
let group = GroupId::new();
|
||||
let mut resolver = FakeResolver::default();
|
||||
resolver.map.insert((app, "assets".into()), group);
|
||||
let files = GroupFilesServiceImpl::new(
|
||||
Arc::new(InMemoryGroupFilesRepo::default()),
|
||||
Arc::new(resolver),
|
||||
Arc::new(DenyingAuthzRepo),
|
||||
8, // tiny cap
|
||||
);
|
||||
let cx = cx_with(app, Some(owner()));
|
||||
let err = files
|
||||
.create(&cx, "assets", new_file("big", b"123456789"))
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(matches!(err, GroupFilesError::TooLarge { limit: 8, .. }));
|
||||
}
|
||||
}
|
||||
@@ -1,238 +0,0 @@
|
||||
//! Low-level Postgres CRUD over `group_kv_entries` (§11.6). A near-clone of
|
||||
//! [`crate::kv_repo`] keyed by the owning `group_id` instead of `app_id` —
|
||||
//! authorization, group resolution, event policy, and empty-collection
|
||||
//! validation live one layer up in `GroupKvServiceImpl`.
|
||||
|
||||
use async_trait::async_trait;
|
||||
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
|
||||
use base64::Engine as _;
|
||||
use picloud_shared::{GroupId, KvListPage};
|
||||
use sqlx::PgPool;
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum GroupKvRepoError {
|
||||
#[error("database error: {0}")]
|
||||
Db(#[from] sqlx::Error),
|
||||
|
||||
#[error("invalid pagination cursor")]
|
||||
InvalidCursor,
|
||||
}
|
||||
|
||||
/// Repo surface. The trait is exposed so tests can substitute an in-memory
|
||||
/// backing without spinning up Postgres.
|
||||
#[async_trait]
|
||||
pub trait GroupKvRepo: Send + Sync {
|
||||
async fn get(
|
||||
&self,
|
||||
group_id: GroupId,
|
||||
collection: &str,
|
||||
key: &str,
|
||||
) -> Result<Option<serde_json::Value>, GroupKvRepoError>;
|
||||
|
||||
/// Upserts the row. Returns the previous value (if any) so callers can
|
||||
/// determine whether this was an `insert` or an `update`.
|
||||
async fn set(
|
||||
&self,
|
||||
group_id: GroupId,
|
||||
collection: &str,
|
||||
key: &str,
|
||||
value: serde_json::Value,
|
||||
) -> Result<Option<serde_json::Value>, GroupKvRepoError>;
|
||||
|
||||
/// Returns the deleted value if present, `None` if the row didn't exist.
|
||||
async fn delete(
|
||||
&self,
|
||||
group_id: GroupId,
|
||||
collection: &str,
|
||||
key: &str,
|
||||
) -> Result<Option<serde_json::Value>, GroupKvRepoError>;
|
||||
|
||||
async fn has(
|
||||
&self,
|
||||
group_id: GroupId,
|
||||
collection: &str,
|
||||
key: &str,
|
||||
) -> Result<bool, GroupKvRepoError>;
|
||||
|
||||
async fn list(
|
||||
&self,
|
||||
group_id: GroupId,
|
||||
collection: &str,
|
||||
cursor: Option<&str>,
|
||||
limit: u32,
|
||||
) -> Result<KvListPage, GroupKvRepoError>;
|
||||
|
||||
/// §11.6 quota: total key count across all of the group's shared-KV
|
||||
/// collections. Default `Ok(0)` so non-Postgres impls skip the quota check.
|
||||
async fn count_rows(&self, group_id: GroupId) -> Result<u64, GroupKvRepoError> {
|
||||
let _ = group_id;
|
||||
Ok(0)
|
||||
}
|
||||
}
|
||||
|
||||
pub struct PostgresGroupKvRepo {
|
||||
pool: PgPool,
|
||||
}
|
||||
|
||||
impl PostgresGroupKvRepo {
|
||||
#[must_use]
|
||||
pub fn new(pool: PgPool) -> Self {
|
||||
Self { pool }
|
||||
}
|
||||
}
|
||||
|
||||
/// Hard ceiling on `list` page size (mirrors `kv_repo`).
|
||||
const KV_LIST_MAX_LIMIT: u32 = 1_000;
|
||||
const KV_LIST_DEFAULT_LIMIT: u32 = 100;
|
||||
|
||||
#[async_trait]
|
||||
impl GroupKvRepo for PostgresGroupKvRepo {
|
||||
async fn get(
|
||||
&self,
|
||||
group_id: GroupId,
|
||||
collection: &str,
|
||||
key: &str,
|
||||
) -> Result<Option<serde_json::Value>, GroupKvRepoError> {
|
||||
let row: Option<(serde_json::Value,)> = sqlx::query_as(
|
||||
"SELECT value FROM group_kv_entries \
|
||||
WHERE group_id = $1 AND collection = $2 AND key = $3",
|
||||
)
|
||||
.bind(group_id.into_inner())
|
||||
.bind(collection)
|
||||
.bind(key)
|
||||
.fetch_optional(&self.pool)
|
||||
.await?;
|
||||
Ok(row.map(|(v,)| v))
|
||||
}
|
||||
|
||||
async fn set(
|
||||
&self,
|
||||
group_id: GroupId,
|
||||
collection: &str,
|
||||
key: &str,
|
||||
value: serde_json::Value,
|
||||
) -> Result<Option<serde_json::Value>, GroupKvRepoError> {
|
||||
let row: Option<(Option<serde_json::Value>,)> = sqlx::query_as(
|
||||
"WITH prev AS (\
|
||||
SELECT value FROM group_kv_entries \
|
||||
WHERE group_id = $1 AND collection = $2 AND key = $3\
|
||||
), \
|
||||
upserted AS (\
|
||||
INSERT INTO group_kv_entries (group_id, collection, key, value) \
|
||||
VALUES ($1, $2, $3, $4) \
|
||||
ON CONFLICT (group_id, collection, key) DO UPDATE \
|
||||
SET value = EXCLUDED.value, updated_at = NOW() \
|
||||
RETURNING 1\
|
||||
) \
|
||||
SELECT (SELECT value FROM prev) FROM upserted",
|
||||
)
|
||||
.bind(group_id.into_inner())
|
||||
.bind(collection)
|
||||
.bind(key)
|
||||
.bind(value)
|
||||
.fetch_optional(&self.pool)
|
||||
.await?;
|
||||
Ok(row.and_then(|(v,)| v))
|
||||
}
|
||||
|
||||
async fn delete(
|
||||
&self,
|
||||
group_id: GroupId,
|
||||
collection: &str,
|
||||
key: &str,
|
||||
) -> Result<Option<serde_json::Value>, GroupKvRepoError> {
|
||||
let row: Option<(serde_json::Value,)> = sqlx::query_as(
|
||||
"DELETE FROM group_kv_entries \
|
||||
WHERE group_id = $1 AND collection = $2 AND key = $3 \
|
||||
RETURNING value",
|
||||
)
|
||||
.bind(group_id.into_inner())
|
||||
.bind(collection)
|
||||
.bind(key)
|
||||
.fetch_optional(&self.pool)
|
||||
.await?;
|
||||
Ok(row.map(|(v,)| v))
|
||||
}
|
||||
|
||||
async fn has(
|
||||
&self,
|
||||
group_id: GroupId,
|
||||
collection: &str,
|
||||
key: &str,
|
||||
) -> Result<bool, GroupKvRepoError> {
|
||||
let row: Option<(i64,)> = sqlx::query_as(
|
||||
"SELECT 1 FROM group_kv_entries \
|
||||
WHERE group_id = $1 AND collection = $2 AND key = $3",
|
||||
)
|
||||
.bind(group_id.into_inner())
|
||||
.bind(collection)
|
||||
.bind(key)
|
||||
.fetch_optional(&self.pool)
|
||||
.await?;
|
||||
Ok(row.is_some())
|
||||
}
|
||||
|
||||
async fn list(
|
||||
&self,
|
||||
group_id: GroupId,
|
||||
collection: &str,
|
||||
cursor: Option<&str>,
|
||||
limit: u32,
|
||||
) -> Result<KvListPage, GroupKvRepoError> {
|
||||
let limit = if limit == 0 {
|
||||
KV_LIST_DEFAULT_LIMIT
|
||||
} else {
|
||||
limit.min(KV_LIST_MAX_LIMIT)
|
||||
};
|
||||
|
||||
let last_key = match cursor {
|
||||
Some(c) => Some(decode_cursor(c)?),
|
||||
None => None,
|
||||
};
|
||||
|
||||
let take = i64::from(limit) + 1;
|
||||
let rows: Vec<(String,)> = sqlx::query_as(
|
||||
"SELECT key FROM group_kv_entries \
|
||||
WHERE group_id = $1 AND collection = $2 \
|
||||
AND ($3::text IS NULL OR key > $3) \
|
||||
ORDER BY key ASC \
|
||||
LIMIT $4",
|
||||
)
|
||||
.bind(group_id.into_inner())
|
||||
.bind(collection)
|
||||
.bind(last_key.as_deref())
|
||||
.bind(take)
|
||||
.fetch_all(&self.pool)
|
||||
.await?;
|
||||
|
||||
let mut keys: Vec<String> = rows.into_iter().map(|(k,)| k).collect();
|
||||
let next_cursor = if keys.len() > limit as usize {
|
||||
keys.truncate(limit as usize);
|
||||
keys.last().map(|k| encode_cursor(k))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
Ok(KvListPage { keys, next_cursor })
|
||||
}
|
||||
|
||||
async fn count_rows(&self, group_id: GroupId) -> Result<u64, GroupKvRepoError> {
|
||||
let (n,): (i64,) =
|
||||
sqlx::query_as("SELECT COUNT(*) FROM group_kv_entries WHERE group_id = $1")
|
||||
.bind(group_id.into_inner())
|
||||
.fetch_one(&self.pool)
|
||||
.await?;
|
||||
Ok(u64::try_from(n).unwrap_or(0))
|
||||
}
|
||||
}
|
||||
|
||||
fn encode_cursor(last_key: &str) -> String {
|
||||
URL_SAFE_NO_PAD.encode(last_key.as_bytes())
|
||||
}
|
||||
|
||||
fn decode_cursor(cursor: &str) -> Result<String, GroupKvRepoError> {
|
||||
let bytes = URL_SAFE_NO_PAD
|
||||
.decode(cursor)
|
||||
.map_err(|_| GroupKvRepoError::InvalidCursor)?;
|
||||
String::from_utf8(bytes).map_err(|_| GroupKvRepoError::InvalidCursor)
|
||||
}
|
||||
@@ -1,558 +0,0 @@
|
||||
//! `GroupKvServiceImpl` — wires `GroupKvRepo` + the group-collection registry
|
||||
//! underneath the `picloud_shared::GroupKvService` trait that scripts reach via
|
||||
//! the `kv::shared_collection("name")` Rhai handle (§11.6).
|
||||
//!
|
||||
//! Layers added over the raw repo:
|
||||
//!
|
||||
//! 1. Empty-collection rejection.
|
||||
//! 2. **Owner resolution** (the structural isolation boundary): the collection
|
||||
//! name is resolved to the nearest ancestor group that declares it shared,
|
||||
//! walking the calling app's chain. No declaration on the chain → a clean
|
||||
//! `CollectionNotShared`, BEFORE any authz or storage access.
|
||||
//! 3. **Reads-open / writes-authed authz**: reads use `script_gate`
|
||||
//! (anonymous public scripts skip); writes use `script_gate_require_principal`
|
||||
//! (anonymous fails closed — shared mutation always needs an authenticated
|
||||
//! editor+ on the owning group).
|
||||
//! 4. Per-value size cap (reuses `PICLOUD_KV_MAX_VALUE_BYTES`) + a per-group
|
||||
//! row quota (§11.6 M3, `PICLOUD_GROUP_KV_MAX_ROWS`).
|
||||
//! 5. §11.6 M2 shared-collection triggers: a write fires `shared = true`
|
||||
//! triggers on the owning group, under the writer app (`emit_shared`).
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use picloud_shared::{
|
||||
GroupId, GroupKvError, GroupKvService, KvListPage, NoopEventEmitter, SdkCallCx, ServiceEvent,
|
||||
ServiceEventEmitter,
|
||||
};
|
||||
|
||||
use crate::authz::{self, AuthzRepo, Capability};
|
||||
use crate::group_collection_repo::GroupCollectionResolver;
|
||||
use crate::group_kv_repo::{GroupKvRepo, GroupKvRepoError};
|
||||
use crate::kv_service::kv_max_value_bytes_from_env;
|
||||
|
||||
/// The registry `kind` this service resolves.
|
||||
const KIND_KV: &str = "kv";
|
||||
|
||||
pub struct GroupKvServiceImpl {
|
||||
repo: Arc<dyn GroupKvRepo>,
|
||||
resolver: Arc<dyn GroupCollectionResolver>,
|
||||
authz: Arc<dyn AuthzRepo>,
|
||||
max_value_bytes: usize,
|
||||
/// §11.6 per-group quota: max total keys across the group's shared-KV
|
||||
/// collections (`PICLOUD_GROUP_KV_MAX_ROWS`). Checked only when inserting a
|
||||
/// NEW key.
|
||||
max_rows: u64,
|
||||
/// §11.6: fires `shared = true` triggers on a shared-collection write.
|
||||
/// Defaults to the noop emitter; the host wires the outbox emitter via
|
||||
/// [`Self::with_events`].
|
||||
events: Arc<dyn ServiceEventEmitter>,
|
||||
}
|
||||
|
||||
impl GroupKvServiceImpl {
|
||||
#[must_use]
|
||||
pub fn new(
|
||||
repo: Arc<dyn GroupKvRepo>,
|
||||
resolver: Arc<dyn GroupCollectionResolver>,
|
||||
authz: Arc<dyn AuthzRepo>,
|
||||
) -> Self {
|
||||
Self::with_max_value_bytes(repo, resolver, authz, kv_max_value_bytes_from_env())
|
||||
}
|
||||
|
||||
/// Wire the event emitter (§11.6 shared-collection triggers). Without this
|
||||
/// the service uses the noop emitter and shared writes fire nothing.
|
||||
#[must_use]
|
||||
pub fn with_events(mut self, events: Arc<dyn ServiceEventEmitter>) -> Self {
|
||||
self.events = events;
|
||||
self
|
||||
}
|
||||
|
||||
/// Override the per-group row quota (§11.6). Mainly for tests; the host uses
|
||||
/// the env-var default.
|
||||
#[must_use]
|
||||
pub fn with_max_rows(mut self, max_rows: u64) -> Self {
|
||||
self.max_rows = max_rows;
|
||||
self
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn with_max_value_bytes(
|
||||
repo: Arc<dyn GroupKvRepo>,
|
||||
resolver: Arc<dyn GroupCollectionResolver>,
|
||||
authz: Arc<dyn AuthzRepo>,
|
||||
max_value_bytes: usize,
|
||||
) -> Self {
|
||||
Self {
|
||||
events: Arc::new(NoopEventEmitter),
|
||||
max_rows: crate::group_quota::group_kv_max_rows_from_env(),
|
||||
repo,
|
||||
resolver,
|
||||
authz,
|
||||
max_value_bytes,
|
||||
}
|
||||
}
|
||||
|
||||
/// The structural boundary: resolve the collection to its owning group on
|
||||
/// the calling app's chain. `CollectionNotShared` when no ancestor group
|
||||
/// declares it — the same outcome a foreign app gets, by construction.
|
||||
async fn owning_group(
|
||||
&self,
|
||||
cx: &SdkCallCx,
|
||||
collection: &str,
|
||||
) -> Result<GroupId, GroupKvError> {
|
||||
if collection.is_empty() {
|
||||
return Err(GroupKvError::InvalidCollection);
|
||||
}
|
||||
self.resolver
|
||||
.resolve_owning_group(cx.app_id, collection, KIND_KV)
|
||||
.await
|
||||
.map_err(|e| GroupKvError::Backend(e.to_string()))?
|
||||
.ok_or_else(|| GroupKvError::CollectionNotShared(collection.to_string()))
|
||||
}
|
||||
|
||||
/// §11.6: fire `shared = true` kv triggers on the owning group. Best-effort
|
||||
/// — an emit failure is logged, never surfaced (the write already
|
||||
/// committed), matching the per-app `KvServiceImpl` behaviour.
|
||||
async fn emit_shared(
|
||||
&self,
|
||||
cx: &SdkCallCx,
|
||||
group_id: GroupId,
|
||||
op: &'static str,
|
||||
collection: &str,
|
||||
key: &str,
|
||||
payload: Option<serde_json::Value>,
|
||||
) {
|
||||
if let Err(e) = self
|
||||
.events
|
||||
.emit_shared(
|
||||
cx,
|
||||
group_id,
|
||||
ServiceEvent {
|
||||
source: "kv",
|
||||
op,
|
||||
collection: Some(collection.to_string()),
|
||||
key: Some(key.to_string()),
|
||||
payload,
|
||||
old_payload: None,
|
||||
},
|
||||
)
|
||||
.await
|
||||
{
|
||||
tracing::error!(error = %e, source = "kv", op, event_emit_failure = true, "shared event emit failed");
|
||||
}
|
||||
}
|
||||
|
||||
async fn check_read(&self, cx: &SdkCallCx, group_id: GroupId) -> Result<(), GroupKvError> {
|
||||
authz::script_gate(
|
||||
&*self.authz,
|
||||
cx,
|
||||
Capability::GroupKvRead(group_id),
|
||||
|| GroupKvError::Forbidden,
|
||||
GroupKvError::Backend,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn check_write(&self, cx: &SdkCallCx, group_id: GroupId) -> Result<(), GroupKvError> {
|
||||
// Fails closed for an anonymous principal: shared mutation always needs
|
||||
// an authenticated editor+ on the owning group.
|
||||
authz::script_gate_require_principal(
|
||||
&*self.authz,
|
||||
cx,
|
||||
Capability::GroupKvWrite(group_id),
|
||||
|| GroupKvError::Forbidden,
|
||||
GroupKvError::Backend,
|
||||
)
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
||||
impl From<GroupKvRepoError> for GroupKvError {
|
||||
fn from(e: GroupKvRepoError) -> Self {
|
||||
Self::Backend(e.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl GroupKvService for GroupKvServiceImpl {
|
||||
async fn get(
|
||||
&self,
|
||||
cx: &SdkCallCx,
|
||||
collection: &str,
|
||||
key: &str,
|
||||
) -> Result<Option<serde_json::Value>, GroupKvError> {
|
||||
let group_id = self.owning_group(cx, collection).await?;
|
||||
self.check_read(cx, group_id).await?;
|
||||
Ok(self.repo.get(group_id, collection, key).await?)
|
||||
}
|
||||
|
||||
async fn set(
|
||||
&self,
|
||||
cx: &SdkCallCx,
|
||||
collection: &str,
|
||||
key: &str,
|
||||
value: serde_json::Value,
|
||||
) -> Result<(), GroupKvError> {
|
||||
let group_id = self.owning_group(cx, collection).await?;
|
||||
let encoded_len = serde_json::to_vec(&value)
|
||||
.map(|v| v.len())
|
||||
.map_err(|e| GroupKvError::Backend(format!("encode value: {e}")))?;
|
||||
if encoded_len > self.max_value_bytes {
|
||||
return Err(GroupKvError::ValueTooLarge {
|
||||
limit: self.max_value_bytes,
|
||||
actual: encoded_len,
|
||||
});
|
||||
}
|
||||
self.check_write(cx, group_id).await?;
|
||||
// Determine insert vs update for the event op (best-effort, like the
|
||||
// per-app path — the has+set is non-transactional but triggers are
|
||||
// fire-and-forget).
|
||||
let existed = self.repo.has(group_id, collection, key).await?;
|
||||
// §11.6 quota: a NEW key must fit under the group's row ceiling; an
|
||||
// update of an existing key is net-zero and exempt.
|
||||
if !existed {
|
||||
let count = self.repo.count_rows(group_id).await?;
|
||||
if count >= self.max_rows {
|
||||
return Err(GroupKvError::QuotaExceeded {
|
||||
limit: usize::try_from(self.max_rows).unwrap_or(usize::MAX),
|
||||
actual: usize::try_from(count).unwrap_or(usize::MAX),
|
||||
});
|
||||
}
|
||||
}
|
||||
self.repo
|
||||
.set(group_id, collection, key, value.clone())
|
||||
.await?;
|
||||
self.emit_shared(
|
||||
cx,
|
||||
group_id,
|
||||
if existed { "update" } else { "insert" },
|
||||
collection,
|
||||
key,
|
||||
Some(value),
|
||||
)
|
||||
.await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn delete(
|
||||
&self,
|
||||
cx: &SdkCallCx,
|
||||
collection: &str,
|
||||
key: &str,
|
||||
) -> Result<bool, GroupKvError> {
|
||||
let group_id = self.owning_group(cx, collection).await?;
|
||||
self.check_write(cx, group_id).await?;
|
||||
let deleted = self.repo.delete(group_id, collection, key).await?.is_some();
|
||||
if deleted {
|
||||
self.emit_shared(cx, group_id, "delete", collection, key, None)
|
||||
.await;
|
||||
}
|
||||
Ok(deleted)
|
||||
}
|
||||
|
||||
async fn has(&self, cx: &SdkCallCx, collection: &str, key: &str) -> Result<bool, GroupKvError> {
|
||||
let group_id = self.owning_group(cx, collection).await?;
|
||||
self.check_read(cx, group_id).await?;
|
||||
Ok(self.repo.has(group_id, collection, key).await?)
|
||||
}
|
||||
|
||||
async fn list(
|
||||
&self,
|
||||
cx: &SdkCallCx,
|
||||
collection: &str,
|
||||
cursor: Option<&str>,
|
||||
limit: u32,
|
||||
) -> Result<KvListPage, GroupKvError> {
|
||||
let group_id = self.owning_group(cx, collection).await?;
|
||||
self.check_read(cx, group_id).await?;
|
||||
Ok(self.repo.list(group_id, collection, cursor, limit).await?)
|
||||
}
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// Tests — in-memory repo + a fake resolver so unit tests don't need Postgres.
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::authz::{AuthzError, AuthzRepo};
|
||||
use picloud_shared::{
|
||||
AdminUserId, AppId, AppRole, ExecutionId, InstanceRole, Principal, RequestId, ScriptId,
|
||||
UserId,
|
||||
};
|
||||
use std::collections::{BTreeMap, HashMap};
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
#[derive(Default)]
|
||||
struct InMemoryGroupKvRepo {
|
||||
data: Mutex<BTreeMap<(GroupId, String, String), serde_json::Value>>,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl GroupKvRepo for InMemoryGroupKvRepo {
|
||||
async fn get(
|
||||
&self,
|
||||
group_id: GroupId,
|
||||
collection: &str,
|
||||
key: &str,
|
||||
) -> Result<Option<serde_json::Value>, GroupKvRepoError> {
|
||||
Ok(self
|
||||
.data
|
||||
.lock()
|
||||
.await
|
||||
.get(&(group_id, collection.to_string(), key.to_string()))
|
||||
.cloned())
|
||||
}
|
||||
|
||||
async fn set(
|
||||
&self,
|
||||
group_id: GroupId,
|
||||
collection: &str,
|
||||
key: &str,
|
||||
value: serde_json::Value,
|
||||
) -> Result<Option<serde_json::Value>, GroupKvRepoError> {
|
||||
Ok(self
|
||||
.data
|
||||
.lock()
|
||||
.await
|
||||
.insert((group_id, collection.to_string(), key.to_string()), value))
|
||||
}
|
||||
|
||||
async fn delete(
|
||||
&self,
|
||||
group_id: GroupId,
|
||||
collection: &str,
|
||||
key: &str,
|
||||
) -> Result<Option<serde_json::Value>, GroupKvRepoError> {
|
||||
Ok(self
|
||||
.data
|
||||
.lock()
|
||||
.await
|
||||
.remove(&(group_id, collection.to_string(), key.to_string())))
|
||||
}
|
||||
|
||||
async fn has(
|
||||
&self,
|
||||
group_id: GroupId,
|
||||
collection: &str,
|
||||
key: &str,
|
||||
) -> Result<bool, GroupKvRepoError> {
|
||||
Ok(self.data.lock().await.contains_key(&(
|
||||
group_id,
|
||||
collection.to_string(),
|
||||
key.to_string(),
|
||||
)))
|
||||
}
|
||||
|
||||
async fn list(
|
||||
&self,
|
||||
group_id: GroupId,
|
||||
collection: &str,
|
||||
_cursor: Option<&str>,
|
||||
limit: u32,
|
||||
) -> Result<KvListPage, GroupKvRepoError> {
|
||||
let data = self.data.lock().await;
|
||||
let mut keys: Vec<String> = data
|
||||
.iter()
|
||||
.filter(|((g, c, _), _)| *g == group_id && c == collection)
|
||||
.map(|((_, _, k), _)| k.clone())
|
||||
.collect();
|
||||
keys.sort();
|
||||
keys.truncate((limit as usize).max(1));
|
||||
Ok(KvListPage {
|
||||
keys,
|
||||
next_cursor: None,
|
||||
})
|
||||
}
|
||||
|
||||
async fn count_rows(&self, group_id: GroupId) -> Result<u64, GroupKvRepoError> {
|
||||
let data = self.data.lock().await;
|
||||
Ok(data.keys().filter(|(g, _, _)| *g == group_id).count() as u64)
|
||||
}
|
||||
}
|
||||
|
||||
/// Maps `(app_id, lowercased name) -> owning group`. Anything absent is
|
||||
/// "not shared with you".
|
||||
#[derive(Default)]
|
||||
struct FakeResolver {
|
||||
map: HashMap<(AppId, String), GroupId>,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl GroupCollectionResolver for FakeResolver {
|
||||
async fn resolve_owning_group(
|
||||
&self,
|
||||
app_id: AppId,
|
||||
name: &str,
|
||||
_kind: &str,
|
||||
) -> Result<Option<GroupId>, sqlx::Error> {
|
||||
Ok(self.map.get(&(app_id, name.to_lowercase())).copied())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct DenyingAuthzRepo;
|
||||
|
||||
#[async_trait]
|
||||
impl AuthzRepo for DenyingAuthzRepo {
|
||||
async fn membership(
|
||||
&self,
|
||||
_user_id: UserId,
|
||||
_app_id: AppId,
|
||||
) -> Result<Option<AppRole>, AuthzError> {
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
|
||||
fn cx_with(app_id: AppId, principal: Option<Principal>) -> SdkCallCx {
|
||||
SdkCallCx {
|
||||
app_id,
|
||||
script_id: ScriptId::new(),
|
||||
principal,
|
||||
execution_id: ExecutionId::new(),
|
||||
request_id: RequestId::new(),
|
||||
trigger_depth: 0,
|
||||
root_execution_id: ExecutionId::new(),
|
||||
is_dead_letter_handler: false,
|
||||
event: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn owner() -> Principal {
|
||||
Principal {
|
||||
user_id: AdminUserId::new(),
|
||||
instance_role: InstanceRole::Owner,
|
||||
scopes: None,
|
||||
app_binding: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn member_no_role() -> Principal {
|
||||
Principal {
|
||||
user_id: AdminUserId::new(),
|
||||
instance_role: InstanceRole::Member,
|
||||
scopes: None,
|
||||
app_binding: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn svc(resolver: FakeResolver) -> GroupKvServiceImpl {
|
||||
GroupKvServiceImpl::new(
|
||||
Arc::new(InMemoryGroupKvRepo::default()),
|
||||
Arc::new(resolver),
|
||||
Arc::new(DenyingAuthzRepo),
|
||||
)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn per_group_row_quota_rejects_new_keys_but_allows_updates() {
|
||||
// §11.6: a group's shared-KV store has a row ceiling; a NEW key over the
|
||||
// cap is rejected, but updating an existing key (net-zero) still works.
|
||||
let app = AppId::new();
|
||||
let group = GroupId::new();
|
||||
let mut resolver = FakeResolver::default();
|
||||
resolver.map.insert((app, "catalog".into()), group);
|
||||
let kv = GroupKvServiceImpl::new(
|
||||
Arc::new(InMemoryGroupKvRepo::default()),
|
||||
Arc::new(resolver),
|
||||
Arc::new(DenyingAuthzRepo),
|
||||
)
|
||||
.with_max_rows(2);
|
||||
let cx = cx_with(app, Some(owner()));
|
||||
|
||||
kv.set(&cx, "catalog", "a", serde_json::json!(1))
|
||||
.await
|
||||
.unwrap();
|
||||
kv.set(&cx, "catalog", "b", serde_json::json!(2))
|
||||
.await
|
||||
.unwrap();
|
||||
// A third distinct key exceeds the cap.
|
||||
let err = kv
|
||||
.set(&cx, "catalog", "c", serde_json::json!(3))
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(
|
||||
matches!(
|
||||
err,
|
||||
GroupKvError::QuotaExceeded {
|
||||
limit: 2,
|
||||
actual: 2
|
||||
}
|
||||
),
|
||||
"got {err:?}"
|
||||
);
|
||||
// Updating an existing key is exempt (net-zero).
|
||||
kv.set(&cx, "catalog", "a", serde_json::json!(11))
|
||||
.await
|
||||
.expect("update of an existing key must be allowed at the cap");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn unrelated_app_gets_collection_not_shared() {
|
||||
// app_a's chain declares "catalog"; app_b's does not.
|
||||
let app_a = AppId::new();
|
||||
let app_b = AppId::new();
|
||||
let group = GroupId::new();
|
||||
let mut resolver = FakeResolver::default();
|
||||
resolver.map.insert((app_a, "catalog".into()), group);
|
||||
let kv = svc(resolver);
|
||||
|
||||
// app_a (owner principal) can write+read.
|
||||
let cx_a = cx_with(app_a, Some(owner()));
|
||||
kv.set(&cx_a, "catalog", "k", serde_json::json!(1))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
kv.get(&cx_a, "catalog", "k").await.unwrap(),
|
||||
Some(serde_json::json!(1))
|
||||
);
|
||||
|
||||
// app_b is off-chain — the name does not resolve.
|
||||
let cx_b = cx_with(app_b, Some(owner()));
|
||||
let err = kv.get(&cx_b, "catalog", "k").await.unwrap_err();
|
||||
assert!(matches!(err, GroupKvError::CollectionNotShared(c) if c == "catalog"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn reads_open_writes_require_auth() {
|
||||
let app = AppId::new();
|
||||
let group = GroupId::new();
|
||||
let mut resolver = FakeResolver::default();
|
||||
resolver.map.insert((app, "catalog".into()), group);
|
||||
let kv = svc(resolver);
|
||||
|
||||
// Seed a value as owner.
|
||||
let owner_cx = cx_with(app, Some(owner()));
|
||||
kv.set(&owner_cx, "catalog", "k", serde_json::json!("v"))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Anonymous (principal=None) READ is allowed (reads-open).
|
||||
let anon = cx_with(app, None);
|
||||
assert_eq!(
|
||||
kv.get(&anon, "catalog", "k").await.unwrap(),
|
||||
Some(serde_json::json!("v"))
|
||||
);
|
||||
|
||||
// Anonymous WRITE fails closed.
|
||||
let err = kv
|
||||
.set(&anon, "catalog", "k", serde_json::json!("x"))
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(matches!(err, GroupKvError::Forbidden));
|
||||
|
||||
// Authenticated member with no group role: write forbidden.
|
||||
let member = cx_with(app, Some(member_no_role()));
|
||||
let err = kv.delete(&member, "catalog", "k").await.unwrap_err();
|
||||
assert!(matches!(err, GroupKvError::Forbidden));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn empty_collection_rejected_before_resolve() {
|
||||
let kv = svc(FakeResolver::default());
|
||||
let cx = cx_with(AppId::new(), None);
|
||||
let err = kv.get(&cx, "", "k").await.unwrap_err();
|
||||
assert!(matches!(err, GroupKvError::InvalidCollection));
|
||||
}
|
||||
}
|
||||
@@ -1,128 +0,0 @@
|
||||
//! `GroupPubsubServiceImpl` — wires the group-collection registry + the pubsub
|
||||
//! fan-out repo underneath the `picloud_shared::GroupPubsubService` trait that
|
||||
//! scripts reach via the `pubsub::shared_topic("name")` Rhai handle (§11.6 D2).
|
||||
//!
|
||||
//! A shared topic is storeless — a publish resolves the OWNING GROUP (the
|
||||
//! nearest ancestor group declaring the topic shared, kind `topic`), then fans
|
||||
//! out to that group's `shared = true` pubsub triggers, each delivery stamped
|
||||
//! with the WRITER `cx.app_id` so the handler runs under the publishing app
|
||||
//! (matching the M2 shared-collection trigger model). Owner resolution is the
|
||||
//! isolation boundary — a foreign app's chain never reaches the owning group.
|
||||
//!
|
||||
//! Trust model: a publish is a WRITE, so it uses `script_gate_require_principal`
|
||||
//! (anonymous fails closed — shared publish always needs an authenticated
|
||||
//! editor+ on the owning group).
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use picloud_shared::{GroupId, GroupPubsubError, GroupPubsubService, SdkCallCx, TriggerEvent};
|
||||
|
||||
use crate::authz::{self, AuthzRepo, Capability};
|
||||
use crate::group_collection_repo::GroupCollectionResolver;
|
||||
use crate::pubsub_repo::{PublishCtx, PubsubRepo};
|
||||
use crate::pubsub_service::pubsub_max_message_bytes_from_env;
|
||||
|
||||
/// The registry `kind` this service resolves.
|
||||
const KIND_TOPIC: &str = "topic";
|
||||
|
||||
pub struct GroupPubsubServiceImpl {
|
||||
repo: Arc<dyn PubsubRepo>,
|
||||
resolver: Arc<dyn GroupCollectionResolver>,
|
||||
authz: Arc<dyn AuthzRepo>,
|
||||
max_message_bytes: usize,
|
||||
}
|
||||
|
||||
impl GroupPubsubServiceImpl {
|
||||
#[must_use]
|
||||
pub fn new(
|
||||
repo: Arc<dyn PubsubRepo>,
|
||||
resolver: Arc<dyn GroupCollectionResolver>,
|
||||
authz: Arc<dyn AuthzRepo>,
|
||||
) -> Self {
|
||||
Self {
|
||||
repo,
|
||||
resolver,
|
||||
authz,
|
||||
max_message_bytes: pubsub_max_message_bytes_from_env(),
|
||||
}
|
||||
}
|
||||
|
||||
/// The structural boundary: resolve the topic namespace to its owning group
|
||||
/// on the calling app's chain. `CollectionNotShared` when no ancestor group
|
||||
/// declares it — the same outcome a foreign app gets, by construction.
|
||||
async fn owning_group(
|
||||
&self,
|
||||
cx: &SdkCallCx,
|
||||
namespace: &str,
|
||||
) -> Result<GroupId, GroupPubsubError> {
|
||||
self.resolver
|
||||
.resolve_owning_group(cx.app_id, namespace, KIND_TOPIC)
|
||||
.await
|
||||
.map_err(|e| GroupPubsubError::Backend(e.to_string()))?
|
||||
.ok_or_else(|| GroupPubsubError::CollectionNotShared(namespace.to_string()))
|
||||
}
|
||||
|
||||
async fn check_write(&self, cx: &SdkCallCx, group_id: GroupId) -> Result<(), GroupPubsubError> {
|
||||
authz::script_gate_require_principal(
|
||||
&*self.authz,
|
||||
cx,
|
||||
Capability::GroupPubsubPublish(group_id),
|
||||
|| GroupPubsubError::Forbidden,
|
||||
GroupPubsubError::Backend,
|
||||
)
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl GroupPubsubService for GroupPubsubServiceImpl {
|
||||
async fn publish(
|
||||
&self,
|
||||
cx: &SdkCallCx,
|
||||
namespace: &str,
|
||||
subtopic: &str,
|
||||
message: serde_json::Value,
|
||||
) -> Result<u32, GroupPubsubError> {
|
||||
if namespace.trim().is_empty() {
|
||||
return Err(GroupPubsubError::InvalidTopic);
|
||||
}
|
||||
// Size cap first (no DB) — a cheap reject before the resolve/authz work.
|
||||
let encoded_len = serde_json::to_vec(&message)
|
||||
.map(|v| v.len())
|
||||
.map_err(|e| GroupPubsubError::Backend(format!("encode message: {e}")))?;
|
||||
if encoded_len > self.max_message_bytes {
|
||||
return Err(GroupPubsubError::MessageTooLarge {
|
||||
limit: self.max_message_bytes,
|
||||
actual: encoded_len,
|
||||
});
|
||||
}
|
||||
let group_id = self.owning_group(cx, namespace).await?;
|
||||
self.check_write(cx, group_id).await?;
|
||||
|
||||
// The full topic is `namespace` or `namespace.subtopic`; shared triggers
|
||||
// match it with the same `topic_matches` semantics as the per-app path.
|
||||
let topic = if subtopic.trim().is_empty() {
|
||||
namespace.to_string()
|
||||
} else {
|
||||
format!("{namespace}.{subtopic}")
|
||||
};
|
||||
let event = TriggerEvent::Pubsub {
|
||||
topic: topic.clone(),
|
||||
message,
|
||||
published_at: chrono::Utc::now(),
|
||||
};
|
||||
let payload = serde_json::to_value(&event)
|
||||
.map_err(|e| GroupPubsubError::Backend(format!("event serialize: {e}")))?;
|
||||
let publish_ctx = PublishCtx {
|
||||
app_id: cx.app_id,
|
||||
origin_principal: cx.principal.as_ref().map(|p| p.user_id),
|
||||
trigger_depth: cx.trigger_depth,
|
||||
root_execution_id: cx.root_execution_id,
|
||||
};
|
||||
self.repo
|
||||
.fan_out_shared_publish(publish_ctx, group_id, &topic, payload)
|
||||
.await
|
||||
.map_err(|e| GroupPubsubError::Backend(e.to_string()))
|
||||
}
|
||||
}
|
||||
@@ -1,294 +0,0 @@
|
||||
//! `GroupQueueRepo` — CRUD over `group_queue_messages`, the §11.6 D3 group-
|
||||
//! shared durable queue store.
|
||||
//!
|
||||
//! The group counterpart to [`crate::queue_repo::QueueRepo`], keyed by
|
||||
//! `(group_id, collection)` instead of `(app_id, queue_name)`. Producers enqueue
|
||||
//! via `GroupQueueServiceImpl` (`queue::shared_collection(name).enqueue(...)`);
|
||||
//! the dispatcher's queue arm claims from here for a materialized shared-queue
|
||||
//! consumer (competing consumers — every descendant app runs a consumer copy,
|
||||
//! all claiming this one store with `FOR UPDATE SKIP LOCKED`, so each message is
|
||||
//! delivered at-most-once across the subtree).
|
||||
//!
|
||||
//! Deferred (documented): shared-queue dead-lettering — an exhausted message is
|
||||
//! nacked with backoff by the dispatcher (never silently dropped), but there is
|
||||
//! no group dead-letter store yet.
|
||||
|
||||
use async_trait::async_trait;
|
||||
use chrono::{DateTime, Utc};
|
||||
use picloud_shared::{AdminUserId, GroupId, QueueMessageId};
|
||||
use sqlx::PgPool;
|
||||
use uuid::Uuid;
|
||||
|
||||
/// Cap on `depth`/`depth_pending` scans (mirrors `queue_repo`).
|
||||
const QUEUE_DEPTH_SCAN_CAP: i64 = 10_000;
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum GroupQueueRepoError {
|
||||
#[error("database error: {0}")]
|
||||
Db(#[from] sqlx::Error),
|
||||
}
|
||||
|
||||
/// Insert payload — what `GroupQueueService::enqueue` hands the repo.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct NewGroupQueueMessage {
|
||||
pub group_id: GroupId,
|
||||
pub collection: String,
|
||||
pub payload: serde_json::Value,
|
||||
pub deliver_after: Option<DateTime<Utc>>,
|
||||
pub max_attempts: u32,
|
||||
pub enqueued_by_principal: Option<AdminUserId>,
|
||||
}
|
||||
|
||||
/// One claimed message ready for handler dispatch.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ClaimedGroupMessage {
|
||||
pub id: QueueMessageId,
|
||||
pub group_id: GroupId,
|
||||
pub collection: String,
|
||||
pub payload: serde_json::Value,
|
||||
pub enqueued_at: DateTime<Utc>,
|
||||
pub attempt: u32,
|
||||
pub max_attempts: u32,
|
||||
pub claim_token: Uuid,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
pub trait GroupQueueRepo: Send + Sync {
|
||||
async fn enqueue(
|
||||
&self,
|
||||
msg: NewGroupQueueMessage,
|
||||
) -> Result<QueueMessageId, GroupQueueRepoError>;
|
||||
|
||||
/// Atomic claim of one ready message from `(group_id, collection)` with
|
||||
/// `FOR UPDATE SKIP LOCKED` — the competing-consumer primitive. `Ok(None)`
|
||||
/// when nothing is claimable.
|
||||
async fn claim(
|
||||
&self,
|
||||
group_id: GroupId,
|
||||
collection: &str,
|
||||
) -> Result<Option<ClaimedGroupMessage>, GroupQueueRepoError>;
|
||||
|
||||
/// Handler succeeded: delete the row iff `claim_token` matches.
|
||||
async fn ack(
|
||||
&self,
|
||||
message_id: QueueMessageId,
|
||||
claim_token: Uuid,
|
||||
) -> Result<bool, GroupQueueRepoError>;
|
||||
|
||||
/// Handler threw: clear the claim, defer redelivery by `retry_delay`.
|
||||
async fn nack(
|
||||
&self,
|
||||
message_id: QueueMessageId,
|
||||
claim_token: Uuid,
|
||||
retry_delay: chrono::Duration,
|
||||
) -> Result<bool, GroupQueueRepoError>;
|
||||
|
||||
/// Drop a message that exhausted its attempts (no group dead-letter store
|
||||
/// yet — see the module deferral note). Deletes iff `claim_token` matches.
|
||||
async fn drop_exhausted(
|
||||
&self,
|
||||
message_id: QueueMessageId,
|
||||
claim_token: Uuid,
|
||||
) -> Result<bool, GroupQueueRepoError>;
|
||||
|
||||
/// Periodic safety net: clear claims older than a shared-queue consumer's
|
||||
/// `visibility_timeout_secs`. Returns the number of rows reclaimed.
|
||||
async fn reclaim_visibility_timeouts(&self) -> Result<u64, GroupQueueRepoError>;
|
||||
|
||||
async fn depth(&self, group_id: GroupId, collection: &str) -> Result<u64, GroupQueueRepoError>;
|
||||
|
||||
async fn depth_pending(
|
||||
&self,
|
||||
group_id: GroupId,
|
||||
collection: &str,
|
||||
) -> Result<u64, GroupQueueRepoError>;
|
||||
}
|
||||
|
||||
pub struct PostgresGroupQueueRepo {
|
||||
pool: PgPool,
|
||||
}
|
||||
|
||||
impl PostgresGroupQueueRepo {
|
||||
#[must_use]
|
||||
pub fn new(pool: PgPool) -> Self {
|
||||
Self { pool }
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl GroupQueueRepo for PostgresGroupQueueRepo {
|
||||
async fn enqueue(
|
||||
&self,
|
||||
msg: NewGroupQueueMessage,
|
||||
) -> Result<QueueMessageId, GroupQueueRepoError> {
|
||||
let (id,): (Uuid,) = sqlx::query_as(
|
||||
"INSERT INTO group_queue_messages ( \
|
||||
group_id, collection, payload, deliver_after, \
|
||||
max_attempts, enqueued_by_principal \
|
||||
) VALUES ($1, $2, $3, $4, $5, $6) RETURNING id",
|
||||
)
|
||||
.bind(msg.group_id.into_inner())
|
||||
.bind(&msg.collection)
|
||||
.bind(&msg.payload)
|
||||
.bind(msg.deliver_after)
|
||||
.bind(i32::try_from(msg.max_attempts).unwrap_or(3))
|
||||
.bind(msg.enqueued_by_principal.map(AdminUserId::into_inner))
|
||||
.fetch_one(&self.pool)
|
||||
.await?;
|
||||
Ok(id.into())
|
||||
}
|
||||
|
||||
async fn claim(
|
||||
&self,
|
||||
group_id: GroupId,
|
||||
collection: &str,
|
||||
) -> Result<Option<ClaimedGroupMessage>, GroupQueueRepoError> {
|
||||
let token = Uuid::new_v4();
|
||||
let row: Option<ClaimedRow> = sqlx::query_as(
|
||||
"UPDATE group_queue_messages \
|
||||
SET claim_token = $3, claimed_at = NOW(), attempt = attempt + 1 \
|
||||
WHERE id = ( \
|
||||
SELECT id FROM group_queue_messages \
|
||||
WHERE group_id = $1 AND collection = $2 \
|
||||
AND claim_token IS NULL \
|
||||
AND (deliver_after IS NULL OR deliver_after <= NOW()) \
|
||||
ORDER BY enqueued_at \
|
||||
FOR UPDATE SKIP LOCKED \
|
||||
LIMIT 1 \
|
||||
) \
|
||||
RETURNING id, group_id, collection, payload, enqueued_at, attempt, max_attempts",
|
||||
)
|
||||
.bind(group_id.into_inner())
|
||||
.bind(collection)
|
||||
.bind(token)
|
||||
.fetch_optional(&self.pool)
|
||||
.await?;
|
||||
Ok(row.map(|r| ClaimedGroupMessage {
|
||||
id: r.id.into(),
|
||||
group_id: r.group_id.into(),
|
||||
collection: r.collection,
|
||||
payload: r.payload,
|
||||
enqueued_at: r.enqueued_at,
|
||||
attempt: u32::try_from(r.attempt).unwrap_or(1),
|
||||
max_attempts: u32::try_from(r.max_attempts).unwrap_or(3),
|
||||
claim_token: token,
|
||||
}))
|
||||
}
|
||||
|
||||
async fn ack(
|
||||
&self,
|
||||
message_id: QueueMessageId,
|
||||
claim_token: Uuid,
|
||||
) -> Result<bool, GroupQueueRepoError> {
|
||||
let res =
|
||||
sqlx::query("DELETE FROM group_queue_messages WHERE id = $1 AND claim_token = $2")
|
||||
.bind(message_id.into_inner())
|
||||
.bind(claim_token)
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
Ok(res.rows_affected() == 1)
|
||||
}
|
||||
|
||||
async fn nack(
|
||||
&self,
|
||||
message_id: QueueMessageId,
|
||||
claim_token: Uuid,
|
||||
retry_delay: chrono::Duration,
|
||||
) -> Result<bool, GroupQueueRepoError> {
|
||||
let next = Utc::now() + retry_delay;
|
||||
let res = sqlx::query(
|
||||
"UPDATE group_queue_messages \
|
||||
SET claim_token = NULL, claimed_at = NULL, deliver_after = $3 \
|
||||
WHERE id = $1 AND claim_token = $2",
|
||||
)
|
||||
.bind(message_id.into_inner())
|
||||
.bind(claim_token)
|
||||
.bind(next)
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
Ok(res.rows_affected() == 1)
|
||||
}
|
||||
|
||||
async fn drop_exhausted(
|
||||
&self,
|
||||
message_id: QueueMessageId,
|
||||
claim_token: Uuid,
|
||||
) -> Result<bool, GroupQueueRepoError> {
|
||||
let res =
|
||||
sqlx::query("DELETE FROM group_queue_messages WHERE id = $1 AND claim_token = $2")
|
||||
.bind(message_id.into_inner())
|
||||
.bind(claim_token)
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
Ok(res.rows_affected() == 1)
|
||||
}
|
||||
|
||||
async fn reclaim_visibility_timeouts(&self) -> Result<u64, GroupQueueRepoError> {
|
||||
// A shared-queue consumer is a materialized app-owned trigger
|
||||
// (`shared = TRUE`, `group_id` on the SOURCE template) whose
|
||||
// queue_trigger_details.queue_name IS the shared collection name. Join
|
||||
// the group template to recover the owning group + visibility timeout.
|
||||
let res = sqlx::query(
|
||||
"UPDATE group_queue_messages m \
|
||||
SET claim_token = NULL, claimed_at = NULL \
|
||||
FROM triggers copy \
|
||||
JOIN triggers tmpl ON tmpl.id = copy.materialized_from \
|
||||
JOIN queue_trigger_details d ON d.trigger_id = copy.id \
|
||||
WHERE tmpl.group_id = m.group_id \
|
||||
AND d.queue_name = m.collection \
|
||||
AND tmpl.shared = TRUE \
|
||||
AND m.claim_token IS NOT NULL \
|
||||
AND m.claimed_at < NOW() - (d.visibility_timeout_secs || ' seconds')::INTERVAL",
|
||||
)
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
Ok(res.rows_affected())
|
||||
}
|
||||
|
||||
async fn depth(&self, group_id: GroupId, collection: &str) -> Result<u64, GroupQueueRepoError> {
|
||||
let (n,): (i64,) = sqlx::query_as(
|
||||
"SELECT COUNT(*) FROM ( \
|
||||
SELECT 1 FROM group_queue_messages \
|
||||
WHERE group_id = $1 AND collection = $2 LIMIT $3 \
|
||||
) sub",
|
||||
)
|
||||
.bind(group_id.into_inner())
|
||||
.bind(collection)
|
||||
.bind(QUEUE_DEPTH_SCAN_CAP)
|
||||
.fetch_one(&self.pool)
|
||||
.await?;
|
||||
Ok(u64::try_from(n).unwrap_or(0))
|
||||
}
|
||||
|
||||
async fn depth_pending(
|
||||
&self,
|
||||
group_id: GroupId,
|
||||
collection: &str,
|
||||
) -> Result<u64, GroupQueueRepoError> {
|
||||
let (n,): (i64,) = sqlx::query_as(
|
||||
"SELECT COUNT(*) FROM ( \
|
||||
SELECT 1 FROM group_queue_messages \
|
||||
WHERE group_id = $1 AND collection = $2 \
|
||||
AND claim_token IS NULL \
|
||||
AND (deliver_after IS NULL OR deliver_after <= NOW()) LIMIT $3 \
|
||||
) sub",
|
||||
)
|
||||
.bind(group_id.into_inner())
|
||||
.bind(collection)
|
||||
.bind(QUEUE_DEPTH_SCAN_CAP)
|
||||
.fetch_one(&self.pool)
|
||||
.await?;
|
||||
Ok(u64::try_from(n).unwrap_or(0))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(sqlx::FromRow)]
|
||||
struct ClaimedRow {
|
||||
id: Uuid,
|
||||
group_id: Uuid,
|
||||
collection: String,
|
||||
payload: serde_json::Value,
|
||||
enqueued_at: DateTime<Utc>,
|
||||
attempt: i32,
|
||||
max_attempts: i32,
|
||||
}
|
||||
@@ -1,152 +0,0 @@
|
||||
//! `GroupQueueServiceImpl` — wires `GroupQueueRepo` + the group-collection
|
||||
//! registry underneath the `picloud_shared::GroupQueueService` trait that
|
||||
//! scripts reach via the `queue::shared_collection("name")` Rhai handle
|
||||
//! (§11.6 D3).
|
||||
//!
|
||||
//! Resolves the OWNING GROUP (kind `queue`) from `cx.app_id`'s chain (the
|
||||
//! isolation boundary), enforces editor+ (`GroupQueueEnqueue`, fails closed on
|
||||
//! anon — enqueue is a shared write), caps the payload size, then writes to the
|
||||
//! group-keyed store. Consumption is out of band (competing per-descendant
|
||||
//! materialized consumers, in the dispatcher).
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use chrono::Utc;
|
||||
use picloud_shared::{
|
||||
EnqueueOpts, GroupId, GroupQueueError, GroupQueueService, QueueMessageId, SdkCallCx,
|
||||
};
|
||||
|
||||
use crate::authz::{self, AuthzRepo, Capability};
|
||||
use crate::group_collection_repo::GroupCollectionResolver;
|
||||
use crate::group_queue_repo::{GroupQueueRepo, NewGroupQueueMessage};
|
||||
use crate::queue_service::queue_max_payload_bytes_from_env;
|
||||
|
||||
/// The registry `kind` this service resolves.
|
||||
const KIND_QUEUE: &str = "queue";
|
||||
|
||||
pub struct GroupQueueServiceImpl {
|
||||
repo: Arc<dyn GroupQueueRepo>,
|
||||
resolver: Arc<dyn GroupCollectionResolver>,
|
||||
authz: Arc<dyn AuthzRepo>,
|
||||
max_payload_bytes: usize,
|
||||
}
|
||||
|
||||
impl GroupQueueServiceImpl {
|
||||
#[must_use]
|
||||
pub fn new(
|
||||
repo: Arc<dyn GroupQueueRepo>,
|
||||
resolver: Arc<dyn GroupCollectionResolver>,
|
||||
authz: Arc<dyn AuthzRepo>,
|
||||
) -> Self {
|
||||
Self {
|
||||
repo,
|
||||
resolver,
|
||||
authz,
|
||||
max_payload_bytes: queue_max_payload_bytes_from_env(),
|
||||
}
|
||||
}
|
||||
|
||||
async fn owning_group(
|
||||
&self,
|
||||
cx: &SdkCallCx,
|
||||
collection: &str,
|
||||
) -> Result<GroupId, GroupQueueError> {
|
||||
if collection.is_empty() {
|
||||
return Err(GroupQueueError::InvalidCollection);
|
||||
}
|
||||
self.resolver
|
||||
.resolve_owning_group(cx.app_id, collection, KIND_QUEUE)
|
||||
.await
|
||||
.map_err(|e| GroupQueueError::Backend(e.to_string()))?
|
||||
.ok_or_else(|| GroupQueueError::CollectionNotShared(collection.to_string()))
|
||||
}
|
||||
|
||||
async fn check_write(&self, cx: &SdkCallCx, group_id: GroupId) -> Result<(), GroupQueueError> {
|
||||
authz::script_gate_require_principal(
|
||||
&*self.authz,
|
||||
cx,
|
||||
Capability::GroupQueueEnqueue(group_id),
|
||||
|| GroupQueueError::Forbidden,
|
||||
GroupQueueError::Backend,
|
||||
)
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl GroupQueueService for GroupQueueServiceImpl {
|
||||
async fn enqueue(
|
||||
&self,
|
||||
cx: &SdkCallCx,
|
||||
collection: &str,
|
||||
payload: serde_json::Value,
|
||||
opts: EnqueueOpts,
|
||||
) -> Result<QueueMessageId, GroupQueueError> {
|
||||
if collection.is_empty() {
|
||||
return Err(GroupQueueError::InvalidCollection);
|
||||
}
|
||||
// Size cap first (no DB) — a cheap reject before resolve/authz.
|
||||
let encoded_len = serde_json::to_vec(&payload)
|
||||
.map(|v| v.len())
|
||||
.map_err(|e| GroupQueueError::Backend(format!("encode payload: {e}")))?;
|
||||
if encoded_len > self.max_payload_bytes {
|
||||
return Err(GroupQueueError::PayloadTooLarge {
|
||||
limit: self.max_payload_bytes,
|
||||
actual: encoded_len,
|
||||
});
|
||||
}
|
||||
let max_attempts = opts.max_attempts.unwrap_or(3);
|
||||
if !(1..=20).contains(&max_attempts) {
|
||||
return Err(GroupQueueError::InvalidOpts(
|
||||
"queue::enqueue: max_attempts must be in [1, 20]".into(),
|
||||
));
|
||||
}
|
||||
if let Some(delay) = opts.delay_ms {
|
||||
if !(0..=86_400_000).contains(&delay) {
|
||||
return Err(GroupQueueError::InvalidOpts(
|
||||
"queue::enqueue: delay_ms must be in [0, 86_400_000]".into(),
|
||||
));
|
||||
}
|
||||
}
|
||||
let group_id = self.owning_group(cx, collection).await?;
|
||||
self.check_write(cx, group_id).await?;
|
||||
|
||||
let deliver_after = opts
|
||||
.delay_ms
|
||||
.and_then(chrono::Duration::try_milliseconds)
|
||||
.map(|d| Utc::now() + d);
|
||||
self.repo
|
||||
.enqueue(NewGroupQueueMessage {
|
||||
group_id,
|
||||
collection: collection.to_string(),
|
||||
payload,
|
||||
deliver_after,
|
||||
max_attempts,
|
||||
enqueued_by_principal: cx.principal.as_ref().map(|p| p.user_id),
|
||||
})
|
||||
.await
|
||||
.map_err(|e| GroupQueueError::Backend(e.to_string()))
|
||||
}
|
||||
|
||||
async fn depth(&self, cx: &SdkCallCx, collection: &str) -> Result<u64, GroupQueueError> {
|
||||
let group_id = self.owning_group(cx, collection).await?;
|
||||
// Depth is a read — reads are open (any subtree script).
|
||||
self.repo
|
||||
.depth(group_id, collection)
|
||||
.await
|
||||
.map_err(|e| GroupQueueError::Backend(e.to_string()))
|
||||
}
|
||||
|
||||
async fn depth_pending(
|
||||
&self,
|
||||
cx: &SdkCallCx,
|
||||
collection: &str,
|
||||
) -> Result<u64, GroupQueueError> {
|
||||
let group_id = self.owning_group(cx, collection).await?;
|
||||
self.repo
|
||||
.depth_pending(group_id, collection)
|
||||
.await
|
||||
.map_err(|e| GroupQueueError::Backend(e.to_string()))
|
||||
}
|
||||
}
|
||||
@@ -1,44 +0,0 @@
|
||||
//! §11.6 per-group quotas — global env-var ceilings on a group's shared
|
||||
//! collections, enforced in the group write path (mirrors the per-value
|
||||
//! `PICLOUD_*_MAX_*_BYTES` caps). One default applies to every group; per-group
|
||||
//! configurable limits are deferred.
|
||||
//!
|
||||
//! - KV / docs: a per-group ROW-count ceiling (across the group's collections of
|
||||
//! that kind). An update of an existing key/doc is net-zero and exempt.
|
||||
//! - files: a per-group TOTAL-BYTES ceiling (sum of stored blob sizes).
|
||||
|
||||
/// Default per-group shared-KV row ceiling. Override `PICLOUD_GROUP_KV_MAX_ROWS`.
|
||||
pub const DEFAULT_GROUP_KV_MAX_ROWS: u64 = 100_000;
|
||||
/// Default per-group shared-docs row ceiling. Override `PICLOUD_GROUP_DOCS_MAX_ROWS`.
|
||||
pub const DEFAULT_GROUP_DOCS_MAX_ROWS: u64 = 100_000;
|
||||
/// Default per-group shared-files total-bytes ceiling (10 GiB). Override
|
||||
/// `PICLOUD_GROUP_FILES_MAX_TOTAL_BYTES`.
|
||||
pub const DEFAULT_GROUP_FILES_MAX_TOTAL_BYTES: u64 = 10 * 1024 * 1024 * 1024;
|
||||
|
||||
fn u64_from_env(var: &str, default: u64) -> u64 {
|
||||
if let Ok(v) = std::env::var(var) {
|
||||
match v.trim().parse::<u64>() {
|
||||
Ok(n) if n > 0 => return n,
|
||||
_ => tracing::warn!(value = %v, "ignoring invalid {var} (want a positive integer)"),
|
||||
}
|
||||
}
|
||||
default
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn group_kv_max_rows_from_env() -> u64 {
|
||||
u64_from_env("PICLOUD_GROUP_KV_MAX_ROWS", DEFAULT_GROUP_KV_MAX_ROWS)
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn group_docs_max_rows_from_env() -> u64 {
|
||||
u64_from_env("PICLOUD_GROUP_DOCS_MAX_ROWS", DEFAULT_GROUP_DOCS_MAX_ROWS)
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn group_files_max_total_bytes_from_env() -> u64 {
|
||||
u64_from_env(
|
||||
"PICLOUD_GROUP_FILES_MAX_TOTAL_BYTES",
|
||||
DEFAULT_GROUP_FILES_MAX_TOTAL_BYTES,
|
||||
)
|
||||
}
|
||||
@@ -14,7 +14,7 @@
|
||||
//! future CLI/orchestrator can detect structural drift (§6).
|
||||
|
||||
use async_trait::async_trait;
|
||||
use picloud_shared::{Group, GroupId};
|
||||
use picloud_shared::{AppId, Group, GroupId};
|
||||
use sqlx::PgPool;
|
||||
use uuid::Uuid;
|
||||
|
||||
@@ -191,27 +191,11 @@ impl GroupRepository for PostgresGroupRepository {
|
||||
description: Option<&str>,
|
||||
parent_id: Option<GroupId>,
|
||||
) -> Result<Group, GroupRepositoryError> {
|
||||
let res = sqlx::query_as::<_, GroupRow>(&format!(
|
||||
"INSERT INTO groups (slug, name, description, parent_id) \
|
||||
VALUES ($1, $2, $3, $4) \
|
||||
RETURNING {GROUP_COLS}"
|
||||
))
|
||||
.bind(slug)
|
||||
.bind(name)
|
||||
.bind(description)
|
||||
.bind(parent_id.map(GroupId::into_inner))
|
||||
.fetch_one(&self.pool)
|
||||
.await;
|
||||
match res {
|
||||
Ok(row) => Ok(row.into()),
|
||||
Err(sqlx::Error::Database(e)) if e.is_unique_violation() => Err(
|
||||
GroupRepositoryError::Conflict(format!("slug {slug:?} is already in use")),
|
||||
),
|
||||
Err(sqlx::Error::Database(e)) if e.is_foreign_key_violation() => Err(
|
||||
GroupRepositoryError::Conflict("parent group does not exist".into()),
|
||||
),
|
||||
Err(e) => Err(e.into()),
|
||||
}
|
||||
let mut tx = self.pool.begin().await?;
|
||||
// Interactive create: UI/API-owned (no project claim — §7.5).
|
||||
let g = create_group_tx(&mut tx, slug, name, description, parent_id, None).await?;
|
||||
tx.commit().await?;
|
||||
Ok(g)
|
||||
}
|
||||
|
||||
async fn rename(
|
||||
@@ -247,96 +231,167 @@ impl GroupRepository for PostgresGroupRepository {
|
||||
) -> Result<Group, GroupRepositoryError> {
|
||||
let mut tx = self.pool.begin().await?;
|
||||
// Coarse structural lock: serialize all structural mutations so the
|
||||
// cycle guard + parent write can't interleave with a concurrent
|
||||
// reparent and race into a cycle.
|
||||
sqlx::query("SELECT pg_advisory_xact_lock($1)")
|
||||
.bind(GROUP_STRUCTURAL_LOCK_KEY)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
if let Some(parent) = new_parent {
|
||||
if parent == id {
|
||||
return Err(GroupRepositoryError::Conflict(
|
||||
"a group cannot be its own parent".into(),
|
||||
));
|
||||
}
|
||||
// Cycle guard: walk from the destination up to the root; if we
|
||||
// reach `id`, the move would place `id` beneath itself.
|
||||
let mut cursor = Some(parent);
|
||||
let mut hops = 0u32;
|
||||
while let Some(node) = cursor {
|
||||
if node == id {
|
||||
return Err(GroupRepositoryError::Conflict(
|
||||
"cannot reparent a group beneath one of its own descendants".into(),
|
||||
));
|
||||
}
|
||||
hops += 1;
|
||||
if hops > 64 {
|
||||
return Err(GroupRepositoryError::Conflict(
|
||||
"group ancestry exceeds the maximum depth".into(),
|
||||
));
|
||||
}
|
||||
let parent_of: Option<(Option<Uuid>,)> =
|
||||
sqlx::query_as("SELECT parent_id FROM groups WHERE id = $1")
|
||||
.bind(node.into_inner())
|
||||
.fetch_optional(&mut *tx)
|
||||
.await?;
|
||||
match parent_of {
|
||||
Some((p,)) => cursor = p.map(GroupId::from),
|
||||
// Destination parent doesn't exist.
|
||||
None => {
|
||||
return Err(GroupRepositoryError::Conflict(
|
||||
"destination parent group does not exist".into(),
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let row = sqlx::query_as::<_, GroupRow>(&format!(
|
||||
"UPDATE groups SET \
|
||||
parent_id = $2, \
|
||||
structure_version = structure_version + 1, \
|
||||
updated_at = NOW() \
|
||||
WHERE id = $1 \
|
||||
RETURNING {GROUP_COLS}"
|
||||
))
|
||||
.bind(id.into_inner())
|
||||
.bind(new_parent.map(GroupId::into_inner))
|
||||
.fetch_optional(&mut *tx)
|
||||
.await?;
|
||||
let Some(row) = row else {
|
||||
return Err(GroupRepositoryError::NotFound(id));
|
||||
};
|
||||
// cycle guard + parent write can't race a concurrent reparent.
|
||||
acquire_structural_lock_tx(&mut tx).await?;
|
||||
let g = reparent_group_tx(&mut tx, id, new_parent).await?;
|
||||
tx.commit().await?;
|
||||
Ok(row.into())
|
||||
Ok(g)
|
||||
}
|
||||
|
||||
async fn delete(&self, id: GroupId) -> Result<(), GroupRepositoryError> {
|
||||
// Pre-check for a clean message; the FK RESTRICT is the real guard.
|
||||
let counts = self.child_counts(id).await?;
|
||||
if !counts.is_empty() {
|
||||
return Err(GroupRepositoryError::Conflict(format!(
|
||||
"group still has {} subgroup(s) and {} app(s); move or delete them first",
|
||||
counts.subgroups, counts.apps
|
||||
)));
|
||||
let mut tx = self.pool.begin().await?;
|
||||
delete_group_tx(&mut tx, id).await?;
|
||||
tx.commit().await?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// Transaction-aware structural mutations (Phase 5+ declarative tree apply).
|
||||
//
|
||||
// The declarative `apply_tree` reconciles a whole subtree in ONE transaction,
|
||||
// so group create/reparent/delete must run inside the caller's tx (not on the
|
||||
// pool) to stay all-or-nothing. These free functions hold the SQL; the trait
|
||||
// methods above delegate to them (begin → fn → commit) so there is one SQL
|
||||
// definition each. The caller takes [`acquire_structural_lock_tx`] ONCE before
|
||||
// the structure phase, so the cycle guard + parent writes serialize against
|
||||
// concurrent reparents exactly as the single-shot `reparent` does.
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
/// Take the coarse structural lock inside the caller's transaction. Held until
|
||||
/// the tx commits/rolls back. Call once before any `*_group_tx` mutation.
|
||||
pub(crate) async fn acquire_structural_lock_tx(
|
||||
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
|
||||
) -> Result<(), GroupRepositoryError> {
|
||||
sqlx::query("SELECT pg_advisory_xact_lock($1)")
|
||||
.bind(GROUP_STRUCTURAL_LOCK_KEY)
|
||||
.execute(&mut **tx)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Insert a group inside the caller's tx. Maps unique/FK violations to a clean
|
||||
/// conflict. (The structural lock is not required for a pure insert, but the
|
||||
/// apply path holds it for the whole phase anyway.)
|
||||
pub(crate) async fn create_group_tx(
|
||||
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
|
||||
slug: &str,
|
||||
name: &str,
|
||||
description: Option<&str>,
|
||||
parent_id: Option<GroupId>,
|
||||
owner_project: Option<Uuid>,
|
||||
) -> Result<Group, GroupRepositoryError> {
|
||||
let res = sqlx::query_as::<_, GroupRow>(&format!(
|
||||
"INSERT INTO groups (slug, name, description, parent_id, owner_project) \
|
||||
VALUES ($1, $2, $3, $4, $5) RETURNING {GROUP_COLS}"
|
||||
))
|
||||
.bind(slug)
|
||||
.bind(name)
|
||||
.bind(description)
|
||||
.bind(parent_id.map(GroupId::into_inner))
|
||||
.bind(owner_project)
|
||||
.fetch_one(&mut **tx)
|
||||
.await;
|
||||
match res {
|
||||
Ok(row) => Ok(row.into()),
|
||||
Err(sqlx::Error::Database(e)) if e.is_unique_violation() => Err(
|
||||
GroupRepositoryError::Conflict(format!("slug {slug:?} is already in use")),
|
||||
),
|
||||
Err(sqlx::Error::Database(e)) if e.is_foreign_key_violation() => Err(
|
||||
GroupRepositoryError::Conflict("parent group does not exist".into()),
|
||||
),
|
||||
Err(e) => Err(e.into()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Reparent a group inside the caller's tx. Runs the ancestor-walk cycle guard
|
||||
/// against the IN-TX tree (so it sees parent writes made earlier in the same
|
||||
/// apply). The caller must already hold [`acquire_structural_lock_tx`].
|
||||
pub(crate) async fn reparent_group_tx(
|
||||
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
|
||||
id: GroupId,
|
||||
new_parent: Option<GroupId>,
|
||||
) -> Result<Group, GroupRepositoryError> {
|
||||
if let Some(parent) = new_parent {
|
||||
if parent == id {
|
||||
return Err(GroupRepositoryError::Conflict(
|
||||
"a group cannot be its own parent".into(),
|
||||
));
|
||||
}
|
||||
let res = sqlx::query("DELETE FROM groups WHERE id = $1")
|
||||
.bind(id.into_inner())
|
||||
.execute(&self.pool)
|
||||
.await;
|
||||
match res {
|
||||
Ok(r) if r.rows_affected() == 0 => Err(GroupRepositoryError::NotFound(id)),
|
||||
Ok(_) => Ok(()),
|
||||
Err(sqlx::Error::Database(e)) if e.is_foreign_key_violation() => {
|
||||
// Lost a race with a concurrent child insert.
|
||||
Err(GroupRepositoryError::Conflict(
|
||||
"group still has descendants; move or delete them first".into(),
|
||||
))
|
||||
let mut cursor = Some(parent);
|
||||
let mut hops = 0u32;
|
||||
while let Some(node) = cursor {
|
||||
if node == id {
|
||||
return Err(GroupRepositoryError::Conflict(
|
||||
"cannot reparent a group beneath one of its own descendants".into(),
|
||||
));
|
||||
}
|
||||
hops += 1;
|
||||
if hops > 64 {
|
||||
return Err(GroupRepositoryError::Conflict(
|
||||
"group ancestry exceeds the maximum depth".into(),
|
||||
));
|
||||
}
|
||||
let parent_of: Option<(Option<Uuid>,)> =
|
||||
sqlx::query_as("SELECT parent_id FROM groups WHERE id = $1")
|
||||
.bind(node.into_inner())
|
||||
.fetch_optional(&mut **tx)
|
||||
.await?;
|
||||
match parent_of {
|
||||
Some((p,)) => cursor = p.map(GroupId::from),
|
||||
None => {
|
||||
return Err(GroupRepositoryError::Conflict(
|
||||
"destination parent group does not exist".into(),
|
||||
));
|
||||
}
|
||||
}
|
||||
Err(e) => Err(e.into()),
|
||||
}
|
||||
}
|
||||
let row = sqlx::query_as::<_, GroupRow>(&format!(
|
||||
"UPDATE groups SET parent_id = $2, structure_version = structure_version + 1, \
|
||||
updated_at = NOW() WHERE id = $1 RETURNING {GROUP_COLS}"
|
||||
))
|
||||
.bind(id.into_inner())
|
||||
.bind(new_parent.map(GroupId::into_inner))
|
||||
.fetch_optional(&mut **tx)
|
||||
.await?;
|
||||
row.map(Into::into)
|
||||
.ok_or(GroupRepositoryError::NotFound(id))
|
||||
}
|
||||
|
||||
/// Delete an empty group inside the caller's tx (delete = RESTRICT). Refused
|
||||
/// with a clean conflict if it still has child groups or apps.
|
||||
pub(crate) async fn delete_group_tx(
|
||||
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
|
||||
id: GroupId,
|
||||
) -> Result<(), GroupRepositoryError> {
|
||||
let counts: (i64, i64) = sqlx::query_as(
|
||||
"SELECT (SELECT COUNT(*) FROM groups WHERE parent_id = $1), \
|
||||
(SELECT COUNT(*) FROM apps WHERE group_id = $1)",
|
||||
)
|
||||
.bind(id.into_inner())
|
||||
.fetch_one(&mut **tx)
|
||||
.await?;
|
||||
if counts.0 != 0 || counts.1 != 0 {
|
||||
return Err(GroupRepositoryError::Conflict(format!(
|
||||
"group still has {} subgroup(s) and {} app(s); move or delete them first",
|
||||
counts.0, counts.1
|
||||
)));
|
||||
}
|
||||
let res = sqlx::query("DELETE FROM groups WHERE id = $1")
|
||||
.bind(id.into_inner())
|
||||
.execute(&mut **tx)
|
||||
.await;
|
||||
match res {
|
||||
Ok(r) if r.rows_affected() == 0 => Err(GroupRepositoryError::NotFound(id)),
|
||||
Ok(_) => Ok(()),
|
||||
Err(sqlx::Error::Database(e)) if e.is_foreign_key_violation() => {
|
||||
Err(GroupRepositoryError::Conflict(
|
||||
"group still has descendants; move or delete them first".into(),
|
||||
))
|
||||
}
|
||||
Err(e) => Err(e.into()),
|
||||
}
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
@@ -378,9 +433,9 @@ pub(crate) async fn upsert_project_tx(
|
||||
}
|
||||
|
||||
/// The project that owns `group_id`, as `(project_id, project_key)`. `None` when
|
||||
/// the node is UI/API-owned (no claim). Read-only — used by plan + the takeover
|
||||
/// pre-gate. Joins through `projects` so the caller gets the human-facing key
|
||||
/// for a conflict message.
|
||||
/// the node is UI/API-owned (no claim). Read-only — used by plan + the in-tx
|
||||
/// authoritative re-check. Joins through `projects` so the caller gets the
|
||||
/// human-facing key for a conflict message.
|
||||
pub(crate) async fn get_group_owner(
|
||||
pool: &PgPool,
|
||||
group_id: GroupId,
|
||||
@@ -435,7 +490,7 @@ pub(crate) async fn set_group_owner_tx(
|
||||
|
||||
/// Every group `(id, slug)` owned by `project_id` — the candidate set for
|
||||
/// structural prune (those absent from the manifest are the ones to delete).
|
||||
/// Read-only; the apply path filters and deletes leaf-first under `delete_group_tx`.
|
||||
/// Read-only; the apply path filters and deletes leaf-first under `delete_tx`.
|
||||
pub(crate) async fn list_owned_groups(
|
||||
pool: &PgPool,
|
||||
project_id: Uuid,
|
||||
@@ -468,61 +523,50 @@ pub(crate) async fn list_owned_groups_tx(
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// Does this group hold data a structural prune would SILENTLY cascade away
|
||||
/// (§7 M3 safety guard)? The `apps`/child-group/group-script FKs are RESTRICT
|
||||
/// (delete aborts loudly), but the §11.6 group-owned data surfaces are
|
||||
/// `ON DELETE CASCADE`. Rather than let `apply --prune` vaporize a tenant's
|
||||
/// shared data + secrets because a manifest node was dropped, the prune skips
|
||||
/// such a group with a warning — the operator must delete it explicitly.
|
||||
/// Recursive descendant-app query, the inverse of the ancestor `CHAIN_LEVELS_CTE`
|
||||
/// in `config_resolver`: walk `groups.parent_id` DOWN from `$1` to collect the
|
||||
/// whole subtree of group ids, then every app whose `group_id` falls in it.
|
||||
/// Ordered by `app_id` so callers lock/iterate deterministically. Depth-bounded
|
||||
/// `< 64` as a runaway guard (the cycle guard already forbids real cycles).
|
||||
const DESCENDANT_APPS_SQL: &str = "\
|
||||
WITH RECURSIVE subtree AS ( \
|
||||
SELECT id, 0 AS depth FROM groups WHERE id = $1 \
|
||||
UNION ALL \
|
||||
SELECT g.id, s.depth + 1 FROM groups g JOIN subtree s ON g.parent_id = s.id \
|
||||
WHERE s.depth < 64 \
|
||||
) \
|
||||
SELECT a.id FROM apps a WHERE a.group_id IN (SELECT id FROM subtree) ORDER BY a.id";
|
||||
|
||||
/// Every app in the subtree rooted at `group_id` (the group itself and all
|
||||
/// descendant groups), `app_id`-ordered. The read-only pool variant — used by
|
||||
/// `plan` to size a template's true blast radius across the whole DB subtree,
|
||||
/// not just the apps present in the current apply.
|
||||
///
|
||||
/// We check the actual DATA rows, not just the collection MARKER: un-declaring a
|
||||
/// `collections = [...]` entry deletes only the `group_collections` marker while
|
||||
/// the `group_kv_entries`/`group_docs`/`group_files`/`group_queue_messages` rows
|
||||
/// survive (they're reaped only on group delete). Checking markers alone would
|
||||
/// miss that orphaned-but-live data and cascade it away. Returns true if the
|
||||
/// group has any shared-collection marker, any secret, or any shared data row.
|
||||
pub(crate) async fn group_has_protected_data_tx(
|
||||
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
|
||||
/// # Errors
|
||||
/// Propagates sqlx errors.
|
||||
pub async fn descendant_app_ids(
|
||||
pool: &PgPool,
|
||||
group_id: GroupId,
|
||||
) -> Result<bool, GroupRepositoryError> {
|
||||
let row: (bool,) = sqlx::query_as(
|
||||
"SELECT EXISTS (SELECT 1 FROM group_collections WHERE group_id = $1) \
|
||||
OR EXISTS (SELECT 1 FROM secrets WHERE group_id = $1) \
|
||||
OR EXISTS (SELECT 1 FROM group_kv_entries WHERE group_id = $1) \
|
||||
OR EXISTS (SELECT 1 FROM group_docs WHERE group_id = $1) \
|
||||
OR EXISTS (SELECT 1 FROM group_files WHERE group_id = $1) \
|
||||
OR EXISTS (SELECT 1 FROM group_queue_messages WHERE group_id = $1)",
|
||||
)
|
||||
.bind(group_id.into_inner())
|
||||
.fetch_one(&mut **tx)
|
||||
.await?;
|
||||
Ok(row.0)
|
||||
) -> Result<Vec<AppId>, GroupRepositoryError> {
|
||||
let rows: Vec<(Uuid,)> = sqlx::query_as(DESCENDANT_APPS_SQL)
|
||||
.bind(group_id.into_inner())
|
||||
.fetch_all(pool)
|
||||
.await?;
|
||||
Ok(rows.into_iter().map(|(id,)| id.into()).collect())
|
||||
}
|
||||
|
||||
/// Delete a group inside the apply tx (structural prune, §7 M3). RESTRICT is the
|
||||
/// real guard: the FK from apps/child-groups aborts the whole apply (mapped to a
|
||||
/// clean `Conflict`) rather than orphaning data, so the caller must delete
|
||||
/// leaf-first and never reaches a group that still holds live descendants.
|
||||
pub(crate) async fn delete_group_tx(
|
||||
/// The same subtree enumeration inside the apply tx, so groups/apps created
|
||||
/// earlier in this transaction (M2 structural reconcile) are included when
|
||||
/// fanning a template out to its descendants (§4.5, M7).
|
||||
pub(crate) async fn descendant_app_ids_tx(
|
||||
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
|
||||
group_id: GroupId,
|
||||
) -> Result<(), GroupRepositoryError> {
|
||||
let res = sqlx::query("DELETE FROM groups WHERE id = $1")
|
||||
) -> Result<Vec<AppId>, GroupRepositoryError> {
|
||||
let rows: Vec<(Uuid,)> = sqlx::query_as(DESCENDANT_APPS_SQL)
|
||||
.bind(group_id.into_inner())
|
||||
.execute(&mut **tx)
|
||||
.await;
|
||||
match res {
|
||||
Ok(r) if r.rows_affected() == 0 => Err(GroupRepositoryError::NotFound(group_id)),
|
||||
Ok(_) => Ok(()),
|
||||
Err(sqlx::Error::Database(e)) if e.is_foreign_key_violation() => {
|
||||
Err(GroupRepositoryError::Conflict(
|
||||
"group still has descendants (apps or subgroups); \
|
||||
structural prune refuses to orphan them"
|
||||
.into(),
|
||||
))
|
||||
}
|
||||
Err(e) => Err(e.into()),
|
||||
}
|
||||
.fetch_all(&mut **tx)
|
||||
.await?;
|
||||
Ok(rows.into_iter().map(|(id,)| id.into()).collect())
|
||||
}
|
||||
|
||||
#[derive(sqlx::FromRow)]
|
||||
|
||||
@@ -97,25 +97,10 @@ async fn create_group_script(
|
||||
)
|
||||
.await?;
|
||||
|
||||
// Phase 4b: group modules + imports are allowed. Validate per kind,
|
||||
// mirroring the app-script create path (`api.rs`): a module gets the
|
||||
// stricter shape check + the reserved-name guard; an endpoint the
|
||||
// parse-only path. Without the kind branch a malformed-shape group
|
||||
// module would be accepted here and only fail later at import time,
|
||||
// and a reserved name (`kv`, `log`, …) would slip through. Imports
|
||||
// resolve lexically from this group's chain at runtime (§5.5); the
|
||||
// recorded edges feed the declarative dangling-import plan check.
|
||||
let validated = if input.kind == ScriptKind::Module {
|
||||
if crate::api::RESERVED_MODULE_NAMES.contains(&input.name.as_str()) {
|
||||
return Err(GroupScriptsApiError::Invalid(format!(
|
||||
"{:?} is a reserved module name (shadows a built-in SDK namespace)",
|
||||
input.name
|
||||
)));
|
||||
}
|
||||
s.validator.validate_module(&input.source)?
|
||||
} else {
|
||||
s.validator.validate(&input.source)?
|
||||
};
|
||||
// Phase 4b: group modules + imports are allowed. Imports resolve
|
||||
// lexically from this group's chain at runtime (§5.5); the recorded
|
||||
// edges feed the declarative dangling-import plan check.
|
||||
let validated = s.validator.validate(&input.source)?;
|
||||
s.sandbox_ceiling
|
||||
.check(&input.sandbox)
|
||||
.map_err(|e| GroupScriptsApiError::Invalid(e.to_string()))?;
|
||||
|
||||
@@ -22,8 +22,6 @@ use serde::{Deserialize, Serialize};
|
||||
use serde_json::json;
|
||||
use uuid::Uuid;
|
||||
|
||||
use picloud_orchestrator_core::routing::RouteTable;
|
||||
|
||||
use crate::admin_user_repo::{AdminUserRepository, AdminUserRow};
|
||||
use crate::app_repo::AppRepository;
|
||||
use crate::authz::{require, AuthzDenied, AuthzError, AuthzRepo, Capability};
|
||||
@@ -31,7 +29,6 @@ use crate::group_members_repo::{
|
||||
GroupMembersRepository, GroupMembersRepositoryError, GroupMembershipDetail, GroupMembershipRow,
|
||||
};
|
||||
use crate::group_repo::{GroupRepository, GroupRepositoryError};
|
||||
use crate::route_repo::RouteRepository;
|
||||
|
||||
const SLUG_MAX: usize = 63;
|
||||
const RESERVED_SLUGS: &[&str] = &[
|
||||
@@ -45,15 +42,6 @@ pub struct GroupsState {
|
||||
pub apps: Arc<dyn AppRepository>,
|
||||
pub users: Arc<dyn AdminUserRepository>,
|
||||
pub authz: Arc<dyn AuthzRepo>,
|
||||
/// §11 tail full-live invalidation: reparenting a group moves a whole
|
||||
/// subtree, changing which ancestor-group route TEMPLATES its descendant
|
||||
/// apps inherit — so the route snapshot is rebuilt after a reparent.
|
||||
pub routes: Arc<dyn RouteRepository>,
|
||||
pub route_table: Arc<RouteTable>,
|
||||
/// §4.5 M5: reparenting moves a subtree, changing which ancestor-group
|
||||
/// stateful templates its apps inherit — their materialized cron/queue
|
||||
/// copies are reconciled after a reparent.
|
||||
pub pool: sqlx::PgPool,
|
||||
}
|
||||
|
||||
pub fn groups_router(state: GroupsState) -> Router {
|
||||
@@ -292,18 +280,6 @@ async fn reparent_group(
|
||||
}
|
||||
};
|
||||
let moved = s.groups.reparent(group.id, new_parent).await?;
|
||||
// §11 tail: the moved subtree now inherits a different set of ancestor-group
|
||||
// route TEMPLATES — rebuild the snapshot (best-effort; self-heals on the
|
||||
// next route write or restart if it fails).
|
||||
if let Err(e) = crate::route_admin::rebuild_route_table(s.routes.as_ref(), &s.route_table).await
|
||||
{
|
||||
tracing::warn!(error = %e, "groups: route table refresh after reparent failed; it will self-heal");
|
||||
}
|
||||
// §4.5 M5: the moved subtree inherits a different set of ancestor-group
|
||||
// stateful templates — reconcile its materialized copies.
|
||||
if let Err(e) = crate::materialize::rematerialize_stateful_templates(&s.pool).await {
|
||||
tracing::warn!(error = %e, "groups: stateful-template materialization after reparent failed; it will self-heal");
|
||||
}
|
||||
Ok(Json(moved))
|
||||
}
|
||||
|
||||
|
||||
@@ -44,25 +44,12 @@ pub mod docs_repo;
|
||||
pub mod docs_service;
|
||||
pub mod email_inbound_api;
|
||||
pub mod email_service;
|
||||
pub mod extension_point_repo;
|
||||
pub mod files_api;
|
||||
pub mod files_repo;
|
||||
pub mod files_service;
|
||||
pub mod files_sweep;
|
||||
pub mod gc;
|
||||
pub mod group_blobs_api;
|
||||
pub mod group_collection_repo;
|
||||
pub mod group_docs_repo;
|
||||
pub mod group_docs_service;
|
||||
pub mod group_files_repo;
|
||||
pub mod group_files_service;
|
||||
pub mod group_kv_repo;
|
||||
pub mod group_kv_service;
|
||||
pub mod group_members_repo;
|
||||
pub mod group_pubsub_service;
|
||||
pub mod group_queue_repo;
|
||||
pub mod group_queue_service;
|
||||
pub mod group_quota;
|
||||
pub mod group_repo;
|
||||
pub mod group_scripts_api;
|
||||
pub mod groups_api;
|
||||
@@ -73,7 +60,6 @@ pub mod kv_repo;
|
||||
pub mod kv_service;
|
||||
pub mod log_sink;
|
||||
pub mod login_rate_limit;
|
||||
pub mod materialize;
|
||||
pub mod migrations;
|
||||
pub mod module_source;
|
||||
pub mod outbox_event_emitter;
|
||||
@@ -94,7 +80,7 @@ pub mod secrets_api;
|
||||
pub mod secrets_repo;
|
||||
pub mod secrets_service;
|
||||
pub mod ssrf;
|
||||
pub mod suppression_repo;
|
||||
pub mod template_repo;
|
||||
pub mod topic_repo;
|
||||
pub mod topics_api;
|
||||
pub mod trigger_config;
|
||||
@@ -190,22 +176,10 @@ pub use files_repo::{FilesConfig, FilesRepo, FilesRepoError, FsFilesRepo};
|
||||
pub use files_service::FilesServiceImpl;
|
||||
pub use files_sweep::{spawn_files_orphan_sweep, sweep_orphan_tmp_files, SweepStats};
|
||||
pub use gc::{spawn_abandoned_gc, spawn_app_user_token_gc, spawn_dead_letter_gc};
|
||||
pub use group_collection_repo::{GroupCollectionResolver, PostgresGroupCollectionResolver};
|
||||
pub use group_docs_repo::{GroupDocsRepo, GroupDocsRepoError, PostgresGroupDocsRepo};
|
||||
pub use group_docs_service::GroupDocsServiceImpl;
|
||||
pub use group_files_repo::{FsGroupFilesRepo, GroupFilesRepo, GroupFilesRepoError};
|
||||
pub use group_files_service::GroupFilesServiceImpl;
|
||||
pub use group_kv_repo::{GroupKvRepo, GroupKvRepoError, PostgresGroupKvRepo};
|
||||
pub use group_kv_service::GroupKvServiceImpl;
|
||||
pub use group_members_repo::{
|
||||
GroupMembersRepository, GroupMembersRepositoryError, GroupMembershipDetail, GroupMembershipRow,
|
||||
PostgresGroupMembersRepository,
|
||||
};
|
||||
pub use group_pubsub_service::GroupPubsubServiceImpl;
|
||||
pub use group_queue_repo::{
|
||||
GroupQueueRepo, GroupQueueRepoError, NewGroupQueueMessage, PostgresGroupQueueRepo,
|
||||
};
|
||||
pub use group_queue_service::GroupQueueServiceImpl;
|
||||
pub use group_repo::{
|
||||
GroupChildCounts, GroupRepository, GroupRepositoryError, PostgresGroupRepository,
|
||||
ROOT_GROUP_SLUG,
|
||||
@@ -230,9 +204,7 @@ pub use repo::{
|
||||
ExecutionLogRepository, NewScript, PostgresExecutionLogRepository, PostgresScriptRepository,
|
||||
RepoResolver, ScriptPatch, ScriptRepository, ScriptRepositoryError,
|
||||
};
|
||||
pub use route_admin::{
|
||||
compile_effective_routes, rebuild_route_table, route_admin_router, RouteAdminState,
|
||||
};
|
||||
pub use route_admin::{compile_routes, route_admin_router, RouteAdminState};
|
||||
pub use route_repo::{NewRoute, PostgresRouteRepository, RouteRepository};
|
||||
pub use sandbox::{CeilingError, SandboxCeiling};
|
||||
pub use secrets_api::{secrets_router, SecretsApiError, SecretsState};
|
||||
|
||||
@@ -1,247 +0,0 @@
|
||||
//! §4.5 M5: materialization of STATEFUL group trigger templates.
|
||||
//!
|
||||
//! A group-owned cron / queue / email trigger is a TEMPLATE that is never
|
||||
//! dispatched directly (the scheduler / queue consumer / email webhook all
|
||||
//! filter `t.app_id IS NOT NULL`). Instead this reconciler expands each template
|
||||
//! into an app-owned copy — `materialized_from = template.id` — for every
|
||||
//! descendant app, and removes copies whose (app, template) pair no longer
|
||||
//! inherits. The copy owns its per-app state (cron `last_fired_at`, the queue
|
||||
//! advisory lock), so the unchanged dispatch paths work against it as if it were
|
||||
//! hand-authored. An EMAIL copy is a verbatim byte-copy of the group template's
|
||||
//! sealed inbound secret — the group resolved + sealed it against its own store
|
||||
//! once at apply (shared-group-secret model), and email secrets are v0/no-AAD so
|
||||
//! the ciphertext is portable across rows; no master key is needed here.
|
||||
//!
|
||||
//! Full-live like `rebuild_route_table`: called at the same chokepoints (apply,
|
||||
//! app create/delete, group reparent). A precise create/delete diff (not
|
||||
//! delete-all-then-recreate) preserves `last_fired_at` on unchanged cron copies.
|
||||
//!
|
||||
//! Returns per-app WARNINGS (e.g. a queue whose consumer slot is already taken)
|
||||
//! — the caller surfaces them; materialization of the other pairs still
|
||||
//! proceeds.
|
||||
|
||||
use std::collections::HashSet;
|
||||
|
||||
use sqlx::PgPool;
|
||||
use uuid::Uuid;
|
||||
|
||||
/// The stateful kinds that materialize (M5.2 cron; M5.4 queue; M5.5 email).
|
||||
const MATERIALIZED_KINDS: &[&str] = &["cron", "queue", "email"];
|
||||
|
||||
/// Fixed advisory-lock key serializing all materialization reconcilers (§4.5
|
||||
/// M5). Two reconcilers running concurrently (e.g. two parallel app-creates)
|
||||
/// would otherwise (a) read `should` and `existing` at different READ-COMMITTED
|
||||
/// snapshots — a copy created by one between the two reads could be spuriously
|
||||
/// DELETEd by the other (a lost cron copy that only self-heals on the next
|
||||
/// mutation), and (b) both pass the queue one-consumer check and double-fill a
|
||||
/// slot. An xact-scoped advisory lock makes each reconcile atomic w.r.t. the
|
||||
/// others; reconciles are infrequent + fast, so serializing is cheap.
|
||||
const REMATERIALIZE_LOCK_KEY: i64 = 0x0004_0005_0041_054d; // "M5" materialize marker
|
||||
|
||||
/// (descendant app, source template, kind) that SHOULD have a materialized copy.
|
||||
#[derive(sqlx::FromRow)]
|
||||
struct ShouldRow {
|
||||
effective_app_id: Uuid,
|
||||
template_id: Uuid,
|
||||
kind: String,
|
||||
/// §11.6 D3: a `shared` queue template's copies drain the GROUP store as
|
||||
/// COMPETING consumers, so the one-consumer-per-(app, queue) slot check is
|
||||
/// skipped for them (each descendant intentionally gets a consumer).
|
||||
shared: bool,
|
||||
}
|
||||
|
||||
/// Reconcile all materialized stateful-template copies. Idempotent; safe to call
|
||||
/// on every tree/apply mutation. Best-effort per pair — a pair that can't
|
||||
/// materialize (queue slot taken, missing email secret) yields a warning and is
|
||||
/// skipped, not an error.
|
||||
pub async fn rematerialize_stateful_templates(pool: &PgPool) -> Result<Vec<String>, sqlx::Error> {
|
||||
let mut tx = pool.begin().await?;
|
||||
|
||||
// Serialize reconcilers (see REMATERIALIZE_LOCK_KEY): the lock is held until
|
||||
// this transaction commits, so the `should`/`existing` reads below are
|
||||
// snapshot-consistent relative to any other reconcile and the queue slot
|
||||
// check can't race.
|
||||
sqlx::query("SELECT pg_advisory_xact_lock($1)")
|
||||
.bind(REMATERIALIZE_LOCK_KEY)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
// Every (descendant app × ancestor-group stateful template) that should
|
||||
// exist — the all-apps `app_chain` CTE (each app × its ancestor chain) ⋈
|
||||
// group-owned stateful templates.
|
||||
let kinds: Vec<String> = MATERIALIZED_KINDS
|
||||
.iter()
|
||||
.map(|s| (*s).to_string())
|
||||
.collect();
|
||||
let should: Vec<ShouldRow> = sqlx::query_as(
|
||||
"WITH RECURSIVE app_chain AS ( \
|
||||
SELECT a.id AS effective_app_id, a.group_id AS owner_group, \
|
||||
a.group_id AS next_group, 0 AS depth \
|
||||
FROM apps a WHERE a.group_id IS NOT NULL \
|
||||
UNION ALL \
|
||||
SELECT ac.effective_app_id, g.parent_id, g.parent_id, ac.depth + 1 \
|
||||
FROM groups g JOIN app_chain ac ON g.id = ac.next_group \
|
||||
WHERE ac.depth < 64 AND g.parent_id IS NOT NULL \
|
||||
) \
|
||||
SELECT DISTINCT ac.effective_app_id, t.id AS template_id, t.kind, t.shared \
|
||||
FROM app_chain ac \
|
||||
JOIN triggers t ON t.group_id = ac.owner_group \
|
||||
WHERE t.group_id IS NOT NULL AND t.enabled = TRUE AND t.kind = ANY($1)",
|
||||
)
|
||||
.bind(&kinds)
|
||||
.fetch_all(&mut *tx)
|
||||
.await?;
|
||||
|
||||
let should_set: HashSet<(Uuid, Uuid)> = should
|
||||
.iter()
|
||||
.map(|r| (r.effective_app_id, r.template_id))
|
||||
.collect();
|
||||
|
||||
// Existing managed copies.
|
||||
let existing: Vec<(Uuid, Uuid)> = sqlx::query_as(
|
||||
"SELECT app_id, materialized_from FROM triggers WHERE materialized_from IS NOT NULL",
|
||||
)
|
||||
.fetch_all(&mut *tx)
|
||||
.await?;
|
||||
let existing_set: HashSet<(Uuid, Uuid)> = existing.iter().copied().collect();
|
||||
|
||||
// Delete stale copies (template gone or app no longer a descendant).
|
||||
for (app_id, template_id) in &existing_set {
|
||||
if !should_set.contains(&(*app_id, *template_id)) {
|
||||
sqlx::query("DELETE FROM triggers WHERE app_id = $1 AND materialized_from = $2")
|
||||
.bind(app_id)
|
||||
.bind(template_id)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
|
||||
// Create missing copies.
|
||||
let mut warnings = Vec::new();
|
||||
for r in &should {
|
||||
if existing_set.contains(&(r.effective_app_id, r.template_id)) {
|
||||
continue;
|
||||
}
|
||||
if let Some(w) = materialize_one(
|
||||
&mut tx,
|
||||
r.effective_app_id,
|
||||
r.template_id,
|
||||
&r.kind,
|
||||
r.shared,
|
||||
)
|
||||
.await?
|
||||
{
|
||||
warnings.push(w);
|
||||
}
|
||||
}
|
||||
|
||||
tx.commit().await?;
|
||||
Ok(warnings)
|
||||
}
|
||||
|
||||
/// Create one app-owned copy of a group template + its per-kind detail. Returns
|
||||
/// `Some(warning)` when the pair is intentionally skipped (e.g. a queue slot the
|
||||
/// app already fills). `name` uses the column default (a fresh UUID) so it can't
|
||||
/// collide with the app's own trigger names.
|
||||
async fn materialize_one(
|
||||
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
|
||||
app_id: Uuid,
|
||||
template_id: Uuid,
|
||||
kind: &str,
|
||||
shared: bool,
|
||||
) -> Result<Option<String>, sqlx::Error> {
|
||||
// §M5.4: a per-app queue copy would violate the one-consumer-per-(app_id,
|
||||
// queue_name) invariant if the app already has a consumer on that queue —
|
||||
// skip with a warning. §11.6 D3: a SHARED queue copy drains the group store
|
||||
// as a competing consumer (a different store), so the slot check is skipped
|
||||
// — every descendant intentionally gets a consumer.
|
||||
if kind == "queue" && !shared {
|
||||
let taken: Option<(Uuid,)> = sqlx::query_as(
|
||||
"SELECT t.id FROM triggers t \
|
||||
JOIN queue_trigger_details d ON d.trigger_id = t.id \
|
||||
WHERE t.app_id = $1 AND t.kind = 'queue' \
|
||||
AND d.queue_name = ( \
|
||||
SELECT queue_name FROM queue_trigger_details WHERE trigger_id = $2)",
|
||||
)
|
||||
.bind(app_id)
|
||||
.bind(template_id)
|
||||
.fetch_optional(&mut **tx)
|
||||
.await?;
|
||||
if taken.is_some() {
|
||||
return Ok(Some(format!(
|
||||
"queue template not materialized for one app — it already has a \
|
||||
consumer on that queue (app_id={app_id})"
|
||||
)));
|
||||
}
|
||||
}
|
||||
|
||||
// Parent row: copy the template's settings, owned by the app, linked back.
|
||||
// `ON CONFLICT DO NOTHING` (against the (app_id, materialized_from) partial
|
||||
// unique index) makes this idempotent under concurrent reconciles — the
|
||||
// loser gets no RETURNING row and skips the detail insert.
|
||||
let new_id: Option<(Uuid,)> = sqlx::query_as(
|
||||
"INSERT INTO triggers ( \
|
||||
app_id, script_id, kind, enabled, dispatch_mode, \
|
||||
retry_max_attempts, retry_backoff, retry_base_ms, \
|
||||
registered_by_principal, materialized_from) \
|
||||
SELECT $1, script_id, kind, enabled, dispatch_mode, \
|
||||
retry_max_attempts, retry_backoff, retry_base_ms, \
|
||||
registered_by_principal, id \
|
||||
FROM triggers WHERE id = $2 \
|
||||
ON CONFLICT DO NOTHING RETURNING id",
|
||||
)
|
||||
.bind(app_id)
|
||||
.bind(template_id)
|
||||
.fetch_optional(&mut **tx)
|
||||
.await?;
|
||||
let Some(new_id) = new_id else {
|
||||
// A concurrent reconcile already created this copy.
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
match kind {
|
||||
"cron" => {
|
||||
// A fresh copy starts with last_fired_at NULL → fires on next due.
|
||||
sqlx::query(
|
||||
"INSERT INTO cron_trigger_details (trigger_id, schedule, timezone) \
|
||||
SELECT $1, schedule, timezone FROM cron_trigger_details WHERE trigger_id = $2",
|
||||
)
|
||||
.bind(new_id.0)
|
||||
.bind(template_id)
|
||||
.execute(&mut **tx)
|
||||
.await?;
|
||||
}
|
||||
"queue" => {
|
||||
sqlx::query(
|
||||
"INSERT INTO queue_trigger_details \
|
||||
(trigger_id, queue_name, visibility_timeout_secs) \
|
||||
SELECT $1, queue_name, visibility_timeout_secs \
|
||||
FROM queue_trigger_details WHERE trigger_id = $2",
|
||||
)
|
||||
.bind(new_id.0)
|
||||
.bind(template_id)
|
||||
.execute(&mut **tx)
|
||||
.await?;
|
||||
}
|
||||
"email" => {
|
||||
// §M5.5: the group template sealed its inbound HMAC secret against
|
||||
// the GROUP's own store once at apply (shared-group-secret model).
|
||||
// The secret is v0 (no AAD) — bound only to the master key, not the
|
||||
// owning row — so a verbatim byte-copy is a valid per-app secret; no
|
||||
// master key is needed here. Each copy has its own trigger_id (its
|
||||
// own inbound webhook URL).
|
||||
sqlx::query(
|
||||
"INSERT INTO email_trigger_details \
|
||||
(trigger_id, inbound_secret_encrypted, inbound_secret_nonce) \
|
||||
SELECT $1, inbound_secret_encrypted, inbound_secret_nonce \
|
||||
FROM email_trigger_details WHERE trigger_id = $2",
|
||||
)
|
||||
.bind(new_id.0)
|
||||
.bind(template_id)
|
||||
.execute(&mut **tx)
|
||||
.await?;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
Ok(None)
|
||||
}
|
||||
@@ -1,24 +1,19 @@
|
||||
//! `PostgresModuleSource` — the Postgres-backed `ModuleSource` impl.
|
||||
//!
|
||||
//! `resolve` is **lexical** (Phase 4b): a module is looked up by walking the
|
||||
//! group chain rooted at the **importing script's defining node** (`origin`),
|
||||
//! nearest-owner-wins — *not* the inheriting app's effective view. An `App`
|
||||
//! origin walks `app → its group → ancestors` (reusing [`CHAIN_LEVELS_CTE`]);
|
||||
//! a `Group` origin walks `group → ancestors` ([`GROUP_CHAIN_LEVELS_CTE`]) and
|
||||
//! never sees app-owned modules below it (the trust boundary).
|
||||
//!
|
||||
//! `resolve_policy` adds §5.5 **extension points**: the nearest declaration of
|
||||
//! a name on `origin`'s chain decides lexical (concrete module) vs **dynamic**
|
||||
//! (an extension-point marker → resolve against the inheriting app so it can
|
||||
//! override, falling back to a default body up-chain). EP wins a depth tie.
|
||||
//!
|
||||
//! The resolver lives in `executor-core` and consumes this trait through the
|
||||
//! `Services` bundle, so manager-core stays the only crate that touches Postgres.
|
||||
//! Resolution is **lexical** (§5.5): a module is looked up by walking the
|
||||
//! group chain rooted at the **importing script's defining node**
|
||||
//! (`origin`), nearest-owner-wins — *not* the inheriting app's effective
|
||||
//! view. An `App` origin walks `app → its group → ancestors` (reusing
|
||||
//! [`CHAIN_LEVELS_CTE`]); a `Group` origin walks `group → ancestors`
|
||||
//! ([`GROUP_CHAIN_LEVELS_CTE`]) and never sees app-owned modules below it
|
||||
//! (the trust boundary). The resolver lives in `executor-core` and consumes
|
||||
//! this trait through the `Services` bundle, so manager-core stays the only
|
||||
//! crate that touches Postgres.
|
||||
|
||||
use async_trait::async_trait;
|
||||
use chrono::{DateTime, Utc};
|
||||
use picloud_shared::{
|
||||
AppId, ModuleResolution, ModuleScript, ModuleSource, ModuleSourceError, ScriptOwner,
|
||||
ExtPointResolution, ModuleScript, ModuleSource, ModuleSourceError, ScriptId, ScriptOwner,
|
||||
};
|
||||
use sqlx::PgPool;
|
||||
|
||||
@@ -104,72 +99,91 @@ impl ModuleSource for PostgresModuleSource {
|
||||
Ok(row.map(Into::into))
|
||||
}
|
||||
|
||||
async fn resolve_policy(
|
||||
async fn resolve_extension_point(
|
||||
&self,
|
||||
origin: ScriptOwner,
|
||||
inheriting_app: AppId,
|
||||
name: &str,
|
||||
) -> Result<ModuleResolution, ModuleSourceError> {
|
||||
// §5.5 nearest-declaration-kind-wins. One round trip: the min chain
|
||||
// depth at which `name` is declared as an extension-point marker vs a
|
||||
// concrete module, walking up `origin`'s chain. The EP wins a tie (a
|
||||
// marker + its co-located default body live at the same node).
|
||||
let query = match origin {
|
||||
ScriptOwner::App(_) => format!(
|
||||
"{CHAIN_LEVELS_CTE} \
|
||||
SELECT \
|
||||
(SELECT MIN(c.depth) FROM extension_points e \
|
||||
JOIN chain c ON (e.app_id = c.app_owner OR e.group_id = c.group_owner) \
|
||||
WHERE LOWER(e.name) = LOWER($2)) AS ep_depth, \
|
||||
(SELECT MIN(c.depth) FROM scripts s \
|
||||
JOIN chain c ON (s.app_id = c.app_owner OR s.group_id = c.group_owner) \
|
||||
WHERE s.kind = 'module' AND LOWER(s.name) = LOWER($2)) AS mod_depth",
|
||||
) -> Result<Option<ExtPointResolution>, ModuleSourceError> {
|
||||
// Find the nearest extension-point declaration of `name` on `origin`'s
|
||||
// chain that is NOT shadowed by a concrete module of the same name at
|
||||
// depth <= its own (tie → concrete wins). The NOT EXISTS encodes that
|
||||
// shadowing rule; ORDER BY depth ASC LIMIT 1 picks the nearest
|
||||
// surviving declaration. An App origin can see ext points declared on
|
||||
// an ancestor group (or on the app itself); a Group origin only walks
|
||||
// groups — same trust boundary as `resolve`.
|
||||
let (cte, ep_join, mod_join) = match origin {
|
||||
ScriptOwner::App(_) => (
|
||||
CHAIN_LEVELS_CTE,
|
||||
"(e.group_id = c.group_owner OR e.app_id = c.app_owner)",
|
||||
"(s.app_id = c2.app_owner OR s.group_id = c2.group_owner)",
|
||||
),
|
||||
ScriptOwner::Group(_) => format!(
|
||||
"{GROUP_CHAIN_LEVELS_CTE} \
|
||||
SELECT \
|
||||
(SELECT MIN(c.depth) FROM extension_points e \
|
||||
JOIN chain c ON e.group_id = c.group_owner \
|
||||
WHERE LOWER(e.name) = LOWER($2)) AS ep_depth, \
|
||||
(SELECT MIN(c.depth) FROM scripts s \
|
||||
JOIN chain c ON s.group_id = c.group_owner \
|
||||
WHERE s.kind = 'module' AND LOWER(s.name) = LOWER($2)) AS mod_depth",
|
||||
ScriptOwner::Group(_) => (
|
||||
GROUP_CHAIN_LEVELS_CTE,
|
||||
"e.group_id = c.group_owner",
|
||||
"s.group_id = c2.group_owner",
|
||||
),
|
||||
};
|
||||
let query = format!(
|
||||
"{cte} \
|
||||
SELECT e.default_script_id \
|
||||
FROM extension_points e \
|
||||
JOIN chain c ON {ep_join} \
|
||||
WHERE LOWER(e.name) = LOWER($2) \
|
||||
AND NOT EXISTS ( \
|
||||
SELECT 1 FROM scripts s \
|
||||
JOIN chain c2 ON {mod_join} \
|
||||
WHERE s.kind = 'module' AND LOWER(s.name) = LOWER($2) \
|
||||
AND c2.depth <= c.depth \
|
||||
) \
|
||||
ORDER BY c.depth ASC LIMIT 1",
|
||||
);
|
||||
let root = match origin {
|
||||
ScriptOwner::App(a) => a.into_inner(),
|
||||
ScriptOwner::Group(g) => g.into_inner(),
|
||||
};
|
||||
let row: Option<(Option<uuid::Uuid>,)> = sqlx::query_as(&query)
|
||||
.bind(root)
|
||||
.bind(name)
|
||||
.fetch_optional(&self.pool)
|
||||
.await
|
||||
.map_err(|e| ModuleSourceError::Backend(e.to_string()))?;
|
||||
Ok(row.map(|(default_script_id,)| ExtPointResolution {
|
||||
default_script_id: default_script_id.map(Into::into),
|
||||
}))
|
||||
}
|
||||
|
||||
async fn resolve_by_id(
|
||||
&self,
|
||||
origin: ScriptOwner,
|
||||
script_id: ScriptId,
|
||||
) -> Result<Option<ModuleScript>, ModuleSourceError> {
|
||||
// Load a module body by id (the ext-point fallback), but ONLY if it is
|
||||
// owned by a node on `origin`'s chain — the same trust boundary as
|
||||
// `resolve`. This keeps the resolver self-defending: a `default_script_id`
|
||||
// that ever pointed off-chain resolves to None instead of executing
|
||||
// cross-tenant code. Endpoints are never importable (kind filter).
|
||||
let (cte, join) = match origin {
|
||||
ScriptOwner::App(_) => (
|
||||
CHAIN_LEVELS_CTE,
|
||||
"(s.app_id = c.app_owner OR s.group_id = c.group_owner)",
|
||||
),
|
||||
ScriptOwner::Group(_) => (GROUP_CHAIN_LEVELS_CTE, "s.group_id = c.group_owner"),
|
||||
};
|
||||
let root = match origin {
|
||||
ScriptOwner::App(a) => a.into_inner(),
|
||||
ScriptOwner::Group(g) => g.into_inner(),
|
||||
};
|
||||
let (ep_depth, mod_depth): (Option<i32>, Option<i32>) = sqlx::query_as(&query)
|
||||
.bind(root)
|
||||
.bind(name)
|
||||
.fetch_one(&self.pool)
|
||||
.await
|
||||
.map_err(|e| ModuleSourceError::Backend(e.to_string()))?;
|
||||
|
||||
// EP wins when it is declared and is no farther than the nearest
|
||||
// concrete module (tie → EP). Then resolve dynamically against the
|
||||
// inheriting app (its own override, else the default body up-chain).
|
||||
let ep_wins = match (ep_depth, mod_depth) {
|
||||
(Some(ep), Some(m)) => ep <= m,
|
||||
(Some(_), None) => true,
|
||||
_ => false,
|
||||
};
|
||||
if ep_wins {
|
||||
return Ok(
|
||||
match self.resolve(ScriptOwner::App(inheriting_app), name).await? {
|
||||
Some(m) => ModuleResolution::Module(m),
|
||||
None => ModuleResolution::NoProvider,
|
||||
},
|
||||
);
|
||||
}
|
||||
if mod_depth.is_some() {
|
||||
// Concrete module nearest → lexical, exactly as Phase 4b.
|
||||
return Ok(match self.resolve(origin, name).await? {
|
||||
Some(m) => ModuleResolution::Module(m),
|
||||
None => ModuleResolution::NotFound,
|
||||
});
|
||||
}
|
||||
Ok(ModuleResolution::NotFound)
|
||||
let row: Option<ModuleRow> = sqlx::query_as(&format!(
|
||||
"{cte} \
|
||||
SELECT s.id, s.app_id, s.group_id, s.name, s.source, s.updated_at \
|
||||
FROM scripts s JOIN chain c ON {join} \
|
||||
WHERE s.id = $2 AND s.kind = 'module' LIMIT 1"
|
||||
))
|
||||
.bind(root)
|
||||
.bind(script_id.into_inner())
|
||||
.fetch_optional(&self.pool)
|
||||
.await
|
||||
.map_err(|e| ModuleSourceError::Backend(e.to_string()))?;
|
||||
Ok(row.map(Into::into))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,7 +19,7 @@ use std::sync::Arc;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use picloud_shared::{
|
||||
DocsEventOp, EmitError, FileMeta, FilesEventOp, GroupId, KvEventOp, SdkCallCx, ServiceEvent,
|
||||
DocsEventOp, EmitError, FileMeta, FilesEventOp, KvEventOp, SdkCallCx, ServiceEvent,
|
||||
ServiceEventEmitter, TriggerEvent,
|
||||
};
|
||||
|
||||
@@ -51,130 +51,6 @@ impl ServiceEventEmitter for OutboxEventEmitter {
|
||||
_ => Ok(()),
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_lines)] // one match arm per source kind (kv/docs/files)
|
||||
async fn emit_shared(
|
||||
&self,
|
||||
cx: &SdkCallCx,
|
||||
owning_group: GroupId,
|
||||
event: ServiceEvent,
|
||||
) -> Result<(), EmitError> {
|
||||
// §11.6: a shared-collection write. Match `shared = true` triggers on
|
||||
// the OWNING group; each fires under the WRITER app (`cx.app_id`), the
|
||||
// same "group template runs under the firing app" model.
|
||||
let source_kind = match event.source {
|
||||
"kv" => OutboxSourceKind::Kv,
|
||||
"docs" => OutboxSourceKind::Docs,
|
||||
"files" => OutboxSourceKind::Files,
|
||||
_ => return Ok(()),
|
||||
};
|
||||
let Some(collection) = event.collection.clone() else {
|
||||
return Ok(());
|
||||
};
|
||||
let (matches, trigger_event) = match event.source {
|
||||
"kv" => {
|
||||
let Some(op) = KvEventOp::from_wire(event.op) else {
|
||||
return Ok(());
|
||||
};
|
||||
let m = self
|
||||
.triggers
|
||||
.list_matching_shared_kv(owning_group, &collection, op)
|
||||
.await
|
||||
.map_err(|e| EmitError::Unavailable(format!("trigger lookup: {e}")))?;
|
||||
let ev = TriggerEvent::Kv {
|
||||
op,
|
||||
collection,
|
||||
key: event.key.clone().unwrap_or_default(),
|
||||
value: event.payload.clone(),
|
||||
};
|
||||
(
|
||||
m.into_iter()
|
||||
.map(|x| (x.trigger_id, x.script_id))
|
||||
.collect::<Vec<_>>(),
|
||||
ev,
|
||||
)
|
||||
}
|
||||
"docs" => {
|
||||
let Some(op) = DocsEventOp::from_wire(event.op) else {
|
||||
return Ok(());
|
||||
};
|
||||
let m = self
|
||||
.triggers
|
||||
.list_matching_shared_docs(owning_group, &collection, op)
|
||||
.await
|
||||
.map_err(|e| EmitError::Unavailable(format!("trigger lookup: {e}")))?;
|
||||
let ev = TriggerEvent::Docs {
|
||||
op,
|
||||
collection,
|
||||
id: event.key.clone().unwrap_or_default(),
|
||||
data: event.payload.clone(),
|
||||
prev_data: event.old_payload.clone(),
|
||||
};
|
||||
(
|
||||
m.into_iter()
|
||||
.map(|x| (x.trigger_id, x.script_id))
|
||||
.collect::<Vec<_>>(),
|
||||
ev,
|
||||
)
|
||||
}
|
||||
"files" => {
|
||||
let Some(op) = FilesEventOp::from_wire(event.op) else {
|
||||
return Ok(());
|
||||
};
|
||||
let Some(meta) = event
|
||||
.payload
|
||||
.clone()
|
||||
.and_then(|v| serde_json::from_value::<FileMeta>(v).ok())
|
||||
else {
|
||||
return Ok(());
|
||||
};
|
||||
let m = self
|
||||
.triggers
|
||||
.list_matching_shared_files(owning_group, &collection, op)
|
||||
.await
|
||||
.map_err(|e| EmitError::Unavailable(format!("trigger lookup: {e}")))?;
|
||||
let ev = TriggerEvent::Files {
|
||||
op,
|
||||
collection,
|
||||
id: meta.id.to_string(),
|
||||
name: meta.name,
|
||||
content_type: meta.content_type,
|
||||
size: meta.size,
|
||||
checksum: meta.checksum,
|
||||
prev: event.old_payload.clone(),
|
||||
};
|
||||
(
|
||||
m.into_iter()
|
||||
.map(|x| (x.trigger_id, x.script_id))
|
||||
.collect::<Vec<_>>(),
|
||||
ev,
|
||||
)
|
||||
}
|
||||
_ => return Ok(()),
|
||||
};
|
||||
if matches.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
let payload = serde_json::to_value(&trigger_event)
|
||||
.map_err(|e| EmitError::Rejected(format!("event serialize: {e}")))?;
|
||||
for (trigger_id, script_id) in matches {
|
||||
self.outbox
|
||||
.insert(NewOutboxRow {
|
||||
app_id: cx.app_id,
|
||||
source_kind,
|
||||
trigger_id: Some(trigger_id),
|
||||
script_id: Some(script_id),
|
||||
reply_to: None,
|
||||
payload: payload.clone(),
|
||||
origin_principal: cx.principal.as_ref().map(|p| p.user_id),
|
||||
trigger_depth: cx.trigger_depth.saturating_add(1),
|
||||
root_execution_id: Some(cx.root_execution_id),
|
||||
})
|
||||
.await
|
||||
.map_err(|e| EmitError::Unavailable(format!("outbox insert: {e}")))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl OutboxEventEmitter {
|
||||
|
||||
@@ -12,12 +12,10 @@
|
||||
//! becomes a hot path.
|
||||
|
||||
use async_trait::async_trait;
|
||||
use picloud_shared::{topic_matches, AdminUserId, AppId, ExecutionId, GroupId};
|
||||
use picloud_shared::{topic_matches, AdminUserId, AppId, ExecutionId};
|
||||
use sqlx::PgPool;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::config_resolver::CHAIN_LEVELS_CTE;
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum PubsubRepoError {
|
||||
#[error("database error: {0}")]
|
||||
@@ -47,20 +45,6 @@ pub trait PubsubRepo: Send + Sync {
|
||||
topic: &str,
|
||||
event_payload: serde_json::Value,
|
||||
) -> Result<u32, PubsubRepoError>;
|
||||
|
||||
/// §11.6 D2: fan out a SHARED-topic publish to every `shared = true` pubsub
|
||||
/// trigger on `owning_group` (the group resolved from the shared-topic
|
||||
/// declaration), inserting one outbox row each stamped with the WRITER
|
||||
/// `ctx.app_id` — so the handler runs under the publishing app, matching the
|
||||
/// M2 shared-collection trigger model. No chain union (the owning group is
|
||||
/// already resolved) and no suppression (shared triggers aren't suppressible).
|
||||
async fn fan_out_shared_publish(
|
||||
&self,
|
||||
ctx: PublishCtx,
|
||||
owning_group: GroupId,
|
||||
topic: &str,
|
||||
event_payload: serde_json::Value,
|
||||
) -> Result<u32, PubsubRepoError>;
|
||||
}
|
||||
|
||||
pub struct PostgresPubsubRepo {
|
||||
@@ -94,21 +78,12 @@ impl PubsubRepo for PostgresPubsubRepo {
|
||||
// Load all enabled pubsub triggers for the app; filter by topic
|
||||
// pattern in Rust (keeps the query simple, honours the
|
||||
// empty/`*`/prefix semantics without teaching SQL about globs).
|
||||
// §11 tail: the chain union picks up the app's own pubsub triggers AND
|
||||
// ancestor-group pubsub TEMPLATES (live, no materialization). The outbox
|
||||
// row below stamps the firing `ctx.app_id`, so a template runs under the
|
||||
// publishing app — the inheriting-app boundary. The suppression
|
||||
// anti-join drops an inherited template whose handler the app opts out
|
||||
// of (`$1` = ctx.app_id).
|
||||
let rows: Vec<PubsubTriggerRow> = sqlx::query_as(&format!(
|
||||
"{CHAIN_LEVELS_CTE} \
|
||||
SELECT t.id, t.script_id, d.topic_pattern \
|
||||
let rows: Vec<PubsubTriggerRow> = sqlx::query_as(
|
||||
"SELECT t.id, t.script_id, d.topic_pattern \
|
||||
FROM triggers t \
|
||||
JOIN pubsub_trigger_details d ON d.trigger_id = t.id \
|
||||
JOIN chain c ON (t.app_id = c.app_owner OR t.group_id = c.group_owner) \
|
||||
WHERE t.kind = 'pubsub' AND t.enabled = TRUE AND t.shared = FALSE{ANTIJOIN}",
|
||||
ANTIJOIN = crate::trigger_repo::TRIGGER_SUPPRESSION_ANTIJOIN,
|
||||
))
|
||||
WHERE t.app_id = $1 AND t.kind = 'pubsub' AND t.enabled = TRUE",
|
||||
)
|
||||
.bind(ctx.app_id.into_inner())
|
||||
.fetch_all(&mut *tx)
|
||||
.await?;
|
||||
@@ -118,7 +93,21 @@ impl PubsubRepo for PostgresPubsubRepo {
|
||||
if !topic_matches(&r.topic_pattern, topic) {
|
||||
continue;
|
||||
}
|
||||
insert_pubsub_outbox_row(&mut tx, &ctx, r.id, r.script_id, &event_payload).await?;
|
||||
sqlx::query(
|
||||
"INSERT INTO outbox ( \
|
||||
app_id, source_kind, trigger_id, script_id, reply_to, \
|
||||
payload, origin_principal, trigger_depth, root_execution_id \
|
||||
) VALUES ($1, 'pubsub', $2, $3, NULL, $4, $5, $6, $7)",
|
||||
)
|
||||
.bind(ctx.app_id.into_inner())
|
||||
.bind(r.id)
|
||||
.bind(r.script_id)
|
||||
.bind(&event_payload)
|
||||
.bind(ctx.origin_principal.map(AdminUserId::into_inner))
|
||||
.bind(i32::try_from(ctx.trigger_depth.saturating_add(1)).unwrap_or(1))
|
||||
.bind(ctx.root_execution_id.into_inner())
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
written += 1;
|
||||
}
|
||||
|
||||
@@ -126,69 +115,4 @@ impl PubsubRepo for PostgresPubsubRepo {
|
||||
tx.commit().await?;
|
||||
Ok(written)
|
||||
}
|
||||
|
||||
async fn fan_out_shared_publish(
|
||||
&self,
|
||||
ctx: PublishCtx,
|
||||
owning_group: GroupId,
|
||||
topic: &str,
|
||||
event_payload: serde_json::Value,
|
||||
) -> Result<u32, PubsubRepoError> {
|
||||
let mut tx = self.pool.begin().await?;
|
||||
|
||||
// §11.6 D2: only `shared = true` pubsub triggers on the RESOLVED owning
|
||||
// group — the shared-topic namespace boundary (a per-app or non-owning
|
||||
// trigger never matches a shared publish). Topic-pattern match in Rust
|
||||
// like the per-app path.
|
||||
let rows: Vec<PubsubTriggerRow> = sqlx::query_as(
|
||||
"SELECT t.id, t.script_id, d.topic_pattern \
|
||||
FROM triggers t \
|
||||
JOIN pubsub_trigger_details d ON d.trigger_id = t.id \
|
||||
WHERE t.kind = 'pubsub' AND t.enabled = TRUE AND t.shared = TRUE \
|
||||
AND t.group_id = $1",
|
||||
)
|
||||
.bind(owning_group.into_inner())
|
||||
.fetch_all(&mut *tx)
|
||||
.await?;
|
||||
|
||||
let mut written: u32 = 0;
|
||||
for r in rows {
|
||||
if !topic_matches(&r.topic_pattern, topic) {
|
||||
continue;
|
||||
}
|
||||
insert_pubsub_outbox_row(&mut tx, &ctx, r.id, r.script_id, &event_payload).await?;
|
||||
written += 1;
|
||||
}
|
||||
|
||||
tx.commit().await?;
|
||||
Ok(written)
|
||||
}
|
||||
}
|
||||
|
||||
/// Insert one pubsub delivery row, stamped with the writer `ctx.app_id` so the
|
||||
/// handler runs under the publishing app. Shared by the per-app + shared
|
||||
/// fan-outs.
|
||||
async fn insert_pubsub_outbox_row(
|
||||
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
|
||||
ctx: &PublishCtx,
|
||||
trigger_id: Uuid,
|
||||
script_id: Uuid,
|
||||
event_payload: &serde_json::Value,
|
||||
) -> Result<(), PubsubRepoError> {
|
||||
sqlx::query(
|
||||
"INSERT INTO outbox ( \
|
||||
app_id, source_kind, trigger_id, script_id, reply_to, \
|
||||
payload, origin_principal, trigger_depth, root_execution_id \
|
||||
) VALUES ($1, 'pubsub', $2, $3, NULL, $4, $5, $6, $7)",
|
||||
)
|
||||
.bind(ctx.app_id.into_inner())
|
||||
.bind(trigger_id)
|
||||
.bind(script_id)
|
||||
.bind(event_payload)
|
||||
.bind(ctx.origin_principal.map(AdminUserId::into_inner))
|
||||
.bind(i32::try_from(ctx.trigger_depth.saturating_add(1)).unwrap_or(1))
|
||||
.bind(ctx.root_execution_id.into_inner())
|
||||
.execute(&mut **tx)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -386,17 +386,6 @@ mod tests {
|
||||
self.written.lock().unwrap().extend(staged);
|
||||
Ok(u32::try_from(n).unwrap_or(u32::MAX))
|
||||
}
|
||||
|
||||
async fn fan_out_shared_publish(
|
||||
&self,
|
||||
_ctx: PublishCtx,
|
||||
_owning_group: picloud_shared::GroupId,
|
||||
_topic: &str,
|
||||
_event_payload: serde_json::Value,
|
||||
) -> Result<u32, PubsubRepoError> {
|
||||
// The per-app PubsubService tests don't exercise the shared path.
|
||||
Ok(0)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
|
||||
@@ -13,14 +13,14 @@ use axum::{
|
||||
Extension, Json, Router,
|
||||
};
|
||||
use picloud_orchestrator_core::routing::{conflict, matcher::CompiledRoute, pattern, RouteTable};
|
||||
use picloud_shared::{AppId, HostKind, PathKind, Principal, Route, ScriptId, ScriptOwner};
|
||||
use picloud_shared::{AppId, HostKind, PathKind, Principal, Route, ScriptId};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::app_domain_repo::AppDomainRepository;
|
||||
use crate::authz::{require, AuthzDenied, AuthzRepo, Capability};
|
||||
use crate::repo::{ScriptRepository, ScriptRepositoryError};
|
||||
use crate::route_repo::{EffectiveRoute, NewRoute, RouteRepository};
|
||||
use crate::route_repo::{NewRoute, RouteRepository};
|
||||
|
||||
pub struct RouteAdminState<RR, SR> {
|
||||
pub routes: Arc<RR>,
|
||||
@@ -230,7 +230,7 @@ async fn create_route<RR: RouteRepository, SR: ScriptRepository>(
|
||||
let created = state
|
||||
.routes
|
||||
.create(NewRoute {
|
||||
owner: ScriptOwner::App(app_id),
|
||||
app_id,
|
||||
script_id,
|
||||
host_kind: input.host_kind,
|
||||
host: input.host,
|
||||
@@ -241,9 +241,8 @@ async fn create_route<RR: RouteRepository, SR: ScriptRepository>(
|
||||
dispatch_mode: input.dispatch_mode,
|
||||
// Routes are created active; toggling is a dedicated path.
|
||||
enabled: true,
|
||||
// Sealing is a group-template property (§11 tail); an interactively
|
||||
// created app route is never sealed.
|
||||
sealed: false,
|
||||
// Hand-declared via the interactive API — not a template expansion.
|
||||
from_template: None,
|
||||
})
|
||||
.await?;
|
||||
refresh_table(&state).await?;
|
||||
@@ -262,14 +261,10 @@ async fn delete_route<RR: RouteRepository, SR: ScriptRepository>(
|
||||
.get(route_id)
|
||||
.await?
|
||||
.ok_or(RouteApiError::RouteNotFound(route_id))?;
|
||||
// §11 tail: the interactive route API is app-scoped. A group-owned route
|
||||
// TEMPLATE (`app_id` None) is managed declaratively via apply, not
|
||||
// addressable here — treat it as not found.
|
||||
let route_app_id = route.app_id.ok_or(RouteApiError::RouteNotFound(route_id))?;
|
||||
require(
|
||||
state.authz.as_ref(),
|
||||
&principal,
|
||||
Capability::AppWriteRoute(route_app_id),
|
||||
Capability::AppWriteRoute(route.app_id),
|
||||
)
|
||||
.await?;
|
||||
state.routes.delete(route_id).await?;
|
||||
@@ -391,157 +386,52 @@ fn first_conflict(
|
||||
async fn refresh_table<RR: RouteRepository, SR: ScriptRepository>(
|
||||
state: &RouteAdminState<RR, SR>,
|
||||
) -> Result<(), RouteApiError> {
|
||||
rebuild_route_table(state.routes.as_ref(), &state.table).await?;
|
||||
let rows = state.routes.list_all().await?;
|
||||
let compiled = compile_routes(&rows);
|
||||
state.table.replace_all(compiled);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Rebuild the entire in-memory [`RouteTable`] from the database, expanding
|
||||
/// §11 tail group route TEMPLATES into every descendant app's slice.
|
||||
/// Compile stored route rows into the in-memory match table.
|
||||
///
|
||||
/// This is the single chokepoint for route-table refresh — called from route
|
||||
/// CRUD, the declarative apply reconcile, startup, and (full-live invalidation)
|
||||
/// every tree mutation that changes inheritance (app create/reparent/delete,
|
||||
/// group reparent). It reads the live expansion (`list_effective`) so a group
|
||||
/// template lands in the slice of each app whose ancestor chain contains the
|
||||
/// owning group, and nowhere else — the chain walk is the isolation boundary.
|
||||
pub async fn rebuild_route_table(
|
||||
routes: &dyn RouteRepository,
|
||||
table: &RouteTable,
|
||||
) -> Result<(), ScriptRepositoryError> {
|
||||
let effective = routes.list_effective().await?;
|
||||
// §11 tail: `(app_id, path)` route suppressions — an inherited route at a
|
||||
// suppressed path is dropped from the app's slice (404).
|
||||
let suppressed: std::collections::HashSet<(AppId, String)> = routes
|
||||
.list_route_suppressions()
|
||||
.await?
|
||||
.into_iter()
|
||||
.collect();
|
||||
let compiled = compile_effective_routes(&effective, &suppressed);
|
||||
table.replace_all(compiled);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Compile the §11 tail effective expansion into per-app match slices, applying
|
||||
/// **nearest-owner-wins shadowing**: for a given app, if the same binding tuple
|
||||
/// (method + host + path) is owned at more than one chain level — e.g. the app
|
||||
/// declares its own `/x` and an ancestor group also templates `/x` — the
|
||||
/// nearest owner (lowest `depth`) wins and the farther one is dropped. Unlike
|
||||
/// triggers (which fan out to every match), a route resolves to a single
|
||||
/// winner, so identical bindings MUST dedupe; **non-identical** bindings (the
|
||||
/// app's `/users/:id` + the group's `/users/admin`) coexist and the matcher's
|
||||
/// existing precedence picks per request.
|
||||
///
|
||||
/// This is the single production entry point: app-only installs feed it routes
|
||||
/// at `depth 0` (one per app, no templates), so it subsumes the former app-only
|
||||
/// compile path. Disabled routes are dropped (§4.3) and un-compilable rows
|
||||
/// skipped-with-warning (see [`compile_one`]).
|
||||
///
|
||||
/// **Disabled + inherited (§4.3 semantic):** the `enabled` filter runs *before*
|
||||
/// the shadow check, so a **disabled** own-route does NOT claim its binding — an
|
||||
/// enabled ancestor-group template at the same binding then falls through and
|
||||
/// serves (disabled = "indistinguishable from absent"). To actually 404 an
|
||||
/// inherited route, a descendant SUPPRESSES its path (§11 tail, the
|
||||
/// `suppressed_paths` set) — the deliberate per-app opt-out.
|
||||
///
|
||||
/// `suppressed_paths` holds `(app_id, path)` route suppressions: an INHERITED
|
||||
/// row (`depth > 0`) whose `(effective_app_id, path)` is present is skipped
|
||||
/// entirely (the binding is absent → 404). Gated to `depth > 0` so an app can
|
||||
/// only decline what it inherits, never its own route.
|
||||
///
|
||||
/// Requires `rows` ordered by `(effective_app_id, depth ASC)` — which
|
||||
/// [`RouteRepository::list_effective`] guarantees — so first-seen is nearest.
|
||||
#[must_use]
|
||||
#[allow(clippy::implicit_hasher)] // every caller uses the default hasher
|
||||
pub fn compile_effective_routes(
|
||||
rows: &[EffectiveRoute],
|
||||
suppressed_paths: &std::collections::HashSet<(AppId, String)>,
|
||||
) -> Vec<CompiledRoute> {
|
||||
let mut seen: std::collections::HashSet<(AppId, String)> = std::collections::HashSet::new();
|
||||
let mut out = Vec::new();
|
||||
for er in rows {
|
||||
// A disabled route (§4.3) is dropped from the match table entirely, so a
|
||||
// request to it 404s indistinguishably from an absent route.
|
||||
if !er.route.enabled {
|
||||
continue;
|
||||
}
|
||||
// §11 tail: an inherited route whose path this app suppresses is
|
||||
// dropped — the binding 404s. Gated to inherited rows (`depth > 0`).
|
||||
// A `sealed` template is non-suppressible: the descendant's opt-out is
|
||||
// ignored, so it stays in the slice regardless of the suppression.
|
||||
if er.depth > 0
|
||||
&& !er.route.sealed
|
||||
&& suppressed_paths.contains(&(er.effective_app_id, er.route.path.clone()))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
// A nearer owner already claimed this binding for this app → shadow.
|
||||
if !seen.insert((er.effective_app_id, binding_key(&er.route))) {
|
||||
continue;
|
||||
}
|
||||
if let Some(compiled) = compile_one(&er.route, er.effective_app_id) {
|
||||
out.push(compiled);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The shadow-identity of a route within one app's slice: the binding tuple a
|
||||
/// request resolves against (method + host + path), matching the per-owner DB
|
||||
/// unique index. Two routes with the same key are "the same endpoint" and only
|
||||
/// the nearest owner's survives.
|
||||
fn binding_key(r: &Route) -> String {
|
||||
let method = r
|
||||
.method
|
||||
.as_deref()
|
||||
.map_or("ANY".to_string(), str::to_uppercase);
|
||||
let host_kind = match r.host_kind {
|
||||
HostKind::Any => "any",
|
||||
HostKind::Strict => "strict",
|
||||
HostKind::Wildcard => "wildcard",
|
||||
};
|
||||
let path_kind = match r.path_kind {
|
||||
PathKind::Exact => "exact",
|
||||
PathKind::Prefix => "prefix",
|
||||
PathKind::Param => "param",
|
||||
};
|
||||
format!("{method} {host_kind} {} {path_kind} {}", r.host, r.path)
|
||||
}
|
||||
|
||||
/// Parse one stored route into a `CompiledRoute` bound to `app_id` (the
|
||||
/// effective app — its own for app routes, the firing descendant for a group
|
||||
/// template).
|
||||
///
|
||||
/// **Lenient by design (H1).** A row that fails to parse is *skipped with a
|
||||
/// warning*, not propagated as an error. The motivating case: a path that was
|
||||
/// valid when created but became reserved under a later, stricter validation
|
||||
/// (the case-insensitive reserved-prefix check) — but this also covers any
|
||||
/// other parse failure. A single un-compilable row must never take down the
|
||||
/// data plane: this runs at startup (where a hard error aborts boot) and on
|
||||
/// every table rebuild after an edit (where it would fail an unrelated CRUD
|
||||
/// op). A skipped route simply doesn't match; the warning tells the operator to
|
||||
/// delete or fix it (and migration 0044 sweeps the reserved-path offenders on
|
||||
/// **Lenient by design (H1).** A row that fails to parse is *skipped with
|
||||
/// a warning*, not propagated as an error. The motivating case: a path
|
||||
/// that was valid when created but became reserved under a later, stricter
|
||||
/// validation (e.g. the case-insensitive reserved-prefix check) — but this
|
||||
/// also covers any other parse failure. A single un-compilable legacy row
|
||||
/// must never take down the entire data plane: this function runs at
|
||||
/// startup (where a hard error aborts boot) and on every table rebuild
|
||||
/// after a route edit (where it would fail an unrelated CRUD op). A skipped
|
||||
/// route simply doesn't match; the warning tells the operator to delete or
|
||||
/// fix it (and migration 0044 sweeps the reserved-path offenders on
|
||||
/// upgrade).
|
||||
fn compile_one(r: &Route, app_id: AppId) -> Option<CompiledRoute> {
|
||||
match compile_route(r, app_id) {
|
||||
Ok(compiled) => Some(compiled),
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
route_id = %r.id,
|
||||
app_id = %app_id,
|
||||
path = %r.path,
|
||||
error = %e,
|
||||
"skipping un-compilable stored route — it will not match; \
|
||||
delete or fix it"
|
||||
);
|
||||
None
|
||||
}
|
||||
}
|
||||
#[must_use]
|
||||
pub fn compile_routes(rows: &[Route]) -> Vec<CompiledRoute> {
|
||||
rows.iter()
|
||||
// A disabled route (§4.3) is dropped from the match table entirely, so
|
||||
// a request to it 404s indistinguishably from an absent route.
|
||||
.filter(|r| r.enabled)
|
||||
.filter_map(|r| match compile_route(r) {
|
||||
Ok(compiled) => Some(compiled),
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
route_id = %r.id,
|
||||
app_id = %r.app_id,
|
||||
path = %r.path,
|
||||
error = %e,
|
||||
"skipping un-compilable stored route — it will not match; \
|
||||
delete or fix it"
|
||||
);
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn compile_route(r: &Route, app_id: AppId) -> Result<CompiledRoute, pattern::ParseError> {
|
||||
fn compile_route(r: &Route) -> Result<CompiledRoute, pattern::ParseError> {
|
||||
Ok(CompiledRoute {
|
||||
route_id: r.id,
|
||||
app_id,
|
||||
app_id: r.app_id,
|
||||
script_id: r.script_id,
|
||||
host: pattern::parse_host(r.host_kind, &r.host, r.host_param_name.as_deref())?,
|
||||
path: pattern::parse_path(r.path_kind, &r.path)?,
|
||||
@@ -743,8 +633,7 @@ mod tests {
|
||||
fn route_with_path(path: &str) -> Route {
|
||||
Route {
|
||||
id: Uuid::new_v4(),
|
||||
app_id: Some(AppId::from(Uuid::new_v4())),
|
||||
group_id: None,
|
||||
app_id: AppId::from(Uuid::new_v4()),
|
||||
script_id: ScriptId::from(Uuid::new_v4()),
|
||||
host_kind: HostKind::Any,
|
||||
host: String::new(),
|
||||
@@ -754,38 +643,22 @@ mod tests {
|
||||
method: None,
|
||||
dispatch_mode: DispatchMode::default(),
|
||||
enabled: true,
|
||||
sealed: false,
|
||||
created_at: chrono::Utc::now(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Wrap an app-owned route as its own `depth 0` effective row — the shape
|
||||
/// `list_effective` produces for an app-only install (no group templates).
|
||||
fn eff(route: &Route) -> EffectiveRoute {
|
||||
EffectiveRoute {
|
||||
effective_app_id: route.app_id.expect("test route is app-owned"),
|
||||
depth: 0,
|
||||
route: route.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
/// No route suppressions — the common case for these compile tests.
|
||||
fn no_suppress() -> std::collections::HashSet<(AppId, String)> {
|
||||
std::collections::HashSet::new()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compile_effective_skips_uncompilable_rows_instead_of_failing() {
|
||||
fn compile_routes_skips_uncompilable_rows_instead_of_failing() {
|
||||
// H1 regression guard: a stored route whose path is now reserved
|
||||
// (creatable before the case-insensitive reserved-prefix fix) must
|
||||
// be skipped, not abort the whole compile — otherwise one legacy
|
||||
// row bricks startup (the effective rebuild runs in `build_app`).
|
||||
// row bricks startup (`compile_routes` runs in `build_app`).
|
||||
let good_a = route_with_path("/ok");
|
||||
let bad = route_with_path("/API/v2/x"); // now reserved, case-insensitive
|
||||
let good_b = route_with_path("/items");
|
||||
let rows = [eff(&good_a), eff(&bad), eff(&good_b)];
|
||||
let rows = vec![good_a.clone(), bad.clone(), good_b.clone()];
|
||||
|
||||
let compiled = compile_effective_routes(&rows, &no_suppress());
|
||||
let compiled = compile_routes(&rows);
|
||||
|
||||
let ids: Vec<Uuid> = compiled.iter().map(|c| c.route_id).collect();
|
||||
assert_eq!(compiled.len(), 2, "the reserved row must be dropped");
|
||||
@@ -804,53 +677,8 @@ mod tests {
|
||||
let active = route_with_path("/on");
|
||||
let mut disabled = route_with_path("/off");
|
||||
disabled.enabled = false;
|
||||
let compiled = compile_effective_routes(&[eff(&active), eff(&disabled)], &no_suppress());
|
||||
let compiled = compile_routes(&[active.clone(), disabled.clone()]);
|
||||
let ids: Vec<Uuid> = compiled.iter().map(|c| c.route_id).collect();
|
||||
assert_eq!(ids, vec![active.id], "only the enabled route compiles");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nearest_owner_shadows_identical_binding() {
|
||||
// An app's own route (depth 0) and an ancestor-group template (depth 1)
|
||||
// with the SAME binding collapse to the nearer one; a different binding
|
||||
// coexists. Mirrors the live `group_route_templates` integration test
|
||||
// at the pure-compile layer.
|
||||
let app = AppId::from(Uuid::new_v4());
|
||||
let own = route_with_path("/x"); // app's own /x
|
||||
let mut template = route_with_path("/x"); // group template, same binding
|
||||
template.app_id = None;
|
||||
template.group_id = Some(picloud_shared::GroupId::from(Uuid::new_v4()));
|
||||
let other_template = route_with_path("/y"); // group template, different path
|
||||
|
||||
let rows = [
|
||||
EffectiveRoute {
|
||||
effective_app_id: app,
|
||||
depth: 0,
|
||||
route: own.clone(),
|
||||
},
|
||||
EffectiveRoute {
|
||||
effective_app_id: app,
|
||||
depth: 1,
|
||||
route: template.clone(),
|
||||
},
|
||||
EffectiveRoute {
|
||||
effective_app_id: app,
|
||||
depth: 1,
|
||||
route: other_template.clone(),
|
||||
},
|
||||
];
|
||||
let ids: Vec<Uuid> = compile_effective_routes(&rows, &no_suppress())
|
||||
.iter()
|
||||
.map(|c| c.route_id)
|
||||
.collect();
|
||||
assert!(ids.contains(&own.id), "the app's own /x must win");
|
||||
assert!(
|
||||
!ids.contains(&template.id),
|
||||
"the inherited /x template must be shadowed by the app's own"
|
||||
);
|
||||
assert!(
|
||||
ids.contains(&other_template.id),
|
||||
"a non-identical /y template must coexist"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,9 +4,7 @@
|
||||
//! after every write — see the route_admin module for the binding.
|
||||
|
||||
use async_trait::async_trait;
|
||||
use picloud_shared::{
|
||||
AppId, DispatchMode, GroupId, HostKind, PathKind, Route, ScriptId, ScriptOwner,
|
||||
};
|
||||
use picloud_shared::{AppId, DispatchMode, HostKind, PathKind, Route, ScriptId};
|
||||
use sqlx::PgPool;
|
||||
use uuid::Uuid;
|
||||
|
||||
@@ -14,10 +12,7 @@ use crate::repo::ScriptRepositoryError;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct NewRoute {
|
||||
/// §11 tail: an **app** owns a concrete route; a **group** owns a route
|
||||
/// TEMPLATE. The interactive route API always passes `App`; the declarative
|
||||
/// reconcile passes the bundle node's owner.
|
||||
pub owner: ScriptOwner,
|
||||
pub app_id: AppId,
|
||||
pub script_id: ScriptId,
|
||||
pub host_kind: HostKind,
|
||||
pub host: String,
|
||||
@@ -28,22 +23,10 @@ pub struct NewRoute {
|
||||
pub dispatch_mode: DispatchMode,
|
||||
/// Three-state lifecycle (§4.3). Create active by default.
|
||||
pub enabled: bool,
|
||||
/// §11 tail: `true` for a sealed (non-suppressible) group route template.
|
||||
/// Always `false` for an app-owned route (the reconcile rejects sealed on
|
||||
/// an app owner before reaching here).
|
||||
pub sealed: bool,
|
||||
}
|
||||
|
||||
/// A route resolved for a specific app via the §11 tail expansion: the route
|
||||
/// row itself plus the **effective app** it applies to (the firing
|
||||
/// descendant) and the chain `depth` at which its owner sits (0 = the app's
|
||||
/// own route, ≥1 = an ancestor-group template). Used only by the in-memory
|
||||
/// RouteTable rebuild — never on the request hot path.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct EffectiveRoute {
|
||||
pub effective_app_id: AppId,
|
||||
pub depth: i32,
|
||||
pub route: Route,
|
||||
/// Provenance (§4.5, M4): the route-template id this row was expanded from,
|
||||
/// or `None` for a hand-declared route. Defaults `None` everywhere except
|
||||
/// template expansion.
|
||||
pub from_template: Option<Uuid>,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
@@ -54,42 +37,6 @@ pub trait RouteRepository: Send + Sync {
|
||||
/// (not a path param).
|
||||
async fn get(&self, route_id: Uuid) -> Result<Option<Route>, ScriptRepositoryError>;
|
||||
async fn list_for_app(&self, app_id: AppId) -> Result<Vec<Route>, ScriptRepositoryError>;
|
||||
/// Group-owned route TEMPLATES (§11 tail) declared directly at `group_id`.
|
||||
/// Backs the declarative apply diff for a `[group]` node and
|
||||
/// `pic routes ls --group`. Defaults to empty so non-Postgres impls
|
||||
/// (tests) need not provide it.
|
||||
async fn list_for_group(
|
||||
&self,
|
||||
_group_id: GroupId,
|
||||
) -> Result<Vec<Route>, ScriptRepositoryError> {
|
||||
Ok(Vec::new())
|
||||
}
|
||||
/// §11 tail per-app opt-out: all `(app_id, path)` ROUTE suppressions. The
|
||||
/// route-table rebuild drops an inherited route at a suppressed path.
|
||||
/// Defaults empty (non-Postgres impls have no suppressions).
|
||||
async fn list_route_suppressions(&self) -> Result<Vec<(AppId, String)>, ScriptRepositoryError> {
|
||||
Ok(Vec::new())
|
||||
}
|
||||
/// §11 tail: every route resolved for every app — each app's own routes
|
||||
/// PLUS its ancestor-group templates, tagged with the effective app and
|
||||
/// the owner's chain depth. The in-memory RouteTable rebuild consumes this
|
||||
/// (expansion happens here, once per rebuild, never per request). Defaults
|
||||
/// to `list_all` mapped at depth 0 so non-Postgres impls degrade to the
|
||||
/// pre-§11 (app-only) behavior.
|
||||
async fn list_effective(&self) -> Result<Vec<EffectiveRoute>, ScriptRepositoryError> {
|
||||
Ok(self
|
||||
.list_all()
|
||||
.await?
|
||||
.into_iter()
|
||||
.filter_map(|route| {
|
||||
route.app_id.map(|a| EffectiveRoute {
|
||||
effective_app_id: a,
|
||||
depth: 0,
|
||||
route,
|
||||
})
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
async fn list_for_script(
|
||||
&self,
|
||||
script_id: ScriptId,
|
||||
@@ -121,7 +68,7 @@ impl PostgresRouteRepository {
|
||||
impl RouteRepository for PostgresRouteRepository {
|
||||
async fn list_all(&self) -> Result<Vec<Route>, ScriptRepositoryError> {
|
||||
let rows = sqlx::query_as::<_, RouteRow>(
|
||||
"SELECT id, app_id, group_id, script_id, host_kind, host, host_param_name, \
|
||||
"SELECT id, app_id, script_id, host_kind, host, host_param_name, \
|
||||
path_kind, path, method, dispatch_mode, enabled, created_at \
|
||||
FROM routes ORDER BY created_at",
|
||||
)
|
||||
@@ -132,7 +79,7 @@ impl RouteRepository for PostgresRouteRepository {
|
||||
|
||||
async fn get(&self, route_id: Uuid) -> Result<Option<Route>, ScriptRepositoryError> {
|
||||
let row = sqlx::query_as::<_, RouteRow>(
|
||||
"SELECT id, app_id, group_id, script_id, host_kind, host, host_param_name, \
|
||||
"SELECT id, app_id, script_id, host_kind, host, host_param_name, \
|
||||
path_kind, path, method, dispatch_mode, enabled, created_at \
|
||||
FROM routes WHERE id = $1",
|
||||
)
|
||||
@@ -144,7 +91,7 @@ impl RouteRepository for PostgresRouteRepository {
|
||||
|
||||
async fn list_for_app(&self, app_id: AppId) -> Result<Vec<Route>, ScriptRepositoryError> {
|
||||
let rows = sqlx::query_as::<_, RouteRow>(
|
||||
"SELECT id, app_id, group_id, script_id, host_kind, host, host_param_name, \
|
||||
"SELECT id, app_id, script_id, host_kind, host, host_param_name, \
|
||||
path_kind, path, method, dispatch_mode, enabled, created_at \
|
||||
FROM routes WHERE app_id = $1 ORDER BY created_at",
|
||||
)
|
||||
@@ -154,88 +101,12 @@ impl RouteRepository for PostgresRouteRepository {
|
||||
Ok(rows.into_iter().map(Into::into).collect())
|
||||
}
|
||||
|
||||
async fn list_for_group(&self, group_id: GroupId) -> Result<Vec<Route>, ScriptRepositoryError> {
|
||||
let rows = sqlx::query_as::<_, RouteRow>(
|
||||
"SELECT id, app_id, group_id, script_id, host_kind, host, host_param_name, \
|
||||
path_kind, path, method, dispatch_mode, enabled, sealed, created_at \
|
||||
FROM routes WHERE group_id = $1 ORDER BY created_at",
|
||||
)
|
||||
.bind(group_id.into_inner())
|
||||
.fetch_all(&self.pool)
|
||||
.await?;
|
||||
Ok(rows.into_iter().map(Into::into).collect())
|
||||
}
|
||||
|
||||
async fn list_route_suppressions(&self) -> Result<Vec<(AppId, String)>, ScriptRepositoryError> {
|
||||
// §11 tail M1: a route suppression is owned by an app (declines for
|
||||
// itself) OR a group (declines for its whole subtree). Expand each
|
||||
// group-owned suppression across every descendant app via the all-apps
|
||||
// `app_chain` CTE (the same one `list_effective` uses): the result is
|
||||
// `(effective_app_id, path)` pairs the rebuild consumes unchanged — a
|
||||
// group suppression appears once per descendant app.
|
||||
let rows: Vec<(Uuid, String)> = sqlx::query_as(
|
||||
"WITH RECURSIVE app_chain AS ( \
|
||||
SELECT a.id AS effective_app_id, a.id AS owner_app, \
|
||||
NULL::uuid AS owner_group, a.group_id AS next_group, 0 AS depth \
|
||||
FROM apps a \
|
||||
UNION ALL \
|
||||
SELECT ac.effective_app_id, NULL::uuid, g.id, g.parent_id, ac.depth + 1 \
|
||||
FROM groups g JOIN app_chain ac ON g.id = ac.next_group \
|
||||
WHERE ac.depth < 64 \
|
||||
) \
|
||||
SELECT DISTINCT ac.effective_app_id, ts.reference \
|
||||
FROM app_chain ac \
|
||||
JOIN template_suppressions ts \
|
||||
ON (ts.app_id = ac.owner_app OR ts.group_id = ac.owner_group) \
|
||||
WHERE ts.target_kind = 'route'",
|
||||
)
|
||||
.fetch_all(&self.pool)
|
||||
.await?;
|
||||
Ok(rows.into_iter().map(|(a, r)| (a.into(), r)).collect())
|
||||
}
|
||||
|
||||
async fn list_effective(&self) -> Result<Vec<EffectiveRoute>, ScriptRepositoryError> {
|
||||
// The all-apps generalization of CHAIN_LEVELS_CTE: for EVERY app, walk
|
||||
// its ancestor-group chain, then join routes owned at each level. A
|
||||
// group template appears once per descendant app (tagged with that
|
||||
// app + the owner's depth); an app's own route appears at depth 0.
|
||||
// Runs only on a RouteTable rebuild, never per request.
|
||||
let rows = sqlx::query_as::<_, EffectiveRouteRow>(
|
||||
"WITH RECURSIVE app_chain AS ( \
|
||||
SELECT a.id AS effective_app_id, a.id AS owner_app, \
|
||||
NULL::uuid AS owner_group, a.group_id AS next_group, 0 AS depth \
|
||||
FROM apps a \
|
||||
UNION ALL \
|
||||
SELECT ac.effective_app_id, NULL::uuid, g.id, g.parent_id, ac.depth + 1 \
|
||||
FROM groups g JOIN app_chain ac ON g.id = ac.next_group \
|
||||
WHERE ac.depth < 64 \
|
||||
) \
|
||||
SELECT ac.effective_app_id, ac.depth, \
|
||||
r.id, r.app_id, r.group_id, r.script_id, r.host_kind, r.host, \
|
||||
r.host_param_name, r.path_kind, r.path, r.method, \
|
||||
r.dispatch_mode, r.enabled, r.sealed, r.created_at \
|
||||
FROM app_chain ac \
|
||||
JOIN routes r ON (r.app_id = ac.owner_app OR r.group_id = ac.owner_group) \
|
||||
ORDER BY ac.effective_app_id, ac.depth",
|
||||
)
|
||||
.fetch_all(&self.pool)
|
||||
.await?;
|
||||
Ok(rows
|
||||
.into_iter()
|
||||
.map(|er| EffectiveRoute {
|
||||
effective_app_id: er.effective_app_id.into(),
|
||||
depth: er.depth,
|
||||
route: er.row.into(),
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
async fn list_for_script(
|
||||
&self,
|
||||
script_id: ScriptId,
|
||||
) -> Result<Vec<Route>, ScriptRepositoryError> {
|
||||
let rows = sqlx::query_as::<_, RouteRow>(
|
||||
"SELECT id, app_id, group_id, script_id, host_kind, host, host_param_name, \
|
||||
"SELECT id, app_id, script_id, host_kind, host, host_param_name, \
|
||||
path_kind, path, method, dispatch_mode, enabled, created_at \
|
||||
FROM routes WHERE script_id = $1 ORDER BY created_at",
|
||||
)
|
||||
@@ -305,21 +176,15 @@ pub(crate) async fn insert_route_tx(
|
||||
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
|
||||
input: &NewRoute,
|
||||
) -> Result<Route, ScriptRepositoryError> {
|
||||
// §11 tail: an app owns a concrete route, a group owns a template.
|
||||
let (owner_app_id, owner_group_id) = match input.owner {
|
||||
ScriptOwner::App(a) => (Some(a.into_inner()), None),
|
||||
ScriptOwner::Group(g) => (None, Some(g.into_inner())),
|
||||
};
|
||||
let res = sqlx::query_as::<_, RouteRow>(
|
||||
"INSERT INTO routes ( \
|
||||
app_id, group_id, script_id, host_kind, host, host_param_name, \
|
||||
path_kind, path, method, dispatch_mode, enabled, sealed \
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12) \
|
||||
RETURNING id, app_id, group_id, script_id, host_kind, host, host_param_name, \
|
||||
path_kind, path, method, dispatch_mode, enabled, sealed, created_at",
|
||||
app_id, script_id, host_kind, host, host_param_name, \
|
||||
path_kind, path, method, dispatch_mode, enabled, from_template \
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) \
|
||||
RETURNING id, app_id, script_id, host_kind, host, host_param_name, \
|
||||
path_kind, path, method, dispatch_mode, enabled, created_at",
|
||||
)
|
||||
.bind(owner_app_id)
|
||||
.bind(owner_group_id)
|
||||
.bind(input.app_id.into_inner())
|
||||
.bind(input.script_id.into_inner())
|
||||
.bind(host_kind_str(input.host_kind))
|
||||
.bind(&input.host)
|
||||
@@ -329,7 +194,7 @@ pub(crate) async fn insert_route_tx(
|
||||
.bind(input.method.as_deref())
|
||||
.bind(input.dispatch_mode.as_str())
|
||||
.bind(input.enabled)
|
||||
.bind(input.sealed)
|
||||
.bind(input.from_template)
|
||||
.fetch_one(&mut **tx)
|
||||
.await;
|
||||
match res {
|
||||
@@ -365,11 +230,7 @@ pub(crate) async fn delete_route_tx(
|
||||
#[derive(sqlx::FromRow)]
|
||||
struct RouteRow {
|
||||
id: Uuid,
|
||||
app_id: Option<Uuid>,
|
||||
// `default` so any RETURNING/SELECT that predates the column still
|
||||
// hydrates; every query in this module now lists it explicitly.
|
||||
#[sqlx(default)]
|
||||
group_id: Option<Uuid>,
|
||||
app_id: Uuid,
|
||||
script_id: Uuid,
|
||||
host_kind: String,
|
||||
host: String,
|
||||
@@ -379,31 +240,14 @@ struct RouteRow {
|
||||
method: Option<String>,
|
||||
dispatch_mode: String,
|
||||
enabled: bool,
|
||||
// §11 tail: `default` so a SELECT/RETURNING that omits `sealed` still
|
||||
// hydrates (→ false). The queries that must see a true value — `list_effective`
|
||||
// (the rebuild gate) and the group-route load feeding the apply diff — list
|
||||
// it explicitly.
|
||||
#[sqlx(default)]
|
||||
sealed: bool,
|
||||
created_at: chrono::DateTime<chrono::Utc>,
|
||||
}
|
||||
|
||||
/// Row shape for [`RouteRepository::list_effective`]: the effective app +
|
||||
/// owner depth, with the underlying route columns flattened in via `flatten`.
|
||||
#[derive(sqlx::FromRow)]
|
||||
struct EffectiveRouteRow {
|
||||
effective_app_id: Uuid,
|
||||
depth: i32,
|
||||
#[sqlx(flatten)]
|
||||
row: RouteRow,
|
||||
}
|
||||
|
||||
impl From<RouteRow> for Route {
|
||||
fn from(r: RouteRow) -> Self {
|
||||
Self {
|
||||
id: r.id,
|
||||
app_id: r.app_id.map(Into::into),
|
||||
group_id: r.group_id.map(Into::into),
|
||||
app_id: r.app_id.into(),
|
||||
script_id: r.script_id.into(),
|
||||
host_kind: match r.host_kind.as_str() {
|
||||
"strict" => HostKind::Strict,
|
||||
@@ -421,7 +265,6 @@ impl From<RouteRow> for Route {
|
||||
method: r.method,
|
||||
dispatch_mode: DispatchMode::from_wire(&r.dispatch_mode).unwrap_or(DispatchMode::Sync),
|
||||
enabled: r.enabled,
|
||||
sealed: r.sealed,
|
||||
created_at: r.created_at,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,108 +0,0 @@
|
||||
//! Template-suppression markers (§11 tail) — the `template_suppressions` table
|
||||
//! (0058, made owner-polymorphic in 0060). A marker `(owner, target_kind,
|
||||
//! reference)` records that an OWNER opts OUT of an inherited group template: a
|
||||
//! handler script name it declines (`target_kind='trigger'`) or a path it
|
||||
//! declines (`target_kind='route'`).
|
||||
//!
|
||||
//! Owner-polymorphic (like `extension_points`): an **app** declines for itself;
|
||||
//! a **group** declines for its whole subtree. The functions take a
|
||||
//! `ScriptOwner` and bind the matching owner column. Same tx-function style:
|
||||
//! read over `&PgPool`, write over a `&mut Transaction`.
|
||||
|
||||
use picloud_shared::ScriptOwner;
|
||||
use sqlx::{PgPool, Postgres, Transaction};
|
||||
|
||||
/// The two suppressible template kinds, as stored in `target_kind`.
|
||||
pub const TARGET_TRIGGER: &str = "trigger";
|
||||
pub const TARGET_ROUTE: &str = "route";
|
||||
|
||||
/// List the suppression markers declared at `owner`, as `(target_kind,
|
||||
/// reference)` pairs, stably ordered. Backs `load_current` (the apply diff) and
|
||||
/// the read-only `suppress ls`.
|
||||
pub async fn list_for_owner(
|
||||
pool: &PgPool,
|
||||
owner: ScriptOwner,
|
||||
) -> Result<Vec<(String, String)>, sqlx::Error> {
|
||||
let sql = match owner {
|
||||
ScriptOwner::App(_) => {
|
||||
"SELECT target_kind, reference FROM template_suppressions \
|
||||
WHERE app_id = $1 ORDER BY target_kind, LOWER(reference)"
|
||||
}
|
||||
ScriptOwner::Group(_) => {
|
||||
"SELECT target_kind, reference FROM template_suppressions \
|
||||
WHERE group_id = $1 ORDER BY target_kind, LOWER(reference)"
|
||||
}
|
||||
};
|
||||
sqlx::query_as(sql)
|
||||
.bind(owner_uuid(owner))
|
||||
.fetch_all(pool)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Insert a suppression marker in the apply transaction. Idempotent: re-apply
|
||||
/// of an already-declared `(kind, reference)` is a no-op (`ON CONFLICT DO
|
||||
/// NOTHING` against the per-owner partial unique index), so the marker survives
|
||||
/// without a spurious version bump.
|
||||
pub async fn insert_suppression_tx(
|
||||
tx: &mut Transaction<'_, Postgres>,
|
||||
owner: ScriptOwner,
|
||||
target_kind: &str,
|
||||
reference: &str,
|
||||
) -> Result<(), sqlx::Error> {
|
||||
let (app_id, group_id) = owner_columns(owner);
|
||||
sqlx::query(
|
||||
"INSERT INTO template_suppressions (app_id, group_id, target_kind, reference) \
|
||||
VALUES ($1, $2, $3, $4) \
|
||||
ON CONFLICT DO NOTHING",
|
||||
)
|
||||
.bind(app_id)
|
||||
.bind(group_id)
|
||||
.bind(target_kind)
|
||||
.bind(reference)
|
||||
.execute(&mut **tx)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Delete a suppression marker in the apply transaction. Used by `--prune` when
|
||||
/// the manifest stops declaring the reference → the template re-inherits.
|
||||
pub async fn delete_suppression_tx(
|
||||
tx: &mut Transaction<'_, Postgres>,
|
||||
owner: ScriptOwner,
|
||||
target_kind: &str,
|
||||
reference: &str,
|
||||
) -> Result<(), sqlx::Error> {
|
||||
let sql = match owner {
|
||||
ScriptOwner::App(_) => {
|
||||
"DELETE FROM template_suppressions \
|
||||
WHERE app_id = $1 AND target_kind = $2 AND reference = $3"
|
||||
}
|
||||
ScriptOwner::Group(_) => {
|
||||
"DELETE FROM template_suppressions \
|
||||
WHERE group_id = $1 AND target_kind = $2 AND reference = $3"
|
||||
}
|
||||
};
|
||||
sqlx::query(sql)
|
||||
.bind(owner_uuid(owner))
|
||||
.bind(target_kind)
|
||||
.bind(reference)
|
||||
.execute(&mut **tx)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// `(app_id, group_id)` bind pair for the polymorphic owner — exactly one set.
|
||||
fn owner_columns(owner: ScriptOwner) -> (Option<uuid::Uuid>, Option<uuid::Uuid>) {
|
||||
match owner {
|
||||
ScriptOwner::App(a) => (Some(a.into_inner()), None),
|
||||
ScriptOwner::Group(g) => (None, Some(g.into_inner())),
|
||||
}
|
||||
}
|
||||
|
||||
/// The single owner UUID (app or group), for the WHERE-on-one-column queries.
|
||||
fn owner_uuid(owner: ScriptOwner) -> uuid::Uuid {
|
||||
match owner {
|
||||
ScriptOwner::App(a) => a.into_inner(),
|
||||
ScriptOwner::Group(g) => g.into_inner(),
|
||||
}
|
||||
}
|
||||
318
crates/manager-core/src/template_repo.rs
Normal file
318
crates/manager-core/src/template_repo.rs
Normal file
@@ -0,0 +1,318 @@
|
||||
//! CRUD over `route_templates` (§4.5, M4a) — route declarations owned by a
|
||||
//! group that the apply engine fans out into one concrete `routes` row per
|
||||
//! descendant app. Mirrors the tx-accepting free-function pattern of
|
||||
//! `route_repo`/`group_repo`: pool variants for the read/diff path, `_tx`
|
||||
//! variants for the transactional apply (upsert/delete and chain expansion).
|
||||
|
||||
use picloud_shared::{DispatchMode, HostKind, PathKind};
|
||||
use sqlx::PgPool;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::apply_service::RouteTemplateSpec;
|
||||
use crate::group_repo::GroupRepositoryError as Error;
|
||||
use picloud_shared::GroupId;
|
||||
|
||||
/// A persisted route template, decoded with its enum fields parsed — ready for
|
||||
/// the diff (by name) and for expansion (synthesizing a concrete route).
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct RouteTemplate {
|
||||
pub id: Uuid,
|
||||
pub group_id: GroupId,
|
||||
pub name: String,
|
||||
pub script_name: String,
|
||||
pub method: Option<String>,
|
||||
pub host_kind: HostKind,
|
||||
pub host: String,
|
||||
pub host_param_name: Option<String>,
|
||||
pub path_kind: PathKind,
|
||||
pub path: String,
|
||||
pub dispatch_mode: DispatchMode,
|
||||
pub enabled: bool,
|
||||
}
|
||||
|
||||
#[derive(sqlx::FromRow)]
|
||||
struct RouteTemplateRow {
|
||||
id: Uuid,
|
||||
group_id: Uuid,
|
||||
name: String,
|
||||
script_name: String,
|
||||
method: Option<String>,
|
||||
host_kind: String,
|
||||
host: String,
|
||||
host_param_name: Option<String>,
|
||||
path_kind: String,
|
||||
path: String,
|
||||
dispatch_mode: String,
|
||||
enabled: bool,
|
||||
}
|
||||
|
||||
impl From<RouteTemplateRow> for RouteTemplate {
|
||||
fn from(r: RouteTemplateRow) -> Self {
|
||||
Self {
|
||||
id: r.id,
|
||||
group_id: r.group_id.into(),
|
||||
name: r.name,
|
||||
script_name: r.script_name,
|
||||
method: r.method,
|
||||
host_kind: match r.host_kind.as_str() {
|
||||
"strict" => HostKind::Strict,
|
||||
"wildcard" => HostKind::Wildcard,
|
||||
_ => HostKind::Any,
|
||||
},
|
||||
host: r.host,
|
||||
host_param_name: r.host_param_name,
|
||||
path_kind: match r.path_kind.as_str() {
|
||||
"prefix" => PathKind::Prefix,
|
||||
"param" => PathKind::Param,
|
||||
_ => PathKind::Exact,
|
||||
},
|
||||
path: r.path,
|
||||
dispatch_mode: DispatchMode::from_wire(&r.dispatch_mode).unwrap_or(DispatchMode::Sync),
|
||||
enabled: r.enabled,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const COLS: &str = "id, group_id, name, script_name, method, host_kind, host, \
|
||||
host_param_name, path_kind, path, dispatch_mode, enabled";
|
||||
|
||||
const fn host_kind_str(k: HostKind) -> &'static str {
|
||||
match k {
|
||||
HostKind::Any => "any",
|
||||
HostKind::Strict => "strict",
|
||||
HostKind::Wildcard => "wildcard",
|
||||
}
|
||||
}
|
||||
|
||||
const fn path_kind_str(k: PathKind) -> &'static str {
|
||||
match k {
|
||||
PathKind::Exact => "exact",
|
||||
PathKind::Prefix => "prefix",
|
||||
PathKind::Param => "param",
|
||||
}
|
||||
}
|
||||
|
||||
/// A group's OWN route templates (read/diff path).
|
||||
pub(crate) async fn list_for_group(
|
||||
pool: &PgPool,
|
||||
group_id: GroupId,
|
||||
) -> Result<Vec<RouteTemplate>, Error> {
|
||||
let rows = sqlx::query_as::<_, RouteTemplateRow>(&format!(
|
||||
"SELECT {COLS} FROM route_templates WHERE group_id = $1 ORDER BY LOWER(name)"
|
||||
))
|
||||
.bind(group_id.into_inner())
|
||||
.fetch_all(pool)
|
||||
.await?;
|
||||
Ok(rows.into_iter().map(Into::into).collect())
|
||||
}
|
||||
|
||||
/// Every route template owned by any group in `group_ids` (expansion path,
|
||||
/// in-tx so it sees templates just reconciled earlier in this apply).
|
||||
pub(crate) async fn list_for_groups_tx(
|
||||
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
|
||||
group_ids: &[GroupId],
|
||||
) -> Result<Vec<RouteTemplate>, Error> {
|
||||
if group_ids.is_empty() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
let ids: Vec<Uuid> = group_ids.iter().map(|g| g.into_inner()).collect();
|
||||
let rows = sqlx::query_as::<_, RouteTemplateRow>(&format!(
|
||||
"SELECT {COLS} FROM route_templates WHERE group_id = ANY($1) ORDER BY LOWER(name)"
|
||||
))
|
||||
.bind(&ids)
|
||||
.fetch_all(&mut **tx)
|
||||
.await?;
|
||||
Ok(rows.into_iter().map(Into::into).collect())
|
||||
}
|
||||
|
||||
/// Insert a new route template (diff Op::Create).
|
||||
pub(crate) async fn insert_tx(
|
||||
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
|
||||
group_id: GroupId,
|
||||
spec: &RouteTemplateSpec,
|
||||
) -> Result<(), Error> {
|
||||
let r = &spec.route;
|
||||
sqlx::query(
|
||||
"INSERT INTO route_templates ( \
|
||||
group_id, name, script_name, method, host_kind, host, \
|
||||
host_param_name, path_kind, path, dispatch_mode, enabled \
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)",
|
||||
)
|
||||
.bind(group_id.into_inner())
|
||||
.bind(&spec.name)
|
||||
.bind(&r.script)
|
||||
.bind(r.method.as_deref())
|
||||
.bind(host_kind_str(r.host_kind))
|
||||
.bind(&r.host)
|
||||
.bind(r.host_param_name.as_deref())
|
||||
.bind(path_kind_str(r.path_kind))
|
||||
.bind(&r.path)
|
||||
.bind(r.dispatch_mode.as_str())
|
||||
.bind(r.enabled)
|
||||
.execute(&mut **tx)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Replace an existing route template's fields, keyed by `(group, lower(name))`
|
||||
/// (diff Op::Update).
|
||||
pub(crate) async fn update_tx(
|
||||
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
|
||||
group_id: GroupId,
|
||||
spec: &RouteTemplateSpec,
|
||||
) -> Result<(), Error> {
|
||||
let r = &spec.route;
|
||||
sqlx::query(
|
||||
"UPDATE route_templates SET \
|
||||
script_name = $3, method = $4, host_kind = $5, host = $6, \
|
||||
host_param_name = $7, path_kind = $8, path = $9, dispatch_mode = $10, \
|
||||
enabled = $11, updated_at = NOW() \
|
||||
WHERE group_id = $1 AND LOWER(name) = LOWER($2)",
|
||||
)
|
||||
.bind(group_id.into_inner())
|
||||
.bind(&spec.name)
|
||||
.bind(&r.script)
|
||||
.bind(r.method.as_deref())
|
||||
.bind(host_kind_str(r.host_kind))
|
||||
.bind(&r.host)
|
||||
.bind(r.host_param_name.as_deref())
|
||||
.bind(path_kind_str(r.path_kind))
|
||||
.bind(&r.path)
|
||||
.bind(r.dispatch_mode.as_str())
|
||||
.bind(r.enabled)
|
||||
.execute(&mut **tx)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Delete a route template by name (diff Op::Delete under `--prune`).
|
||||
pub(crate) async fn delete_tx(
|
||||
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
|
||||
group_id: GroupId,
|
||||
name: &str,
|
||||
) -> Result<(), Error> {
|
||||
sqlx::query("DELETE FROM route_templates WHERE group_id = $1 AND LOWER(name) = LOWER($2)")
|
||||
.bind(group_id.into_inner())
|
||||
.bind(name)
|
||||
.execute(&mut **tx)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// Trigger templates (§4.5, M4b). The kind-specific params vary, so the whole
|
||||
// `BundleTrigger` wire object is stored as `spec` JSONB (placeholders
|
||||
// unresolved); the apply rebuilds + resolves it per descendant.
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
/// A persisted trigger template: name + the raw (unresolved) `BundleTrigger`
|
||||
/// wire object.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct TriggerTemplate {
|
||||
pub id: Uuid,
|
||||
pub group_id: GroupId,
|
||||
pub name: String,
|
||||
pub spec: serde_json::Value,
|
||||
}
|
||||
|
||||
#[derive(sqlx::FromRow)]
|
||||
struct TriggerTemplateRow {
|
||||
id: Uuid,
|
||||
group_id: Uuid,
|
||||
name: String,
|
||||
spec: serde_json::Value,
|
||||
}
|
||||
|
||||
impl From<TriggerTemplateRow> for TriggerTemplate {
|
||||
fn from(r: TriggerTemplateRow) -> Self {
|
||||
Self {
|
||||
id: r.id,
|
||||
group_id: r.group_id.into(),
|
||||
name: r.name,
|
||||
spec: r.spec,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A group's OWN trigger templates (read/diff path).
|
||||
pub(crate) async fn list_triggers_for_group(
|
||||
pool: &PgPool,
|
||||
group_id: GroupId,
|
||||
) -> Result<Vec<TriggerTemplate>, Error> {
|
||||
let rows = sqlx::query_as::<_, TriggerTemplateRow>(
|
||||
"SELECT id, group_id, name, spec FROM trigger_templates \
|
||||
WHERE group_id = $1 ORDER BY LOWER(name)",
|
||||
)
|
||||
.bind(group_id.into_inner())
|
||||
.fetch_all(pool)
|
||||
.await?;
|
||||
Ok(rows.into_iter().map(Into::into).collect())
|
||||
}
|
||||
|
||||
/// Every trigger template owned by any group in `group_ids` (expansion path).
|
||||
pub(crate) async fn list_triggers_for_groups_tx(
|
||||
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
|
||||
group_ids: &[GroupId],
|
||||
) -> Result<Vec<TriggerTemplate>, Error> {
|
||||
if group_ids.is_empty() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
let ids: Vec<Uuid> = group_ids.iter().map(|g| g.into_inner()).collect();
|
||||
let rows = sqlx::query_as::<_, TriggerTemplateRow>(
|
||||
"SELECT id, group_id, name, spec FROM trigger_templates \
|
||||
WHERE group_id = ANY($1) ORDER BY LOWER(name)",
|
||||
)
|
||||
.bind(&ids)
|
||||
.fetch_all(&mut **tx)
|
||||
.await?;
|
||||
Ok(rows.into_iter().map(Into::into).collect())
|
||||
}
|
||||
|
||||
/// Insert a new trigger template (diff Op::Create).
|
||||
pub(crate) async fn insert_trigger_tx(
|
||||
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
|
||||
group_id: GroupId,
|
||||
name: &str,
|
||||
spec: &serde_json::Value,
|
||||
) -> Result<(), Error> {
|
||||
sqlx::query("INSERT INTO trigger_templates (group_id, name, spec) VALUES ($1, $2, $3)")
|
||||
.bind(group_id.into_inner())
|
||||
.bind(name)
|
||||
.bind(spec)
|
||||
.execute(&mut **tx)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Replace a trigger template's spec, keyed by `(group, lower(name))` (Update).
|
||||
pub(crate) async fn update_trigger_tx(
|
||||
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
|
||||
group_id: GroupId,
|
||||
name: &str,
|
||||
spec: &serde_json::Value,
|
||||
) -> Result<(), Error> {
|
||||
sqlx::query(
|
||||
"UPDATE trigger_templates SET spec = $3, updated_at = NOW() \
|
||||
WHERE group_id = $1 AND LOWER(name) = LOWER($2)",
|
||||
)
|
||||
.bind(group_id.into_inner())
|
||||
.bind(name)
|
||||
.bind(spec)
|
||||
.execute(&mut **tx)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Delete a trigger template by name (Delete under `--prune`).
|
||||
pub(crate) async fn delete_trigger_tx(
|
||||
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
|
||||
group_id: GroupId,
|
||||
name: &str,
|
||||
) -> Result<(), Error> {
|
||||
sqlx::query("DELETE FROM trigger_templates WHERE group_id = $1 AND LOWER(name) = LOWER($2)")
|
||||
.bind(group_id.into_inner())
|
||||
.bind(name)
|
||||
.execute(&mut **tx)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -7,35 +7,14 @@ use async_trait::async_trait;
|
||||
use chrono::{DateTime, Utc};
|
||||
use futures::stream::{self, TryStreamExt};
|
||||
use picloud_shared::{
|
||||
AdminUserId, AppId, DocsEventOp, FilesEventOp, GroupId, KvEventOp, ScriptId, ScriptOwner,
|
||||
TriggerId,
|
||||
AdminUserId, AppId, DocsEventOp, FilesEventOp, KvEventOp, ScriptId, TriggerId,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sqlx::PgPool;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::config_resolver::CHAIN_LEVELS_CTE;
|
||||
use crate::trigger_config::BackoffShape;
|
||||
|
||||
/// §11 tail opt-out: exclude an INHERITED (group-owned) trigger whose handler
|
||||
/// script name a suppression on the firing app's chain declines. Appended to
|
||||
/// each dispatch match query's WHERE clause; `$1` is the firing app_id (already
|
||||
/// bound by the `CHAIN_LEVELS_CTE`). §11 tail M1: the anti-join joins the
|
||||
/// `chain` CTE, so a suppression owned by the firing app OR any ANCESTOR GROUP
|
||||
/// on its chain applies — a group declines a template for its whole subtree.
|
||||
/// The `t.group_id IS NOT NULL` guard keeps an app's OWN trigger unsuppressable
|
||||
/// (an owner can only decline what it inherits); `t.sealed = FALSE` keeps a
|
||||
/// mandatory template non-declinable.
|
||||
pub(crate) const TRIGGER_SUPPRESSION_ANTIJOIN: &str = " \
|
||||
AND NOT EXISTS ( \
|
||||
SELECT 1 FROM template_suppressions ts \
|
||||
JOIN scripts s ON s.id = t.script_id \
|
||||
JOIN chain sc ON (ts.app_id = sc.app_owner OR ts.group_id = sc.group_owner) \
|
||||
WHERE ts.target_kind = 'trigger' \
|
||||
AND LOWER(ts.reference) = LOWER(s.name) \
|
||||
AND t.group_id IS NOT NULL \
|
||||
AND t.sealed = FALSE)";
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum TriggerRepoError {
|
||||
#[error("database error: {0}")]
|
||||
@@ -50,35 +29,15 @@ pub enum TriggerRepoError {
|
||||
|
||||
/// Parent-table row plus the per-kind detail merged in. Serialized
|
||||
/// back to admin clients via the JSON API.
|
||||
// enabled + sealed + shared + materialized are distinct trigger facets, not a
|
||||
// bit-packed config — a struct is the right shape.
|
||||
#[allow(clippy::struct_excessive_bools)]
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Trigger {
|
||||
pub id: TriggerId,
|
||||
/// Polymorphic owner (§11 tail): an app owns a trigger; a group owns a
|
||||
/// trigger TEMPLATE. Exactly one is set (DB CHECK). Mirrors `Script`.
|
||||
pub app_id: Option<AppId>,
|
||||
pub group_id: Option<GroupId>,
|
||||
pub app_id: AppId,
|
||||
pub script_id: ScriptId,
|
||||
/// §4.5 per-app trigger identifier; the manifest merge/upsert key.
|
||||
pub name: String,
|
||||
pub kind: TriggerKind,
|
||||
pub enabled: bool,
|
||||
/// §11 tail: `true` for a sealed (non-suppressible) group trigger template.
|
||||
/// Always `false` for an app-owned trigger. Part of the apply diff identity
|
||||
/// so toggling it re-materializes the row.
|
||||
#[serde(default)]
|
||||
pub sealed: bool,
|
||||
/// §11.6: `true` for a shared-collection group trigger (watches a shared
|
||||
/// collection, not per-app ones). Also part of the apply diff identity.
|
||||
#[serde(default)]
|
||||
pub shared: bool,
|
||||
/// §4.5 M5: `true` for a materialized copy of a group stateful template
|
||||
/// (`materialized_from` is set). Read-only — inherited, not app-authored.
|
||||
/// Derived, not stored directly (see `TriggerRow::materialized_from`).
|
||||
#[serde(default)]
|
||||
pub materialized: bool,
|
||||
pub dispatch_mode: TriggerDispatchMode,
|
||||
pub retry_max_attempts: u32,
|
||||
pub retry_backoff: BackoffShape,
|
||||
@@ -337,11 +296,6 @@ pub struct ActiveQueueConsumer {
|
||||
pub retry_backoff: BackoffShape,
|
||||
pub retry_base_ms: u32,
|
||||
pub registered_by_principal: AdminUserId,
|
||||
/// §11.6 D3: `Some(group)` when this consumer is a materialized copy of a
|
||||
/// SHARED group queue template — it drains `group_queue_messages(group,
|
||||
/// queue_name)` (competing consumers) instead of the per-app store. `None`
|
||||
/// for an ordinary per-app or non-shared-template consumer.
|
||||
pub shared_group: Option<GroupId>,
|
||||
}
|
||||
|
||||
/// What the inbound-email webhook receiver needs to verify + dispatch a
|
||||
@@ -460,13 +414,6 @@ pub trait TriggerRepo: Send + Sync {
|
||||
|
||||
async fn list_for_app(&self, app_id: AppId) -> Result<Vec<Trigger>, TriggerRepoError>;
|
||||
|
||||
/// Group-owned trigger TEMPLATES (§11 tail) declared directly at `group_id`.
|
||||
/// Used by the declarative apply diff for a `[group]` node. Defaults to
|
||||
/// empty so non-Postgres impls (tests) need not provide it.
|
||||
async fn list_for_group(&self, _group_id: GroupId) -> Result<Vec<Trigger>, TriggerRepoError> {
|
||||
Ok(Vec::new())
|
||||
}
|
||||
|
||||
async fn get(&self, id: TriggerId) -> Result<Option<Trigger>, TriggerRepoError>;
|
||||
|
||||
async fn delete(&self, id: TriggerId) -> Result<bool, TriggerRepoError>;
|
||||
@@ -502,39 +449,6 @@ pub trait TriggerRepo: Send + Sync {
|
||||
op: FilesEventOp,
|
||||
) -> Result<Vec<FilesTriggerMatch>, TriggerRepoError>;
|
||||
|
||||
/// §11.6 shared-collection fan-out: enabled `shared = true` triggers on the
|
||||
/// OWNING group (the group that declares the shared collection), glob-matched
|
||||
/// in Rust. The dispatch runs under the writing app; these methods key on the
|
||||
/// owning group, not the writer's chain. Default empty so non-Postgres impls
|
||||
/// degrade to "no shared triggers".
|
||||
async fn list_matching_shared_kv(
|
||||
&self,
|
||||
owning_group: GroupId,
|
||||
collection: &str,
|
||||
op: KvEventOp,
|
||||
) -> Result<Vec<KvTriggerMatch>, TriggerRepoError> {
|
||||
let _ = (owning_group, collection, op);
|
||||
Ok(Vec::new())
|
||||
}
|
||||
async fn list_matching_shared_docs(
|
||||
&self,
|
||||
owning_group: GroupId,
|
||||
collection: &str,
|
||||
op: DocsEventOp,
|
||||
) -> Result<Vec<DocsTriggerMatch>, TriggerRepoError> {
|
||||
let _ = (owning_group, collection, op);
|
||||
Ok(Vec::new())
|
||||
}
|
||||
async fn list_matching_shared_files(
|
||||
&self,
|
||||
owning_group: GroupId,
|
||||
collection: &str,
|
||||
op: FilesEventOp,
|
||||
) -> Result<Vec<FilesTriggerMatch>, TriggerRepoError> {
|
||||
let _ = (owning_group, collection, op);
|
||||
Ok(Vec::new())
|
||||
}
|
||||
|
||||
/// Dispatcher hot path for dead-letter fan-out. Filters: source
|
||||
/// (or any-source), originating trigger_id (or any), originating
|
||||
/// script_id (or any). Each filter is "match OR is_null".
|
||||
@@ -588,39 +502,6 @@ impl PostgresTriggerRepo {
|
||||
pub fn new(pool: PgPool) -> Self {
|
||||
Self { pool }
|
||||
}
|
||||
|
||||
/// §11.6 shared-collection match: enabled `shared = true` triggers on the
|
||||
/// OWNING group, glob + op filtered in Rust (like the per-app path). `kind`
|
||||
/// and `detail_table` are hard-coded literals from the three call sites (no
|
||||
/// injection). No chain / no suppression anti-join — a shared trigger is
|
||||
/// declared on the group that owns the collection and fires under the writer.
|
||||
async fn shared_match_rows(
|
||||
&self,
|
||||
kind: &str,
|
||||
detail_table: &str,
|
||||
owning_group: GroupId,
|
||||
collection: &str,
|
||||
op_str: &str,
|
||||
) -> Result<Vec<KvMatchRow>, TriggerRepoError> {
|
||||
let rows: Vec<KvMatchRow> = sqlx::query_as(&format!(
|
||||
"SELECT t.id, t.script_id, t.dispatch_mode, \
|
||||
t.retry_max_attempts, t.retry_backoff, t.retry_base_ms, \
|
||||
t.registered_by_principal, \
|
||||
d.collection_glob, d.ops \
|
||||
FROM triggers t \
|
||||
JOIN {detail_table} d ON d.trigger_id = t.id \
|
||||
WHERE t.kind = '{kind}' AND t.enabled = TRUE \
|
||||
AND t.shared = TRUE AND t.group_id = $1"
|
||||
))
|
||||
.bind(owning_group.into_inner())
|
||||
.fetch_all(&self.pool)
|
||||
.await?;
|
||||
Ok(rows
|
||||
.into_iter()
|
||||
.filter(|r| collection_matches(&r.collection_glob, collection))
|
||||
.filter(|r| r.ops.is_empty() || r.ops.iter().any(|o| o == op_str))
|
||||
.collect())
|
||||
}
|
||||
}
|
||||
|
||||
/// Insert a trigger (parent row + per-kind detail) within an existing
|
||||
@@ -630,20 +511,15 @@ impl PostgresTriggerRepo {
|
||||
#[allow(clippy::too_many_arguments, clippy::too_many_lines)]
|
||||
pub(crate) async fn insert_trigger_tx(
|
||||
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
|
||||
owner: ScriptOwner,
|
||||
app_id: AppId,
|
||||
script_id: ScriptId,
|
||||
registered_by: AdminUserId,
|
||||
dispatch_mode: TriggerDispatchMode,
|
||||
retry_max_attempts: u32,
|
||||
retry_backoff: BackoffShape,
|
||||
retry_base_ms: u32,
|
||||
// §11 tail: `true` for a sealed (non-suppressible) group trigger template.
|
||||
// Always `false` for an app-owned trigger.
|
||||
sealed: bool,
|
||||
// §11.6: `true` for a shared-collection group trigger (watches a shared
|
||||
// collection, not per-app ones). Always `false` for an app-owned trigger.
|
||||
shared: bool,
|
||||
details: &TriggerDetails,
|
||||
from_template: Option<Uuid>,
|
||||
) -> Result<TriggerId, TriggerRepoError> {
|
||||
let kind = match details {
|
||||
TriggerDetails::Kv { .. } => "kv",
|
||||
@@ -658,20 +534,12 @@ pub(crate) async fn insert_trigger_tx(
|
||||
));
|
||||
}
|
||||
};
|
||||
// §11 tail: a trigger is owned by an app XOR a group (the latter a
|
||||
// template, unioned into descendant apps via the chain CTE at dispatch).
|
||||
let (owner_app_id, owner_group_id) = match owner {
|
||||
ScriptOwner::App(a) => (Some(a.into_inner()), None),
|
||||
ScriptOwner::Group(g) => (None, Some(g.into_inner())),
|
||||
};
|
||||
// Queue: enforce the one-consumer-per-(app_id, queue_name) invariant —
|
||||
// the same advisory-lock + existence guard the interactive
|
||||
// `create_queue_trigger` uses. Without this, a concurrent apply +
|
||||
// interactive create on disjoint locks could double-register a queue
|
||||
// consumer (there is no DB unique constraint backing the invariant).
|
||||
// Queue is app-only (rejected on a group), so this never runs for a
|
||||
// group owner.
|
||||
if let (TriggerDetails::Queue { queue_name, .. }, ScriptOwner::App(app_id)) = (details, owner) {
|
||||
if let TriggerDetails::Queue { queue_name, .. } = details {
|
||||
sqlx::query("SELECT pg_advisory_xact_lock($1)")
|
||||
.bind(advisory_lock_key(app_id, queue_name))
|
||||
.execute(&mut **tx)
|
||||
@@ -693,13 +561,12 @@ pub(crate) async fn insert_trigger_tx(
|
||||
}
|
||||
let row: (Uuid,) = sqlx::query_as(
|
||||
"INSERT INTO triggers ( \
|
||||
app_id, group_id, script_id, kind, enabled, dispatch_mode, \
|
||||
app_id, script_id, kind, enabled, dispatch_mode, \
|
||||
retry_max_attempts, retry_backoff, retry_base_ms, \
|
||||
registered_by_principal, sealed, shared \
|
||||
) VALUES ($1, $2, $3, $4, TRUE, $5, $6, $7, $8, $9, $10, $11) RETURNING id",
|
||||
registered_by_principal, from_template \
|
||||
) VALUES ($1, $2, $3, TRUE, $4, $5, $6, $7, $8, $9) RETURNING id",
|
||||
)
|
||||
.bind(owner_app_id)
|
||||
.bind(owner_group_id)
|
||||
.bind(app_id.into_inner())
|
||||
.bind(script_id.into_inner())
|
||||
.bind(kind)
|
||||
.bind(dispatch_mode.as_str())
|
||||
@@ -707,8 +574,7 @@ pub(crate) async fn insert_trigger_tx(
|
||||
.bind(retry_backoff.as_str())
|
||||
.bind(i32::try_from(retry_base_ms).unwrap_or(1000))
|
||||
.bind(registered_by.into_inner())
|
||||
.bind(sealed)
|
||||
.bind(shared)
|
||||
.bind(from_template)
|
||||
.fetch_one(&mut **tx)
|
||||
.await?;
|
||||
let tid = row.0;
|
||||
@@ -805,37 +671,29 @@ pub(crate) async fn insert_trigger_tx(
|
||||
}
|
||||
|
||||
/// Insert an email trigger within a transaction. The inbound HMAC secret
|
||||
/// is sealed by the apply engine (resolved from the owner's secret store);
|
||||
/// is sealed by the apply engine (resolved from the app's secret store);
|
||||
/// this writes the ciphertext. Parent retry settings match the
|
||||
/// interactive `create_email_trigger` path (async, 3, exponential, 1000).
|
||||
///
|
||||
/// §4.5 M5: `owner` is app-XOR-group. A `ScriptOwner::Group` writes an email
|
||||
/// trigger TEMPLATE (group_id set, app_id NULL) that is never dispatched
|
||||
/// directly (`email_inbound_target` filters `app_id IS NOT NULL`) — the
|
||||
/// materializer copies it into a per-descendant-app row.
|
||||
pub(crate) async fn insert_email_trigger_tx(
|
||||
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
|
||||
owner: ScriptOwner,
|
||||
app_id: AppId,
|
||||
script_id: ScriptId,
|
||||
registered_by: AdminUserId,
|
||||
inbound_secret_encrypted: &[u8],
|
||||
inbound_secret_nonce: &[u8],
|
||||
from_template: Option<Uuid>,
|
||||
) -> Result<TriggerId, TriggerRepoError> {
|
||||
let (owner_app_id, owner_group_id) = match owner {
|
||||
ScriptOwner::App(a) => (Some(a.into_inner()), None),
|
||||
ScriptOwner::Group(g) => (None, Some(g.into_inner())),
|
||||
};
|
||||
let row: (Uuid,) = sqlx::query_as(
|
||||
"INSERT INTO triggers ( \
|
||||
app_id, group_id, script_id, kind, enabled, dispatch_mode, \
|
||||
app_id, script_id, kind, enabled, dispatch_mode, \
|
||||
retry_max_attempts, retry_backoff, retry_base_ms, \
|
||||
registered_by_principal \
|
||||
) VALUES ($1, $2, $3, 'email', TRUE, 'async', 3, 'exponential', 1000, $4) RETURNING id",
|
||||
registered_by_principal, from_template \
|
||||
) VALUES ($1, $2, 'email', TRUE, 'async', 3, 'exponential', 1000, $3, $4) RETURNING id",
|
||||
)
|
||||
.bind(owner_app_id)
|
||||
.bind(owner_group_id)
|
||||
.bind(app_id.into_inner())
|
||||
.bind(script_id.into_inner())
|
||||
.bind(registered_by.into_inner())
|
||||
.bind(from_template)
|
||||
.fetch_one(&mut **tx)
|
||||
.await?;
|
||||
sqlx::query(
|
||||
@@ -912,15 +770,11 @@ impl TriggerRepo for PostgresTriggerRepo {
|
||||
|
||||
Ok(Trigger {
|
||||
id: parent.id.into(),
|
||||
app_id: parent.app_id.map(Into::into),
|
||||
group_id: parent.group_id.map(Into::into),
|
||||
app_id: parent.app_id.into(),
|
||||
script_id: parent.script_id.into(),
|
||||
name: parent.name.clone(),
|
||||
kind: TriggerKind::Kv,
|
||||
enabled: parent.enabled,
|
||||
sealed: parent.sealed,
|
||||
shared: parent.shared,
|
||||
materialized: parent.materialized_from.is_some(),
|
||||
dispatch_mode: dispatch_from_str(&parent.dispatch_mode),
|
||||
retry_max_attempts: u32::try_from(parent.retry_max_attempts).unwrap_or(3),
|
||||
retry_backoff: BackoffShape::from_wire(&parent.retry_backoff)
|
||||
@@ -982,15 +836,11 @@ impl TriggerRepo for PostgresTriggerRepo {
|
||||
|
||||
Ok(Trigger {
|
||||
id: parent.id.into(),
|
||||
app_id: parent.app_id.map(Into::into),
|
||||
group_id: parent.group_id.map(Into::into),
|
||||
app_id: parent.app_id.into(),
|
||||
script_id: parent.script_id.into(),
|
||||
name: parent.name.clone(),
|
||||
kind: TriggerKind::Docs,
|
||||
enabled: parent.enabled,
|
||||
sealed: parent.sealed,
|
||||
shared: parent.shared,
|
||||
materialized: parent.materialized_from.is_some(),
|
||||
dispatch_mode: dispatch_from_str(&parent.dispatch_mode),
|
||||
retry_max_attempts: u32::try_from(parent.retry_max_attempts).unwrap_or(3),
|
||||
retry_backoff: BackoffShape::from_wire(&parent.retry_backoff)
|
||||
@@ -1047,15 +897,11 @@ impl TriggerRepo for PostgresTriggerRepo {
|
||||
|
||||
Ok(Trigger {
|
||||
id: parent.id.into(),
|
||||
app_id: parent.app_id.map(Into::into),
|
||||
group_id: parent.group_id.map(Into::into),
|
||||
app_id: parent.app_id.into(),
|
||||
script_id: parent.script_id.into(),
|
||||
name: parent.name.clone(),
|
||||
kind: TriggerKind::DeadLetter,
|
||||
enabled: parent.enabled,
|
||||
sealed: parent.sealed,
|
||||
shared: parent.shared,
|
||||
materialized: parent.materialized_from.is_some(),
|
||||
dispatch_mode: dispatch_from_str(&parent.dispatch_mode),
|
||||
retry_max_attempts: u32::try_from(parent.retry_max_attempts).unwrap_or(1),
|
||||
retry_backoff: BackoffShape::from_wire(&parent.retry_backoff)
|
||||
@@ -1118,15 +964,11 @@ impl TriggerRepo for PostgresTriggerRepo {
|
||||
|
||||
Ok(Trigger {
|
||||
id: parent.id.into(),
|
||||
app_id: parent.app_id.map(Into::into),
|
||||
group_id: parent.group_id.map(Into::into),
|
||||
app_id: parent.app_id.into(),
|
||||
script_id: parent.script_id.into(),
|
||||
name: parent.name.clone(),
|
||||
kind: TriggerKind::Cron,
|
||||
enabled: parent.enabled,
|
||||
sealed: parent.sealed,
|
||||
shared: parent.shared,
|
||||
materialized: parent.materialized_from.is_some(),
|
||||
dispatch_mode: dispatch_from_str(&parent.dispatch_mode),
|
||||
retry_max_attempts: u32::try_from(parent.retry_max_attempts).unwrap_or(3),
|
||||
retry_backoff: BackoffShape::from_wire(&parent.retry_backoff)
|
||||
@@ -1189,15 +1031,11 @@ impl TriggerRepo for PostgresTriggerRepo {
|
||||
|
||||
Ok(Trigger {
|
||||
id: parent.id.into(),
|
||||
app_id: parent.app_id.map(Into::into),
|
||||
group_id: parent.group_id.map(Into::into),
|
||||
app_id: parent.app_id.into(),
|
||||
script_id: parent.script_id.into(),
|
||||
name: parent.name.clone(),
|
||||
kind: TriggerKind::Files,
|
||||
enabled: parent.enabled,
|
||||
sealed: parent.sealed,
|
||||
shared: parent.shared,
|
||||
materialized: parent.materialized_from.is_some(),
|
||||
dispatch_mode: dispatch_from_str(&parent.dispatch_mode),
|
||||
retry_max_attempts: u32::try_from(parent.retry_max_attempts).unwrap_or(3),
|
||||
retry_backoff: BackoffShape::from_wire(&parent.retry_backoff)
|
||||
@@ -1255,15 +1093,11 @@ impl TriggerRepo for PostgresTriggerRepo {
|
||||
|
||||
Ok(Trigger {
|
||||
id: parent.id.into(),
|
||||
app_id: parent.app_id.map(Into::into),
|
||||
group_id: parent.group_id.map(Into::into),
|
||||
app_id: parent.app_id.into(),
|
||||
script_id: parent.script_id.into(),
|
||||
name: parent.name.clone(),
|
||||
kind: TriggerKind::Pubsub,
|
||||
enabled: parent.enabled,
|
||||
sealed: parent.sealed,
|
||||
shared: parent.shared,
|
||||
materialized: parent.materialized_from.is_some(),
|
||||
dispatch_mode: dispatch_from_str(&parent.dispatch_mode),
|
||||
retry_max_attempts: u32::try_from(parent.retry_max_attempts).unwrap_or(3),
|
||||
retry_backoff: BackoffShape::from_wire(&parent.retry_backoff)
|
||||
@@ -1319,15 +1153,11 @@ impl TriggerRepo for PostgresTriggerRepo {
|
||||
|
||||
Ok(Trigger {
|
||||
id: parent.id.into(),
|
||||
app_id: parent.app_id.map(Into::into),
|
||||
group_id: parent.group_id.map(Into::into),
|
||||
app_id: parent.app_id.into(),
|
||||
script_id: parent.script_id.into(),
|
||||
name: parent.name.clone(),
|
||||
kind: TriggerKind::Email,
|
||||
enabled: parent.enabled,
|
||||
sealed: parent.sealed,
|
||||
shared: parent.shared,
|
||||
materialized: parent.materialized_from.is_some(),
|
||||
dispatch_mode: dispatch_from_str(&parent.dispatch_mode),
|
||||
retry_max_attempts: u32::try_from(parent.retry_max_attempts).unwrap_or(3),
|
||||
retry_backoff: BackoffShape::from_wire(&parent.retry_backoff)
|
||||
@@ -1345,16 +1175,12 @@ impl TriggerRepo for PostgresTriggerRepo {
|
||||
trigger_id: TriggerId,
|
||||
) -> Result<Option<EmailInboundTarget>, TriggerRepoError> {
|
||||
let row: Option<EmailInboundRow> = sqlx::query_as(
|
||||
// §4.5 M5: `t.app_id IS NOT NULL` excludes a group email TEMPLATE
|
||||
// row — only its materialized per-app copies are directly invocable
|
||||
// (mirrors the cron-scheduler / queue-consumer guards). A template
|
||||
// has no app to run under, so hitting its webhook URL must 404.
|
||||
"SELECT t.app_id, t.script_id, t.enabled, t.dispatch_mode, \
|
||||
t.registered_by_principal, \
|
||||
d.inbound_secret_encrypted, d.inbound_secret_nonce \
|
||||
FROM triggers t \
|
||||
JOIN email_trigger_details d ON d.trigger_id = t.id \
|
||||
WHERE t.id = $1 AND t.kind = 'email' AND t.app_id IS NOT NULL",
|
||||
WHERE t.id = $1 AND t.kind = 'email'",
|
||||
)
|
||||
.bind(trigger_id.into_inner())
|
||||
.fetch_optional(&self.pool)
|
||||
@@ -1372,8 +1198,8 @@ impl TriggerRepo for PostgresTriggerRepo {
|
||||
|
||||
async fn list_for_app(&self, app_id: AppId) -> Result<Vec<Trigger>, TriggerRepoError> {
|
||||
let parents: Vec<TriggerRow> = sqlx::query_as(
|
||||
"SELECT id, app_id, script_id, name, kind, enabled, sealed, shared, materialized_from, \
|
||||
dispatch_mode, retry_max_attempts, retry_backoff, retry_base_ms, \
|
||||
"SELECT id, app_id, script_id, name, kind, enabled, dispatch_mode, \
|
||||
retry_max_attempts, retry_backoff, retry_base_ms, \
|
||||
registered_by_principal, created_at, updated_at \
|
||||
FROM triggers WHERE app_id = $1 ORDER BY created_at DESC",
|
||||
)
|
||||
@@ -1398,35 +1224,9 @@ impl TriggerRepo for PostgresTriggerRepo {
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
async fn list_for_group(&self, group_id: GroupId) -> Result<Vec<Trigger>, TriggerRepoError> {
|
||||
let parents: Vec<TriggerRow> = sqlx::query_as(
|
||||
"SELECT id, app_id, group_id, script_id, name, kind, enabled, sealed, shared, dispatch_mode, \
|
||||
retry_max_attempts, retry_backoff, retry_base_ms, \
|
||||
registered_by_principal, created_at, updated_at \
|
||||
FROM triggers WHERE group_id = $1 ORDER BY created_at DESC",
|
||||
)
|
||||
.bind(group_id.into_inner())
|
||||
.fetch_all(&self.pool)
|
||||
.await?;
|
||||
let pool = self.pool.clone();
|
||||
let out = stream::iter(parents.into_iter().map(Ok))
|
||||
.map_ok(|p| {
|
||||
let pool = pool.clone();
|
||||
async move { hydrate_one(&pool, p).await }
|
||||
})
|
||||
.try_buffered(8)
|
||||
.try_collect::<Vec<_>>()
|
||||
.await?;
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
async fn get(&self, id: TriggerId) -> Result<Option<Trigger>, TriggerRepoError> {
|
||||
// §11 tail: `get` can fetch ANY trigger by id, including a group-owned
|
||||
// TEMPLATE, so it must SELECT `group_id` (unlike `list_for_app`, whose
|
||||
// rows are all app-owned) — else a template would hydrate with both
|
||||
// owners None, breaking the exactly-one invariant in memory.
|
||||
let parent: Option<TriggerRow> = sqlx::query_as(
|
||||
"SELECT id, app_id, group_id, script_id, name, kind, enabled, sealed, shared, dispatch_mode, \
|
||||
"SELECT id, app_id, script_id, name, kind, enabled, dispatch_mode, \
|
||||
retry_max_attempts, retry_backoff, retry_base_ms, \
|
||||
registered_by_principal, created_at, updated_at \
|
||||
FROM triggers WHERE id = $1",
|
||||
@@ -1459,18 +1259,15 @@ impl TriggerRepo for PostgresTriggerRepo {
|
||||
// happens in Rust so we don't have to teach the query about
|
||||
// `*` and `prefix:*`. Sets are tiny in practice (one app's
|
||||
// worth of triggers, usually a handful).
|
||||
let rows: Vec<KvMatchRow> = sqlx::query_as(&format!(
|
||||
"{CHAIN_LEVELS_CTE} \
|
||||
SELECT t.id, t.script_id, t.dispatch_mode, \
|
||||
let rows: Vec<KvMatchRow> = sqlx::query_as(
|
||||
"SELECT t.id, t.script_id, t.dispatch_mode, \
|
||||
t.retry_max_attempts, t.retry_backoff, t.retry_base_ms, \
|
||||
t.registered_by_principal, \
|
||||
d.collection_glob, d.ops \
|
||||
FROM triggers t \
|
||||
JOIN kv_trigger_details d ON d.trigger_id = t.id \
|
||||
JOIN chain c ON (t.app_id = c.app_owner OR t.group_id = c.group_owner) \
|
||||
WHERE t.kind = 'kv' AND t.enabled = TRUE \
|
||||
AND t.shared = FALSE{TRIGGER_SUPPRESSION_ANTIJOIN}"
|
||||
))
|
||||
WHERE t.app_id = $1 AND t.kind = 'kv' AND t.enabled = TRUE",
|
||||
)
|
||||
.bind(app_id.into_inner())
|
||||
.fetch_all(&self.pool)
|
||||
.await?;
|
||||
@@ -1510,18 +1307,15 @@ impl TriggerRepo for PostgresTriggerRepo {
|
||||
// ops check into SQL (`WHERE $op = ANY(ops)`) — that would
|
||||
// exclude rows with `ops = '{}'` from the results, breaking
|
||||
// the empty-array-means-any-op semantic.
|
||||
let rows: Vec<KvMatchRow> = sqlx::query_as(&format!(
|
||||
"{CHAIN_LEVELS_CTE} \
|
||||
SELECT t.id, t.script_id, t.dispatch_mode, \
|
||||
let rows: Vec<KvMatchRow> = sqlx::query_as(
|
||||
"SELECT t.id, t.script_id, t.dispatch_mode, \
|
||||
t.retry_max_attempts, t.retry_backoff, t.retry_base_ms, \
|
||||
t.registered_by_principal, \
|
||||
d.collection_glob, d.ops \
|
||||
FROM triggers t \
|
||||
JOIN docs_trigger_details d ON d.trigger_id = t.id \
|
||||
JOIN chain c ON (t.app_id = c.app_owner OR t.group_id = c.group_owner) \
|
||||
WHERE t.kind = 'docs' AND t.enabled = TRUE \
|
||||
AND t.shared = FALSE{TRIGGER_SUPPRESSION_ANTIJOIN}"
|
||||
))
|
||||
WHERE t.app_id = $1 AND t.kind = 'docs' AND t.enabled = TRUE",
|
||||
)
|
||||
.bind(app_id.into_inner())
|
||||
.fetch_all(&self.pool)
|
||||
.await?;
|
||||
@@ -1558,18 +1352,15 @@ impl TriggerRepo for PostgresTriggerRepo {
|
||||
) -> Result<Vec<FilesTriggerMatch>, TriggerRepoError> {
|
||||
// Mirrors list_matching_kv: pull every enabled files trigger,
|
||||
// filter glob + ops in Rust (empty ops array means "any op").
|
||||
let rows: Vec<KvMatchRow> = sqlx::query_as(&format!(
|
||||
"{CHAIN_LEVELS_CTE} \
|
||||
SELECT t.id, t.script_id, t.dispatch_mode, \
|
||||
let rows: Vec<KvMatchRow> = sqlx::query_as(
|
||||
"SELECT t.id, t.script_id, t.dispatch_mode, \
|
||||
t.retry_max_attempts, t.retry_backoff, t.retry_base_ms, \
|
||||
t.registered_by_principal, \
|
||||
d.collection_glob, d.ops \
|
||||
FROM triggers t \
|
||||
JOIN files_trigger_details d ON d.trigger_id = t.id \
|
||||
JOIN chain c ON (t.app_id = c.app_owner OR t.group_id = c.group_owner) \
|
||||
WHERE t.kind = 'files' AND t.enabled = TRUE \
|
||||
AND t.shared = FALSE{TRIGGER_SUPPRESSION_ANTIJOIN}"
|
||||
))
|
||||
WHERE t.app_id = $1 AND t.kind = 'files' AND t.enabled = TRUE",
|
||||
)
|
||||
.bind(app_id.into_inner())
|
||||
.fetch_all(&self.pool)
|
||||
.await?;
|
||||
@@ -1598,60 +1389,6 @@ impl TriggerRepo for PostgresTriggerRepo {
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
async fn list_matching_shared_kv(
|
||||
&self,
|
||||
owning_group: GroupId,
|
||||
collection: &str,
|
||||
op: KvEventOp,
|
||||
) -> Result<Vec<KvTriggerMatch>, TriggerRepoError> {
|
||||
let rows = self
|
||||
.shared_match_rows(
|
||||
"kv",
|
||||
"kv_trigger_details",
|
||||
owning_group,
|
||||
collection,
|
||||
op.as_str(),
|
||||
)
|
||||
.await?;
|
||||
Ok(rows.into_iter().map(KvMatchRow::into_kv).collect())
|
||||
}
|
||||
|
||||
async fn list_matching_shared_docs(
|
||||
&self,
|
||||
owning_group: GroupId,
|
||||
collection: &str,
|
||||
op: DocsEventOp,
|
||||
) -> Result<Vec<DocsTriggerMatch>, TriggerRepoError> {
|
||||
let rows = self
|
||||
.shared_match_rows(
|
||||
"docs",
|
||||
"docs_trigger_details",
|
||||
owning_group,
|
||||
collection,
|
||||
op.as_str(),
|
||||
)
|
||||
.await?;
|
||||
Ok(rows.into_iter().map(KvMatchRow::into_docs).collect())
|
||||
}
|
||||
|
||||
async fn list_matching_shared_files(
|
||||
&self,
|
||||
owning_group: GroupId,
|
||||
collection: &str,
|
||||
op: FilesEventOp,
|
||||
) -> Result<Vec<FilesTriggerMatch>, TriggerRepoError> {
|
||||
let rows = self
|
||||
.shared_match_rows(
|
||||
"files",
|
||||
"files_trigger_details",
|
||||
owning_group,
|
||||
collection,
|
||||
op.as_str(),
|
||||
)
|
||||
.await?;
|
||||
Ok(rows.into_iter().map(KvMatchRow::into_files).collect())
|
||||
}
|
||||
|
||||
async fn list_matching_dead_letter(
|
||||
&self,
|
||||
app_id: AppId,
|
||||
@@ -1768,15 +1505,11 @@ impl TriggerRepo for PostgresTriggerRepo {
|
||||
|
||||
Ok(Trigger {
|
||||
id: parent.id.into(),
|
||||
app_id: parent.app_id.map(Into::into),
|
||||
group_id: parent.group_id.map(Into::into),
|
||||
app_id: parent.app_id.into(),
|
||||
script_id: parent.script_id.into(),
|
||||
name: parent.name.clone(),
|
||||
kind: TriggerKind::Queue,
|
||||
enabled: parent.enabled,
|
||||
sealed: parent.sealed,
|
||||
shared: parent.shared,
|
||||
materialized: parent.materialized_from.is_some(),
|
||||
dispatch_mode: dispatch_from_str(&parent.dispatch_mode),
|
||||
retry_max_attempts: u32::try_from(parent.retry_max_attempts).unwrap_or(3),
|
||||
retry_backoff: BackoffShape::from_wire(&parent.retry_backoff)
|
||||
@@ -1797,21 +1530,14 @@ impl TriggerRepo for PostgresTriggerRepo {
|
||||
&self,
|
||||
) -> Result<Vec<ActiveQueueConsumer>, TriggerRepoError> {
|
||||
let rows: Vec<QueueConsumerRow> = sqlx::query_as(
|
||||
// §11.6 D3: LEFT JOIN the SOURCE template of a materialized copy; if
|
||||
// that template is a SHARED group queue, `shared_group` is its
|
||||
// owning group and this consumer drains the group store instead of
|
||||
// the per-app one.
|
||||
"SELECT t.id AS trigger_id, t.app_id, t.script_id, d.queue_name, \
|
||||
d.visibility_timeout_secs, \
|
||||
t.retry_max_attempts, t.retry_backoff, t.retry_base_ms, \
|
||||
t.registered_by_principal, tmpl.group_id AS shared_group \
|
||||
t.registered_by_principal \
|
||||
FROM triggers t \
|
||||
JOIN queue_trigger_details d ON d.trigger_id = t.id \
|
||||
JOIN scripts s ON s.id = t.script_id \
|
||||
LEFT JOIN triggers tmpl \
|
||||
ON tmpl.id = t.materialized_from AND tmpl.shared = TRUE \
|
||||
WHERE t.kind = 'queue' AND t.enabled = TRUE AND s.enabled = TRUE \
|
||||
AND t.app_id IS NOT NULL",
|
||||
WHERE t.kind = 'queue' AND t.enabled = TRUE AND s.enabled = TRUE",
|
||||
)
|
||||
.fetch_all(&self.pool)
|
||||
.await?;
|
||||
@@ -1828,7 +1554,6 @@ impl TriggerRepo for PostgresTriggerRepo {
|
||||
.unwrap_or(BackoffShape::Exponential),
|
||||
retry_base_ms: u32::try_from(r.retry_base_ms).unwrap_or(1000),
|
||||
registered_by_principal: r.registered_by_principal.into(),
|
||||
shared_group: r.shared_group.map(Into::into),
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
@@ -1988,15 +1713,11 @@ async fn hydrate_one(pool: &PgPool, parent: TriggerRow) -> Result<Trigger, Trigg
|
||||
|
||||
Ok(Trigger {
|
||||
id: parent.id.into(),
|
||||
app_id: parent.app_id.map(Into::into),
|
||||
group_id: parent.group_id.map(Into::into),
|
||||
app_id: parent.app_id.into(),
|
||||
script_id: parent.script_id.into(),
|
||||
name: parent.name,
|
||||
kind,
|
||||
enabled: parent.enabled,
|
||||
sealed: parent.sealed,
|
||||
shared: parent.shared,
|
||||
materialized: parent.materialized_from.is_some(),
|
||||
dispatch_mode: dispatch_from_str(&parent.dispatch_mode),
|
||||
retry_max_attempts: u32::try_from(parent.retry_max_attempts).unwrap_or(3),
|
||||
retry_backoff: BackoffShape::from_wire(&parent.retry_backoff)
|
||||
@@ -2035,29 +1756,11 @@ pub fn collection_matches(glob: &str, collection: &str) -> bool {
|
||||
#[derive(sqlx::FromRow)]
|
||||
struct TriggerRow {
|
||||
id: Uuid,
|
||||
app_id: Option<Uuid>,
|
||||
// `default` so the interactive-create RETURNING clauses (which don't list
|
||||
// group_id — they only ever build app-owned rows) still hydrate; the
|
||||
// group/app list queries SELECT it explicitly.
|
||||
#[sqlx(default)]
|
||||
group_id: Option<Uuid>,
|
||||
app_id: Uuid,
|
||||
script_id: Uuid,
|
||||
name: String,
|
||||
kind: String,
|
||||
enabled: bool,
|
||||
// `default` (§11 tail) so the interactive-create RETURNING clauses (which
|
||||
// build app-owned rows, never sealed) still hydrate; the group/app list
|
||||
// queries that must see a true value SELECT it explicitly.
|
||||
#[sqlx(default)]
|
||||
sealed: bool,
|
||||
// §11.6: same `default` treatment as `sealed`.
|
||||
#[sqlx(default)]
|
||||
shared: bool,
|
||||
// §4.5 M5: set on a materialized copy of a group stateful template; NULL on
|
||||
// a hand-authored trigger. `default` like the flags above — only the app/
|
||||
// group list queries SELECT it.
|
||||
#[sqlx(default)]
|
||||
materialized_from: Option<Uuid>,
|
||||
dispatch_mode: String,
|
||||
retry_max_attempts: i32,
|
||||
retry_backoff: String,
|
||||
@@ -2108,9 +1811,6 @@ struct QueueConsumerRow {
|
||||
retry_backoff: String,
|
||||
retry_base_ms: i32,
|
||||
registered_by_principal: Uuid,
|
||||
// §11.6 D3: the owning group of a SHARED queue template this consumer was
|
||||
// materialized from; NULL for a per-app / non-shared consumer.
|
||||
shared_group: Option<Uuid>,
|
||||
}
|
||||
|
||||
#[derive(sqlx::FromRow)]
|
||||
@@ -2145,47 +1845,6 @@ struct KvMatchRow {
|
||||
ops: Vec<String>,
|
||||
}
|
||||
|
||||
impl KvMatchRow {
|
||||
fn into_kv(self) -> KvTriggerMatch {
|
||||
KvTriggerMatch {
|
||||
trigger_id: self.id.into(),
|
||||
script_id: self.script_id.into(),
|
||||
dispatch_mode: dispatch_from_str(&self.dispatch_mode),
|
||||
retry_max_attempts: u32::try_from(self.retry_max_attempts).unwrap_or(3),
|
||||
retry_backoff: BackoffShape::from_wire(&self.retry_backoff)
|
||||
.unwrap_or(BackoffShape::Exponential),
|
||||
retry_base_ms: u32::try_from(self.retry_base_ms).unwrap_or(1000),
|
||||
registered_by_principal: self.registered_by_principal.into(),
|
||||
}
|
||||
}
|
||||
|
||||
fn into_docs(self) -> DocsTriggerMatch {
|
||||
let m = self.into_kv();
|
||||
DocsTriggerMatch {
|
||||
trigger_id: m.trigger_id,
|
||||
script_id: m.script_id,
|
||||
dispatch_mode: m.dispatch_mode,
|
||||
retry_max_attempts: m.retry_max_attempts,
|
||||
retry_backoff: m.retry_backoff,
|
||||
retry_base_ms: m.retry_base_ms,
|
||||
registered_by_principal: m.registered_by_principal,
|
||||
}
|
||||
}
|
||||
|
||||
fn into_files(self) -> FilesTriggerMatch {
|
||||
let m = self.into_kv();
|
||||
FilesTriggerMatch {
|
||||
trigger_id: m.trigger_id,
|
||||
script_id: m.script_id,
|
||||
dispatch_mode: m.dispatch_mode,
|
||||
retry_max_attempts: m.retry_max_attempts,
|
||||
retry_backoff: m.retry_backoff,
|
||||
retry_base_ms: m.retry_base_ms,
|
||||
registered_by_principal: m.registered_by_principal,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(sqlx::FromRow)]
|
||||
struct DlMatchRow {
|
||||
id: Uuid,
|
||||
|
||||
@@ -698,9 +698,7 @@ async fn delete_trigger(
|
||||
.get(trigger_id)
|
||||
.await?
|
||||
.ok_or(TriggersApiError::NotFound(trigger_id))?;
|
||||
// Interactive trigger management is app-scoped; a group TEMPLATE
|
||||
// (app_id NULL) is never addressable here, so a mismatch (incl. None) 404s.
|
||||
if trigger.app_id != Some(app_id) {
|
||||
if trigger.app_id != app_id {
|
||||
return Err(TriggersApiError::NotFound(trigger_id));
|
||||
}
|
||||
if !s.triggers.delete(trigger_id).await? {
|
||||
@@ -902,15 +900,11 @@ mod tests {
|
||||
let id = TriggerId::new();
|
||||
let trigger = Trigger {
|
||||
id,
|
||||
app_id: Some(app_id),
|
||||
group_id: None,
|
||||
app_id,
|
||||
script_id: req.script_id,
|
||||
name: "mock".into(),
|
||||
kind: crate::trigger_repo::TriggerKind::Kv,
|
||||
enabled: true,
|
||||
sealed: false,
|
||||
shared: false,
|
||||
materialized: false,
|
||||
dispatch_mode: req.dispatch_mode,
|
||||
retry_max_attempts: req.retry_max_attempts,
|
||||
retry_backoff: req.retry_backoff,
|
||||
@@ -935,15 +929,11 @@ mod tests {
|
||||
let id = TriggerId::new();
|
||||
let trigger = Trigger {
|
||||
id,
|
||||
app_id: Some(app_id),
|
||||
group_id: None,
|
||||
app_id,
|
||||
script_id: req.script_id,
|
||||
name: "mock".into(),
|
||||
kind: crate::trigger_repo::TriggerKind::Docs,
|
||||
enabled: true,
|
||||
sealed: false,
|
||||
shared: false,
|
||||
materialized: false,
|
||||
dispatch_mode: req.dispatch_mode,
|
||||
retry_max_attempts: req.retry_max_attempts,
|
||||
retry_backoff: req.retry_backoff,
|
||||
@@ -968,15 +958,11 @@ mod tests {
|
||||
let id = TriggerId::new();
|
||||
let trigger = Trigger {
|
||||
id,
|
||||
app_id: Some(app_id),
|
||||
group_id: None,
|
||||
app_id,
|
||||
script_id: req.script_id,
|
||||
name: "mock".into(),
|
||||
kind: crate::trigger_repo::TriggerKind::DeadLetter,
|
||||
enabled: true,
|
||||
sealed: false,
|
||||
shared: false,
|
||||
materialized: false,
|
||||
dispatch_mode: TriggerDispatchMode::Async,
|
||||
retry_max_attempts: 1,
|
||||
retry_backoff: BackoffShape::Constant,
|
||||
@@ -1002,15 +988,11 @@ mod tests {
|
||||
let id = TriggerId::new();
|
||||
let trigger = Trigger {
|
||||
id,
|
||||
app_id: Some(app_id),
|
||||
group_id: None,
|
||||
app_id,
|
||||
script_id: req.script_id,
|
||||
name: "mock".into(),
|
||||
kind: TriggerKind::Email,
|
||||
enabled: true,
|
||||
sealed: false,
|
||||
shared: false,
|
||||
materialized: false,
|
||||
dispatch_mode: TriggerDispatchMode::Async,
|
||||
retry_max_attempts: 3,
|
||||
retry_backoff: BackoffShape::Exponential,
|
||||
@@ -1031,12 +1013,9 @@ mod tests {
|
||||
) -> Result<Option<EmailInboundTarget>, TriggerRepoError> {
|
||||
let g = self.inner.lock().await;
|
||||
Ok(g.get(&trigger_id)
|
||||
// Mirror the production guard (`email_inbound_target` filters
|
||||
// `AND t.app_id IS NOT NULL`): a group-owned email TEMPLATE is
|
||||
// never a valid inbound target.
|
||||
.filter(|t| t.kind == TriggerKind::Email && t.app_id.is_some())
|
||||
.filter(|t| t.kind == TriggerKind::Email)
|
||||
.map(|t| EmailInboundTarget {
|
||||
app_id: t.app_id.expect("filtered to app-owned above"),
|
||||
app_id: t.app_id,
|
||||
script_id: t.script_id,
|
||||
enabled: t.enabled,
|
||||
dispatch_mode: t.dispatch_mode,
|
||||
@@ -1054,15 +1033,11 @@ mod tests {
|
||||
let id = TriggerId::new();
|
||||
let trigger = Trigger {
|
||||
id,
|
||||
app_id: Some(app_id),
|
||||
group_id: None,
|
||||
app_id,
|
||||
script_id: req.script_id,
|
||||
name: "mock".into(),
|
||||
kind: crate::trigger_repo::TriggerKind::Cron,
|
||||
enabled: true,
|
||||
sealed: false,
|
||||
shared: false,
|
||||
materialized: false,
|
||||
dispatch_mode: req.dispatch_mode,
|
||||
retry_max_attempts: req.retry_max_attempts,
|
||||
retry_backoff: req.retry_backoff,
|
||||
@@ -1088,15 +1063,11 @@ mod tests {
|
||||
let id = TriggerId::new();
|
||||
let trigger = Trigger {
|
||||
id,
|
||||
app_id: Some(app_id),
|
||||
group_id: None,
|
||||
app_id,
|
||||
script_id: req.script_id,
|
||||
name: "mock".into(),
|
||||
kind: crate::trigger_repo::TriggerKind::Files,
|
||||
enabled: true,
|
||||
sealed: false,
|
||||
shared: false,
|
||||
materialized: false,
|
||||
dispatch_mode: req.dispatch_mode,
|
||||
retry_max_attempts: req.retry_max_attempts,
|
||||
retry_backoff: req.retry_backoff,
|
||||
@@ -1121,15 +1092,11 @@ mod tests {
|
||||
let id = TriggerId::new();
|
||||
let trigger = Trigger {
|
||||
id,
|
||||
app_id: Some(app_id),
|
||||
group_id: None,
|
||||
app_id,
|
||||
script_id: req.script_id,
|
||||
name: "mock".into(),
|
||||
kind: crate::trigger_repo::TriggerKind::Pubsub,
|
||||
enabled: true,
|
||||
sealed: false,
|
||||
shared: false,
|
||||
materialized: false,
|
||||
dispatch_mode: req.dispatch_mode,
|
||||
retry_max_attempts: req.retry_max_attempts,
|
||||
retry_backoff: req.retry_backoff,
|
||||
@@ -1150,7 +1117,7 @@ mod tests {
|
||||
.lock()
|
||||
.await
|
||||
.values()
|
||||
.filter(|t| t.app_id == Some(app_id))
|
||||
.filter(|t| t.app_id == app_id)
|
||||
.cloned()
|
||||
.collect())
|
||||
}
|
||||
@@ -1202,15 +1169,11 @@ mod tests {
|
||||
let id = TriggerId::new();
|
||||
let trigger = Trigger {
|
||||
id,
|
||||
app_id: Some(app_id),
|
||||
group_id: None,
|
||||
app_id,
|
||||
script_id: req.script_id,
|
||||
name: "mock".into(),
|
||||
kind: TriggerKind::Queue,
|
||||
enabled: true,
|
||||
sealed: false,
|
||||
shared: false,
|
||||
materialized: false,
|
||||
dispatch_mode: req.dispatch_mode,
|
||||
retry_max_attempts: req.retry_max_attempts,
|
||||
retry_backoff: req.retry_backoff,
|
||||
|
||||
@@ -203,6 +203,7 @@ table: extension_points
|
||||
group_id: uuid NULL
|
||||
app_id: uuid NULL
|
||||
name: text NOT NULL
|
||||
default_script_id: uuid NULL
|
||||
created_at: timestamp with time zone NOT NULL default=now()
|
||||
updated_at: timestamp with time zone NOT NULL default=now()
|
||||
|
||||
@@ -222,61 +223,12 @@ table: files_trigger_details
|
||||
collection_glob: text NOT NULL
|
||||
ops: ARRAY NOT NULL
|
||||
|
||||
table: group_collections
|
||||
id: uuid NOT NULL default=gen_random_uuid()
|
||||
group_id: uuid NULL
|
||||
app_id: uuid NULL
|
||||
name: text NOT NULL
|
||||
kind: text NOT NULL default='kv'::text
|
||||
created_at: timestamp with time zone NOT NULL default=now()
|
||||
updated_at: timestamp with time zone NOT NULL default=now()
|
||||
|
||||
table: group_docs
|
||||
group_id: uuid NOT NULL
|
||||
collection: text NOT NULL
|
||||
id: uuid NOT NULL
|
||||
data: jsonb NOT NULL
|
||||
created_at: timestamp with time zone NOT NULL default=now()
|
||||
updated_at: timestamp with time zone NOT NULL default=now()
|
||||
|
||||
table: group_files
|
||||
group_id: uuid NOT NULL
|
||||
collection: text NOT NULL
|
||||
id: uuid NOT NULL
|
||||
name: text NOT NULL
|
||||
content_type: text NOT NULL
|
||||
size_bytes: bigint NOT NULL
|
||||
checksum_sha256: text NOT NULL
|
||||
created_at: timestamp with time zone NOT NULL default=now()
|
||||
updated_at: timestamp with time zone NOT NULL default=now()
|
||||
|
||||
table: group_kv_entries
|
||||
group_id: uuid NOT NULL
|
||||
collection: text NOT NULL
|
||||
key: text NOT NULL
|
||||
value: jsonb NOT NULL
|
||||
created_at: timestamp with time zone NOT NULL default=now()
|
||||
updated_at: timestamp with time zone NOT NULL default=now()
|
||||
|
||||
table: group_members
|
||||
group_id: uuid NOT NULL
|
||||
user_id: uuid NOT NULL
|
||||
role: text NOT NULL
|
||||
created_at: timestamp with time zone NOT NULL default=now()
|
||||
|
||||
table: group_queue_messages
|
||||
id: uuid NOT NULL default=gen_random_uuid()
|
||||
group_id: uuid NOT NULL
|
||||
collection: text NOT NULL
|
||||
payload: jsonb NOT NULL
|
||||
enqueued_at: timestamp with time zone NOT NULL default=now()
|
||||
deliver_after: timestamp with time zone NULL
|
||||
claim_token: uuid NULL
|
||||
claimed_at: timestamp with time zone NULL
|
||||
attempt: integer NOT NULL default=0
|
||||
max_attempts: integer NOT NULL default=3
|
||||
enqueued_by_principal: uuid NULL
|
||||
|
||||
table: groups
|
||||
id: uuid NOT NULL default=gen_random_uuid()
|
||||
parent_id: uuid NULL
|
||||
@@ -346,6 +298,22 @@ table: queue_trigger_details
|
||||
visibility_timeout_secs: integer NOT NULL default=30
|
||||
last_fired_at: timestamp with time zone NULL
|
||||
|
||||
table: route_templates
|
||||
id: uuid NOT NULL default=gen_random_uuid()
|
||||
group_id: uuid NOT NULL
|
||||
name: text NOT NULL
|
||||
script_name: text NOT NULL
|
||||
method: text NULL
|
||||
host_kind: text NOT NULL
|
||||
host: text NOT NULL default=''::text
|
||||
host_param_name: text NULL
|
||||
path_kind: text NOT NULL
|
||||
path: text NOT NULL
|
||||
dispatch_mode: text NOT NULL default='sync'::text
|
||||
enabled: boolean NOT NULL default=true
|
||||
created_at: timestamp with time zone NOT NULL default=now()
|
||||
updated_at: timestamp with time zone NOT NULL default=now()
|
||||
|
||||
table: routes
|
||||
id: uuid NOT NULL default=gen_random_uuid()
|
||||
script_id: uuid NOT NULL
|
||||
@@ -356,11 +324,10 @@ table: routes
|
||||
path: text NOT NULL
|
||||
method: text NULL
|
||||
created_at: timestamp with time zone NOT NULL default=now()
|
||||
app_id: uuid NULL
|
||||
app_id: uuid NOT NULL
|
||||
dispatch_mode: text NOT NULL default='sync'::text
|
||||
enabled: boolean NOT NULL default=true
|
||||
group_id: uuid NULL
|
||||
sealed: boolean NOT NULL default=false
|
||||
from_template: uuid NULL
|
||||
|
||||
table: script_imports
|
||||
app_id: uuid NOT NULL
|
||||
@@ -395,14 +362,6 @@ table: secrets
|
||||
group_id: uuid NULL
|
||||
environment_scope: text NOT NULL default='*'::text
|
||||
|
||||
table: template_suppressions
|
||||
id: uuid NOT NULL default=gen_random_uuid()
|
||||
app_id: uuid NULL
|
||||
target_kind: text NOT NULL
|
||||
reference: text NOT NULL
|
||||
created_at: timestamp with time zone NOT NULL default=now()
|
||||
group_id: uuid NULL
|
||||
|
||||
table: topics
|
||||
app_id: uuid NOT NULL
|
||||
name: text NOT NULL
|
||||
@@ -411,9 +370,17 @@ table: topics
|
||||
created_at: timestamp with time zone NOT NULL default=now()
|
||||
updated_at: timestamp with time zone NOT NULL default=now()
|
||||
|
||||
table: trigger_templates
|
||||
id: uuid NOT NULL default=gen_random_uuid()
|
||||
group_id: uuid NOT NULL
|
||||
name: text NOT NULL
|
||||
spec: jsonb NOT NULL
|
||||
created_at: timestamp with time zone NOT NULL default=now()
|
||||
updated_at: timestamp with time zone NOT NULL default=now()
|
||||
|
||||
table: triggers
|
||||
id: uuid NOT NULL default=gen_random_uuid()
|
||||
app_id: uuid NULL
|
||||
app_id: uuid NOT NULL
|
||||
script_id: uuid NOT NULL
|
||||
kind: text NOT NULL
|
||||
enabled: boolean NOT NULL default=true
|
||||
@@ -425,10 +392,7 @@ table: triggers
|
||||
created_at: timestamp with time zone NOT NULL default=now()
|
||||
updated_at: timestamp with time zone NOT NULL default=now()
|
||||
name: text NOT NULL default=(gen_random_uuid())::text
|
||||
group_id: uuid NULL
|
||||
sealed: boolean NOT NULL default=false
|
||||
shared: boolean NOT NULL default=false
|
||||
materialized_from: uuid NULL
|
||||
from_template: uuid NULL
|
||||
|
||||
table: vars
|
||||
id: uuid NOT NULL default=gen_random_uuid()
|
||||
@@ -542,6 +506,7 @@ indexes on execution_logs:
|
||||
indexes on extension_points:
|
||||
extension_points_app_id_idx: public.extension_points USING btree (app_id) WHERE (app_id IS NOT NULL)
|
||||
extension_points_app_uidx: public.extension_points USING btree (app_id, lower(name)) WHERE (app_id IS NOT NULL)
|
||||
extension_points_default_script_idx: public.extension_points USING btree (default_script_id) WHERE (default_script_id IS NOT NULL)
|
||||
extension_points_group_id_idx: public.extension_points USING btree (group_id) WHERE (group_id IS NOT NULL)
|
||||
extension_points_group_uidx: public.extension_points USING btree (group_id, lower(name)) WHERE (group_id IS NOT NULL)
|
||||
extension_points_pkey: public.extension_points USING btree (id)
|
||||
@@ -553,36 +518,10 @@ indexes on files:
|
||||
indexes on files_trigger_details:
|
||||
files_trigger_details_pkey: public.files_trigger_details USING btree (trigger_id)
|
||||
|
||||
indexes on group_collections:
|
||||
group_collections_app_id_idx: public.group_collections USING btree (app_id) WHERE (app_id IS NOT NULL)
|
||||
group_collections_app_uidx: public.group_collections USING btree (app_id, lower(name), kind) WHERE (app_id IS NOT NULL)
|
||||
group_collections_group_id_idx: public.group_collections USING btree (group_id) WHERE (group_id IS NOT NULL)
|
||||
group_collections_group_uidx: public.group_collections USING btree (group_id, lower(name), kind) WHERE (group_id IS NOT NULL)
|
||||
group_collections_pkey: public.group_collections USING btree (id)
|
||||
|
||||
indexes on group_docs:
|
||||
group_docs_pkey: public.group_docs USING btree (group_id, collection, id)
|
||||
idx_group_docs_data_gin: public.group_docs USING gin (data jsonb_path_ops)
|
||||
idx_group_docs_group_collection: public.group_docs USING btree (group_id, collection)
|
||||
|
||||
indexes on group_files:
|
||||
group_files_pkey: public.group_files USING btree (group_id, collection, id)
|
||||
idx_group_files_group_collection: public.group_files USING btree (group_id, collection)
|
||||
|
||||
indexes on group_kv_entries:
|
||||
group_kv_entries_pkey: public.group_kv_entries USING btree (group_id, collection, key)
|
||||
idx_group_kv_entries_group_collection: public.group_kv_entries USING btree (group_id, collection)
|
||||
|
||||
indexes on group_members:
|
||||
group_members_pkey: public.group_members USING btree (group_id, user_id)
|
||||
group_members_user_id_idx: public.group_members USING btree (user_id)
|
||||
|
||||
indexes on group_queue_messages:
|
||||
group_queue_messages_pkey: public.group_queue_messages USING btree (id)
|
||||
idx_group_queue_messages_claimed: public.group_queue_messages USING btree (claimed_at) WHERE (claim_token IS NOT NULL)
|
||||
idx_group_queue_messages_dispatch: public.group_queue_messages USING btree (group_id, collection, enqueued_at) WHERE (claim_token IS NULL)
|
||||
idx_group_queue_messages_group_collection: public.group_queue_messages USING btree (group_id, collection)
|
||||
|
||||
indexes on groups:
|
||||
groups_owner_project_idx: public.groups USING btree (owner_project)
|
||||
groups_parent_id_idx: public.groups USING btree (parent_id)
|
||||
@@ -618,14 +557,17 @@ indexes on queue_trigger_details:
|
||||
idx_queue_trigger_details_queue_name: public.queue_trigger_details USING btree (queue_name)
|
||||
queue_trigger_details_pkey: public.queue_trigger_details USING btree (trigger_id)
|
||||
|
||||
indexes on route_templates:
|
||||
route_templates_group_name_idx: public.route_templates USING btree (group_id, lower(name))
|
||||
route_templates_pkey: public.route_templates USING btree (id)
|
||||
|
||||
indexes on routes:
|
||||
routes_app_id_idx: public.routes USING btree (app_id)
|
||||
routes_group_binding_idx: public.routes USING btree (group_id, host_kind, host, path_kind, path, COALESCE(method, ''::text)) WHERE (group_id IS NOT NULL)
|
||||
routes_group_id_idx: public.routes USING btree (group_id) WHERE (group_id IS NOT NULL)
|
||||
routes_from_template_idx: public.routes USING btree (app_id, from_template)
|
||||
routes_lookup_idx: public.routes USING btree (host_kind, host)
|
||||
routes_pkey: public.routes USING btree (id)
|
||||
routes_script_id_idx: public.routes USING btree (script_id)
|
||||
routes_unique_binding_idx: public.routes USING btree (app_id, host_kind, host, path_kind, path, COALESCE(method, ''::text)) WHERE (app_id IS NOT NULL)
|
||||
routes_unique_binding_idx: public.routes USING btree (app_id, host_kind, host, path_kind, path, COALESCE(method, ''::text))
|
||||
|
||||
indexes on script_imports:
|
||||
idx_script_imports_app: public.script_imports USING btree (app_id)
|
||||
@@ -646,25 +588,19 @@ indexes on secrets:
|
||||
secrets_app_uidx: public.secrets USING btree (app_id, environment_scope, name) WHERE (app_id IS NOT NULL)
|
||||
secrets_group_uidx: public.secrets USING btree (group_id, environment_scope, name) WHERE (group_id IS NOT NULL)
|
||||
|
||||
indexes on template_suppressions:
|
||||
template_suppressions_app_idx: public.template_suppressions USING btree (app_id)
|
||||
template_suppressions_app_uidx: public.template_suppressions USING btree (app_id, target_kind, reference) WHERE (app_id IS NOT NULL)
|
||||
template_suppressions_group_idx: public.template_suppressions USING btree (group_id) WHERE (group_id IS NOT NULL)
|
||||
template_suppressions_group_uidx: public.template_suppressions USING btree (group_id, target_kind, reference) WHERE (group_id IS NOT NULL)
|
||||
template_suppressions_pkey: public.template_suppressions USING btree (id)
|
||||
|
||||
indexes on topics:
|
||||
topics_pkey: public.topics USING btree (app_id, name)
|
||||
|
||||
indexes on trigger_templates:
|
||||
trigger_templates_group_name_idx: public.trigger_templates USING btree (group_id, lower(name))
|
||||
trigger_templates_pkey: public.trigger_templates USING btree (id)
|
||||
|
||||
indexes on triggers:
|
||||
idx_triggers_app_kind_enabled: public.triggers USING btree (app_id, kind) WHERE ((enabled = true) AND (app_id IS NOT NULL))
|
||||
idx_triggers_app_kind_enabled: public.triggers USING btree (app_id, kind) WHERE (enabled = true)
|
||||
idx_triggers_app_pubsub_enabled: public.triggers USING btree (app_id, kind) WHERE ((enabled = true) AND (kind = 'pubsub'::text))
|
||||
idx_triggers_group_kind_enabled: public.triggers USING btree (group_id, kind) WHERE ((enabled = true) AND (group_id IS NOT NULL))
|
||||
idx_triggers_kind_enabled: public.triggers USING btree (kind) WHERE (enabled = true)
|
||||
idx_triggers_materialized_from: public.triggers USING btree (materialized_from) WHERE (materialized_from IS NOT NULL)
|
||||
triggers_app_name_uniq: public.triggers USING btree (app_id, name) WHERE (app_id IS NOT NULL)
|
||||
triggers_group_name_uniq: public.triggers USING btree (group_id, name) WHERE (group_id IS NOT NULL)
|
||||
triggers_materialized_uidx: public.triggers USING btree (app_id, materialized_from) WHERE (materialized_from IS NOT NULL)
|
||||
triggers_app_name_uniq: public.triggers USING btree (app_id, name)
|
||||
triggers_from_template_idx: public.triggers USING btree (app_id, from_template)
|
||||
triggers_pkey: public.triggers USING btree (id)
|
||||
|
||||
indexes on vars:
|
||||
@@ -786,6 +722,7 @@ constraints on execution_logs:
|
||||
constraints on extension_points:
|
||||
[CHECK] extension_points_owner_exactly_one: CHECK (((group_id IS NULL) <> (app_id IS NULL)))
|
||||
[FOREIGN KEY] extension_points_app_id_fkey: FOREIGN KEY (app_id) REFERENCES apps(id) ON DELETE CASCADE
|
||||
[FOREIGN KEY] extension_points_default_script_id_fkey: FOREIGN KEY (default_script_id) REFERENCES scripts(id) ON DELETE SET NULL
|
||||
[FOREIGN KEY] extension_points_group_id_fkey: FOREIGN KEY (group_id) REFERENCES groups(id) ON DELETE CASCADE
|
||||
[PRIMARY KEY] extension_points_pkey: PRIMARY KEY (id)
|
||||
|
||||
@@ -797,35 +734,12 @@ constraints on files_trigger_details:
|
||||
[FOREIGN KEY] files_trigger_details_trigger_id_fkey: FOREIGN KEY (trigger_id) REFERENCES triggers(id) ON DELETE CASCADE
|
||||
[PRIMARY KEY] files_trigger_details_pkey: PRIMARY KEY (trigger_id)
|
||||
|
||||
constraints on group_collections:
|
||||
[CHECK] group_collections_kind_check: CHECK ((kind = ANY (ARRAY['kv'::text, 'docs'::text, 'files'::text, 'topic'::text, 'queue'::text])))
|
||||
[CHECK] group_collections_owner_exactly_one: CHECK (((group_id IS NULL) <> (app_id IS NULL)))
|
||||
[FOREIGN KEY] group_collections_app_id_fkey: FOREIGN KEY (app_id) REFERENCES apps(id) ON DELETE CASCADE
|
||||
[FOREIGN KEY] group_collections_group_id_fkey: FOREIGN KEY (group_id) REFERENCES groups(id) ON DELETE CASCADE
|
||||
[PRIMARY KEY] group_collections_pkey: PRIMARY KEY (id)
|
||||
|
||||
constraints on group_docs:
|
||||
[FOREIGN KEY] group_docs_group_id_fkey: FOREIGN KEY (group_id) REFERENCES groups(id) ON DELETE CASCADE
|
||||
[PRIMARY KEY] group_docs_pkey: PRIMARY KEY (group_id, collection, id)
|
||||
|
||||
constraints on group_files:
|
||||
[FOREIGN KEY] group_files_group_id_fkey: FOREIGN KEY (group_id) REFERENCES groups(id) ON DELETE CASCADE
|
||||
[PRIMARY KEY] group_files_pkey: PRIMARY KEY (group_id, collection, id)
|
||||
|
||||
constraints on group_kv_entries:
|
||||
[FOREIGN KEY] group_kv_entries_group_id_fkey: FOREIGN KEY (group_id) REFERENCES groups(id) ON DELETE CASCADE
|
||||
[PRIMARY KEY] group_kv_entries_pkey: PRIMARY KEY (group_id, collection, key)
|
||||
|
||||
constraints on group_members:
|
||||
[CHECK] group_members_role_check: CHECK ((role = ANY (ARRAY['app_admin'::text, 'editor'::text, 'viewer'::text])))
|
||||
[FOREIGN KEY] group_members_group_id_fkey: FOREIGN KEY (group_id) REFERENCES groups(id) ON DELETE CASCADE
|
||||
[FOREIGN KEY] group_members_user_id_fkey: FOREIGN KEY (user_id) REFERENCES admin_users(id) ON DELETE CASCADE
|
||||
[PRIMARY KEY] group_members_pkey: PRIMARY KEY (group_id, user_id)
|
||||
|
||||
constraints on group_queue_messages:
|
||||
[FOREIGN KEY] group_queue_messages_group_id_fkey: FOREIGN KEY (group_id) REFERENCES groups(id) ON DELETE CASCADE
|
||||
[PRIMARY KEY] group_queue_messages_pkey: PRIMARY KEY (id)
|
||||
|
||||
constraints on groups:
|
||||
[FOREIGN KEY] groups_owner_project_fkey: FOREIGN KEY (owner_project) REFERENCES projects(id) ON DELETE SET NULL
|
||||
[FOREIGN KEY] groups_parent_id_fkey: FOREIGN KEY (parent_id) REFERENCES groups(id) ON DELETE RESTRICT
|
||||
@@ -861,13 +775,18 @@ constraints on queue_trigger_details:
|
||||
[FOREIGN KEY] queue_trigger_details_trigger_id_fkey: FOREIGN KEY (trigger_id) REFERENCES triggers(id) ON DELETE CASCADE
|
||||
[PRIMARY KEY] queue_trigger_details_pkey: PRIMARY KEY (trigger_id)
|
||||
|
||||
constraints on route_templates:
|
||||
[CHECK] route_templates_dispatch_mode_check: CHECK ((dispatch_mode = ANY (ARRAY['sync'::text, 'async'::text])))
|
||||
[CHECK] route_templates_host_kind_check: CHECK ((host_kind = ANY (ARRAY['any'::text, 'strict'::text, 'wildcard'::text])))
|
||||
[CHECK] route_templates_path_kind_check: CHECK ((path_kind = ANY (ARRAY['exact'::text, 'prefix'::text, 'param'::text])))
|
||||
[FOREIGN KEY] route_templates_group_id_fkey: FOREIGN KEY (group_id) REFERENCES groups(id) ON DELETE CASCADE
|
||||
[PRIMARY KEY] route_templates_pkey: PRIMARY KEY (id)
|
||||
|
||||
constraints on routes:
|
||||
[CHECK] routes_dispatch_mode_check: CHECK ((dispatch_mode = ANY (ARRAY['sync'::text, 'async'::text])))
|
||||
[CHECK] routes_host_kind_check: CHECK ((host_kind = ANY (ARRAY['any'::text, 'strict'::text, 'wildcard'::text])))
|
||||
[CHECK] routes_owner_exactly_one: CHECK (((group_id IS NULL) <> (app_id IS NULL)))
|
||||
[CHECK] routes_path_kind_check: CHECK ((path_kind = ANY (ARRAY['exact'::text, 'prefix'::text, 'param'::text])))
|
||||
[FOREIGN KEY] routes_app_id_fk: FOREIGN KEY (app_id) REFERENCES apps(id) ON DELETE CASCADE
|
||||
[FOREIGN KEY] routes_group_id_fkey: FOREIGN KEY (group_id) REFERENCES groups(id) ON DELETE RESTRICT
|
||||
[FOREIGN KEY] routes_script_id_fkey: FOREIGN KEY (script_id) REFERENCES scripts(id) ON DELETE CASCADE
|
||||
[PRIMARY KEY] routes_pkey: PRIMARY KEY (id)
|
||||
|
||||
@@ -892,26 +811,20 @@ constraints on secrets:
|
||||
[FOREIGN KEY] secrets_app_id_fkey: FOREIGN KEY (app_id) REFERENCES apps(id) ON DELETE CASCADE
|
||||
[FOREIGN KEY] secrets_group_id_fkey: FOREIGN KEY (group_id) REFERENCES groups(id) ON DELETE CASCADE
|
||||
|
||||
constraints on template_suppressions:
|
||||
[CHECK] template_suppressions_owner_exactly_one: CHECK (((group_id IS NULL) <> (app_id IS NULL)))
|
||||
[CHECK] template_suppressions_target_kind_check: CHECK ((target_kind = ANY (ARRAY['trigger'::text, 'route'::text])))
|
||||
[FOREIGN KEY] template_suppressions_app_id_fkey: FOREIGN KEY (app_id) REFERENCES apps(id) ON DELETE CASCADE
|
||||
[FOREIGN KEY] template_suppressions_group_id_fkey: FOREIGN KEY (group_id) REFERENCES groups(id) ON DELETE CASCADE
|
||||
[PRIMARY KEY] template_suppressions_pkey: PRIMARY KEY (id)
|
||||
|
||||
constraints on topics:
|
||||
[CHECK] topics_auth_mode_check: CHECK ((auth_mode = ANY (ARRAY['public'::text, 'token'::text, 'session'::text])))
|
||||
[FOREIGN KEY] topics_app_id_fkey: FOREIGN KEY (app_id) REFERENCES apps(id) ON DELETE CASCADE
|
||||
[PRIMARY KEY] topics_pkey: PRIMARY KEY (app_id, name)
|
||||
|
||||
constraints on trigger_templates:
|
||||
[FOREIGN KEY] trigger_templates_group_id_fkey: FOREIGN KEY (group_id) REFERENCES groups(id) ON DELETE CASCADE
|
||||
[PRIMARY KEY] trigger_templates_pkey: PRIMARY KEY (id)
|
||||
|
||||
constraints on triggers:
|
||||
[CHECK] triggers_dispatch_mode_check: CHECK ((dispatch_mode = ANY (ARRAY['sync'::text, 'async'::text])))
|
||||
[CHECK] triggers_kind_check: CHECK ((kind = ANY (ARRAY['kv'::text, 'dead_letter'::text, 'docs'::text, 'cron'::text, 'files'::text, 'pubsub'::text, 'email'::text, 'queue'::text])))
|
||||
[CHECK] triggers_owner_exactly_one: CHECK (((group_id IS NULL) <> (app_id IS NULL)))
|
||||
[CHECK] triggers_retry_backoff_check: CHECK ((retry_backoff = ANY (ARRAY['exponential'::text, 'linear'::text, 'constant'::text])))
|
||||
[FOREIGN KEY] triggers_app_id_fkey: FOREIGN KEY (app_id) REFERENCES apps(id) ON DELETE CASCADE
|
||||
[FOREIGN KEY] triggers_group_id_fkey: FOREIGN KEY (group_id) REFERENCES groups(id) ON DELETE RESTRICT
|
||||
[FOREIGN KEY] triggers_materialized_from_fkey: FOREIGN KEY (materialized_from) REFERENCES triggers(id) ON DELETE CASCADE
|
||||
[FOREIGN KEY] triggers_registered_by_principal_fkey: FOREIGN KEY (registered_by_principal) REFERENCES admin_users(id) ON DELETE CASCADE
|
||||
[FOREIGN KEY] triggers_script_id_fkey: FOREIGN KEY (script_id) REFERENCES scripts(id) ON DELETE CASCADE
|
||||
[PRIMARY KEY] triggers_pkey: PRIMARY KEY (id)
|
||||
@@ -974,18 +887,6 @@ constraints on vars:
|
||||
0049: group secrets
|
||||
0050: group scripts
|
||||
0051: extension points
|
||||
0052: group collections
|
||||
0053: group kv entries
|
||||
0054: group docs
|
||||
0055: group files
|
||||
0056: group triggers
|
||||
0057: group routes
|
||||
0058: template suppressions
|
||||
0059: sealed templates
|
||||
0060: group suppressions
|
||||
0061: shared triggers
|
||||
0062: materialized triggers
|
||||
0063: materialized unique
|
||||
0064: shared topic queue kinds
|
||||
0065: group queues
|
||||
0066: owner project
|
||||
0052: owner project
|
||||
0053: templates
|
||||
0054: trigger templates
|
||||
|
||||
@@ -1,136 +0,0 @@
|
||||
//! §11.6 D3 integration test: group-shared durable queue store.
|
||||
//!
|
||||
//! Proves the competing-consumer primitive: N messages enqueued into one
|
||||
//! group-keyed store are claimed EXACTLY ONCE across concurrent claimers (the
|
||||
//! `FOR UPDATE SKIP LOCKED` guarantee that makes per-descendant materialized
|
||||
//! consumers safe). Also checks ack removes a row and nack re-defers it.
|
||||
//!
|
||||
//! Deterministic: drives `GroupQueueRepo` directly (no dispatcher). Skips when
|
||||
//! `DATABASE_URL` is unset.
|
||||
|
||||
#![allow(clippy::too_many_lines, clippy::many_single_char_names)]
|
||||
|
||||
use std::collections::HashSet;
|
||||
|
||||
use picloud_manager_core::group_queue_repo::{
|
||||
GroupQueueRepo, NewGroupQueueMessage, PostgresGroupQueueRepo,
|
||||
};
|
||||
use picloud_shared::GroupId;
|
||||
use sqlx::postgres::PgPoolOptions;
|
||||
use sqlx::PgPool;
|
||||
use uuid::Uuid;
|
||||
|
||||
async fn pool_or_skip() -> Option<PgPool> {
|
||||
let Ok(url) = std::env::var("DATABASE_URL") else {
|
||||
eprintln!("group_queue: DATABASE_URL unset — skipping");
|
||||
return None;
|
||||
};
|
||||
let pool = PgPoolOptions::new()
|
||||
.max_connections(6)
|
||||
.connect(&url)
|
||||
.await
|
||||
.expect("connect");
|
||||
sqlx::migrate!("./migrations")
|
||||
.run(&pool)
|
||||
.await
|
||||
.expect("migrate");
|
||||
Some(pool)
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||
async fn competing_consumers_claim_each_message_exactly_once() {
|
||||
let Some(pool) = pool_or_skip().await else {
|
||||
return;
|
||||
};
|
||||
let sfx = Uuid::new_v4().simple().to_string();
|
||||
let g: (Uuid,) = sqlx::query_as("INSERT INTO groups (slug, name) VALUES ($1, $1) RETURNING id")
|
||||
.bind(format!("gq-g-{sfx}"))
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
let group = GroupId::from(g.0);
|
||||
let repo = PostgresGroupQueueRepo::new(pool.clone());
|
||||
|
||||
// Enqueue 50 distinct messages into the shared `tasks` queue.
|
||||
let n_msgs: usize = 50;
|
||||
for i in 0..n_msgs {
|
||||
repo.enqueue(NewGroupQueueMessage {
|
||||
group_id: group,
|
||||
collection: "tasks".into(),
|
||||
payload: serde_json::json!({ "i": i }),
|
||||
deliver_after: None,
|
||||
max_attempts: 3,
|
||||
enqueued_by_principal: None,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
// Four concurrent "consumers" claim until the queue is drained, ack-ing each.
|
||||
// Every claimed payload id must be unique — no message delivered twice.
|
||||
let claim_loop = |repo: PostgresGroupQueueRepo, group: GroupId| async move {
|
||||
let mut got: Vec<i64> = Vec::new();
|
||||
while let Some(msg) = repo.claim(group, "tasks").await.unwrap() {
|
||||
got.push(msg.payload["i"].as_i64().unwrap());
|
||||
assert!(repo.ack(msg.id, msg.claim_token).await.unwrap(), "ack ok");
|
||||
}
|
||||
got
|
||||
};
|
||||
let (a, b, c, d) = tokio::join!(
|
||||
claim_loop(PostgresGroupQueueRepo::new(pool.clone()), group),
|
||||
claim_loop(PostgresGroupQueueRepo::new(pool.clone()), group),
|
||||
claim_loop(PostgresGroupQueueRepo::new(pool.clone()), group),
|
||||
claim_loop(PostgresGroupQueueRepo::new(pool.clone()), group),
|
||||
);
|
||||
|
||||
let mut all: Vec<i64> = Vec::new();
|
||||
all.extend(a);
|
||||
all.extend(b);
|
||||
all.extend(c);
|
||||
all.extend(d);
|
||||
let unique: HashSet<i64> = all.iter().copied().collect();
|
||||
assert_eq!(
|
||||
all.len(),
|
||||
n_msgs,
|
||||
"every message delivered exactly once (no dupes)"
|
||||
);
|
||||
assert_eq!(unique.len(), n_msgs, "all distinct ids covered");
|
||||
assert_eq!(
|
||||
repo.depth(group, "tasks").await.unwrap(),
|
||||
0,
|
||||
"queue drained"
|
||||
);
|
||||
|
||||
// nack re-defers a message (a later claim gets it back).
|
||||
let id = repo
|
||||
.enqueue(NewGroupQueueMessage {
|
||||
group_id: group,
|
||||
collection: "tasks".into(),
|
||||
payload: serde_json::json!({ "i": 999 }),
|
||||
deliver_after: None,
|
||||
max_attempts: 3,
|
||||
enqueued_by_principal: None,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
let msg = repo.claim(group, "tasks").await.unwrap().expect("claimed");
|
||||
assert_eq!(msg.id, id);
|
||||
repo.nack(msg.id, msg.claim_token, chrono::Duration::milliseconds(0))
|
||||
.await
|
||||
.unwrap();
|
||||
// After nack (0ms delay) it is claimable again.
|
||||
let again = repo.claim(group, "tasks").await.unwrap().expect("re-claim");
|
||||
assert_eq!(again.id, id, "nacked message is re-delivered");
|
||||
assert_eq!(again.attempt, 2, "attempt incremented on re-claim");
|
||||
repo.ack(again.id, again.claim_token).await.unwrap();
|
||||
|
||||
// Cleanup (messages cascade on group delete anyway).
|
||||
let _ = sqlx::query("DELETE FROM group_queue_messages WHERE group_id = $1")
|
||||
.bind(g.0)
|
||||
.execute(&pool)
|
||||
.await;
|
||||
let _ = sqlx::query("DELETE FROM groups WHERE id = $1")
|
||||
.bind(g.0)
|
||||
.execute(&pool)
|
||||
.await;
|
||||
}
|
||||
@@ -1,214 +0,0 @@
|
||||
//! §11 tail integration test: the live expansion of group ROUTE templates into
|
||||
//! per-app match slices. A group-owned route TEMPLATE must land in the compiled
|
||||
//! slice of a **descendant** app and must NOT land in an app in a **sibling**
|
||||
//! subtree — the ancestor-chain expansion is the isolation boundary. An app's
|
||||
//! own route with an identical binding must **shadow** the template (nearest
|
||||
//! owner wins); a non-identical app route must **coexist**.
|
||||
//!
|
||||
//! Deterministic: drives `list_effective` + `compile_effective_routes` directly
|
||||
//! (no async dispatcher), pinning the security-critical SQL + the shadow rule
|
||||
//! without flakiness. Skips cleanly when `DATABASE_URL` is unset.
|
||||
|
||||
#![allow(
|
||||
clippy::needless_pass_by_value,
|
||||
clippy::many_single_char_names,
|
||||
clippy::too_many_lines
|
||||
)]
|
||||
|
||||
use picloud_manager_core::route_admin::compile_effective_routes;
|
||||
use picloud_manager_core::route_repo::{PostgresRouteRepository, RouteRepository};
|
||||
use picloud_shared::AppId;
|
||||
use sqlx::postgres::PgPoolOptions;
|
||||
use sqlx::PgPool;
|
||||
use uuid::Uuid;
|
||||
|
||||
async fn pool_or_skip() -> Option<PgPool> {
|
||||
let Ok(url) = std::env::var("DATABASE_URL") else {
|
||||
eprintln!("group_route_templates: DATABASE_URL unset — skipping");
|
||||
return None;
|
||||
};
|
||||
let pool = PgPoolOptions::new()
|
||||
.max_connections(2)
|
||||
.connect(&url)
|
||||
.await
|
||||
.expect("connect to DATABASE_URL");
|
||||
sqlx::migrate!("./migrations")
|
||||
.run(&pool)
|
||||
.await
|
||||
.expect("apply migrations");
|
||||
Some(pool)
|
||||
}
|
||||
|
||||
async fn id1(pool: &PgPool, sql: &str, bind: &str) -> Uuid {
|
||||
let row: (Uuid,) = sqlx::query_as(sql)
|
||||
.bind(bind)
|
||||
.fetch_one(pool)
|
||||
.await
|
||||
.expect("insert returning id");
|
||||
row.0
|
||||
}
|
||||
|
||||
async fn app_under(pool: &PgPool, slug: &str, group: Uuid) -> Uuid {
|
||||
let row: (Uuid,) =
|
||||
sqlx::query_as("INSERT INTO apps (slug, name, group_id) VALUES ($1, $1, $2) RETURNING id")
|
||||
.bind(slug)
|
||||
.bind(group)
|
||||
.fetch_one(pool)
|
||||
.await
|
||||
.expect("app insert");
|
||||
row.0
|
||||
}
|
||||
|
||||
/// The compiled match slice for `app` — its own routes plus inherited
|
||||
/// ancestor-group templates, after nearest-wins shadowing.
|
||||
async fn slice_paths(repo: &PostgresRouteRepository, app: Uuid) -> Vec<(String, Uuid)> {
|
||||
let effective = repo.list_effective().await.expect("list_effective");
|
||||
let suppressed = repo
|
||||
.list_route_suppressions()
|
||||
.await
|
||||
.expect("list_route_suppressions")
|
||||
.into_iter()
|
||||
.collect();
|
||||
compile_effective_routes(&effective, &suppressed)
|
||||
.into_iter()
|
||||
.filter(|c| c.app_id == AppId::from(app))
|
||||
.map(|c| {
|
||||
// PathPattern's Debug carries the raw path; pair it with the bound
|
||||
// script so the shadow assertion can check which handler won.
|
||||
(format!("{:?}", c.path), c.script_id.into_inner())
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn group_route_template_expands_to_descendant_not_sibling_and_shadows() {
|
||||
let Some(pool) = pool_or_skip().await else {
|
||||
return;
|
||||
};
|
||||
let sfx = Uuid::new_v4().simple().to_string();
|
||||
|
||||
// G owns the template; G2 is an unrelated sibling subtree.
|
||||
let g = id1(
|
||||
&pool,
|
||||
"INSERT INTO groups (slug, name) VALUES ($1, $1) RETURNING id",
|
||||
&format!("grt-g-{sfx}"),
|
||||
)
|
||||
.await;
|
||||
let g2 = id1(
|
||||
&pool,
|
||||
"INSERT INTO groups (slug, name) VALUES ($1, $1) RETURNING id",
|
||||
&format!("grt-g2-{sfx}"),
|
||||
)
|
||||
.await;
|
||||
|
||||
// App A under G (descendant); app C under G2 (sibling subtree).
|
||||
let a = app_under(&pool, &format!("grt-a-{sfx}"), g).await;
|
||||
let c = app_under(&pool, &format!("grt-c-{sfx}"), g2).await;
|
||||
|
||||
// Group-owned handler script + a group route TEMPLATE binding it.
|
||||
let group_script: (Uuid,) = sqlx::query_as(
|
||||
"INSERT INTO scripts (name, source, group_id) VALUES ($1, 'x', $2) RETURNING id",
|
||||
)
|
||||
.bind(format!("ghandler-{sfx}"))
|
||||
.bind(g)
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.expect("group script");
|
||||
sqlx::query(
|
||||
"INSERT INTO routes (group_id, script_id, host_kind, host, path_kind, path, method) \
|
||||
VALUES ($1, $2, 'any', '', 'exact', '/hello', NULL)",
|
||||
)
|
||||
.bind(g)
|
||||
.bind(group_script.0)
|
||||
.execute(&pool)
|
||||
.await
|
||||
.expect("group route template");
|
||||
|
||||
let repo = PostgresRouteRepository::new(pool.clone());
|
||||
|
||||
// Descendant app A's slice CONTAINS the template, bound to the group script.
|
||||
let a_slice = slice_paths(&repo, a).await;
|
||||
assert!(
|
||||
a_slice
|
||||
.iter()
|
||||
.any(|(p, sid)| p.contains("/hello") && *sid == group_script.0),
|
||||
"a descendant app must inherit the group route template:\n{a_slice:?}"
|
||||
);
|
||||
|
||||
// Sibling-subtree app C's slice does NOT — the chain expansion is the bound.
|
||||
let c_slice = slice_paths(&repo, c).await;
|
||||
assert!(
|
||||
!c_slice.iter().any(|(p, _)| p.contains("/hello")),
|
||||
"a sibling-subtree app must NOT inherit another subtree's template:\n{c_slice:?}"
|
||||
);
|
||||
|
||||
// A's OWN /hello route (its own app-owned script) must SHADOW the template:
|
||||
// exactly one compiled /hello for A, bound to the app script, not the group.
|
||||
let app_script: (Uuid,) = sqlx::query_as(
|
||||
"INSERT INTO scripts (name, source, app_id) VALUES ($1, 'x', $2) RETURNING id",
|
||||
)
|
||||
.bind(format!("ahandler-{sfx}"))
|
||||
.bind(a)
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.expect("app script");
|
||||
sqlx::query(
|
||||
"INSERT INTO routes (app_id, script_id, host_kind, host, path_kind, path, method) \
|
||||
VALUES ($1, $2, 'any', '', 'exact', '/hello', NULL)",
|
||||
)
|
||||
.bind(a)
|
||||
.bind(app_script.0)
|
||||
.execute(&pool)
|
||||
.await
|
||||
.expect("app route shadow");
|
||||
|
||||
let a_after = slice_paths(&repo, a).await;
|
||||
let hellos: Vec<_> = a_after
|
||||
.iter()
|
||||
.filter(|(p, _)| p.contains("/hello"))
|
||||
.collect();
|
||||
assert_eq!(
|
||||
hellos.len(),
|
||||
1,
|
||||
"nearest-wins shadowing must leave exactly one /hello for A:\n{a_after:?}"
|
||||
);
|
||||
assert_eq!(
|
||||
hellos[0].1, app_script.0,
|
||||
"the app's own route must win over the inherited group template"
|
||||
);
|
||||
|
||||
// A non-identical app route (different path) must COEXIST with the template.
|
||||
sqlx::query(
|
||||
"INSERT INTO routes (app_id, script_id, host_kind, host, path_kind, path, method) \
|
||||
VALUES ($1, $2, 'any', '', 'exact', '/own', NULL)",
|
||||
)
|
||||
.bind(a)
|
||||
.bind(app_script.0)
|
||||
.execute(&pool)
|
||||
.await
|
||||
.expect("app route coexist");
|
||||
let a_final = slice_paths(&repo, a).await;
|
||||
assert!(
|
||||
a_final.iter().any(|(p, _)| p.contains("/own")),
|
||||
"a non-identical app route must coexist with the inherited template:\n{a_final:?}"
|
||||
);
|
||||
|
||||
// Cleanup (FK order: routes → scripts → apps → groups).
|
||||
let _ = sqlx::query("DELETE FROM routes WHERE app_id = ANY($1) OR group_id = $2")
|
||||
.bind(vec![a, c])
|
||||
.bind(g)
|
||||
.execute(&pool)
|
||||
.await;
|
||||
let _ = sqlx::query("DELETE FROM scripts WHERE id = ANY($1)")
|
||||
.bind(vec![group_script.0, app_script.0])
|
||||
.execute(&pool)
|
||||
.await;
|
||||
let _ = sqlx::query("DELETE FROM apps WHERE id = ANY($1)")
|
||||
.bind(vec![a, c])
|
||||
.execute(&pool)
|
||||
.await;
|
||||
let _ = sqlx::query("DELETE FROM groups WHERE id = ANY($1)")
|
||||
.bind(vec![g, g2])
|
||||
.execute(&pool)
|
||||
.await;
|
||||
}
|
||||
@@ -1,261 +0,0 @@
|
||||
//! §11 tail M1 integration test: GROUP-level template suppression.
|
||||
//! A child group declares a suppression for a template it inherits from its
|
||||
//! parent group → EVERY app in the child's subtree declines it (trigger
|
||||
//! anti-join joins the chain; route rebuild expands the group suppression
|
||||
//! across descendants), while an app under the parent but NOT under the child
|
||||
//! still inherits. A `sealed` parent template ignores the child's suppression.
|
||||
//!
|
||||
//! Deterministic: drives `list_matching_kv` + `list_effective` /
|
||||
//! `compile_effective_routes` directly. Skips when `DATABASE_URL` is unset.
|
||||
|
||||
#![allow(
|
||||
clippy::needless_pass_by_value,
|
||||
clippy::many_single_char_names,
|
||||
clippy::too_many_lines
|
||||
)]
|
||||
|
||||
use std::collections::HashSet;
|
||||
|
||||
use picloud_manager_core::route_admin::compile_effective_routes;
|
||||
use picloud_manager_core::route_repo::{PostgresRouteRepository, RouteRepository};
|
||||
use picloud_manager_core::trigger_repo::{PostgresTriggerRepo, TriggerRepo};
|
||||
use picloud_shared::{AppId, KvEventOp};
|
||||
use sqlx::postgres::PgPoolOptions;
|
||||
use sqlx::PgPool;
|
||||
use uuid::Uuid;
|
||||
|
||||
async fn pool_or_skip() -> Option<PgPool> {
|
||||
let Ok(url) = std::env::var("DATABASE_URL") else {
|
||||
eprintln!("group_suppression: DATABASE_URL unset — skipping");
|
||||
return None;
|
||||
};
|
||||
let pool = PgPoolOptions::new()
|
||||
.max_connections(2)
|
||||
.connect(&url)
|
||||
.await
|
||||
.expect("connect to DATABASE_URL");
|
||||
sqlx::migrate!("./migrations")
|
||||
.run(&pool)
|
||||
.await
|
||||
.expect("apply migrations");
|
||||
Some(pool)
|
||||
}
|
||||
|
||||
async fn id1(pool: &PgPool, sql: &str, bind: &str) -> Uuid {
|
||||
let row: (Uuid,) = sqlx::query_as(sql)
|
||||
.bind(bind)
|
||||
.fetch_one(pool)
|
||||
.await
|
||||
.expect("insert returning id");
|
||||
row.0
|
||||
}
|
||||
|
||||
/// A group with an explicit parent.
|
||||
async fn group_under(pool: &PgPool, slug: &str, parent: Option<Uuid>) -> Uuid {
|
||||
let row: (Uuid,) = sqlx::query_as(
|
||||
"INSERT INTO groups (slug, name, parent_id) VALUES ($1, $1, $2) RETURNING id",
|
||||
)
|
||||
.bind(slug)
|
||||
.bind(parent)
|
||||
.fetch_one(pool)
|
||||
.await
|
||||
.expect("group insert");
|
||||
row.0
|
||||
}
|
||||
|
||||
async fn app_under(pool: &PgPool, slug: &str, group: Uuid) -> Uuid {
|
||||
let row: (Uuid,) =
|
||||
sqlx::query_as("INSERT INTO apps (slug, name, group_id) VALUES ($1, $1, $2) RETURNING id")
|
||||
.bind(slug)
|
||||
.bind(group)
|
||||
.fetch_one(pool)
|
||||
.await
|
||||
.expect("app insert");
|
||||
row.0
|
||||
}
|
||||
|
||||
/// Whether `app`'s compiled route slice serves `/hello`.
|
||||
async fn serves_hello(repo: &PostgresRouteRepository, app: Uuid) -> bool {
|
||||
let effective = repo.list_effective().await.expect("list_effective");
|
||||
let suppressed: HashSet<(AppId, String)> = repo
|
||||
.list_route_suppressions()
|
||||
.await
|
||||
.expect("list_route_suppressions")
|
||||
.into_iter()
|
||||
.collect();
|
||||
compile_effective_routes(&effective, &suppressed)
|
||||
.into_iter()
|
||||
.any(|c| c.app_id == AppId::from(app) && format!("{:?}", c.path).contains("/hello"))
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn group_suppression_declines_for_whole_subtree_only() {
|
||||
let Some(pool) = pool_or_skip().await else {
|
||||
return;
|
||||
};
|
||||
let sfx = Uuid::new_v4().simple().to_string();
|
||||
let admin = id1(
|
||||
&pool,
|
||||
"INSERT INTO admin_users (username, password_hash) VALUES ($1, 'x') RETURNING id",
|
||||
&format!("gs-{sfx}"),
|
||||
)
|
||||
.await;
|
||||
|
||||
// Parent group P owns a handler `audit` + a kv trigger template + a /hello
|
||||
// route template. Child group C is under P. A sealed route template too.
|
||||
let p = group_under(&pool, &format!("gs-p-{sfx}"), None).await;
|
||||
let c = group_under(&pool, &format!("gs-c-{sfx}"), Some(p)).await;
|
||||
let handler_name = format!("audit-{sfx}");
|
||||
let handler: (Uuid,) = sqlx::query_as(
|
||||
"INSERT INTO scripts (name, source, group_id) VALUES ($1, 'x', $2) RETURNING id",
|
||||
)
|
||||
.bind(&handler_name)
|
||||
.bind(p)
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.expect("group handler");
|
||||
|
||||
let tmpl: (Uuid,) = sqlx::query_as(
|
||||
"INSERT INTO triggers \
|
||||
(group_id, script_id, kind, enabled, dispatch_mode, \
|
||||
retry_max_attempts, retry_backoff, retry_base_ms, \
|
||||
registered_by_principal, name) \
|
||||
VALUES ($1, $2, 'kv', TRUE, 'async', 3, 'exponential', 1000, $3, $4) RETURNING id",
|
||||
)
|
||||
.bind(p)
|
||||
.bind(handler.0)
|
||||
.bind(admin)
|
||||
.bind(format!("tmpl-{sfx}"))
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.expect("kv template");
|
||||
sqlx::query(
|
||||
"INSERT INTO kv_trigger_details (trigger_id, collection_glob, ops) \
|
||||
VALUES ($1, '*', ARRAY['insert'])",
|
||||
)
|
||||
.bind(tmpl.0)
|
||||
.execute(&pool)
|
||||
.await
|
||||
.expect("kv details");
|
||||
sqlx::query(
|
||||
"INSERT INTO routes (group_id, script_id, host_kind, host, path_kind, path, method) \
|
||||
VALUES ($1, $2, 'any', '', 'exact', '/hello', NULL)",
|
||||
)
|
||||
.bind(p)
|
||||
.bind(handler.0)
|
||||
.execute(&pool)
|
||||
.await
|
||||
.expect("route template");
|
||||
|
||||
// App A under child C (will inherit C's suppression); app D under parent P
|
||||
// but NOT under C (control — must still inherit).
|
||||
let a = app_under(&pool, &format!("gs-a-{sfx}"), c).await;
|
||||
let d = app_under(&pool, &format!("gs-d-{sfx}"), p).await;
|
||||
|
||||
// Child group C suppresses BOTH the trigger (by handler name) and the route.
|
||||
sqlx::query(
|
||||
"INSERT INTO template_suppressions (group_id, target_kind, reference) \
|
||||
VALUES ($1, 'trigger', $2), ($1, 'route', '/hello')",
|
||||
)
|
||||
.bind(c)
|
||||
.bind(&handler_name)
|
||||
.execute(&pool)
|
||||
.await
|
||||
.expect("group suppressions for C");
|
||||
|
||||
let trig = PostgresTriggerRepo::new(pool.clone());
|
||||
let routes = PostgresRouteRepository::new(pool.clone());
|
||||
|
||||
// App A (under C) → both declined by C's group-level suppression.
|
||||
let a_trig = trig
|
||||
.list_matching_kv(AppId::from(a), "users", KvEventOp::Insert)
|
||||
.await
|
||||
.expect("match A");
|
||||
assert!(
|
||||
!a_trig.iter().any(|m| m.trigger_id == tmpl.0.into()),
|
||||
"an app under the suppressing group must NOT match the inherited trigger"
|
||||
);
|
||||
assert!(
|
||||
!serves_hello(&routes, a).await,
|
||||
"an app under the suppressing group must NOT serve the inherited route"
|
||||
);
|
||||
|
||||
// App D (under P only) → still inherits both (isolation: C's suppression
|
||||
// does not reach a sibling subtree).
|
||||
let d_trig = trig
|
||||
.list_matching_kv(AppId::from(d), "users", KvEventOp::Insert)
|
||||
.await
|
||||
.expect("match D");
|
||||
assert!(
|
||||
d_trig.iter().any(|m| m.trigger_id == tmpl.0.into()),
|
||||
"an app outside the suppressing group's subtree still inherits the trigger"
|
||||
);
|
||||
assert!(
|
||||
serves_hello(&routes, d).await,
|
||||
"an app outside the suppressing group's subtree still serves the route"
|
||||
);
|
||||
|
||||
// A SEALED parent route template ignores the child group's suppression.
|
||||
sqlx::query(
|
||||
"INSERT INTO routes \
|
||||
(group_id, script_id, host_kind, host, path_kind, path, method, sealed) \
|
||||
VALUES ($1, $2, 'any', '', 'exact', '/sealed', NULL, TRUE)",
|
||||
)
|
||||
.bind(p)
|
||||
.bind(handler.0)
|
||||
.execute(&pool)
|
||||
.await
|
||||
.expect("sealed route template");
|
||||
sqlx::query(
|
||||
"INSERT INTO template_suppressions (group_id, target_kind, reference) \
|
||||
VALUES ($1, 'route', '/sealed')",
|
||||
)
|
||||
.bind(c)
|
||||
.execute(&pool)
|
||||
.await
|
||||
.expect("suppress sealed");
|
||||
let effective = routes.list_effective().await.expect("list_effective");
|
||||
let suppressed: HashSet<(AppId, String)> = routes
|
||||
.list_route_suppressions()
|
||||
.await
|
||||
.expect("suppressions")
|
||||
.into_iter()
|
||||
.collect();
|
||||
let a_serves_sealed = compile_effective_routes(&effective, &suppressed)
|
||||
.into_iter()
|
||||
.any(|cr| cr.app_id == AppId::from(a) && format!("{:?}", cr.path).contains("/sealed"));
|
||||
assert!(
|
||||
a_serves_sealed,
|
||||
"a sealed parent template is non-suppressible even by a group"
|
||||
);
|
||||
|
||||
// Cleanup (FK order).
|
||||
let _ = sqlx::query("DELETE FROM triggers WHERE id = $1")
|
||||
.bind(tmpl.0)
|
||||
.execute(&pool)
|
||||
.await;
|
||||
let _ = sqlx::query("DELETE FROM routes WHERE group_id = $1")
|
||||
.bind(p)
|
||||
.execute(&pool)
|
||||
.await;
|
||||
let _ = sqlx::query("DELETE FROM template_suppressions WHERE group_id = $1")
|
||||
.bind(c)
|
||||
.execute(&pool)
|
||||
.await;
|
||||
let _ = sqlx::query("DELETE FROM apps WHERE id = ANY($1)")
|
||||
.bind(vec![a, d])
|
||||
.execute(&pool)
|
||||
.await;
|
||||
let _ = sqlx::query("DELETE FROM scripts WHERE id = $1")
|
||||
.bind(handler.0)
|
||||
.execute(&pool)
|
||||
.await;
|
||||
let _ = sqlx::query("DELETE FROM groups WHERE id = ANY($1)")
|
||||
.bind(vec![c, p])
|
||||
.execute(&pool)
|
||||
.await;
|
||||
let _ = sqlx::query("DELETE FROM admin_users WHERE id = $1")
|
||||
.bind(admin)
|
||||
.execute(&pool)
|
||||
.await;
|
||||
}
|
||||
@@ -1,170 +0,0 @@
|
||||
//! §11 tail integration test: the live chain-union dispatch for group trigger
|
||||
//! templates. A group-owned kv trigger TEMPLATE must be matched for a
|
||||
//! **descendant** app (the union via `CHAIN_LEVELS_CTE`) and must NOT be matched
|
||||
//! for an app in a **sibling** subtree — that walk is the isolation boundary.
|
||||
//!
|
||||
//! Deterministic (no async dispatcher) so it pins the security-critical SQL
|
||||
//! without flakiness. Skips cleanly when `DATABASE_URL` is unset.
|
||||
|
||||
#![allow(
|
||||
clippy::needless_pass_by_value,
|
||||
clippy::many_single_char_names,
|
||||
clippy::too_many_lines
|
||||
)]
|
||||
|
||||
use picloud_manager_core::trigger_repo::{PostgresTriggerRepo, TriggerRepo};
|
||||
use picloud_shared::{AppId, KvEventOp};
|
||||
use sqlx::postgres::PgPoolOptions;
|
||||
use sqlx::PgPool;
|
||||
use uuid::Uuid;
|
||||
|
||||
async fn pool_or_skip() -> Option<PgPool> {
|
||||
let Ok(url) = std::env::var("DATABASE_URL") else {
|
||||
eprintln!("group_trigger_templates: DATABASE_URL unset — skipping");
|
||||
return None;
|
||||
};
|
||||
let pool = PgPoolOptions::new()
|
||||
.max_connections(2)
|
||||
.connect(&url)
|
||||
.await
|
||||
.expect("connect to DATABASE_URL");
|
||||
sqlx::migrate!("./migrations")
|
||||
.run(&pool)
|
||||
.await
|
||||
.expect("apply migrations");
|
||||
Some(pool)
|
||||
}
|
||||
|
||||
async fn id1(pool: &PgPool, sql: &str, bind: &str) -> Uuid {
|
||||
let row: (Uuid,) = sqlx::query_as(sql)
|
||||
.bind(bind)
|
||||
.fetch_one(pool)
|
||||
.await
|
||||
.expect("insert returning id");
|
||||
row.0
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn group_kv_template_matches_descendant_not_sibling() {
|
||||
let Some(pool) = pool_or_skip().await else {
|
||||
return;
|
||||
};
|
||||
let sfx = Uuid::new_v4().simple().to_string();
|
||||
|
||||
// registered_by principal
|
||||
let admin = id1(
|
||||
&pool,
|
||||
"INSERT INTO admin_users (username, password_hash) VALUES ($1, 'x') RETURNING id",
|
||||
&format!("gtt-{sfx}"),
|
||||
)
|
||||
.await;
|
||||
|
||||
// G owns the template; G2 is an unrelated sibling subtree.
|
||||
let g = id1(
|
||||
&pool,
|
||||
"INSERT INTO groups (slug, name) VALUES ($1, $1) RETURNING id",
|
||||
&format!("gtt-g-{sfx}"),
|
||||
)
|
||||
.await;
|
||||
let g2 = id1(
|
||||
&pool,
|
||||
"INSERT INTO groups (slug, name) VALUES ($1, $1) RETURNING id",
|
||||
&format!("gtt-g2-{sfx}"),
|
||||
)
|
||||
.await;
|
||||
|
||||
// App A under G (descendant); app C under G2 (sibling subtree).
|
||||
let a: (Uuid,) =
|
||||
sqlx::query_as("INSERT INTO apps (slug, name, group_id) VALUES ($1, $1, $2) RETURNING id")
|
||||
.bind(format!("gtt-a-{sfx}"))
|
||||
.bind(g)
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.expect("app A");
|
||||
let c: (Uuid,) =
|
||||
sqlx::query_as("INSERT INTO apps (slug, name, group_id) VALUES ($1, $1, $2) RETURNING id")
|
||||
.bind(format!("gtt-c-{sfx}"))
|
||||
.bind(g2)
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.expect("app C");
|
||||
|
||||
// Group-owned handler script under G.
|
||||
let s: (Uuid,) = sqlx::query_as(
|
||||
"INSERT INTO scripts (name, source, group_id) VALUES ($1, 'x', $2) RETURNING id",
|
||||
)
|
||||
.bind(format!("handler-{sfx}"))
|
||||
.bind(g)
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.expect("group script");
|
||||
|
||||
// Group kv trigger TEMPLATE (group_id = G, app_id NULL) + details.
|
||||
let t: (Uuid,) = sqlx::query_as(
|
||||
"INSERT INTO triggers \
|
||||
(group_id, script_id, kind, enabled, dispatch_mode, \
|
||||
retry_max_attempts, retry_backoff, retry_base_ms, \
|
||||
registered_by_principal, name) \
|
||||
VALUES ($1, $2, 'kv', TRUE, 'async', 3, 'exponential', 1000, $3, $4) \
|
||||
RETURNING id",
|
||||
)
|
||||
.bind(g)
|
||||
.bind(s.0)
|
||||
.bind(admin)
|
||||
.bind(format!("tmpl-{sfx}"))
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.expect("template trigger");
|
||||
sqlx::query(
|
||||
"INSERT INTO kv_trigger_details (trigger_id, collection_glob, ops) \
|
||||
VALUES ($1, '*', ARRAY['insert'])",
|
||||
)
|
||||
.bind(t.0)
|
||||
.execute(&pool)
|
||||
.await
|
||||
.expect("kv details");
|
||||
|
||||
let repo = PostgresTriggerRepo::new(pool.clone());
|
||||
|
||||
// Descendant app A's kv insert matches the ancestor group's template.
|
||||
let matched = repo
|
||||
.list_matching_kv(AppId::from(a.0), "users", KvEventOp::Insert)
|
||||
.await
|
||||
.expect("match for A");
|
||||
assert!(
|
||||
matched.iter().any(|m| m.trigger_id == t.0.into()),
|
||||
"a descendant app must match the group's kv template"
|
||||
);
|
||||
|
||||
// Sibling-subtree app C must NOT — the chain union is the isolation boundary.
|
||||
let none = repo
|
||||
.list_matching_kv(AppId::from(c.0), "users", KvEventOp::Insert)
|
||||
.await
|
||||
.expect("match for C");
|
||||
assert!(
|
||||
!none.iter().any(|m| m.trigger_id == t.0.into()),
|
||||
"a sibling-subtree app must NOT match another subtree's group template"
|
||||
);
|
||||
|
||||
// Cleanup (best-effort; ordering respects the FKs).
|
||||
let _ = sqlx::query("DELETE FROM triggers WHERE id = $1")
|
||||
.bind(t.0)
|
||||
.execute(&pool)
|
||||
.await;
|
||||
let _ = sqlx::query("DELETE FROM scripts WHERE id = $1")
|
||||
.bind(s.0)
|
||||
.execute(&pool)
|
||||
.await;
|
||||
let _ = sqlx::query("DELETE FROM apps WHERE id = ANY($1)")
|
||||
.bind(vec![a.0, c.0])
|
||||
.execute(&pool)
|
||||
.await;
|
||||
let _ = sqlx::query("DELETE FROM groups WHERE id = ANY($1)")
|
||||
.bind(vec![g, g2])
|
||||
.execute(&pool)
|
||||
.await;
|
||||
let _ = sqlx::query("DELETE FROM admin_users WHERE id = $1")
|
||||
.bind(admin)
|
||||
.execute(&pool)
|
||||
.await;
|
||||
}
|
||||
@@ -1,237 +0,0 @@
|
||||
//! §11 tail integration test: `sealed` (non-suppressible) group templates.
|
||||
//! A group marks a TRIGGER and a ROUTE template `sealed = true`; a descendant
|
||||
//! app that declares a `[suppress]` for both references STILL fires the trigger
|
||||
//! (dispatch anti-join skips a sealed row) and STILL serves the route
|
||||
//! (rebuild-time skip is gated on `!sealed`). The gate is exactly `sealed`: an
|
||||
//! UNSEALED sibling template with the same suppression is still declined.
|
||||
//!
|
||||
//! Deterministic: drives `list_matching_kv` + `list_effective` /
|
||||
//! `compile_effective_routes` directly (no async dispatcher). Skips cleanly
|
||||
//! when `DATABASE_URL` is unset.
|
||||
|
||||
#![allow(
|
||||
clippy::needless_pass_by_value,
|
||||
clippy::many_single_char_names,
|
||||
clippy::too_many_lines
|
||||
)]
|
||||
|
||||
use std::collections::HashSet;
|
||||
|
||||
use picloud_manager_core::route_admin::compile_effective_routes;
|
||||
use picloud_manager_core::route_repo::{PostgresRouteRepository, RouteRepository};
|
||||
use picloud_manager_core::trigger_repo::{PostgresTriggerRepo, TriggerRepo};
|
||||
use picloud_shared::{AppId, KvEventOp};
|
||||
use sqlx::postgres::PgPoolOptions;
|
||||
use sqlx::PgPool;
|
||||
use uuid::Uuid;
|
||||
|
||||
async fn pool_or_skip() -> Option<PgPool> {
|
||||
let Ok(url) = std::env::var("DATABASE_URL") else {
|
||||
eprintln!("sealed_templates: DATABASE_URL unset — skipping");
|
||||
return None;
|
||||
};
|
||||
let pool = PgPoolOptions::new()
|
||||
.max_connections(2)
|
||||
.connect(&url)
|
||||
.await
|
||||
.expect("connect to DATABASE_URL");
|
||||
sqlx::migrate!("./migrations")
|
||||
.run(&pool)
|
||||
.await
|
||||
.expect("apply migrations");
|
||||
Some(pool)
|
||||
}
|
||||
|
||||
async fn id1(pool: &PgPool, sql: &str, bind: &str) -> Uuid {
|
||||
let row: (Uuid,) = sqlx::query_as(sql)
|
||||
.bind(bind)
|
||||
.fetch_one(pool)
|
||||
.await
|
||||
.expect("insert returning id");
|
||||
row.0
|
||||
}
|
||||
|
||||
/// A group-owned script handler.
|
||||
async fn group_handler(pool: &PgPool, name: &str, group: Uuid) -> Uuid {
|
||||
let row: (Uuid,) = sqlx::query_as(
|
||||
"INSERT INTO scripts (name, source, group_id) VALUES ($1, 'x', $2) RETURNING id",
|
||||
)
|
||||
.bind(name)
|
||||
.bind(group)
|
||||
.fetch_one(pool)
|
||||
.await
|
||||
.expect("group handler");
|
||||
row.0
|
||||
}
|
||||
|
||||
/// A group-owned kv trigger TEMPLATE (glob `*`, op insert), with `sealed`.
|
||||
async fn kv_template(pool: &PgPool, group: Uuid, script: Uuid, admin: Uuid, sealed: bool) -> Uuid {
|
||||
let row: (Uuid,) = sqlx::query_as(
|
||||
"INSERT INTO triggers \
|
||||
(group_id, script_id, kind, enabled, dispatch_mode, \
|
||||
retry_max_attempts, retry_backoff, retry_base_ms, \
|
||||
registered_by_principal, name, sealed) \
|
||||
VALUES ($1, $2, 'kv', TRUE, 'async', 3, 'exponential', 1000, $3, $4, $5) RETURNING id",
|
||||
)
|
||||
.bind(group)
|
||||
.bind(script)
|
||||
.bind(admin)
|
||||
.bind(Uuid::new_v4().simple().to_string())
|
||||
.bind(sealed)
|
||||
.fetch_one(pool)
|
||||
.await
|
||||
.expect("kv template");
|
||||
sqlx::query(
|
||||
"INSERT INTO kv_trigger_details (trigger_id, collection_glob, ops) \
|
||||
VALUES ($1, '*', ARRAY['insert'])",
|
||||
)
|
||||
.bind(row.0)
|
||||
.execute(pool)
|
||||
.await
|
||||
.expect("kv details");
|
||||
row.0
|
||||
}
|
||||
|
||||
/// A group-owned route TEMPLATE at `path`, with `sealed`.
|
||||
async fn route_template(pool: &PgPool, group: Uuid, script: Uuid, path: &str, sealed: bool) {
|
||||
sqlx::query(
|
||||
"INSERT INTO routes \
|
||||
(group_id, script_id, host_kind, host, path_kind, path, method, sealed) \
|
||||
VALUES ($1, $2, 'any', '', 'exact', $3, NULL, $4)",
|
||||
)
|
||||
.bind(group)
|
||||
.bind(script)
|
||||
.bind(path)
|
||||
.bind(sealed)
|
||||
.execute(pool)
|
||||
.await
|
||||
.expect("route template");
|
||||
}
|
||||
|
||||
/// Whether `app`'s compiled route slice serves `path`.
|
||||
async fn serves(repo: &PostgresRouteRepository, app: Uuid, path: &str) -> bool {
|
||||
let effective = repo.list_effective().await.expect("list_effective");
|
||||
let suppressed: HashSet<(AppId, String)> = repo
|
||||
.list_route_suppressions()
|
||||
.await
|
||||
.expect("list_route_suppressions")
|
||||
.into_iter()
|
||||
.collect();
|
||||
compile_effective_routes(&effective, &suppressed)
|
||||
.into_iter()
|
||||
.any(|c| c.app_id == AppId::from(app) && format!("{:?}", c.path).contains(path))
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn sealed_template_survives_suppression_unsealed_still_declined() {
|
||||
let Some(pool) = pool_or_skip().await else {
|
||||
return;
|
||||
};
|
||||
let sfx = Uuid::new_v4().simple().to_string();
|
||||
let admin = id1(
|
||||
&pool,
|
||||
"INSERT INTO admin_users (username, password_hash) VALUES ($1, 'x') RETURNING id",
|
||||
&format!("seal-{sfx}"),
|
||||
)
|
||||
.await;
|
||||
|
||||
// Group G owns two handlers, each with a trigger + a route template:
|
||||
// `audit` → SEALED (non-suppressible)
|
||||
// `plain` → unsealed (suppressible, the control)
|
||||
let g = id1(
|
||||
&pool,
|
||||
"INSERT INTO groups (slug, name) VALUES ($1, $1) RETURNING id",
|
||||
&format!("seal-g-{sfx}"),
|
||||
)
|
||||
.await;
|
||||
let audit_name = format!("audit-{sfx}");
|
||||
let plain_name = format!("plain-{sfx}");
|
||||
let audit = group_handler(&pool, &audit_name, g).await;
|
||||
let plain = group_handler(&pool, &plain_name, g).await;
|
||||
|
||||
let sealed_trig = kv_template(&pool, g, audit, admin, true).await;
|
||||
let plain_trig = kv_template(&pool, g, plain, admin, false).await;
|
||||
route_template(&pool, g, audit, "/sealed", true).await;
|
||||
route_template(&pool, g, plain, "/plain", false).await;
|
||||
|
||||
// App A suppresses BOTH handlers + BOTH paths.
|
||||
let a: (Uuid,) =
|
||||
sqlx::query_as("INSERT INTO apps (slug, name, group_id) VALUES ($1, $1, $2) RETURNING id")
|
||||
.bind(format!("seal-a-{sfx}"))
|
||||
.bind(g)
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.expect("app A");
|
||||
let a = a.0;
|
||||
sqlx::query(
|
||||
"INSERT INTO template_suppressions (app_id, target_kind, reference) VALUES \
|
||||
($1, 'trigger', $2), ($1, 'trigger', $3), \
|
||||
($1, 'route', '/sealed'), ($1, 'route', '/plain')",
|
||||
)
|
||||
.bind(a)
|
||||
.bind(&audit_name)
|
||||
.bind(&plain_name)
|
||||
.execute(&pool)
|
||||
.await
|
||||
.expect("suppressions for A");
|
||||
|
||||
let trig = PostgresTriggerRepo::new(pool.clone());
|
||||
let routes = PostgresRouteRepository::new(pool.clone());
|
||||
|
||||
let matched = trig
|
||||
.list_matching_kv(AppId::from(a), "users", KvEventOp::Insert)
|
||||
.await
|
||||
.expect("match A");
|
||||
|
||||
// Sealed template: suppression is IGNORED — the trigger still fires and the
|
||||
// route still serves.
|
||||
assert!(
|
||||
matched.iter().any(|m| m.trigger_id == sealed_trig.into()),
|
||||
"a SEALED trigger template fires despite the suppression"
|
||||
);
|
||||
assert!(
|
||||
serves(&routes, a, "/sealed").await,
|
||||
"a SEALED route template serves despite the suppression"
|
||||
);
|
||||
|
||||
// Unsealed control: the same suppression DOES decline it — proving the gate
|
||||
// is exactly `sealed`, not a blanket bypass.
|
||||
assert!(
|
||||
!matched.iter().any(|m| m.trigger_id == plain_trig.into()),
|
||||
"an UNSEALED trigger template is still declined by the suppression"
|
||||
);
|
||||
assert!(
|
||||
!serves(&routes, a, "/plain").await,
|
||||
"an UNSEALED route template is still declined by the suppression"
|
||||
);
|
||||
|
||||
// Cleanup (FK order).
|
||||
let _ = sqlx::query("DELETE FROM triggers WHERE id = ANY($1)")
|
||||
.bind(vec![sealed_trig, plain_trig])
|
||||
.execute(&pool)
|
||||
.await;
|
||||
let _ = sqlx::query("DELETE FROM routes WHERE group_id = $1")
|
||||
.bind(g)
|
||||
.execute(&pool)
|
||||
.await;
|
||||
let _ = sqlx::query("DELETE FROM template_suppressions WHERE app_id = $1")
|
||||
.bind(a)
|
||||
.execute(&pool)
|
||||
.await;
|
||||
let _ = sqlx::query("DELETE FROM scripts WHERE id = ANY($1)")
|
||||
.bind(vec![audit, plain])
|
||||
.execute(&pool)
|
||||
.await;
|
||||
let _ = sqlx::query("DELETE FROM apps WHERE id = $1")
|
||||
.bind(a)
|
||||
.execute(&pool)
|
||||
.await;
|
||||
let _ = sqlx::query("DELETE FROM groups WHERE id = $1")
|
||||
.bind(g)
|
||||
.execute(&pool)
|
||||
.await;
|
||||
let _ = sqlx::query("DELETE FROM admin_users WHERE id = $1")
|
||||
.bind(admin)
|
||||
.execute(&pool)
|
||||
.await;
|
||||
}
|
||||
@@ -1,225 +0,0 @@
|
||||
//! §11.6 D2 integration test: SHARED-topic pubsub fan-out.
|
||||
//!
|
||||
//! A group-owned `shared = true` pubsub trigger watches the group's shared
|
||||
//! topic. A SHARED publish (`fan_out_shared_publish` on the owning group) fans
|
||||
//! out to it — but NOT to a per-app pubsub trigger or a non-shared group
|
||||
//! template of the same pattern. Conversely a per-app publish
|
||||
//! (`fan_out_publish`) fans out to the app's own + inherited non-shared group
|
||||
//! templates but NEVER the shared trigger (`shared` is the namespace boundary).
|
||||
//!
|
||||
//! Deterministic: drives the repo fan-out directly (no async dispatcher). Skips
|
||||
//! when `DATABASE_URL` is unset.
|
||||
|
||||
#![allow(clippy::too_many_lines)]
|
||||
|
||||
use picloud_manager_core::pubsub_repo::{PostgresPubsubRepo, PublishCtx, PubsubRepo};
|
||||
use picloud_shared::{AppId, ExecutionId, GroupId};
|
||||
use sqlx::postgres::PgPoolOptions;
|
||||
use sqlx::PgPool;
|
||||
use uuid::Uuid;
|
||||
|
||||
async fn pool_or_skip() -> Option<PgPool> {
|
||||
let Ok(url) = std::env::var("DATABASE_URL") else {
|
||||
eprintln!("shared_topics: DATABASE_URL unset — skipping");
|
||||
return None;
|
||||
};
|
||||
let pool = PgPoolOptions::new()
|
||||
.max_connections(2)
|
||||
.connect(&url)
|
||||
.await
|
||||
.expect("connect");
|
||||
sqlx::migrate!("./migrations")
|
||||
.run(&pool)
|
||||
.await
|
||||
.expect("migrate");
|
||||
Some(pool)
|
||||
}
|
||||
|
||||
/// Insert a pubsub trigger (owner + `shared` flag) + its details; returns id.
|
||||
async fn pubsub_trigger(
|
||||
pool: &PgPool,
|
||||
app_id: Option<Uuid>,
|
||||
group_id: Option<Uuid>,
|
||||
script: Uuid,
|
||||
admin: Uuid,
|
||||
shared: bool,
|
||||
pattern: &str,
|
||||
) -> Uuid {
|
||||
let row: (Uuid,) = sqlx::query_as(
|
||||
"INSERT INTO triggers \
|
||||
(app_id, group_id, script_id, kind, enabled, dispatch_mode, \
|
||||
retry_max_attempts, retry_backoff, retry_base_ms, \
|
||||
registered_by_principal, name, shared) \
|
||||
VALUES ($1, $2, $3, 'pubsub', TRUE, 'async', 3, 'exponential', 1000, $4, $5, $6) \
|
||||
RETURNING id",
|
||||
)
|
||||
.bind(app_id)
|
||||
.bind(group_id)
|
||||
.bind(script)
|
||||
.bind(admin)
|
||||
.bind(Uuid::new_v4().simple().to_string())
|
||||
.bind(shared)
|
||||
.fetch_one(pool)
|
||||
.await
|
||||
.unwrap();
|
||||
sqlx::query("INSERT INTO pubsub_trigger_details (trigger_id, topic_pattern) VALUES ($1, $2)")
|
||||
.bind(row.0)
|
||||
.bind(pattern)
|
||||
.execute(pool)
|
||||
.await
|
||||
.unwrap();
|
||||
row.0
|
||||
}
|
||||
|
||||
async fn outbox_count(pool: &PgPool, trigger_id: Uuid) -> i64 {
|
||||
let (n,): (i64,) = sqlx::query_as("SELECT COUNT(*) FROM outbox WHERE trigger_id = $1")
|
||||
.bind(trigger_id)
|
||||
.fetch_one(pool)
|
||||
.await
|
||||
.unwrap();
|
||||
n
|
||||
}
|
||||
|
||||
fn ctx(app: Uuid) -> PublishCtx {
|
||||
PublishCtx {
|
||||
app_id: AppId::from(app),
|
||||
origin_principal: None,
|
||||
trigger_depth: 0,
|
||||
root_execution_id: ExecutionId::new(),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn shared_topic_fan_out_respects_the_namespace_boundary() {
|
||||
let Some(pool) = pool_or_skip().await else {
|
||||
return;
|
||||
};
|
||||
let sfx = Uuid::new_v4().simple().to_string();
|
||||
let admin: (Uuid,) = sqlx::query_as(
|
||||
"INSERT INTO admin_users (username, password_hash) VALUES ($1, 'x') RETURNING id",
|
||||
)
|
||||
.bind(format!("st-{sfx}"))
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
let g: (Uuid,) = sqlx::query_as("INSERT INTO groups (slug, name) VALUES ($1, $1) RETURNING id")
|
||||
.bind(format!("st-g-{sfx}"))
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
let handler: (Uuid,) = sqlx::query_as(
|
||||
"INSERT INTO scripts (name, source, group_id) VALUES ($1, 'x', $2) RETURNING id",
|
||||
)
|
||||
.bind(format!("onmsg-{sfx}"))
|
||||
.bind(g.0)
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
let app: (Uuid,) =
|
||||
sqlx::query_as("INSERT INTO apps (slug, name, group_id) VALUES ($1, $1, $2) RETURNING id")
|
||||
.bind(format!("st-a-{sfx}"))
|
||||
.bind(g.0)
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
let app_script: (Uuid,) = sqlx::query_as(
|
||||
"INSERT INTO scripts (name, source, app_id) VALUES ($1, 'x', $2) RETURNING id",
|
||||
)
|
||||
.bind(format!("appmsg-{sfx}"))
|
||||
.bind(app.0)
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Three triggers, all watching `events.*`:
|
||||
let shared = pubsub_trigger(&pool, None, Some(g.0), handler.0, admin.0, true, "events.*").await;
|
||||
let group_tmpl = pubsub_trigger(
|
||||
&pool,
|
||||
None,
|
||||
Some(g.0),
|
||||
handler.0,
|
||||
admin.0,
|
||||
false,
|
||||
"events.*",
|
||||
)
|
||||
.await;
|
||||
let per_app = pubsub_trigger(
|
||||
&pool,
|
||||
Some(app.0),
|
||||
None,
|
||||
app_script.0,
|
||||
admin.0,
|
||||
false,
|
||||
"events.*",
|
||||
)
|
||||
.await;
|
||||
|
||||
let repo = PostgresPubsubRepo::new(pool.clone());
|
||||
let payload = serde_json::json!({"topic": "events.created", "message": 1});
|
||||
|
||||
// SHARED publish → only the shared trigger fires.
|
||||
let n = repo
|
||||
.fan_out_shared_publish(
|
||||
ctx(app.0),
|
||||
GroupId::from(g.0),
|
||||
"events.created",
|
||||
payload.clone(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(n, 1, "shared publish matched exactly the shared trigger");
|
||||
assert_eq!(outbox_count(&pool, shared).await, 1, "shared trigger fired");
|
||||
assert_eq!(
|
||||
outbox_count(&pool, group_tmpl).await,
|
||||
0,
|
||||
"a non-shared group template must NOT fire on a shared publish"
|
||||
);
|
||||
assert_eq!(
|
||||
outbox_count(&pool, per_app).await,
|
||||
0,
|
||||
"a per-app trigger must NOT fire on a shared publish"
|
||||
);
|
||||
|
||||
// PER-APP publish → the app's own + inherited non-shared template fire, but
|
||||
// NEVER the shared trigger.
|
||||
repo.fan_out_publish(ctx(app.0), "events.created", payload)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
outbox_count(&pool, shared).await,
|
||||
1,
|
||||
"the shared trigger must NOT fire on a per-app publish (still 1 from before)"
|
||||
);
|
||||
assert_eq!(
|
||||
outbox_count(&pool, group_tmpl).await,
|
||||
1,
|
||||
"the inherited non-shared group template fires on a per-app publish"
|
||||
);
|
||||
assert_eq!(
|
||||
outbox_count(&pool, per_app).await,
|
||||
1,
|
||||
"the per-app trigger fires on a per-app publish"
|
||||
);
|
||||
|
||||
// Cleanup.
|
||||
let _ = sqlx::query("DELETE FROM triggers WHERE registered_by_principal = $1")
|
||||
.bind(admin.0)
|
||||
.execute(&pool)
|
||||
.await;
|
||||
let _ = sqlx::query("DELETE FROM apps WHERE group_id = $1")
|
||||
.bind(g.0)
|
||||
.execute(&pool)
|
||||
.await;
|
||||
let _ = sqlx::query("DELETE FROM scripts WHERE group_id = $1")
|
||||
.bind(g.0)
|
||||
.execute(&pool)
|
||||
.await;
|
||||
let _ = sqlx::query("DELETE FROM groups WHERE id = $1")
|
||||
.bind(g.0)
|
||||
.execute(&pool)
|
||||
.await;
|
||||
let _ = sqlx::query("DELETE FROM admin_users WHERE id = $1")
|
||||
.bind(admin.0)
|
||||
.execute(&pool)
|
||||
.await;
|
||||
}
|
||||
@@ -1,192 +0,0 @@
|
||||
//! §11.6 integration test: SHARED-collection triggers.
|
||||
//! A group-owned `shared = true` trigger watches the group's shared collection.
|
||||
//! A write to that shared collection (by any subtree app) matches the shared
|
||||
//! trigger via the OWNING-group match query; a per-app collection of the same
|
||||
//! name does NOT match it, and the shared trigger does NOT match a per-app
|
||||
//! write (the `shared` flag is the namespace boundary).
|
||||
//!
|
||||
//! Deterministic: drives `list_matching_shared_kv` + `list_matching_kv`
|
||||
//! directly (no async dispatcher). Skips when `DATABASE_URL` is unset.
|
||||
|
||||
#![allow(clippy::needless_pass_by_value, clippy::too_many_lines)]
|
||||
|
||||
use picloud_manager_core::trigger_repo::{PostgresTriggerRepo, TriggerRepo};
|
||||
use picloud_shared::{AppId, GroupId, KvEventOp};
|
||||
use sqlx::postgres::PgPoolOptions;
|
||||
use sqlx::PgPool;
|
||||
use uuid::Uuid;
|
||||
|
||||
async fn pool_or_skip() -> Option<PgPool> {
|
||||
let Ok(url) = std::env::var("DATABASE_URL") else {
|
||||
eprintln!("shared_triggers: DATABASE_URL unset — skipping");
|
||||
return None;
|
||||
};
|
||||
let pool = PgPoolOptions::new()
|
||||
.max_connections(2)
|
||||
.connect(&url)
|
||||
.await
|
||||
.expect("connect");
|
||||
sqlx::migrate!("./migrations")
|
||||
.run(&pool)
|
||||
.await
|
||||
.expect("migrate");
|
||||
Some(pool)
|
||||
}
|
||||
|
||||
/// Insert a kv trigger (owner + `shared` flag) + its details; returns id.
|
||||
async fn kv_trigger(
|
||||
pool: &PgPool,
|
||||
app_id: Option<Uuid>,
|
||||
group_id: Option<Uuid>,
|
||||
script: Uuid,
|
||||
admin: Uuid,
|
||||
shared: bool,
|
||||
glob: &str,
|
||||
) -> Uuid {
|
||||
let row: (Uuid,) = sqlx::query_as(
|
||||
"INSERT INTO triggers \
|
||||
(app_id, group_id, script_id, kind, enabled, dispatch_mode, \
|
||||
retry_max_attempts, retry_backoff, retry_base_ms, \
|
||||
registered_by_principal, name, shared) \
|
||||
VALUES ($1, $2, $3, 'kv', TRUE, 'async', 3, 'exponential', 1000, $4, $5, $6) RETURNING id",
|
||||
)
|
||||
.bind(app_id)
|
||||
.bind(group_id)
|
||||
.bind(script)
|
||||
.bind(admin)
|
||||
.bind(Uuid::new_v4().simple().to_string())
|
||||
.bind(shared)
|
||||
.fetch_one(pool)
|
||||
.await
|
||||
.expect("trigger");
|
||||
sqlx::query(
|
||||
"INSERT INTO kv_trigger_details (trigger_id, collection_glob, ops) \
|
||||
VALUES ($1, $2, ARRAY['insert'])",
|
||||
)
|
||||
.bind(row.0)
|
||||
.bind(glob)
|
||||
.execute(pool)
|
||||
.await
|
||||
.expect("details");
|
||||
row.0
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn shared_trigger_matches_only_shared_writes() {
|
||||
let Some(pool) = pool_or_skip().await else {
|
||||
return;
|
||||
};
|
||||
let sfx = Uuid::new_v4().simple().to_string();
|
||||
let admin = {
|
||||
let r: (Uuid,) = sqlx::query_as(
|
||||
"INSERT INTO admin_users (username, password_hash) VALUES ($1, 'x') RETURNING id",
|
||||
)
|
||||
.bind(format!("sh-{sfx}"))
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
r.0
|
||||
};
|
||||
// Group G with a handler + a SHARED kv trigger on collection "catalog".
|
||||
let g: (Uuid,) = sqlx::query_as("INSERT INTO groups (slug, name) VALUES ($1, $1) RETURNING id")
|
||||
.bind(format!("sh-g-{sfx}"))
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
let handler: (Uuid,) = sqlx::query_as(
|
||||
"INSERT INTO scripts (name, source, group_id) VALUES ($1, 'x', $2) RETURNING id",
|
||||
)
|
||||
.bind(format!("on-cat-{sfx}"))
|
||||
.bind(g.0)
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
let shared_trig = kv_trigger(&pool, None, Some(g.0), handler.0, admin, true, "catalog").await;
|
||||
|
||||
// App A under G with its OWN (non-shared) kv trigger on "catalog".
|
||||
let a: (Uuid,) =
|
||||
sqlx::query_as("INSERT INTO apps (slug, name, group_id) VALUES ($1, $1, $2) RETURNING id")
|
||||
.bind(format!("sh-a-{sfx}"))
|
||||
.bind(g.0)
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
let app_script: (Uuid,) = sqlx::query_as(
|
||||
"INSERT INTO scripts (name, source, app_id) VALUES ($1, 'x', $2) RETURNING id",
|
||||
)
|
||||
.bind(format!("app-cat-{sfx}"))
|
||||
.bind(a.0)
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
let app_trig = kv_trigger(
|
||||
&pool,
|
||||
Some(a.0),
|
||||
None,
|
||||
app_script.0,
|
||||
admin,
|
||||
false,
|
||||
"catalog",
|
||||
)
|
||||
.await;
|
||||
|
||||
let trig = PostgresTriggerRepo::new(pool.clone());
|
||||
|
||||
// A SHARED write to G's "catalog" → matches the shared trigger, NOT the
|
||||
// app's per-app trigger.
|
||||
let shared_matches = trig
|
||||
.list_matching_shared_kv(GroupId::from(g.0), "catalog", KvEventOp::Insert)
|
||||
.await
|
||||
.expect("shared match");
|
||||
assert!(
|
||||
shared_matches
|
||||
.iter()
|
||||
.any(|m| m.trigger_id == shared_trig.into()),
|
||||
"a shared write must match the group's shared trigger"
|
||||
);
|
||||
assert!(
|
||||
!shared_matches
|
||||
.iter()
|
||||
.any(|m| m.trigger_id == app_trig.into()),
|
||||
"a shared write must NOT match a per-app trigger of the same name"
|
||||
);
|
||||
|
||||
// A PER-APP write to app A's "catalog" → matches the app's trigger, NOT the
|
||||
// shared trigger (the per-app query excludes `shared = TRUE`).
|
||||
let app_matches = trig
|
||||
.list_matching_kv(AppId::from(a.0), "catalog", KvEventOp::Insert)
|
||||
.await
|
||||
.expect("app match");
|
||||
assert!(
|
||||
app_matches.iter().any(|m| m.trigger_id == app_trig.into()),
|
||||
"a per-app write must match the app's own trigger"
|
||||
);
|
||||
assert!(
|
||||
!app_matches
|
||||
.iter()
|
||||
.any(|m| m.trigger_id == shared_trig.into()),
|
||||
"a per-app write must NOT match the group's shared trigger"
|
||||
);
|
||||
|
||||
// Cleanup.
|
||||
let _ = sqlx::query("DELETE FROM triggers WHERE id = ANY($1)")
|
||||
.bind(vec![shared_trig, app_trig])
|
||||
.execute(&pool)
|
||||
.await;
|
||||
let _ = sqlx::query("DELETE FROM apps WHERE id = $1")
|
||||
.bind(a.0)
|
||||
.execute(&pool)
|
||||
.await;
|
||||
let _ = sqlx::query("DELETE FROM scripts WHERE id = ANY($1)")
|
||||
.bind(vec![handler.0, app_script.0])
|
||||
.execute(&pool)
|
||||
.await;
|
||||
let _ = sqlx::query("DELETE FROM groups WHERE id = $1")
|
||||
.bind(g.0)
|
||||
.execute(&pool)
|
||||
.await;
|
||||
let _ = sqlx::query("DELETE FROM admin_users WHERE id = $1")
|
||||
.bind(admin)
|
||||
.execute(&pool)
|
||||
.await;
|
||||
}
|
||||
@@ -1,338 +0,0 @@
|
||||
//! §4.5 M5 integration test: materialization of STATEFUL group trigger
|
||||
//! templates. A group cron template materializes one app-owned copy per
|
||||
//! descendant app; a new app materializes on reconcile; an app leaving the
|
||||
//! subtree de-materializes; a group queue template skips (with a warning) an app
|
||||
//! that already fills that queue's consumer slot. A group EMAIL template
|
||||
//! materializes a per-app copy whose sealed inbound secret byte-equals the
|
||||
//! template's (shared-group-secret model — a verbatim copy, no reseal).
|
||||
//!
|
||||
//! Drives `rematerialize_stateful_templates` directly. Skips when `DATABASE_URL`
|
||||
//! is unset.
|
||||
|
||||
#![allow(clippy::too_many_lines, clippy::many_single_char_names)]
|
||||
|
||||
use picloud_manager_core::materialize::rematerialize_stateful_templates;
|
||||
use sqlx::postgres::PgPoolOptions;
|
||||
use sqlx::PgPool;
|
||||
use uuid::Uuid;
|
||||
|
||||
async fn pool_or_skip() -> Option<PgPool> {
|
||||
let Ok(url) = std::env::var("DATABASE_URL") else {
|
||||
eprintln!("stateful_templates: DATABASE_URL unset — skipping");
|
||||
return None;
|
||||
};
|
||||
let pool = PgPoolOptions::new()
|
||||
.max_connections(2)
|
||||
.connect(&url)
|
||||
.await
|
||||
.expect("connect");
|
||||
sqlx::migrate!("./migrations")
|
||||
.run(&pool)
|
||||
.await
|
||||
.expect("migrate");
|
||||
Some(pool)
|
||||
}
|
||||
|
||||
/// Count materialized copies of `template_id` owned by `app_id`.
|
||||
async fn copies(pool: &PgPool, app_id: Uuid, template_id: Uuid) -> i64 {
|
||||
let (n,): (i64,) = sqlx::query_as(
|
||||
"SELECT COUNT(*) FROM triggers WHERE app_id = $1 AND materialized_from = $2",
|
||||
)
|
||||
.bind(app_id)
|
||||
.bind(template_id)
|
||||
.fetch_one(pool)
|
||||
.await
|
||||
.unwrap();
|
||||
n
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn cron_template_materializes_per_descendant_and_reconciles() {
|
||||
let Some(pool) = pool_or_skip().await else {
|
||||
return;
|
||||
};
|
||||
let sfx = Uuid::new_v4().simple().to_string();
|
||||
let admin = {
|
||||
let r: (Uuid,) = sqlx::query_as(
|
||||
"INSERT INTO admin_users (username, password_hash) VALUES ($1, 'x') RETURNING id",
|
||||
)
|
||||
.bind(format!("mat-{sfx}"))
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
r.0
|
||||
};
|
||||
// Group G with a handler + a cron TEMPLATE.
|
||||
let g: (Uuid,) = sqlx::query_as("INSERT INTO groups (slug, name) VALUES ($1, $1) RETURNING id")
|
||||
.bind(format!("mat-g-{sfx}"))
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
let handler: (Uuid,) = sqlx::query_as(
|
||||
"INSERT INTO scripts (name, source, group_id) VALUES ($1, 'x', $2) RETURNING id",
|
||||
)
|
||||
.bind(format!("nightly-{sfx}"))
|
||||
.bind(g.0)
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
let tmpl: (Uuid,) = sqlx::query_as(
|
||||
"INSERT INTO triggers \
|
||||
(group_id, script_id, kind, enabled, dispatch_mode, retry_max_attempts, \
|
||||
retry_backoff, retry_base_ms, registered_by_principal, name) \
|
||||
VALUES ($1, $2, 'cron', TRUE, 'async', 3, 'exponential', 1000, $3, $4) RETURNING id",
|
||||
)
|
||||
.bind(g.0)
|
||||
.bind(handler.0)
|
||||
.bind(admin)
|
||||
.bind(format!("t-{sfx}"))
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
sqlx::query(
|
||||
"INSERT INTO cron_trigger_details (trigger_id, schedule, timezone) \
|
||||
VALUES ($1, '0 0 * * * *', 'UTC')",
|
||||
)
|
||||
.bind(tmpl.0)
|
||||
.execute(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Two apps under G.
|
||||
let mk_app = |slug: String| {
|
||||
let pool = pool.clone();
|
||||
let g = g.0;
|
||||
async move {
|
||||
let r: (Uuid,) = sqlx::query_as(
|
||||
"INSERT INTO apps (slug, name, group_id) VALUES ($1, $1, $2) RETURNING id",
|
||||
)
|
||||
.bind(slug)
|
||||
.bind(g)
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
r.0
|
||||
}
|
||||
};
|
||||
let a = mk_app(format!("mat-a-{sfx}")).await;
|
||||
let b = mk_app(format!("mat-b-{sfx}")).await;
|
||||
|
||||
// Reconcile → one copy per app.
|
||||
let w = rematerialize_stateful_templates(&pool).await.unwrap();
|
||||
assert!(w.is_empty(), "no warnings expected: {w:?}");
|
||||
assert_eq!(copies(&pool, a, tmpl.0).await, 1, "app A materialized");
|
||||
assert_eq!(copies(&pool, b, tmpl.0).await, 1, "app B materialized");
|
||||
|
||||
// Idempotent: a second reconcile does not duplicate.
|
||||
rematerialize_stateful_templates(&pool).await.unwrap();
|
||||
assert_eq!(copies(&pool, a, tmpl.0).await, 1, "no duplicate copy");
|
||||
|
||||
// The copy is app-owned + linked back to the template + carries the detail.
|
||||
let (kind, sched): (String, String) = sqlx::query_as(
|
||||
"SELECT t.kind, d.schedule FROM triggers t \
|
||||
JOIN cron_trigger_details d ON d.trigger_id = t.id \
|
||||
WHERE t.app_id = $1 AND t.materialized_from = $2",
|
||||
)
|
||||
.bind(a)
|
||||
.bind(tmpl.0)
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(kind, "cron");
|
||||
assert_eq!(sched, "0 0 * * * *");
|
||||
|
||||
// A new app under G materializes on the next reconcile.
|
||||
let c = mk_app(format!("mat-c-{sfx}")).await;
|
||||
rematerialize_stateful_templates(&pool).await.unwrap();
|
||||
assert_eq!(copies(&pool, c, tmpl.0).await, 1, "new app materialized");
|
||||
|
||||
// Reparent app A into a SIBLING group (not under G) → its copy
|
||||
// de-materializes.
|
||||
let g2: (Uuid,) =
|
||||
sqlx::query_as("INSERT INTO groups (slug, name) VALUES ($1, $1) RETURNING id")
|
||||
.bind(format!("mat-g2-{sfx}"))
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
sqlx::query("UPDATE apps SET group_id = $2 WHERE id = $1")
|
||||
.bind(a)
|
||||
.bind(g2.0)
|
||||
.execute(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
rematerialize_stateful_templates(&pool).await.unwrap();
|
||||
assert_eq!(
|
||||
copies(&pool, a, tmpl.0).await,
|
||||
0,
|
||||
"an app that left the subtree de-materializes"
|
||||
);
|
||||
assert_eq!(
|
||||
copies(&pool, b, tmpl.0).await,
|
||||
1,
|
||||
"a still-descendant app keeps its copy"
|
||||
);
|
||||
|
||||
// Cleanup (materialized copies CASCADE via app delete / template delete).
|
||||
let _ = sqlx::query("DELETE FROM triggers WHERE materialized_from = $1")
|
||||
.bind(tmpl.0)
|
||||
.execute(&pool)
|
||||
.await;
|
||||
let _ = sqlx::query("DELETE FROM triggers WHERE id = $1")
|
||||
.bind(tmpl.0)
|
||||
.execute(&pool)
|
||||
.await;
|
||||
let _ = sqlx::query("DELETE FROM apps WHERE id = ANY($1)")
|
||||
.bind(vec![a, b, c])
|
||||
.execute(&pool)
|
||||
.await;
|
||||
let _ = sqlx::query("DELETE FROM scripts WHERE id = $1")
|
||||
.bind(handler.0)
|
||||
.execute(&pool)
|
||||
.await;
|
||||
let _ = sqlx::query("DELETE FROM groups WHERE id = ANY($1)")
|
||||
.bind(vec![g.0, g2.0])
|
||||
.execute(&pool)
|
||||
.await;
|
||||
let _ = sqlx::query("DELETE FROM admin_users WHERE id = $1")
|
||||
.bind(admin)
|
||||
.execute(&pool)
|
||||
.await;
|
||||
}
|
||||
|
||||
/// §M5.5: a group EMAIL template materializes a per-descendant-app copy whose
|
||||
/// sealed inbound secret is a verbatim byte-copy of the template's (no reseal),
|
||||
/// and de-materializes when the app leaves the subtree.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn email_template_copies_sealed_secret_per_descendant() {
|
||||
let Some(pool) = pool_or_skip().await else {
|
||||
return;
|
||||
};
|
||||
let sfx = Uuid::new_v4().simple().to_string();
|
||||
let admin = {
|
||||
let r: (Uuid,) = sqlx::query_as(
|
||||
"INSERT INTO admin_users (username, password_hash) VALUES ($1, 'x') RETURNING id",
|
||||
)
|
||||
.bind(format!("mat-em-{sfx}"))
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
r.0
|
||||
};
|
||||
// Group G with a handler + an email TEMPLATE carrying a (fake) sealed secret.
|
||||
// The materializer copies the ciphertext bytes verbatim, so real crypto is
|
||||
// not needed to exercise the copy path.
|
||||
let g: (Uuid,) = sqlx::query_as("INSERT INTO groups (slug, name) VALUES ($1, $1) RETURNING id")
|
||||
.bind(format!("mat-em-g-{sfx}"))
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
let handler: (Uuid,) = sqlx::query_as(
|
||||
"INSERT INTO scripts (name, source, group_id) VALUES ($1, 'x', $2) RETURNING id",
|
||||
)
|
||||
.bind(format!("inbound-{sfx}"))
|
||||
.bind(g.0)
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
let tmpl: (Uuid,) = sqlx::query_as(
|
||||
"INSERT INTO triggers \
|
||||
(group_id, script_id, kind, enabled, dispatch_mode, retry_max_attempts, \
|
||||
retry_backoff, retry_base_ms, registered_by_principal, name) \
|
||||
VALUES ($1, $2, 'email', TRUE, 'async', 3, 'exponential', 1000, $3, $4) RETURNING id",
|
||||
)
|
||||
.bind(g.0)
|
||||
.bind(handler.0)
|
||||
.bind(admin)
|
||||
.bind(format!("t-em-{sfx}"))
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
let ct: Vec<u8> = vec![1, 2, 3, 4, 5, 6, 7, 8];
|
||||
let nonce: Vec<u8> = vec![9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9];
|
||||
sqlx::query(
|
||||
"INSERT INTO email_trigger_details \
|
||||
(trigger_id, inbound_secret_encrypted, inbound_secret_nonce) VALUES ($1, $2, $3)",
|
||||
)
|
||||
.bind(tmpl.0)
|
||||
.bind(&ct)
|
||||
.bind(&nonce)
|
||||
.execute(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// An app under G.
|
||||
let a: (Uuid,) =
|
||||
sqlx::query_as("INSERT INTO apps (slug, name, group_id) VALUES ($1, $1, $2) RETURNING id")
|
||||
.bind(format!("mat-em-a-{sfx}"))
|
||||
.bind(g.0)
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Reconcile → one email copy, secret bytes verbatim from the template.
|
||||
let w = rematerialize_stateful_templates(&pool).await.unwrap();
|
||||
assert!(w.is_empty(), "no warnings expected: {w:?}");
|
||||
assert_eq!(copies(&pool, a.0, tmpl.0).await, 1, "app materialized");
|
||||
let (copy_ct, copy_nonce): (Vec<u8>, Vec<u8>) = sqlx::query_as(
|
||||
"SELECT d.inbound_secret_encrypted, d.inbound_secret_nonce \
|
||||
FROM triggers t JOIN email_trigger_details d ON d.trigger_id = t.id \
|
||||
WHERE t.app_id = $1 AND t.materialized_from = $2",
|
||||
)
|
||||
.bind(a.0)
|
||||
.bind(tmpl.0)
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(copy_ct, ct, "inbound secret ciphertext copied verbatim");
|
||||
assert_eq!(copy_nonce, nonce, "inbound secret nonce copied verbatim");
|
||||
|
||||
// Idempotent.
|
||||
rematerialize_stateful_templates(&pool).await.unwrap();
|
||||
assert_eq!(copies(&pool, a.0, tmpl.0).await, 1, "no duplicate copy");
|
||||
|
||||
// Reparent the app out of the subtree → the copy de-materializes.
|
||||
let g2: (Uuid,) =
|
||||
sqlx::query_as("INSERT INTO groups (slug, name) VALUES ($1, $1) RETURNING id")
|
||||
.bind(format!("mat-em-g2-{sfx}"))
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
sqlx::query("UPDATE apps SET group_id = $2 WHERE id = $1")
|
||||
.bind(a.0)
|
||||
.bind(g2.0)
|
||||
.execute(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
rematerialize_stateful_templates(&pool).await.unwrap();
|
||||
assert_eq!(
|
||||
copies(&pool, a.0, tmpl.0).await,
|
||||
0,
|
||||
"an app that left the subtree de-materializes its email copy"
|
||||
);
|
||||
|
||||
// Cleanup.
|
||||
let _ = sqlx::query("DELETE FROM triggers WHERE materialized_from = $1")
|
||||
.bind(tmpl.0)
|
||||
.execute(&pool)
|
||||
.await;
|
||||
let _ = sqlx::query("DELETE FROM triggers WHERE id = $1")
|
||||
.bind(tmpl.0)
|
||||
.execute(&pool)
|
||||
.await;
|
||||
let _ = sqlx::query("DELETE FROM apps WHERE id = $1")
|
||||
.bind(a.0)
|
||||
.execute(&pool)
|
||||
.await;
|
||||
let _ = sqlx::query("DELETE FROM scripts WHERE id = $1")
|
||||
.bind(handler.0)
|
||||
.execute(&pool)
|
||||
.await;
|
||||
let _ = sqlx::query("DELETE FROM groups WHERE id = ANY($1)")
|
||||
.bind(vec![g.0, g2.0])
|
||||
.execute(&pool)
|
||||
.await;
|
||||
let _ = sqlx::query("DELETE FROM admin_users WHERE id = $1")
|
||||
.bind(admin)
|
||||
.execute(&pool)
|
||||
.await;
|
||||
}
|
||||
@@ -1,261 +0,0 @@
|
||||
//! §11 tail integration test: per-app opt-out of inherited group templates.
|
||||
//! An app that declares a suppression must NOT match the inherited TRIGGER
|
||||
//! (dispatch anti-join) nor serve the inherited ROUTE (rebuild-time skip); a
|
||||
//! sibling app with no suppression still inherits both. Suppression is
|
||||
//! inheritance-only — an app's OWN trigger on the suppressed handler still
|
||||
//! fires.
|
||||
//!
|
||||
//! Deterministic: drives `list_matching_kv` + `list_effective` /
|
||||
//! `compile_effective_routes` directly (no async dispatcher). Skips cleanly
|
||||
//! when `DATABASE_URL` is unset.
|
||||
|
||||
#![allow(
|
||||
clippy::needless_pass_by_value,
|
||||
clippy::many_single_char_names,
|
||||
clippy::too_many_lines
|
||||
)]
|
||||
|
||||
use std::collections::HashSet;
|
||||
|
||||
use picloud_manager_core::route_admin::compile_effective_routes;
|
||||
use picloud_manager_core::route_repo::{PostgresRouteRepository, RouteRepository};
|
||||
use picloud_manager_core::trigger_repo::{PostgresTriggerRepo, TriggerRepo};
|
||||
use picloud_shared::{AppId, KvEventOp};
|
||||
use sqlx::postgres::PgPoolOptions;
|
||||
use sqlx::PgPool;
|
||||
use uuid::Uuid;
|
||||
|
||||
async fn pool_or_skip() -> Option<PgPool> {
|
||||
let Ok(url) = std::env::var("DATABASE_URL") else {
|
||||
eprintln!("template_suppression: DATABASE_URL unset — skipping");
|
||||
return None;
|
||||
};
|
||||
let pool = PgPoolOptions::new()
|
||||
.max_connections(2)
|
||||
.connect(&url)
|
||||
.await
|
||||
.expect("connect to DATABASE_URL");
|
||||
sqlx::migrate!("./migrations")
|
||||
.run(&pool)
|
||||
.await
|
||||
.expect("apply migrations");
|
||||
Some(pool)
|
||||
}
|
||||
|
||||
async fn id1(pool: &PgPool, sql: &str, bind: &str) -> Uuid {
|
||||
let row: (Uuid,) = sqlx::query_as(sql)
|
||||
.bind(bind)
|
||||
.fetch_one(pool)
|
||||
.await
|
||||
.expect("insert returning id");
|
||||
row.0
|
||||
}
|
||||
|
||||
async fn app_under(pool: &PgPool, slug: &str, group: Uuid) -> Uuid {
|
||||
let row: (Uuid,) =
|
||||
sqlx::query_as("INSERT INTO apps (slug, name, group_id) VALUES ($1, $1, $2) RETURNING id")
|
||||
.bind(slug)
|
||||
.bind(group)
|
||||
.fetch_one(pool)
|
||||
.await
|
||||
.expect("app insert");
|
||||
row.0
|
||||
}
|
||||
|
||||
/// Whether `app`'s compiled route slice serves `/hello`.
|
||||
async fn serves_hello(repo: &PostgresRouteRepository, app: Uuid) -> bool {
|
||||
let effective = repo.list_effective().await.expect("list_effective");
|
||||
let suppressed: HashSet<(AppId, String)> = repo
|
||||
.list_route_suppressions()
|
||||
.await
|
||||
.expect("list_route_suppressions")
|
||||
.into_iter()
|
||||
.collect();
|
||||
compile_effective_routes(&effective, &suppressed)
|
||||
.into_iter()
|
||||
.any(|c| c.app_id == AppId::from(app) && format!("{:?}", c.path).contains("/hello"))
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn suppression_declines_inherited_trigger_and_route_only_for_declaring_app() {
|
||||
let Some(pool) = pool_or_skip().await else {
|
||||
return;
|
||||
};
|
||||
let sfx = Uuid::new_v4().simple().to_string();
|
||||
let admin = id1(
|
||||
&pool,
|
||||
"INSERT INTO admin_users (username, password_hash) VALUES ($1, 'x') RETURNING id",
|
||||
&format!("ts-{sfx}"),
|
||||
)
|
||||
.await;
|
||||
|
||||
// Group G owns a handler `audit` + a kv trigger template + a /hello route
|
||||
// template, both bound to it.
|
||||
let g = id1(
|
||||
&pool,
|
||||
"INSERT INTO groups (slug, name) VALUES ($1, $1) RETURNING id",
|
||||
&format!("ts-g-{sfx}"),
|
||||
)
|
||||
.await;
|
||||
let handler: (Uuid,) = sqlx::query_as(
|
||||
"INSERT INTO scripts (name, source, group_id) VALUES ($1, 'x', $2) RETURNING id",
|
||||
)
|
||||
.bind(format!("audit-{sfx}"))
|
||||
.bind(g)
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.expect("group handler");
|
||||
let handler_name = format!("audit-{sfx}");
|
||||
|
||||
let tmpl: (Uuid,) = sqlx::query_as(
|
||||
"INSERT INTO triggers \
|
||||
(group_id, script_id, kind, enabled, dispatch_mode, \
|
||||
retry_max_attempts, retry_backoff, retry_base_ms, \
|
||||
registered_by_principal, name) \
|
||||
VALUES ($1, $2, 'kv', TRUE, 'async', 3, 'exponential', 1000, $3, $4) RETURNING id",
|
||||
)
|
||||
.bind(g)
|
||||
.bind(handler.0)
|
||||
.bind(admin)
|
||||
.bind(format!("tmpl-{sfx}"))
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.expect("kv template");
|
||||
sqlx::query(
|
||||
"INSERT INTO kv_trigger_details (trigger_id, collection_glob, ops) \
|
||||
VALUES ($1, '*', ARRAY['insert'])",
|
||||
)
|
||||
.bind(tmpl.0)
|
||||
.execute(&pool)
|
||||
.await
|
||||
.expect("kv details");
|
||||
sqlx::query(
|
||||
"INSERT INTO routes (group_id, script_id, host_kind, host, path_kind, path, method) \
|
||||
VALUES ($1, $2, 'any', '', 'exact', '/hello', NULL)",
|
||||
)
|
||||
.bind(g)
|
||||
.bind(handler.0)
|
||||
.execute(&pool)
|
||||
.await
|
||||
.expect("route template");
|
||||
|
||||
// App A (suppresses both) + app B (no suppression), both under G.
|
||||
let a = app_under(&pool, &format!("ts-a-{sfx}"), g).await;
|
||||
let b = app_under(&pool, &format!("ts-b-{sfx}"), g).await;
|
||||
sqlx::query(
|
||||
"INSERT INTO template_suppressions (app_id, target_kind, reference) \
|
||||
VALUES ($1, 'trigger', $2), ($1, 'route', '/hello')",
|
||||
)
|
||||
.bind(a)
|
||||
.bind(&handler_name)
|
||||
.execute(&pool)
|
||||
.await
|
||||
.expect("suppressions for A");
|
||||
|
||||
let trig = PostgresTriggerRepo::new(pool.clone());
|
||||
let routes = PostgresRouteRepository::new(pool.clone());
|
||||
|
||||
// A suppressed → neither the inherited trigger matches nor the route serves.
|
||||
let a_trig = trig
|
||||
.list_matching_kv(AppId::from(a), "users", KvEventOp::Insert)
|
||||
.await
|
||||
.expect("match A");
|
||||
assert!(
|
||||
!a_trig.iter().any(|m| m.trigger_id == tmpl.0.into()),
|
||||
"the suppressing app must NOT match the inherited trigger"
|
||||
);
|
||||
assert!(
|
||||
!serves_hello(&routes, a).await,
|
||||
"the suppressing app must NOT serve the inherited route"
|
||||
);
|
||||
|
||||
// B did not suppress → still inherits both.
|
||||
let b_trig = trig
|
||||
.list_matching_kv(AppId::from(b), "users", KvEventOp::Insert)
|
||||
.await
|
||||
.expect("match B");
|
||||
assert!(
|
||||
b_trig.iter().any(|m| m.trigger_id == tmpl.0.into()),
|
||||
"a sibling app that did not suppress still inherits the trigger"
|
||||
);
|
||||
assert!(
|
||||
serves_hello(&routes, b).await,
|
||||
"a sibling app that did not suppress still serves the route"
|
||||
);
|
||||
|
||||
// Suppression is inheritance-only: A's OWN kv trigger on the same handler
|
||||
// name (a distinct app-owned script) still fires despite the suppression.
|
||||
let a_own_script: (Uuid,) = sqlx::query_as(
|
||||
"INSERT INTO scripts (name, source, app_id) VALUES ($1, 'x', $2) RETURNING id",
|
||||
)
|
||||
.bind(&handler_name)
|
||||
.bind(a)
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.expect("A own script");
|
||||
let a_own_trig: (Uuid,) = sqlx::query_as(
|
||||
"INSERT INTO triggers \
|
||||
(app_id, script_id, kind, enabled, dispatch_mode, \
|
||||
retry_max_attempts, retry_backoff, retry_base_ms, \
|
||||
registered_by_principal, name) \
|
||||
VALUES ($1, $2, 'kv', TRUE, 'async', 3, 'exponential', 1000, $3, $4) RETURNING id",
|
||||
)
|
||||
.bind(a)
|
||||
.bind(a_own_script.0)
|
||||
.bind(admin)
|
||||
.bind(format!("own-{sfx}"))
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.expect("A own trigger");
|
||||
sqlx::query(
|
||||
"INSERT INTO kv_trigger_details (trigger_id, collection_glob, ops) \
|
||||
VALUES ($1, '*', ARRAY['insert'])",
|
||||
)
|
||||
.bind(a_own_trig.0)
|
||||
.execute(&pool)
|
||||
.await
|
||||
.expect("A own kv details");
|
||||
|
||||
let a_after = trig
|
||||
.list_matching_kv(AppId::from(a), "users", KvEventOp::Insert)
|
||||
.await
|
||||
.expect("match A after own trigger");
|
||||
assert!(
|
||||
a_after.iter().any(|m| m.trigger_id == a_own_trig.0.into()),
|
||||
"the app's OWN trigger fires — suppression only declines inherited ones"
|
||||
);
|
||||
assert!(
|
||||
!a_after.iter().any(|m| m.trigger_id == tmpl.0.into()),
|
||||
"the inherited trigger stays suppressed"
|
||||
);
|
||||
|
||||
// Cleanup (FK order).
|
||||
let _ = sqlx::query("DELETE FROM triggers WHERE id = ANY($1)")
|
||||
.bind(vec![tmpl.0, a_own_trig.0])
|
||||
.execute(&pool)
|
||||
.await;
|
||||
let _ = sqlx::query("DELETE FROM routes WHERE group_id = $1")
|
||||
.bind(g)
|
||||
.execute(&pool)
|
||||
.await;
|
||||
let _ = sqlx::query("DELETE FROM template_suppressions WHERE app_id = $1")
|
||||
.bind(a)
|
||||
.execute(&pool)
|
||||
.await;
|
||||
let _ = sqlx::query("DELETE FROM scripts WHERE id = ANY($1)")
|
||||
.bind(vec![handler.0, a_own_script.0])
|
||||
.execute(&pool)
|
||||
.await;
|
||||
let _ = sqlx::query("DELETE FROM apps WHERE id = ANY($1)")
|
||||
.bind(vec![a, b])
|
||||
.execute(&pool)
|
||||
.await;
|
||||
let _ = sqlx::query("DELETE FROM groups WHERE id = $1")
|
||||
.bind(g)
|
||||
.execute(&pool)
|
||||
.await;
|
||||
let _ = sqlx::query("DELETE FROM admin_users WHERE id = $1")
|
||||
.bind(admin)
|
||||
.execute(&pool)
|
||||
.await;
|
||||
}
|
||||
@@ -34,7 +34,6 @@ toml = "0.8"
|
||||
directories = "5"
|
||||
rpassword = "7"
|
||||
anyhow = "1"
|
||||
# §7 M3: minting the stable project key in `.picloud/project.json`.
|
||||
uuid = { version = "1", features = ["v4"] }
|
||||
|
||||
[dev-dependencies]
|
||||
|
||||
@@ -123,6 +123,19 @@ impl Client {
|
||||
decode(resp).await
|
||||
}
|
||||
|
||||
/// `GET /api/v1/admin/apps/{id}/extension-points` — the app's OWN
|
||||
/// extension-point declarations (for `pic pull`).
|
||||
pub async fn extension_points_list(&self, ident: &str) -> Result<Vec<ExtPointDto>> {
|
||||
let resp = self
|
||||
.request(
|
||||
Method::GET,
|
||||
&format!("/api/v1/admin/apps/{}/extension-points", seg(ident)),
|
||||
)
|
||||
.send()
|
||||
.await?;
|
||||
decode(resp).await
|
||||
}
|
||||
|
||||
/// `GET /api/v1/admin/scripts` — every script the caller can see
|
||||
/// (server filters by membership for `Member`). Lets `pic scripts ls`
|
||||
/// (no `--app`) collapse what used to be an N+1 per-app walk into a
|
||||
@@ -1141,39 +1154,6 @@ impl Client {
|
||||
Ok(wrapped.value)
|
||||
}
|
||||
|
||||
/// §11.6 M4: a group's shared-KV collection.
|
||||
/// `GET /api/v1/admin/groups/{id_or_slug}/kv?collection=…`
|
||||
pub async fn group_kv_list(
|
||||
&self,
|
||||
group: &str,
|
||||
collection: &str,
|
||||
limit: u32,
|
||||
) -> Result<KvListPageDto> {
|
||||
let (group, collection) = (seg(group), seg(collection));
|
||||
let resp = self
|
||||
.request(
|
||||
Method::GET,
|
||||
&format!("/api/v1/admin/groups/{group}/kv?collection={collection}&limit={limit}"),
|
||||
)
|
||||
.send()
|
||||
.await?;
|
||||
decode(resp).await
|
||||
}
|
||||
|
||||
/// `GET /api/v1/admin/groups/{id_or_slug}/kv/{collection}/{key}`
|
||||
pub async fn group_kv_get(&self, group: &str, collection: &str, key: &str) -> Result<Value> {
|
||||
let (group, collection, key) = (seg(group), seg(collection), seg(key));
|
||||
let resp = self
|
||||
.request(
|
||||
Method::GET,
|
||||
&format!("/api/v1/admin/groups/{group}/kv/{collection}/{key}"),
|
||||
)
|
||||
.send()
|
||||
.await?;
|
||||
let wrapped: KvGetResponse = decode(resp).await?;
|
||||
Ok(wrapped.value)
|
||||
}
|
||||
|
||||
// --- Queues (G2, read-only admin surface) -----------------------------
|
||||
|
||||
/// `GET /api/v1/admin/apps/{id_or_slug}/queues`
|
||||
@@ -1262,8 +1242,8 @@ impl Client {
|
||||
}
|
||||
|
||||
/// `POST /api/v1/admin/tree/plan` — diff a whole project tree (Phase 5).
|
||||
/// `project_key` (§7, M3) lets the plan surface ownership conflicts + prune
|
||||
/// candidates for this repo.
|
||||
/// `project_key` (§7, M3) lets the server report ownership conflicts and
|
||||
/// structural-prune candidates for this repo.
|
||||
pub async fn plan_tree(
|
||||
&self,
|
||||
bundle: &serde_json::Value,
|
||||
@@ -1282,27 +1262,28 @@ impl Client {
|
||||
}
|
||||
|
||||
/// `POST /api/v1/admin/tree/apply` — reconcile a whole project tree in one
|
||||
/// transaction (Phase 5). `project_key` + `allow_takeover` drive the §7 M3
|
||||
/// ownership claim / takeover.
|
||||
/// transaction (Phase 5). `project_key`/`allow_takeover` drive ownership
|
||||
/// (§7, M3): the apply claims unclaimed declared groups for this project and
|
||||
/// refuses (or, with `allow_takeover`, seizes) ones another project owns.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub async fn apply_tree(
|
||||
&self,
|
||||
bundle: &serde_json::Value,
|
||||
prune: bool,
|
||||
expected_token: Option<&str>,
|
||||
env: Option<&str>,
|
||||
approved_envs: &[String],
|
||||
project_key: Option<&str>,
|
||||
allow_takeover: bool,
|
||||
env: Option<&str>,
|
||||
approved_envs: &[String],
|
||||
) -> Result<ApplyReportDto> {
|
||||
let body = serde_json::json!({
|
||||
"bundle": bundle,
|
||||
"prune": prune,
|
||||
"expected_token": expected_token,
|
||||
"env": env,
|
||||
"approved_envs": approved_envs,
|
||||
"project_key": project_key,
|
||||
"allow_takeover": allow_takeover,
|
||||
"env": env,
|
||||
"approved_envs": approved_envs,
|
||||
});
|
||||
let resp = self
|
||||
.request(Method::POST, "/api/v1/admin/tree/apply")
|
||||
@@ -1310,18 +1291,11 @@ impl Client {
|
||||
.send()
|
||||
.await?;
|
||||
if resp.status() == reqwest::StatusCode::CONFLICT {
|
||||
// 409 covers both bound-plan staleness AND an ownership conflict; the
|
||||
// server's message is self-explanatory for each, so surface it raw.
|
||||
let body = resp.text().await.unwrap_or_default();
|
||||
let msg = parse_error_body(&body).unwrap_or(body);
|
||||
// An ownership conflict (§7) is self-explanatory ("re-run with
|
||||
// --takeover"); only a staleness (token) conflict warrants the
|
||||
// re-plan hint. Distinguish on the server's message.
|
||||
if msg.contains("owned by another project") {
|
||||
return Err(anyhow!("{msg}"));
|
||||
}
|
||||
return Err(anyhow!(
|
||||
"{msg}\nThe project tree changed since your last `pic plan`. Re-run \
|
||||
`pic plan --dir` to review, then `pic apply --dir` — or add `--force`."
|
||||
));
|
||||
return Err(anyhow!("{msg}"));
|
||||
}
|
||||
decode(resp).await
|
||||
}
|
||||
@@ -1364,156 +1338,6 @@ impl NodeKind {
|
||||
}
|
||||
}
|
||||
|
||||
/// One row of the §5.5 extension-point report.
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct ExtensionPointInfoDto {
|
||||
pub name: String,
|
||||
#[serde(default)]
|
||||
pub declared_here: bool,
|
||||
#[serde(default)]
|
||||
pub provider: Option<String>,
|
||||
}
|
||||
|
||||
impl Client {
|
||||
/// `GET /api/v1/admin/{apps|groups}/{ident}/extension-points`.
|
||||
pub async fn extension_points_list(
|
||||
&self,
|
||||
kind: NodeKind,
|
||||
ident: &str,
|
||||
) -> Result<Vec<ExtensionPointInfoDto>> {
|
||||
let ident = seg(ident);
|
||||
let resp = self
|
||||
.request(
|
||||
Method::GET,
|
||||
&format!("/api/v1/admin/{}/{ident}/extension-points", kind.path()),
|
||||
)
|
||||
.send()
|
||||
.await?;
|
||||
decode(resp).await
|
||||
}
|
||||
|
||||
/// `GET /api/v1/admin/groups/{ident}/collections` (§11.6). Group-only —
|
||||
/// shared collections are declared on groups.
|
||||
pub async fn collections_list(&self, group_ident: &str) -> Result<Vec<CollectionInfoDto>> {
|
||||
let ident = seg(group_ident);
|
||||
let resp = self
|
||||
.request(
|
||||
Method::GET,
|
||||
&format!("/api/v1/admin/groups/{ident}/collections"),
|
||||
)
|
||||
.send()
|
||||
.await?;
|
||||
decode(resp).await
|
||||
}
|
||||
|
||||
/// `GET /api/v1/admin/groups/{ident}/triggers` (§11 tail). Group-only —
|
||||
/// trigger templates are declared on groups and fan out to descendant apps.
|
||||
pub async fn group_triggers_list(&self, group_ident: &str) -> Result<Vec<TriggerTemplateDto>> {
|
||||
let ident = seg(group_ident);
|
||||
let resp = self
|
||||
.request(
|
||||
Method::GET,
|
||||
&format!("/api/v1/admin/groups/{ident}/triggers"),
|
||||
)
|
||||
.send()
|
||||
.await?;
|
||||
decode(resp).await
|
||||
}
|
||||
|
||||
/// `GET /api/v1/admin/groups/{ident}/routes` (§11 tail). Group-only — route
|
||||
/// templates are declared on groups and inherited by descendant apps.
|
||||
pub async fn group_routes_list(&self, group_ident: &str) -> Result<Vec<RouteTemplateDto>> {
|
||||
let ident = seg(group_ident);
|
||||
let resp = self
|
||||
.request(Method::GET, &format!("/api/v1/admin/groups/{ident}/routes"))
|
||||
.send()
|
||||
.await?;
|
||||
decode(resp).await
|
||||
}
|
||||
|
||||
/// `GET /api/v1/admin/apps/{ident}/suppressions` (§11 tail). App-only — the
|
||||
/// inherited templates the app declines.
|
||||
pub async fn suppressions_list(&self, app_ident: &str) -> Result<Vec<SuppressionDto>> {
|
||||
let ident = seg(app_ident);
|
||||
let resp = self
|
||||
.request(
|
||||
Method::GET,
|
||||
&format!("/api/v1/admin/apps/{ident}/suppressions"),
|
||||
)
|
||||
.send()
|
||||
.await?;
|
||||
decode(resp).await
|
||||
}
|
||||
|
||||
/// §11 tail M1: a group's own suppressions (declined for its whole subtree).
|
||||
pub async fn group_suppressions_list(&self, group_ident: &str) -> Result<Vec<SuppressionDto>> {
|
||||
let ident = seg(group_ident);
|
||||
let resp = self
|
||||
.request(
|
||||
Method::GET,
|
||||
&format!("/api/v1/admin/groups/{ident}/suppressions"),
|
||||
)
|
||||
.send()
|
||||
.await?;
|
||||
decode(resp).await
|
||||
}
|
||||
}
|
||||
|
||||
/// One row of the §11 tail suppression report.
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct SuppressionDto {
|
||||
#[serde(default)]
|
||||
pub target_kind: String,
|
||||
#[serde(default)]
|
||||
pub reference: String,
|
||||
}
|
||||
|
||||
/// One row of the §11 tail trigger-template report.
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct TriggerTemplateDto {
|
||||
pub kind: String,
|
||||
#[serde(default)]
|
||||
pub target: String,
|
||||
#[serde(default)]
|
||||
pub script: String,
|
||||
pub enabled: bool,
|
||||
/// §11 tail: `true` for a sealed (non-suppressible) template.
|
||||
#[serde(default)]
|
||||
pub sealed: bool,
|
||||
/// §11.6: `true` for a shared-collection template.
|
||||
#[serde(default)]
|
||||
pub shared: bool,
|
||||
}
|
||||
|
||||
/// One row of the §11 tail route-template report.
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct RouteTemplateDto {
|
||||
#[serde(default)]
|
||||
pub method: String,
|
||||
#[serde(default)]
|
||||
pub host: String,
|
||||
#[serde(default)]
|
||||
pub path_kind: String,
|
||||
#[serde(default)]
|
||||
pub path: String,
|
||||
#[serde(default)]
|
||||
pub script: String,
|
||||
#[serde(default)]
|
||||
pub dispatch: String,
|
||||
pub enabled: bool,
|
||||
/// §11 tail: `true` for a sealed (non-suppressible) template.
|
||||
#[serde(default)]
|
||||
pub sealed: bool,
|
||||
}
|
||||
|
||||
/// One row of the §11.6 shared-collection report.
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct CollectionInfoDto {
|
||||
pub name: String,
|
||||
#[serde(default)]
|
||||
pub kind: String,
|
||||
}
|
||||
|
||||
// ---------- DTOs (CLI-local, wire-shape-matched) ----------
|
||||
|
||||
/// Response of `POST .../plan`: per-resource diffs grouped by kind.
|
||||
@@ -1531,10 +1355,6 @@ pub struct PlanDto {
|
||||
pub vars: Vec<ChangeDto>,
|
||||
#[serde(default)]
|
||||
pub extension_points: Vec<ChangeDto>,
|
||||
#[serde(default)]
|
||||
pub collections: Vec<ChangeDto>,
|
||||
#[serde(default)]
|
||||
pub suppressions: Vec<ChangeDto>,
|
||||
/// Fingerprint of the live state this plan was computed against; carried
|
||||
/// in `.picloud/` and replayed to `apply` for the bound-plan check.
|
||||
#[serde(default)]
|
||||
@@ -1549,6 +1369,14 @@ pub struct ChangeDto {
|
||||
pub detail: Option<String>,
|
||||
}
|
||||
|
||||
/// One of an owner's OWN extension-point declarations (`pic pull`).
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct ExtPointDto {
|
||||
pub name: String,
|
||||
#[serde(default)]
|
||||
pub default: Option<String>,
|
||||
}
|
||||
|
||||
/// Response of `POST .../tree/plan` (Phase 5): a per-node diff + one combined
|
||||
/// bound-plan token for the whole subtree.
|
||||
#[derive(Debug, Deserialize)]
|
||||
@@ -1557,24 +1385,35 @@ pub struct TreePlanDto {
|
||||
pub nodes: Vec<NodePlanDto>,
|
||||
#[serde(default)]
|
||||
pub state_token: String,
|
||||
/// Declared groups owned by another project (§7, M3) — surfaced so CI sees
|
||||
/// the conflict before `apply` refuses it.
|
||||
#[serde(default)]
|
||||
pub conflicts: Vec<OwnershipConflictDto>,
|
||||
/// Slugs of owned groups absent from the manifest — what `--prune` deletes.
|
||||
#[serde(default)]
|
||||
pub structural_prunes: Vec<String>,
|
||||
/// Route-template fan-out per declaring group (§4.5, M4a).
|
||||
#[serde(default)]
|
||||
pub template_blast_radius: Vec<TemplateBlastRadiusDto>,
|
||||
/// Environments the project marks confirm-required (§4.2, M5).
|
||||
#[serde(default)]
|
||||
pub approvals_required: Vec<String>,
|
||||
/// Declared groups another project already owns (§7, M3).
|
||||
#[serde(default)]
|
||||
pub conflicts: Vec<OwnershipConflictDto>,
|
||||
/// Owned-but-undeclared group slugs `apply --prune` would delete (§7, M3).
|
||||
#[serde(default)]
|
||||
pub structural_prunes: Vec<String>,
|
||||
}
|
||||
|
||||
/// A group declared by this manifest that another project owns (§7, M3).
|
||||
/// A single ownership conflict (`pic plan --dir`).
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct OwnershipConflictDto {
|
||||
pub slug: String,
|
||||
pub owner_key: String,
|
||||
}
|
||||
|
||||
/// One group's route-template blast radius (`pic plan --dir`).
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct TemplateBlastRadiusDto {
|
||||
pub group: String,
|
||||
pub affected_apps: usize,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct NodePlanDto {
|
||||
pub kind: String,
|
||||
@@ -1592,9 +1431,9 @@ pub struct NodePlanDto {
|
||||
#[serde(default)]
|
||||
pub extension_points: Vec<ChangeDto>,
|
||||
#[serde(default)]
|
||||
pub collections: Vec<ChangeDto>,
|
||||
pub route_templates: Vec<ChangeDto>,
|
||||
#[serde(default)]
|
||||
pub suppressions: Vec<ChangeDto>,
|
||||
pub trigger_templates: Vec<ChangeDto>,
|
||||
}
|
||||
|
||||
/// Response of `POST .../apply`: counts of what changed.
|
||||
@@ -1625,25 +1464,32 @@ pub struct ApplyReportDto {
|
||||
#[serde(default)]
|
||||
pub extension_points_created: u32,
|
||||
#[serde(default)]
|
||||
pub extension_points_updated: u32,
|
||||
#[serde(default)]
|
||||
pub extension_points_deleted: u32,
|
||||
#[serde(default)]
|
||||
pub collections_created: u32,
|
||||
pub groups_created: u32,
|
||||
#[serde(default)]
|
||||
pub collections_deleted: u32,
|
||||
#[serde(default)]
|
||||
pub suppressions_created: u32,
|
||||
#[serde(default)]
|
||||
pub suppressions_deleted: u32,
|
||||
/// Group nodes claimed this apply (§7, M3).
|
||||
pub groups_reparented: u32,
|
||||
#[serde(default)]
|
||||
pub groups_claimed: u32,
|
||||
/// Group nodes taken over from another project this apply (§7, M3).
|
||||
#[serde(default)]
|
||||
pub groups_taken_over: u32,
|
||||
/// Owned-but-undeclared groups deleted by structural prune (§7, M3).
|
||||
#[serde(default)]
|
||||
pub groups_pruned: u32,
|
||||
#[serde(default)]
|
||||
pub route_templates_created: u32,
|
||||
#[serde(default)]
|
||||
pub route_templates_updated: u32,
|
||||
#[serde(default)]
|
||||
pub route_templates_deleted: u32,
|
||||
#[serde(default)]
|
||||
pub trigger_templates_created: u32,
|
||||
#[serde(default)]
|
||||
pub trigger_templates_updated: u32,
|
||||
#[serde(default)]
|
||||
pub trigger_templates_deleted: u32,
|
||||
#[serde(default)]
|
||||
pub warnings: Vec<String>,
|
||||
}
|
||||
|
||||
@@ -1811,11 +1657,6 @@ pub struct TriggerDto {
|
||||
pub enabled: bool,
|
||||
pub dispatch_mode: String,
|
||||
pub retry_max_attempts: u32,
|
||||
/// §4.5 M5: true for a materialized copy of a group stateful template
|
||||
/// (inherited, read-only). `default` so an older server without the field
|
||||
/// still deserializes.
|
||||
#[serde(default)]
|
||||
pub materialized: bool,
|
||||
pub created_at: DateTime<Utc>,
|
||||
#[serde(default)]
|
||||
pub details: Value,
|
||||
|
||||
@@ -31,7 +31,7 @@ pub async fn run(
|
||||
// admin-gated, audited per-env approval is enforced server-side only on the
|
||||
// tree apply path. Single-node `apply --file` cannot provide that guarantee,
|
||||
// so rather than silently bypass a confirm-required env, refuse and direct
|
||||
// the user to `--dir`.
|
||||
// the user to `--dir` (which carries the policy + `--approve` to the server).
|
||||
if let (Some(env), Some(project)) = (env, &manifest.project) {
|
||||
if project
|
||||
.environments
|
||||
@@ -106,26 +106,24 @@ pub async fn run(
|
||||
),
|
||||
)
|
||||
.field(
|
||||
"extension_points",
|
||||
"extension-points",
|
||||
format!(
|
||||
"+{} -{}",
|
||||
report.extension_points_created, report.extension_points_deleted
|
||||
),
|
||||
)
|
||||
.field(
|
||||
"collections",
|
||||
format!(
|
||||
"+{} -{}",
|
||||
report.collections_created, report.collections_deleted
|
||||
),
|
||||
)
|
||||
.field(
|
||||
"suppressions",
|
||||
format!(
|
||||
"+{} -{}",
|
||||
report.suppressions_created, report.suppressions_deleted
|
||||
"+{} ~{} -{}",
|
||||
report.extension_points_created,
|
||||
report.extension_points_updated,
|
||||
report.extension_points_deleted
|
||||
),
|
||||
);
|
||||
// Structural changes only happen on a tree apply; omit the line otherwise.
|
||||
if report.groups_created > 0 || report.groups_reparented > 0 {
|
||||
block.field(
|
||||
"groups",
|
||||
format!(
|
||||
"+{} reparented {}",
|
||||
report.groups_created, report.groups_reparented
|
||||
),
|
||||
);
|
||||
}
|
||||
for w in &report.warnings {
|
||||
block.field("warning", w.clone());
|
||||
}
|
||||
@@ -141,9 +139,9 @@ pub async fn run_tree(
|
||||
prune: bool,
|
||||
yes: bool,
|
||||
force: bool,
|
||||
takeover: bool,
|
||||
env: Option<&str>,
|
||||
approve: &[String],
|
||||
takeover: bool,
|
||||
mode: OutputMode,
|
||||
) -> Result<()> {
|
||||
let creds = config::resolve()?;
|
||||
@@ -157,14 +155,10 @@ pub async fn run_tree(
|
||||
// Per-env approval gate (§4.2, M5): if the selected env is confirm-required,
|
||||
// it needs an explicit `--approve <env>` (a TTY may confirm interactively;
|
||||
// `--yes` does NOT cover it). The server re-checks this authoritatively — the
|
||||
// local check just fails fast with a clear message.
|
||||
// local check just fails fast with a clear message. `approved` is the set
|
||||
// sent on the wire.
|
||||
let approved = resolve_approvals(env, &envs, approve)?;
|
||||
|
||||
// Mint/read this repo's stable project key (§7, M3): the apply claims the
|
||||
// declared groups for this project and refuses ones another project owns
|
||||
// (unless `--takeover`, group-admin gated).
|
||||
let project_key = crate::linkstate::ensure_project_key(dir)?;
|
||||
|
||||
let token_key = crate::cmds::plan::TREE_TOKEN_KEY;
|
||||
let expected_token = if force {
|
||||
None
|
||||
@@ -174,15 +168,20 @@ pub async fn run_tree(
|
||||
.map(|l| l.state_token)
|
||||
};
|
||||
|
||||
// This repo's stable project key (§7, M3): the server claims declared
|
||||
// groups for it and refuses ones another project owns (unless --takeover).
|
||||
let project_key = crate::linkstate::ensure_project_key(dir)
|
||||
.context("establishing project identity in .picloud/")?;
|
||||
|
||||
let report = client
|
||||
.apply_tree(
|
||||
&bundle,
|
||||
prune,
|
||||
expected_token.as_deref(),
|
||||
env,
|
||||
&approved,
|
||||
Some(&project_key),
|
||||
takeover,
|
||||
env,
|
||||
&approved,
|
||||
)
|
||||
.await?;
|
||||
crate::linkstate::clear_plan(dir, token_key);
|
||||
@@ -190,13 +189,6 @@ pub async fn run_tree(
|
||||
let mut block = KvBlock::new();
|
||||
block
|
||||
.field("nodes", node_count.to_string())
|
||||
.field(
|
||||
"groups",
|
||||
format!(
|
||||
"claimed {} taken-over {} pruned {}",
|
||||
report.groups_claimed, report.groups_taken_over, report.groups_pruned
|
||||
),
|
||||
)
|
||||
.field(
|
||||
"scripts",
|
||||
format!(
|
||||
@@ -223,26 +215,68 @@ pub async fn run_tree(
|
||||
),
|
||||
)
|
||||
.field(
|
||||
"extension_points",
|
||||
"extension-points",
|
||||
format!(
|
||||
"+{} -{}",
|
||||
report.extension_points_created, report.extension_points_deleted
|
||||
),
|
||||
)
|
||||
.field(
|
||||
"collections",
|
||||
format!(
|
||||
"+{} -{}",
|
||||
report.collections_created, report.collections_deleted
|
||||
),
|
||||
)
|
||||
.field(
|
||||
"suppressions",
|
||||
format!(
|
||||
"+{} -{}",
|
||||
report.suppressions_created, report.suppressions_deleted
|
||||
"+{} ~{} -{}",
|
||||
report.extension_points_created,
|
||||
report.extension_points_updated,
|
||||
report.extension_points_deleted
|
||||
),
|
||||
);
|
||||
// Structural changes only happen on a tree apply; omit the line otherwise.
|
||||
if report.groups_created > 0
|
||||
|| report.groups_reparented > 0
|
||||
|| report.groups_pruned > 0
|
||||
|| report.groups_claimed > 0
|
||||
|| report.groups_taken_over > 0
|
||||
{
|
||||
block.field(
|
||||
"groups",
|
||||
format!(
|
||||
"+{} reparented {} pruned {}",
|
||||
report.groups_created, report.groups_reparented, report.groups_pruned
|
||||
),
|
||||
);
|
||||
if report.groups_claimed > 0 || report.groups_taken_over > 0 {
|
||||
block.field(
|
||||
"ownership",
|
||||
format!(
|
||||
"claimed {} taken-over {}",
|
||||
report.groups_claimed, report.groups_taken_over
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
// Route templates (§4.5, M4a) — the template rows; their per-app route
|
||||
// expansions are folded into the `routes` line above.
|
||||
if report.route_templates_created > 0
|
||||
|| report.route_templates_updated > 0
|
||||
|| report.route_templates_deleted > 0
|
||||
{
|
||||
block.field(
|
||||
"route-templates",
|
||||
format!(
|
||||
"+{} ~{} -{}",
|
||||
report.route_templates_created,
|
||||
report.route_templates_updated,
|
||||
report.route_templates_deleted
|
||||
),
|
||||
);
|
||||
}
|
||||
if report.trigger_templates_created > 0
|
||||
|| report.trigger_templates_updated > 0
|
||||
|| report.trigger_templates_deleted > 0
|
||||
{
|
||||
block.field(
|
||||
"trigger-templates",
|
||||
format!(
|
||||
"+{} ~{} -{}",
|
||||
report.trigger_templates_created,
|
||||
report.trigger_templates_updated,
|
||||
report.trigger_templates_deleted
|
||||
),
|
||||
);
|
||||
}
|
||||
for w in &report.warnings {
|
||||
block.field("warning", w.clone());
|
||||
}
|
||||
@@ -250,10 +284,6 @@ pub async fn run_tree(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// `--prune` deletes live scripts/routes/triggers absent from the manifest —
|
||||
/// irreversible. Require an explicit go-ahead: an interactive `y`, or `--yes`
|
||||
/// for non-interactive/CI use. Refuse a non-interactive prune without `--yes`
|
||||
/// rather than silently deleting (review the deletions first with `pic plan`).
|
||||
/// Resolve the set of environments the actor approves for this apply (§4.2, M5).
|
||||
/// If the selected `env` is confirm-required (per the root manifest's
|
||||
/// `[project]` policy) it must be approved — via `--approve <env>`, or an
|
||||
@@ -297,6 +327,10 @@ fn resolve_approvals(
|
||||
);
|
||||
}
|
||||
|
||||
/// `--prune` deletes live scripts/routes/triggers absent from the manifest —
|
||||
/// irreversible. Require an explicit go-ahead: an interactive `y`, or `--yes`
|
||||
/// for non-interactive/CI use. Refuse a non-interactive prune without `--yes`
|
||||
/// rather than silently deleting (review the deletions first with `pic plan`).
|
||||
fn confirm_prune(slug: &str) -> Result<()> {
|
||||
if !std::io::stdin().is_terminal() {
|
||||
anyhow::bail!(
|
||||
@@ -307,7 +341,8 @@ fn confirm_prune(slug: &str) -> Result<()> {
|
||||
}
|
||||
eprint!(
|
||||
"apply --prune will DELETE live scripts/routes/triggers on `{slug}` that are \
|
||||
absent from the manifest. This cannot be undone. Continue? [y/N] "
|
||||
absent from the manifest — and, for a tree apply, entire owned groups absent \
|
||||
from the manifest. This cannot be undone. Continue? [y/N] "
|
||||
);
|
||||
std::io::stderr().flush().ok();
|
||||
let mut answer = String::new();
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
//! `pic collections ls --group <g>` — read-only view of a group's §11.6 shared
|
||||
//! KV collections. Group-only (shared collections are owned by groups);
|
||||
//! authoring is declarative via the `[group]` manifest `collections = [...]`.
|
||||
//! Scripts read/write them with `kv::shared_collection("name")`.
|
||||
|
||||
use anyhow::Result;
|
||||
|
||||
use crate::client::Client;
|
||||
use crate::config;
|
||||
use crate::output::{OutputMode, Table};
|
||||
|
||||
pub async fn ls(group: &str, mode: OutputMode) -> Result<()> {
|
||||
let creds = config::resolve()?;
|
||||
let client = Client::from_creds(&creds)?;
|
||||
let items = client.collections_list(group).await?;
|
||||
|
||||
let mut table = Table::new(["name", "kind"]);
|
||||
for c in &items {
|
||||
table.row([c.name.clone(), c.kind.clone()]);
|
||||
}
|
||||
table.print(mode);
|
||||
Ok(())
|
||||
}
|
||||
@@ -1,48 +0,0 @@
|
||||
//! `pic extension-points ls --app|--group` — read-only view of a node's
|
||||
//! §5.5 extension points. For an app, each EP visible on its chain is shown
|
||||
//! with its resolved provider (`app override` / `inherited default` / `unset`);
|
||||
//! for a group, its own declared names. Authoring is declarative only (the
|
||||
//! manifest `extension_points = [...]`).
|
||||
|
||||
use anyhow::{bail, Result};
|
||||
|
||||
use crate::client::{Client, NodeKind};
|
||||
use crate::config;
|
||||
use crate::output::{OutputMode, Table};
|
||||
|
||||
pub async fn ls(app: Option<&str>, group: Option<&str>, mode: OutputMode) -> Result<()> {
|
||||
let (kind, ident) = match (app, group) {
|
||||
(Some(a), None) => (NodeKind::App, a),
|
||||
(None, Some(g)) => (NodeKind::Group, g),
|
||||
_ => bail!("`extension-points ls` requires exactly one of --app or --group"),
|
||||
};
|
||||
let creds = config::resolve()?;
|
||||
let client = Client::from_creds(&creds)?;
|
||||
let items = client.extension_points_list(kind, ident).await?;
|
||||
|
||||
match kind {
|
||||
NodeKind::App => {
|
||||
let mut table = Table::new(["name", "source", "provider"]);
|
||||
for ep in &items {
|
||||
table.row([
|
||||
ep.name.clone(),
|
||||
if ep.declared_here {
|
||||
"declared".into()
|
||||
} else {
|
||||
"inherited".into()
|
||||
},
|
||||
ep.provider.clone().unwrap_or_else(|| "unset".into()),
|
||||
]);
|
||||
}
|
||||
table.print(mode);
|
||||
}
|
||||
NodeKind::Group => {
|
||||
let mut table = Table::new(["name"]);
|
||||
for ep in &items {
|
||||
table.row([ep.name.clone()]);
|
||||
}
|
||||
table.print(mode);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -87,9 +87,9 @@ pub fn run(
|
||||
}
|
||||
fs::write(&manifest_path, body).with_context(|| format!("writing {MANIFEST_FILE}"))?;
|
||||
ensure_gitignored(dir)?;
|
||||
// Mint this repo's stable, gitignored project identity (§7, M3) up front so
|
||||
// the first tree apply already has an ownership handle to claim under.
|
||||
crate::linkstate::ensure_project_key(dir)?;
|
||||
// Mint the repo's stable project identity (§7, M3) so the first tree apply
|
||||
// claims its groups under a key that survives clones/CI. Gitignored.
|
||||
crate::linkstate::ensure_project_key(dir).context("minting project key in .picloud/")?;
|
||||
|
||||
let mut block = KvBlock::new();
|
||||
block
|
||||
@@ -116,7 +116,6 @@ fn scaffold_manifest(slug: &str, name: &str) -> Manifest {
|
||||
slug: slug.to_string(),
|
||||
name: name.to_string(),
|
||||
description: None,
|
||||
extension_points: Vec::new(),
|
||||
}),
|
||||
group: None,
|
||||
project: None,
|
||||
@@ -140,12 +139,13 @@ fn scaffold_manifest(slug: &str, name: &str) -> Manifest {
|
||||
path: "/hello".into(),
|
||||
dispatch_mode: DispatchMode::Sync,
|
||||
enabled: true,
|
||||
sealed: false,
|
||||
}],
|
||||
triggers: crate::manifest::ManifestTriggers::default(),
|
||||
secrets: crate::manifest::ManifestSecrets::default(),
|
||||
vars: std::collections::BTreeMap::new(),
|
||||
suppress: crate::manifest::ManifestSuppress::default(),
|
||||
extension_points: Vec::new(),
|
||||
route_templates: Vec::new(),
|
||||
trigger_templates: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -7,27 +7,16 @@
|
||||
|
||||
use std::io::Write;
|
||||
|
||||
use anyhow::{bail, Result};
|
||||
use anyhow::Result;
|
||||
|
||||
use crate::client::Client;
|
||||
use crate::config;
|
||||
use crate::output::{OutputMode, Table};
|
||||
|
||||
pub async fn ls(
|
||||
app: Option<&str>,
|
||||
group: Option<&str>,
|
||||
collection: &str,
|
||||
limit: u32,
|
||||
mode: OutputMode,
|
||||
) -> Result<()> {
|
||||
pub async fn ls(app: &str, collection: &str, limit: u32, mode: OutputMode) -> Result<()> {
|
||||
let creds = config::resolve()?;
|
||||
let client = Client::from_creds(&creds)?;
|
||||
let page = match (app, group) {
|
||||
(Some(a), None) => client.kv_list(a, collection, limit).await?,
|
||||
// §11.6 M4: a group's shared-KV collection.
|
||||
(None, Some(g)) => client.group_kv_list(g, collection, limit).await?,
|
||||
_ => bail!("provide exactly one of --app or --group"),
|
||||
};
|
||||
let page = client.kv_list(app, collection, limit).await?;
|
||||
let mut table = Table::new(["key"]);
|
||||
for k in page.keys {
|
||||
table.row([k]);
|
||||
@@ -42,19 +31,10 @@ pub async fn ls(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn get(
|
||||
app: Option<&str>,
|
||||
group: Option<&str>,
|
||||
collection: &str,
|
||||
key: &str,
|
||||
) -> Result<()> {
|
||||
pub async fn get(app: &str, collection: &str, key: &str) -> Result<()> {
|
||||
let creds = config::resolve()?;
|
||||
let client = Client::from_creds(&creds)?;
|
||||
let value = match (app, group) {
|
||||
(Some(a), None) => client.kv_get(a, collection, key).await?,
|
||||
(None, Some(g)) => client.group_kv_get(g, collection, key).await?,
|
||||
_ => bail!("provide exactly one of --app or --group"),
|
||||
};
|
||||
let value = client.kv_get(app, collection, key).await?;
|
||||
// Always emit the JSON value (pretty) so it pipes cleanly into jq.
|
||||
let pretty = serde_json::to_string_pretty(&value).unwrap_or_else(|_| value.to_string());
|
||||
println!("{pretty}");
|
||||
|
||||
@@ -3,10 +3,8 @@ pub mod api_keys;
|
||||
pub mod apply;
|
||||
pub mod apps;
|
||||
pub mod apps_domains;
|
||||
pub mod collections;
|
||||
pub mod config;
|
||||
pub mod dead_letters;
|
||||
pub mod extension_points;
|
||||
pub mod files;
|
||||
pub mod groups;
|
||||
pub mod init;
|
||||
@@ -21,7 +19,6 @@ pub mod queues;
|
||||
pub mod routes;
|
||||
pub mod scripts;
|
||||
pub mod secrets;
|
||||
pub mod suppress;
|
||||
pub mod topics;
|
||||
pub mod triggers;
|
||||
pub mod users;
|
||||
|
||||
@@ -50,15 +50,10 @@ pub async fn run_tree(dir: &Path, env: Option<&str>, mode: OutputMode) -> Result
|
||||
let creds = config::resolve()?;
|
||||
let client = Client::from_creds(&creds)?;
|
||||
let (bundle, _count, _envs) = crate::discover::build_tree(dir, env)?;
|
||||
// Mint/read this repo's project key (§7, M3) so the plan can surface
|
||||
// ownership conflicts + prune candidates. `plan` DOES mint it (a rare
|
||||
// write for an otherwise read-only command) rather than read-only-peek,
|
||||
// because the bound-plan token folds the ownership state gated on a key
|
||||
// being present: a keyless plan and a keyed apply would fold DIFFERENT
|
||||
// token parts and trip a spurious `StateMoved`. Minting here keeps plan and
|
||||
// apply keyed identically. The key is local + gitignored + idempotent.
|
||||
let project_key = crate::linkstate::ensure_project_key(dir)?;
|
||||
let plan = client.plan_tree(&bundle, Some(&project_key)).await?;
|
||||
// Mint/read this repo's project key so the server can report ownership
|
||||
// (§7, M3). Best-effort: a read-only dir still plans (ownership unreported).
|
||||
let project_key = crate::linkstate::ensure_project_key(dir).ok();
|
||||
let plan = client.plan_tree(&bundle, project_key.as_deref()).await?;
|
||||
if !plan.state_token.is_empty() {
|
||||
if let Err(e) = crate::linkstate::write_plan(dir, TREE_TOKEN_KEY, &plan.state_token) {
|
||||
eprintln!("warning: could not record plan state for `pic apply`: {e}");
|
||||
@@ -78,9 +73,9 @@ fn render_tree(plan: &TreePlanDto, mode: OutputMode) {
|
||||
("trigger", &n.triggers),
|
||||
("secret", &n.secrets),
|
||||
("var", &n.vars),
|
||||
("extension_point", &n.extension_points),
|
||||
("collection", &n.collections),
|
||||
("suppression", &n.suppressions),
|
||||
("extension-point", &n.extension_points),
|
||||
("route-template", &n.route_templates),
|
||||
("trigger-template", &n.trigger_templates),
|
||||
];
|
||||
for (rk, changes) in groups {
|
||||
for c in changes {
|
||||
@@ -96,29 +91,31 @@ fn render_tree(plan: &TreePlanDto, mode: OutputMode) {
|
||||
}
|
||||
table.print(mode);
|
||||
|
||||
// Per-env approval gating (§4.2, M5): surface the confirm-required envs so
|
||||
// CI sees the gate at plan time, before an apply refuses.
|
||||
if mode != OutputMode::Json && !plan.approvals_required.is_empty() {
|
||||
eprintln!("\nenvironments requiring explicit approval (apply with --approve <env>):");
|
||||
for e in &plan.approvals_required {
|
||||
eprintln!(" - {e}");
|
||||
}
|
||||
}
|
||||
|
||||
// Ownership (§7, M3): surface groups another project owns (apply refuses
|
||||
// without `--takeover`) and owned-but-undeclared groups `--prune` would
|
||||
// delete, so both are visible before an apply acts on them.
|
||||
// Ownership diagnostics (§7, M3) — surfaced to stderr in non-JSON output
|
||||
// (JSON consumers read `conflicts`/`structural_prunes` from the payload).
|
||||
if mode != OutputMode::Json {
|
||||
if !plan.conflicts.is_empty() {
|
||||
eprintln!("\ngroups owned by another project (apply with --takeover to claim):");
|
||||
eprintln!("\nownership conflicts (apply blocked without --takeover):");
|
||||
for c in &plan.conflicts {
|
||||
eprintln!(" - {} (owned by project {})", c.slug, c.owner_key);
|
||||
eprintln!(" - {} is owned by project {}", c.slug, c.owner_key);
|
||||
}
|
||||
}
|
||||
if !plan.structural_prunes.is_empty() {
|
||||
eprintln!("\ngroups this project owns but no longer declares (--prune deletes):");
|
||||
for s in &plan.structural_prunes {
|
||||
eprintln!(" - {s}");
|
||||
eprintln!("\ngroups absent from the manifest (deleted only with --prune):");
|
||||
for slug in &plan.structural_prunes {
|
||||
eprintln!(" - {slug}");
|
||||
}
|
||||
}
|
||||
if !plan.template_blast_radius.is_empty() {
|
||||
eprintln!("\nroute-template blast radius (every descendant app in the DB subtree):");
|
||||
for b in &plan.template_blast_radius {
|
||||
eprintln!(" - {} → {} app(s)", b.group, b.affected_apps);
|
||||
}
|
||||
}
|
||||
if !plan.approvals_required.is_empty() {
|
||||
eprintln!("\nenvironments requiring explicit approval (apply with --approve <env>):");
|
||||
for e in &plan.approvals_required {
|
||||
eprintln!(" - {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -192,20 +189,48 @@ pub fn build_bundle(manifest: &Manifest, base_dir: &Path) -> Result<Value> {
|
||||
);
|
||||
}
|
||||
|
||||
// Extension points: name + optional default module. The server's
|
||||
// `ExtPointSpec` deserializes this shape directly.
|
||||
let extension_points: Vec<Value> = manifest
|
||||
.extension_points
|
||||
.iter()
|
||||
.map(|e| {
|
||||
let mut o = Map::new();
|
||||
o.insert("name".into(), json!(e.name));
|
||||
if let Some(d) = &e.default {
|
||||
o.insert("default".into(), json!(d));
|
||||
}
|
||||
Value::Object(o)
|
||||
})
|
||||
.collect();
|
||||
|
||||
// Route templates (§4.5, M4a): name + the flattened route fields the
|
||||
// server's `RouteTemplateSpec` (name + `#[serde(flatten)] BundleRoute`)
|
||||
// deserializes. Group manifests only; the server rejects them on an app.
|
||||
let route_templates = manifest
|
||||
.route_templates
|
||||
.iter()
|
||||
.map(serde_json::to_value)
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
|
||||
// Trigger templates (§4.5, M4b): name + the flattened trigger spec
|
||||
// (kind/script/params) — the server's `TriggerTemplateSpec` deserializes
|
||||
// `{ name, <BundleTrigger> }`. Group manifests only.
|
||||
let trigger_templates = manifest
|
||||
.trigger_templates
|
||||
.iter()
|
||||
.map(serde_json::to_value)
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
|
||||
Ok(json!({
|
||||
"scripts": scripts,
|
||||
"routes": routes,
|
||||
"triggers": triggers,
|
||||
"secrets": manifest.secrets.names,
|
||||
"vars": vars,
|
||||
"extension_points": manifest.extension_points(),
|
||||
"collections": manifest
|
||||
.collections()
|
||||
.into_iter()
|
||||
.map(|(name, kind)| json!({ "name": name, "kind": kind }))
|
||||
.collect::<Vec<_>>(),
|
||||
"suppress_triggers": manifest.suppress.triggers,
|
||||
"suppress_routes": manifest.suppress.routes,
|
||||
"extension_points": extension_points,
|
||||
"route_templates": route_templates,
|
||||
"trigger_templates": trigger_templates,
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -220,15 +245,13 @@ fn tagged(kind: &str, spec: impl Serialize) -> Result<Value> {
|
||||
|
||||
fn render(plan: &PlanDto, mode: OutputMode) {
|
||||
let mut table = Table::new(["kind", "op", "resource", "detail"]);
|
||||
let groups: [(&str, &Vec<ChangeDto>); 8] = [
|
||||
let groups: [(&str, &Vec<ChangeDto>); 6] = [
|
||||
("script", &plan.scripts),
|
||||
("route", &plan.routes),
|
||||
("trigger", &plan.triggers),
|
||||
("secret", &plan.secrets),
|
||||
("var", &plan.vars),
|
||||
("extension_point", &plan.extension_points),
|
||||
("collection", &plan.collections),
|
||||
("suppression", &plan.suppressions),
|
||||
("extension-point", &plan.extension_points),
|
||||
];
|
||||
for (kind, changes) in groups {
|
||||
for c in changes {
|
||||
|
||||
@@ -18,8 +18,8 @@ use crate::client::{Client, VarOwnerArg};
|
||||
use crate::config;
|
||||
use crate::manifest::{
|
||||
CronTriggerSpec, DocsTriggerSpec, FilesTriggerSpec, KvTriggerSpec, Manifest, ManifestApp,
|
||||
ManifestRoute, ManifestScript, ManifestSecrets, ManifestTriggers, PubsubTriggerSpec,
|
||||
QueueTriggerSpec, MANIFEST_FILE,
|
||||
ManifestExtensionPoint, ManifestRoute, ManifestScript, ManifestSecrets, ManifestTriggers,
|
||||
PubsubTriggerSpec, QueueTriggerSpec, MANIFEST_FILE,
|
||||
};
|
||||
use crate::output::{KvBlock, OutputMode};
|
||||
|
||||
@@ -49,15 +49,6 @@ pub async fn run(app_ident: &str, dir: &Path, force: bool, mode: OutputMode) ->
|
||||
.secrets_list(VarOwnerArg::App(app_ident), None)
|
||||
.await?
|
||||
.secrets;
|
||||
// The app's OWN declared extension points (§5.5) — inherited ones belong to
|
||||
// the ancestor group's manifest, so filter to `declared_here`.
|
||||
let extension_points: Vec<String> = client
|
||||
.extension_points_list(crate::client::NodeKind::App, app_ident)
|
||||
.await?
|
||||
.into_iter()
|
||||
.filter(|ep| ep.declared_here)
|
||||
.map(|ep| ep.name)
|
||||
.collect();
|
||||
// App's OWN env-agnostic vars become the manifest `[vars]` block. Inherited
|
||||
// group vars and tombstones are excluded (pull exports own rows only, §4.6);
|
||||
// a value TOML can't represent (e.g. JSON null) is skipped with a warning.
|
||||
@@ -94,9 +85,6 @@ pub async fn run(app_ident: &str, dir: &Path, force: bool, mode: OutputMode) ->
|
||||
path: r.path,
|
||||
dispatch_mode: r.dispatch_mode,
|
||||
enabled: r.enabled,
|
||||
// `pull` reconciles an app; app-owned routes are never sealed
|
||||
// (sealing is a group-template property, §11 tail).
|
||||
sealed: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -161,9 +149,6 @@ pub async fn run(app_ident: &str, dir: &Path, force: bool, mode: OutputMode) ->
|
||||
ops: d.ops,
|
||||
dispatch_mode,
|
||||
retry_max_attempts,
|
||||
// app-owned triggers are never sealed/shared (group-only).
|
||||
sealed: false,
|
||||
shared: false,
|
||||
});
|
||||
}
|
||||
"docs" => {
|
||||
@@ -174,8 +159,6 @@ pub async fn run(app_ident: &str, dir: &Path, force: bool, mode: OutputMode) ->
|
||||
ops: d.ops,
|
||||
dispatch_mode,
|
||||
retry_max_attempts,
|
||||
sealed: false,
|
||||
shared: false,
|
||||
});
|
||||
}
|
||||
"files" => {
|
||||
@@ -186,8 +169,6 @@ pub async fn run(app_ident: &str, dir: &Path, force: bool, mode: OutputMode) ->
|
||||
ops: d.ops,
|
||||
dispatch_mode,
|
||||
retry_max_attempts,
|
||||
sealed: false,
|
||||
shared: false,
|
||||
});
|
||||
}
|
||||
"cron" => {
|
||||
@@ -207,9 +188,6 @@ pub async fn run(app_ident: &str, dir: &Path, force: bool, mode: OutputMode) ->
|
||||
topic_pattern: d.topic_pattern,
|
||||
dispatch_mode,
|
||||
retry_max_attempts,
|
||||
// app-owned triggers are never sealed/shared (group-only).
|
||||
sealed: false,
|
||||
shared: false,
|
||||
});
|
||||
}
|
||||
"queue" => {
|
||||
@@ -220,8 +198,6 @@ pub async fn run(app_ident: &str, dir: &Path, force: bool, mode: OutputMode) ->
|
||||
visibility_timeout_secs: Some(d.visibility_timeout_secs),
|
||||
dispatch_mode,
|
||||
retry_max_attempts,
|
||||
// app-owned triggers are never shared (group-only).
|
||||
shared: false,
|
||||
});
|
||||
}
|
||||
// `email` is skipped: the server stores the sealed secret value,
|
||||
@@ -234,12 +210,23 @@ pub async fn run(app_ident: &str, dir: &Path, force: bool, mode: OutputMode) ->
|
||||
eprintln!("warning: skipping {s} trigger — not yet representable in the manifest");
|
||||
}
|
||||
|
||||
// Extension points (§5.5): the app's own declarations, exported so a
|
||||
// re-applied pulled manifest doesn't prune them.
|
||||
let extension_points: Vec<ManifestExtensionPoint> = client
|
||||
.extension_points_list(app_ident)
|
||||
.await?
|
||||
.into_iter()
|
||||
.map(|e| ManifestExtensionPoint {
|
||||
name: e.name,
|
||||
default: e.default,
|
||||
})
|
||||
.collect();
|
||||
|
||||
let manifest = Manifest {
|
||||
app: Some(ManifestApp {
|
||||
slug: app.app.slug.clone(),
|
||||
name: app.app.name.clone(),
|
||||
description: app.app.description.clone(),
|
||||
extension_points,
|
||||
}),
|
||||
group: None,
|
||||
project: None,
|
||||
@@ -250,7 +237,10 @@ pub async fn run(app_ident: &str, dir: &Path, force: bool, mode: OutputMode) ->
|
||||
names: secrets.iter().map(|s| s.name.clone()).collect(),
|
||||
},
|
||||
vars: manifest_vars,
|
||||
suppress: crate::manifest::ManifestSuppress::default(),
|
||||
extension_points,
|
||||
// `pull` is app-scoped; templates are group-owned (§4.5).
|
||||
route_templates: Vec::new(),
|
||||
trigger_templates: Vec::new(),
|
||||
};
|
||||
|
||||
std::fs::write(&manifest_path, manifest.to_toml()?)
|
||||
|
||||
@@ -35,39 +35,6 @@ pub async fn ls(script_id: &str, mode: OutputMode) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// `pic routes ls --group <g>` — read-only view of a group's §11 tail route
|
||||
/// TEMPLATES (inherited live by every descendant app). Authoring is declarative
|
||||
/// via the `[group]` manifest `[[routes]]`.
|
||||
pub async fn ls_group(group: &str, mode: OutputMode) -> Result<()> {
|
||||
let creds = config::resolve()?;
|
||||
let client = Client::from_creds(&creds)?;
|
||||
let rows = client.group_routes_list(group).await?;
|
||||
let mut table = Table::new([
|
||||
"method",
|
||||
"host",
|
||||
"path_kind",
|
||||
"path",
|
||||
"script",
|
||||
"dispatch",
|
||||
"enabled",
|
||||
"sealed",
|
||||
]);
|
||||
for r in rows {
|
||||
table.row([
|
||||
r.method,
|
||||
r.host,
|
||||
r.path_kind,
|
||||
r.path,
|
||||
r.script,
|
||||
r.dispatch,
|
||||
r.enabled.to_string(),
|
||||
r.sealed.to_string(),
|
||||
]);
|
||||
}
|
||||
table.print(mode);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub async fn create(
|
||||
script_id: &str,
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
//! `pic suppress ls --app <a>` / `--group <g>` — read-only view of an owner's
|
||||
//! §11 tail opt-outs: the inherited group templates it declines. An app declines
|
||||
//! for itself; a group declines for its whole subtree (§11 tail M1). Authoring
|
||||
//! is declarative via the `[suppress]` block (`triggers = [...]` handler script
|
||||
//! names, `routes = [...]` paths).
|
||||
|
||||
use anyhow::{bail, Result};
|
||||
|
||||
use crate::client::Client;
|
||||
use crate::config;
|
||||
use crate::output::{OutputMode, Table};
|
||||
|
||||
pub async fn ls(app: Option<&str>, group: Option<&str>, mode: OutputMode) -> Result<()> {
|
||||
let creds = config::resolve()?;
|
||||
let client = Client::from_creds(&creds)?;
|
||||
let items = match (app, group) {
|
||||
(Some(a), None) => client.suppressions_list(a).await?,
|
||||
(None, Some(g)) => client.group_suppressions_list(g).await?,
|
||||
_ => bail!("provide exactly one of --app or --group"),
|
||||
};
|
||||
|
||||
let mut table = Table::new(["kind", "reference"]);
|
||||
for s in &items {
|
||||
table.row([s.target_kind.clone(), s.reference.clone()]);
|
||||
}
|
||||
table.print(mode);
|
||||
Ok(())
|
||||
}
|
||||
@@ -23,7 +23,6 @@ pub async fn ls(app: &str, mode: OutputMode) -> Result<()> {
|
||||
"enabled",
|
||||
"dispatch",
|
||||
"retry_max",
|
||||
"materialized",
|
||||
"created_at",
|
||||
]);
|
||||
for t in resp.triggers {
|
||||
@@ -34,7 +33,6 @@ pub async fn ls(app: &str, mode: OutputMode) -> Result<()> {
|
||||
t.enabled.to_string(),
|
||||
t.dispatch_mode,
|
||||
t.retry_max_attempts.to_string(),
|
||||
t.materialized.to_string(),
|
||||
t.created_at.to_rfc3339(),
|
||||
]);
|
||||
}
|
||||
@@ -42,28 +40,6 @@ pub async fn ls(app: &str, mode: OutputMode) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// `pic triggers ls --group <g>` — read-only view of a group's §11 tail trigger
|
||||
/// TEMPLATES (event kinds that fan out live to descendant apps). Authoring is
|
||||
/// declarative via the `[group]` manifest `[[triggers.*]]`.
|
||||
pub async fn ls_group(group: &str, mode: OutputMode) -> Result<()> {
|
||||
let creds = config::resolve()?;
|
||||
let client = Client::from_creds(&creds)?;
|
||||
let rows = client.group_triggers_list(group).await?;
|
||||
let mut table = Table::new(["kind", "target", "script", "enabled", "sealed", "shared"]);
|
||||
for t in rows {
|
||||
table.row([
|
||||
t.kind,
|
||||
t.target,
|
||||
t.script,
|
||||
t.enabled.to_string(),
|
||||
t.sealed.to_string(),
|
||||
t.shared.to_string(),
|
||||
]);
|
||||
}
|
||||
table.print(mode);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn rm(app: &str, trigger_id: &str) -> Result<()> {
|
||||
let creds = config::resolve()?;
|
||||
let client = Client::from_creds(&creds)?;
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
//! the server resolves ancestry from its own group tree, so the on-disk
|
||||
//! nesting is organizational, not authoritative here.
|
||||
|
||||
use std::collections::HashSet;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use anyhow::{bail, Context, Result};
|
||||
@@ -62,8 +62,10 @@ pub fn build_tree(
|
||||
);
|
||||
}
|
||||
let root_manifest = root.join(MANIFEST_FILE);
|
||||
let mut nodes = Vec::with_capacity(paths.len());
|
||||
let mut seen: HashSet<(String, String)> = HashSet::new();
|
||||
// First pass: load every manifest and index its DIRECTORY → (slug, is_group)
|
||||
// so a group node's parent can be inferred from the enclosing directory.
|
||||
let mut loaded: Vec<(PathBuf, Manifest)> = Vec::with_capacity(paths.len());
|
||||
let mut group_dirs: HashMap<PathBuf, String> = HashMap::new();
|
||||
let mut project: Option<crate::manifest::ManifestProject> = None;
|
||||
for path in &paths {
|
||||
let manifest = Manifest::load_with_env(path, env)
|
||||
@@ -80,14 +82,41 @@ pub fn build_tree(
|
||||
);
|
||||
}
|
||||
}
|
||||
let base_dir = path.parent().unwrap_or_else(|| Path::new("."));
|
||||
let bundle = build_bundle(&manifest, base_dir)?;
|
||||
let dir = path
|
||||
.parent()
|
||||
.unwrap_or_else(|| Path::new("."))
|
||||
.to_path_buf();
|
||||
if manifest.is_group() {
|
||||
group_dirs.insert(dir.clone(), manifest.slug().to_string());
|
||||
}
|
||||
loaded.push((dir, manifest));
|
||||
}
|
||||
|
||||
let mut nodes = Vec::with_capacity(loaded.len());
|
||||
let mut seen: HashSet<(String, String)> = HashSet::new();
|
||||
for (dir, manifest) in &loaded {
|
||||
let bundle = build_bundle(manifest, dir)?;
|
||||
let kind = if manifest.is_group() { "group" } else { "app" };
|
||||
let slug = manifest.slug().to_string();
|
||||
if !seen.insert((kind.to_string(), slug.clone())) {
|
||||
bail!("project tree declares {kind} `{slug}` more than once");
|
||||
}
|
||||
nodes.push(json!({ "kind": kind, "slug": slug, "bundle": bundle }));
|
||||
let mut node = json!({ "kind": kind, "slug": slug, "bundle": bundle });
|
||||
// Group nodes carry their parent (explicit `[group] parent`, else the
|
||||
// nearest ancestor directory's group) + display name, so the server can
|
||||
// create/reparent them (M2). App nodes ignore both server-side.
|
||||
if let Some(g) = &manifest.group {
|
||||
let parent = g
|
||||
.parent
|
||||
.clone()
|
||||
.or_else(|| nearest_ancestor_group(dir, &group_dirs));
|
||||
let obj = node.as_object_mut().expect("json object");
|
||||
obj.insert("name".into(), json!(g.name));
|
||||
if let Some(p) = parent {
|
||||
obj.insert("parent_slug".into(), json!(p));
|
||||
}
|
||||
}
|
||||
nodes.push(node);
|
||||
}
|
||||
let count = nodes.len();
|
||||
let envs = project
|
||||
@@ -107,3 +136,16 @@ pub fn build_tree(
|
||||
}
|
||||
Ok((bundle, count, envs))
|
||||
}
|
||||
|
||||
/// The slug of the nearest ancestor directory (above `dir`) that holds a group
|
||||
/// manifest — the directory-derived parent for a nested group node.
|
||||
fn nearest_ancestor_group(dir: &Path, group_dirs: &HashMap<PathBuf, String>) -> Option<String> {
|
||||
let mut cur = dir.parent();
|
||||
while let Some(d) = cur {
|
||||
if let Some(slug) = group_dirs.get(d) {
|
||||
return Some(slug.clone());
|
||||
}
|
||||
cur = d.parent();
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
@@ -44,18 +44,6 @@ pub fn ensure_project_key(base: &Path) -> Result<String> {
|
||||
if let Some(k) = read_project_key(base) {
|
||||
return Ok(k);
|
||||
}
|
||||
// A present-but-unreadable/corrupt `project.json` must NOT be silently
|
||||
// re-minted: a fresh key would orphan every group this repo already claimed
|
||||
// (the old key is lost, so the next apply hits an ownership conflict). Fail
|
||||
// loudly and let the operator restore or intentionally re-key it.
|
||||
let path = project_path(base);
|
||||
if path.exists() {
|
||||
anyhow::bail!(
|
||||
"{} exists but could not be parsed as a project key; refusing to mint a new one \
|
||||
(a new key would orphan this repo's group ownership). Restore or delete it.",
|
||||
path.display()
|
||||
);
|
||||
}
|
||||
let key = uuid::Uuid::new_v4().to_string();
|
||||
write_project_key(base, &key)?;
|
||||
Ok(key)
|
||||
|
||||
@@ -153,32 +153,6 @@ enum Cmd {
|
||||
cmd: VarsCmd,
|
||||
},
|
||||
|
||||
/// Extension points (§5.5) — read-only view of the module names a node
|
||||
/// marks as overridable. Authored declaratively via the manifest
|
||||
/// `extension_points = [...]`; this lists them + (for an app) each one's
|
||||
/// resolved provider.
|
||||
ExtensionPoints {
|
||||
#[command(subcommand)]
|
||||
cmd: ExtensionPointsCmd,
|
||||
},
|
||||
|
||||
/// Shared group collections (§11.6) — read-only view of the KV collection
|
||||
/// names a group offers as cross-app-shared. Authored declaratively via the
|
||||
/// `[group]` manifest `collections = [...]`; scripts read/write them with
|
||||
/// `kv::shared_collection("name")`.
|
||||
Collections {
|
||||
#[command(subcommand)]
|
||||
cmd: CollectionsCmd,
|
||||
},
|
||||
|
||||
/// Per-app opt-out of inherited group templates (§11 tail). Authoring is
|
||||
/// declarative via the `[suppress]` manifest block; this lists what an app
|
||||
/// declines.
|
||||
Suppress {
|
||||
#[command(subcommand)]
|
||||
cmd: SuppressCmd,
|
||||
},
|
||||
|
||||
/// Files inspection — list a collection's blobs, download bytes, or
|
||||
/// delete a file. Read + delete only; writes go through scripts.
|
||||
Files {
|
||||
@@ -246,6 +220,10 @@ struct ApplyArgs {
|
||||
/// since the last `pic plan`).
|
||||
#[arg(long)]
|
||||
force: bool,
|
||||
/// Tree apply only (`--dir`): seize declared groups currently owned by
|
||||
/// another project (§7). Requires group-admin on each contested node.
|
||||
#[arg(long, requires = "dir")]
|
||||
takeover: bool,
|
||||
/// Merge the `picloud.<env>.toml` overlay (per-env slug + secrets) on
|
||||
/// top of the base manifest before applying.
|
||||
#[arg(long)]
|
||||
@@ -255,11 +233,6 @@ struct ApplyArgs {
|
||||
/// policy, §4.2). Repeatable. A blanket `--yes` does NOT cover it.
|
||||
#[arg(long = "approve", requires = "dir")]
|
||||
approve: Vec<String>,
|
||||
/// Tree apply only (`--dir`): claim declared groups another project owns
|
||||
/// (§7, M3). Group-admin gated. Without it, an owned-elsewhere group is a
|
||||
/// hard conflict.
|
||||
#[arg(long, requires = "dir")]
|
||||
takeover: bool,
|
||||
}
|
||||
|
||||
#[derive(Args)]
|
||||
@@ -325,24 +298,19 @@ struct ConfigArgs {
|
||||
|
||||
#[derive(Subcommand)]
|
||||
enum KvCmd {
|
||||
/// List keys in a collection. Exactly one of `--app` (per-app KV) or
|
||||
/// `--group` (a group's §11.6 shared KV).
|
||||
/// List keys in a collection.
|
||||
Ls {
|
||||
#[arg(long, conflicts_with = "group")]
|
||||
app: Option<String>,
|
||||
#[arg(long)]
|
||||
group: Option<String>,
|
||||
app: String,
|
||||
#[arg(long)]
|
||||
collection: String,
|
||||
#[arg(long, default_value_t = 100)]
|
||||
limit: u32,
|
||||
},
|
||||
/// Fetch one key's value (printed as JSON). `--app` or `--group`.
|
||||
/// Fetch one key's value (printed as JSON).
|
||||
Get {
|
||||
#[arg(long, conflicts_with = "group")]
|
||||
app: Option<String>,
|
||||
#[arg(long)]
|
||||
group: Option<String>,
|
||||
app: String,
|
||||
#[arg(long)]
|
||||
collection: String,
|
||||
key: String,
|
||||
@@ -578,41 +546,6 @@ enum DomainsCmd {
|
||||
Rm { app: String, domain_id: String },
|
||||
}
|
||||
|
||||
#[derive(Subcommand)]
|
||||
enum ExtensionPointsCmd {
|
||||
/// List a node's extension points. `--app` shows every EP visible on the
|
||||
/// app's chain with its resolved provider; `--group` shows the group's own.
|
||||
Ls {
|
||||
#[arg(long)]
|
||||
app: Option<String>,
|
||||
#[arg(long, conflicts_with = "app")]
|
||||
group: Option<String>,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Subcommand)]
|
||||
enum CollectionsCmd {
|
||||
/// List the shared collections a group declares (§11.6). Group-only —
|
||||
/// shared collections are owned by groups, not apps.
|
||||
Ls {
|
||||
#[arg(long)]
|
||||
group: String,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Subcommand)]
|
||||
enum SuppressCmd {
|
||||
/// List the inherited templates an owner declines (§11 tail). Exactly one
|
||||
/// of `--app` (declines for itself) or `--group` (declines for its whole
|
||||
/// subtree, §11 tail M1).
|
||||
Ls {
|
||||
#[arg(long, conflicts_with = "group")]
|
||||
app: Option<String>,
|
||||
#[arg(long)]
|
||||
group: Option<String>,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Subcommand)]
|
||||
enum ScriptsCmd {
|
||||
/// List scripts. With `--app`, scoped to one app; with `--group`, a
|
||||
@@ -857,14 +790,8 @@ impl From<InstanceRoleArg> for picloud_shared::InstanceRole {
|
||||
|
||||
#[derive(Subcommand)]
|
||||
enum RoutesCmd {
|
||||
/// List routes bound to a script, or a group's route TEMPLATES (`--group`).
|
||||
Ls {
|
||||
#[arg(conflicts_with = "group", required_unless_present = "group")]
|
||||
script_id: Option<String>,
|
||||
/// List the group's §11 tail route templates instead.
|
||||
#[arg(long)]
|
||||
group: Option<String>,
|
||||
},
|
||||
/// List routes bound to a script.
|
||||
Ls { script_id: String },
|
||||
|
||||
/// Create a new route. Host defaults to `*` (any). Path-kind
|
||||
/// defaults to `exact`. Dispatch defaults to `sync`.
|
||||
@@ -933,13 +860,10 @@ enum RoutesCmd {
|
||||
|
||||
#[derive(Subcommand)]
|
||||
enum TriggersCmd {
|
||||
/// List an app's triggers, or a group's trigger TEMPLATES (`--group`).
|
||||
/// List every trigger in an app.
|
||||
Ls {
|
||||
#[arg(long, conflicts_with = "group", required_unless_present = "group")]
|
||||
app: Option<String>,
|
||||
/// List the group's §11 tail trigger templates instead.
|
||||
#[arg(long)]
|
||||
group: Option<String>,
|
||||
app: String,
|
||||
},
|
||||
|
||||
/// Delete a trigger by id.
|
||||
@@ -1431,9 +1355,9 @@ async fn main() -> ExitCode {
|
||||
args.prune,
|
||||
args.yes,
|
||||
args.force,
|
||||
args.takeover,
|
||||
args.env.as_deref(),
|
||||
&args.approve,
|
||||
args.takeover,
|
||||
mode,
|
||||
)
|
||||
.await
|
||||
@@ -1652,12 +1576,8 @@ async fn main() -> ExitCode {
|
||||
Err(e) => Err(e),
|
||||
},
|
||||
Cmd::Routes {
|
||||
cmd: RoutesCmd::Ls { script_id, group },
|
||||
} => match (script_id, group) {
|
||||
(_, Some(group)) => cmds::routes::ls_group(&group, mode).await,
|
||||
(Some(script_id), None) => cmds::routes::ls(&script_id, mode).await,
|
||||
(None, None) => Err(anyhow::anyhow!("provide a script id or --group")),
|
||||
},
|
||||
cmd: RoutesCmd::Ls { script_id },
|
||||
} => cmds::routes::ls(&script_id, mode).await,
|
||||
Cmd::Routes {
|
||||
cmd:
|
||||
RoutesCmd::Create {
|
||||
@@ -1757,12 +1677,8 @@ async fn main() -> ExitCode {
|
||||
cmd: AdminsCmd::Rm { id },
|
||||
} => cmds::admins::rm(&id).await,
|
||||
Cmd::Triggers {
|
||||
cmd: TriggersCmd::Ls { app, group },
|
||||
} => match (app, group) {
|
||||
(_, Some(group)) => cmds::triggers::ls_group(&group, mode).await,
|
||||
(Some(app), None) => cmds::triggers::ls(&app, mode).await,
|
||||
(None, None) => Err(anyhow::anyhow!("provide --app or --group")),
|
||||
},
|
||||
cmd: TriggersCmd::Ls { app },
|
||||
} => cmds::triggers::ls(&app, mode).await,
|
||||
Cmd::Triggers {
|
||||
cmd: TriggersCmd::Rm { app, trigger_id },
|
||||
} => cmds::triggers::rm(&app, &trigger_id).await,
|
||||
@@ -2056,15 +1972,6 @@ async fn main() -> ExitCode {
|
||||
env,
|
||||
},
|
||||
} => cmds::vars::rm(group.as_deref(), app.as_deref(), &key, env.as_deref()).await,
|
||||
Cmd::ExtensionPoints {
|
||||
cmd: ExtensionPointsCmd::Ls { app, group },
|
||||
} => cmds::extension_points::ls(app.as_deref(), group.as_deref(), mode).await,
|
||||
Cmd::Collections {
|
||||
cmd: CollectionsCmd::Ls { group },
|
||||
} => cmds::collections::ls(&group, mode).await,
|
||||
Cmd::Suppress {
|
||||
cmd: SuppressCmd::Ls { app, group },
|
||||
} => cmds::suppress::ls(app.as_deref(), group.as_deref(), mode).await,
|
||||
Cmd::Files {
|
||||
cmd:
|
||||
FilesCmd::Ls {
|
||||
@@ -2100,20 +2007,18 @@ async fn main() -> ExitCode {
|
||||
cmd:
|
||||
KvCmd::Ls {
|
||||
app,
|
||||
group,
|
||||
collection,
|
||||
limit,
|
||||
},
|
||||
} => cmds::kv::ls(app.as_deref(), group.as_deref(), &collection, limit, mode).await,
|
||||
} => cmds::kv::ls(&app, &collection, limit, mode).await,
|
||||
Cmd::Kv {
|
||||
cmd:
|
||||
KvCmd::Get {
|
||||
app,
|
||||
group,
|
||||
collection,
|
||||
key,
|
||||
},
|
||||
} => cmds::kv::get(app.as_deref(), group.as_deref(), &collection, &key).await,
|
||||
} => cmds::kv::get(&app, &collection, &key).await,
|
||||
};
|
||||
|
||||
match result {
|
||||
|
||||
@@ -29,7 +29,6 @@ use serde::{Deserialize, Serialize};
|
||||
pub const MANIFEST_FILE: &str = "picloud.toml";
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct Manifest {
|
||||
/// An app node declares `[app]`; a group node declares `[group]` (Phase 5).
|
||||
/// Exactly one is present (enforced by [`Manifest::parse`]).
|
||||
@@ -37,9 +36,8 @@ pub struct Manifest {
|
||||
pub app: Option<ManifestApp>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub group: Option<ManifestGroup>,
|
||||
/// `[project]` — project-level policy (per-env approval gating, §4.2/§6).
|
||||
/// Consumed only by `apply --dir` and only on the tree's ROOT manifest
|
||||
/// (enforced in `build_tree`).
|
||||
/// `[project]` — project-level policy (M5), consumed only by `apply --dir`
|
||||
/// and only on the tree's ROOT manifest (enforced in `build_tree`).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub project: Option<ManifestProject>,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
@@ -55,10 +53,21 @@ pub struct Manifest {
|
||||
/// The overlay merges per-env, last-write-wins by key.
|
||||
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
|
||||
pub vars: BTreeMap<String, toml::Value>,
|
||||
/// `[suppress]` (§11 tail) — per-app opt-out of inherited group templates.
|
||||
/// App-only; a `[group]` carrying it is rejected in [`Manifest::parse`].
|
||||
#[serde(default, skip_serializing_if = "ManifestSuppress::is_empty")]
|
||||
pub suppress: ManifestSuppress,
|
||||
/// `[[extension_points]]` — module names this node opens for per-tenant
|
||||
/// resolution (§5.5). Allowed on both app and group manifests.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub extension_points: Vec<ManifestExtensionPoint>,
|
||||
/// `[[route_templates]]` — GROUP-only route declarations that fan out into a
|
||||
/// concrete route on every descendant app at apply time (§4.5, M4a). String
|
||||
/// fields may carry `{app_slug}`/`{env}`/`{var:NAME}` placeholders.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub route_templates: Vec<ManifestRouteTemplate>,
|
||||
/// `[[trigger_templates]]` — GROUP-only trigger declarations that fan out per
|
||||
/// descendant app (§4.5, M4b). Each entry is `name = …`, `kind = "cron"|…`,
|
||||
/// `script = …`, plus the kind's params (string fields may carry
|
||||
/// placeholders). Kept loosely-typed so one block covers all seven kinds.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub trigger_templates: Vec<ManifestTriggerTemplate>,
|
||||
}
|
||||
|
||||
impl Manifest {
|
||||
@@ -76,16 +85,20 @@ impl Manifest {
|
||||
anyhow::bail!("manifest declares neither [app] nor [group]")
|
||||
}
|
||||
}
|
||||
// A group node owns scripts + vars (+ secret names) and, as of §11 tail,
|
||||
// ROUTE TEMPLATES + EVENT trigger TEMPLATES (kv/docs/files/pubsub) that
|
||||
// fan out live to descendant apps. As of §11 tail M1 a group may also
|
||||
// declare `[suppress]` to decline a template it inherits from a higher
|
||||
// ancestor, for its whole subtree.
|
||||
// §4.5 M5: a group may declare STATEFUL trigger templates too — cron,
|
||||
// queue, and email materialize a per-descendant-app row. An email
|
||||
// template resolves its `inbound_secret_ref` against the GROUP's own
|
||||
// secret store once at apply (shared-group-secret model); the server
|
||||
// rejects it there if the secret is unset.
|
||||
// A group node owns only scripts + vars (+ secret names) — routes and
|
||||
// triggers are app concerns. Reject them early with a clear message.
|
||||
if m.group.is_some() {
|
||||
if !m.routes.is_empty() {
|
||||
anyhow::bail!(
|
||||
"a [group] manifest cannot declare [[routes]] — routes bind to an app"
|
||||
);
|
||||
}
|
||||
if !m.triggers.is_empty() {
|
||||
anyhow::bail!(
|
||||
"a [group] manifest cannot declare [[triggers]] — triggers belong to an app"
|
||||
);
|
||||
}
|
||||
}
|
||||
Ok(m)
|
||||
}
|
||||
|
||||
@@ -105,33 +118,6 @@ impl Manifest {
|
||||
self.group.is_some()
|
||||
}
|
||||
|
||||
/// This node's declared extension-point names (§5.5), from whichever of
|
||||
/// `[app]` / `[group]` is present.
|
||||
#[must_use]
|
||||
pub fn extension_points(&self) -> &[String] {
|
||||
match (&self.app, &self.group) {
|
||||
(Some(a), _) => &a.extension_points,
|
||||
(_, Some(g)) => &g.extension_points,
|
||||
_ => &[],
|
||||
}
|
||||
}
|
||||
|
||||
/// This node's declared shared group collections (§11.6), normalized to
|
||||
/// `(name, kind)` pairs. Authored on `[group]` nodes only — an `[app]`
|
||||
/// manifest carrying `collections` is a hard parse error (`ManifestApp` has
|
||||
/// no such field + `deny_unknown_fields`).
|
||||
#[must_use]
|
||||
pub fn collections(&self) -> Vec<(String, String)> {
|
||||
match &self.group {
|
||||
Some(g) => g
|
||||
.collections
|
||||
.iter()
|
||||
.map(CollectionDecl::normalized)
|
||||
.collect(),
|
||||
None => Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Load and parse the manifest at `path`.
|
||||
pub fn load(path: &Path) -> Result<Self> {
|
||||
let body =
|
||||
@@ -231,44 +217,30 @@ pub struct OverlayApp {
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct ManifestApp {
|
||||
pub slug: String,
|
||||
pub name: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub description: Option<String>,
|
||||
/// `extension_points = ["theme", …]` (§5.5) — module names this node marks
|
||||
/// as provided/overridable by descendants. Name-only, like `[secrets]`; the
|
||||
/// optional default body is a co-located `[[scripts]]` module of the same
|
||||
/// name. A key of the `[app]` table so TOML can't mis-nest it.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub extension_points: Vec<String>,
|
||||
}
|
||||
|
||||
/// A `[group]` node (Phase 5): a group's own declarative content — its scripts
|
||||
/// and `[vars]`. The group must already exist on the server (created with
|
||||
/// `pic groups create`); the manifest reconciles its content, not the tree
|
||||
/// shape. In a nested project the parent is inferred from the directory tree.
|
||||
/// A `[group]` node (Phase 5/M2): a group's own declarative content — its
|
||||
/// scripts and `[vars]`. With M2, `pic apply --dir` also creates the group if
|
||||
/// it doesn't exist and reparents it under `parent`. When `parent` is omitted,
|
||||
/// the parent is inferred from the enclosing directory's group manifest (or the
|
||||
/// instance root for a top-level group).
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct ManifestGroup {
|
||||
pub slug: String,
|
||||
pub name: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub description: Option<String>,
|
||||
/// See [`ManifestApp::extension_points`]. A key of the `[group]` table.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub extension_points: Vec<String>,
|
||||
/// `collections = [...]` (§11.6) — shared collections this group offers as
|
||||
/// cross-app-shared (read by any app in the subtree, written by an
|
||||
/// authenticated editor+). Each entry is either a bare string (a `kv`
|
||||
/// collection) or a `{ name, kind }` table (`kind` ∈ `kv`/`docs`).
|
||||
/// Group-only: there is no `collections` key on `[app]`.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub collections: Vec<CollectionDecl>,
|
||||
/// Explicit parent group slug (M2). Overrides the directory-derived parent.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub parent: Option<String>,
|
||||
}
|
||||
|
||||
/// `[project]` — project-level policy (§4.2/§6). Valid ONLY on the root
|
||||
/// `[project]` — project-level policy (§4.2/§6, M5). Valid ONLY on the root
|
||||
/// manifest of a `pic apply --dir` tree; rejected elsewhere by [`build_tree`].
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
@@ -279,7 +251,7 @@ pub struct ManifestProject {
|
||||
}
|
||||
|
||||
/// One `[[project.environments]]` entry. `confirm = true` gates applying to this
|
||||
/// env behind an explicit `pic apply --approve <name>`.
|
||||
/// env behind an explicit `pic apply --approve <name>` (M5).
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct ManifestEnvironment {
|
||||
@@ -288,63 +260,6 @@ pub struct ManifestEnvironment {
|
||||
pub confirm: bool,
|
||||
}
|
||||
|
||||
/// The store kind of a shared collection (§11.6).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum CollectionKind {
|
||||
Kv,
|
||||
Docs,
|
||||
Files,
|
||||
/// §11.6 D2: a storeless group-scoped publish namespace, watched by
|
||||
/// `shared = true` group pubsub triggers.
|
||||
Topic,
|
||||
/// §11.6 D3: a group-keyed durable queue with competing per-descendant
|
||||
/// consumers.
|
||||
Queue,
|
||||
}
|
||||
|
||||
impl CollectionKind {
|
||||
#[must_use]
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::Kv => "kv",
|
||||
Self::Docs => "docs",
|
||||
Self::Files => "files",
|
||||
Self::Topic => "topic",
|
||||
Self::Queue => "queue",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One `collections` entry: a bare string (`kv` shorthand) or a `{ name, kind }`
|
||||
/// table. Untagged so the shipped `collections = ["catalog"]` form keeps working
|
||||
/// alongside `{ name = "articles", kind = "docs" }`.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(untagged)]
|
||||
pub enum CollectionDecl {
|
||||
Name(String),
|
||||
Full {
|
||||
name: String,
|
||||
#[serde(default = "kv_kind")]
|
||||
kind: CollectionKind,
|
||||
},
|
||||
}
|
||||
|
||||
fn kv_kind() -> CollectionKind {
|
||||
CollectionKind::Kv
|
||||
}
|
||||
|
||||
impl CollectionDecl {
|
||||
/// `(name, kind-as-string)` — a bare string normalizes to `kind = "kv"`.
|
||||
#[must_use]
|
||||
pub fn normalized(&self) -> (String, String) {
|
||||
match self {
|
||||
Self::Name(n) => (n.clone(), "kv".to_string()),
|
||||
Self::Full { name, kind } => (name.clone(), kind.as_str().to_string()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct ManifestScript {
|
||||
pub name: String,
|
||||
@@ -393,11 +308,43 @@ pub struct ManifestRoute {
|
||||
skip_serializing_if = "is_true"
|
||||
)]
|
||||
pub enabled: bool,
|
||||
/// §11 tail: `sealed = true` on a `[group]` route template makes it
|
||||
/// non-suppressible — a descendant's `[suppress]` cannot decline it.
|
||||
/// Meaningless (rejected at apply) on an `[app]` route. Omitted ⇒ unsealed.
|
||||
#[serde(default, skip_serializing_if = "is_false")]
|
||||
pub sealed: bool,
|
||||
}
|
||||
|
||||
/// `[[route_templates]]` — a route declared once on a group, fanned out per
|
||||
/// descendant app (§4.5, M4a). Same shape as [`ManifestRoute`] plus a `name`
|
||||
/// (the per-group identity/upsert key).
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct ManifestRouteTemplate {
|
||||
/// Per-group template name (identity/upsert key).
|
||||
pub name: String,
|
||||
pub script: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub method: Option<String>,
|
||||
pub host_kind: HostKind,
|
||||
#[serde(default, skip_serializing_if = "String::is_empty")]
|
||||
pub host: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub host_param_name: Option<String>,
|
||||
pub path_kind: PathKind,
|
||||
pub path: String,
|
||||
#[serde(default, skip_serializing_if = "is_sync")]
|
||||
pub dispatch_mode: DispatchMode,
|
||||
#[serde(
|
||||
default = "picloud_shared::default_true",
|
||||
skip_serializing_if = "is_true"
|
||||
)]
|
||||
pub enabled: bool,
|
||||
}
|
||||
|
||||
/// `[[trigger_templates]]` — a trigger declared once on a group, fanned out per
|
||||
/// descendant app (§4.5, M4b). `name` is the per-group identity; the remaining
|
||||
/// keys (`kind`, `script`, kind params) are kept as a flattened table so one
|
||||
/// block covers every kind, matching the server's `{ name, <BundleTrigger> }`.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct ManifestTriggerTemplate {
|
||||
pub name: String,
|
||||
#[serde(flatten)]
|
||||
pub spec: toml::Value,
|
||||
}
|
||||
|
||||
/// Triggers grouped by kind (arrays-of-tables: `[[triggers.cron]]`, …).
|
||||
@@ -442,14 +389,6 @@ pub struct KvTriggerSpec {
|
||||
pub dispatch_mode: Option<DispatchMode>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub retry_max_attempts: Option<u32>,
|
||||
/// §11 tail: `sealed = true` on a `[group]` template makes it
|
||||
/// non-suppressible (event kinds only; group-only). Omitted ⇒ unsealed.
|
||||
#[serde(default, skip_serializing_if = "is_false")]
|
||||
pub sealed: bool,
|
||||
/// §11.6: `shared = true` on a `[group]` template makes it watch the group's
|
||||
/// SHARED collection (not per-app ones); group-only. Omitted ⇒ per-app.
|
||||
#[serde(default, skip_serializing_if = "is_false")]
|
||||
pub shared: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
@@ -462,12 +401,6 @@ pub struct DocsTriggerSpec {
|
||||
pub dispatch_mode: Option<DispatchMode>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub retry_max_attempts: Option<u32>,
|
||||
/// See [`KvTriggerSpec::sealed`].
|
||||
#[serde(default, skip_serializing_if = "is_false")]
|
||||
pub sealed: bool,
|
||||
/// See [`KvTriggerSpec::shared`].
|
||||
#[serde(default, skip_serializing_if = "is_false")]
|
||||
pub shared: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
@@ -480,12 +413,6 @@ pub struct FilesTriggerSpec {
|
||||
pub dispatch_mode: Option<DispatchMode>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub retry_max_attempts: Option<u32>,
|
||||
/// See [`KvTriggerSpec::sealed`].
|
||||
#[serde(default, skip_serializing_if = "is_false")]
|
||||
pub sealed: bool,
|
||||
/// See [`KvTriggerSpec::shared`].
|
||||
#[serde(default, skip_serializing_if = "is_false")]
|
||||
pub shared: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
@@ -509,14 +436,6 @@ pub struct PubsubTriggerSpec {
|
||||
pub dispatch_mode: Option<DispatchMode>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub retry_max_attempts: Option<u32>,
|
||||
/// See [`KvTriggerSpec::sealed`].
|
||||
#[serde(default, skip_serializing_if = "is_false")]
|
||||
pub sealed: bool,
|
||||
/// §11.6 D2: `true` for a shared-TOPIC group trigger — fires when a subtree
|
||||
/// app publishes to the group's declared shared topic, not on per-app
|
||||
/// publishes. Group-only; requires a declared `kind = "topic"` collection.
|
||||
#[serde(default, skip_serializing_if = "is_false")]
|
||||
pub shared: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
@@ -541,11 +460,6 @@ pub struct QueueTriggerSpec {
|
||||
pub dispatch_mode: Option<DispatchMode>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub retry_max_attempts: Option<u32>,
|
||||
/// §11.6 D3: `true` for a shared-QUEUE group consumer over a declared
|
||||
/// `kind = "queue"` collection — competing per-descendant consumers drain
|
||||
/// one group-owned store. Group-only.
|
||||
#[serde(default, skip_serializing_if = "is_false")]
|
||||
pub shared: bool,
|
||||
}
|
||||
|
||||
/// `[secrets] names = [...]` — declares which secrets the app expects.
|
||||
@@ -563,25 +477,15 @@ impl ManifestSecrets {
|
||||
}
|
||||
}
|
||||
|
||||
/// `[suppress]` (§11 tail) — per-app opt-out of inherited group templates.
|
||||
/// `triggers = [...]` names handler SCRIPTS whose inherited triggers this app
|
||||
/// declines; `routes = [...]` names PATHS whose inherited route this app
|
||||
/// declines (404s instead of serving). App-only — a `[group]` carrying
|
||||
/// `[suppress]` is a hard parse error.
|
||||
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct ManifestSuppress {
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub triggers: Vec<String>,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub routes: Vec<String>,
|
||||
}
|
||||
|
||||
impl ManifestSuppress {
|
||||
#[must_use]
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.triggers.is_empty() && self.routes.is_empty()
|
||||
}
|
||||
/// `[[extension_points]]` — a module name opened for per-tenant resolution
|
||||
/// (§5.5). A parent/group script importing `name` resolves it against the
|
||||
/// inheriting app's module instead of the sealed lexical chain; `default`
|
||||
/// names a local module used when an app provides none.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct ManifestExtensionPoint {
|
||||
pub name: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub default: Option<String>,
|
||||
}
|
||||
|
||||
// ---- serde skip/default helpers ----
|
||||
@@ -599,11 +503,6 @@ fn is_true(b: &bool) -> bool {
|
||||
*b
|
||||
}
|
||||
|
||||
/// Skip-serialize helper: `sealed` defaults false, so only emit it when true.
|
||||
fn is_false(b: &bool) -> bool {
|
||||
!*b
|
||||
}
|
||||
|
||||
fn default_timezone() -> String {
|
||||
"UTC".to_string()
|
||||
}
|
||||
@@ -618,7 +517,6 @@ mod tests {
|
||||
slug: "blog".into(),
|
||||
name: "My Blog".into(),
|
||||
description: Some("demo".into()),
|
||||
extension_points: vec!["theme".into()],
|
||||
}),
|
||||
group: None,
|
||||
project: None,
|
||||
@@ -657,7 +555,6 @@ mod tests {
|
||||
path: "/posts".into(),
|
||||
dispatch_mode: DispatchMode::Sync,
|
||||
enabled: true,
|
||||
sealed: false,
|
||||
}],
|
||||
triggers: ManifestTriggers {
|
||||
cron: vec![CronTriggerSpec {
|
||||
@@ -673,8 +570,6 @@ mod tests {
|
||||
ops: vec![KvEventOp::Insert, KvEventOp::Update],
|
||||
dispatch_mode: Some(DispatchMode::Async),
|
||||
retry_max_attempts: Some(5),
|
||||
sealed: false,
|
||||
shared: false,
|
||||
}],
|
||||
..ManifestTriggers::default()
|
||||
},
|
||||
@@ -685,7 +580,12 @@ mod tests {
|
||||
("region".to_string(), toml::Value::String("eu".into())),
|
||||
("max-retries".to_string(), toml::Value::Integer(3)),
|
||||
]),
|
||||
suppress: ManifestSuppress::default(),
|
||||
extension_points: vec![ManifestExtensionPoint {
|
||||
name: "theme".into(),
|
||||
default: Some("default-theme".into()),
|
||||
}],
|
||||
route_templates: Vec::new(),
|
||||
trigger_templates: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -782,7 +682,6 @@ mod tests {
|
||||
slug: "x".into(),
|
||||
name: "X".into(),
|
||||
description: None,
|
||||
extension_points: vec![],
|
||||
}),
|
||||
group: None,
|
||||
project: None,
|
||||
@@ -791,7 +690,9 @@ mod tests {
|
||||
triggers: ManifestTriggers::default(),
|
||||
secrets: ManifestSecrets::default(),
|
||||
vars: BTreeMap::new(),
|
||||
suppress: ManifestSuppress::default(),
|
||||
extension_points: Vec::new(),
|
||||
route_templates: Vec::new(),
|
||||
trigger_templates: Vec::new(),
|
||||
};
|
||||
let text = m.to_toml().unwrap();
|
||||
assert!(!text.contains("[[scripts]]"), "got:\n{text}");
|
||||
@@ -804,102 +705,30 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_misplaced_or_unknown_keys() {
|
||||
// §5.5 footgun guard: `extension_points` is an `[app]`/`[group]` node
|
||||
// key. Placed at top level (before any table header) TOML reads it as a
|
||||
// root key — `deny_unknown_fields` must reject it loudly rather than
|
||||
// silently drop it (the silent-drop bug that bit in C5).
|
||||
let misplaced = "extension_points = [\"theme\"]\n[app]\nslug = \"x\"\nname = \"X\"\n";
|
||||
assert!(
|
||||
Manifest::parse(misplaced).is_err(),
|
||||
"a top-level extension_points must be rejected, not silently ignored"
|
||||
);
|
||||
// A typo'd key inside [app] is likewise a hard error, not a drop.
|
||||
let typo = "[app]\nslug = \"x\"\nname = \"X\"\nextention_points = [\"theme\"]\n";
|
||||
assert!(
|
||||
Manifest::parse(typo).is_err(),
|
||||
"an unknown key in [app] must be rejected"
|
||||
);
|
||||
// The correct node-key placement still parses.
|
||||
let ok = "[app]\nslug = \"x\"\nname = \"X\"\nextension_points = [\"theme\"]\n";
|
||||
let m = Manifest::parse(ok).expect("node-key extension_points must parse");
|
||||
assert_eq!(m.extension_points(), ["theme"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn collections_string_or_table_normalizes_kind() {
|
||||
// §11.6: a bare string is a `kv` collection (the shipped form); a
|
||||
// `{ name, kind }` table sets an explicit kind. Both coexist.
|
||||
fn group_manifest_parses_and_rejects_app_only_blocks() {
|
||||
// A [group] node: scripts + vars, no [app].
|
||||
let m = Manifest::parse(
|
||||
"[group]\nslug = \"acme\"\nname = \"ACME\"\n\n\
|
||||
collections = [\"catalog\", { name = \"articles\", kind = \"docs\" }, \
|
||||
{ name = \"assets\", kind = \"files\" }]\n",
|
||||
[[scripts]]\nname = \"shared\"\nfile = \"scripts/shared.rhai\"\n\n\
|
||||
[vars]\nregion = \"eu\"\n",
|
||||
)
|
||||
.expect("string-or-table collections must parse");
|
||||
assert_eq!(
|
||||
m.collections(),
|
||||
vec![
|
||||
("catalog".to_string(), "kv".to_string()),
|
||||
("articles".to_string(), "docs".to_string()),
|
||||
("assets".to_string(), "files".to_string()),
|
||||
]
|
||||
);
|
||||
// An app manifest carrying `collections` is rejected (no such field +
|
||||
// deny_unknown_fields).
|
||||
let app = "[app]\nslug = \"x\"\nname = \"X\"\ncollections = [\"catalog\"]\n";
|
||||
assert!(
|
||||
Manifest::parse(app).is_err(),
|
||||
"collections on [app] must be rejected"
|
||||
);
|
||||
}
|
||||
.expect("group manifest parses");
|
||||
assert!(m.is_group());
|
||||
assert_eq!(m.slug(), "acme");
|
||||
assert_eq!(m.scripts.len(), 1);
|
||||
|
||||
#[test]
|
||||
fn app_and_group_suppress_parse() {
|
||||
// §11 tail: an [app] declares [suppress] to opt out of inherited
|
||||
// templates — script names (triggers) + paths (routes).
|
||||
let m = Manifest::parse(
|
||||
"[app]\nslug = \"blog\"\nname = \"Blog\"\n\n\
|
||||
[suppress]\ntriggers = [\"audit\"]\nroutes = [\"/hello\"]\n",
|
||||
)
|
||||
.expect("app [suppress] must parse");
|
||||
assert_eq!(m.suppress.triggers, ["audit"]);
|
||||
assert_eq!(m.suppress.routes, ["/hello"]);
|
||||
|
||||
// §11 tail M1: a [group] MAY suppress — it declines a template it
|
||||
// inherits from a higher ancestor, for its whole subtree.
|
||||
let g = Manifest::parse(
|
||||
// A group cannot carry routes/triggers.
|
||||
let err = Manifest::parse(
|
||||
"[group]\nslug = \"acme\"\nname = \"ACME\"\n\n\
|
||||
[suppress]\ntriggers = [\"audit\"]\n",
|
||||
[[routes]]\nscript = \"shared\"\nhost_kind = \"any\"\npath_kind = \"exact\"\npath = \"/x\"\n",
|
||||
)
|
||||
.expect("group [suppress] must parse");
|
||||
assert_eq!(g.suppress.triggers, ["audit"]);
|
||||
.expect_err("group with routes is rejected");
|
||||
assert!(err.to_string().contains("routes"), "got: {err}");
|
||||
|
||||
// An unknown key inside [suppress] is a hard error (deny_unknown_fields).
|
||||
let typo = "[app]\nslug = \"x\"\nname = \"X\"\n\n[suppress]\ntrigger = [\"a\"]\n";
|
||||
assert!(
|
||||
Manifest::parse(typo).is_err(),
|
||||
"an unknown key in [suppress] must be rejected"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sealed_parses_on_group_route_and_trigger_templates() {
|
||||
// §11 tail: a [group] can mark a route/trigger template `sealed = true`
|
||||
// (non-suppressible). The default is unsealed.
|
||||
let m = Manifest::parse(
|
||||
"[group]\nslug = \"acme\"\nname = \"ACME\"\n\n\
|
||||
[[routes]]\nscript = \"gate\"\npath = \"/health\"\npath_kind = \"exact\"\n\
|
||||
host_kind = \"any\"\nsealed = true\n\n\
|
||||
[[triggers.kv]]\nscript = \"audit\"\ncollection_glob = \"*\"\nsealed = true\n\n\
|
||||
[[triggers.docs]]\nscript = \"log\"\ncollection_glob = \"*\"\n",
|
||||
)
|
||||
.expect("sealed templates parse");
|
||||
assert!(m.routes[0].sealed, "route sealed must parse");
|
||||
assert!(m.triggers.kv[0].sealed, "kv trigger sealed must parse");
|
||||
assert!(
|
||||
!m.triggers.docs[0].sealed,
|
||||
"an omitted sealed defaults to false"
|
||||
);
|
||||
// Neither / both is rejected.
|
||||
Manifest::parse("[vars]\nx = 1\n").expect_err("no [app] or [group]");
|
||||
Manifest::parse("[app]\nslug=\"a\"\nname=\"A\"\n[group]\nslug=\"g\"\nname=\"G\"\n")
|
||||
.expect_err("both [app] and [group]");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -919,49 +748,6 @@ mod tests {
|
||||
assert!(!p.environments[1].confirm);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn group_manifest_parses_and_rejects_app_only_blocks() {
|
||||
// A [group] node: scripts + vars, no [app].
|
||||
let m = Manifest::parse(
|
||||
"[group]\nslug = \"acme\"\nname = \"ACME\"\n\n\
|
||||
[[scripts]]\nname = \"shared\"\nfile = \"scripts/shared.rhai\"\n\n\
|
||||
[vars]\nregion = \"eu\"\n",
|
||||
)
|
||||
.expect("group manifest parses");
|
||||
assert!(m.is_group());
|
||||
assert_eq!(m.slug(), "acme");
|
||||
assert_eq!(m.scripts.len(), 1);
|
||||
|
||||
// §11 tail: a group MAY carry ROUTE templates (inherited by descendants).
|
||||
let routed = Manifest::parse(
|
||||
"[group]\nslug = \"acme\"\nname = \"ACME\"\n\n\
|
||||
[[routes]]\nscript = \"shared\"\nhost_kind = \"any\"\npath_kind = \"exact\"\npath = \"/x\"\n",
|
||||
)
|
||||
.expect("group with a route template parses");
|
||||
assert!(routed.is_group());
|
||||
assert_eq!(routed.routes.len(), 1);
|
||||
|
||||
// §4.5 M5: a group cron template is now ALLOWED (materialized per app).
|
||||
let cron = Manifest::parse(
|
||||
"[group]\nslug = \"acme\"\nname = \"ACME\"\n\n\
|
||||
[[triggers.cron]]\nscript = \"shared\"\nschedule = \"0 0 * * * *\"\n",
|
||||
)
|
||||
.expect("group cron template parses");
|
||||
assert_eq!(cron.triggers.cron.len(), 1);
|
||||
// ...but an email template on a group is still rejected (per-app secret).
|
||||
let err = Manifest::parse(
|
||||
"[group]\nslug = \"acme\"\nname = \"ACME\"\n\n\
|
||||
[[triggers.email]]\nscript = \"shared\"\ninbound_secret_ref = \"sig\"\n",
|
||||
)
|
||||
.expect_err("group email template is rejected");
|
||||
assert!(err.to_string().contains("email"), "got: {err}");
|
||||
|
||||
// Neither / both is rejected.
|
||||
Manifest::parse("[vars]\nx = 1\n").expect_err("no [app] or [group]");
|
||||
Manifest::parse("[app]\nslug=\"a\"\nname=\"A\"\n[group]\nslug=\"g\"\nname=\"G\"\n")
|
||||
.expect_err("both [app] and [group]");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn overlay_vars_override_base_per_key() {
|
||||
let mut base = sample();
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
//! does not cover it) — refused non-interactively at the CLI,
|
||||
//! * `--approve <env>` (as an admin) lets it through,
|
||||
//! * a non-gated environment applies with plain `--yes`,
|
||||
//! * single-node `apply --file` to a gated env is refused (no silent bypass),
|
||||
//! * approving a gated apply needs ADMIN authority on the node — a non-admin
|
||||
//! editor with `--approve` is refused server-side (403).
|
||||
|
||||
@@ -18,12 +17,14 @@ use crate::common::cleanup::{AppGuard, GroupGuard};
|
||||
use crate::common::member;
|
||||
|
||||
/// A single-app project dir whose root manifest declares a `[project]` policy:
|
||||
/// `production` is confirm-required, `staging` is not. A `[vars]` entry gives
|
||||
/// the app write-requiring content so an `editor` member's AppVarsWrite is
|
||||
/// exercised (proving the admin gate is ABOVE editor-write). Vars cascade with
|
||||
/// the app; scripts are ON DELETE RESTRICT and would break AppGuard teardown.
|
||||
/// `production` is confirm-required, `staging` is not.
|
||||
fn project_dir(app: &str) -> TempDir {
|
||||
let dir = TempDir::new().expect("tempdir");
|
||||
// A `[vars]` entry so the app bundle has write-requiring content: an `editor`
|
||||
// member must hold (and exercise) AppVarsWrite to pass authz_tree — which
|
||||
// makes the admin-gate test prove the approval gate is ABOVE editor-write,
|
||||
// not merely "any non-admin is refused". (Vars cascade-delete with the app,
|
||||
// unlike scripts which are ON DELETE RESTRICT, so AppGuard teardown is clean.)
|
||||
fs::write(
|
||||
dir.path().join("picloud.toml"),
|
||||
format!(
|
||||
@@ -101,7 +102,8 @@ fn confirm_required_env_needs_explicit_approval() {
|
||||
String::from_utf8_lossy(&ok2.stderr)
|
||||
);
|
||||
|
||||
// --- single-node `apply --file` to a gated env is refused (no bypass). ---
|
||||
// --- single-node `apply --file` to a gated env is refused (no silent
|
||||
// bypass): the admin-gated approval is a `--dir` feature. ---
|
||||
let single = common::pic_as(&env)
|
||||
.args(["apply", "--file"])
|
||||
.arg(dir.path().join("picloud.toml"))
|
||||
@@ -124,7 +126,7 @@ fn confirm_required_env_needs_explicit_approval() {
|
||||
fn approving_a_gated_apply_requires_admin() {
|
||||
// §4.2: approving a confirm-required env is admin-gated — a second gate on
|
||||
// top of the editor-level write caps an ordinary apply needs. An editor who
|
||||
// CAN write the app (its `[vars]`) still cannot approve a gated apply.
|
||||
// CAN write the app still cannot approve a gated apply.
|
||||
let Some(fx) = common::fixture_or_skip() else {
|
||||
return;
|
||||
};
|
||||
@@ -165,76 +167,3 @@ fn approving_a_gated_apply_requires_admin() {
|
||||
"approval denial should be an authz error:\n{err}"
|
||||
);
|
||||
}
|
||||
|
||||
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||
#[test]
|
||||
fn gated_group_node_admin_gate_is_not_bypassable_by_uuid_slug() {
|
||||
// Regression (§4.2): `enforce_env_approval` must resolve a group node by
|
||||
// UUID-or-slug and FAIL CLOSED — a bare `get_by_slug` skipped the GroupAdmin
|
||||
// gate when the node addressed the group by its UUID (which the apply still
|
||||
// resolves), letting a group EDITOR apply to a confirm-required env without
|
||||
// admin. We drive the raw `/tree/apply` wire directly (the CLI only ever
|
||||
// sends slugs) with the group node's UUID as its slug.
|
||||
let Some(fx) = common::fixture_or_skip() else {
|
||||
return;
|
||||
};
|
||||
let env = common::admin_env(fx);
|
||||
let group = common::unique_slug("appr3-g");
|
||||
let _g = GroupGuard::new(&env.url, &env.token, &group);
|
||||
common::pic_as(&env)
|
||||
.args(["groups", "create", &group])
|
||||
.assert()
|
||||
.success();
|
||||
|
||||
// Resolve the group's UUID.
|
||||
let http = reqwest::blocking::Client::new();
|
||||
let detail: serde_json::Value = http
|
||||
.get(format!("{}/api/v1/admin/groups/{}", env.url, group))
|
||||
.bearer_auth(&env.token)
|
||||
.send()
|
||||
.expect("get group")
|
||||
.json()
|
||||
.expect("group json");
|
||||
let group_uuid = detail["id"].as_str().expect("group id").to_string();
|
||||
|
||||
// A group `editor` — write caps, but NOT GroupAdmin.
|
||||
let m = member::member_user(fx, &common::unique_username("appr3"));
|
||||
common::pic_as(&env)
|
||||
.args([
|
||||
"groups", "members", "add", &group, &m.id, "--role", "editor",
|
||||
])
|
||||
.assert()
|
||||
.success();
|
||||
|
||||
// A gated bundle whose single group node is addressed by UUID. A `vars`
|
||||
// entry makes the node non-empty (and exercises the editor's write cap).
|
||||
let body = serde_json::json!({
|
||||
"bundle": {
|
||||
"nodes": [{
|
||||
"kind": "group",
|
||||
"slug": group_uuid,
|
||||
"bundle": { "vars": { "region": "eu" } }
|
||||
}],
|
||||
"project": { "environments": [{ "name": "production", "confirm": true }] }
|
||||
},
|
||||
"prune": false,
|
||||
"env": "production",
|
||||
"approved_envs": ["production"],
|
||||
"allow_takeover": false,
|
||||
});
|
||||
|
||||
let resp = http
|
||||
.post(format!("{}/api/v1/admin/tree/apply", env.url))
|
||||
.bearer_auth(&m.token)
|
||||
.json(&body)
|
||||
.send()
|
||||
.expect("tree apply");
|
||||
assert_eq!(
|
||||
resp.status().as_u16(),
|
||||
403,
|
||||
"a group editor must be 403'd approving a gated apply even when the node \
|
||||
is addressed by UUID (got {}: {})",
|
||||
resp.status(),
|
||||
resp.text().unwrap_or_default()
|
||||
);
|
||||
}
|
||||
|
||||
@@ -19,7 +19,6 @@ mod apply;
|
||||
mod approval;
|
||||
mod apps;
|
||||
mod auth;
|
||||
mod collections;
|
||||
mod config;
|
||||
mod dead_letters;
|
||||
mod email_queue;
|
||||
@@ -27,10 +26,8 @@ mod enabled;
|
||||
mod env_overlay;
|
||||
mod extension_points;
|
||||
mod group_modules;
|
||||
mod group_routes;
|
||||
mod group_scripts;
|
||||
mod group_secrets;
|
||||
mod group_triggers;
|
||||
mod groups;
|
||||
mod init;
|
||||
mod invoke;
|
||||
@@ -43,14 +40,10 @@ mod pull;
|
||||
mod roles;
|
||||
mod routes;
|
||||
mod scripts;
|
||||
mod sealed;
|
||||
mod secrets;
|
||||
mod shared_queues;
|
||||
mod shared_topics;
|
||||
mod shared_triggers;
|
||||
mod staleness;
|
||||
mod stateful_templates;
|
||||
mod suppress;
|
||||
mod templates;
|
||||
mod tree;
|
||||
mod tree_shape;
|
||||
mod triggers;
|
||||
mod vars;
|
||||
|
||||
@@ -1,648 +0,0 @@
|
||||
//! §11.6 shared group collections, end to end via `pic`:
|
||||
//! * a group declares a shared KV collection `catalog`; two apps under it
|
||||
//! share one store — an authenticated write in app A is read back in app B
|
||||
//! (cross-app data sharing, the deliberate inverse of per-app isolation),
|
||||
//! * an app under a SIBLING group naming `catalog` gets CollectionNotShared
|
||||
//! (the ancestry walk is the isolation boundary),
|
||||
//! * `pic collections ls --group` lists the declared name; re-plan is NoOp.
|
||||
//!
|
||||
//! The anonymous-write-fails-closed half of the trust model is unit-tested in
|
||||
//! `group_kv_service` (a journey invoke always carries the admin principal).
|
||||
|
||||
use std::fs;
|
||||
|
||||
use tempfile::TempDir;
|
||||
|
||||
use crate::common;
|
||||
use crate::common::cleanup::{AppGuard, GroupGuard};
|
||||
|
||||
fn manifest_dir() -> TempDir {
|
||||
let dir = TempDir::new().expect("tempdir");
|
||||
fs::create_dir_all(dir.path().join("scripts")).expect("scripts dir");
|
||||
dir
|
||||
}
|
||||
|
||||
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||
#[test]
|
||||
fn shared_collection_is_read_write_across_the_subtree() {
|
||||
let Some(fx) = common::fixture_or_skip() else {
|
||||
return;
|
||||
};
|
||||
let env = common::admin_env(fx);
|
||||
let group = common::unique_slug("coll-grp");
|
||||
let sibling = common::unique_slug("coll-sib");
|
||||
let app_a = common::unique_slug("coll-a");
|
||||
let app_b = common::unique_slug("coll-b");
|
||||
let foreign = common::unique_slug("coll-f");
|
||||
|
||||
let _g = GroupGuard::new(&env.url, &env.token, &group);
|
||||
let _gs = GroupGuard::new(&env.url, &env.token, &sibling);
|
||||
common::pic_as(&env)
|
||||
.args(["groups", "create", &group])
|
||||
.assert()
|
||||
.success();
|
||||
common::pic_as(&env)
|
||||
.args(["groups", "create", &sibling])
|
||||
.assert()
|
||||
.success();
|
||||
|
||||
// The group declares `catalog` a shared collection (declarative apply).
|
||||
let dir = manifest_dir();
|
||||
let gmanifest =
|
||||
format!("[group]\nslug = \"{group}\"\nname = \"Coll\"\n\ncollections = [\"catalog\"]\n");
|
||||
let gpath = dir.path().join("group.toml");
|
||||
fs::write(&gpath, &gmanifest).unwrap();
|
||||
common::pic_as(&env)
|
||||
.args(["apply", "--file"])
|
||||
.arg(&gpath)
|
||||
.assert()
|
||||
.success();
|
||||
|
||||
// `collections ls --group` shows the declared name.
|
||||
let ls = String::from_utf8(
|
||||
common::pic_as(&env)
|
||||
.args(["collections", "ls", "--group", &group])
|
||||
.output()
|
||||
.unwrap()
|
||||
.stdout,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(
|
||||
ls.contains("catalog"),
|
||||
"ls should list the collection:\n{ls}"
|
||||
);
|
||||
|
||||
// Re-apply is a no-op (the marker already exists).
|
||||
common::pic_as(&env)
|
||||
.args(["apply", "--file"])
|
||||
.arg(&gpath)
|
||||
.assert()
|
||||
.success();
|
||||
|
||||
// Two apps under the group; one under a sibling group.
|
||||
let _a = AppGuard::new(&env.url, &env.token, &app_a);
|
||||
let _b = AppGuard::new(&env.url, &env.token, &app_b);
|
||||
let _f = AppGuard::new(&env.url, &env.token, &foreign);
|
||||
common::pic_as(&env)
|
||||
.args(["apps", "create", &app_a, "--group", &group])
|
||||
.assert()
|
||||
.success();
|
||||
common::pic_as(&env)
|
||||
.args(["apps", "create", &app_b, "--group", &group])
|
||||
.assert()
|
||||
.success();
|
||||
common::pic_as(&env)
|
||||
.args(["apps", "create", &foreign, "--group", &sibling])
|
||||
.assert()
|
||||
.success();
|
||||
|
||||
// App A writes to the shared collection (authenticated invoke → admin
|
||||
// principal, so the editor+ write gate is satisfied).
|
||||
fs::write(
|
||||
dir.path().join("scripts/writer.rhai"),
|
||||
r#"kv::shared_collection("catalog").set("sku-1", #{ price: 9 }); "ok""#,
|
||||
)
|
||||
.unwrap();
|
||||
common::pic_as(&env)
|
||||
.args(["scripts", "deploy"])
|
||||
.arg(dir.path().join("scripts/writer.rhai"))
|
||||
.args(["--app", &app_a, "--name", "writer"])
|
||||
.assert()
|
||||
.success();
|
||||
let writer_id = app_script_id(&env, &app_a, "writer");
|
||||
common::pic_as(&env)
|
||||
.args(["scripts", "invoke", &writer_id])
|
||||
.assert()
|
||||
.success();
|
||||
|
||||
// App B reads the SAME store back — cross-app sharing.
|
||||
fs::write(
|
||||
dir.path().join("scripts/reader.rhai"),
|
||||
r#"kv::shared_collection("catalog").get("sku-1")"#,
|
||||
)
|
||||
.unwrap();
|
||||
common::pic_as(&env)
|
||||
.args(["scripts", "deploy"])
|
||||
.arg(dir.path().join("scripts/reader.rhai"))
|
||||
.args(["--app", &app_b, "--name", "reader"])
|
||||
.assert()
|
||||
.success();
|
||||
let reader_id = app_script_id(&env, &app_b, "reader");
|
||||
assert_eq!(
|
||||
invoke_body(&env, &reader_id),
|
||||
serde_json::json!({ "price": 9 }),
|
||||
"an app in the subtree reads what another app wrote to the shared collection"
|
||||
);
|
||||
|
||||
// The foreign app (sibling subtree) names `catalog` but it does not resolve
|
||||
// on its chain — CollectionNotShared. The invoke fails.
|
||||
fs::write(
|
||||
dir.path().join("scripts/foreign.rhai"),
|
||||
r#"kv::shared_collection("catalog").get("sku-1")"#,
|
||||
)
|
||||
.unwrap();
|
||||
common::pic_as(&env)
|
||||
.args(["scripts", "deploy"])
|
||||
.arg(dir.path().join("scripts/foreign.rhai"))
|
||||
.args(["--app", &foreign, "--name", "peek"])
|
||||
.assert()
|
||||
.success();
|
||||
let foreign_id = app_script_id(&env, &foreign, "peek");
|
||||
let out = common::pic_as(&env)
|
||||
.args(["scripts", "invoke", &foreign_id])
|
||||
.output()
|
||||
.expect("invoke");
|
||||
assert!(
|
||||
!out.status.success(),
|
||||
"a foreign app must not resolve another subtree's shared collection"
|
||||
);
|
||||
}
|
||||
|
||||
/// Nearest-ancestor-group-wins (§11.6, the CoW-shadowing guarantee). A parent
|
||||
/// AND a child group both declare `catalog`; an app under the child must bind
|
||||
/// the CHILD's store, not the parent's. Discriminated by a second app directly
|
||||
/// under the parent: it resolves to the PARENT's store, so it must NOT see what
|
||||
/// the deep app wrote. If the resolver's `ORDER BY depth LIMIT 1` regressed to
|
||||
/// the farther group, the shallow app would see the deep app's value.
|
||||
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||
#[test]
|
||||
fn nearest_declaring_group_wins() {
|
||||
let Some(fx) = common::fixture_or_skip() else {
|
||||
return;
|
||||
};
|
||||
let env = common::admin_env(fx);
|
||||
let root = common::unique_slug("nw-root");
|
||||
let team = common::unique_slug("nw-team");
|
||||
let deep = common::unique_slug("nw-deep");
|
||||
let shallow = common::unique_slug("nw-shallow");
|
||||
|
||||
let _gr = GroupGuard::new(&env.url, &env.token, &root);
|
||||
let _gt = GroupGuard::new(&env.url, &env.token, &team);
|
||||
common::pic_as(&env)
|
||||
.args(["groups", "create", &root])
|
||||
.assert()
|
||||
.success();
|
||||
common::pic_as(&env)
|
||||
.args(["groups", "create", &team, "--parent", &root])
|
||||
.assert()
|
||||
.success();
|
||||
|
||||
// BOTH groups declare `catalog` (two distinct stores, same name).
|
||||
let dir = manifest_dir();
|
||||
for g in [&root, &team] {
|
||||
let m = format!("[group]\nslug = \"{g}\"\nname = \"NW\"\n\ncollections = [\"catalog\"]\n");
|
||||
let p = dir.path().join(format!("{g}.toml"));
|
||||
fs::write(&p, &m).unwrap();
|
||||
common::pic_as(&env)
|
||||
.args(["apply", "--file"])
|
||||
.arg(&p)
|
||||
.assert()
|
||||
.success();
|
||||
}
|
||||
|
||||
// `deep` under team, `shallow` directly under root.
|
||||
let _ad = AppGuard::new(&env.url, &env.token, &deep);
|
||||
let _as = AppGuard::new(&env.url, &env.token, &shallow);
|
||||
common::pic_as(&env)
|
||||
.args(["apps", "create", &deep, "--group", &team])
|
||||
.assert()
|
||||
.success();
|
||||
common::pic_as(&env)
|
||||
.args(["apps", "create", &shallow, "--group", &root])
|
||||
.assert()
|
||||
.success();
|
||||
|
||||
// The deep app writes — must land in TEAM's store (nearest declarer).
|
||||
fs::write(
|
||||
dir.path().join("scripts/w.rhai"),
|
||||
r#"kv::shared_collection("catalog").set("k", "team-value"); "ok""#,
|
||||
)
|
||||
.unwrap();
|
||||
common::pic_as(&env)
|
||||
.args(["scripts", "deploy"])
|
||||
.arg(dir.path().join("scripts/w.rhai"))
|
||||
.args(["--app", &deep, "--name", "w"])
|
||||
.assert()
|
||||
.success();
|
||||
common::pic_as(&env)
|
||||
.args(["scripts", "invoke", &app_script_id(&env, &deep, "w")])
|
||||
.assert()
|
||||
.success();
|
||||
|
||||
// The deep app reads its own write back (team's store).
|
||||
fs::write(
|
||||
dir.path().join("scripts/r.rhai"),
|
||||
r#"kv::shared_collection("catalog").get("k")"#,
|
||||
)
|
||||
.unwrap();
|
||||
common::pic_as(&env)
|
||||
.args(["scripts", "deploy"])
|
||||
.arg(dir.path().join("scripts/r.rhai"))
|
||||
.args(["--app", &deep, "--name", "r"])
|
||||
.assert()
|
||||
.success();
|
||||
assert_eq!(
|
||||
invoke_body(&env, &app_script_id(&env, &deep, "r")),
|
||||
serde_json::json!("team-value"),
|
||||
"the deep app reads back its own write to the nearest (team) store"
|
||||
);
|
||||
|
||||
// The shallow app reads catalog → ROOT's store, which is empty. Seeing
|
||||
// `team-value` here would mean the write leaked to the farther group.
|
||||
common::pic_as(&env)
|
||||
.args(["scripts", "deploy"])
|
||||
.arg(dir.path().join("scripts/r.rhai"))
|
||||
.args(["--app", &shallow, "--name", "r"])
|
||||
.assert()
|
||||
.success();
|
||||
assert_eq!(
|
||||
invoke_body(&env, &app_script_id(&env, &shallow, "r")),
|
||||
serde_json::Value::Null,
|
||||
"the shallow app resolves the ROOT store (empty) — nearest-wins kept the \
|
||||
deep write in the team store"
|
||||
);
|
||||
}
|
||||
|
||||
/// §11.6 docs slice: a group declares a `docs`-kind shared collection (via the
|
||||
/// string-or-table manifest, mixed with a `kv` one); an authenticated app A
|
||||
/// `create`s a document and app B `find`s it back across the subtree; a
|
||||
/// sibling-subtree app gets `CollectionNotShared`; `collections ls` shows both
|
||||
/// kinds.
|
||||
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||
#[test]
|
||||
fn shared_docs_collection_is_read_write_across_the_subtree() {
|
||||
let Some(fx) = common::fixture_or_skip() else {
|
||||
return;
|
||||
};
|
||||
let env = common::admin_env(fx);
|
||||
let group = common::unique_slug("dcoll-grp");
|
||||
let sibling = common::unique_slug("dcoll-sib");
|
||||
let app_a = common::unique_slug("dcoll-a");
|
||||
let app_b = common::unique_slug("dcoll-b");
|
||||
let foreign = common::unique_slug("dcoll-f");
|
||||
|
||||
let _g = GroupGuard::new(&env.url, &env.token, &group);
|
||||
let _gs = GroupGuard::new(&env.url, &env.token, &sibling);
|
||||
common::pic_as(&env)
|
||||
.args(["groups", "create", &group])
|
||||
.assert()
|
||||
.success();
|
||||
common::pic_as(&env)
|
||||
.args(["groups", "create", &sibling])
|
||||
.assert()
|
||||
.success();
|
||||
|
||||
// The group declares a kv AND a docs collection (string-or-table form).
|
||||
let dir = manifest_dir();
|
||||
let gmanifest = format!(
|
||||
"[group]\nslug = \"{group}\"\nname = \"DColl\"\n\n\
|
||||
collections = [\"catalog\", {{ name = \"articles\", kind = \"docs\" }}]\n"
|
||||
);
|
||||
let gpath = dir.path().join("group.toml");
|
||||
fs::write(&gpath, &gmanifest).unwrap();
|
||||
common::pic_as(&env)
|
||||
.args(["apply", "--file"])
|
||||
.arg(&gpath)
|
||||
.assert()
|
||||
.success();
|
||||
|
||||
// `collections ls` shows both names with their kinds.
|
||||
let ls = String::from_utf8(
|
||||
common::pic_as(&env)
|
||||
.args(["collections", "ls", "--group", &group])
|
||||
.output()
|
||||
.unwrap()
|
||||
.stdout,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(
|
||||
ls.contains("articles") && ls.contains("docs") && ls.contains("catalog"),
|
||||
"ls should list both kinds:\n{ls}"
|
||||
);
|
||||
|
||||
// Re-apply is a no-op (both markers already exist).
|
||||
common::pic_as(&env)
|
||||
.args(["apply", "--file"])
|
||||
.arg(&gpath)
|
||||
.assert()
|
||||
.success();
|
||||
|
||||
let _a = AppGuard::new(&env.url, &env.token, &app_a);
|
||||
let _b = AppGuard::new(&env.url, &env.token, &app_b);
|
||||
let _f = AppGuard::new(&env.url, &env.token, &foreign);
|
||||
for (app, grp) in [(&app_a, &group), (&app_b, &group), (&foreign, &sibling)] {
|
||||
common::pic_as(&env)
|
||||
.args(["apps", "create", app, "--group", grp])
|
||||
.assert()
|
||||
.success();
|
||||
}
|
||||
|
||||
// App A creates a document in the shared docs collection (authenticated).
|
||||
fs::write(
|
||||
dir.path().join("scripts/dwriter.rhai"),
|
||||
r#"docs::shared_collection("articles").create(#{ t: "hi" })"#,
|
||||
)
|
||||
.unwrap();
|
||||
common::pic_as(&env)
|
||||
.args(["scripts", "deploy"])
|
||||
.arg(dir.path().join("scripts/dwriter.rhai"))
|
||||
.args(["--app", &app_a, "--name", "dwriter"])
|
||||
.assert()
|
||||
.success();
|
||||
common::pic_as(&env)
|
||||
.args(["scripts", "invoke", &app_script_id(&env, &app_a, "dwriter")])
|
||||
.assert()
|
||||
.success();
|
||||
|
||||
// App B finds the SAME doc back — cross-app docs sharing + the find DSL.
|
||||
fs::write(
|
||||
dir.path().join("scripts/dreader.rhai"),
|
||||
r#"docs::shared_collection("articles").find(#{ t: "hi" })[0].data.t"#,
|
||||
)
|
||||
.unwrap();
|
||||
common::pic_as(&env)
|
||||
.args(["scripts", "deploy"])
|
||||
.arg(dir.path().join("scripts/dreader.rhai"))
|
||||
.args(["--app", &app_b, "--name", "dreader"])
|
||||
.assert()
|
||||
.success();
|
||||
assert_eq!(
|
||||
invoke_body(&env, &app_script_id(&env, &app_b, "dreader")),
|
||||
serde_json::json!("hi"),
|
||||
"app B finds the document app A created in the shared docs collection"
|
||||
);
|
||||
|
||||
// The foreign app (sibling subtree) → CollectionNotShared.
|
||||
fs::write(
|
||||
dir.path().join("scripts/dpeek.rhai"),
|
||||
r#"docs::shared_collection("articles").find(#{})"#,
|
||||
)
|
||||
.unwrap();
|
||||
common::pic_as(&env)
|
||||
.args(["scripts", "deploy"])
|
||||
.arg(dir.path().join("scripts/dpeek.rhai"))
|
||||
.args(["--app", &foreign, "--name", "dpeek"])
|
||||
.assert()
|
||||
.success();
|
||||
let out = common::pic_as(&env)
|
||||
.args(["scripts", "invoke", &app_script_id(&env, &foreign, "dpeek")])
|
||||
.output()
|
||||
.expect("invoke");
|
||||
assert!(
|
||||
!out.status.success(),
|
||||
"a foreign app must not resolve another subtree's shared docs collection"
|
||||
);
|
||||
}
|
||||
|
||||
/// §11.6 files slice: a group declares a `files`-kind shared collection (mixed
|
||||
/// with kv + docs in one string-or-table manifest); an authenticated app A
|
||||
/// `create`s a blob and app B `list`s + `get`s the SAME bytes back across the
|
||||
/// subtree (the group-keyed disk path under `files/groups/<group_id>/...`); a
|
||||
/// sibling-subtree app gets `CollectionNotShared`; `collections ls` shows all
|
||||
/// three kinds.
|
||||
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||
#[test]
|
||||
fn shared_files_collection_is_read_write_across_the_subtree() {
|
||||
let Some(fx) = common::fixture_or_skip() else {
|
||||
return;
|
||||
};
|
||||
let env = common::admin_env(fx);
|
||||
let group = common::unique_slug("fcoll-grp");
|
||||
let sibling = common::unique_slug("fcoll-sib");
|
||||
let app_a = common::unique_slug("fcoll-a");
|
||||
let app_b = common::unique_slug("fcoll-b");
|
||||
let foreign = common::unique_slug("fcoll-f");
|
||||
|
||||
let _g = GroupGuard::new(&env.url, &env.token, &group);
|
||||
let _gs = GroupGuard::new(&env.url, &env.token, &sibling);
|
||||
common::pic_as(&env)
|
||||
.args(["groups", "create", &group])
|
||||
.assert()
|
||||
.success();
|
||||
common::pic_as(&env)
|
||||
.args(["groups", "create", &sibling])
|
||||
.assert()
|
||||
.success();
|
||||
|
||||
// The group declares kv + docs + files collections in one manifest.
|
||||
let dir = manifest_dir();
|
||||
let gmanifest = format!(
|
||||
"[group]\nslug = \"{group}\"\nname = \"FColl\"\n\n\
|
||||
collections = [\"catalog\", {{ name = \"articles\", kind = \"docs\" }}, \
|
||||
{{ name = \"assets\", kind = \"files\" }}]\n"
|
||||
);
|
||||
let gpath = dir.path().join("group.toml");
|
||||
fs::write(&gpath, &gmanifest).unwrap();
|
||||
common::pic_as(&env)
|
||||
.args(["apply", "--file"])
|
||||
.arg(&gpath)
|
||||
.assert()
|
||||
.success();
|
||||
|
||||
// `collections ls` shows all three kinds.
|
||||
let ls = String::from_utf8(
|
||||
common::pic_as(&env)
|
||||
.args(["collections", "ls", "--group", &group])
|
||||
.output()
|
||||
.unwrap()
|
||||
.stdout,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(
|
||||
ls.contains("assets") && ls.contains("files"),
|
||||
"ls should list the files collection:\n{ls}"
|
||||
);
|
||||
|
||||
// Re-apply is a no-op (all three markers already exist).
|
||||
common::pic_as(&env)
|
||||
.args(["apply", "--file"])
|
||||
.arg(&gpath)
|
||||
.assert()
|
||||
.success();
|
||||
|
||||
let _a = AppGuard::new(&env.url, &env.token, &app_a);
|
||||
let _b = AppGuard::new(&env.url, &env.token, &app_b);
|
||||
let _f = AppGuard::new(&env.url, &env.token, &foreign);
|
||||
for (app, grp) in [(&app_a, &group), (&app_b, &group), (&foreign, &sibling)] {
|
||||
common::pic_as(&env)
|
||||
.args(["apps", "create", app, "--group", grp])
|
||||
.assert()
|
||||
.success();
|
||||
}
|
||||
|
||||
// App A creates a blob in the shared files collection (authenticated).
|
||||
// base64("hi") = "aGk=".
|
||||
fs::write(
|
||||
dir.path().join("scripts/fwriter.rhai"),
|
||||
r#"files::shared_collection("assets").create(#{ name: "a.txt", content_type: "text/plain", data: base64::decode("aGk=") })"#,
|
||||
)
|
||||
.unwrap();
|
||||
common::pic_as(&env)
|
||||
.args(["scripts", "deploy"])
|
||||
.arg(dir.path().join("scripts/fwriter.rhai"))
|
||||
.args(["--app", &app_a, "--name", "fwriter"])
|
||||
.assert()
|
||||
.success();
|
||||
common::pic_as(&env)
|
||||
.args(["scripts", "invoke", &app_script_id(&env, &app_a, "fwriter")])
|
||||
.assert()
|
||||
.success();
|
||||
|
||||
// App B lists the shared collection and reads the SAME bytes back —
|
||||
// cross-app blob sharing via the group-keyed disk path.
|
||||
fs::write(
|
||||
dir.path().join("scripts/freader.rhai"),
|
||||
r#"let c = files::shared_collection("assets"); let p = c.list(); base64::encode(c.get(p.files[0].id))"#,
|
||||
)
|
||||
.unwrap();
|
||||
common::pic_as(&env)
|
||||
.args(["scripts", "deploy"])
|
||||
.arg(dir.path().join("scripts/freader.rhai"))
|
||||
.args(["--app", &app_b, "--name", "freader"])
|
||||
.assert()
|
||||
.success();
|
||||
assert_eq!(
|
||||
invoke_body(&env, &app_script_id(&env, &app_b, "freader")),
|
||||
serde_json::json!("aGk="),
|
||||
"app B reads the blob app A wrote to the shared files collection"
|
||||
);
|
||||
|
||||
// The foreign app (sibling subtree) → CollectionNotShared.
|
||||
fs::write(
|
||||
dir.path().join("scripts/fpeek.rhai"),
|
||||
r#"files::shared_collection("assets").list()"#,
|
||||
)
|
||||
.unwrap();
|
||||
common::pic_as(&env)
|
||||
.args(["scripts", "deploy"])
|
||||
.arg(dir.path().join("scripts/fpeek.rhai"))
|
||||
.args(["--app", &foreign, "--name", "fpeek"])
|
||||
.assert()
|
||||
.success();
|
||||
let out = common::pic_as(&env)
|
||||
.args(["scripts", "invoke", &app_script_id(&env, &foreign, "fpeek")])
|
||||
.output()
|
||||
.expect("invoke");
|
||||
assert!(
|
||||
!out.status.success(),
|
||||
"a foreign app must not resolve another subtree's shared files collection"
|
||||
);
|
||||
}
|
||||
|
||||
/// Id of the named script in an app (`pic scripts ls --app <a>`).
|
||||
fn app_script_id(env: &common::TestEnv, app: &str, name: &str) -> String {
|
||||
let ls = common::pic_as(env)
|
||||
.args(["scripts", "ls", "--app", app])
|
||||
.output()
|
||||
.expect("scripts ls");
|
||||
let table = String::from_utf8(ls.stdout).unwrap();
|
||||
table
|
||||
.lines()
|
||||
.map(common::cells)
|
||||
.find(|c| c.get(2) == Some(&name))
|
||||
.and_then(|c| c.first().map(|s| (*s).to_string()))
|
||||
.unwrap_or_else(|| panic!("script `{name}` not found:\n{table}"))
|
||||
}
|
||||
|
||||
fn invoke_body(env: &common::TestEnv, id: &str) -> serde_json::Value {
|
||||
let out = common::pic_as(env)
|
||||
.args(["scripts", "invoke", id])
|
||||
.output()
|
||||
.expect("scripts invoke");
|
||||
assert!(
|
||||
out.status.success(),
|
||||
"invoke failed: {}",
|
||||
String::from_utf8_lossy(&out.stderr)
|
||||
);
|
||||
serde_json::from_slice(&out.stdout).expect("invoke body is JSON")
|
||||
}
|
||||
|
||||
/// §11.6 M4: the read-only operator admin API for a group's shared collections.
|
||||
/// A script writes to a shared KV collection; the operator then lists + fetches
|
||||
/// it through `pic kv ls --group` / `pic kv get --group` (no script).
|
||||
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||
#[test]
|
||||
fn operator_reads_shared_kv_via_admin_api() {
|
||||
let Some(fx) = common::fixture_or_skip() else {
|
||||
return;
|
||||
};
|
||||
let env = common::admin_env(fx);
|
||||
let group = common::unique_slug("m4-grp");
|
||||
let app = common::unique_slug("m4-app");
|
||||
|
||||
let _g = GroupGuard::new(&env.url, &env.token, &group);
|
||||
common::pic_as(&env)
|
||||
.args(["groups", "create", &group])
|
||||
.assert()
|
||||
.success();
|
||||
|
||||
let dir = manifest_dir();
|
||||
let gmanifest =
|
||||
format!("[group]\nslug = \"{group}\"\nname = \"M4\"\n\ncollections = [\"catalog\"]\n");
|
||||
let gpath = dir.path().join("group.toml");
|
||||
fs::write(&gpath, &gmanifest).unwrap();
|
||||
common::pic_as(&env)
|
||||
.args(["apply", "--file"])
|
||||
.arg(&gpath)
|
||||
.assert()
|
||||
.success();
|
||||
|
||||
let _a = AppGuard::new(&env.url, &env.token, &app);
|
||||
common::pic_as(&env)
|
||||
.args(["apps", "create", &app, "--group", &group])
|
||||
.assert()
|
||||
.success();
|
||||
|
||||
// A script writes a value to the shared collection.
|
||||
fs::write(
|
||||
dir.path().join("scripts/w.rhai"),
|
||||
r#"kv::shared_collection("catalog").set("op-key", #{ v: 42 }); "ok""#,
|
||||
)
|
||||
.unwrap();
|
||||
common::pic_as(&env)
|
||||
.args(["scripts", "deploy"])
|
||||
.arg(dir.path().join("scripts/w.rhai"))
|
||||
.args(["--app", &app, "--name", "w"])
|
||||
.assert()
|
||||
.success();
|
||||
common::pic_as(&env)
|
||||
.args(["scripts", "invoke", &app_script_id(&env, &app, "w")])
|
||||
.assert()
|
||||
.success();
|
||||
|
||||
// Operator lists the shared collection's keys via the admin API — no script.
|
||||
let ls = String::from_utf8(
|
||||
common::pic_as(&env)
|
||||
.args(["kv", "ls", "--group", &group, "--collection", "catalog"])
|
||||
.output()
|
||||
.unwrap()
|
||||
.stdout,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(
|
||||
ls.contains("op-key"),
|
||||
"kv ls --group must list the key:\n{ls}"
|
||||
);
|
||||
|
||||
// Operator fetches the value.
|
||||
let get = String::from_utf8(
|
||||
common::pic_as(&env)
|
||||
.args([
|
||||
"kv",
|
||||
"get",
|
||||
"--group",
|
||||
&group,
|
||||
"--collection",
|
||||
"catalog",
|
||||
"op-key",
|
||||
])
|
||||
.output()
|
||||
.unwrap()
|
||||
.stdout,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(
|
||||
get.contains("42"),
|
||||
"kv get --group must return the value:\n{get}"
|
||||
);
|
||||
}
|
||||
@@ -78,6 +78,26 @@ pub fn grant_membership(fx: &Fixture, app_slug: &str, user_id: &str, role: &str)
|
||||
);
|
||||
}
|
||||
|
||||
/// `POST /api/v1/admin/groups/{slug}/members` — grant `role` on a group.
|
||||
pub fn grant_group_membership(fx: &Fixture, group_slug: &str, user_id: &str, role: &str) {
|
||||
let client = reqwest::blocking::Client::new();
|
||||
let resp = client
|
||||
.post(format!(
|
||||
"{}/api/v1/admin/groups/{}/members",
|
||||
fx.url, group_slug
|
||||
))
|
||||
.bearer_auth(&fx.admin_token)
|
||||
.json(&json!({ "user_id": user_id, "role": role }))
|
||||
.send()
|
||||
.expect("grant group membership");
|
||||
assert!(
|
||||
resp.status().is_success(),
|
||||
"grant group membership failed: {} {}",
|
||||
resp.status(),
|
||||
resp.text().unwrap_or_default(),
|
||||
);
|
||||
}
|
||||
|
||||
/// `PATCH /api/v1/admin/apps/{slug}/members/{user_id}` — promote/demote.
|
||||
pub fn update_membership(fx: &Fixture, app_slug: &str, user_id: &str, role: &str) {
|
||||
let client = reqwest::blocking::Client::new();
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
//! §5.5 opt-in extension points, end to end via `pic`:
|
||||
//! * a group marks a module `theme` an extension point (default body) and an
|
||||
//! endpoint `render` imports it; an app inherits → uses the DEFAULT,
|
||||
//! * the app provides its OWN `theme` module → `render` now binds the app's
|
||||
//! (DYNAMIC override — the inverse of the Phase 4b sealed/lexical import),
|
||||
//! * `pic extension-points ls --app` shows the resolved provider,
|
||||
//! * an inherited extension point with NO provider is a `pic plan` error.
|
||||
//! Extension points (§5.5) end-to-end via `pic apply` + `invoke`:
|
||||
//! * a GROUP declares a module name `theme` an extension point (with a
|
||||
//! default body) and a group endpoint `render` that imports it,
|
||||
//! * an app under the group provides its OWN `theme`; invoking the inherited
|
||||
//! `render` in that app's context resolves the APP's `theme` (the
|
||||
//! inversion) — NOT sealed to the group,
|
||||
//! * an app that provides no `theme` falls back to the group's default body,
|
||||
//! * `pull` round-trips the declaration (a re-applied pulled manifest is a
|
||||
//! no-op, so `--prune` never silently drops it).
|
||||
|
||||
use std::fs;
|
||||
|
||||
@@ -21,14 +23,16 @@ fn manifest_dir() -> TempDir {
|
||||
|
||||
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||
#[test]
|
||||
fn extension_point_default_then_app_override() {
|
||||
fn extension_point_resolves_per_app_with_default_fallback() {
|
||||
let Some(fx) = common::fixture_or_skip() else {
|
||||
return;
|
||||
};
|
||||
let env = common::admin_env(fx);
|
||||
let group = common::unique_slug("ep-grp");
|
||||
let child = common::unique_slug("ep-child");
|
||||
let app_a = common::unique_slug("ep-a");
|
||||
let app_b = common::unique_slug("ep-b");
|
||||
|
||||
// GroupGuard first so it drops LAST (after apps + group scripts).
|
||||
let _g = GroupGuard::new(&env.url, &env.token, &group);
|
||||
common::pic_as(&env)
|
||||
.args(["groups", "create", &group])
|
||||
@@ -36,163 +40,171 @@ fn extension_point_default_then_app_override() {
|
||||
.success();
|
||||
|
||||
let dir = manifest_dir();
|
||||
// Group default `theme` module + a `render` endpoint importing it.
|
||||
|
||||
// --- Group manifest: a default `theme` body, a `render` endpoint that
|
||||
// imports `theme`, and the `[[extension_points]]` declaration. ---
|
||||
fs::write(
|
||||
dir.path().join("scripts/theme.rhai"),
|
||||
r#"fn color() { "blue" }"#,
|
||||
dir.path().join("scripts/defaulttheme.rhai"),
|
||||
r#"fn name() { "default" }"#,
|
||||
)
|
||||
.unwrap();
|
||||
common::pic_as(&env)
|
||||
.args(["scripts", "deploy"])
|
||||
.arg(dir.path().join("scripts/theme.rhai"))
|
||||
.args(["--group", &group, "--name", "theme", "--kind", "module"])
|
||||
.assert()
|
||||
.success();
|
||||
fs::write(
|
||||
dir.path().join("scripts/render.rhai"),
|
||||
r#"import "theme" as t; t::color()"#,
|
||||
r#"import "theme" as t; t::name()"#,
|
||||
)
|
||||
.unwrap();
|
||||
common::pic_as(&env)
|
||||
.args(["scripts", "deploy"])
|
||||
.arg(dir.path().join("scripts/render.rhai"))
|
||||
.args(["--group", &group, "--name", "render"])
|
||||
.assert()
|
||||
.success();
|
||||
let _gt = ScriptGuard::new(
|
||||
&env.url,
|
||||
&env.token,
|
||||
&group_script_id(&env, &group, "theme"),
|
||||
let group_manifest = format!(
|
||||
"[group]\nslug = \"{group}\"\nname = \"EP Group\"\n\n\
|
||||
[[scripts]]\nname = \"defaulttheme\"\nfile = \"scripts/defaulttheme.rhai\"\nkind = \"module\"\n\n\
|
||||
[[scripts]]\nname = \"render\"\nfile = \"scripts/render.rhai\"\n\n\
|
||||
[[extension_points]]\nname = \"theme\"\ndefault = \"defaulttheme\"\n"
|
||||
);
|
||||
let group_path = dir.path().join("group.toml");
|
||||
fs::write(&group_path, &group_manifest).unwrap();
|
||||
let out = common::pic_as(&env)
|
||||
.args(["apply", "--file"])
|
||||
.arg(&group_path)
|
||||
.output()
|
||||
.expect("group apply");
|
||||
assert!(
|
||||
out.status.success(),
|
||||
"group apply failed: {}",
|
||||
String::from_utf8_lossy(&out.stderr)
|
||||
);
|
||||
// Group scripts block group deletion (RESTRICT) — guard them.
|
||||
let _gr = ScriptGuard::new(
|
||||
&env.url,
|
||||
&env.token,
|
||||
&group_script_id(&env, &group, "render"),
|
||||
);
|
||||
let _gd = ScriptGuard::new(
|
||||
&env.url,
|
||||
&env.token,
|
||||
&group_script_id(&env, &group, "defaulttheme"),
|
||||
);
|
||||
|
||||
// Mark `theme` an extension point at the group (declarative apply).
|
||||
let gmanifest =
|
||||
format!("[group]\nslug = \"{group}\"\nname = \"EP\"\n\nextension_points = [\"theme\"]\n");
|
||||
let gpath = dir.path().join("group.toml");
|
||||
fs::write(&gpath, &gmanifest).unwrap();
|
||||
// --- App A under the group: provides its OWN `theme` + a caller that
|
||||
// invokes the inherited `render`. ---
|
||||
let _ga = AppGuard::new(&env.url, &env.token, &app_a);
|
||||
common::pic_as(&env)
|
||||
.args(["apply", "--file"])
|
||||
.arg(&gpath)
|
||||
.assert()
|
||||
.success();
|
||||
|
||||
// App under the group; a `caller` invokes the inherited `render`.
|
||||
let _child = AppGuard::new(&env.url, &env.token, &child);
|
||||
common::pic_as(&env)
|
||||
.args(["apps", "create", &child, "--group", &group])
|
||||
.args(["apps", "create", &app_a, "--group", &group])
|
||||
.assert()
|
||||
.success();
|
||||
fs::write(
|
||||
dir.path().join("scripts/theme-a.rhai"),
|
||||
r#"fn name() { "app-a" }"#,
|
||||
)
|
||||
.unwrap();
|
||||
fs::write(
|
||||
dir.path().join("scripts/caller.rhai"),
|
||||
r#"invoke("render", #{})"#,
|
||||
)
|
||||
.unwrap();
|
||||
let app_a_manifest = format!(
|
||||
"[app]\nslug = \"{app_a}\"\nname = \"EP A\"\n\n\
|
||||
[[scripts]]\nname = \"theme\"\nfile = \"scripts/theme-a.rhai\"\nkind = \"module\"\n\n\
|
||||
[[scripts]]\nname = \"caller\"\nfile = \"scripts/caller.rhai\"\n"
|
||||
);
|
||||
let app_a_path = dir.path().join("a.toml");
|
||||
fs::write(&app_a_path, &app_a_manifest).unwrap();
|
||||
common::pic_as(&env)
|
||||
.args(["scripts", "deploy"])
|
||||
.arg(dir.path().join("scripts/caller.rhai"))
|
||||
.args(["--app", &child, "--name", "caller"])
|
||||
.args(["apply", "--file"])
|
||||
.arg(&app_a_path)
|
||||
.assert()
|
||||
.success();
|
||||
let caller_id = app_script_id(&env, &child, "caller");
|
||||
|
||||
// Default: with no app override, `render`'s EP import falls back to the
|
||||
// group's default body → "blue".
|
||||
// The inherited `render` imports the ext point `theme`; in App A's context
|
||||
// it resolves App A's OWN `theme`.
|
||||
let caller_a = app_script_id(&env, &app_a, "caller");
|
||||
assert_eq!(
|
||||
invoke_body(&env, &caller_id),
|
||||
serde_json::json!("blue"),
|
||||
"an inherited extension point resolves the group's default body"
|
||||
invoke_body(&env, &caller_a),
|
||||
serde_json::json!("app-a"),
|
||||
"extension point must resolve the inheriting app's module"
|
||||
);
|
||||
|
||||
// DYNAMIC OVERRIDE: the app provides its own `theme`. Because `theme` is an
|
||||
// extension point, the group endpoint's import now binds the APP's module —
|
||||
// the exact inverse of a Phase 4b lexical (sealed) import.
|
||||
fs::write(
|
||||
dir.path().join("scripts/apptheme.rhai"),
|
||||
r#"fn color() { "red" }"#,
|
||||
)
|
||||
.unwrap();
|
||||
// --- App B under the group: provides NO `theme` → render falls back to the
|
||||
// group's default body. ---
|
||||
let _gb = AppGuard::new(&env.url, &env.token, &app_b);
|
||||
common::pic_as(&env)
|
||||
.args(["scripts", "deploy"])
|
||||
.arg(dir.path().join("scripts/apptheme.rhai"))
|
||||
.args(["--app", &child, "--name", "theme", "--kind", "module"])
|
||||
.args(["apps", "create", &app_b, "--group", &group])
|
||||
.assert()
|
||||
.success();
|
||||
assert_eq!(
|
||||
invoke_body(&env, &caller_id),
|
||||
serde_json::json!("red"),
|
||||
"the app must override an extension point the group declared"
|
||||
let app_b_manifest = format!(
|
||||
"[app]\nslug = \"{app_b}\"\nname = \"EP B\"\n\n\
|
||||
[[scripts]]\nname = \"caller\"\nfile = \"scripts/caller.rhai\"\n"
|
||||
);
|
||||
|
||||
// `extension-points ls --app` shows the resolved provider.
|
||||
let ls = String::from_utf8(
|
||||
common::pic_as(&env)
|
||||
.args(["extension-points", "ls", "--app", &child])
|
||||
.output()
|
||||
.unwrap()
|
||||
.stdout,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(
|
||||
ls.contains("theme") && ls.contains("app override"),
|
||||
"ls should report the app override:\n{ls}"
|
||||
let app_b_path = dir.path().join("b.toml");
|
||||
fs::write(&app_b_path, &app_b_manifest).unwrap();
|
||||
common::pic_as(&env)
|
||||
.args(["apply", "--file"])
|
||||
.arg(&app_b_path)
|
||||
.assert()
|
||||
.success();
|
||||
let caller_b = app_script_id(&env, &app_b, "caller");
|
||||
assert_eq!(
|
||||
invoke_body(&env, &caller_b),
|
||||
serde_json::json!("default"),
|
||||
"an app providing no module must fall back to the ext point's default"
|
||||
);
|
||||
}
|
||||
|
||||
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||
#[test]
|
||||
fn extension_point_without_provider_is_rejected_by_plan() {
|
||||
fn extension_point_round_trips_through_pull() {
|
||||
let Some(fx) = common::fixture_or_skip() else {
|
||||
return;
|
||||
};
|
||||
let env = common::admin_env(fx);
|
||||
let group = common::unique_slug("epn-grp");
|
||||
let child = common::unique_slug("epn-child");
|
||||
let app = common::unique_slug("ep-pull");
|
||||
|
||||
let _g = GroupGuard::new(&env.url, &env.token, &group);
|
||||
let _a = AppGuard::new(&env.url, &env.token, &app);
|
||||
common::pic_as(&env)
|
||||
.args(["groups", "create", &group])
|
||||
.args(["apps", "create", &app])
|
||||
.assert()
|
||||
.success();
|
||||
|
||||
// Group declares an EP `ghost` with NO default body (body-less).
|
||||
// Apply an app manifest that declares an extension point with a default.
|
||||
let dir = manifest_dir();
|
||||
let gmanifest =
|
||||
format!("[group]\nslug = \"{group}\"\nname = \"EPN\"\n\nextension_points = [\"ghost\"]\n");
|
||||
let gpath = dir.path().join("group.toml");
|
||||
fs::write(&gpath, &gmanifest).unwrap();
|
||||
fs::write(
|
||||
dir.path().join("scripts/theme.rhai"),
|
||||
r#"fn name() { "x" }"#,
|
||||
)
|
||||
.unwrap();
|
||||
let manifest = format!(
|
||||
"[app]\nslug = \"{app}\"\nname = \"EP Pull\"\n\n\
|
||||
[[scripts]]\nname = \"theme\"\nfile = \"scripts/theme.rhai\"\nkind = \"module\"\n\n\
|
||||
[[extension_points]]\nname = \"theme_slot\"\ndefault = \"theme\"\n"
|
||||
);
|
||||
let manifest_path = dir.path().join("picloud.toml");
|
||||
fs::write(&manifest_path, &manifest).unwrap();
|
||||
common::pic_as(&env)
|
||||
.args(["apply", "--file"])
|
||||
.arg(&gpath)
|
||||
.arg(&manifest_path)
|
||||
.assert()
|
||||
.success();
|
||||
|
||||
// App under the group provides no `ghost` → any plan for it is refused.
|
||||
let _child = AppGuard::new(&env.url, &env.token, &child);
|
||||
// Pull into a fresh dir, then re-plan: the extension point must round-trip
|
||||
// (no Create/Delete), so `--prune` would never drop it.
|
||||
let pulled = manifest_dir();
|
||||
common::pic_as(&env)
|
||||
.args(["apps", "create", &child, "--group", &group])
|
||||
.args(["pull", &app, "--dir"])
|
||||
.arg(pulled.path())
|
||||
.assert()
|
||||
.success();
|
||||
let amanifest = format!("[app]\nslug = \"{child}\"\nname = \"EPN child\"\n");
|
||||
let apath = dir.path().join("picloud.toml");
|
||||
fs::write(&apath, &amanifest).unwrap();
|
||||
let out = common::pic_as(&env)
|
||||
let toml = fs::read_to_string(pulled.path().join("picloud.toml")).unwrap();
|
||||
assert!(
|
||||
toml.contains("theme_slot"),
|
||||
"pulled manifest must export the extension point:\n{toml}"
|
||||
);
|
||||
let plan = common::pic_as(&env)
|
||||
.args(["plan", "--file"])
|
||||
.arg(&apath)
|
||||
.arg(pulled.path().join("picloud.toml"))
|
||||
.output()
|
||||
.expect("plan");
|
||||
let stdout = String::from_utf8_lossy(&plan.stdout);
|
||||
assert!(
|
||||
!out.status.success(),
|
||||
"an inherited extension point with no provider must fail plan"
|
||||
);
|
||||
let stderr = String::from_utf8_lossy(&out.stderr).to_lowercase();
|
||||
assert!(
|
||||
stderr.contains("ghost") || stderr.contains("no provider") || stderr.contains("extension"),
|
||||
"plan error should name the unprovided extension point:\n{stderr}"
|
||||
!stdout.to_lowercase().contains("create") && !stdout.to_lowercase().contains("delete"),
|
||||
"re-planning a pulled manifest must be a no-op:\n{stdout}"
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -137,37 +137,6 @@ fn group_module_is_lexically_sealed_under_inheritance() {
|
||||
);
|
||||
}
|
||||
|
||||
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||
#[test]
|
||||
fn reserved_group_module_name_is_rejected() {
|
||||
// Parity with app modules (api.rs): a group `module` named after a built-in
|
||||
// SDK namespace is refused at create — not silently accepted to fail later.
|
||||
let Some(fx) = common::fixture_or_skip() else {
|
||||
return;
|
||||
};
|
||||
let env = common::admin_env(fx);
|
||||
let group = common::unique_slug("gm-rsv");
|
||||
|
||||
let _g = GroupGuard::new(&env.url, &env.token, &group);
|
||||
common::pic_as(&env)
|
||||
.args(["groups", "create", &group])
|
||||
.assert()
|
||||
.success();
|
||||
|
||||
let dir = manifest_dir();
|
||||
fs::write(dir.path().join("scripts/kv.rhai"), r#"fn x() { 1 }"#).unwrap();
|
||||
let out = common::pic_as(&env)
|
||||
.args(["scripts", "deploy"])
|
||||
.arg(dir.path().join("scripts/kv.rhai"))
|
||||
.args(["--group", &group, "--name", "kv", "--kind", "module"])
|
||||
.output()
|
||||
.expect("deploy");
|
||||
assert!(
|
||||
!out.status.success(),
|
||||
"a reserved module name must be rejected for a group module too"
|
||||
);
|
||||
}
|
||||
|
||||
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||
#[test]
|
||||
fn dangling_import_is_rejected_by_plan() {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user