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 =
|
const GROUP_COLS: &str = "id, parent_id, slug, name, description, structure_version, created_at, \
|
||||||
"id, parent_id, slug, name, description, structure_version, created_at, updated_at";
|
updated_at, owner_project";
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
impl GroupRepository for PostgresGroupRepository {
|
impl GroupRepository for PostgresGroupRepository {
|
||||||
@@ -157,7 +157,8 @@ impl GroupRepository for PostgresGroupRepository {
|
|||||||
SELECT {GROUP_COLS}, 0 AS depth FROM groups WHERE id = $1
|
SELECT {GROUP_COLS}, 0 AS depth FROM groups WHERE id = $1
|
||||||
UNION ALL
|
UNION ALL
|
||||||
SELECT g.id, g.parent_id, g.slug, g.name, g.description, \
|
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 \
|
FROM groups g JOIN chain c ON g.id = c.parent_id \
|
||||||
WHERE c.depth < 64
|
WHERE c.depth < 64
|
||||||
)
|
)
|
||||||
@@ -349,6 +350,7 @@ struct GroupRow {
|
|||||||
structure_version: i64,
|
structure_version: i64,
|
||||||
created_at: chrono::DateTime<chrono::Utc>,
|
created_at: chrono::DateTime<chrono::Utc>,
|
||||||
updated_at: chrono::DateTime<chrono::Utc>,
|
updated_at: chrono::DateTime<chrono::Utc>,
|
||||||
|
owner_project: Option<Uuid>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl From<GroupRow> for Group {
|
impl From<GroupRow> for Group {
|
||||||
@@ -360,6 +362,7 @@ impl From<GroupRow> for Group {
|
|||||||
name: r.name,
|
name: r.name,
|
||||||
description: r.description,
|
description: r.description,
|
||||||
structure_version: r.structure_version,
|
structure_version: r.structure_version,
|
||||||
|
owner_project: r.owner_project.map(Into::into),
|
||||||
created_at: r.created_at,
|
created_at: r.created_at,
|
||||||
updated_at: r.updated_at,
|
updated_at: r.updated_at,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -318,6 +318,13 @@ table: outbox
|
|||||||
claimed_by: text NULL
|
claimed_by: text NULL
|
||||||
created_at: timestamp with time zone NOT NULL default=now()
|
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
|
table: pubsub_trigger_details
|
||||||
trigger_id: uuid NOT NULL
|
trigger_id: uuid NOT NULL
|
||||||
topic_pattern: text 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)
|
idx_group_queue_messages_group_collection: public.group_queue_messages USING btree (group_id, collection)
|
||||||
|
|
||||||
indexes on groups:
|
indexes on groups:
|
||||||
|
groups_owner_project_idx: public.groups USING btree (owner_project)
|
||||||
groups_parent_id_idx: public.groups USING btree (parent_id)
|
groups_parent_id_idx: public.groups USING btree (parent_id)
|
||||||
groups_pkey: public.groups USING btree (id)
|
groups_pkey: public.groups USING btree (id)
|
||||||
groups_slug_key: public.groups USING btree (slug)
|
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)
|
idx_outbox_due: public.outbox USING btree (next_attempt_at) WHERE (claimed_at IS NULL)
|
||||||
outbox_pkey: public.outbox USING btree (id)
|
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:
|
indexes on pubsub_trigger_details:
|
||||||
pubsub_trigger_details_pkey: public.pubsub_trigger_details USING btree (trigger_id)
|
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)
|
[PRIMARY KEY] group_queue_messages_pkey: PRIMARY KEY (id)
|
||||||
|
|
||||||
constraints on groups:
|
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
|
[FOREIGN KEY] groups_parent_id_fkey: FOREIGN KEY (parent_id) REFERENCES groups(id) ON DELETE RESTRICT
|
||||||
[PRIMARY KEY] groups_pkey: PRIMARY KEY (id)
|
[PRIMARY KEY] groups_pkey: PRIMARY KEY (id)
|
||||||
[UNIQUE] groups_slug_key: UNIQUE (slug)
|
[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
|
[FOREIGN KEY] outbox_app_id_fkey: FOREIGN KEY (app_id) REFERENCES apps(id) ON DELETE CASCADE
|
||||||
[PRIMARY KEY] outbox_pkey: PRIMARY KEY (id)
|
[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:
|
constraints on pubsub_trigger_details:
|
||||||
[FOREIGN KEY] pubsub_trigger_details_trigger_id_fkey: FOREIGN KEY (trigger_id) REFERENCES triggers(id) ON DELETE CASCADE
|
[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)
|
[PRIMARY KEY] pubsub_trigger_details_pkey: PRIMARY KEY (trigger_id)
|
||||||
@@ -973,3 +991,4 @@ constraints on vars:
|
|||||||
0063: materialized unique
|
0063: materialized unique
|
||||||
0064: shared topic queue kinds
|
0064: shared topic queue kinds
|
||||||
0065: group queues
|
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!(v["public_base_url"].is_string());
|
||||||
assert_eq!(v["api"], 1);
|
assert_eq!(v["api"], 1);
|
||||||
// `schema` is migrations::latest_version() — the highest embedded
|
// `schema` is migrations::latest_version() — the highest embedded
|
||||||
// migration number, currently 65 (…0064_shared_topic_queue_kinds,
|
// migration number, currently 66 (…0065_group_queues, then
|
||||||
// 0065_group_queues from the v1.2 D2/D3 deferrals). This test is
|
// 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
|
// #[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
|
// current reality so an unintended schema change is still caught. Bump it
|
||||||
// whenever a migration lands.
|
// whenever a migration lands.
|
||||||
assert_eq!(v["schema"], 65);
|
assert_eq!(v["schema"], 66);
|
||||||
assert_eq!(v["sdk"], "1.10");
|
assert_eq!(v["sdk"], "1.10");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -11,7 +11,7 @@
|
|||||||
use chrono::{DateTime, Utc};
|
use chrono::{DateTime, Utc};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
use crate::GroupId;
|
use crate::{GroupId, ProjectId};
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct Group {
|
pub struct Group {
|
||||||
@@ -26,6 +26,10 @@ pub struct Group {
|
|||||||
/// Per-subtree structure version, bumped on every structural mutation
|
/// Per-subtree structure version, bumped on every structural mutation
|
||||||
/// (reparent/rename/delete). Not an authz input.
|
/// (reparent/rename/delete). Not an authz input.
|
||||||
pub structure_version: i64,
|
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 created_at: DateTime<Utc>,
|
||||||
pub updated_at: DateTime<Utc>,
|
pub updated_at: DateTime<Utc>,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -58,3 +58,4 @@ id_type!(AppUserId);
|
|||||||
id_type!(InvitationId);
|
id_type!(InvitationId);
|
||||||
id_type!(QueueMessageId);
|
id_type!(QueueMessageId);
|
||||||
id_type!(GroupId);
|
id_type!(GroupId);
|
||||||
|
id_type!(ProjectId);
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ pub mod kv;
|
|||||||
pub mod log_sink;
|
pub mod log_sink;
|
||||||
pub mod modules;
|
pub mod modules;
|
||||||
pub mod outbox_writer;
|
pub mod outbox_writer;
|
||||||
|
pub mod project;
|
||||||
pub mod pubsub;
|
pub mod pubsub;
|
||||||
pub mod queue;
|
pub mod queue;
|
||||||
pub mod realtime;
|
pub mod realtime;
|
||||||
@@ -77,8 +78,8 @@ pub use group_pubsub::{GroupPubsubError, GroupPubsubService, NoopGroupPubsubServ
|
|||||||
pub use group_queue::{GroupQueueError, GroupQueueService, NoopGroupQueueService};
|
pub use group_queue::{GroupQueueError, GroupQueueService, NoopGroupQueueService};
|
||||||
pub use http::{HttpError, HttpRequest, HttpResponse, HttpService, NoopHttpService};
|
pub use http::{HttpError, HttpRequest, HttpResponse, HttpService, NoopHttpService};
|
||||||
pub use ids::{
|
pub use ids::{
|
||||||
AdminUserId, ApiKeyId, AppId, AppUserId, ExecutionId, GroupId, InvitationId, QueueMessageId,
|
AdminUserId, ApiKeyId, AppId, AppUserId, ExecutionId, GroupId, InvitationId, ProjectId,
|
||||||
RequestId, ScriptId, TriggerId,
|
QueueMessageId, RequestId, ScriptId, TriggerId,
|
||||||
};
|
};
|
||||||
pub use inbox::{
|
pub use inbox::{
|
||||||
InboxDeliveryOutcome, InboxFailureKind, InboxResolver, InboxResult, NoopInboxResolver,
|
InboxDeliveryOutcome, InboxFailureKind, InboxResolver, InboxResult, NoopInboxResolver,
|
||||||
@@ -90,6 +91,7 @@ pub use modules::{
|
|||||||
ModuleResolution, ModuleScript, ModuleSource, ModuleSourceError, NoopModuleSource,
|
ModuleResolution, ModuleScript, ModuleSource, ModuleSourceError, NoopModuleSource,
|
||||||
};
|
};
|
||||||
pub use outbox_writer::{HttpDispatchPayload, NewHttpOutbox, OutboxWriter, OutboxWriterError};
|
pub use outbox_writer::{HttpDispatchPayload, NewHttpOutbox, OutboxWriter, OutboxWriterError};
|
||||||
|
pub use project::Project;
|
||||||
pub use pubsub::{
|
pub use pubsub::{
|
||||||
topic_matches, validate_topic_pattern, NoopPubsubService, PubsubError, PubsubService,
|
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