Files
PiCloud/crates/manager-core/src/group_collection_repo.rs
MechaCat02 f552238586 refactor(modules): drop dead group_collection_repo::list_for_owner (§11.6 review)
The single-kind `list_for_owner` was superseded by `list_all_for_owner`
(the apply diff and `collection_report` both moved to it during the docs
slice). It had no callers but, being `pub`, escaped the dead-code lint —
removing it drops two unexercised SQL queries.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 19:19:48 +02:00

190 lines
6.4 KiB
Rust

//! 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
}
}