Two clean, local dedups in the group shared-collection services: - Extract `GroupKvServiceImpl::check_value_size` — `set` and `set_if` inlined the identical encode-and-cap block; now one method (mirrors the sibling `group_docs_service::check_data_size`). - Add `group_collection_repo::best_effort_emit_shared` — the KV/docs/files services each copied the same emit-and-log-on-error tail for shared-collection triggers. Centralize the tail (logging the event's own `source`/`op`); each service still builds its own `ServiceEvent` (payload shapes differ). Pure refactor, pinned by the existing group-service unit tests. (The `owning_group` resolver was evaluated for dedup too but left as-is: the five error enums diverge — `GroupFilesError::InvalidCollection` carries a `String`, `GroupPubsubError` lacks the variant, and files/pubsub skip the empty-check kv/ docs/queue do — so a shared conversion would be a behavior change, not a refactor.) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
208 lines
7.3 KiB
Rust
208 lines
7.3 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, 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<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
|
|
}
|
|
}
|