feat(core): Add promotion schema entities and DTOs (#37939)

This commit is contained in:
Kai 2026-09-09 07:17:09 +00:00 committed by GitHub
parent a0535781a7
commit e77b7bb12f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
29 changed files with 2307 additions and 2 deletions

View File

@ -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

View File

@ -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

View File

@ -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)

View File

@ -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)

View File

@ -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)

View File

@ -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)

View File

@ -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

View File

@ -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

View File

@ -0,0 +1,71 @@
# promotion_config
## Description
<details>
<summary><strong>Table Definition</strong></summary>
```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)
```
</details>
## 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)

View File

@ -0,0 +1,90 @@
# promotion_connection
## Description
<details>
<summary><strong>Table Definition</strong></summary>
```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)
```
</details>
## 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)

View File

@ -0,0 +1,77 @@
# promotion_connection_project
## Description
<details>
<summary><strong>Table Definition</strong></summary>
```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)
```
</details>
## 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)

View File

@ -0,0 +1,72 @@
# promotion_provider
## Description
<details>
<summary><strong>Table Definition</strong></summary>
```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')))
```
</details>
## 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)

View File

@ -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';

View File

@ -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);
});
});

View File

@ -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');
});
});

View File

@ -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);
});
});

View File

@ -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');
});
});

View File

@ -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);

View File

@ -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<typeof promotionDirectionSchema>;
/**
* 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<typeof promotionConfigSettingsSchema>;
/*
* 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
>;

View File

@ -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<typeof promotionConnectionScopeSchema>;
/**
* 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<typeof promotionConnectionTargetSchema>;
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()),
}) {}

View File

@ -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) {}

View File

@ -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<typeof promotionProviderTypeSchema>;
/** `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<typeof promotionProviderAuthTypeSchema>;
/** Key algorithms the backend can generate for an `ssh-key` provider. */
export const promotionSshKeyTypeSchema = z.enum(['ed25519', 'rsa']);
export type PromotionSshKeyType = z.infer<typeof promotionSshKeyTypeSchema>;
/**
* 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<typeof promotionProviderConfigSchema>;
// 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<typeof promotionProviderAuthInputSchema>;
export const promotionProviderAuthUpdateSchema = z.discriminatedUnion('authType', [
promotionGitSshKeyAuthUpdateSchema,
promotionGitTokenAuthInputSchema,
]);
export type PromotionProviderAuthUpdate = z.infer<typeof promotionProviderAuthUpdateSchema>;
/**
* 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(),
}) {}

View File

@ -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);
}
}

View File

@ -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<PromotionConnection>;
}

View File

@ -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<PromotionConnection>;
}

View File

@ -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<PromotionProvider>;
}

View File

@ -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;
}

View File

@ -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."
}
]
}

View File

@ -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<Array<{ c: number }>>(
`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();
});
});
});