feat(modules): group-docs schema + kind-generalized registry (§11.6 docs C1)

Data layer for extending shared group collections from KV to docs. KV behavior
unchanged (callers pass kind="kv").

- 0054_group_docs.sql: widen the group_collections kind CHECK to ('kv','docs')
  and add the group-keyed group_docs store (clone of docs/0013, keyed by
  group_id, CASCADE on group delete) + its (group_id,collection) and data GIN
  indexes.
- group_collection_repo: thread a `kind` parameter through list_for_owner,
  resolve_owning_group, insert/delete_collection_tx; add list_all_for_owner ->
  (name,kind) for the kind-aware diff (D4). Relocate the injectable
  GroupCollectionResolver trait + Postgres impl here (now shared by kv+docs;
  resolve takes kind) — was in group_kv_service.
- group_docs_repo: a near-clone of docs_repo keyed by group_id; find reuses the
  generalized build_find_query.
- docs_repo: generalize build_find_query(table, owner_col, owner_id, …) — both
  are compile-time literals (no injection); app docs passes ("docs","app_id"),
  group docs ("group_docs","group_id"). row_to_doc/build_find_query are now
  pub(crate) for reuse.

Schema snapshot re-blessed (55 migrations).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-06-30 07:33:39 +02:00
parent af525e71bd
commit 5d1d4b3ff6
8 changed files with 479 additions and 87 deletions

View File

@@ -1,30 +1,31 @@
//! Group-collection markers (§11.6) — the `group_collections` table (0052).
//!
//! A marker `(owner, name, kind)` declares that a collection name is
//! group-shared (the data lives in a per-kind store; `group_kv_entries` for
//! `kind='kv'`). This module holds the read + transactional-write helpers and
//! the runtime resolver; it mirrors the var/secret/extension-point tx-function
//! style (free functions over a `&PgPool` / `&mut Transaction`), keyed by the
//! shared [`ScriptOwner`].
//! 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. That walk **is** the isolation boundary — a foreign app's
//! chain never contains the owning group, so the name does not resolve.
//! 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;
/// The only storage kind that ships in the MVP. The `kind` column generalizes
/// to docs/files/topics/queue later; every query pins this value for now.
const KIND_KV: &str = "kv";
/// List the collection names declared **directly at** `owner` (not inherited),
/// case-insensitively sorted. Used by `load_current` (apply diff) and the
/// read-only `collections ls`. MVP is `kind='kv'` only.
pub async fn list_for_owner(pool: &PgPool, owner: ScriptOwner) -> Result<Vec<String>, sqlx::Error> {
/// List the collection names of `kind` declared **directly at** `owner` (not
/// inherited), case-insensitively sorted. Used by `load_current` (apply diff)
/// and the read-only `collections ls`.
pub async fn list_for_owner(
pool: &PgPool,
owner: ScriptOwner,
kind: &str,
) -> Result<Vec<String>, sqlx::Error> {
let rows: Vec<(String,)> = match owner {
ScriptOwner::App(a) => {
sqlx::query_as(
@@ -32,7 +33,7 @@ pub async fn list_for_owner(pool: &PgPool, owner: ScriptOwner) -> Result<Vec<Str
WHERE app_id = $1 AND kind = $2 ORDER BY LOWER(name)",
)
.bind(a.into_inner())
.bind(KIND_KV)
.bind(kind)
.fetch_all(pool)
.await?
}
@@ -42,7 +43,7 @@ pub async fn list_for_owner(pool: &PgPool, owner: ScriptOwner) -> Result<Vec<Str
WHERE group_id = $1 AND kind = $2 ORDER BY LOWER(name)",
)
.bind(g.into_inner())
.bind(KIND_KV)
.bind(kind)
.fetch_all(pool)
.await?
}
@@ -50,11 +51,40 @@ pub async fn list_for_owner(pool: &PgPool, owner: ScriptOwner) -> Result<Vec<Str
Ok(rows.into_iter().map(|(n,)| n).collect())
}
/// Resolve the group that OWNS the shared collection `name` for a reading app:
/// the nearest ancestor group on the app's chain that declares it (`kind='kv'`).
/// Returns `None` when no group on the chain shares that name — the structural
/// "not shared with you" boundary. Nearest-wins (CoW shadowing) is enforced by
/// `ORDER BY depth ASC LIMIT 1` and is security-relevant.
/// 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
@@ -63,6 +93,7 @@ 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} \
@@ -73,19 +104,20 @@ pub async fn resolve_owning_group(
))
.bind(app_id.into_inner())
.bind(name)
.bind(KIND_KV)
.bind(kind)
.fetch_optional(pool)
.await?;
Ok(row.map(|(id,)| GroupId::from(id)))
}
/// Insert a collection 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.
/// 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) => {
@@ -95,7 +127,7 @@ pub async fn insert_collection_tx(
)
.bind(a.into_inner())
.bind(name)
.bind(KIND_KV)
.bind(kind)
.execute(&mut **tx)
.await?;
}
@@ -106,7 +138,7 @@ pub async fn insert_collection_tx(
)
.bind(g.into_inner())
.bind(name)
.bind(KIND_KV)
.bind(kind)
.execute(&mut **tx)
.await?;
}
@@ -114,14 +146,15 @@ pub async fn insert_collection_tx(
Ok(())
}
/// Delete a collection marker at `owner` (case-insensitive), in the apply
/// transaction. Used by `--prune` when the manifest stops declaring a name.
/// The `group_kv_entries` data is NOT dropped here — pruning a marker hides the
/// store but leaves the data until the owning group is deleted.
/// 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) => {
@@ -131,7 +164,7 @@ pub async fn delete_collection_tx(
)
.bind(a.into_inner())
.bind(name)
.bind(KIND_KV)
.bind(kind)
.execute(&mut **tx)
.await?;
}
@@ -142,10 +175,48 @@ pub async fn delete_collection_tx(
)
.bind(g.into_inner())
.bind(name)
.bind(KIND_KV)
.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
}
}