feat(projects): projects repo + group owner reads; wire into ApplyService

Read side of §7 ownership, ahead of the claim logic.

- project_repo: `ProjectRepository` trait + `PostgresProjectRepository`
  (list / get_by_id / get_by_slug). Writes are NOT here — a claim registers
  its project via an in-tx upsert (next commit).
- group_repo: `list_with_owner()` LEFT-JOINs projects for each group's owner
  slug (backs `pic groups ls`); columns qualified since id/slug/name exist on
  both tables.
- ApplyService gains a `projects` handle; constructed in the picloud binary.
- tests/projects_repo: round-trip pinning list_with_owner (claimed→slug,
  unclaimed→None) and that ancestors() surfaces owner_project nearest-first —
  the fold input the claim path walks.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-07-06 20:13:34 +02:00
parent ba97c35aaf
commit 2cce051dc4
6 changed files with 243 additions and 6 deletions

View File

@@ -653,6 +653,10 @@ pub struct ApplyService {
pub apps: Arc<dyn AppRepository>,
/// Group lookups (Phase 5): resolve a group slug→id for group/tree apply.
pub groups: Arc<dyn crate::group_repo::GroupRepository>,
/// Project registry (§7 multi-repo ownership): the READ side. The claim
/// write path registers projects via `upsert_project_tx` inside the apply
/// transaction, not through this repo.
pub projects: Arc<dyn crate::project_repo::ProjectRepository>,
/// Module resolver (Phase 4b): lexical, origin-aware module lookup used by
/// the single-node dangling-import plan check (§5.5).
pub modules: Arc<dyn picloud_shared::ModuleSource>,

View File

@@ -58,6 +58,10 @@ pub trait GroupRepository: Send + Sync {
/// Every group on the instance, ordered by name. The tree is small
/// (org structure), so callers assemble the hierarchy in memory.
async fn list(&self) -> Result<Vec<Group>, GroupRepositoryError>;
/// Every group paired with its owning project's slug (`None` when the node
/// is unclaimed — no manifest owns it). One LEFT JOIN instead of an N+1
/// per-group project lookup; backs `pic groups ls`' owner column (§7).
async fn list_with_owner(&self) -> Result<Vec<(Group, Option<String>)>, GroupRepositoryError>;
async fn get_by_id(&self, id: GroupId) -> Result<Option<Group>, GroupRepositoryError>;
async fn get_by_slug(&self, slug: &str) -> Result<Option<Group>, GroupRepositoryError>;
/// Direct children groups of `parent`.
@@ -119,6 +123,24 @@ impl GroupRepository for PostgresGroupRepository {
Ok(rows.into_iter().map(Into::into).collect())
}
async fn list_with_owner(&self) -> Result<Vec<(Group, Option<String>)>, GroupRepositoryError> {
// Columns are qualified (`g.`) because `id`/`slug`/`name` exist on both
// groups and projects — a bare GROUP_COLS would be ambiguous here.
let rows = sqlx::query_as::<_, GroupWithOwnerRow>(
"SELECT g.id, g.parent_id, g.slug, g.name, g.description, \
g.structure_version, g.created_at, g.updated_at, g.owner_project, \
p.slug AS owner_slug \
FROM groups g LEFT JOIN projects p ON p.id = g.owner_project \
ORDER BY g.name",
)
.fetch_all(&self.pool)
.await?;
Ok(rows
.into_iter()
.map(|r| (r.group.into(), r.owner_slug))
.collect())
}
async fn get_by_id(&self, id: GroupId) -> Result<Option<Group>, GroupRepositoryError> {
let row = sqlx::query_as::<_, GroupRow>(&format!(
"SELECT {GROUP_COLS} FROM groups WHERE id = $1"
@@ -353,6 +375,15 @@ struct GroupRow {
owner_project: Option<Uuid>,
}
/// `list_with_owner` row: a `GroupRow` flattened with its owning project's
/// slug (`NULL` when the group is unclaimed).
#[derive(sqlx::FromRow)]
struct GroupWithOwnerRow {
#[sqlx(flatten)]
group: GroupRow,
owner_slug: Option<String>,
}
impl From<GroupRow> for Group {
fn from(r: GroupRow) -> Self {
Self {

View File

@@ -79,6 +79,7 @@ pub mod module_source;
pub mod outbox_event_emitter;
pub mod outbox_repo;
pub mod principal_resolver;
pub mod project_repo;
pub mod pubsub_repo;
pub mod pubsub_service;
pub mod queue_repo;
@@ -223,6 +224,7 @@ pub use outbox_repo::{
NewOutboxRow, OutboxRepo, OutboxRepoError, OutboxRow, OutboxSourceKind, PostgresOutboxRepo,
};
pub use principal_resolver::{AdminPrincipalResolver, PrincipalResolver, PrincipalResolverError};
pub use project_repo::{PostgresProjectRepository, ProjectRepository, ProjectRepositoryError};
pub use pubsub_repo::{PostgresPubsubRepo, PublishCtx, PubsubRepo, PubsubRepoError};
pub use pubsub_service::{PubsubServiceImpl, SubscriberTokenConfig};
pub use realtime_authority::RealtimeAuthorityImpl;

View File

@@ -0,0 +1,92 @@
//! Read access over the `projects` registry (§7 multi-repo ownership).
//!
//! A project is registered by the APPLY path — an upsert-by-slug inside the
//! reconcile transaction (see `apply_service`), so a claim registers the
//! project atomically with the write. This repo is the READ side only, used
//! for ownership visibility (`pic projects ls`, the `pic groups ls` owner
//! column) and tests. Writes deliberately do not live here.
use async_trait::async_trait;
use picloud_shared::{Project, ProjectId};
use sqlx::PgPool;
use uuid::Uuid;
#[derive(Debug, thiserror::Error)]
pub enum ProjectRepositoryError {
#[error("database error: {0}")]
Db(#[from] sqlx::Error),
}
#[async_trait]
pub trait ProjectRepository: Send + Sync {
/// Every project on the instance, ordered by slug.
async fn list(&self) -> Result<Vec<Project>, ProjectRepositoryError>;
async fn get_by_id(&self, id: ProjectId) -> Result<Option<Project>, ProjectRepositoryError>;
async fn get_by_slug(&self, slug: &str) -> Result<Option<Project>, ProjectRepositoryError>;
}
pub struct PostgresProjectRepository {
pool: PgPool,
}
impl PostgresProjectRepository {
#[must_use]
pub fn new(pool: PgPool) -> Self {
Self { pool }
}
}
const PROJECT_COLS: &str = "id, slug, name, created_by, created_at";
#[async_trait]
impl ProjectRepository for PostgresProjectRepository {
async fn list(&self) -> Result<Vec<Project>, ProjectRepositoryError> {
let rows = sqlx::query_as::<_, ProjectRow>(&format!(
"SELECT {PROJECT_COLS} FROM projects ORDER BY slug"
))
.fetch_all(&self.pool)
.await?;
Ok(rows.into_iter().map(Into::into).collect())
}
async fn get_by_id(&self, id: ProjectId) -> Result<Option<Project>, ProjectRepositoryError> {
let row = sqlx::query_as::<_, ProjectRow>(&format!(
"SELECT {PROJECT_COLS} FROM projects WHERE id = $1"
))
.bind(id.into_inner())
.fetch_optional(&self.pool)
.await?;
Ok(row.map(Into::into))
}
async fn get_by_slug(&self, slug: &str) -> Result<Option<Project>, ProjectRepositoryError> {
let row = sqlx::query_as::<_, ProjectRow>(&format!(
"SELECT {PROJECT_COLS} FROM projects WHERE slug = $1"
))
.bind(slug)
.fetch_optional(&self.pool)
.await?;
Ok(row.map(Into::into))
}
}
#[derive(sqlx::FromRow)]
struct ProjectRow {
id: Uuid,
slug: String,
name: String,
created_by: Option<Uuid>,
created_at: chrono::DateTime<chrono::Utc>,
}
impl From<ProjectRow> for Project {
fn from(r: ProjectRow) -> Self {
Self {
id: r.id.into(),
slug: r.slug,
name: r.name,
created_by: r.created_by.map(Into::into),
created_at: r.created_at,
}
}
}