feat(projects): projects table + owner_project FK; ProjectId/Project types
First commit of §7 multi-repo ownership. A 'project' is a repo-root that declaratively manages a slice of the shared group tree; each group node is owned by exactly one project (single-owner-per-node). This lands the registry + shared types and wires the inert 0047 `owner_project` seam — no behavior change yet (claim logic is the next commit). - migration 0066: `projects` table (UUID pk, unique slug, name, created_by → admin_users ON DELETE SET NULL); `groups.owner_project` gains its FK → projects(id) ON DELETE SET NULL (un-claim, never cascade-destroy a tree) + an index. - shared: `ProjectId` (id_type! macro) + `Project` struct; `Group` gains `owner_project: Option<ProjectId>`. - group_repo: GROUP_COLS + the ancestors recursive-CTE term now carry `owner_project`, so `ancestors()` surfaces ownership for the nearest-claimed fold; GroupRow + From updated. - schema snapshot re-blessed; /version schema assertion 65 → 66. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
41
crates/manager-core/migrations/0066_projects.sql
Normal file
41
crates/manager-core/migrations/0066_projects.sql
Normal file
@@ -0,0 +1,41 @@
|
||||
-- §7 multi-repo ownership: the `projects` registry + wiring the inert
|
||||
-- `owner_project` seam.
|
||||
--
|
||||
-- A "project" is a repo-root that declaratively MANAGES a slice of the shared
|
||||
-- group tree (contains its manifest as something it applies, not merely
|
||||
-- references). §7's model is SINGLE-OWNER-PER-NODE: each group node is owned by
|
||||
-- exactly one project; the first `pic apply` that declares a `[project]` claims
|
||||
-- the node, and a second repo applying to an owned node is refused unless it
|
||||
-- passes a group-admin-gated `--takeover`. Ownership ⟂ RBAC — it records which
|
||||
-- manifest is authoritative, not whether a principal may act.
|
||||
--
|
||||
-- Identity is a committed `[project] slug` (stable across clones); the server
|
||||
-- assigns the UUID (server-internal). The `owner_project UUID` column already
|
||||
-- exists on `groups` (0047, inert) — this migration creates the table it was
|
||||
-- always meant to reference and wires the FK. Apps carry NO owner column: an
|
||||
-- app inherits ownership from its NEAREST claimed ancestor group (the ancestor
|
||||
-- walk is the isolation boundary), faithful to §7's "don't co-own a node —
|
||||
-- split config downward" corollary.
|
||||
|
||||
CREATE TABLE projects (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
-- Instance-global identity, declared in the repo's committed root manifest
|
||||
-- and frozen. First apply with a new slug registers the project.
|
||||
slug TEXT NOT NULL UNIQUE,
|
||||
name TEXT NOT NULL,
|
||||
-- The admin who first registered it. SET NULL (not CASCADE) — losing the
|
||||
-- creator's account must not orphan-delete the project or un-claim its tree.
|
||||
created_by UUID REFERENCES admin_users(id) ON DELETE SET NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- Wire the inert 0047 seam. ON DELETE SET NULL un-claims the owned nodes (they
|
||||
-- fall back to UI/API-owned) rather than cascading a destructive tree delete —
|
||||
-- deleting a project must never destroy the groups/apps it happened to manage.
|
||||
ALTER TABLE groups
|
||||
ADD CONSTRAINT groups_owner_project_fk
|
||||
FOREIGN KEY (owner_project) REFERENCES projects(id) ON DELETE SET NULL;
|
||||
|
||||
-- Ownership lookups: "what does project X own?" (pic projects ls) and the
|
||||
-- LEFT JOIN behind pic groups ls' owner column.
|
||||
CREATE INDEX groups_owner_project_idx ON groups (owner_project);
|
||||
@@ -105,8 +105,8 @@ impl PostgresGroupRepository {
|
||||
}
|
||||
}
|
||||
|
||||
const GROUP_COLS: &str =
|
||||
"id, parent_id, slug, name, description, structure_version, created_at, updated_at";
|
||||
const GROUP_COLS: &str = "id, parent_id, slug, name, description, structure_version, created_at, \
|
||||
updated_at, owner_project";
|
||||
|
||||
#[async_trait]
|
||||
impl GroupRepository for PostgresGroupRepository {
|
||||
@@ -157,7 +157,8 @@ impl GroupRepository for PostgresGroupRepository {
|
||||
SELECT {GROUP_COLS}, 0 AS depth FROM groups WHERE id = $1
|
||||
UNION ALL
|
||||
SELECT g.id, g.parent_id, g.slug, g.name, g.description, \
|
||||
g.structure_version, g.created_at, g.updated_at, c.depth + 1 \
|
||||
g.structure_version, g.created_at, g.updated_at, g.owner_project, \
|
||||
c.depth + 1 \
|
||||
FROM groups g JOIN chain c ON g.id = c.parent_id \
|
||||
WHERE c.depth < 64
|
||||
)
|
||||
@@ -349,6 +350,7 @@ struct GroupRow {
|
||||
structure_version: i64,
|
||||
created_at: chrono::DateTime<chrono::Utc>,
|
||||
updated_at: chrono::DateTime<chrono::Utc>,
|
||||
owner_project: Option<Uuid>,
|
||||
}
|
||||
|
||||
impl From<GroupRow> for Group {
|
||||
@@ -360,6 +362,7 @@ impl From<GroupRow> for Group {
|
||||
name: r.name,
|
||||
description: r.description,
|
||||
structure_version: r.structure_version,
|
||||
owner_project: r.owner_project.map(Into::into),
|
||||
created_at: r.created_at,
|
||||
updated_at: r.updated_at,
|
||||
}
|
||||
|
||||
@@ -318,6 +318,13 @@ table: outbox
|
||||
claimed_by: text NULL
|
||||
created_at: timestamp with time zone NOT NULL default=now()
|
||||
|
||||
table: projects
|
||||
id: uuid NOT NULL default=gen_random_uuid()
|
||||
slug: text NOT NULL
|
||||
name: text NOT NULL
|
||||
created_by: uuid NULL
|
||||
created_at: timestamp with time zone NOT NULL default=now()
|
||||
|
||||
table: pubsub_trigger_details
|
||||
trigger_id: uuid NOT NULL
|
||||
topic_pattern: text NOT NULL
|
||||
@@ -579,6 +586,7 @@ indexes on group_queue_messages:
|
||||
idx_group_queue_messages_group_collection: public.group_queue_messages USING btree (group_id, collection)
|
||||
|
||||
indexes on groups:
|
||||
groups_owner_project_idx: public.groups USING btree (owner_project)
|
||||
groups_parent_id_idx: public.groups USING btree (parent_id)
|
||||
groups_pkey: public.groups USING btree (id)
|
||||
groups_slug_key: public.groups USING btree (slug)
|
||||
@@ -595,6 +603,10 @@ indexes on outbox:
|
||||
idx_outbox_due: public.outbox USING btree (next_attempt_at) WHERE (claimed_at IS NULL)
|
||||
outbox_pkey: public.outbox USING btree (id)
|
||||
|
||||
indexes on projects:
|
||||
projects_pkey: public.projects USING btree (id)
|
||||
projects_slug_key: public.projects USING btree (slug)
|
||||
|
||||
indexes on pubsub_trigger_details:
|
||||
pubsub_trigger_details_pkey: public.pubsub_trigger_details USING btree (trigger_id)
|
||||
|
||||
@@ -817,6 +829,7 @@ constraints on group_queue_messages:
|
||||
[PRIMARY KEY] group_queue_messages_pkey: PRIMARY KEY (id)
|
||||
|
||||
constraints on groups:
|
||||
[FOREIGN KEY] groups_owner_project_fk: FOREIGN KEY (owner_project) REFERENCES projects(id) ON DELETE SET NULL
|
||||
[FOREIGN KEY] groups_parent_id_fkey: FOREIGN KEY (parent_id) REFERENCES groups(id) ON DELETE RESTRICT
|
||||
[PRIMARY KEY] groups_pkey: PRIMARY KEY (id)
|
||||
[UNIQUE] groups_slug_key: UNIQUE (slug)
|
||||
@@ -834,6 +847,11 @@ constraints on outbox:
|
||||
[FOREIGN KEY] outbox_app_id_fkey: FOREIGN KEY (app_id) REFERENCES apps(id) ON DELETE CASCADE
|
||||
[PRIMARY KEY] outbox_pkey: PRIMARY KEY (id)
|
||||
|
||||
constraints on projects:
|
||||
[FOREIGN KEY] projects_created_by_fkey: FOREIGN KEY (created_by) REFERENCES admin_users(id) ON DELETE SET NULL
|
||||
[PRIMARY KEY] projects_pkey: PRIMARY KEY (id)
|
||||
[UNIQUE] projects_slug_key: UNIQUE (slug)
|
||||
|
||||
constraints on pubsub_trigger_details:
|
||||
[FOREIGN KEY] pubsub_trigger_details_trigger_id_fkey: FOREIGN KEY (trigger_id) REFERENCES triggers(id) ON DELETE CASCADE
|
||||
[PRIMARY KEY] pubsub_trigger_details_pkey: PRIMARY KEY (trigger_id)
|
||||
@@ -973,3 +991,4 @@ constraints on vars:
|
||||
0063: materialized unique
|
||||
0064: shared topic queue kinds
|
||||
0065: group queues
|
||||
0066: projects
|
||||
|
||||
@@ -891,12 +891,12 @@ async fn version_includes_public_base_url(pool: PgPool) {
|
||||
assert!(v["public_base_url"].is_string());
|
||||
assert_eq!(v["api"], 1);
|
||||
// `schema` is migrations::latest_version() — the highest embedded
|
||||
// migration number, currently 65 (…0064_shared_topic_queue_kinds,
|
||||
// 0065_group_queues from the v1.2 D2/D3 deferrals). This test is
|
||||
// migration number, currently 66 (…0065_group_queues, then
|
||||
// 0066_projects wiring the §7 multi-repo ownership seam). This test is
|
||||
// #[ignore]-gated so it doesn't run in the default `cargo test`; pinned to
|
||||
// current reality so an unintended schema change is still caught. Bump it
|
||||
// whenever a migration lands.
|
||||
assert_eq!(v["schema"], 65);
|
||||
assert_eq!(v["schema"], 66);
|
||||
assert_eq!(v["sdk"], "1.10");
|
||||
}
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::GroupId;
|
||||
use crate::{GroupId, ProjectId};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Group {
|
||||
@@ -26,6 +26,10 @@ pub struct Group {
|
||||
/// Per-subtree structure version, bumped on every structural mutation
|
||||
/// (reparent/rename/delete). Not an authz input.
|
||||
pub structure_version: i64,
|
||||
/// §7 multi-repo ownership: the project that declaratively manages this
|
||||
/// node, or `None` when the node is UI/API-owned (no manifest claims it).
|
||||
/// Apps inherit ownership from their nearest claimed ancestor group.
|
||||
pub owner_project: Option<ProjectId>,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
@@ -58,3 +58,4 @@ id_type!(AppUserId);
|
||||
id_type!(InvitationId);
|
||||
id_type!(QueueMessageId);
|
||||
id_type!(GroupId);
|
||||
id_type!(ProjectId);
|
||||
|
||||
@@ -29,6 +29,7 @@ pub mod kv;
|
||||
pub mod log_sink;
|
||||
pub mod modules;
|
||||
pub mod outbox_writer;
|
||||
pub mod project;
|
||||
pub mod pubsub;
|
||||
pub mod queue;
|
||||
pub mod realtime;
|
||||
@@ -77,8 +78,8 @@ pub use group_pubsub::{GroupPubsubError, GroupPubsubService, NoopGroupPubsubServ
|
||||
pub use group_queue::{GroupQueueError, GroupQueueService, NoopGroupQueueService};
|
||||
pub use http::{HttpError, HttpRequest, HttpResponse, HttpService, NoopHttpService};
|
||||
pub use ids::{
|
||||
AdminUserId, ApiKeyId, AppId, AppUserId, ExecutionId, GroupId, InvitationId, QueueMessageId,
|
||||
RequestId, ScriptId, TriggerId,
|
||||
AdminUserId, ApiKeyId, AppId, AppUserId, ExecutionId, GroupId, InvitationId, ProjectId,
|
||||
QueueMessageId, RequestId, ScriptId, TriggerId,
|
||||
};
|
||||
pub use inbox::{
|
||||
InboxDeliveryOutcome, InboxFailureKind, InboxResolver, InboxResult, NoopInboxResolver,
|
||||
@@ -90,6 +91,7 @@ pub use modules::{
|
||||
ModuleResolution, ModuleScript, ModuleSource, ModuleSourceError, NoopModuleSource,
|
||||
};
|
||||
pub use outbox_writer::{HttpDispatchPayload, NewHttpOutbox, OutboxWriter, OutboxWriterError};
|
||||
pub use project::Project;
|
||||
pub use pubsub::{
|
||||
topic_matches, validate_topic_pattern, NoopPubsubService, PubsubError, PubsubService,
|
||||
};
|
||||
|
||||
27
crates/shared/src/project.rs
Normal file
27
crates/shared/src/project.rs
Normal file
@@ -0,0 +1,27 @@
|
||||
//! Projects: the §7 multi-repo ownership registry.
|
||||
//!
|
||||
//! A project is a repo-root that declaratively MANAGES a slice of the shared
|
||||
//! group tree. Each group node is owned by exactly one project (recorded in
|
||||
//! `groups.owner_project`); apps inherit ownership from their nearest claimed
|
||||
//! ancestor group. Identity is a committed `[project] slug` — stable across
|
||||
//! clones; the server assigns the UUID.
|
||||
//!
|
||||
//! See docs/design/groups-and-project-tool.md §7.
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::{AdminUserId, ProjectId};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Project {
|
||||
pub id: ProjectId,
|
||||
/// Instance-global identity, declared in the repo's root manifest and
|
||||
/// frozen at registration.
|
||||
pub slug: String,
|
||||
pub name: String,
|
||||
/// The admin who first registered the project (`None` once their account
|
||||
/// is deleted — the FK is `ON DELETE SET NULL`).
|
||||
pub created_by: Option<AdminUserId>,
|
||||
pub created_at: DateTime<Utc>,
|
||||
}
|
||||
Reference in New Issue
Block a user