Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 0 additions & 3 deletions crates/nvisy-postgres/src/model/workspace.rs
Original file line number Diff line number Diff line change
Expand Up @@ -41,9 +41,6 @@ pub struct Workspace {
}

/// Data for creating a new workspace.
///
/// Not `Default`: a workspace cannot exist without a [`Slug`], so the
/// slug must always be supplied explicitly.
#[derive(Debug, Clone, Insertable)]
#[diesel(table_name = workspaces)]
#[diesel(check_for_backend(diesel::pg::Pg))]
Expand Down
3 changes: 0 additions & 3 deletions crates/nvisy-postgres/src/model/workspace_pipeline.rs
Original file line number Diff line number Diff line change
Expand Up @@ -45,9 +45,6 @@ pub struct WorkspacePipeline {
}

/// Data for creating a new workspace pipeline.
///
/// Not `Default`: a pipeline cannot exist without a [`Slug`], so the slug
/// must always be supplied explicitly.
#[derive(Debug, Clone, Insertable)]
#[diesel(table_name = workspace_pipelines)]
#[diesel(check_for_backend(diesel::pg::Pg))]
Expand Down
8 changes: 6 additions & 2 deletions crates/nvisy-postgres/src/model/workspace_webhook.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ use uuid::Uuid;

use crate::schema::workspace_webhooks;
use crate::types::{
HasCreatedAt, HasDeletedAt, HasOwnership, HasUpdatedAt, WebhookEvent, WebhookStatus,
HasCreatedAt, HasDeletedAt, HasOwnership, HasUpdatedAt, Slug, WebhookEvent, WebhookStatus,
};

/// Workspace webhook model representing a webhook configuration for a workspace.
Expand All @@ -27,6 +27,8 @@ pub struct WorkspaceWebhook {
pub id: Uuid,
/// Reference to the workspace this webhook belongs to.
pub workspace_id: Uuid,
/// URL-safe webhook identifier, unique within the workspace.
pub slug: Slug,
/// Human-readable name for the webhook.
pub display_name: String,
/// Description of the webhook's purpose.
Expand Down Expand Up @@ -54,12 +56,14 @@ pub struct WorkspaceWebhook {
}

/// Data structure for creating a new workspace webhook.
#[derive(Debug, Default, Clone, Insertable)]
#[derive(Debug, Clone, Insertable)]
#[diesel(table_name = workspace_webhooks)]
#[diesel(check_for_backend(diesel::pg::Pg))]
pub struct NewWorkspaceWebhook {
/// Reference to the workspace this webhook will belong to.
pub workspace_id: Uuid,
/// URL-safe webhook identifier, unique within the workspace.
pub slug: Slug,
/// Human-readable name for the webhook.
pub display_name: String,
/// Description of the webhook's purpose.
Expand Down
136 changes: 132 additions & 4 deletions crates/nvisy-postgres/src/query/pipeline_reference.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ use diesel_async::RunQueryDsl;
use uuid::Uuid;

use crate::model::{PipelineContext, PipelinePolicy};
use crate::types::Slug;
use crate::{PgConnection, PgError, PgResult, schema};

/// Repository for pipeline reference join tables.
Expand All @@ -36,17 +37,50 @@ pub trait PipelineReferenceRepository {
context_ids: &[Uuid],
) -> impl Future<Output = PgResult<()>> + Send;

/// Lists the policy ids a pipeline references.
/// Lists the ids of the policies a pipeline references.
///
/// Used by the run path to resolve each referenced policy to its record for
/// the engine; the API-facing read path uses [`Self::list_pipeline_policy_slugs`].
fn list_pipeline_policy_ids(
&mut self,
pipeline_id: Uuid,
) -> impl Future<Output = PgResult<Vec<Uuid>>> + Send;

/// Lists the context ids a pipeline references.
/// Lists the ids of the contexts a pipeline references.
fn list_pipeline_context_ids(
&mut self,
pipeline_id: Uuid,
) -> impl Future<Output = PgResult<Vec<Uuid>>> + Send;

/// Lists the slugs of the policies a pipeline references.
fn list_pipeline_policy_slugs(
&mut self,
pipeline_id: Uuid,
) -> impl Future<Output = PgResult<Vec<Slug>>> + Send;

/// Lists the slugs of the contexts a pipeline references.
fn list_pipeline_context_slugs(
&mut self,
pipeline_id: Uuid,
) -> impl Future<Output = PgResult<Vec<Slug>>> + Send;

/// Resolves policy slugs to their ids within a workspace, preserving order.
///
/// Returns `None` if any slug does not match a live policy in the workspace,
/// so the caller can reject the whole set rather than silently dropping an
/// unknown reference.
fn resolve_policy_slugs(
&mut self,
workspace_id: Uuid,
slugs: &[Slug],
) -> impl Future<Output = PgResult<Option<Vec<Uuid>>>> + Send;

/// Resolves context slugs to their ids within a workspace, preserving order.
fn resolve_context_slugs(
&mut self,
workspace_id: Uuid,
slugs: &[Slug],
) -> impl Future<Output = PgResult<Option<Vec<Uuid>>>> + Send;
}

impl PipelineReferenceRepository for PgConnection {
Expand Down Expand Up @@ -119,8 +153,6 @@ impl PipelineReferenceRepository for PgConnection {
async fn list_pipeline_policy_ids(&mut self, pipeline_id: Uuid) -> PgResult<Vec<Uuid>> {
use schema::{workspace_pipeline_policies, workspace_policies};

// Join to the parent so soft-deleted policies (deleted_at set, join row
// still present since CASCADE only fires on hard delete) are excluded.
let ids = workspace_pipeline_policies::table
.inner_join(
workspace_policies::table
Expand Down Expand Up @@ -153,6 +185,102 @@ impl PipelineReferenceRepository for PgConnection {

Ok(ids)
}

async fn list_pipeline_policy_slugs(&mut self, pipeline_id: Uuid) -> PgResult<Vec<Slug>> {
use schema::{workspace_pipeline_policies, workspace_policies};

// Join to the parent so soft-deleted policies (deleted_at set, join row
// still present since CASCADE only fires on hard delete) are excluded.
let slugs = workspace_pipeline_policies::table
.inner_join(
workspace_policies::table
.on(workspace_policies::id.eq(workspace_pipeline_policies::policy_id)),
)
.filter(workspace_pipeline_policies::pipeline_id.eq(pipeline_id))
.filter(workspace_policies::deleted_at.is_null())
.select(workspace_policies::slug)
.load(self)
.await
.map_err(PgError::from)?;

Ok(slugs)
}

async fn list_pipeline_context_slugs(&mut self, pipeline_id: Uuid) -> PgResult<Vec<Slug>> {
use schema::{workspace_contexts, workspace_pipeline_contexts};

let slugs = workspace_pipeline_contexts::table
.inner_join(
workspace_contexts::table
.on(workspace_contexts::id.eq(workspace_pipeline_contexts::context_id)),
)
.filter(workspace_pipeline_contexts::pipeline_id.eq(pipeline_id))
.filter(workspace_contexts::deleted_at.is_null())
.select(workspace_contexts::slug)
.load(self)
.await
.map_err(PgError::from)?;

Ok(slugs)
}

async fn resolve_policy_slugs(
&mut self,
workspace_id: Uuid,
slugs: &[Slug],
) -> PgResult<Option<Vec<Uuid>>> {
use schema::workspace_policies::{self, dsl};

if slugs.is_empty() {
return Ok(Some(Vec::new()));
}

let wanted: Vec<String> = slugs.iter().map(|slug| slug.as_str().to_owned()).collect();
let found: Vec<(Slug, Uuid)> = workspace_policies::table
.filter(dsl::workspace_id.eq(workspace_id))
.filter(dsl::deleted_at.is_null())
.filter(dsl::slug.eq_any(&wanted))
.select((dsl::slug, dsl::id))
.load(self)
.await
.map_err(PgError::from)?;

Ok(map_slugs_to_ids(slugs, found))
}

async fn resolve_context_slugs(
&mut self,
workspace_id: Uuid,
slugs: &[Slug],
) -> PgResult<Option<Vec<Uuid>>> {
use schema::workspace_contexts::{self, dsl};

if slugs.is_empty() {
return Ok(Some(Vec::new()));
}

let wanted: Vec<String> = slugs.iter().map(|slug| slug.as_str().to_owned()).collect();
let found: Vec<(Slug, Uuid)> = workspace_contexts::table
.filter(dsl::workspace_id.eq(workspace_id))
.filter(dsl::deleted_at.is_null())
.filter(dsl::slug.eq_any(&wanted))
.select((dsl::slug, dsl::id))
.load(self)
.await
.map_err(PgError::from)?;

Ok(map_slugs_to_ids(slugs, found))
}
}

/// Maps the requested slugs to ids in request order, returning `None` if any
/// requested slug is missing from the resolved set.
fn map_slugs_to_ids(requested: &[Slug], found: Vec<(Slug, Uuid)>) -> Option<Vec<Uuid>> {
let by_slug: std::collections::HashMap<Slug, Uuid> = found.into_iter().collect();
requested
.iter()
.map(|slug| by_slug.get(slug).copied())
.collect()
}

/// Deduplicates ids, preserving first-seen order.
Expand Down
51 changes: 0 additions & 51 deletions crates/nvisy-postgres/src/query/workspace_connection.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,6 @@ use crate::model::{NewWorkspaceConnection, UpdateWorkspaceConnection, WorkspaceC
use crate::types::{CursorPage, CursorPagination, OffsetPagination};
use crate::{PgConnection, PgError, PgResult, schema};

/// Maximum number of slug candidates tried before giving up when generating a
/// unique connection slug (the preferred slug plus numeric suffixes).
const MAX_SLUG_ATTEMPTS: u32 = 100;

/// Repository for workspace connection database operations.
///
/// Handles connection lifecycle management including creation, updates,
Expand All @@ -25,17 +21,6 @@ pub trait WorkspaceConnectionRepository {
new_connection: NewWorkspaceConnection,
) -> impl Future<Output = PgResult<WorkspaceConnection>> + Send;

/// Creates a connection, resolving slug collisions with a numeric suffix.
///
/// The `new_connection.slug` is treated as the preferred slug. If it is
/// already taken within the workspace, the insert is retried with `-2`,
/// `-3`, … suffixes until one succeeds. The retry is driven by the
/// database's unique constraint, so it is race-safe.
fn create_connection_with_unique_slug(
&mut self,
new_connection: NewWorkspaceConnection,
) -> impl Future<Output = PgResult<WorkspaceConnection>> + Send;

/// Finds a connection by its unique identifier.
fn find_workspace_connection_by_id(
&mut self,
Expand Down Expand Up @@ -126,42 +111,6 @@ impl WorkspaceConnectionRepository for PgConnection {
Ok(connection)
}

async fn create_connection_with_unique_slug(
&mut self,
new_connection: NewWorkspaceConnection,
) -> PgResult<WorkspaceConnection> {
let preferred = new_connection.slug.clone();

// Attempt the preferred slug first, then `-2`, `-3`, … on collision.
// Each attempt uses a fresh candidate cloned from the base record, so a
// failed insert never consumes the data needed for the next try. The
// loop is bounded; in practice the first suffix or two resolves any
// realistic collision.
for attempt in 0..MAX_SLUG_ATTEMPTS {
let candidate = if attempt == 0 {
new_connection.clone()
} else {
let slug = preferred.with_numeric_suffix(attempt + 1).ok_or_else(|| {
PgError::unexpected("connection slug cannot be disambiguated")
})?;
NewWorkspaceConnection {
slug,
..new_connection.clone()
}
};

match self.create_workspace_connection(candidate).await {
Ok(created) => return Ok(created),
Err(error) if error.is_slug_conflict() => continue,
Err(error) => return Err(error),
}
}

Err(PgError::unexpected(
"exhausted connection slug generation attempts",
))
}

async fn find_workspace_connection_by_id(
&mut self,
connection_id: Uuid,
Expand Down
27 changes: 27 additions & 0 deletions crates/nvisy-postgres/src/query/workspace_webhook.rs
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,13 @@ pub trait WorkspaceWebhookRepository {
webhook_id: Uuid,
) -> impl Future<Output = PgResult<Option<WorkspaceWebhook>>> + Send;

/// Finds a webhook by slug within a workspace, excluding soft-deleted rows.
fn find_webhook_in_workspace_by_slug(
&mut self,
workspace_id: Uuid,
slug: &str,
) -> impl Future<Output = PgResult<Option<WorkspaceWebhook>>> + Send;

/// Lists all webhooks for a workspace with offset pagination.
fn offset_list_workspace_webhooks(
&mut self,
Expand Down Expand Up @@ -161,6 +168,26 @@ impl WorkspaceWebhookRepository for PgConnection {
Ok(webhook)
}

async fn find_webhook_in_workspace_by_slug(
&mut self,
workspace_id: Uuid,
slug_value: &str,
) -> PgResult<Option<WorkspaceWebhook>> {
use schema::workspace_webhooks::{self, dsl};

let webhook = workspace_webhooks::table
.filter(dsl::slug.eq(slug_value))
.filter(dsl::workspace_id.eq(workspace_id))
.filter(dsl::deleted_at.is_null())
.select(WorkspaceWebhook::as_select())
.first(self)
.await
.optional()
.map_err(PgError::from)?;

Ok(webhook)
}

async fn offset_list_workspace_webhooks(
&mut self,
workspace_id: Uuid,
Expand Down
1 change: 1 addition & 0 deletions crates/nvisy-postgres/src/schema.rs
Original file line number Diff line number Diff line change
Expand Up @@ -366,6 +366,7 @@ diesel::table! {
workspace_webhooks (id) {
id -> Uuid,
workspace_id -> Uuid,
slug -> Text,
display_name -> Text,
description -> Text,
url -> Text,
Expand Down
13 changes: 11 additions & 2 deletions crates/nvisy-postgres/src/types/constraint/workspace_webhooks.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,14 @@ pub enum WorkspaceWebhookConstraints {
// Webhook unique constraints
#[strum(serialize = "workspace_webhooks_workspace_id_id_key")]
WorkspaceIdIdUnique,
#[strum(serialize = "workspace_webhooks_workspace_id_slug_key")]
SlugUnique,

// Webhook validation constraints
#[strum(serialize = "workspace_webhooks_slug_length")]
SlugLength,
#[strum(serialize = "workspace_webhooks_slug_format")]
SlugFormat,
#[strum(serialize = "workspace_webhooks_display_name_length")]
DisplayNameLength,
#[strum(serialize = "workspace_webhooks_description_length")]
Expand Down Expand Up @@ -44,9 +50,12 @@ impl WorkspaceWebhookConstraints {
/// Returns the category of this constraint violation.
pub fn categorize(&self) -> ConstraintCategory {
match self {
WorkspaceWebhookConstraints::WorkspaceIdIdUnique => ConstraintCategory::Uniqueness,
WorkspaceWebhookConstraints::WorkspaceIdIdUnique
| WorkspaceWebhookConstraints::SlugUnique => ConstraintCategory::Uniqueness,

WorkspaceWebhookConstraints::DisplayNameLength
WorkspaceWebhookConstraints::SlugLength
| WorkspaceWebhookConstraints::SlugFormat
| WorkspaceWebhookConstraints::DisplayNameLength
| WorkspaceWebhookConstraints::DescriptionLength
| WorkspaceWebhookConstraints::UrlLength
| WorkspaceWebhookConstraints::UrlFormat
Expand Down
8 changes: 8 additions & 0 deletions crates/nvisy-server/src/handler/error/pg_workspace.rs
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,14 @@ impl From<WorkspaceActivitiesConstraints> for Error<'static> {
impl From<WorkspaceWebhookConstraints> for Error<'static> {
fn from(c: WorkspaceWebhookConstraints) -> Self {
let error = match c {
WorkspaceWebhookConstraints::SlugLength => ErrorKind::BadRequest
.with_message("Webhook slug must be between 3 and 32 characters long"),
WorkspaceWebhookConstraints::SlugFormat => ErrorKind::BadRequest.with_message(
"Webhook slug must be lowercase alphanumeric with single internal dashes",
),
WorkspaceWebhookConstraints::SlugUnique => {
ErrorKind::Conflict.with_message("A webhook with this slug already exists")
}
WorkspaceWebhookConstraints::DisplayNameLength => ErrorKind::BadRequest
.with_message("Webhook name must be between 3 and 64 characters long"),
WorkspaceWebhookConstraints::DescriptionLength => {
Expand Down
Loading