feat(modules): group-collection registry + group-KV storage schema (§11.6 C1)

Data layer for shared cross-app group collections (KV-only MVP), nothing wired
yet. Two migrations + two repos, modeled on the extension_points (0051) marker
and the kv_entries (0007) store:

- 0052_group_collections.sql: owner-polymorphic marker table declaring a
  collection name group-shared, with a `kind` discriminator (CHECK 'kv' for
  now; generalizes to docs/files/topics/queue later) and per-owner
  partial-unique (owner, LOWER(name), kind) indexes. CASCADE — a marker is
  config, not code.
- 0053_group_kv_entries.sql: the shared store, keyed by (group_id, collection,
  key) — NO app_id (a shared row belongs to the group). CASCADE on group delete
  (data dies with its group, like vars/secrets config; an app delete leaves it).
- group_collection_repo: list_for_owner, insert/delete_collection_tx, and the
  load-bearing resolve_owning_group — walks the reading app's chain
  (CHAIN_LEVELS_CTE) for the nearest ancestor group declaring the name
  (nearest-wins via ORDER BY depth LIMIT 1). That walk IS the isolation
  boundary; the join is on group_owner only.
- group_kv_repo: a near-clone of kv_repo keyed by group_id.

Schema snapshot re-blessed (53 migrations).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-06-29 21:30:42 +02:00
parent e53e2f583d
commit f1d5f5c34e
6 changed files with 494 additions and 0 deletions

View File

@@ -0,0 +1,151 @@
//! 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`].
//!
//! 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.
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> {
let rows: Vec<(String,)> = match owner {
ScriptOwner::App(a) => {
sqlx::query_as(
"SELECT name FROM group_collections \
WHERE app_id = $1 AND kind = $2 ORDER BY LOWER(name)",
)
.bind(a.into_inner())
.bind(KIND_KV)
.fetch_all(pool)
.await?
}
ScriptOwner::Group(g) => {
sqlx::query_as(
"SELECT name FROM group_collections \
WHERE group_id = $1 AND kind = $2 ORDER BY LOWER(name)",
)
.bind(g.into_inner())
.bind(KIND_KV)
.fetch_all(pool)
.await?
}
};
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.
///
/// 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,
) -> 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_KV)
.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.
pub async fn insert_collection_tx(
tx: &mut Transaction<'_, Postgres>,
owner: ScriptOwner,
name: &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_KV)
.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_KV)
.execute(&mut **tx)
.await?;
}
}
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.
pub async fn delete_collection_tx(
tx: &mut Transaction<'_, Postgres>,
owner: ScriptOwner,
name: &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_KV)
.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_KV)
.execute(&mut **tx)
.await?;
}
}
Ok(())
}