diff --git a/docs/generated/postgres-schema/README.md b/docs/generated/postgres-schema/README.md index f4c6f17a4d9..bcd13ca3da8 100644 --- a/docs/generated/postgres-schema/README.md +++ b/docs/generated/postgres-schema/README.md @@ -104,6 +104,10 @@ Auto-generated from the PostgreSQL migrations in @n8n/db. Do not edit by hand. | [public.project_pool_settings](public.project_pool_settings.md) | 4 | | BASE TABLE | | [public.project_relation](public.project_relation.md) | 5 | | BASE TABLE | | [public.project_secrets_provider_access](public.project_secrets_provider_access.md) | 5 | | BASE TABLE | +| [public.promotion_config](public.promotion_config.md) | 7 | | BASE TABLE | +| [public.promotion_connection](public.promotion_connection.md) | 7 | | BASE TABLE | +| [public.promotion_connection_project](public.promotion_connection_project.md) | 4 | | BASE TABLE | +| [public.promotion_provider](public.promotion_provider.md) | 8 | | BASE TABLE | | [public.role](public.role.md) | 7 | | BASE TABLE | | [public.role_mapping_rule](public.role_mapping_rule.md) | 7 | | BASE TABLE | | [public.role_mapping_rule_project](public.role_mapping_rule_project.md) | 2 | | BASE TABLE | @@ -307,6 +311,10 @@ erDiagram "public.project_relation" }o--|| "public.role" : "FOREIGN KEY (role) REFERENCES role(slug)" "public.project_secrets_provider_access" }o--|| "public.project" : "FOREIGN KEY (#quot;projectId#quot;) REFERENCES project(id) ON DELETE CASCADE" "public.project_secrets_provider_access" }o--|| "public.secrets_provider_connection" : "FOREIGN KEY (#quot;secretsProviderConnectionId#quot;) REFERENCES secrets_provider_connection(id) ON DELETE CASCADE" +"public.promotion_config" }o--|| "public.promotion_connection" : "FOREIGN KEY (#quot;connectionId#quot;) REFERENCES promotion_connection(id) ON DELETE CASCADE" +"public.promotion_connection" }o--|| "public.promotion_provider" : "FOREIGN KEY (#quot;providerId#quot;) REFERENCES promotion_provider(id) ON DELETE RESTRICT" +"public.promotion_connection_project" |o--|| "public.project" : "FOREIGN KEY (#quot;projectId#quot;) REFERENCES project(id) ON DELETE CASCADE" +"public.promotion_connection_project" }o--|| "public.promotion_connection" : "FOREIGN KEY (#quot;connectionId#quot;) REFERENCES promotion_connection(id) ON DELETE CASCADE" "public.role_mapping_rule" }o--|| "public.role" : "FOREIGN KEY (role) REFERENCES role(slug) ON UPDATE CASCADE ON DELETE CASCADE" "public.role_mapping_rule_project" }o--|| "public.project" : "FOREIGN KEY (#quot;projectId#quot;) REFERENCES project(id) ON DELETE CASCADE" "public.role_mapping_rule_project" }o--|| "public.role_mapping_rule" : "FOREIGN KEY (#quot;roleMappingRuleId#quot;) REFERENCES role_mapping_rule(id) ON DELETE CASCADE" @@ -1324,6 +1332,40 @@ erDiagram integer secretsProviderConnectionId FK timestamp_3__with_time_zone updatedAt } +"public.promotion_config" { + varchar_36_ connectionId FK + timestamp_3__with_time_zone createdAt + varchar_16_ direction + varchar_36_ id + varchar_128_ name + json settings + timestamp_3__with_time_zone updatedAt +} +"public.promotion_connection" { + timestamp_3__with_time_zone createdAt + varchar_36_ id + varchar_128_ name + varchar_36_ providerId FK + varchar_16_ scope + json target + timestamp_3__with_time_zone updatedAt +} +"public.promotion_connection_project" { + varchar_36_ connectionId FK + timestamp_3__with_time_zone createdAt + varchar_36_ projectId FK + timestamp_3__with_time_zone updatedAt +} +"public.promotion_provider" { + text auth + varchar_32_ authType + json config + timestamp_3__with_time_zone createdAt + varchar_36_ id + varchar_128_ name + varchar_32_ type + timestamp_3__with_time_zone updatedAt +} "public.role" { timestamp_3__with_time_zone createdAt text description diff --git a/docs/generated/postgres-schema/public.project.md b/docs/generated/postgres-schema/public.project.md index 04551b05278..40926a8d86d 100644 --- a/docs/generated/postgres-schema/public.project.md +++ b/docs/generated/postgres-schema/public.project.md @@ -9,7 +9,7 @@ | customTelemetryTags | json | '[]'::json | false | | | | | description | varchar(512) | | true | | | | | icon | json | | true | | | | -| id | varchar(36) | | false | [public.activity_event](public.activity_event.md) [public.agent_chat_attachments](public.agent_chat_attachments.md) [public.agent_execution_threads](public.agent_execution_threads.md) [public.agents](public.agents.md) [public.data_table](public.data_table.md) [public.folder](public.folder.md) [public.git_connection_project](public.git_connection_project.md) [public.insights_metadata](public.insights_metadata.md) [public.instance_ai_threads](public.instance_ai_threads.md) [public.project_pool_settings](public.project_pool_settings.md) [public.project_relation](public.project_relation.md) [public.project_secrets_provider_access](public.project_secrets_provider_access.md) [public.role_mapping_rule_project](public.role_mapping_rule_project.md) [public.shared_credentials](public.shared_credentials.md) [public.shared_workflow](public.shared_workflow.md) [public.type_availability_policy_scope](public.type_availability_policy_scope.md) [public.variables](public.variables.md) [public.workflow_review_request](public.workflow_review_request.md) | | | +| id | varchar(36) | | false | [public.activity_event](public.activity_event.md) [public.agent_chat_attachments](public.agent_chat_attachments.md) [public.agent_execution_threads](public.agent_execution_threads.md) [public.agents](public.agents.md) [public.data_table](public.data_table.md) [public.folder](public.folder.md) [public.git_connection_project](public.git_connection_project.md) [public.insights_metadata](public.insights_metadata.md) [public.instance_ai_threads](public.instance_ai_threads.md) [public.project_pool_settings](public.project_pool_settings.md) [public.project_relation](public.project_relation.md) [public.project_secrets_provider_access](public.project_secrets_provider_access.md) [public.promotion_connection_project](public.promotion_connection_project.md) [public.role_mapping_rule_project](public.role_mapping_rule_project.md) [public.shared_credentials](public.shared_credentials.md) [public.shared_workflow](public.shared_workflow.md) [public.type_availability_policy_scope](public.type_availability_policy_scope.md) [public.variables](public.variables.md) [public.workflow_review_request](public.workflow_review_request.md) | | | | name | varchar(255) | | false | | | | | type | varchar(36) | | false | | | | | updatedAt | timestamp(3) with time zone | CURRENT_TIMESTAMP(3) | false | | | | @@ -51,6 +51,7 @@ erDiagram "public.project_pool_settings" |o--|| "public.project" : "FOREIGN KEY (#quot;projectId#quot;) REFERENCES project(id) ON DELETE CASCADE" "public.project_relation" }o--|| "public.project" : "FOREIGN KEY (#quot;projectId#quot;) REFERENCES project(id) ON DELETE CASCADE" "public.project_secrets_provider_access" }o--|| "public.project" : "FOREIGN KEY (#quot;projectId#quot;) REFERENCES project(id) ON DELETE CASCADE" +"public.promotion_connection_project" |o--|| "public.project" : "FOREIGN KEY (#quot;projectId#quot;) REFERENCES project(id) ON DELETE CASCADE" "public.role_mapping_rule_project" }o--|| "public.project" : "FOREIGN KEY (#quot;projectId#quot;) REFERENCES project(id) ON DELETE CASCADE" "public.shared_credentials" }o--|| "public.project" : "FOREIGN KEY (#quot;projectId#quot;) REFERENCES project(id) ON DELETE CASCADE" "public.shared_workflow" }o--|| "public.project" : "FOREIGN KEY (#quot;projectId#quot;) REFERENCES project(id) ON DELETE CASCADE" @@ -205,6 +206,12 @@ erDiagram integer secretsProviderConnectionId FK timestamp_3__with_time_zone updatedAt } +"public.promotion_connection_project" { + varchar_36_ connectionId FK + timestamp_3__with_time_zone createdAt + varchar_36_ projectId FK + timestamp_3__with_time_zone updatedAt +} "public.role_mapping_rule_project" { varchar_36_ projectId FK varchar_16_ roleMappingRuleId FK diff --git a/docs/generated/postgres-schema/public.promotion_config.md b/docs/generated/postgres-schema/public.promotion_config.md new file mode 100644 index 00000000000..ef0bf07cc50 --- /dev/null +++ b/docs/generated/postgres-schema/public.promotion_config.md @@ -0,0 +1,66 @@ +# public.promotion_config + +## Columns + +| Name | Type | Default | Nullable | Children | Parents | Comment | +| ---- | ---- | ------- | -------- | -------- | ------- | ------- | +| connectionId | varchar(36) | | false | | [public.promotion_connection](public.promotion_connection.md) | | +| createdAt | timestamp(3) with time zone | CURRENT_TIMESTAMP(3) | false | | | | +| direction | varchar(16) | | false | | | PromotionDirection enum: "apply", "promote". Cannot change after creation. | +| id | varchar(36) | | false | | | Also the name of the local checkout directory. | +| name | varchar(128) | | false | | | | +| settings | json | | false | | | Branch settings for this direction, with their own schemaVersion. Validated in code. | +| updatedAt | timestamp(3) with time zone | CURRENT_TIMESTAMP(3) | false | | | | + +## Constraints + +| Name | Type | Definition | +| ---- | ---- | ---------- | +| CHK_promotion_config_direction | CHECK | CHECK (((direction)::text = ANY ((ARRAY['apply'::character varying, 'promote'::character varying])::text[]))) | +| FK_promotion_config_connectionId | FOREIGN KEY | FOREIGN KEY ("connectionId") REFERENCES promotion_connection(id) ON DELETE CASCADE | +| PK_b4d534111f62d31699e6ceb544f | PRIMARY KEY | PRIMARY KEY (id) | +| promotion_config_connectionId_not_null | n | NOT NULL "connectionId" | +| promotion_config_createdAt_not_null | n | NOT NULL "createdAt" | +| promotion_config_direction_not_null | n | NOT NULL direction | +| promotion_config_id_not_null | n | NOT NULL id | +| promotion_config_name_not_null | n | NOT NULL name | +| promotion_config_settings_not_null | n | NOT NULL settings | +| promotion_config_updatedAt_not_null | n | NOT NULL "updatedAt" | + +## Indexes + +| Name | Definition | +| ---- | ---------- | +| IDX_promotion_config_connectionId_direction | CREATE UNIQUE INDEX "IDX_promotion_config_connectionId_direction" ON public.promotion_config USING btree ("connectionId", direction) | +| PK_b4d534111f62d31699e6ceb544f | CREATE UNIQUE INDEX "PK_b4d534111f62d31699e6ceb544f" ON public.promotion_config USING btree (id) | + +## Relations + +```mermaid +erDiagram + +"public.promotion_config" }o--|| "public.promotion_connection" : "FOREIGN KEY (#quot;connectionId#quot;) REFERENCES promotion_connection(id) ON DELETE CASCADE" + +"public.promotion_config" { + varchar_36_ connectionId FK + timestamp_3__with_time_zone createdAt + varchar_16_ direction + varchar_36_ id + varchar_128_ name + json settings + timestamp_3__with_time_zone updatedAt +} +"public.promotion_connection" { + timestamp_3__with_time_zone createdAt + varchar_36_ id + varchar_128_ name + varchar_36_ providerId FK + varchar_16_ scope + json target + timestamp_3__with_time_zone updatedAt +} +``` + +--- + +> Generated by [tbls](https://github.com/k1LoW/tbls) diff --git a/docs/generated/postgres-schema/public.promotion_connection.md b/docs/generated/postgres-schema/public.promotion_connection.md new file mode 100644 index 00000000000..07f341ecc18 --- /dev/null +++ b/docs/generated/postgres-schema/public.promotion_connection.md @@ -0,0 +1,85 @@ +# public.promotion_connection + +## Columns + +| Name | Type | Default | Nullable | Children | Parents | Comment | +| ---- | ---- | ------- | -------- | -------- | ------- | ------- | +| createdAt | timestamp(3) with time zone | CURRENT_TIMESTAMP(3) | false | | | | +| id | varchar(36) | | false | [public.promotion_config](public.promotion_config.md) [public.promotion_connection_project](public.promotion_connection_project.md) | | | +| name | varchar(128) | | false | | | | +| providerId | varchar(36) | | false | | [public.promotion_provider](public.promotion_provider.md) | The provider whose credentials this connection uses. | +| scope | varchar(16) | | false | | | PromotionConnectionScope enum: "instance", "projects". Cannot change after creation. | +| target | json | | false | | | Where to push and pull, with its own schemaVersion. Plain Git stores the full remote URL. | +| updatedAt | timestamp(3) with time zone | CURRENT_TIMESTAMP(3) | false | | | | + +## Constraints + +| Name | Type | Definition | +| ---- | ---- | ---------- | +| CHK_promotion_connection_scope | CHECK | CHECK (((scope)::text = ANY ((ARRAY['instance'::character varying, 'projects'::character varying])::text[]))) | +| FK_promotion_connection_providerId | FOREIGN KEY | FOREIGN KEY ("providerId") REFERENCES promotion_provider(id) ON DELETE RESTRICT | +| PK_65cec79aba16950b45592335204 | PRIMARY KEY | PRIMARY KEY (id) | +| promotion_connection_createdAt_not_null | n | NOT NULL "createdAt" | +| promotion_connection_id_not_null | n | NOT NULL id | +| promotion_connection_name_not_null | n | NOT NULL name | +| promotion_connection_providerId_not_null | n | NOT NULL "providerId" | +| promotion_connection_scope_not_null | n | NOT NULL scope | +| promotion_connection_target_not_null | n | NOT NULL target | +| promotion_connection_updatedAt_not_null | n | NOT NULL "updatedAt" | + +## Indexes + +| Name | Definition | +| ---- | ---------- | +| IDX_9917feaa6ed5705046432b117f | CREATE INDEX "IDX_9917feaa6ed5705046432b117f" ON public.promotion_connection USING btree ("providerId") | +| IDX_promotion_connection_scope | CREATE UNIQUE INDEX "IDX_promotion_connection_scope" ON public.promotion_connection USING btree (scope) WHERE ((scope)::text = 'instance'::text) | +| PK_65cec79aba16950b45592335204 | CREATE UNIQUE INDEX "PK_65cec79aba16950b45592335204" ON public.promotion_connection USING btree (id) | + +## Relations + +```mermaid +erDiagram + +"public.promotion_config" }o--|| "public.promotion_connection" : "FOREIGN KEY (#quot;connectionId#quot;) REFERENCES promotion_connection(id) ON DELETE CASCADE" +"public.promotion_connection_project" }o--|| "public.promotion_connection" : "FOREIGN KEY (#quot;connectionId#quot;) REFERENCES promotion_connection(id) ON DELETE CASCADE" +"public.promotion_connection" }o--|| "public.promotion_provider" : "FOREIGN KEY (#quot;providerId#quot;) REFERENCES promotion_provider(id) ON DELETE RESTRICT" + +"public.promotion_connection" { + timestamp_3__with_time_zone createdAt + varchar_36_ id + varchar_128_ name + varchar_36_ providerId FK + varchar_16_ scope + json target + timestamp_3__with_time_zone updatedAt +} +"public.promotion_config" { + varchar_36_ connectionId FK + timestamp_3__with_time_zone createdAt + varchar_16_ direction + varchar_36_ id + varchar_128_ name + json settings + timestamp_3__with_time_zone updatedAt +} +"public.promotion_connection_project" { + varchar_36_ connectionId FK + timestamp_3__with_time_zone createdAt + varchar_36_ projectId FK + timestamp_3__with_time_zone updatedAt +} +"public.promotion_provider" { + text auth + varchar_32_ authType + json config + timestamp_3__with_time_zone createdAt + varchar_36_ id + varchar_128_ name + varchar_32_ type + timestamp_3__with_time_zone updatedAt +} +``` + +--- + +> Generated by [tbls](https://github.com/k1LoW/tbls) diff --git a/docs/generated/postgres-schema/public.promotion_connection_project.md b/docs/generated/postgres-schema/public.promotion_connection_project.md new file mode 100644 index 00000000000..1cb03b74698 --- /dev/null +++ b/docs/generated/postgres-schema/public.promotion_connection_project.md @@ -0,0 +1,69 @@ +# public.promotion_connection_project + +## Columns + +| Name | Type | Default | Nullable | Children | Parents | Comment | +| ---- | ---- | ------- | -------- | -------- | ------- | ------- | +| connectionId | varchar(36) | | false | | [public.promotion_connection](public.promotion_connection.md) | | +| createdAt | timestamp(3) with time zone | CURRENT_TIMESTAMP(3) | false | | | | +| projectId | varchar(36) | | false | | [public.project](public.project.md) | | +| updatedAt | timestamp(3) with time zone | CURRENT_TIMESTAMP(3) | false | | | | + +## Constraints + +| Name | Type | Definition | +| ---- | ---- | ---------- | +| FK_promotion_connection_project_connectionId | FOREIGN KEY | FOREIGN KEY ("connectionId") REFERENCES promotion_connection(id) ON DELETE CASCADE | +| FK_promotion_connection_project_projectId | FOREIGN KEY | FOREIGN KEY ("projectId") REFERENCES project(id) ON DELETE CASCADE | +| PK_0c63d9094bf8ca78349ba860147 | PRIMARY KEY | PRIMARY KEY ("projectId") | +| promotion_connection_project_connectionId_not_null | n | NOT NULL "connectionId" | +| promotion_connection_project_createdAt_not_null | n | NOT NULL "createdAt" | +| promotion_connection_project_projectId_not_null | n | NOT NULL "projectId" | +| promotion_connection_project_updatedAt_not_null | n | NOT NULL "updatedAt" | + +## Indexes + +| Name | Definition | +| ---- | ---------- | +| IDX_2c579b1068b66359de1b4d2de5 | CREATE INDEX "IDX_2c579b1068b66359de1b4d2de5" ON public.promotion_connection_project USING btree ("connectionId") | +| PK_0c63d9094bf8ca78349ba860147 | CREATE UNIQUE INDEX "PK_0c63d9094bf8ca78349ba860147" ON public.promotion_connection_project USING btree ("projectId") | + +## Relations + +```mermaid +erDiagram + +"public.promotion_connection_project" }o--|| "public.promotion_connection" : "FOREIGN KEY (#quot;connectionId#quot;) REFERENCES promotion_connection(id) ON DELETE CASCADE" +"public.promotion_connection_project" |o--|| "public.project" : "FOREIGN KEY (#quot;projectId#quot;) REFERENCES project(id) ON DELETE CASCADE" + +"public.promotion_connection_project" { + varchar_36_ connectionId FK + timestamp_3__with_time_zone createdAt + varchar_36_ projectId FK + timestamp_3__with_time_zone updatedAt +} +"public.promotion_connection" { + timestamp_3__with_time_zone createdAt + varchar_36_ id + varchar_128_ name + varchar_36_ providerId FK + varchar_16_ scope + json target + timestamp_3__with_time_zone updatedAt +} +"public.project" { + timestamp_3__with_time_zone createdAt + uuid creatorId FK + json customTelemetryTags + varchar_512_ description + json icon + varchar_36_ id + varchar_255_ name + varchar_36_ type + timestamp_3__with_time_zone updatedAt +} +``` + +--- + +> Generated by [tbls](https://github.com/k1LoW/tbls) diff --git a/docs/generated/postgres-schema/public.promotion_provider.md b/docs/generated/postgres-schema/public.promotion_provider.md new file mode 100644 index 00000000000..0769e5ddae8 --- /dev/null +++ b/docs/generated/postgres-schema/public.promotion_provider.md @@ -0,0 +1,68 @@ +# public.promotion_provider + +## Columns + +| Name | Type | Default | Nullable | Children | Parents | Comment | +| ---- | ---- | ------- | -------- | -------- | ------- | ------- | +| auth | text | | false | | | Encrypted credentials. Never sent to a client. | +| authType | varchar(32) | | false | | | PromotionProviderAuthType enum: "ssh-key", "token". "token" is an HTTP(S) username and password. | +| config | json | | false | | | Non-secret provider settings, with their own schemaVersion. Validated in code. | +| createdAt | timestamp(3) with time zone | CURRENT_TIMESTAMP(3) | false | | | | +| id | varchar(36) | | false | [public.promotion_connection](public.promotion_connection.md) | | | +| name | varchar(128) | | false | | | | +| type | varchar(32) | | false | | | PromotionProviderType enum: "git" | +| updatedAt | timestamp(3) with time zone | CURRENT_TIMESTAMP(3) | false | | | | + +## Constraints + +| Name | Type | Definition | +| ---- | ---- | ---------- | +| CHK_promotion_provider_authType | CHECK | CHECK ((("authType")::text = ANY ((ARRAY['ssh-key'::character varying, 'token'::character varying])::text[]))) | +| CHK_promotion_provider_type | CHECK | CHECK (((type)::text = 'git'::text)) | +| PK_6e968555ff44699f59ad10be765 | PRIMARY KEY | PRIMARY KEY (id) | +| promotion_provider_authType_not_null | n | NOT NULL "authType" | +| promotion_provider_auth_not_null | n | NOT NULL auth | +| promotion_provider_config_not_null | n | NOT NULL config | +| promotion_provider_createdAt_not_null | n | NOT NULL "createdAt" | +| promotion_provider_id_not_null | n | NOT NULL id | +| promotion_provider_name_not_null | n | NOT NULL name | +| promotion_provider_type_not_null | n | NOT NULL type | +| promotion_provider_updatedAt_not_null | n | NOT NULL "updatedAt" | + +## Indexes + +| Name | Definition | +| ---- | ---------- | +| PK_6e968555ff44699f59ad10be765 | CREATE UNIQUE INDEX "PK_6e968555ff44699f59ad10be765" ON public.promotion_provider USING btree (id) | + +## Relations + +```mermaid +erDiagram + +"public.promotion_connection" }o--|| "public.promotion_provider" : "FOREIGN KEY (#quot;providerId#quot;) REFERENCES promotion_provider(id) ON DELETE RESTRICT" + +"public.promotion_provider" { + text auth + varchar_32_ authType + json config + timestamp_3__with_time_zone createdAt + varchar_36_ id + varchar_128_ name + varchar_32_ type + timestamp_3__with_time_zone updatedAt +} +"public.promotion_connection" { + timestamp_3__with_time_zone createdAt + varchar_36_ id + varchar_128_ name + varchar_36_ providerId FK + varchar_16_ scope + json target + timestamp_3__with_time_zone updatedAt +} +``` + +--- + +> Generated by [tbls](https://github.com/k1LoW/tbls) diff --git a/docs/generated/sqlite-schema/README.md b/docs/generated/sqlite-schema/README.md index 93a159708f9..ceb2751ae27 100644 --- a/docs/generated/sqlite-schema/README.md +++ b/docs/generated/sqlite-schema/README.md @@ -104,6 +104,10 @@ Auto-generated from the SQLite migrations in @n8n/db. Do not edit by hand. | [project_pool_settings](project_pool_settings.md) | 4 | | table | | [project_relation](project_relation.md) | 5 | | table | | [project_secrets_provider_access](project_secrets_provider_access.md) | 5 | | table | +| [promotion_config](promotion_config.md) | 7 | | table | +| [promotion_connection](promotion_connection.md) | 7 | | table | +| [promotion_connection_project](promotion_connection_project.md) | 4 | | table | +| [promotion_provider](promotion_provider.md) | 8 | | table | | [role](role.md) | 7 | | table | | [role_mapping_rule](role_mapping_rule.md) | 7 | | table | | [role_mapping_rule_project](role_mapping_rule_project.md) | 2 | | table | @@ -290,6 +294,10 @@ erDiagram "project_relation" |o--|| "user" : "FOREIGN KEY (userId) REFERENCES user (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE" "project_secrets_provider_access" |o--|| "secrets_provider_connection" : "FOREIGN KEY (secretsProviderConnectionId) REFERENCES secrets_provider_connection (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE" "project_secrets_provider_access" |o--|| "project" : "FOREIGN KEY (projectId) REFERENCES project (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE" +"promotion_config" }o--|| "promotion_connection" : "FOREIGN KEY (connectionId) REFERENCES promotion_connection (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE" +"promotion_connection" }o--|| "promotion_provider" : "FOREIGN KEY (providerId) REFERENCES promotion_provider (id) ON UPDATE NO ACTION ON DELETE RESTRICT MATCH NONE" +"promotion_connection_project" }o--|| "promotion_connection" : "FOREIGN KEY (connectionId) REFERENCES promotion_connection (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE" +"promotion_connection_project" |o--|| "project" : "FOREIGN KEY (projectId) REFERENCES project (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE" "role_mapping_rule" }o--|| "role" : "FOREIGN KEY (role) REFERENCES role (slug) ON UPDATE CASCADE ON DELETE CASCADE MATCH NONE" "role_mapping_rule_project" |o--|| "project" : "FOREIGN KEY (projectId) REFERENCES project (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE" "role_mapping_rule_project" |o--|| "role_mapping_rule" : "FOREIGN KEY (roleMappingRuleId) REFERENCES role_mapping_rule (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE" @@ -1311,6 +1319,40 @@ erDiagram INTEGER secretsProviderConnectionId PK datetime_3_ updatedAt } +"promotion_config" { + varchar_36_ connectionId FK + datetime_3_ createdAt + varchar_16_ direction + varchar_36_ id PK + varchar_128_ name + TEXT settings + datetime_3_ updatedAt +} +"promotion_connection" { + datetime_3_ createdAt + varchar_36_ id PK + varchar_128_ name + varchar_36_ providerId FK + varchar_16_ scope + TEXT target + datetime_3_ updatedAt +} +"promotion_connection_project" { + varchar_36_ connectionId FK + datetime_3_ createdAt + varchar_36_ projectId PK + datetime_3_ updatedAt +} +"promotion_provider" { + TEXT auth + varchar_32_ authType + TEXT config + datetime_3_ createdAt + varchar_36_ id PK + varchar_128_ name + varchar_32_ type + datetime_3_ updatedAt +} "role" { datetime_3_ createdAt TEXT description diff --git a/docs/generated/sqlite-schema/project.md b/docs/generated/sqlite-schema/project.md index f2adf8c9679..c546586b120 100644 --- a/docs/generated/sqlite-schema/project.md +++ b/docs/generated/sqlite-schema/project.md @@ -20,7 +20,7 @@ CREATE TABLE "project" ("id" varchar(36) PRIMARY KEY NOT NULL, "name" varchar(25 | customTelemetryTags | TEXT | '[]' | false | | | | | description | varchar(512) | | true | | | | | icon | TEXT | | true | | | | -| id | varchar(36) | | false | [activity_event](activity_event.md) [agent_chat_attachments](agent_chat_attachments.md) [agent_execution_threads](agent_execution_threads.md) [agents](agents.md) [data_table](data_table.md) [folder](folder.md) [git_connection_project](git_connection_project.md) [insights_metadata](insights_metadata.md) [instance_ai_threads](instance_ai_threads.md) [project_pool_settings](project_pool_settings.md) [project_relation](project_relation.md) [project_secrets_provider_access](project_secrets_provider_access.md) [role_mapping_rule_project](role_mapping_rule_project.md) [shared_credentials](shared_credentials.md) [shared_workflow](shared_workflow.md) [type_availability_policy_scope](type_availability_policy_scope.md) [variables](variables.md) [workflow_review_request](workflow_review_request.md) | | | +| id | varchar(36) | | false | [activity_event](activity_event.md) [agent_chat_attachments](agent_chat_attachments.md) [agent_execution_threads](agent_execution_threads.md) [agents](agents.md) [data_table](data_table.md) [folder](folder.md) [git_connection_project](git_connection_project.md) [insights_metadata](insights_metadata.md) [instance_ai_threads](instance_ai_threads.md) [project_pool_settings](project_pool_settings.md) [project_relation](project_relation.md) [project_secrets_provider_access](project_secrets_provider_access.md) [promotion_connection_project](promotion_connection_project.md) [role_mapping_rule_project](role_mapping_rule_project.md) [shared_credentials](shared_credentials.md) [shared_workflow](shared_workflow.md) [type_availability_policy_scope](type_availability_policy_scope.md) [variables](variables.md) [workflow_review_request](workflow_review_request.md) | | | | name | varchar(255) | | false | | | | | type | varchar(36) | | false | | | | | updatedAt | datetime(3) | STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW') | false | | | | @@ -57,6 +57,7 @@ erDiagram "project_pool_settings" |o--|| "project" : "FOREIGN KEY (projectId) REFERENCES project (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE" "project_relation" |o--|| "project" : "FOREIGN KEY (projectId) REFERENCES project (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE" "project_secrets_provider_access" |o--|| "project" : "FOREIGN KEY (projectId) REFERENCES project (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE" +"promotion_connection_project" |o--|| "project" : "FOREIGN KEY (projectId) REFERENCES project (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE" "role_mapping_rule_project" |o--|| "project" : "FOREIGN KEY (projectId) REFERENCES project (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE" "shared_credentials" |o--|| "project" : "FOREIGN KEY (projectId) REFERENCES project (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE" "shared_workflow" |o--|| "project" : "FOREIGN KEY (projectId) REFERENCES project (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE" @@ -211,6 +212,12 @@ erDiagram INTEGER secretsProviderConnectionId PK datetime_3_ updatedAt } +"promotion_connection_project" { + varchar_36_ connectionId FK + datetime_3_ createdAt + varchar_36_ projectId PK + datetime_3_ updatedAt +} "role_mapping_rule_project" { varchar_36_ projectId PK varchar_16_ roleMappingRuleId PK diff --git a/docs/generated/sqlite-schema/promotion_config.md b/docs/generated/sqlite-schema/promotion_config.md new file mode 100644 index 00000000000..1362b193428 --- /dev/null +++ b/docs/generated/sqlite-schema/promotion_config.md @@ -0,0 +1,71 @@ +# promotion_config + +## Description + +
+Table Definition + +```sql +CREATE TABLE "promotion_config" ("id" varchar(36) PRIMARY KEY NOT NULL, "connectionId" varchar(36) NOT NULL, "name" varchar(128) NOT NULL, "direction" varchar(16) NOT NULL, "settings" text NOT NULL, "createdAt" datetime(3) NOT NULL DEFAULT (STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')), "updatedAt" datetime(3) NOT NULL DEFAULT (STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')), CONSTRAINT "CHK_promotion_config_direction" CHECK ("direction" IN ('apply', 'promote')), CONSTRAINT "FK_promotion_config_connectionId" FOREIGN KEY ("connectionId") REFERENCES "promotion_connection" ("id") ON DELETE CASCADE) +``` + +
+ +## Columns + +| Name | Type | Default | Nullable | Children | Parents | Comment | +| ---- | ---- | ------- | -------- | -------- | ------- | ------- | +| connectionId | varchar(36) | | false | | [promotion_connection](promotion_connection.md) | | +| createdAt | datetime(3) | STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW') | false | | | | +| direction | varchar(16) | | false | | | | +| id | varchar(36) | | false | | | | +| name | varchar(128) | | false | | | | +| settings | TEXT | | false | | | | +| updatedAt | datetime(3) | STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW') | false | | | | + +## Constraints + +| Name | Type | Definition | +| ---- | ---- | ---------- | +| - | CHECK | CHECK ("direction" IN ('apply', 'promote')) | +| - (Foreign key ID: 0) | FOREIGN KEY | FOREIGN KEY (connectionId) REFERENCES promotion_connection (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE | +| id | PRIMARY KEY | PRIMARY KEY (id) | +| sqlite_autoindex_promotion_config_1 | PRIMARY KEY | PRIMARY KEY (id) | + +## Indexes + +| Name | Definition | +| ---- | ---------- | +| IDX_promotion_config_connectionId_direction | CREATE UNIQUE INDEX "IDX_promotion_config_connectionId_direction" ON "promotion_config" ("connectionId", "direction") | +| sqlite_autoindex_promotion_config_1 | PRIMARY KEY (id) | + +## Relations + +```mermaid +erDiagram + +"promotion_config" }o--|| "promotion_connection" : "FOREIGN KEY (connectionId) REFERENCES promotion_connection (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE" + +"promotion_config" { + varchar_36_ connectionId FK + datetime_3_ createdAt + varchar_16_ direction + varchar_36_ id PK + varchar_128_ name + TEXT settings + datetime_3_ updatedAt +} +"promotion_connection" { + datetime_3_ createdAt + varchar_36_ id PK + varchar_128_ name + varchar_36_ providerId FK + varchar_16_ scope + TEXT target + datetime_3_ updatedAt +} +``` + +--- + +> Generated by [tbls](https://github.com/k1LoW/tbls) diff --git a/docs/generated/sqlite-schema/promotion_connection.md b/docs/generated/sqlite-schema/promotion_connection.md new file mode 100644 index 00000000000..5674fd75eb5 --- /dev/null +++ b/docs/generated/sqlite-schema/promotion_connection.md @@ -0,0 +1,90 @@ +# promotion_connection + +## Description + +
+Table Definition + +```sql +CREATE TABLE "promotion_connection" ("id" varchar(36) PRIMARY KEY NOT NULL, "name" varchar(128) NOT NULL, "providerId" varchar(36) NOT NULL, "scope" varchar(16) NOT NULL, "target" text NOT NULL, "createdAt" datetime(3) NOT NULL DEFAULT (STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')), "updatedAt" datetime(3) NOT NULL DEFAULT (STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')), CONSTRAINT "CHK_promotion_connection_scope" CHECK ("scope" IN ('instance', 'projects')), CONSTRAINT "FK_promotion_connection_providerId" FOREIGN KEY ("providerId") REFERENCES "promotion_provider" ("id") ON DELETE RESTRICT) +``` + +
+ +## Columns + +| Name | Type | Default | Nullable | Children | Parents | Comment | +| ---- | ---- | ------- | -------- | -------- | ------- | ------- | +| createdAt | datetime(3) | STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW') | false | | | | +| id | varchar(36) | | false | [promotion_config](promotion_config.md) [promotion_connection_project](promotion_connection_project.md) | | | +| name | varchar(128) | | false | | | | +| providerId | varchar(36) | | false | | [promotion_provider](promotion_provider.md) | | +| scope | varchar(16) | | false | | | | +| target | TEXT | | false | | | | +| updatedAt | datetime(3) | STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW') | false | | | | + +## Constraints + +| Name | Type | Definition | +| ---- | ---- | ---------- | +| - | CHECK | CHECK ("scope" IN ('instance', 'projects')) | +| - (Foreign key ID: 0) | FOREIGN KEY | FOREIGN KEY (providerId) REFERENCES promotion_provider (id) ON UPDATE NO ACTION ON DELETE RESTRICT MATCH NONE | +| id | PRIMARY KEY | PRIMARY KEY (id) | +| sqlite_autoindex_promotion_connection_1 | PRIMARY KEY | PRIMARY KEY (id) | + +## Indexes + +| Name | Definition | +| ---- | ---------- | +| IDX_9917feaa6ed5705046432b117f | CREATE INDEX "IDX_9917feaa6ed5705046432b117f" ON "promotion_connection" ("providerId") | +| IDX_promotion_connection_scope | CREATE UNIQUE INDEX "IDX_promotion_connection_scope" ON "promotion_connection" ("scope") WHERE "scope" = 'instance' | +| sqlite_autoindex_promotion_connection_1 | PRIMARY KEY (id) | + +## Relations + +```mermaid +erDiagram + +"promotion_config" }o--|| "promotion_connection" : "FOREIGN KEY (connectionId) REFERENCES promotion_connection (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE" +"promotion_connection_project" }o--|| "promotion_connection" : "FOREIGN KEY (connectionId) REFERENCES promotion_connection (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE" +"promotion_connection" }o--|| "promotion_provider" : "FOREIGN KEY (providerId) REFERENCES promotion_provider (id) ON UPDATE NO ACTION ON DELETE RESTRICT MATCH NONE" + +"promotion_connection" { + datetime_3_ createdAt + varchar_36_ id PK + varchar_128_ name + varchar_36_ providerId FK + varchar_16_ scope + TEXT target + datetime_3_ updatedAt +} +"promotion_config" { + varchar_36_ connectionId FK + datetime_3_ createdAt + varchar_16_ direction + varchar_36_ id PK + varchar_128_ name + TEXT settings + datetime_3_ updatedAt +} +"promotion_connection_project" { + varchar_36_ connectionId FK + datetime_3_ createdAt + varchar_36_ projectId PK + datetime_3_ updatedAt +} +"promotion_provider" { + TEXT auth + varchar_32_ authType + TEXT config + datetime_3_ createdAt + varchar_36_ id PK + varchar_128_ name + varchar_32_ type + datetime_3_ updatedAt +} +``` + +--- + +> Generated by [tbls](https://github.com/k1LoW/tbls) diff --git a/docs/generated/sqlite-schema/promotion_connection_project.md b/docs/generated/sqlite-schema/promotion_connection_project.md new file mode 100644 index 00000000000..4348dc0916d --- /dev/null +++ b/docs/generated/sqlite-schema/promotion_connection_project.md @@ -0,0 +1,77 @@ +# promotion_connection_project + +## Description + +
+Table Definition + +```sql +CREATE TABLE "promotion_connection_project" ("projectId" varchar(36) PRIMARY KEY NOT NULL, "connectionId" varchar(36) NOT NULL, "createdAt" datetime(3) NOT NULL DEFAULT (STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')), "updatedAt" datetime(3) NOT NULL DEFAULT (STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')), CONSTRAINT "FK_promotion_connection_project_projectId" FOREIGN KEY ("projectId") REFERENCES "project" ("id") ON DELETE CASCADE, CONSTRAINT "FK_promotion_connection_project_connectionId" FOREIGN KEY ("connectionId") REFERENCES "promotion_connection" ("id") ON DELETE CASCADE) +``` + +
+ +## Columns + +| Name | Type | Default | Nullable | Children | Parents | Comment | +| ---- | ---- | ------- | -------- | -------- | ------- | ------- | +| connectionId | varchar(36) | | false | | [promotion_connection](promotion_connection.md) | | +| createdAt | datetime(3) | STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW') | false | | | | +| projectId | varchar(36) | | false | | [project](project.md) | | +| updatedAt | datetime(3) | STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW') | false | | | | + +## Constraints + +| Name | Type | Definition | +| ---- | ---- | ---------- | +| - (Foreign key ID: 0) | FOREIGN KEY | FOREIGN KEY (connectionId) REFERENCES promotion_connection (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE | +| - (Foreign key ID: 1) | FOREIGN KEY | FOREIGN KEY (projectId) REFERENCES project (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE | +| projectId | PRIMARY KEY | PRIMARY KEY (projectId) | +| sqlite_autoindex_promotion_connection_project_1 | PRIMARY KEY | PRIMARY KEY (projectId) | + +## Indexes + +| Name | Definition | +| ---- | ---------- | +| IDX_2c579b1068b66359de1b4d2de5 | CREATE INDEX "IDX_2c579b1068b66359de1b4d2de5" ON "promotion_connection_project" ("connectionId") | +| sqlite_autoindex_promotion_connection_project_1 | PRIMARY KEY (projectId) | + +## Relations + +```mermaid +erDiagram + +"promotion_connection_project" }o--|| "promotion_connection" : "FOREIGN KEY (connectionId) REFERENCES promotion_connection (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE" +"promotion_connection_project" |o--|| "project" : "FOREIGN KEY (projectId) REFERENCES project (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE" + +"promotion_connection_project" { + varchar_36_ connectionId FK + datetime_3_ createdAt + varchar_36_ projectId PK + datetime_3_ updatedAt +} +"promotion_connection" { + datetime_3_ createdAt + varchar_36_ id PK + varchar_128_ name + varchar_36_ providerId FK + varchar_16_ scope + TEXT target + datetime_3_ updatedAt +} +"project" { + datetime_3_ createdAt + varchar creatorId FK + TEXT customTelemetryTags + varchar_512_ description + TEXT icon + varchar_36_ id PK + varchar_255_ name + varchar_36_ type + datetime_3_ updatedAt +} +``` + +--- + +> Generated by [tbls](https://github.com/k1LoW/tbls) diff --git a/docs/generated/sqlite-schema/promotion_provider.md b/docs/generated/sqlite-schema/promotion_provider.md new file mode 100644 index 00000000000..03a04615e63 --- /dev/null +++ b/docs/generated/sqlite-schema/promotion_provider.md @@ -0,0 +1,72 @@ +# promotion_provider + +## Description + +
+Table Definition + +```sql +CREATE TABLE "promotion_provider" ("id" varchar(36) PRIMARY KEY NOT NULL, "name" varchar(128) NOT NULL, "type" varchar(32) NOT NULL, "authType" varchar(32) NOT NULL, "config" text NOT NULL, "auth" text NOT NULL, "createdAt" datetime(3) NOT NULL DEFAULT (STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')), "updatedAt" datetime(3) NOT NULL DEFAULT (STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')), CONSTRAINT "CHK_promotion_provider_type" CHECK ("type" IN ('git')), CONSTRAINT "CHK_promotion_provider_authType" CHECK ("authType" IN ('ssh-key', 'token'))) +``` + +
+ +## Columns + +| Name | Type | Default | Nullable | Children | Parents | Comment | +| ---- | ---- | ------- | -------- | -------- | ------- | ------- | +| auth | TEXT | | false | | | | +| authType | varchar(32) | | false | | | | +| config | TEXT | | false | | | | +| createdAt | datetime(3) | STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW') | false | | | | +| id | varchar(36) | | false | [promotion_connection](promotion_connection.md) | | | +| name | varchar(128) | | false | | | | +| type | varchar(32) | | false | | | | +| updatedAt | datetime(3) | STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW') | false | | | | + +## Constraints + +| Name | Type | Definition | +| ---- | ---- | ---------- | +| - | CHECK | CHECK ("type" IN ('git')) | +| - | CHECK | CHECK ("authType" IN ('ssh-key', 'token')) | +| id | PRIMARY KEY | PRIMARY KEY (id) | +| sqlite_autoindex_promotion_provider_1 | PRIMARY KEY | PRIMARY KEY (id) | + +## Indexes + +| Name | Definition | +| ---- | ---------- | +| sqlite_autoindex_promotion_provider_1 | PRIMARY KEY (id) | + +## Relations + +```mermaid +erDiagram + +"promotion_connection" }o--|| "promotion_provider" : "FOREIGN KEY (providerId) REFERENCES promotion_provider (id) ON UPDATE NO ACTION ON DELETE RESTRICT MATCH NONE" + +"promotion_provider" { + TEXT auth + varchar_32_ authType + TEXT config + datetime_3_ createdAt + varchar_36_ id PK + varchar_128_ name + varchar_32_ type + datetime_3_ updatedAt +} +"promotion_connection" { + datetime_3_ createdAt + varchar_36_ id PK + varchar_128_ name + varchar_36_ providerId FK + varchar_16_ scope + TEXT target + datetime_3_ updatedAt +} +``` + +--- + +> Generated by [tbls](https://github.com/k1LoW/tbls) diff --git a/packages/@n8n/api-types/src/dto/index.ts b/packages/@n8n/api-types/src/dto/index.ts index 4f7eb83bf69..9bc9bf1e0c9 100644 --- a/packages/@n8n/api-types/src/dto/index.ts +++ b/packages/@n8n/api-types/src/dto/index.ts @@ -461,3 +461,75 @@ export { type PromotableResourceType, type PromoteRequest, } from './promotions/promotable-resource.dto'; + +export { promotionDisplayNameSchema } from './promotions/promotion-common.dto'; +export { + CreatePromotionProviderDto, + ListPromotionProvidersQueryDto, + PromotionProviderCreatedPublicDto, + PromotionProviderListPublicDto, + PromotionProviderPublicDto, + UpdatePromotionProviderDto, + promotionGitConfigSchemas, + promotionGitSshKeyAuthInputSchema, + promotionGitSshKeyAuthUpdateSchema, + promotionGitSshKeyConfigSchema, + promotionGitTokenAuthInputSchema, + promotionGitTokenConfigSchema, + promotionProviderAuthInputSchema, + promotionProviderAuthTypeSchema, + promotionProviderAuthUpdateSchema, + promotionProviderConfigSchema, + promotionProviderPublicSchema, + promotionProviderSummarySchema, + promotionProviderTypeSchema, + promotionSshKeyTypeSchema, + type PromotionProviderAuthInput, + type PromotionProviderAuthType, + type PromotionProviderAuthUpdate, + type PromotionProviderConfig, + type PromotionProviderType, + type PromotionSshKeyType, +} from './promotions/promotion-provider.dto'; +export { + PromotionApplyConfigPublicDto, + PromotionPromoteConfigPublicDto, + UpsertPromotionApplyConfigDto, + UpsertPromotionPromoteConfigDto, + promotionApplyConfigPublicSchema, + promotionConfigSettingsSchema, + promotionConnectionConfigsPublicSchema, + promotionDirectionSchema, + promotionGitApplySettingsSchema, + promotionGitPromoteSettingsSchema, + promotionGitSettingsSchemas, + promotionPromoteConfigPublicSchema, + type PromotionConfigSettings, + type PromotionConnectionConfigsPublic, + type PromotionDirection, +} from './promotions/promotion-config.dto'; +export { + CreatePromotionConnectionDto, + ListPromotionConnectionsQueryDto, + PromotionConnectionListPublicDto, + PromotionConnectionProjectListPublicDto, + PromotionConnectionProjectPublicDto, + PromotionConnectionPublicDto, + UpdatePromotionConnectionDto, + promotionConnectionProjectPublicSchema, + promotionConnectionPublicSchema, + promotionConnectionScopeSchema, + promotionConnectionTargetSchema, + type PromotionConnectionScope, + type PromotionConnectionTarget, +} from './promotions/promotion-connection.dto'; +export { + ApplyPackageResultDto, + PromotePackageDto, + PromotePackageResultDto, + applyPackageCountsSchema, + applyPackageResultSchema, + promotePackageCountsSchema, + promotePackageResultSchema, + promotionGitResultSchema, +} from './promotions/promotion-operations.dto'; diff --git a/packages/@n8n/api-types/src/dto/promotions/__tests__/promotion-config.dto.test.ts b/packages/@n8n/api-types/src/dto/promotions/__tests__/promotion-config.dto.test.ts new file mode 100644 index 00000000000..f4216fe652b --- /dev/null +++ b/packages/@n8n/api-types/src/dto/promotions/__tests__/promotion-config.dto.test.ts @@ -0,0 +1,107 @@ +import { + PromotionPromoteConfigPublicDto, + UpsertPromotionApplyConfigDto, + UpsertPromotionPromoteConfigDto, +} from '../promotion-config.dto'; + +const applySettings = { schemaVersion: 1 as const, branchName: 'dev' }; +const promoteSettings = { + schemaVersion: 1 as const, + baseBranchName: 'staging', + createBranchOnPromotion: false, +}; + +describe('UpsertPromotionApplyConfigDto', () => { + it('accepts settings with an optional name', () => { + expect(UpsertPromotionApplyConfigDto.safeParse({ settings: applySettings }).success).toBe(true); + expect( + UpsertPromotionApplyConfigDto.safeParse({ name: 'Apply', settings: applySettings }).success, + ).toBe(true); + }); + + // The direction is in the path, so a body that names one is malformed. + it('rejects a direction and a connection in the body', () => { + expect( + UpsertPromotionApplyConfigDto.safeParse({ direction: 'apply', settings: applySettings }) + .success, + ).toBe(false); + expect( + UpsertPromotionApplyConfigDto.safeParse({ connectionId: 'conn-1', settings: applySettings }) + .success, + ).toBe(false); + }); + + it('rejects promote settings and the branching flag', () => { + expect(UpsertPromotionApplyConfigDto.safeParse({ settings: promoteSettings }).success).toBe( + false, + ); + expect( + UpsertPromotionApplyConfigDto.safeParse({ + settings: { ...applySettings, createBranchOnPromotion: true }, + }).success, + ).toBe(false); + }); + + it('requires a branch name of at most 255 characters', () => { + const settings = (branchName: string) => ({ schemaVersion: 1, branchName }); + + expect( + UpsertPromotionApplyConfigDto.safeParse({ settings: { schemaVersion: 1 } }).success, + ).toBe(false); + expect( + UpsertPromotionApplyConfigDto.safeParse({ settings: settings('a'.repeat(255)) }).success, + ).toBe(true); + expect( + UpsertPromotionApplyConfigDto.safeParse({ settings: settings('a'.repeat(256)) }).success, + ).toBe(false); + }); + + it('rejects settings of an unsupported schema version', () => { + expect( + UpsertPromotionApplyConfigDto.safeParse({ + settings: { schemaVersion: 2, branchName: 'dev' }, + }).success, + ).toBe(false); + }); +}); + +describe('UpsertPromotionPromoteConfigDto', () => { + // A write replaces the whole config, so the flag has no default to fall back on. + it('requires createBranchOnPromotion, so a write cannot reset it by omission', () => { + expect( + UpsertPromotionPromoteConfigDto.safeParse({ + settings: { schemaVersion: 1, baseBranchName: 'staging' }, + }).success, + ).toBe(false); + expect(UpsertPromotionPromoteConfigDto.safeParse({ settings: promoteSettings }).success).toBe( + true, + ); + }); + + it('rejects the apply branch field', () => { + expect(UpsertPromotionPromoteConfigDto.safeParse({ settings: applySettings }).success).toBe( + false, + ); + }); +}); + +describe('PromotionPromoteConfigPublicDto', () => { + const base = { + id: 'cfg-1', + name: 'Promote', + createdAt: '2026-09-07T08:00:00.000Z', + updatedAt: '2026-09-07T08:00:00.000Z', + }; + + it('always reports createBranchOnPromotion', () => { + expect( + PromotionPromoteConfigPublicDto.safeParse({ + ...base, + settings: { schemaVersion: 1, baseBranchName: 'staging' }, + }).success, + ).toBe(false); + expect( + PromotionPromoteConfigPublicDto.safeParse({ ...base, settings: promoteSettings }).success, + ).toBe(true); + }); +}); diff --git a/packages/@n8n/api-types/src/dto/promotions/__tests__/promotion-connection.dto.test.ts b/packages/@n8n/api-types/src/dto/promotions/__tests__/promotion-connection.dto.test.ts new file mode 100644 index 00000000000..ae1a4092cc3 --- /dev/null +++ b/packages/@n8n/api-types/src/dto/promotions/__tests__/promotion-connection.dto.test.ts @@ -0,0 +1,137 @@ +import { + CreatePromotionConnectionDto, + PromotionConnectionPublicDto, + UpdatePromotionConnectionDto, +} from '../promotion-connection.dto'; + +const target = { schemaVersion: 1 as const, remoteUrl: 'git@example.com:team/workflows.git' }; +const applyConfig = { settings: { schemaVersion: 1 as const, branchName: 'dev' } }; +const promoteConfig = { + settings: { + schemaVersion: 1 as const, + baseBranchName: 'staging', + createBranchOnPromotion: false, + }, +}; + +const newConnection = { + name: 'Staging', + scope: 'instance' as const, + providerId: 'prov-1', + target, +}; + +const timestamp = '2026-09-07T08:00:00.000Z'; +const publicProvider = { + id: 'prov-1', + name: 'Staging', + type: 'git' as const, + authType: 'ssh-key' as const, + config: { + schemaVersion: 1 as const, + publicKey: 'ssh-ed25519 AAAAKEY', + keyType: 'ed25519' as const, + }, + createdAt: timestamp, + updatedAt: timestamp, +}; + +describe('CreatePromotionConnectionDto', () => { + it('accepts a connection with no configs, so a public key can be read before cloning', () => { + expect(CreatePromotionConnectionDto.safeParse(newConnection).success).toBe(true); + }); + + it('accepts a config for each direction, keyed by direction', () => { + expect( + CreatePromotionConnectionDto.safeParse({ + ...newConnection, + configs: { apply: applyConfig, promote: promoteConfig }, + }).success, + ).toBe(true); + expect( + CreatePromotionConnectionDto.safeParse({ ...newConnection, configs: { apply: applyConfig } }) + .success, + ).toBe(true); + }); + + // Keying by direction is what makes "one per direction" structural. + it('rejects a config array and an unknown direction key', () => { + expect( + CreatePromotionConnectionDto.safeParse({ + ...newConnection, + configs: [applyConfig, promoteConfig], + }).success, + ).toBe(false); + expect( + CreatePromotionConnectionDto.safeParse({ ...newConnection, configs: { sync: applyConfig } }) + .success, + ).toBe(false); + }); + + it('rejects settings under the wrong direction key', () => { + expect( + CreatePromotionConnectionDto.safeParse({ + ...newConnection, + configs: { apply: promoteConfig }, + }).success, + ).toBe(false); + }); + + it('requires a provider id and rejects a target of an unsupported version', () => { + expect( + CreatePromotionConnectionDto.safeParse({ ...newConnection, providerId: '' }).success, + ).toBe(false); + expect( + CreatePromotionConnectionDto.safeParse({ + ...newConnection, + target: { schemaVersion: 2, remoteUrl: 'git@example.com:team/workflows.git' }, + }).success, + ).toBe(false); + }); +}); + +describe('UpdatePromotionConnectionDto', () => { + it('accepts the name, the target, and the provider', () => { + expect( + UpdatePromotionConnectionDto.safeParse({ name: 'Renamed', target, providerId: 'prov-2' }) + .success, + ).toBe(true); + }); + + it('rejects configs, a scope change, and an operation payload', () => { + expect( + UpdatePromotionConnectionDto.safeParse({ name: 'Renamed', configs: { apply: applyConfig } }) + .success, + ).toBe(false); + expect(UpdatePromotionConnectionDto.safeParse({ scope: 'projects' }).success).toBe(false); + expect(UpdatePromotionConnectionDto.safeParse({ commitMessage: 'Update' }).success).toBe(false); + }); +}); + +describe('PromotionConnectionPublicDto', () => { + // Detail and list share this shape, so a full provider would put the key in every row. + it('embeds the provider without its public key', () => { + const result = PromotionConnectionPublicDto.safeParse({ + id: 'conn-1', + name: 'Staging', + scope: 'instance', + target, + provider: publicProvider, + configs: { + apply: { + ...applyConfig, + id: 'cfg-1', + name: 'Apply', + createdAt: timestamp, + updatedAt: timestamp, + }, + }, + createdAt: timestamp, + updatedAt: timestamp, + }); + + if (!result.success) throw result.error; + expect(result.data.provider).not.toHaveProperty('config'); + expect(JSON.stringify(result.data)).not.toContain('AAAAKEY'); + }); +}); diff --git a/packages/@n8n/api-types/src/dto/promotions/__tests__/promotion-operations.dto.test.ts b/packages/@n8n/api-types/src/dto/promotions/__tests__/promotion-operations.dto.test.ts new file mode 100644 index 00000000000..076b549f815 --- /dev/null +++ b/packages/@n8n/api-types/src/dto/promotions/__tests__/promotion-operations.dto.test.ts @@ -0,0 +1,19 @@ +import { PromotePackageDto } from '../promotion-operations.dto'; + +describe('PromotePackageDto', () => { + it('requires a non-blank commit message and accepts an optional force flag', () => { + expect(PromotePackageDto.safeParse({ commitMessage: 'Update projects' }).success).toBe(true); + expect(PromotePackageDto.safeParse({ commitMessage: 'Update', force: true }).success).toBe( + true, + ); + expect(PromotePackageDto.safeParse({}).success).toBe(false); + expect(PromotePackageDto.safeParse({ commitMessage: ' ' }).success).toBe(false); + }); + + // The branch comes from the stored config, never from the request. + it('rejects unknown fields, so an operation cannot be steered from the request', () => { + expect( + PromotePackageDto.safeParse({ commitMessage: 'Update', branchName: 'main' }).success, + ).toBe(false); + }); +}); diff --git a/packages/@n8n/api-types/src/dto/promotions/__tests__/promotion-provider.dto.test.ts b/packages/@n8n/api-types/src/dto/promotions/__tests__/promotion-provider.dto.test.ts new file mode 100644 index 00000000000..ad855237d57 --- /dev/null +++ b/packages/@n8n/api-types/src/dto/promotions/__tests__/promotion-provider.dto.test.ts @@ -0,0 +1,157 @@ +import { + CreatePromotionProviderDto, + PromotionProviderListPublicDto, + PromotionProviderPublicDto, + UpdatePromotionProviderDto, +} from '../promotion-provider.dto'; + +const provider = { name: 'Staging', type: 'git' as const }; +const sshAuth = { authType: 'ssh-key' as const }; +const tokenAuth = { authType: 'token' as const, username: 'git-user', password: 'secret' }; + +const timestamp = '2026-09-07T08:00:00.000Z'; +const publicProvider = { + ...provider, + id: 'prov-1', + authType: 'ssh-key' as const, + config: { + schemaVersion: 1 as const, + publicKey: 'ssh-ed25519 AAAAKEY', + keyType: 'ed25519' as const, + }, + createdAt: timestamp, + updatedAt: timestamp, +}; + +describe('CreatePromotionProviderDto', () => { + it('defaults an SSH key type to ed25519', () => { + const result = CreatePromotionProviderDto.safeParse({ ...provider, auth: sshAuth }); + + if (!result.success) throw result.error; + expect(result.data.auth).toEqual({ authType: 'ssh-key', keyType: 'ed25519' }); + }); + + it('trims the username but stores the password as sent', () => { + const result = CreatePromotionProviderDto.safeParse({ + ...provider, + auth: { ...tokenAuth, username: ' git-user ', password: ' secret ' }, + }); + + if (!result.success) throw result.error; + expect(result.data.auth).toEqual({ + authType: 'token', + username: 'git-user', + password: ' secret ', + }); + }); + + // The auth type lives inside `auth`, so credentials cannot be paired with + // another auth type's fields. + it('rejects an auth payload that mixes the two auth types', () => { + expect( + CreatePromotionProviderDto.safeParse({ + ...provider, + auth: { ...tokenAuth, keyType: 'rsa' }, + }).success, + ).toBe(false); + expect( + CreatePromotionProviderDto.safeParse({ + ...provider, + auth: { authType: 'ssh-key', username: 'git-user', password: 'secret' }, + }).success, + ).toBe(false); + }); + + it('rejects an auth payload with no auth type or no credentials', () => { + expect(CreatePromotionProviderDto.safeParse({ ...provider, auth: {} }).success).toBe(false); + expect( + CreatePromotionProviderDto.safeParse({ ...provider, auth: { authType: 'token' } }).success, + ).toBe(false); + expect( + CreatePromotionProviderDto.safeParse({ + ...provider, + auth: { authType: 'token', username: 'git-user' }, + }).success, + ).toBe(false); + }); + + it('rejects a blank name, an over-long name, and a blank password', () => { + expect( + CreatePromotionProviderDto.safeParse({ ...provider, name: ' ', auth: tokenAuth }).success, + ).toBe(false); + expect( + CreatePromotionProviderDto.safeParse({ + ...provider, + name: 'a'.repeat(129), + auth: tokenAuth, + }).success, + ).toBe(false); + expect( + CreatePromotionProviderDto.safeParse({ + ...provider, + auth: { ...tokenAuth, password: '' }, + }).success, + ).toBe(false); + }); + + it('rejects a top-level auth type and a config', () => { + expect( + CreatePromotionProviderDto.safeParse({ ...provider, authType: 'token', auth: tokenAuth }) + .success, + ).toBe(false); + expect( + CreatePromotionProviderDto.safeParse({ + ...provider, + auth: sshAuth, + config: { schemaVersion: 1, publicKey: 'ssh-ed25519 AAAAKEY', keyType: 'ed25519' }, + }).success, + ).toBe(false); + }); +}); + +describe('UpdatePromotionProviderDto', () => { + it('keeps the stored credentials when auth is omitted', () => { + expect(UpdatePromotionProviderDto.safeParse({ name: 'Renamed' }).success).toBe(true); + }); + + it('leaves the key type unset, so a rotation can keep the current algorithm', () => { + const result = UpdatePromotionProviderDto.safeParse({ auth: sshAuth }); + + if (!result.success) throw result.error; + expect(result.data.auth).toEqual({ authType: 'ssh-key' }); + }); + + it('still requires both credentials for a token provider', () => { + expect(UpdatePromotionProviderDto.safeParse({ auth: { authType: 'token' } }).success).toBe( + false, + ); + expect(UpdatePromotionProviderDto.safeParse({ auth: tokenAuth }).success).toBe(true); + }); + + it('rejects a change to the provider type or the config', () => { + expect(UpdatePromotionProviderDto.safeParse({ type: 'git' }).success).toBe(false); + expect(UpdatePromotionProviderDto.safeParse({ config: { schemaVersion: 1 } }).success).toBe( + false, + ); + }); +}); + +describe('provider responses', () => { + it('leaves out the encrypted credentials', () => { + const result = PromotionProviderPublicDto.safeParse({ ...publicProvider, auth: 'encrypted' }); + + if (!result.success) throw result.error; + expect(result.data).not.toHaveProperty('auth'); + }); + + it('drops the public key from list rows', () => { + const result = PromotionProviderListPublicDto.safeParse({ + data: [publicProvider], + nextCursor: null, + }); + + if (!result.success) throw result.error; + expect(result.data.data[0]).not.toHaveProperty('config'); + expect(JSON.stringify(result.data)).not.toContain('AAAAKEY'); + }); +}); diff --git a/packages/@n8n/api-types/src/dto/promotions/promotion-common.dto.ts b/packages/@n8n/api-types/src/dto/promotions/promotion-common.dto.ts new file mode 100644 index 00000000000..f359754dbd9 --- /dev/null +++ b/packages/@n8n/api-types/src/dto/promotions/promotion-common.dto.ts @@ -0,0 +1,8 @@ +import { z } from 'zod'; + +/** + * Display name of a provider, connection, or config. All three are `varchar(128)`, + * so cap the length here to reject over-long text with a 400 instead of a 500. + * Names do not have to be unique. + */ +export const promotionDisplayNameSchema = z.string().trim().min(1).max(128); diff --git a/packages/@n8n/api-types/src/dto/promotions/promotion-config.dto.ts b/packages/@n8n/api-types/src/dto/promotions/promotion-config.dto.ts new file mode 100644 index 00000000000..75b7e1b7eee --- /dev/null +++ b/packages/@n8n/api-types/src/dto/promotions/promotion-config.dto.ts @@ -0,0 +1,124 @@ +import { z } from 'zod'; + +import { promotionDisplayNameSchema } from './promotion-common.dto'; +import { Z } from '../../zod-class'; + +export const promotionDirectionSchema = z.enum(['apply', 'promote']); +export type PromotionDirection = z.infer; + +/** + * Every config carries a branch for its direction. A direction that is not set up + * has no config at all. Git sets no limit on branch names, so cap it here to keep + * the payload small. The backend still checks the name with Git. + */ +const branchNameSchema = z.string().trim().min(1).max(255); + +/** Apply imports the package from `branchName`. */ +export const promotionGitApplySettingsSchema = z + .object({ schemaVersion: z.literal(1), branchName: branchNameSchema }) + .strict(); + +/** + * Promote starts from `baseBranchName`. With branching off it pushes there + * directly. With branching on it creates the promotion branch from there. The + * branch it really pushed to comes back in the result and is never saved here. + * + * `createBranchOnPromotion` is always required. A write replaces the whole config, + * so a default here would let an unrelated edit reset it. The off-by-default + * choice lives in the UI toggle and the CLI examples instead. + */ +export const promotionGitPromoteSettingsSchema = z + .object({ + schemaVersion: z.literal(1), + baseBranchName: branchNameSchema, + createBranchOnPromotion: z.boolean(), + }) + .strict(); + +/** + * Settings schema for each direction. Use this map when the direction is known, + * such as when checking a stored row. A new provider type adds its own map, with + * its own fields and no branch name. + */ +export const promotionGitSettingsSchemas = { + apply: promotionGitApplySettingsSchema, + promote: promotionGitPromoteSettingsSchema, +} as const; + +export const promotionConfigSettingsSchema = z.union([ + promotionGitApplySettingsSchema, + promotionGitPromoteSettingsSchema, +]); +export type PromotionConfigSettings = z.infer; + +/* + * Request bodies for `PUT /connections/:id/configs/:direction`, which creates the + * config or replaces it. The direction is in the path, so the body needs no + * discriminator and each direction gets a plain object of its own. + * + * A write replaces the whole config. An omitted `name` resets it to the direction + * label, so a client changing only a branch has to resend the name it wants to + * keep. The connection-create payload reuses these same two schemas. + */ + +export class UpsertPromotionApplyConfigDto extends Z.class( + { + name: promotionDisplayNameSchema.optional(), + settings: promotionGitApplySettingsSchema, + }, + { strict: true }, +) {} + +export class UpsertPromotionPromoteConfigDto extends Z.class( + { + name: promotionDisplayNameSchema.optional(), + settings: promotionGitPromoteSettingsSchema, + }, + { strict: true }, +) {} + +const configPublicFields = { + /** + * Also names the local checkout directory. Stable while the config exists, and + * replaced by a new one if the direction is deleted and set up again. + */ + id: z.string(), + name: z.string(), + createdAt: z.string().datetime(), + updatedAt: z.string().datetime(), +}; + +/* + * Responses leave out the connection and the direction: both are the address the + * caller already used. + */ + +export const promotionApplyConfigPublicSchema = z.object({ + ...configPublicFields, + settings: promotionGitApplySettingsSchema, +}); + +export class PromotionApplyConfigPublicDto extends Z.class( + promotionApplyConfigPublicSchema.shape, +) {} + +export const promotionPromoteConfigPublicSchema = z.object({ + ...configPublicFields, + settings: promotionGitPromoteSettingsSchema, +}); + +export class PromotionPromoteConfigPublicDto extends Z.class( + promotionPromoteConfigPublicSchema.shape, +) {} + +/** + * Configs keyed by direction, so a client reads `configs.promote?.settings` + * without narrowing a union. A missing key means that direction is not set up. + */ +export const promotionConnectionConfigsPublicSchema = z.object({ + apply: promotionApplyConfigPublicSchema.optional(), + promote: promotionPromoteConfigPublicSchema.optional(), +}); +export type PromotionConnectionConfigsPublic = z.infer< + typeof promotionConnectionConfigsPublicSchema +>; diff --git a/packages/@n8n/api-types/src/dto/promotions/promotion-connection.dto.ts b/packages/@n8n/api-types/src/dto/promotions/promotion-connection.dto.ts new file mode 100644 index 00000000000..a2532b92b37 --- /dev/null +++ b/packages/@n8n/api-types/src/dto/promotions/promotion-connection.dto.ts @@ -0,0 +1,123 @@ +import { z } from 'zod'; + +import { promotionDisplayNameSchema } from './promotion-common.dto'; +import { + UpsertPromotionApplyConfigDto, + UpsertPromotionPromoteConfigDto, + promotionConnectionConfigsPublicSchema, +} from './promotion-config.dto'; +import { promotionProviderSummarySchema } from './promotion-provider.dto'; +import { Z } from '../../zod-class'; +import { publicApiPaginationSchema } from '../pagination/pagination.dto'; + +/** + * `instance` covers the whole instance. `projects` covers the projects linked to + * the connection. Only one instance connection can exist. + */ +export const promotionConnectionScopeSchema = z.enum(['instance', 'projects']); +export type PromotionConnectionScope = z.infer; + +/** + * Checks the shape only. The backend checks the URL itself: allowed protocols, + * accepted SSH forms, and no credentials in the URL. + */ +const remoteUrlSchema = z.string().trim().min(1); + +/** + * Plain Git points at a repository with its full SSH or HTTP(S) remote URL. A new + * provider type adds its own target here and needs no remote URL. + */ +export const promotionConnectionTargetSchema = z + .object({ schemaVersion: z.literal(1), remoteUrl: remoteUrlSchema }) + .strict(); +export type PromotionConnectionTarget = z.infer; + +export class CreatePromotionConnectionDto extends Z.class( + { + name: promotionDisplayNameSchema, + scope: promotionConnectionScopeSchema, + /** Providers are created on their own route. A connection only picks one. */ + providerId: z.string().min(1), + target: promotionConnectionTargetSchema, + /** + * Initial configs, keyed by direction, reusing the bodies of the config + * routes. Keying makes "one per direction" structural. A connection can start + * with no configs, which lets an admin read the provider's public key before + * the remote can be cloned. + */ + configs: z + .object({ + apply: UpsertPromotionApplyConfigDto.schema.optional(), + promote: UpsertPromotionPromoteConfigDto.schema.optional(), + }) + .strict() + .optional(), + }, + { strict: true }, +) {} + +/** + * Connection fields only. `scope` cannot change, and configs, project links, and + * package operations each have their own route, so a strict shape rejects them + * here. + */ +export class UpdatePromotionConnectionDto extends Z.class( + { + name: promotionDisplayNameSchema.optional(), + target: promotionConnectionTargetSchema.optional(), + providerId: z.string().min(1).optional(), + }, + { strict: true }, +) {} + +/** + * The `providerId` filter shows which connections a provider edit affects. The + * instance settings page asks for `scope=instance` rather than taking whichever + * connection comes first. + */ +export class ListPromotionConnectionsQueryDto extends Z.class({ + limit: publicApiPaginationSchema.limit, + cursor: z.string().optional(), + scope: promotionConnectionScopeSchema.optional(), + providerId: z.string().optional(), +}) {} + +/** + * There are at most two configs, so they are embedded instead of paginated, keyed + * by direction to match the request side. + * + * The provider is embedded as a summary, which names it but leaves out its public + * config. Detail and list share this schema, so the full form would put the SSH + * public key in every row of the connection list. Read the key from the provider + * detail route. + */ +export const promotionConnectionPublicSchema = z.object({ + id: z.string(), + name: z.string(), + scope: promotionConnectionScopeSchema, + target: promotionConnectionTargetSchema, + provider: promotionProviderSummarySchema, + configs: promotionConnectionConfigsPublicSchema, + createdAt: z.string().datetime(), + updatedAt: z.string().datetime(), +}); + +export class PromotionConnectionPublicDto extends Z.class(promotionConnectionPublicSchema.shape) {} + +export class PromotionConnectionListPublicDto extends Z.class({ + data: z.array(promotionConnectionPublicSchema), + nextCursor: z.string().nullable(), +}) {} + +export const promotionConnectionProjectPublicSchema = z.object({ + projectId: z.string(), + connectionId: z.string(), +}); + +export class PromotionConnectionProjectPublicDto extends Z.class( + promotionConnectionProjectPublicSchema.shape, +) {} + +export class PromotionConnectionProjectListPublicDto extends Z.class({ + projectIds: z.array(z.string()), +}) {} diff --git a/packages/@n8n/api-types/src/dto/promotions/promotion-operations.dto.ts b/packages/@n8n/api-types/src/dto/promotions/promotion-operations.dto.ts new file mode 100644 index 00000000000..503023abfdf --- /dev/null +++ b/packages/@n8n/api-types/src/dto/promotions/promotion-operations.dto.ts @@ -0,0 +1,89 @@ +import { z } from 'zod'; + +import { Z } from '../../zod-class'; + +export class PromotePackageDto extends Z.class( + { + commitMessage: z.string().trim().min(1).max(1000), + force: z.boolean().optional(), + }, + { strict: true }, +) {} + +/** The Git part of an operation result. A new transport returns its own instead. */ +export const promotionGitResultSchema = z.object({ + commitSha: z.string(), + /** + * For Promote, the branch it pushed to. That is the configured base branch for a + * direct push, or the new promotion branch once branching lands. For Apply, the + * branch the package came from. + */ + branchName: z.string(), +}); + +const count = () => z.number().int().nonnegative(); + +/** What ended up in the exported package, after folders and auto-inclusion. */ +export const promotePackageCountsSchema = z.object({ + workflows: count(), + folders: count(), + credentials: count(), + dataTables: count(), + variables: count(), + tags: count(), +}); + +/** Describes the instance that handled the request, not any other instance. */ +export const promotePackageResultSchema = z.object({ + connectionId: z.string(), + configId: z.string(), + counts: promotePackageCountsSchema, + git: promotionGitResultSchema, +}); + +export class PromotePackageResultDto extends Z.class(promotePackageResultSchema.shape) {} + +/** What the import changed. Partial results are visible here too. */ +export const applyPackageCountsSchema = z.object({ + projects: z.object({ created: count(), updated: count(), skipped: count(), deleted: count() }), + folders: z.object({ created: count(), skipped: count(), removed: count() }), + workflows: z.object({ + created: count(), + updated: count(), + skipped: count(), + archived: count(), + deleted: count(), + publishing: z.object({ + published: count(), + unpublished: count(), + unchanged: count(), + blocked: count(), + failed: count(), + }), + }), + credentials: z.object({ matched: count(), stubbed: count() }), + dataTables: z.object({ matched: count(), created: count() }), + variables: z.object({ + matched: count(), + created: count(), + updated: count(), + stubbed: count(), + missing: count(), + }), + tags: z.object({ + matched: count(), + created: count(), + renamed: count(), + reconciled: count(), + skipped: count(), + }), +}); + +export const applyPackageResultSchema = z.object({ + connectionId: z.string(), + configId: z.string(), + counts: applyPackageCountsSchema, + git: promotionGitResultSchema, +}); + +export class ApplyPackageResultDto extends Z.class(applyPackageResultSchema.shape) {} diff --git a/packages/@n8n/api-types/src/dto/promotions/promotion-provider.dto.ts b/packages/@n8n/api-types/src/dto/promotions/promotion-provider.dto.ts new file mode 100644 index 00000000000..790c221c6e0 --- /dev/null +++ b/packages/@n8n/api-types/src/dto/promotions/promotion-provider.dto.ts @@ -0,0 +1,177 @@ +import { z } from 'zod'; + +import { promotionDisplayNameSchema } from './promotion-common.dto'; +import { Z } from '../../zod-class'; +import { publicApiPaginationSchema } from '../pagination/pagination.dto'; + +export const promotionProviderTypeSchema = z.enum(['git']); +export type PromotionProviderType = z.infer; + +/** `token` is an HTTP(S) username and password, not a Git host API token. */ +export const promotionProviderAuthTypeSchema = z.enum(['ssh-key', 'token']); +export type PromotionProviderAuthType = z.infer; + +/** Key algorithms the backend can generate for an `ssh-key` provider. */ +export const promotionSshKeyTypeSchema = z.enum(['ed25519', 'rsa']); +export type PromotionSshKeyType = z.infer; + +/** + * The backend writes the public key and key type when it generates the key pair. + * No request schema accepts them. + */ +export const promotionGitSshKeyConfigSchema = z + .object({ + schemaVersion: z.literal(1), + publicKey: z.string().min(1), + keyType: promotionSshKeyTypeSchema, + }) + .strict(); + +/** HTTP(S) keeps the username with the password, so there is nothing public. */ +export const promotionGitTokenConfigSchema = z.object({ schemaVersion: z.literal(1) }).strict(); + +/** + * Config schema for each auth type. Use this map once the auth type is known: the + * union below accepts either shape and cannot tell them apart. A new provider type + * adds its own map. + */ +export const promotionGitConfigSchemas = { + 'ssh-key': promotionGitSshKeyConfigSchema, + token: promotionGitTokenConfigSchema, +} as const; + +export const promotionProviderConfigSchema = z.union([ + promotionGitSshKeyConfigSchema, + promotionGitTokenConfigSchema, +]); +export type PromotionProviderConfig = z.infer; + +// Trim the username, so a padded value is never saved. +const promotionUsernameSchema = z.string().trim().min(1); +// Do not trim the password. A token is saved exactly as sent, and a blank one is rejected. +const promotionSecretSchema = z.string().min(1); + +/* + * The auth payload carries its own `authType`, so it is a discriminated union and + * a request cannot pair one auth type with another's fields. Keeping the + * discriminator inside `auth` rather than beside it is what lets the request body + * stay a plain object: only a field can be a union here, not the body root. + * + * Responses have no `auth` at all, so they report `authType` at the top level. + */ + +/** The backend generates the key pair. The caller only picks the algorithm. */ +export const promotionGitSshKeyAuthInputSchema = z + .object({ + authType: z.literal('ssh-key'), + keyType: promotionSshKeyTypeSchema.default('ed25519'), + }) + .strict(); + +/** + * The same payload for an update, but `keyType` has no default. An omitted value + * stays omitted, so the service can rotate the key with the algorithm the provider + * already uses. A default here would turn an `rsa` provider into an `ed25519` one + * on an edit that never mentioned the key. + */ +export const promotionGitSshKeyAuthUpdateSchema = z + .object({ + authType: z.literal('ssh-key'), + keyType: promotionSshKeyTypeSchema.optional(), + }) + .strict(); + +/** Username and password are required together, on create and on update. */ +export const promotionGitTokenAuthInputSchema = z + .object({ + authType: z.literal('token'), + username: promotionUsernameSchema, + password: promotionSecretSchema, + }) + .strict(); + +export const promotionProviderAuthInputSchema = z.discriminatedUnion('authType', [ + promotionGitSshKeyAuthInputSchema, + promotionGitTokenAuthInputSchema, +]); +export type PromotionProviderAuthInput = z.infer; + +export const promotionProviderAuthUpdateSchema = z.discriminatedUnion('authType', [ + promotionGitSshKeyAuthUpdateSchema, + promotionGitTokenAuthInputSchema, +]); +export type PromotionProviderAuthUpdate = z.infer; + +/** + * The auth type is stated once, inside `auth`, so it always matches the credentials + * beside it. The service reads it from there for the `authType` column. + * + * `git` is the only provider type today, so its two auth methods are the only ones + * the union accepts. A second provider type brings its own auth variants, and the + * pairing of provider type to auth method becomes a choice to model here. + */ +export class CreatePromotionProviderDto extends Z.class( + { + name: promotionDisplayNameSchema, + type: promotionProviderTypeSchema, + auth: promotionProviderAuthInputSchema, + }, + { strict: true }, +) {} + +/** + * `type` cannot change and `config` holds generated key material, so a strict shape + * rejects both. Leaving out `auth` keeps the stored credentials. Sending it replaces + * them. + * + * `auth.authType` states which credentials are being sent, and the auth type itself + * cannot change, so the service compares it with the stored one and rejects a + * mismatch. It also reads an omitted `keyType` from the provider's current key type, + * so rotating an `rsa` key keeps it `rsa`. + */ +export class UpdatePromotionProviderDto extends Z.class( + { + name: promotionDisplayNameSchema.optional(), + auth: promotionProviderAuthUpdateSchema.optional(), + }, + { strict: true }, +) {} + +/** Leaves out `auth`, which is never returned. */ +export const promotionProviderPublicSchema = z.object({ + id: z.string(), + name: z.string(), + type: promotionProviderTypeSchema, + authType: promotionProviderAuthTypeSchema, + config: promotionProviderConfigSchema, + createdAt: z.string().datetime(), + updatedAt: z.string().datetime(), +}); + +export class PromotionProviderPublicDto extends Z.class(promotionProviderPublicSchema.shape) {} + +/** + * A provider without its public config. Used for list rows and inside a + * connection, neither of which shows the SSH public key. Only the provider detail + * route does. + */ +export const promotionProviderSummarySchema = promotionProviderPublicSchema.omit({ config: true }); + +export class PromotionProviderListPublicDto extends Z.class({ + data: z.array(promotionProviderSummarySchema), + nextCursor: z.string().nullable(), +}) {} + +/** + * Returns the generated public key on its own, so a client does not have to read + * the config variant. `token` providers have none. + */ +export class PromotionProviderCreatedPublicDto extends Z.class({ + provider: promotionProviderPublicSchema, + publicKey: z.string().nullable(), +}) {} + +export class ListPromotionProvidersQueryDto extends Z.class({ + limit: publicApiPaginationSchema.limit, + cursor: z.string().optional(), +}) {} diff --git a/packages/@n8n/db/src/migrations/common/1788759351166-CreatePromotionsTables.ts b/packages/@n8n/db/src/migrations/common/1788759351166-CreatePromotionsTables.ts new file mode 100644 index 00000000000..9e2c22ced70 --- /dev/null +++ b/packages/@n8n/db/src/migrations/common/1788759351166-CreatePromotionsTables.ts @@ -0,0 +1,144 @@ +import type { MigrationContext, ReversibleMigration } from '../migration-types'; + +const PROVIDER_TABLE = 'promotion_provider'; +const CONNECTION_TABLE = 'promotion_connection'; +const CONNECTION_PROJECT_TABLE = 'promotion_connection_project'; +const CONFIG_TABLE = 'promotion_config'; + +/** + * Promotions keeps Git credentials on a provider, the remote to talk to on a + * connection, and the settings for one direction in a config. Code validates the + * JSON payloads, so a new provider type can reuse these tables. + */ +export class CreatePromotionsTables1788759351166 implements ReversibleMigration { + async up(context: MigrationContext) { + await this.createProviderTable(context); + await this.createConnectionTable(context); + await this.createConnectionProjectTable(context); + await this.createConfigTable(context); + } + + async down({ schemaBuilder: { dropTable } }: MigrationContext) { + await dropTable(CONFIG_TABLE); + await dropTable(CONNECTION_PROJECT_TABLE); + await dropTable(CONNECTION_TABLE); + await dropTable(PROVIDER_TABLE); + } + + private async createProviderTable({ schemaBuilder: { createTable, column } }: MigrationContext) { + await createTable(PROVIDER_TABLE).withColumns( + column('id').varchar(36).primary, + column('name').varchar(128).notNull, + column('type') + .varchar(32) + .notNull.withEnumCheck(['git']) + .comment('PromotionProviderType enum: "git"'), + column('authType') + .varchar(32) + .notNull.withEnumCheck(['ssh-key', 'token']) + .comment( + 'PromotionProviderAuthType enum: "ssh-key", "token". "token" is an HTTP(S) username and password.', + ), + column('config').json.notNull.comment( + 'Non-secret provider settings, with their own schemaVersion. Validated in code.', + ), + column('auth').text.notNull.comment('Encrypted credentials. Never sent to a client.'), + ).withTimestamps; + } + + private async createConnectionTable({ + schemaBuilder: { createTable, createIndex, column }, + tablePrefix, + }: MigrationContext) { + await createTable(CONNECTION_TABLE) + .withColumns( + column('id').varchar(36).primary, + column('name').varchar(128).notNull, + column('providerId') + .varchar(36) + .notNull.comment('The provider whose credentials this connection uses.'), + column('scope') + .varchar(16) + .notNull.withEnumCheck(['instance', 'projects']) + .comment( + 'PromotionConnectionScope enum: "instance", "projects". Cannot change after creation.', + ), + column('target').json.notNull.comment( + 'Where to push and pull, with its own schemaVersion. Plain Git stores the full remote URL.', + ), + ) + // RESTRICT, not CASCADE: one provider can serve many connections, so deleting + // it must not take their configuration with it. + .withTimestamps.withForeignKey('providerId', { + tableName: PROVIDER_TABLE, + columnName: 'id', + onDelete: 'RESTRICT', + name: `FK_${tablePrefix}promotion_connection_providerId`, + }) + // Index the foreign key, for listing a provider's connections and for the + // check on delete. + .withIndexOn(['providerId']); + + // Only one instance connection is allowed, but any number of project ones. + // A count check in code would let two slip through at the same time. + await createIndex(CONNECTION_TABLE, ['scope'], true, undefined, '"scope" = \'instance\''); + } + + private async createConnectionProjectTable({ + schemaBuilder: { createTable, column }, + tablePrefix, + }: MigrationContext) { + // projectId is the primary key, so a project links to one connection at most. + // A connection can hold many links. Both foreign keys cascade, so a link goes + // away when either side is deleted. + await createTable(CONNECTION_PROJECT_TABLE) + .withColumns( + column('projectId').varchar(36).primary, + column('connectionId').varchar(36).notNull, + ) + .withTimestamps.withForeignKey('projectId', { + tableName: 'project', + columnName: 'id', + onDelete: 'CASCADE', + name: `FK_${tablePrefix}promotion_connection_project_projectId`, + }) + .withForeignKey('connectionId', { + tableName: CONNECTION_TABLE, + columnName: 'id', + onDelete: 'CASCADE', + name: `FK_${tablePrefix}promotion_connection_project_connectionId`, + }) + // Index the foreign key, for listing a connection's projects and for the + // cascade delete. + .withIndexOn(['connectionId']); + } + + private async createConfigTable({ + schemaBuilder: { createTable, createIndex, column }, + tablePrefix, + }: MigrationContext) { + await createTable(CONFIG_TABLE) + .withColumns( + column('id').varchar(36).primary.comment('Also the name of the local checkout directory.'), + column('connectionId').varchar(36).notNull, + column('name').varchar(128).notNull, + column('direction') + .varchar(16) + .notNull.withEnumCheck(['apply', 'promote']) + .comment('PromotionDirection enum: "apply", "promote". Cannot change after creation.'), + column('settings').json.notNull.comment( + 'Branch settings for this direction, with their own schemaVersion. Validated in code.', + ), + ) + .withTimestamps.withForeignKey('connectionId', { + tableName: CONNECTION_TABLE, + columnName: 'id', + onDelete: 'CASCADE', + name: `FK_${tablePrefix}promotion_config_connectionId`, + }); + + // One config per direction. This index also covers connectionId lookups and the + // cascade delete, so connectionId needs no index of its own. + await createIndex(CONFIG_TABLE, ['connectionId', 'direction'], true); + } +} diff --git a/packages/cli/src/modules/promotions.ee/database/entities/promotion-config.entity.ts b/packages/cli/src/modules/promotions.ee/database/entities/promotion-config.entity.ts new file mode 100644 index 00000000000..aedc2e57983 --- /dev/null +++ b/packages/cli/src/modules/promotions.ee/database/entities/promotion-config.entity.ts @@ -0,0 +1,32 @@ +import type { PromotionConfigSettings, PromotionDirection } from '@n8n/api-types'; +import { JsonColumn, WithTimestampsAndStringId } from '@n8n/db'; +import { Column, Entity, Index, JoinColumn, ManyToOne, type Relation } from '@n8n/typeorm'; + +import { PromotionConnection } from './promotion-connection.entity'; + +/** + * Settings for one direction of one connection. The generated `id` also names the + * local checkout directory. + */ +@Entity('promotion_config') +// One config for each direction. This index also covers lookups by connectionId. +@Index(['connectionId', 'direction'], { unique: true }) +export class PromotionConfig extends WithTimestampsAndStringId { + @Column({ type: 'varchar', length: 36 }) + connectionId: string; + + /** Names are not unique. */ + @Column({ type: 'varchar', length: 128 }) + name: string; + + @Column({ type: 'varchar', length: 16 }) + direction: PromotionDirection; + + /** Branch settings for {@link direction}, with their own `schemaVersion`. */ + @JsonColumn() + settings: PromotionConfigSettings; + + @ManyToOne(() => PromotionConnection, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'connectionId' }) + connection: Relation; +} diff --git a/packages/cli/src/modules/promotions.ee/database/entities/promotion-connection-project.entity.ts b/packages/cli/src/modules/promotions.ee/database/entities/promotion-connection-project.entity.ts new file mode 100644 index 00000000000..d49f02da8ec --- /dev/null +++ b/packages/cli/src/modules/promotions.ee/database/entities/promotion-connection-project.entity.ts @@ -0,0 +1,36 @@ +import { WithTimestamps } from '@n8n/db'; +import { + Column, + Entity, + Index, + JoinColumn, + ManyToOne, + PrimaryColumn, + type Relation, +} from '@n8n/typeorm'; + +import { PromotionConnection } from './promotion-connection.entity'; + +/** + * Links a project to a promotion connection. The `projectId` primary key keeps a + * project on one connection at most. A connection can hold many links, or none. + * Which projects and connections are eligible spans tables, so the service checks + * that rather than the schema. + * + * The foreign key to `project` is declared in the migration and not as a + * `@ManyToOne`, to keep this module apart from the core `Project` entity. This + * follows the workflow-reviews pattern. + */ +@Entity('promotion_connection_project') +export class PromotionConnectionProject extends WithTimestamps { + @PrimaryColumn({ type: 'varchar', length: 36 }) + projectId: string; + + @Index() + @Column({ type: 'varchar', length: 36 }) + connectionId: string; + + @ManyToOne(() => PromotionConnection, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'connectionId' }) + connection: Relation; +} diff --git a/packages/cli/src/modules/promotions.ee/database/entities/promotion-connection.entity.ts b/packages/cli/src/modules/promotions.ee/database/entities/promotion-connection.entity.ts new file mode 100644 index 00000000000..06c2a5d67c9 --- /dev/null +++ b/packages/cli/src/modules/promotions.ee/database/entities/promotion-connection.entity.ts @@ -0,0 +1,36 @@ +import type { PromotionConnectionScope, PromotionConnectionTarget } from '@n8n/api-types'; +import { JsonColumn, WithTimestampsAndStringId } from '@n8n/db'; +import { Column, Entity, Index, JoinColumn, ManyToOne, type Relation } from '@n8n/typeorm'; + +import { PromotionProvider } from './promotion-provider.entity'; + +/** + * One remote, reached with one provider's credentials. The provider, remote URL, + * and name are not unique, because a provider can serve several connections. + */ +@Entity('promotion_connection') +// Only one instance connection is allowed, but any number of project ones. +@Index(['scope'], { unique: true, where: '"scope" = \'instance\'' }) +export class PromotionConnection extends WithTimestampsAndStringId { + @Column({ type: 'varchar', length: 128 }) + name: string; + + @Index() + @Column({ type: 'varchar', length: 36 }) + providerId: string; + + /** + * Cannot change after creation. A change would quietly move the instance + * fallback or break existing project links. Create a new connection instead. + */ + @Column({ type: 'varchar', length: 16 }) + scope: PromotionConnectionScope; + + /** Where to push and pull, with its own `schemaVersion`. */ + @JsonColumn() + target: PromotionConnectionTarget; + + @ManyToOne(() => PromotionProvider, { onDelete: 'RESTRICT' }) + @JoinColumn({ name: 'providerId' }) + provider: Relation; +} diff --git a/packages/cli/src/modules/promotions.ee/database/entities/promotion-provider.entity.ts b/packages/cli/src/modules/promotions.ee/database/entities/promotion-provider.entity.ts new file mode 100644 index 00000000000..86349237b26 --- /dev/null +++ b/packages/cli/src/modules/promotions.ee/database/entities/promotion-provider.entity.ts @@ -0,0 +1,36 @@ +import type { + PromotionProviderAuthType, + PromotionProviderConfig, + PromotionProviderType, +} from '@n8n/api-types'; +import { JsonColumn, WithTimestampsAndStringId } from '@n8n/db'; +import { Column, Entity } from '@n8n/typeorm'; + +/** + * Credentials for a remote, shared by any number of connections. The database + * limits which {@link type} and {@link authType} values are allowed. + */ +@Entity('promotion_provider') +export class PromotionProvider extends WithTimestampsAndStringId { + @Column({ type: 'varchar', length: 128 }) + name: string; + + @Column({ type: 'varchar', length: 32 }) + type: PromotionProviderType; + + /** `token` is an HTTP(S) username and password, not a Git host API token. */ + @Column({ type: 'varchar', length: 32 }) + authType: PromotionProviderAuthType; + + /** + * Non-secret settings, with their own `schemaVersion`. This type says what we + * write, not what an older row holds, so check a loaded row against the schema + * for {@link authType}. The same applies to the other JSON columns in this module. + */ + @JsonColumn() + config: PromotionProviderConfig; + + /** Encrypted credentials. Never sent to a client. */ + @Column({ type: 'text' }) + auth: string; +} diff --git a/packages/cli/src/services/ownership-transfer/ownership-transfer.manifest.json b/packages/cli/src/services/ownership-transfer/ownership-transfer.manifest.json index 4897c3054fc..b0374a3a7b1 100644 --- a/packages/cli/src/services/ownership-transfer/ownership-transfer.manifest.json +++ b/packages/cli/src/services/ownership-transfer/ownership-transfer.manifest.json @@ -88,6 +88,11 @@ "name": "GitConnectionProject", "path": "packages/cli/src/modules/git-connections.ee/database/entities/git-connection-project.entity.ts", "reason": "Project-scoped git configuration (which connection the project is linked to), not user data. Dropped via the projectId FK cascade when the source project itself is deleted, as in ownership-transfer flows." + }, + { + "name": "PromotionConnectionProject", + "path": "packages/cli/src/modules/promotions.ee/database/entities/promotion-connection-project.entity.ts", + "reason": "Promotion configuration for the project (which connection it is linked to), not user data. Dropped via the projectId FK cascade when the source project is deleted, as in ownership-transfer flows. Replaces GitConnectionProject, which goes away with the old tables." } ] } diff --git a/packages/cli/test/migration/1788759351166-create-promotions-tables.test.ts b/packages/cli/test/migration/1788759351166-create-promotions-tables.test.ts new file mode 100644 index 00000000000..c5990789f83 --- /dev/null +++ b/packages/cli/test/migration/1788759351166-create-promotions-tables.test.ts @@ -0,0 +1,307 @@ +import { + createTestMigrationContext, + initDbUpToMigration, + runSingleMigration, + type TestMigrationContext, +} from '@n8n/backend-test-utils'; +import { DbConnection } from '@n8n/db'; +import { Container } from '@n8n/di'; +import { DataSource } from '@n8n/typeorm'; +import { randomUUID } from 'node:crypto'; + +const MIGRATION_NAME = 'CreatePromotionsTables1788759351166'; + +const PROVIDER_TABLE = 'promotion_provider'; +const CONNECTION_TABLE = 'promotion_connection'; +const LINK_TABLE = 'promotion_connection_project'; +const CONFIG_TABLE = 'promotion_config'; + +describe('CreatePromotionsTables migration', () => { + let dataSource: DataSource; + + beforeAll(async () => { + const dbConnection = Container.get(DbConnection); + await dbConnection.init(); + dataSource = Container.get(DataSource); + const context = createTestMigrationContext(dataSource); + await context.queryRunner.clearDatabase(); + await context.queryRunner.release(); + await initDbUpToMigration(MIGRATION_NAME); + await runSingleMigration(MIGRATION_NAME); + }); + + afterAll(async () => { + await Container.get(DbConnection).close(); + }); + + async function insertProvider( + context: TestMigrationContext, + id: string, + overrides: { type?: string; authType?: string } = {}, + ) { + const now = new Date(); + await context.runQuery( + `INSERT INTO ${context.escape.tableName(PROVIDER_TABLE)} + ("id", "name", "type", "authType", "config", "auth", "createdAt", "updatedAt") + VALUES (:id, :name, :type, :authType, :config, :auth, :createdAt, :updatedAt)`, + { + id, + name: 'Provider', + type: overrides.type ?? 'git', + authType: overrides.authType ?? 'ssh-key', + config: JSON.stringify({ + schemaVersion: 1, + publicKey: 'ssh-ed25519 KEY', + keyType: 'ed25519', + }), + auth: 'encrypted', + createdAt: now, + updatedAt: now, + }, + ); + } + + async function insertConnection( + context: TestMigrationContext, + id: string, + providerId: string, + scope = 'projects', + ) { + const now = new Date(); + await context.runQuery( + `INSERT INTO ${context.escape.tableName(CONNECTION_TABLE)} + ("id", "name", "providerId", "scope", "target", "createdAt", "updatedAt") + VALUES (:id, :name, :providerId, :scope, :target, :createdAt, :updatedAt)`, + { + id, + name: 'Connection', + providerId, + scope, + target: JSON.stringify({ schemaVersion: 1, remoteUrl: 'git@example.com:team/repo.git' }), + createdAt: now, + updatedAt: now, + }, + ); + } + + async function insertProject(context: TestMigrationContext, id: string) { + const now = new Date(); + await context.runQuery( + `INSERT INTO ${context.escape.tableName('project')} + ("id", "name", "type", "customTelemetryTags", "createdAt", "updatedAt") + VALUES (:id, :name, :type, :tags, :createdAt, :updatedAt)`, + { id, name: 'Project', type: 'team', tags: '[]', createdAt: now, updatedAt: now }, + ); + } + + async function insertLink( + context: TestMigrationContext, + projectId: string, + connectionId: string, + ) { + const now = new Date(); + await context.runQuery( + `INSERT INTO ${context.escape.tableName(LINK_TABLE)} + ("projectId", "connectionId", "createdAt", "updatedAt") + VALUES (:projectId, :connectionId, :createdAt, :updatedAt)`, + { projectId, connectionId, createdAt: now, updatedAt: now }, + ); + } + + async function insertConfig( + context: TestMigrationContext, + id: string, + connectionId: string, + direction: string, + ) { + const now = new Date(); + await context.runQuery( + `INSERT INTO ${context.escape.tableName(CONFIG_TABLE)} + ("id", "connectionId", "name", "direction", "settings", "createdAt", "updatedAt") + VALUES (:id, :connectionId, :name, :direction, :settings, :createdAt, :updatedAt)`, + { + id, + connectionId, + name: direction, + direction, + settings: JSON.stringify( + direction === 'apply' + ? { schemaVersion: 1, branchName: 'dev' } + : { schemaVersion: 1, baseBranchName: 'staging', createBranchOnPromotion: false }, + ), + createdAt: now, + updatedAt: now, + }, + ); + } + + async function countRows( + context: TestMigrationContext, + table: string, + where: string, + params: object, + ) { + const rows = await context.runQuery>( + `SELECT COUNT(*) as c FROM ${context.escape.tableName(table)} WHERE ${where}`, + params, + ); + return Number(rows[0].c); + } + + describe('Up migration', () => { + it('allows one instance connection but any number of project connections', async () => { + const context = createTestMigrationContext(dataSource); + const providerId = randomUUID(); + await insertProvider(context, providerId); + await insertConnection(context, randomUUID(), providerId, 'instance'); + + await expect( + insertConnection(context, randomUUID(), providerId, 'instance'), + ).rejects.toThrow(); + + await insertConnection(context, randomUUID(), providerId, 'projects'); + await insertConnection(context, randomUUID(), providerId, 'projects'); + expect( + await countRows(context, CONNECTION_TABLE, '"scope" = :scope', { scope: 'projects' }), + ).toBe(2); + await context.queryRunner.release(); + }); + + it('rejects values outside the type, auth type, scope, and direction enums', async () => { + const context = createTestMigrationContext(dataSource); + await expect(insertProvider(context, randomUUID(), { type: 'gitlab' })).rejects.toThrow(); + await expect(insertProvider(context, randomUUID(), { authType: 'oauth2' })).rejects.toThrow(); + + const providerId = randomUUID(); + await insertProvider(context, providerId); + await expect( + insertConnection(context, randomUUID(), providerId, 'workflows'), + ).rejects.toThrow(); + + const connectionId = randomUUID(); + await insertConnection(context, connectionId, providerId); + await expect(insertConfig(context, randomUUID(), connectionId, 'sync')).rejects.toThrow(); + await context.queryRunner.release(); + }); + + it('refuses to delete a provider that a connection still uses', async () => { + const context = createTestMigrationContext(dataSource); + const providerId = randomUUID(); + await insertProvider(context, providerId); + await insertConnection(context, randomUUID(), providerId); + + await expect( + context.runQuery( + `DELETE FROM ${context.escape.tableName(PROVIDER_TABLE)} WHERE "id" = :id`, + { id: providerId }, + ), + ).rejects.toThrow(); + await context.queryRunner.release(); + }); + + it('allows one config for each direction and rejects a repeated direction', async () => { + const context = createTestMigrationContext(dataSource); + const providerId = randomUUID(); + const connectionId = randomUUID(); + await insertProvider(context, providerId); + await insertConnection(context, connectionId, providerId); + await insertConfig(context, randomUUID(), connectionId, 'apply'); + await insertConfig(context, randomUUID(), connectionId, 'promote'); + + await expect(insertConfig(context, randomUUID(), connectionId, 'apply')).rejects.toThrow(); + expect( + await countRows(context, CONFIG_TABLE, '"connectionId" = :connectionId', { connectionId }), + ).toBe(2); + await context.queryRunner.release(); + }); + + it('links a project to one connection at most', async () => { + const context = createTestMigrationContext(dataSource); + const providerId = randomUUID(); + const connectionId = randomUUID(); + const otherConnectionId = randomUUID(); + const projectId = randomUUID(); + await insertProvider(context, providerId); + await insertConnection(context, connectionId, providerId); + await insertConnection(context, otherConnectionId, providerId); + await insertProject(context, projectId); + await insertLink(context, projectId, connectionId); + + await expect(insertLink(context, projectId, otherConnectionId)).rejects.toThrow(); + await context.queryRunner.release(); + }); + + it('deletes the configs and links of a deleted connection, but keeps its provider', async () => { + const context = createTestMigrationContext(dataSource); + const providerId = randomUUID(); + const connectionId = randomUUID(); + const projectId = randomUUID(); + await insertProvider(context, providerId); + await insertConnection(context, connectionId, providerId); + await insertProject(context, projectId); + await insertLink(context, projectId, connectionId); + await insertConfig(context, randomUUID(), connectionId, 'apply'); + + await context.runQuery( + `DELETE FROM ${context.escape.tableName(CONNECTION_TABLE)} WHERE "id" = :id`, + { id: connectionId }, + ); + + expect( + await countRows(context, CONFIG_TABLE, '"connectionId" = :connectionId', { connectionId }), + ).toBe(0); + expect(await countRows(context, LINK_TABLE, '"projectId" = :projectId', { projectId })).toBe( + 0, + ); + expect(await countRows(context, PROVIDER_TABLE, '"id" = :id', { id: providerId })).toBe(1); + await context.queryRunner.release(); + }); + + it('deletes only the link of a deleted project, not the connection', async () => { + const context = createTestMigrationContext(dataSource); + const providerId = randomUUID(); + const connectionId = randomUUID(); + const projectId = randomUUID(); + await insertProvider(context, providerId); + await insertConnection(context, connectionId, providerId); + await insertProject(context, projectId); + await insertLink(context, projectId, connectionId); + + await context.runQuery( + `DELETE FROM ${context.escape.tableName('project')} WHERE "id" = :id`, + { id: projectId }, + ); + + expect(await countRows(context, LINK_TABLE, '"projectId" = :projectId', { projectId })).toBe( + 0, + ); + expect(await countRows(context, CONNECTION_TABLE, '"id" = :id', { id: connectionId })).toBe( + 1, + ); + await context.queryRunner.release(); + }); + }); + + describe('Down migration', () => { + it('drops the four tables and can be applied again', async () => { + const tables = [PROVIDER_TABLE, CONNECTION_TABLE, LINK_TABLE, CONFIG_TABLE]; + await dataSource.undoLastMigration({ transaction: 'each' }); + + const context = createTestMigrationContext(dataSource); + for (const table of tables) { + expect(await context.queryRunner.hasTable(`${context.tablePrefix}${table}`)).toBe(false); + } + await context.queryRunner.release(); + + await runSingleMigration(MIGRATION_NAME); + + const afterRerun = createTestMigrationContext(dataSource); + for (const table of tables) { + expect(await afterRerun.queryRunner.hasTable(`${afterRerun.tablePrefix}${table}`)).toBe( + true, + ); + } + await afterRerun.queryRunner.release(); + }); + }); +});