//! 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, SdkCallCx, ServiceEvent, ServiceEventEmitter}; use sqlx::{PgPool, Postgres, Transaction}; use crate::config_resolver::CHAIN_LEVELS_CTE; /// §11.6: fire a `shared = true` trigger event on the owning group, best-effort. /// An emit failure is logged (with the event's `source`/`op`) and swallowed — /// the write already committed, and shared triggers are fire-and-forget, so a /// dropped emit must never fail the caller. The single home for the tail the /// group KV/docs/files services previously each copied; each still builds its /// own `ServiceEvent` (payload shapes differ) and calls this. pub(crate) async fn best_effort_emit_shared( events: &dyn ServiceEventEmitter, cx: &SdkCallCx, group_id: GroupId, event: ServiceEvent, ) { let (source, op) = (event.source, event.op); if let Err(e) = events.emit_shared(cx, group_id, event).await { tracing::error!(error = %e, source, op, event_emit_failure = true, "shared event emit failed"); } } /// 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, 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, 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, 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, sqlx::Error> { resolve_owning_group(&self.pool, app_id, name, kind).await } }