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:
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user