feat(manager-core,orchestrator-core): multi-app scoping (Phase 3b)
Apps become the isolation boundary for scripts, routes, domains, and
later data. Doing this now — while the surface is small — avoids
several migrations on populated tables once v1.1 data-plane services
ship.
Schema (migration 0005_apps.sql):
- New tables: apps, app_domains (with shape_key UNIQUE for collision
detection), app_slug_history (for permanent slug-rename redirects).
- app_id added to scripts, routes, execution_logs (non-null, cascading
rules per row).
- Script-name uniqueness becomes per-app; the route unique index is
swapped for an app-scoped version.
- The "default" app is seeded unconditionally with a localhost claim;
existing scripts/routes backfill into it. Fresh installs additionally
get the Hello World seed via seed_hello_world_if_fresh after
migrations run (idempotent — only fires when the default app has no
scripts).
Orchestrator dispatch is two-phase: AppDomainTable resolves Host →
app_id (most-specific match wins, exact beats wildcard), then the
existing route matcher runs against that app's partitioned slice via
RouteTable. Unknown hosts return 404 at the app layer with a clear
message; /api/v1/execute/{id} still works as the implicit
__internal__ claim, decoupled from any public domain.
Manager API: full CRUD for /api/v1/admin/apps/* and
/api/v1/admin/apps/{id_or_slug}/domains/*, with slug:check + force
takeover semantics implementing the rename-history flow (two-step
check → confirm, never a single endpoint). Script create requires
app_id; list accepts ?app= filter. Route create validates host
against the parent app's claims; conflict detection stays strictly
intra-app.
Dashboard: /admin/apps and /admin/apps/{slug} (overview + scripts +
domains + settings tabs, with slug-history-aware redirects). Root
path redirects to the apps list. Script detail page gains an app
breadcrumb and threads app_id into the route preview.
Deferred per design: per-app admin roles. The require_admin middleware
remains the seam where role checks will slot in later.
Blueprint §11.5 and roadmap updated to reflect what shipped; docs/
versioning.md notes the schema 3 → 5 bump.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
380
crates/manager-core/src/app_repo.rs
Normal file
380
crates/manager-core/src/app_repo.rs
Normal file
@@ -0,0 +1,380 @@
|
||||
//! CRUD over the `apps` and `app_slug_history` tables.
|
||||
//!
|
||||
//! Slug validation (regex, reserved-word check) lives in the API
|
||||
//! handler; this repo enforces only what Postgres enforces (uniqueness,
|
||||
//! FK). The slug-rename flow is exposed as a single `rename_slug` call
|
||||
//! that writes the history row in the same transaction.
|
||||
|
||||
use async_trait::async_trait;
|
||||
use picloud_shared::{App, AppId};
|
||||
use sqlx::PgPool;
|
||||
|
||||
use crate::repo::ScriptRepositoryError;
|
||||
|
||||
/// Result of looking up an app by slug or via the redirect history.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct AppLookup {
|
||||
pub app: App,
|
||||
/// `true` when the slug was found in `app_slug_history` rather than
|
||||
/// directly on `apps`. Dashboards should issue a redirect.
|
||||
pub redirected: bool,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
pub trait AppRepository: Send + Sync {
|
||||
async fn list(&self) -> Result<Vec<App>, ScriptRepositoryError>;
|
||||
async fn get_by_id(&self, id: AppId) -> Result<Option<App>, ScriptRepositoryError>;
|
||||
async fn get_by_slug(&self, slug: &str) -> Result<Option<App>, ScriptRepositoryError>;
|
||||
async fn get_by_slug_or_history(
|
||||
&self,
|
||||
slug: &str,
|
||||
) -> Result<Option<AppLookup>, ScriptRepositoryError>;
|
||||
async fn slug_in_history(&self, slug: &str) -> Result<Option<App>, ScriptRepositoryError>;
|
||||
async fn create(
|
||||
&self,
|
||||
slug: &str,
|
||||
name: &str,
|
||||
description: Option<&str>,
|
||||
) -> Result<App, ScriptRepositoryError>;
|
||||
/// Create that also consumes a matching `app_slug_history` row, if
|
||||
/// any. Used after the operator has confirmed they want to break old
|
||||
/// redirects.
|
||||
async fn create_with_takeover(
|
||||
&self,
|
||||
slug: &str,
|
||||
name: &str,
|
||||
description: Option<&str>,
|
||||
) -> Result<App, ScriptRepositoryError>;
|
||||
async fn update(
|
||||
&self,
|
||||
id: AppId,
|
||||
name: Option<&str>,
|
||||
description: Option<Option<&str>>,
|
||||
) -> Result<App, ScriptRepositoryError>;
|
||||
/// Rename and record the old slug in `app_slug_history` (so
|
||||
/// retired URLs keep redirecting). If `take_over_history` is true,
|
||||
/// any existing history row for `new_slug` is consumed.
|
||||
async fn rename_slug(
|
||||
&self,
|
||||
id: AppId,
|
||||
new_slug: &str,
|
||||
take_over_history: bool,
|
||||
) -> Result<App, ScriptRepositoryError>;
|
||||
async fn delete(&self, id: AppId) -> Result<(), ScriptRepositoryError>;
|
||||
async fn count_scripts_in_app(&self, id: AppId) -> Result<i64, ScriptRepositoryError>;
|
||||
}
|
||||
|
||||
pub struct PostgresAppRepository {
|
||||
pool: PgPool,
|
||||
}
|
||||
|
||||
impl PostgresAppRepository {
|
||||
#[must_use]
|
||||
pub fn new(pool: PgPool) -> Self {
|
||||
Self { pool }
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl AppRepository for PostgresAppRepository {
|
||||
async fn list(&self) -> Result<Vec<App>, ScriptRepositoryError> {
|
||||
let rows = sqlx::query_as::<_, AppRow>(
|
||||
"SELECT id, slug, name, description, created_at, updated_at \
|
||||
FROM apps ORDER BY name",
|
||||
)
|
||||
.fetch_all(&self.pool)
|
||||
.await?;
|
||||
Ok(rows.into_iter().map(Into::into).collect())
|
||||
}
|
||||
|
||||
async fn get_by_id(&self, id: AppId) -> Result<Option<App>, ScriptRepositoryError> {
|
||||
let row = sqlx::query_as::<_, AppRow>(
|
||||
"SELECT id, slug, name, description, created_at, updated_at \
|
||||
FROM apps 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<App>, ScriptRepositoryError> {
|
||||
let row = sqlx::query_as::<_, AppRow>(
|
||||
"SELECT id, slug, name, description, created_at, updated_at \
|
||||
FROM apps WHERE slug = $1",
|
||||
)
|
||||
.bind(slug)
|
||||
.fetch_optional(&self.pool)
|
||||
.await?;
|
||||
Ok(row.map(Into::into))
|
||||
}
|
||||
|
||||
async fn get_by_slug_or_history(
|
||||
&self,
|
||||
slug: &str,
|
||||
) -> Result<Option<AppLookup>, ScriptRepositoryError> {
|
||||
if let Some(app) = self.get_by_slug(slug).await? {
|
||||
return Ok(Some(AppLookup {
|
||||
app,
|
||||
redirected: false,
|
||||
}));
|
||||
}
|
||||
if let Some(app) = self.slug_in_history(slug).await? {
|
||||
return Ok(Some(AppLookup {
|
||||
app,
|
||||
redirected: true,
|
||||
}));
|
||||
}
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
async fn slug_in_history(&self, slug: &str) -> Result<Option<App>, ScriptRepositoryError> {
|
||||
let row = sqlx::query_as::<_, AppRow>(
|
||||
"SELECT a.id, a.slug, a.name, a.description, a.created_at, a.updated_at \
|
||||
FROM app_slug_history h \
|
||||
JOIN apps a ON a.id = h.current_app_id \
|
||||
WHERE h.slug = $1",
|
||||
)
|
||||
.bind(slug)
|
||||
.fetch_optional(&self.pool)
|
||||
.await?;
|
||||
Ok(row.map(Into::into))
|
||||
}
|
||||
|
||||
async fn create(
|
||||
&self,
|
||||
slug: &str,
|
||||
name: &str,
|
||||
description: Option<&str>,
|
||||
) -> Result<App, ScriptRepositoryError> {
|
||||
let res = sqlx::query_as::<_, AppRow>(
|
||||
"INSERT INTO apps (slug, name, description) \
|
||||
VALUES ($1, $2, $3) \
|
||||
RETURNING id, slug, name, description, created_at, updated_at",
|
||||
)
|
||||
.bind(slug)
|
||||
.bind(name)
|
||||
.bind(description)
|
||||
.fetch_one(&self.pool)
|
||||
.await;
|
||||
|
||||
match res {
|
||||
Ok(row) => Ok(row.into()),
|
||||
Err(sqlx::Error::Database(e)) if e.is_unique_violation() => Err(
|
||||
ScriptRepositoryError::Conflict(format!("slug {slug:?} is already in use")),
|
||||
),
|
||||
Err(e) => Err(e.into()),
|
||||
}
|
||||
}
|
||||
|
||||
async fn create_with_takeover(
|
||||
&self,
|
||||
slug: &str,
|
||||
name: &str,
|
||||
description: Option<&str>,
|
||||
) -> Result<App, ScriptRepositoryError> {
|
||||
let mut tx = self.pool.begin().await?;
|
||||
sqlx::query("DELETE FROM app_slug_history WHERE slug = $1")
|
||||
.bind(slug)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
let row = sqlx::query_as::<_, AppRow>(
|
||||
"INSERT INTO apps (slug, name, description) \
|
||||
VALUES ($1, $2, $3) \
|
||||
RETURNING id, slug, name, description, created_at, updated_at",
|
||||
)
|
||||
.bind(slug)
|
||||
.bind(name)
|
||||
.bind(description)
|
||||
.fetch_one(&mut *tx)
|
||||
.await;
|
||||
let row = match row {
|
||||
Ok(r) => r,
|
||||
Err(sqlx::Error::Database(e)) if e.is_unique_violation() => {
|
||||
return Err(ScriptRepositoryError::Conflict(format!(
|
||||
"slug {slug:?} is already in use"
|
||||
)));
|
||||
}
|
||||
Err(e) => return Err(e.into()),
|
||||
};
|
||||
tx.commit().await?;
|
||||
Ok(row.into())
|
||||
}
|
||||
|
||||
async fn update(
|
||||
&self,
|
||||
id: AppId,
|
||||
name: Option<&str>,
|
||||
description: Option<Option<&str>>,
|
||||
) -> Result<App, ScriptRepositoryError> {
|
||||
let row = sqlx::query_as::<_, AppRow>(
|
||||
"UPDATE apps SET \
|
||||
name = COALESCE($2, name), \
|
||||
description = CASE WHEN $3::bool THEN $4 ELSE description END, \
|
||||
updated_at = NOW() \
|
||||
WHERE id = $1 \
|
||||
RETURNING id, slug, name, description, created_at, updated_at",
|
||||
)
|
||||
.bind(id.into_inner())
|
||||
.bind(name)
|
||||
.bind(description.is_some())
|
||||
.bind(description.and_then(|d| d))
|
||||
.fetch_optional(&self.pool)
|
||||
.await?;
|
||||
row.map(Into::into)
|
||||
.ok_or_else(|| ScriptRepositoryError::Conflict(format!("app {id} not found")))
|
||||
}
|
||||
|
||||
async fn rename_slug(
|
||||
&self,
|
||||
id: AppId,
|
||||
new_slug: &str,
|
||||
take_over_history: bool,
|
||||
) -> Result<App, ScriptRepositoryError> {
|
||||
let mut tx = self.pool.begin().await?;
|
||||
|
||||
// 1. Read the current slug (so we can record it in history).
|
||||
let current: Option<(String,)> = sqlx::query_as("SELECT slug FROM apps WHERE id = $1")
|
||||
.bind(id.into_inner())
|
||||
.fetch_optional(&mut *tx)
|
||||
.await?;
|
||||
let Some((current_slug,)) = current else {
|
||||
return Err(ScriptRepositoryError::Conflict(format!(
|
||||
"app {id} not found"
|
||||
)));
|
||||
};
|
||||
|
||||
if current_slug == new_slug {
|
||||
// No-op rename; just return the row.
|
||||
let row = sqlx::query_as::<_, AppRow>(
|
||||
"SELECT id, slug, name, description, created_at, updated_at \
|
||||
FROM apps WHERE id = $1",
|
||||
)
|
||||
.bind(id.into_inner())
|
||||
.fetch_one(&mut *tx)
|
||||
.await?;
|
||||
tx.commit().await?;
|
||||
return Ok(row.into());
|
||||
}
|
||||
|
||||
// 2. If renaming back to this app's own retired slug, just
|
||||
// consume the history row silently (no warning, no takeover
|
||||
// flag required).
|
||||
let owns_history: Option<(uuid::Uuid,)> =
|
||||
sqlx::query_as("SELECT current_app_id FROM app_slug_history WHERE slug = $1")
|
||||
.bind(new_slug)
|
||||
.fetch_optional(&mut *tx)
|
||||
.await?;
|
||||
|
||||
match owns_history {
|
||||
Some((owner,)) if owner == id.into_inner() => {
|
||||
sqlx::query("DELETE FROM app_slug_history WHERE slug = $1")
|
||||
.bind(new_slug)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
}
|
||||
Some(_) if take_over_history => {
|
||||
sqlx::query("DELETE FROM app_slug_history WHERE slug = $1")
|
||||
.bind(new_slug)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
}
|
||||
Some(_) => {
|
||||
return Err(ScriptRepositoryError::Conflict(format!(
|
||||
"slug {new_slug:?} is in history; rename with takeover to claim it"
|
||||
)));
|
||||
}
|
||||
None => {}
|
||||
}
|
||||
|
||||
// 3. Record the current slug in history (replacing any older
|
||||
// entry — the same slug can pass through history multiple
|
||||
// times across many renames).
|
||||
sqlx::query(
|
||||
"INSERT INTO app_slug_history (slug, current_app_id) \
|
||||
VALUES ($1, $2) \
|
||||
ON CONFLICT (slug) DO UPDATE SET current_app_id = EXCLUDED.current_app_id, \
|
||||
retired_at = NOW()",
|
||||
)
|
||||
.bind(¤t_slug)
|
||||
.bind(id.into_inner())
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
// 4. Apply the rename. Unique violation = another live app
|
||||
// already holds this slug.
|
||||
let row = sqlx::query_as::<_, AppRow>(
|
||||
"UPDATE apps SET slug = $2, updated_at = NOW() \
|
||||
WHERE id = $1 \
|
||||
RETURNING id, slug, name, description, created_at, updated_at",
|
||||
)
|
||||
.bind(id.into_inner())
|
||||
.bind(new_slug)
|
||||
.fetch_one(&mut *tx)
|
||||
.await;
|
||||
let row = match row {
|
||||
Ok(r) => r,
|
||||
Err(sqlx::Error::Database(e)) if e.is_unique_violation() => {
|
||||
return Err(ScriptRepositoryError::Conflict(format!(
|
||||
"slug {new_slug:?} is already in use by another app"
|
||||
)));
|
||||
}
|
||||
Err(e) => return Err(e.into()),
|
||||
};
|
||||
|
||||
tx.commit().await?;
|
||||
Ok(row.into())
|
||||
}
|
||||
|
||||
async fn delete(&self, id: AppId) -> Result<(), ScriptRepositoryError> {
|
||||
let res = sqlx::query("DELETE FROM apps WHERE id = $1")
|
||||
.bind(id.into_inner())
|
||||
.execute(&self.pool)
|
||||
.await;
|
||||
match res {
|
||||
Ok(r) if r.rows_affected() == 0 => Err(ScriptRepositoryError::Conflict(format!(
|
||||
"app {id} not found"
|
||||
))),
|
||||
Ok(_) => Ok(()),
|
||||
Err(sqlx::Error::Database(e)) if e.is_foreign_key_violation() => {
|
||||
// ON DELETE RESTRICT on scripts.app_id — surface a clean
|
||||
// "has dependents" error rather than a raw SQL message.
|
||||
Err(ScriptRepositoryError::Conflict(
|
||||
"app still contains scripts; delete or move them first".into(),
|
||||
))
|
||||
}
|
||||
Err(e) => Err(e.into()),
|
||||
}
|
||||
}
|
||||
|
||||
async fn count_scripts_in_app(&self, id: AppId) -> Result<i64, ScriptRepositoryError> {
|
||||
let count: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM scripts WHERE app_id = $1")
|
||||
.bind(id.into_inner())
|
||||
.fetch_one(&self.pool)
|
||||
.await?;
|
||||
Ok(count.0)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(sqlx::FromRow)]
|
||||
struct AppRow {
|
||||
id: uuid::Uuid,
|
||||
slug: String,
|
||||
name: String,
|
||||
description: Option<String>,
|
||||
created_at: chrono::DateTime<chrono::Utc>,
|
||||
updated_at: chrono::DateTime<chrono::Utc>,
|
||||
}
|
||||
|
||||
impl From<AppRow> for App {
|
||||
fn from(r: AppRow) -> Self {
|
||||
Self {
|
||||
id: r.id.into(),
|
||||
slug: r.slug,
|
||||
name: r.name,
|
||||
description: r.description,
|
||||
created_at: r.created_at,
|
||||
updated_at: r.updated_at,
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user