feat(core): Add promotion run and review data model (no-changelog) (#40794)

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Sandra Zollner 2026-10-16 11:28:06 +00:00 committed by GitHub
parent c70b5d49b3
commit 64d8a3ad19
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
23 changed files with 1545 additions and 7 deletions

View File

@ -121,6 +121,8 @@ Auto-generated from the PostgreSQL migrations in @n8n/db. Do not edit by hand.
| [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.promotion_review](public.promotion_review.md) | 10 | | BASE TABLE |
| [public.promotion_run](public.promotion_run.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 |
@ -365,6 +367,12 @@ erDiagram
"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.promotion_review" }o--o| "public.user" : "FOREIGN KEY (#quot;approvedById#quot;) REFERENCES #quot;user#quot;(id) ON DELETE SET NULL"
"public.promotion_review" }o--o| "public.user" : "FOREIGN KEY (#quot;createdById#quot;) REFERENCES #quot;user#quot;(id) ON DELETE SET NULL"
"public.promotion_review" }o--o| "public.promotion_connection" : "FOREIGN KEY (#quot;connectionId#quot;) REFERENCES promotion_connection(id) ON DELETE SET NULL"
"public.promotion_run" }o--o| "public.user" : "FOREIGN KEY (#quot;createdById#quot;) REFERENCES #quot;user#quot;(id) ON DELETE SET NULL"
"public.promotion_run" }o--o| "public.promotion_config" : "FOREIGN KEY (#quot;configId#quot;) REFERENCES promotion_config(id) ON DELETE SET NULL"
"public.promotion_run" }o--o| "public.promotion_review" : "FOREIGN KEY (#quot;reviewId#quot;) REFERENCES promotion_review(id) ON DELETE SET NULL"
"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"
@ -1557,6 +1565,28 @@ erDiagram
varchar_32_ type
timestamp_3__with_time_zone updatedAt
}
"public.promotion_review" {
timestamp_3__with_time_zone approvedAt
uuid approvedById FK
varchar_36_ connectionId FK
timestamp_3__with_time_zone createdAt
uuid createdById FK
uuid id
timestamp_3__with_time_zone lastStateChangeAt
varchar_255_ remoteReviewId
varchar_32_ state
varchar_16_ type
}
"public.promotion_run" {
varchar_36_ configId FK
timestamp_3__with_time_zone createdAt
uuid createdById FK
json details
varchar_16_ direction
uuid id
uuid reviewId FK
varchar_16_ type
}
"public.role" {
timestamp_3__with_time_zone createdAt
text description

View File

@ -7,7 +7,7 @@
| 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. |
| id | varchar(36) | | false | [public.promotion_run](public.promotion_run.md) | | 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 | | | |
@ -40,6 +40,7 @@
erDiagram
"public.promotion_config" }o--|| "public.promotion_connection" : "FOREIGN KEY (#quot;connectionId#quot;) REFERENCES promotion_connection(id) ON DELETE CASCADE"
"public.promotion_run" }o--o| "public.promotion_config" : "FOREIGN KEY (#quot;configId#quot;) REFERENCES promotion_config(id) ON DELETE SET NULL"
"public.promotion_config" {
varchar_36_ connectionId FK
@ -59,6 +60,16 @@ erDiagram
json target
timestamp_3__with_time_zone updatedAt
}
"public.promotion_run" {
varchar_36_ configId FK
timestamp_3__with_time_zone createdAt
uuid createdById FK
json details
varchar_16_ direction
uuid id
uuid reviewId FK
varchar_16_ type
}
```
---

View File

@ -5,7 +5,7 @@
| 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) | | |
| id | varchar(36) | | false | [public.promotion_config](public.promotion_config.md) [public.promotion_connection_project](public.promotion_connection_project.md) [public.promotion_review](public.promotion_review.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. |
@ -42,6 +42,7 @@ 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_review" }o--o| "public.promotion_connection" : "FOREIGN KEY (#quot;connectionId#quot;) REFERENCES promotion_connection(id) ON DELETE SET NULL"
"public.promotion_connection" }o--|| "public.promotion_provider" : "FOREIGN KEY (#quot;providerId#quot;) REFERENCES promotion_provider(id) ON DELETE RESTRICT"
"public.promotion_connection" {
@ -68,6 +69,18 @@ erDiagram
varchar_36_ projectId FK
timestamp_3__with_time_zone updatedAt
}
"public.promotion_review" {
timestamp_3__with_time_zone approvedAt
uuid approvedById FK
varchar_36_ connectionId FK
timestamp_3__with_time_zone createdAt
uuid createdById FK
uuid id
timestamp_3__with_time_zone lastStateChangeAt
varchar_255_ remoteReviewId
varchar_32_ state
varchar_16_ type
}
"public.promotion_provider" {
text auth
varchar_32_ authType

View File

@ -0,0 +1,104 @@
# public.promotion_review
## Columns
| Name | Type | Default | Nullable | Children | Parents | Comment |
| ---- | ---- | ------- | -------- | -------- | ------- | ------- |
| approvedAt | timestamp(3) with time zone | | true | | | When the n8n user approved the review. |
| approvedById | uuid | | true | | [public.user](public.user.md) | The one n8n user who approved the review in n8n. NULL until then. n8n does not copy an approver from the host. |
| connectionId | varchar(36) | | true | | [public.promotion_connection](public.promotion_connection.md) | The connection that remoteReviewId resolves through. NULL after the connection is deleted. |
| createdAt | timestamp(3) with time zone | CURRENT_TIMESTAMP(3) | false | | | |
| createdById | uuid | | true | | [public.user](public.user.md) | The n8n user whose Promote opened this review. NULL after that user is deleted. |
| id | uuid | | false | [public.promotion_run](public.promotion_run.md) | | |
| lastStateChangeAt | timestamp(3) with time zone | | true | | | Time of the latest state change. Covers every transition, including changes_requested and a reopened review. NULL until the first change. |
| remoteReviewId | varchar(255) | | true | | | Host reference for type. GitLab stores "\<projectId\>!\<iid\>". NULL when the review lives in n8n. |
| state | varchar(32) | 'open'::character varying | false | | | PromotionReviewState enum: "open", "changes_requested", "merged", "closed". "merged" and "closed" are terminal. |
| type | varchar(16) | | false | | | PromotionReviewType enum: "gitlab". |
## Constraints
| Name | Type | Definition |
| ---- | ---- | ---------- |
| CHK_promotion_review_state | CHECK | CHECK (((state)::text = ANY ((ARRAY['open'::character varying, 'changes_requested'::character varying, 'merged'::character varying, 'closed'::character varying])::text[]))) |
| CHK_promotion_review_type | CHECK | CHECK (((type)::text = 'gitlab'::text)) |
| FK_promotion_review_approvedById | FOREIGN KEY | FOREIGN KEY ("approvedById") REFERENCES "user"(id) ON DELETE SET NULL |
| FK_promotion_review_connectionId | FOREIGN KEY | FOREIGN KEY ("connectionId") REFERENCES promotion_connection(id) ON DELETE SET NULL |
| FK_promotion_review_createdById | FOREIGN KEY | FOREIGN KEY ("createdById") REFERENCES "user"(id) ON DELETE SET NULL |
| PK_fe93e2f1add228dc14d3330c8e5 | PRIMARY KEY | PRIMARY KEY (id) |
| promotion_review_createdAt_not_null | n | NOT NULL "createdAt" |
| promotion_review_id_not_null | n | NOT NULL id |
| promotion_review_state_not_null | n | NOT NULL state |
| promotion_review_type_not_null | n | NOT NULL type |
## Indexes
| Name | Definition |
| ---- | ---------- |
| IDX_82d6930410e9e6c7d04c515079 | CREATE INDEX "IDX_82d6930410e9e6c7d04c515079" ON public.promotion_review USING btree (state, "createdAt") |
| IDX_93e4ec34f4091a0333af0076b6 | CREATE INDEX "IDX_93e4ec34f4091a0333af0076b6" ON public.promotion_review USING btree ("connectionId") |
| IDX_promotion_review_connectionId_remoteReviewId | CREATE UNIQUE INDEX "IDX_promotion_review_connectionId_remoteReviewId" ON public.promotion_review USING btree ("connectionId", "remoteReviewId") WHERE ("remoteReviewId" IS NOT NULL) |
| PK_fe93e2f1add228dc14d3330c8e5 | CREATE UNIQUE INDEX "PK_fe93e2f1add228dc14d3330c8e5" ON public.promotion_review USING btree (id) |
## Relations
```mermaid
erDiagram
"public.promotion_review" }o--o| "public.user" : "FOREIGN KEY (#quot;approvedById#quot;) REFERENCES #quot;user#quot;(id) ON DELETE SET NULL"
"public.promotion_review" }o--o| "public.promotion_connection" : "FOREIGN KEY (#quot;connectionId#quot;) REFERENCES promotion_connection(id) ON DELETE SET NULL"
"public.promotion_review" }o--o| "public.user" : "FOREIGN KEY (#quot;createdById#quot;) REFERENCES #quot;user#quot;(id) ON DELETE SET NULL"
"public.promotion_run" }o--o| "public.promotion_review" : "FOREIGN KEY (#quot;reviewId#quot;) REFERENCES promotion_review(id) ON DELETE SET NULL"
"public.promotion_review" {
timestamp_3__with_time_zone approvedAt
uuid approvedById FK
varchar_36_ connectionId FK
timestamp_3__with_time_zone createdAt
uuid createdById FK
uuid id
timestamp_3__with_time_zone lastStateChangeAt
varchar_255_ remoteReviewId
varchar_32_ state
varchar_16_ type
}
"public.user" {
timestamp_3__with_time_zone createdAt
boolean disabled
varchar_255_ email
varchar_32_ firstName
uuid id
date lastActiveAt
varchar_32_ lastName
boolean mfaEnabled
text mfaRecoveryCodes
text mfaSecret
varchar_255_ password
json personalizationAnswers
varchar_128_ roleSlug FK
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_run" {
varchar_36_ configId FK
timestamp_3__with_time_zone createdAt
uuid createdById FK
json details
varchar_16_ direction
uuid id
uuid reviewId FK
varchar_16_ type
}
```
---
> Generated by [tbls](https://github.com/k1LoW/tbls)

View File

@ -0,0 +1,100 @@
# public.promotion_run
## Columns
| Name | Type | Default | Nullable | Children | Parents | Comment |
| ---- | ---- | ------- | -------- | -------- | ------- | ------- |
| configId | varchar(36) | | true | | [public.promotion_config](public.promotion_config.md) | The config this run used. NULL after the config is deleted. |
| createdAt | timestamp(3) with time zone | CURRENT_TIMESTAMP(3) | false | | | |
| createdById | uuid | | true | | [public.user](public.user.md) | The user who ran Promote or Apply. |
| details | json | | true | | | Payload for type. A git run stores schemaVersion, branchName and commitSha. NULL when the type has no details. |
| direction | varchar(16) | | false | | | PromotionDirection enum: "promote" pushes to the remote, "apply" imports from it. Copied from the config when the run is written. |
| id | uuid | | false | | | |
| reviewId | uuid | | true | | [public.promotion_review](public.promotion_review.md) | The review this run belongs to. NULL when the push has no review. Not unique: an amend adds another run. |
| type | varchar(16) | | false | | | PromotionRunType enum: "git". Git covers GitHub, GitLab and plain Git. |
## Constraints
| Name | Type | Definition |
| ---- | ---- | ---------- |
| CHK_promotion_run_direction | CHECK | CHECK (((direction)::text = ANY ((ARRAY['promote'::character varying, 'apply'::character varying])::text[]))) |
| CHK_promotion_run_type | CHECK | CHECK (((type)::text = 'git'::text)) |
| FK_promotion_run_configId | FOREIGN KEY | FOREIGN KEY ("configId") REFERENCES promotion_config(id) ON DELETE SET NULL |
| FK_promotion_run_createdById | FOREIGN KEY | FOREIGN KEY ("createdById") REFERENCES "user"(id) ON DELETE SET NULL |
| FK_promotion_run_reviewId | FOREIGN KEY | FOREIGN KEY ("reviewId") REFERENCES promotion_review(id) ON DELETE SET NULL |
| PK_f5b941d810bf2eb7a5f8327f446 | PRIMARY KEY | PRIMARY KEY (id) |
| promotion_run_createdAt_not_null | n | NOT NULL "createdAt" |
| promotion_run_direction_not_null | n | NOT NULL direction |
| promotion_run_id_not_null | n | NOT NULL id |
| promotion_run_type_not_null | n | NOT NULL type |
## Indexes
| Name | Definition |
| ---- | ---------- |
| IDX_5c00951cc85ac1bc6f120b1e69 | CREATE INDEX "IDX_5c00951cc85ac1bc6f120b1e69" ON public.promotion_run USING btree ("configId") |
| IDX_b24c3e6b803dc496c8702f12ab | CREATE INDEX "IDX_b24c3e6b803dc496c8702f12ab" ON public.promotion_run USING btree ("reviewId") |
| PK_f5b941d810bf2eb7a5f8327f446 | CREATE UNIQUE INDEX "PK_f5b941d810bf2eb7a5f8327f446" ON public.promotion_run USING btree (id) |
## Relations
```mermaid
erDiagram
"public.promotion_run" }o--o| "public.promotion_config" : "FOREIGN KEY (#quot;configId#quot;) REFERENCES promotion_config(id) ON DELETE SET NULL"
"public.promotion_run" }o--o| "public.user" : "FOREIGN KEY (#quot;createdById#quot;) REFERENCES #quot;user#quot;(id) ON DELETE SET NULL"
"public.promotion_run" }o--o| "public.promotion_review" : "FOREIGN KEY (#quot;reviewId#quot;) REFERENCES promotion_review(id) ON DELETE SET NULL"
"public.promotion_run" {
varchar_36_ configId FK
timestamp_3__with_time_zone createdAt
uuid createdById FK
json details
varchar_16_ direction
uuid id
uuid reviewId FK
varchar_16_ type
}
"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.user" {
timestamp_3__with_time_zone createdAt
boolean disabled
varchar_255_ email
varchar_32_ firstName
uuid id
date lastActiveAt
varchar_32_ lastName
boolean mfaEnabled
text mfaRecoveryCodes
text mfaSecret
varchar_255_ password
json personalizationAnswers
varchar_128_ roleSlug FK
json settings
timestamp_3__with_time_zone updatedAt
}
"public.promotion_review" {
timestamp_3__with_time_zone approvedAt
uuid approvedById FK
varchar_36_ connectionId FK
timestamp_3__with_time_zone createdAt
uuid createdById FK
uuid id
timestamp_3__with_time_zone lastStateChangeAt
varchar_255_ remoteReviewId
varchar_32_ state
varchar_16_ type
}
```
---
> Generated by [tbls](https://github.com/k1LoW/tbls)

View File

@ -8,7 +8,7 @@
| disabled | boolean | false | false | | | |
| email | varchar(255) | | true | | | |
| firstName | varchar(32) | | true | | | |
| id | uuid | gen_random_uuid() | false | [public.activity_event](public.activity_event.md) [public.agent_eval_dataset](public.agent_eval_dataset.md) [public.agent_eval_rating](public.agent_eval_rating.md) [public.agent_eval_run](public.agent_eval_run.md) [public.agent_execution_threads](public.agent_execution_threads.md) [public.agent_history](public.agent_history.md) [public.ai_preference](public.ai_preference.md) [public.auth_identity](public.auth_identity.md) [public.chat_hub_agents](public.chat_hub_agents.md) [public.chat_hub_sessions](public.chat_hub_sessions.md) [public.chat_hub_tools](public.chat_hub_tools.md) [public.dynamic_credential_user_entry](public.dynamic_credential_user_entry.md) [public.evaluation_collection](public.evaluation_collection.md) [public.idempotency_key](public.idempotency_key.md) [public.instance_ai_mcp_registry_connections](public.instance_ai_mcp_registry_connections.md) [public.instance_ai_pending_confirmations](public.instance_ai_pending_confirmations.md) [public.instance_ai_thread_grants](public.instance_ai_thread_grants.md) [public.instance_ai_thread_tabs](public.instance_ai_thread_tabs.md) [public.migration_workflow_owner](public.migration_workflow_owner.md) [public.oauth_access_tokens](public.oauth_access_tokens.md) [public.oauth_authorization_codes](public.oauth_authorization_codes.md) [public.oauth_refresh_tokens](public.oauth_refresh_tokens.md) [public.oauth_user_consents](public.oauth_user_consents.md) [public.project](public.project.md) [public.project_relation](public.project_relation.md) [public.self_healing_result](public.self_healing_result.md) [public.skill](public.skill.md) [public.skill_version](public.skill_version.md) [public.trusted_source_identity](public.trusted_source_identity.md) [public.user_api_keys](public.user_api_keys.md) [public.user_favorites](public.user_favorites.md) [public.workflow_builder_session](public.workflow_builder_session.md) [public.workflow_publish_history](public.workflow_publish_history.md) [public.workflow_review_activity](public.workflow_review_activity.md) [public.workflow_review_activity_comment](public.workflow_review_activity_comment.md) [public.workflow_review_request](public.workflow_review_request.md) [public.workflow_review_request_authors](public.workflow_review_request_authors.md) [public.workflow_review_request_reviewers](public.workflow_review_request_reviewers.md) [public.workflow_suggestion](public.workflow_suggestion.md) [public.workflow_suggestion_activity](public.workflow_suggestion_activity.md) | | |
| id | uuid | gen_random_uuid() | false | [public.activity_event](public.activity_event.md) [public.agent_eval_dataset](public.agent_eval_dataset.md) [public.agent_eval_rating](public.agent_eval_rating.md) [public.agent_eval_run](public.agent_eval_run.md) [public.agent_execution_threads](public.agent_execution_threads.md) [public.agent_history](public.agent_history.md) [public.ai_preference](public.ai_preference.md) [public.auth_identity](public.auth_identity.md) [public.chat_hub_agents](public.chat_hub_agents.md) [public.chat_hub_sessions](public.chat_hub_sessions.md) [public.chat_hub_tools](public.chat_hub_tools.md) [public.dynamic_credential_user_entry](public.dynamic_credential_user_entry.md) [public.evaluation_collection](public.evaluation_collection.md) [public.idempotency_key](public.idempotency_key.md) [public.instance_ai_mcp_registry_connections](public.instance_ai_mcp_registry_connections.md) [public.instance_ai_pending_confirmations](public.instance_ai_pending_confirmations.md) [public.instance_ai_thread_grants](public.instance_ai_thread_grants.md) [public.instance_ai_thread_tabs](public.instance_ai_thread_tabs.md) [public.migration_workflow_owner](public.migration_workflow_owner.md) [public.oauth_access_tokens](public.oauth_access_tokens.md) [public.oauth_authorization_codes](public.oauth_authorization_codes.md) [public.oauth_refresh_tokens](public.oauth_refresh_tokens.md) [public.oauth_user_consents](public.oauth_user_consents.md) [public.project](public.project.md) [public.project_relation](public.project_relation.md) [public.promotion_review](public.promotion_review.md) [public.promotion_run](public.promotion_run.md) [public.self_healing_result](public.self_healing_result.md) [public.skill](public.skill.md) [public.skill_version](public.skill_version.md) [public.trusted_source_identity](public.trusted_source_identity.md) [public.user_api_keys](public.user_api_keys.md) [public.user_favorites](public.user_favorites.md) [public.workflow_builder_session](public.workflow_builder_session.md) [public.workflow_publish_history](public.workflow_publish_history.md) [public.workflow_review_activity](public.workflow_review_activity.md) [public.workflow_review_activity_comment](public.workflow_review_activity_comment.md) [public.workflow_review_request](public.workflow_review_request.md) [public.workflow_review_request_authors](public.workflow_review_request_authors.md) [public.workflow_review_request_reviewers](public.workflow_review_request_reviewers.md) [public.workflow_suggestion](public.workflow_suggestion.md) [public.workflow_suggestion_activity](public.workflow_suggestion_activity.md) | | |
| lastActiveAt | date | | true | | | |
| lastName | varchar(32) | | true | | | |
| mfaEnabled | boolean | false | false | | | |
@ -74,6 +74,9 @@ erDiagram
"public.oauth_user_consents" }o--|| "public.user" : "FOREIGN KEY (#quot;userId#quot;) REFERENCES #quot;user#quot;(id) ON DELETE CASCADE"
"public.project" }o--o| "public.user" : "FOREIGN KEY (#quot;creatorId#quot;) REFERENCES #quot;user#quot;(id) ON DELETE SET NULL"
"public.project_relation" }o--|| "public.user" : "FOREIGN KEY (#quot;userId#quot;) REFERENCES #quot;user#quot;(id) ON DELETE CASCADE"
"public.promotion_review" }o--o| "public.user" : "FOREIGN KEY (#quot;approvedById#quot;) REFERENCES #quot;user#quot;(id) ON DELETE SET NULL"
"public.promotion_review" }o--o| "public.user" : "FOREIGN KEY (#quot;createdById#quot;) REFERENCES #quot;user#quot;(id) ON DELETE SET NULL"
"public.promotion_run" }o--o| "public.user" : "FOREIGN KEY (#quot;createdById#quot;) REFERENCES #quot;user#quot;(id) ON DELETE SET NULL"
"public.self_healing_result" }o--|| "public.user" : "FOREIGN KEY (#quot;backgroundUserId#quot;) REFERENCES #quot;user#quot;(id) ON DELETE CASCADE"
"public.self_healing_result" }o--o| "public.user" : "FOREIGN KEY (#quot;dismissedById#quot;) REFERENCES #quot;user#quot;(id) ON DELETE SET NULL"
"public.skill" }o--o| "public.user" : "FOREIGN KEY (#quot;createdById#quot;) REFERENCES #quot;user#quot;(id) ON DELETE SET NULL"
@ -383,6 +386,28 @@ erDiagram
timestamp_3__with_time_zone updatedAt
uuid userId FK
}
"public.promotion_review" {
timestamp_3__with_time_zone approvedAt
uuid approvedById FK
varchar_36_ connectionId FK
timestamp_3__with_time_zone createdAt
uuid createdById FK
uuid id
timestamp_3__with_time_zone lastStateChangeAt
varchar_255_ remoteReviewId
varchar_32_ state
varchar_16_ type
}
"public.promotion_run" {
varchar_36_ configId FK
timestamp_3__with_time_zone createdAt
uuid createdById FK
json details
varchar_16_ direction
uuid id
uuid reviewId FK
varchar_16_ type
}
"public.self_healing_result" {
uuid backgroundUserId FK
timestamp_3__with_time_zone completedAt

View File

@ -121,6 +121,8 @@ Auto-generated from the SQLite migrations in @n8n/db. Do not edit by hand.
| [promotion_connection](promotion_connection.md) | 7 | | table |
| [promotion_connection_project](promotion_connection_project.md) | 4 | | table |
| [promotion_provider](promotion_provider.md) | 8 | | table |
| [promotion_review](promotion_review.md) | 10 | | table |
| [promotion_run](promotion_run.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 |
@ -347,6 +349,12 @@ erDiagram
"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"
"promotion_review" }o--o| "user" : "FOREIGN KEY (approvedById) REFERENCES user (id) ON UPDATE NO ACTION ON DELETE SET NULL MATCH NONE"
"promotion_review" }o--o| "user" : "FOREIGN KEY (createdById) REFERENCES user (id) ON UPDATE NO ACTION ON DELETE SET NULL MATCH NONE"
"promotion_review" }o--o| "promotion_connection" : "FOREIGN KEY (connectionId) REFERENCES promotion_connection (id) ON UPDATE NO ACTION ON DELETE SET NULL MATCH NONE"
"promotion_run" }o--o| "promotion_review" : "FOREIGN KEY (reviewId) REFERENCES promotion_review (id) ON UPDATE NO ACTION ON DELETE SET NULL MATCH NONE"
"promotion_run" }o--o| "user" : "FOREIGN KEY (createdById) REFERENCES user (id) ON UPDATE NO ACTION ON DELETE SET NULL MATCH NONE"
"promotion_run" }o--o| "promotion_config" : "FOREIGN KEY (configId) REFERENCES promotion_config (id) ON UPDATE NO ACTION ON DELETE SET NULL 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"
@ -1543,6 +1551,28 @@ erDiagram
varchar_32_ type
datetime_3_ updatedAt
}
"promotion_review" {
datetime_3_ approvedAt
varchar approvedById FK
varchar_36_ connectionId FK
datetime_3_ createdAt
varchar createdById FK
varchar id PK
datetime_3_ lastStateChangeAt
varchar_255_ remoteReviewId
varchar_32_ state
varchar_16_ type
}
"promotion_run" {
varchar_36_ configId FK
datetime_3_ createdAt
varchar createdById FK
TEXT details
varchar_16_ direction
varchar id PK
varchar reviewId FK
varchar_16_ type
}
"role" {
datetime_3_ createdAt
TEXT description

View File

@ -18,7 +18,7 @@ CREATE TABLE "promotion_config" ("id" varchar(36) PRIMARY KEY NOT NULL, "connect
| 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 | | | |
| id | varchar(36) | | false | [promotion_run](promotion_run.md) | | |
| name | varchar(128) | | false | | | |
| settings | TEXT | | false | | | |
| updatedAt | datetime(3) | STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW') | false | | | |
@ -45,6 +45,7 @@ CREATE TABLE "promotion_config" ("id" varchar(36) PRIMARY KEY NOT NULL, "connect
erDiagram
"promotion_config" }o--|| "promotion_connection" : "FOREIGN KEY (connectionId) REFERENCES promotion_connection (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE"
"promotion_run" }o--o| "promotion_config" : "FOREIGN KEY (configId) REFERENCES promotion_config (id) ON UPDATE NO ACTION ON DELETE SET NULL MATCH NONE"
"promotion_config" {
varchar_36_ connectionId FK
@ -64,6 +65,16 @@ erDiagram
TEXT target
datetime_3_ updatedAt
}
"promotion_run" {
varchar_36_ configId FK
datetime_3_ createdAt
varchar createdById FK
TEXT details
varchar_16_ direction
varchar id PK
varchar reviewId FK
varchar_16_ type
}
```
---

View File

@ -16,7 +16,7 @@ CREATE TABLE "promotion_connection" ("id" varchar(36) PRIMARY KEY NOT NULL, "nam
| 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) | | |
| id | varchar(36) | | false | [promotion_config](promotion_config.md) [promotion_connection_project](promotion_connection_project.md) [promotion_review](promotion_review.md) | | |
| name | varchar(128) | | false | | | |
| providerId | varchar(36) | | false | | [promotion_provider](promotion_provider.md) | |
| scope | varchar(16) | | false | | | |
@ -47,6 +47,7 @@ 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_review" }o--o| "promotion_connection" : "FOREIGN KEY (connectionId) REFERENCES promotion_connection (id) ON UPDATE NO ACTION ON DELETE SET NULL 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" {
@ -73,6 +74,18 @@ erDiagram
varchar_36_ projectId PK
datetime_3_ updatedAt
}
"promotion_review" {
datetime_3_ approvedAt
varchar approvedById FK
varchar_36_ connectionId FK
datetime_3_ createdAt
varchar createdById FK
varchar id PK
datetime_3_ lastStateChangeAt
varchar_255_ remoteReviewId
varchar_32_ state
varchar_16_ type
}
"promotion_provider" {
TEXT auth
varchar_32_ authType

View File

@ -0,0 +1,112 @@
# promotion_review
## Description
<details>
<summary><strong>Table Definition</strong></summary>
```sql
CREATE TABLE "promotion_review" ("id" varchar PRIMARY KEY NOT NULL, "connectionId" varchar(36), "type" varchar(16) NOT NULL, "remoteReviewId" varchar(255), "state" varchar(32) NOT NULL DEFAULT ('open'), "lastStateChangeAt" datetime(3), "createdById" varchar, "approvedById" varchar, "approvedAt" datetime(3), "createdAt" datetime(3) NOT NULL DEFAULT (STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')), CONSTRAINT "CHK_promotion_review_type" CHECK ("type" IN ('gitlab')), CONSTRAINT "CHK_promotion_review_state" CHECK ("state" IN ('open', 'changes_requested', 'merged', 'closed')), CONSTRAINT "FK_promotion_review_connectionId" FOREIGN KEY ("connectionId") REFERENCES "promotion_connection" ("id") ON DELETE SET NULL, CONSTRAINT "FK_promotion_review_createdById" FOREIGN KEY ("createdById") REFERENCES "user" ("id") ON DELETE SET NULL, CONSTRAINT "FK_promotion_review_approvedById" FOREIGN KEY ("approvedById") REFERENCES "user" ("id") ON DELETE SET NULL)
```
</details>
## Columns
| Name | Type | Default | Nullable | Children | Parents | Comment |
| ---- | ---- | ------- | -------- | -------- | ------- | ------- |
| approvedAt | datetime(3) | | true | | | |
| approvedById | varchar | | true | | [user](user.md) | |
| connectionId | varchar(36) | | true | | [promotion_connection](promotion_connection.md) | |
| createdAt | datetime(3) | STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW') | false | | | |
| createdById | varchar | | true | | [user](user.md) | |
| id | varchar | | false | [promotion_run](promotion_run.md) | | |
| lastStateChangeAt | datetime(3) | | true | | | |
| remoteReviewId | varchar(255) | | true | | | |
| state | varchar(32) | 'open' | false | | | |
| type | varchar(16) | | false | | | |
## Constraints
| Name | Type | Definition |
| ---- | ---- | ---------- |
| - | CHECK | CHECK ("type" IN ('gitlab')) |
| - | CHECK | CHECK ("state" IN ('open', 'changes_requested', 'merged', 'closed')) |
| - (Foreign key ID: 0) | FOREIGN KEY | FOREIGN KEY (approvedById) REFERENCES user (id) ON UPDATE NO ACTION ON DELETE SET NULL MATCH NONE |
| - (Foreign key ID: 1) | FOREIGN KEY | FOREIGN KEY (createdById) REFERENCES user (id) ON UPDATE NO ACTION ON DELETE SET NULL MATCH NONE |
| - (Foreign key ID: 2) | FOREIGN KEY | FOREIGN KEY (connectionId) REFERENCES promotion_connection (id) ON UPDATE NO ACTION ON DELETE SET NULL MATCH NONE |
| id | PRIMARY KEY | PRIMARY KEY (id) |
| sqlite_autoindex_promotion_review_1 | PRIMARY KEY | PRIMARY KEY (id) |
## Indexes
| Name | Definition |
| ---- | ---------- |
| IDX_82d6930410e9e6c7d04c515079 | CREATE INDEX "IDX_82d6930410e9e6c7d04c515079" ON "promotion_review" ("state", "createdAt") |
| IDX_93e4ec34f4091a0333af0076b6 | CREATE INDEX "IDX_93e4ec34f4091a0333af0076b6" ON "promotion_review" ("connectionId") |
| IDX_promotion_review_connectionId_remoteReviewId | CREATE UNIQUE INDEX "IDX_promotion_review_connectionId_remoteReviewId" ON "promotion_review" ("connectionId", "remoteReviewId") WHERE "remoteReviewId" IS NOT NULL |
| sqlite_autoindex_promotion_review_1 | PRIMARY KEY (id) |
## Relations
```mermaid
erDiagram
"promotion_review" }o--o| "user" : "FOREIGN KEY (approvedById) REFERENCES user (id) ON UPDATE NO ACTION ON DELETE SET NULL MATCH NONE"
"promotion_review" }o--o| "promotion_connection" : "FOREIGN KEY (connectionId) REFERENCES promotion_connection (id) ON UPDATE NO ACTION ON DELETE SET NULL MATCH NONE"
"promotion_review" }o--o| "user" : "FOREIGN KEY (createdById) REFERENCES user (id) ON UPDATE NO ACTION ON DELETE SET NULL MATCH NONE"
"promotion_run" }o--o| "promotion_review" : "FOREIGN KEY (reviewId) REFERENCES promotion_review (id) ON UPDATE NO ACTION ON DELETE SET NULL MATCH NONE"
"promotion_review" {
datetime_3_ approvedAt
varchar approvedById FK
varchar_36_ connectionId FK
datetime_3_ createdAt
varchar createdById FK
varchar id PK
datetime_3_ lastStateChangeAt
varchar_255_ remoteReviewId
varchar_32_ state
varchar_16_ type
}
"user" {
datetime_3_ createdAt
boolean disabled
varchar_255_ email
varchar_32_ firstName
varchar id PK
date lastActiveAt
varchar_32_ lastName
boolean mfaEnabled
TEXT mfaRecoveryCodes
TEXT mfaSecret
varchar password
TEXT personalizationAnswers
varchar_128_ roleSlug FK
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_run" {
varchar_36_ configId FK
datetime_3_ createdAt
varchar createdById FK
TEXT details
varchar_16_ direction
varchar id PK
varchar reviewId FK
varchar_16_ type
}
```
---
> Generated by [tbls](https://github.com/k1LoW/tbls)

View File

@ -0,0 +1,108 @@
# promotion_run
## Description
<details>
<summary><strong>Table Definition</strong></summary>
```sql
CREATE TABLE "promotion_run" ("id" varchar PRIMARY KEY NOT NULL, "configId" varchar(36), "direction" varchar(16) NOT NULL, "type" varchar(16) NOT NULL, "createdById" varchar, "details" text, "reviewId" varchar, "createdAt" datetime(3) NOT NULL DEFAULT (STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')), CONSTRAINT "CHK_promotion_run_direction" CHECK ("direction" IN ('promote', 'apply')), CONSTRAINT "CHK_promotion_run_type" CHECK ("type" IN ('git')), CONSTRAINT "FK_promotion_run_configId" FOREIGN KEY ("configId") REFERENCES "promotion_config" ("id") ON DELETE SET NULL, CONSTRAINT "FK_promotion_run_createdById" FOREIGN KEY ("createdById") REFERENCES "user" ("id") ON DELETE SET NULL, CONSTRAINT "FK_promotion_run_reviewId" FOREIGN KEY ("reviewId") REFERENCES "promotion_review" ("id") ON DELETE SET NULL)
```
</details>
## Columns
| Name | Type | Default | Nullable | Children | Parents | Comment |
| ---- | ---- | ------- | -------- | -------- | ------- | ------- |
| configId | varchar(36) | | true | | [promotion_config](promotion_config.md) | |
| createdAt | datetime(3) | STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW') | false | | | |
| createdById | varchar | | true | | [user](user.md) | |
| details | TEXT | | true | | | |
| direction | varchar(16) | | false | | | |
| id | varchar | | false | | | |
| reviewId | varchar | | true | | [promotion_review](promotion_review.md) | |
| type | varchar(16) | | false | | | |
## Constraints
| Name | Type | Definition |
| ---- | ---- | ---------- |
| - | CHECK | CHECK ("direction" IN ('promote', 'apply')) |
| - | CHECK | CHECK ("type" IN ('git')) |
| - (Foreign key ID: 0) | FOREIGN KEY | FOREIGN KEY (reviewId) REFERENCES promotion_review (id) ON UPDATE NO ACTION ON DELETE SET NULL MATCH NONE |
| - (Foreign key ID: 1) | FOREIGN KEY | FOREIGN KEY (createdById) REFERENCES user (id) ON UPDATE NO ACTION ON DELETE SET NULL MATCH NONE |
| - (Foreign key ID: 2) | FOREIGN KEY | FOREIGN KEY (configId) REFERENCES promotion_config (id) ON UPDATE NO ACTION ON DELETE SET NULL MATCH NONE |
| id | PRIMARY KEY | PRIMARY KEY (id) |
| sqlite_autoindex_promotion_run_1 | PRIMARY KEY | PRIMARY KEY (id) |
## Indexes
| Name | Definition |
| ---- | ---------- |
| IDX_5c00951cc85ac1bc6f120b1e69 | CREATE INDEX "IDX_5c00951cc85ac1bc6f120b1e69" ON "promotion_run" ("configId") |
| IDX_b24c3e6b803dc496c8702f12ab | CREATE INDEX "IDX_b24c3e6b803dc496c8702f12ab" ON "promotion_run" ("reviewId") |
| sqlite_autoindex_promotion_run_1 | PRIMARY KEY (id) |
## Relations
```mermaid
erDiagram
"promotion_run" }o--o| "promotion_config" : "FOREIGN KEY (configId) REFERENCES promotion_config (id) ON UPDATE NO ACTION ON DELETE SET NULL MATCH NONE"
"promotion_run" }o--o| "user" : "FOREIGN KEY (createdById) REFERENCES user (id) ON UPDATE NO ACTION ON DELETE SET NULL MATCH NONE"
"promotion_run" }o--o| "promotion_review" : "FOREIGN KEY (reviewId) REFERENCES promotion_review (id) ON UPDATE NO ACTION ON DELETE SET NULL MATCH NONE"
"promotion_run" {
varchar_36_ configId FK
datetime_3_ createdAt
varchar createdById FK
TEXT details
varchar_16_ direction
varchar id PK
varchar reviewId FK
varchar_16_ type
}
"promotion_config" {
varchar_36_ connectionId FK
datetime_3_ createdAt
varchar_16_ direction
varchar_36_ id PK
varchar_128_ name
TEXT settings
datetime_3_ updatedAt
}
"user" {
datetime_3_ createdAt
boolean disabled
varchar_255_ email
varchar_32_ firstName
varchar id PK
date lastActiveAt
varchar_32_ lastName
boolean mfaEnabled
TEXT mfaRecoveryCodes
TEXT mfaSecret
varchar password
TEXT personalizationAnswers
varchar_128_ roleSlug FK
TEXT settings
datetime_3_ updatedAt
}
"promotion_review" {
datetime_3_ approvedAt
varchar approvedById FK
varchar_36_ connectionId FK
datetime_3_ createdAt
varchar createdById FK
varchar id PK
datetime_3_ lastStateChangeAt
varchar_255_ remoteReviewId
varchar_32_ state
varchar_16_ type
}
```
---
> Generated by [tbls](https://github.com/k1LoW/tbls)

View File

@ -19,7 +19,7 @@ CREATE TABLE "user" ("id" varchar PRIMARY KEY, "email" varchar(255), "firstName"
| disabled | boolean | FALSE | false | | | |
| email | varchar(255) | | true | | | |
| firstName | varchar(32) | | true | | | |
| id | varchar | | true | [activity_event](activity_event.md) [agent_eval_dataset](agent_eval_dataset.md) [agent_eval_rating](agent_eval_rating.md) [agent_eval_run](agent_eval_run.md) [agent_execution_threads](agent_execution_threads.md) [agent_history](agent_history.md) [ai_preference](ai_preference.md) [auth_identity](auth_identity.md) [chat_hub_agents](chat_hub_agents.md) [chat_hub_sessions](chat_hub_sessions.md) [chat_hub_tools](chat_hub_tools.md) [dynamic_credential_user_entry](dynamic_credential_user_entry.md) [evaluation_collection](evaluation_collection.md) [idempotency_key](idempotency_key.md) [instance_ai_mcp_registry_connections](instance_ai_mcp_registry_connections.md) [instance_ai_pending_confirmations](instance_ai_pending_confirmations.md) [instance_ai_thread_grants](instance_ai_thread_grants.md) [instance_ai_thread_tabs](instance_ai_thread_tabs.md) [migration_workflow_owner](migration_workflow_owner.md) [oauth_access_tokens](oauth_access_tokens.md) [oauth_authorization_codes](oauth_authorization_codes.md) [oauth_refresh_tokens](oauth_refresh_tokens.md) [oauth_user_consents](oauth_user_consents.md) [project](project.md) [project_relation](project_relation.md) [self_healing_result](self_healing_result.md) [skill](skill.md) [skill_version](skill_version.md) [trusted_source_identity](trusted_source_identity.md) [user_api_keys](user_api_keys.md) [user_favorites](user_favorites.md) [workflow_builder_session](workflow_builder_session.md) [workflow_publish_history](workflow_publish_history.md) [workflow_review_activity](workflow_review_activity.md) [workflow_review_activity_comment](workflow_review_activity_comment.md) [workflow_review_request](workflow_review_request.md) [workflow_review_request_authors](workflow_review_request_authors.md) [workflow_review_request_reviewers](workflow_review_request_reviewers.md) [workflow_suggestion](workflow_suggestion.md) [workflow_suggestion_activity](workflow_suggestion_activity.md) | | |
| id | varchar | | true | [activity_event](activity_event.md) [agent_eval_dataset](agent_eval_dataset.md) [agent_eval_rating](agent_eval_rating.md) [agent_eval_run](agent_eval_run.md) [agent_execution_threads](agent_execution_threads.md) [agent_history](agent_history.md) [ai_preference](ai_preference.md) [auth_identity](auth_identity.md) [chat_hub_agents](chat_hub_agents.md) [chat_hub_sessions](chat_hub_sessions.md) [chat_hub_tools](chat_hub_tools.md) [dynamic_credential_user_entry](dynamic_credential_user_entry.md) [evaluation_collection](evaluation_collection.md) [idempotency_key](idempotency_key.md) [instance_ai_mcp_registry_connections](instance_ai_mcp_registry_connections.md) [instance_ai_pending_confirmations](instance_ai_pending_confirmations.md) [instance_ai_thread_grants](instance_ai_thread_grants.md) [instance_ai_thread_tabs](instance_ai_thread_tabs.md) [migration_workflow_owner](migration_workflow_owner.md) [oauth_access_tokens](oauth_access_tokens.md) [oauth_authorization_codes](oauth_authorization_codes.md) [oauth_refresh_tokens](oauth_refresh_tokens.md) [oauth_user_consents](oauth_user_consents.md) [project](project.md) [project_relation](project_relation.md) [promotion_review](promotion_review.md) [promotion_run](promotion_run.md) [self_healing_result](self_healing_result.md) [skill](skill.md) [skill_version](skill_version.md) [trusted_source_identity](trusted_source_identity.md) [user_api_keys](user_api_keys.md) [user_favorites](user_favorites.md) [workflow_builder_session](workflow_builder_session.md) [workflow_publish_history](workflow_publish_history.md) [workflow_review_activity](workflow_review_activity.md) [workflow_review_activity_comment](workflow_review_activity_comment.md) [workflow_review_request](workflow_review_request.md) [workflow_review_request_authors](workflow_review_request_authors.md) [workflow_review_request_reviewers](workflow_review_request_reviewers.md) [workflow_suggestion](workflow_suggestion.md) [workflow_suggestion_activity](workflow_suggestion_activity.md) | | |
| lastActiveAt | date | | true | | | |
| lastName | varchar(32) | | true | | | |
| mfaEnabled | boolean | FALSE | false | | | |
@ -80,6 +80,9 @@ erDiagram
"oauth_user_consents" }o--|| "user" : "FOREIGN KEY (userId) REFERENCES user (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE"
"project" }o--o| "user" : "FOREIGN KEY (creatorId) REFERENCES user (id) ON UPDATE NO ACTION ON DELETE SET NULL MATCH NONE"
"project_relation" |o--|| "user" : "FOREIGN KEY (userId) REFERENCES user (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE"
"promotion_review" }o--o| "user" : "FOREIGN KEY (approvedById) REFERENCES user (id) ON UPDATE NO ACTION ON DELETE SET NULL MATCH NONE"
"promotion_review" }o--o| "user" : "FOREIGN KEY (createdById) REFERENCES user (id) ON UPDATE NO ACTION ON DELETE SET NULL MATCH NONE"
"promotion_run" }o--o| "user" : "FOREIGN KEY (createdById) REFERENCES user (id) ON UPDATE NO ACTION ON DELETE SET NULL MATCH NONE"
"self_healing_result" }o--o| "user" : "FOREIGN KEY (dismissedById) REFERENCES user (id) ON UPDATE NO ACTION ON DELETE SET NULL MATCH NONE"
"self_healing_result" }o--|| "user" : "FOREIGN KEY (backgroundUserId) REFERENCES user (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE"
"skill" }o--o| "user" : "FOREIGN KEY (createdById) REFERENCES user (id) ON UPDATE NO ACTION ON DELETE SET NULL MATCH NONE"
@ -389,6 +392,28 @@ erDiagram
datetime_3_ updatedAt
varchar userId PK
}
"promotion_review" {
datetime_3_ approvedAt
varchar approvedById FK
varchar_36_ connectionId FK
datetime_3_ createdAt
varchar createdById FK
varchar id PK
datetime_3_ lastStateChangeAt
varchar_255_ remoteReviewId
varchar_32_ state
varchar_16_ type
}
"promotion_run" {
varchar_36_ configId FK
datetime_3_ createdAt
varchar createdById FK
TEXT details
varchar_16_ direction
varchar id PK
varchar reviewId FK
varchar_16_ type
}
"self_healing_result" {
varchar backgroundUserId FK
datetime_3_ completedAt

View File

@ -809,6 +809,42 @@ export {
promotePackageResultSchema,
promotionGitResultSchema,
} from './promotions/promotion-operations.dto';
export {
parsePromotionRunDetails,
promotionGitRunDetailsSchema,
promotionRunDetailsSchemas,
promotionRunTypeSchema,
type PromotionGitRunDetails,
type PromotionRunDetails,
type PromotionRunType,
} from './promotions/promotion-run.dto';
export {
ListPromotionReviewsQueryDto,
PromotionReviewDetailDto,
PromotionReviewListPublicDto,
PromotionReviewWorkflowDiffDto,
promotionMergeRequestResultSchema,
promotionReviewChangeKindSchema,
promotionReviewDetailSchema,
promotionReviewListPublicSchema,
gitLabRemoteReviewIdSchema,
parseRemoteReviewId,
promotionRemoteReviewIdSchemas,
promotionReviewStateSchema,
promotionReviewSummarySchema,
promotionReviewTabSchema,
promotionReviewTypeSchema,
promotionReviewWorkflowChangeSchema,
promotionReviewWorkflowDiffSchema,
type PromotionMergeRequestResult,
type PromotionReviewChangeKind,
type PromotionReviewRemote,
type PromotionReviewState,
type PromotionReviewSummary,
type PromotionReviewType,
type PromotionReviewTab,
type PromotionReviewWorkflowChange,
} from './promotions/promotion-review.dto';
export { ScimConfigPatchDto } from './scim';
export { CreateTrustedSourceDto } from './trusted-source/create-trusted-source.dto';

View File

@ -0,0 +1,29 @@
import { parsePromotionRunDetails, promotionRunTypeSchema } from '../promotion-run.dto';
import { parseRemoteReviewId } from '../promotion-review.dto';
const gitDetails = {
schemaVersion: 1 as const,
branchName: 'n8n-promotion/1',
commitSha: 'a'.repeat(40),
};
describe('promotion run details', () => {
it('accepts a git payload for the git run type', () => {
expect(parsePromotionRunDetails('git', gitDetails)).toEqual(gitDetails);
expect(promotionRunTypeSchema.safeParse('gitlab').success).toBe(false);
});
it('rejects a git payload with another schema version', () => {
expect(() => parsePromotionRunDetails('git', { ...gitDetails, schemaVersion: 2 })).toThrow();
});
});
describe('promotion review host reference', () => {
it('accepts a GitLab project id and iid', () => {
expect(parseRemoteReviewId('gitlab', '42!7')).toBe('42!7');
});
it('rejects a GitLab reference that still has a host prefix', () => {
expect(() => parseRemoteReviewId('gitlab', 'gitlab:42!7')).toThrow();
});
});

View File

@ -0,0 +1,148 @@
import { z } from 'zod';
import { n8nIdSchema } from '../../schemas/id.schema';
import { Z } from '../../zod-class';
/**
* The state of a Promotion Review. `open` and `changes_requested` mirror the
* review on the host. `merged` and `closed` are terminal and owned by n8n.
*/
export const promotionReviewStateSchema = z.enum(['open', 'changes_requested', 'merged', 'closed']);
export type PromotionReviewState = z.infer<typeof promotionReviewStateSchema>;
/**
* Where the review lives. The application checks this value. The database does not.
*/
export const promotionReviewTypeSchema = z.enum(['gitlab']);
export type PromotionReviewType = z.infer<typeof promotionReviewTypeSchema>;
/**
* Host reference for each review type. Code selects the schema from the type
* column. GitLab stores `<projectId>!<iid>`. The type column names the host.
*/
export const gitLabRemoteReviewIdSchema = z.string().regex(/^\d+!\d+$/);
export const promotionRemoteReviewIdSchemas = {
gitlab: gitLabRemoteReviewIdSchema,
} as const;
/** Parse a host reference with the schema for `type`. */
export function parseRemoteReviewId<T extends PromotionReviewType>(
type: T,
remoteReviewId: string,
): z.infer<(typeof promotionRemoteReviewIdSchemas)[T]> {
return promotionRemoteReviewIdSchemas[type].parse(remoteReviewId);
}
const reviewUserSchema = z.object({
id: z.string(),
firstName: z.string().nullable(),
lastName: z.string().nullable(),
email: z.string().nullable(),
});
/** What the host reports about the review. NULL when the host did not answer. */
const promotionReviewRemoteSchema = z.object({
title: z.string(),
webUrl: z.string(),
hasConflicts: z.boolean(),
});
export type PromotionReviewRemote = z.infer<typeof promotionReviewRemoteSchema>;
/**
* One inbox row. The branch and commit stay off this row. They live on the newest
* run, and the list query does not load runs.
*/
export const promotionReviewSummarySchema = z.object({
id: n8nIdSchema,
state: promotionReviewStateSchema,
/** Host reference for the review type. GitLab stores `<projectId>!<iid>`. NULL when the review lives in n8n. */
remoteReviewId: z.string().nullable(),
connection: z.object({ id: n8nIdSchema, name: z.string() }).nullable(),
createdBy: reviewUserSchema.nullable(),
approvedBy: reviewUserSchema.nullable(),
createdAt: z.string().datetime(),
approvedAt: z.string().datetime().nullable(),
/** Time of the latest state change. NULL until the first change. */
lastStateChangeAt: z.string().datetime().nullable(),
remote: promotionReviewRemoteSchema.nullable(),
});
export type PromotionReviewSummary = z.infer<typeof promotionReviewSummarySchema>;
export const promotionReviewListPublicSchema = z.object({
count: z.number().int().nonnegative(),
data: z.array(promotionReviewSummarySchema),
});
export class PromotionReviewListPublicDto extends Z.class(promotionReviewListPublicSchema.shape) {}
/** Open rows are one tab, every terminal state is the other. */
export const promotionReviewTabSchema = z.enum(['open', 'closed']);
export type PromotionReviewTab = z.infer<typeof promotionReviewTabSchema>;
export class ListPromotionReviewsQueryDto extends Z.class({
tab: promotionReviewTabSchema.optional(),
offset: z.coerce.number().int().nonnegative().optional(),
limit: z.coerce.number().int().positive().max(100).optional(),
}) {}
export const promotionReviewChangeKindSchema = z.enum(['added', 'modified', 'deleted']);
export type PromotionReviewChangeKind = z.infer<typeof promotionReviewChangeKindSchema>;
/** A workflow the Promotion Review changed against the Review Baseline. */
export const promotionReviewWorkflowChangeSchema = z.object({
workflowId: z.string(),
name: z.string(),
projectName: z.string().nullable(),
change: promotionReviewChangeKindSchema,
/** Repository path of the workflow file at the head commit, or at the baseline for a deletion. */
path: z.string(),
});
export type PromotionReviewWorkflowChange = z.infer<typeof promotionReviewWorkflowChangeSchema>;
/** The detail view: the row, the newest run, the merge request, and the changed workflows. */
export const promotionReviewDetailSchema = promotionReviewSummarySchema.extend({
/** The branch of the newest run. */
branchName: z.string(),
/** The commit of the newest run. */
commitSha: z.string(),
mergeRequest: z
.object({
description: z.string().nullable(),
sourceBranch: z.string(),
targetBranch: z.string(),
authorName: z.string().nullable(),
approvalsLeft: z.number().int().nullable(),
mergeStatus: z.string().nullable(),
})
.nullable(),
/**
* The commit the review diffs against. Open: the merge base of the promotion
* branch head and the base branch. Merged or closed: the base the host reports.
* NULL when neither can be read.
*/
baselineCommitSha: z.string().nullable(),
workflows: z.array(promotionReviewWorkflowChangeSchema),
/** Why parts of the detail are missing, for the UI to show. */
warnings: z.array(z.string()),
});
export class PromotionReviewDetailDto extends Z.class(promotionReviewDetailSchema.shape) {}
/** The two sides of a workflow diff. A side is NULL when the workflow does not exist on that commit. */
export const promotionReviewWorkflowDiffSchema = z.object({
workflowId: z.string(),
baselineCommitSha: z.string().nullable(),
headCommitSha: z.string(),
base: z.record(z.unknown()).nullable(),
head: z.record(z.unknown()).nullable(),
});
export class PromotionReviewWorkflowDiffDto extends Z.class(
promotionReviewWorkflowDiffSchema.shape,
) {}
/** The merge request a Promote opened, as returned with the promote result. */
export const promotionMergeRequestResultSchema = z.object({
reviewId: n8nIdSchema,
iid: z.number().int(),
webUrl: z.string(),
});
export type PromotionMergeRequestResult = z.infer<typeof promotionMergeRequestResultSchema>;

View File

@ -0,0 +1,38 @@
import { z } from 'zod';
/**
* How the package moved. GitHub, GitLab and plain Git are all `git`. The
* application checks this value. The database does not.
*/
export const promotionRunTypeSchema = z.enum(['git']);
export type PromotionRunType = z.infer<typeof promotionRunTypeSchema>;
/**
* Payload for a `git` run. `branchName` is the branch the run pushed to or
* applied from. `commitSha` is the commit it pushed or applied.
*/
export const promotionGitRunDetailsSchema = z
.object({
schemaVersion: z.literal(1),
branchName: z.string().min(1).max(255),
commitSha: z.string().min(1).max(64),
})
.strict();
export type PromotionGitRunDetails = z.infer<typeof promotionGitRunDetailsSchema>;
/** Details schema for each run type. Code selects the schema from the type column. */
export const promotionRunDetailsSchemas = {
git: promotionGitRunDetailsSchema,
} as const;
export type PromotionRunDetails = {
[Type in PromotionRunType]: z.infer<(typeof promotionRunDetailsSchemas)[Type]>;
}[PromotionRunType];
/** Parse `details` with the schema for `type`. Do not infer the type from the payload. */
export function parsePromotionRunDetails<T extends PromotionRunType>(
type: T,
details: unknown,
): z.infer<(typeof promotionRunDetailsSchemas)[T]> {
return promotionRunDetailsSchemas[type].parse(details);
}

View File

@ -0,0 +1,147 @@
import type { MigrationContext, ReversibleMigration } from '../migration-types';
const runTable = 'promotion_run';
const reviewTable = 'promotion_review';
const configTable = 'promotion_config';
const connectionTable = 'promotion_connection';
const userTable = 'user';
// Inlined on purpose: a migration must not import live enums from other packages.
const directions = ['promote', 'apply'];
const reviewTypes = ['gitlab'];
const reviewStates = ['open', 'changes_requested', 'merged', 'closed'];
const runTypes = ['git'];
/**
* A promotion run is one successful Promote or Apply. A promotion review is the
* gate on that push. The run points at the review, so one review can collect
* several runs. A direct push has a run and no review.
*/
export class CreatePromotionRunAndReviewTables1792139680417 implements ReversibleMigration {
async up({
schemaBuilder: { createTable, column, createIndex },
tablePrefix,
escape,
}: MigrationContext) {
// The run references the review, so the review table comes first.
await createTable(reviewTable)
.withColumns(
column('id').uuid.primary,
column('connectionId')
.varchar(36)
.comment(
'The connection that remoteReviewId resolves through. NULL after the connection is deleted.',
),
column('type')
.varchar(16)
.notNull.withEnumCheck(reviewTypes)
.comment('PromotionReviewType enum: "gitlab".'),
column('remoteReviewId')
.varchar(255)
.comment(
'Host reference for type. GitLab stores "<projectId>!<iid>". NULL when the review lives in n8n.',
),
column('state')
.varchar(32)
.notNull.default("'open'")
.withEnumCheck(reviewStates)
.comment(
'PromotionReviewState enum: "open", "changes_requested", "merged", "closed". "merged" and "closed" are terminal.',
),
column('lastStateChangeAt')
.timestampTimezone()
.comment(
'Time of the latest state change. Covers every transition, including changes_requested and a reopened review. NULL until the first change.',
),
column('createdById').uuid.comment(
'The n8n user whose Promote opened this review. NULL after that user is deleted.',
),
column('approvedById').uuid.comment(
'The one n8n user who approved the review in n8n. NULL until then. n8n does not copy an approver from the host.',
),
column('approvedAt').timestampTimezone().comment('When the n8n user approved the review.'),
)
.withCreatedAt.withForeignKey('connectionId', {
tableName: connectionTable,
columnName: 'id',
onDelete: 'SET NULL',
name: `FK_${tablePrefix}promotion_review_connectionId`,
})
.withForeignKey('createdById', {
tableName: userTable,
columnName: 'id',
onDelete: 'SET NULL',
name: `FK_${tablePrefix}promotion_review_createdById`,
})
.withForeignKey('approvedById', {
tableName: userTable,
columnName: 'id',
onDelete: 'SET NULL',
name: `FK_${tablePrefix}promotion_review_approvedById`,
})
// The inbox lists by state and orders by creation time.
.withIndexOn(['state', 'createdAt'])
// Foreign key index, for the SET NULL when the connection is deleted.
.withIndexOn(['connectionId']);
// Two hosts can use the same project id and iid. NULL remote ids are not unique.
await createIndex(
reviewTable,
['connectionId', 'remoteReviewId'],
true,
undefined,
`${escape.columnName('remoteReviewId')} IS NOT NULL`,
);
await createTable(runTable)
.withColumns(
column('id').uuid.primary,
column('configId')
.varchar(36)
.comment('The config this run used. NULL after the config is deleted.'),
column('direction')
.varchar(16)
.notNull.withEnumCheck(directions)
.comment(
'PromotionDirection enum: "promote" pushes to the remote, "apply" imports from it. Copied from the config when the run is written.',
),
column('type')
.varchar(16)
.notNull.withEnumCheck(runTypes)
.comment('PromotionRunType enum: "git". Git covers GitHub, GitLab and plain Git.'),
column('createdById').uuid.comment('The user who ran Promote or Apply.'),
column('details').json.comment(
'Payload for type. A git run stores schemaVersion, branchName and commitSha. NULL when the type has no details.',
),
column('reviewId').uuid.comment(
'The review this run belongs to. NULL when the push has no review. Not unique: an amend adds another run.',
),
)
.withCreatedAt.withForeignKey('configId', {
tableName: configTable,
columnName: 'id',
onDelete: 'SET NULL',
name: `FK_${tablePrefix}promotion_run_configId`,
})
.withForeignKey('createdById', {
tableName: userTable,
columnName: 'id',
onDelete: 'SET NULL',
name: `FK_${tablePrefix}promotion_run_createdById`,
})
.withForeignKey('reviewId', {
tableName: reviewTable,
columnName: 'id',
onDelete: 'SET NULL',
name: `FK_${tablePrefix}promotion_run_reviewId`,
})
// Foreign key indexes, for SET NULL when the config or the review is deleted.
.withIndexOn(['configId'])
.withIndexOn(['reviewId']);
}
async down({ schemaBuilder: { dropTable } }: MigrationContext) {
await dropTable(runTable);
await dropTable(reviewTable);
}
}

View File

@ -0,0 +1,289 @@
import { testDb, testModules } from '@n8n/backend-test-utils';
import type { User } from '@n8n/db';
import { UserRepository } from '@n8n/db';
import { Container } from '@n8n/di';
import { v7 as uuidv7 } from 'uuid';
import { createMember, createOwner } from '@test-integration/db/users';
import { PromotionConfigRepository } from '../database/repositories/promotion-config.repository';
import { PromotionConnectionRepository } from '../database/repositories/promotion-connection.repository';
import { PromotionProviderRepository } from '../database/repositories/promotion-provider.repository';
import { PromotionReviewRepository } from '../database/repositories/promotion-review.repository';
import { PromotionRunRepository } from '../database/repositories/promotion-run.repository';
let providerRepository: PromotionProviderRepository;
let connectionRepository: PromotionConnectionRepository;
let configRepository: PromotionConfigRepository;
let runRepository: PromotionRunRepository;
let reviewRepository: PromotionReviewRepository;
let owner: User;
let connectionId: string;
let configId: string;
let providerId: string;
beforeAll(async () => {
await testModules.loadModules(['promotions']);
await testDb.init();
providerRepository = Container.get(PromotionProviderRepository);
connectionRepository = Container.get(PromotionConnectionRepository);
configRepository = Container.get(PromotionConfigRepository);
runRepository = Container.get(PromotionRunRepository);
reviewRepository = Container.get(PromotionReviewRepository);
});
afterAll(async () => {
await testDb.terminate();
});
beforeEach(async () => {
// Runs point at reviews and configs, so delete the runs first.
await runRepository.delete({});
await reviewRepository.delete({});
await configRepository.delete({});
await connectionRepository.delete({});
await providerRepository.delete({});
await testDb.truncate(['User']);
owner = await createOwner();
// Only one instance connection may exist, so every review of a test shares it.
const provider = await providerRepository.insertProvider({
name: 'repo',
type: 'git',
authType: 'token',
config: { schemaVersion: 1 },
auth: 'encrypted-repo',
});
providerId = provider.id;
const connection = await connectionRepository.insertConnection({
name: 'repo',
scope: 'instance',
providerId: provider.id,
target: { schemaVersion: 1, remoteUrl: 'https://example.com/org/repo.git' },
});
connectionId = connection.id;
const config = await configRepository.insertConfig({
connectionId,
direction: 'promote',
name: 'Promote',
settings: { schemaVersion: 1, baseBranchName: 'main', createBranchOnPromotion: true },
});
configId = config.id;
});
let reviewCount = 0;
async function insertReview(over: { id?: string; remoteReviewId?: string } = {}) {
reviewCount += 1;
const review = await reviewRepository.insertReview({
id: over.id ?? uuidv7(),
connectionId,
type: 'gitlab',
remoteReviewId: over.remoteReviewId ?? `7!${reviewCount}`,
createdById: owner.id,
});
const run = await runRepository.insertRun({
configId,
createdById: owner.id,
direction: 'promote',
type: 'git',
details: {
schemaVersion: 1,
branchName: `n8n-promotion/${reviewCount}`,
commitSha: String(reviewCount).repeat(40).slice(0, 40),
},
reviewId: review.id,
});
return { review, run };
}
describe('PromotionReviewRepository', () => {
it('stores a new review as open with the id the caller chose', async () => {
const chosenReviewId = '018f6b2e-7c3a-7000-8000-0000000000aa';
const { review, run } = await insertReview({ id: chosenReviewId });
const found = await reviewRepository.findByIdWithRelations(chosenReviewId);
expect(found).toMatchObject({
id: chosenReviewId,
state: 'open',
type: 'gitlab',
connectionId,
remoteReviewId: review.remoteReviewId,
lastStateChangeAt: null,
approvedById: null,
approvedAt: null,
createdById: owner.id,
});
expect(found?.createdBy?.id).toBe(owner.id);
expect(found?.connection?.id).toBe(connectionId);
expect(found?.runs).toHaveLength(1);
expect(found?.runs[0]).toMatchObject({
direction: 'promote',
type: 'git',
reviewId: review.id,
details: run.details,
});
expect(found?.runs[0]?.createdBy?.id).toBe(owner.id);
expect(found?.runs[0]?.config?.id).toBe(configId);
expect(found?.approvedBy).toBeNull();
});
it('attaches a recorded run to its review', async () => {
const review = await reviewRepository.insertReview({
id: uuidv7(),
connectionId,
type: 'gitlab',
remoteReviewId: '9!1',
createdById: owner.id,
});
const run = await runRepository.insertRun({
configId,
createdById: owner.id,
direction: 'promote',
type: 'git',
details: {
schemaVersion: 1,
branchName: 'n8n-promotion/later',
commitSha: 'c'.repeat(40),
},
reviewId: null,
});
await runRepository.attachReview(run.id, review.id);
const found = await reviewRepository.findByIdWithRelations(review.id);
expect(found?.runs).toHaveLength(1);
expect(found?.runs[0]).toMatchObject({ id: run.id, reviewId: review.id });
});
it('keeps the review when its config or approver is deleted', async () => {
const { review, run } = await insertReview();
const member = await createMember();
await reviewRepository.update(
{ id: review.id },
{ approvedById: member.id, approvedAt: new Date() },
);
await connectionRepository.delete({ id: connectionId });
await Container.get(UserRepository).delete({ id: member.id });
const found = await reviewRepository.findByIdWithRelations(review.id);
expect(found).toMatchObject({ approvedById: null, connectionId: null });
expect(found?.connection).toBeNull();
expect(found?.runs[0]?.configId).toBeNull();
expect(found?.runs[0]?.config).toBeNull();
expect(found?.runs[0]?.direction).toBe('promote');
expect(found?.runs[0]?.details).toEqual(run.details);
expect(found?.approvedBy).toBeNull();
expect(found?.runs[0]?.createdById).toBe(owner.id);
expect(found?.runs[0]?.createdBy?.id).toBe(owner.id);
});
it('keeps the review when its creator is deleted', async () => {
const { review } = await insertReview();
await Container.get(UserRepository).delete({ id: owner.id });
const found = await reviewRepository.findByIdWithRelations(review.id);
expect(found).toMatchObject({ createdById: null });
expect(found?.createdBy).toBeNull();
expect(found?.runs[0]?.createdById).toBeNull();
expect(found?.runs[0]?.createdBy).toBeNull();
});
it('lists newest first and filters by tab', async () => {
const { review: first } = await insertReview();
const { review: merged } = await insertReview();
const { review: closed } = await insertReview();
// Three inserts can share a millisecond, so pin the creation order.
for (const [index, review] of [first, merged, closed].entries()) {
await reviewRepository.update({ id: review.id }, { createdAt: new Date(1_000_000 * index) });
}
await reviewRepository.update(
{ id: merged.id },
{ state: 'merged', lastStateChangeAt: new Date() },
);
await reviewRepository.update(
{ id: closed.id },
{ state: 'closed', lastStateChangeAt: new Date() },
);
const all = await reviewRepository.listReviews({ offset: 0, limit: 10 });
expect(all.count).toBe(3);
expect(all.data.map((r) => r.id)).toEqual([closed.id, merged.id, first.id]);
const open = await reviewRepository.listReviews({ tab: 'open', offset: 0, limit: 10 });
expect(open.data.map((r) => r.id)).toEqual([first.id]);
expect(open.data[0]?.createdBy?.id).toBe(owner.id);
const done = await reviewRepository.listReviews({ tab: 'closed', offset: 0, limit: 10 });
expect(done.count).toBe(2);
expect(done.data.map((r) => r.id)).toEqual([closed.id, merged.id]);
const page = await reviewRepository.listReviews({ offset: 1, limit: 1 });
expect(page.count).toBe(3);
expect(page.data.map((r) => r.id)).toEqual([merged.id]);
});
it('pages stably when reviews share a creation time', async () => {
const olderReviewId = '018f6b2e-7c3a-7000-8000-000000000001';
const newerReviewId = '018f6b2e-7c3a-7000-8000-000000000002';
const { review: older } = await insertReview({ id: olderReviewId });
const { review: newer } = await insertReview({ id: newerReviewId });
const createdAt = new Date('2026-01-01T00:00:00.000Z');
await reviewRepository.update({ id: older.id }, { createdAt });
await reviewRepository.update({ id: newer.id }, { createdAt });
const first = await reviewRepository.listReviews({ offset: 0, limit: 1 });
const second = await reviewRepository.listReviews({ offset: 1, limit: 1 });
expect(first.data.map((r) => r.id)).toEqual([newerReviewId]);
expect(second.data.map((r) => r.id)).toEqual([olderReviewId]);
});
it('rejects a second review with the same remote id on one connection', async () => {
await insertReview({ remoteReviewId: '7!1' });
await expect(insertReview({ remoteReviewId: '7!1' })).rejects.toThrow();
});
it('allows the same remote id on another connection', async () => {
await insertReview({ remoteReviewId: '7!1' });
const other = await connectionRepository.insertConnection({
name: 'other',
scope: 'projects',
providerId,
target: { schemaVersion: 1, remoteUrl: 'https://example.com/org/other.git' },
});
const review = await reviewRepository.insertReview({
id: uuidv7(),
connectionId: other.id,
type: 'gitlab',
remoteReviewId: '7!1',
createdById: owner.id,
});
expect(review.remoteReviewId).toBe('7!1');
});
it('allows several reviews without a remote id', async () => {
await reviewRepository.insertReview({
id: uuidv7(),
connectionId,
type: 'gitlab',
remoteReviewId: null,
createdById: owner.id,
});
await reviewRepository.insertReview({
id: uuidv7(),
connectionId,
type: 'gitlab',
remoteReviewId: null,
createdById: owner.id,
});
const listed = await reviewRepository.listReviews({ offset: 0, limit: 10 });
expect(listed.count).toBe(2);
});
});

View File

@ -0,0 +1,89 @@
import type { PromotionReviewState, PromotionReviewType } from '@n8n/api-types';
import { DateTimeColumn, User, WithCreatedAt } from '@n8n/db';
import {
BeforeInsert,
Column,
Entity,
Index,
JoinColumn,
ManyToOne,
OneToMany,
PrimaryColumn,
type Relation,
} from '@n8n/typeorm';
import { v7 as uuidv7 } from 'uuid';
import { PromotionConnection } from './promotion-connection.entity';
import { PromotionRun } from './promotion-run.entity';
/**
* The gate on a promote. Several runs can point at one review. The host owns
* the review while it is `open` or `changes_requested`. n8n owns the state once
* it is terminal. Title, URL and conflicts are read from the host.
*/
@Entity('promotion_review')
@Index(['state', 'createdAt'])
@Index(['connectionId', 'remoteReviewId'], {
unique: true,
where: '"remoteReviewId" IS NOT NULL',
})
export class PromotionReview extends WithCreatedAt {
@PrimaryColumn({ type: 'uuid' })
id: string;
@BeforeInsert()
generateId() {
if (!this.id) this.id = uuidv7();
}
@Index()
@Column({ type: 'varchar', length: 36, nullable: true })
connectionId: string | null;
/** Where the review lives. The application checks the value. */
@Column({ type: 'varchar', length: 16 })
type: PromotionReviewType;
/**
* Host reference for {@link type}. GitLab stores `<projectId>!<iid>`.
* NULL when the review lives in n8n.
*/
@Column({ type: 'varchar', length: 255, nullable: true })
remoteReviewId: string | null;
@Column({ type: 'varchar', length: 32, default: 'open' })
state: PromotionReviewState;
/** Time of the latest state change. NULL until the first change. */
@DateTimeColumn({ nullable: true })
lastStateChangeAt: Date | null;
/** The n8n user whose Promote opened this review. NULL after that user is deleted. */
@Column({ type: 'uuid', nullable: true })
createdById: string | null;
/** The one n8n user who approved in n8n. NULL until then. Not copied from the host. */
@Column({ type: 'uuid', nullable: true })
approvedById: string | null;
@DateTimeColumn({ nullable: true })
approvedAt: Date | null;
@ManyToOne(() => PromotionConnection, { onDelete: 'SET NULL', nullable: true })
@JoinColumn({ name: 'connectionId' })
connection: Relation<PromotionConnection> | null;
@ManyToOne(() => User, { onDelete: 'SET NULL', nullable: true })
@JoinColumn({ name: 'createdById' })
createdBy: Relation<User> | null;
@ManyToOne(() => User, { onDelete: 'SET NULL', nullable: true })
@JoinColumn({ name: 'approvedById' })
approvedBy: Relation<User> | null;
@OneToMany(
() => PromotionRun,
(run) => run.review,
)
runs: Relation<PromotionRun>[];
}

View File

@ -0,0 +1,75 @@
import type { PromotionDirection, PromotionRunDetails, PromotionRunType } from '@n8n/api-types';
import { JsonColumn, User, WithCreatedAt } from '@n8n/db';
import {
BeforeInsert,
Column,
Entity,
Index,
JoinColumn,
ManyToOne,
PrimaryColumn,
type Relation,
} from '@n8n/typeorm';
import { v7 as uuidv7 } from 'uuid';
import { PromotionConfig } from './promotion-config.entity';
import { PromotionReview } from './promotion-review.entity';
/**
* One successful Promote or Apply. Direction is a copy, so the row can still
* say Promote or Apply after the config is deleted. Git data lives in `details`.
* `reviewId` stays NULL when the push has no review.
*/
@Entity('promotion_run')
export class PromotionRun extends WithCreatedAt {
@PrimaryColumn({ type: 'uuid' })
id: string;
@BeforeInsert()
generateId() {
if (!this.id) this.id = uuidv7();
}
@Index()
@Column({ type: 'varchar', length: 36, nullable: true })
configId: string | null;
/** Copied from the config when the run is written. A later config edit does not change it. */
@Column({ type: 'varchar', length: 16 })
direction: PromotionDirection;
/** How the package moved. The application checks the value. */
@Column({ type: 'varchar', length: 16 })
type: PromotionRunType;
@Column({ type: 'uuid', nullable: true })
createdById: string | null;
/** Payload for {@link type}. A git run stores the branch and the commit. */
@JsonColumn({ nullable: true })
details: PromotionRunDetails | null;
/** Not unique. An amend adds another run for the same review. */
@Index()
@Column({ type: 'uuid', nullable: true })
reviewId: string | null;
@ManyToOne(() => PromotionConfig, { onDelete: 'SET NULL', nullable: true })
@JoinColumn({ name: 'configId' })
config: Relation<PromotionConfig> | null;
@ManyToOne(() => User, { onDelete: 'SET NULL', nullable: true })
@JoinColumn({ name: 'createdById' })
createdBy: Relation<User> | null;
@ManyToOne(
() => PromotionReview,
(review) => review.runs,
{
onDelete: 'SET NULL',
nullable: true,
},
)
@JoinColumn({ name: 'reviewId' })
review: Relation<PromotionReview> | null;
}

View File

@ -0,0 +1,69 @@
import type { PromotionReviewState, PromotionReviewTab } from '@n8n/api-types';
import { BaseRepository, type OperationContext, TransactionRunner } from '@n8n/db';
import { Service } from '@n8n/di';
import { DataSource, In, type FindOptionsWhere } from '@n8n/typeorm';
import { PromotionReview } from '../entities/promotion-review.entity';
/** The caller supplies the id, so the review link can exist before the row does. */
export type NewPromotionReview = Pick<
PromotionReview,
'id' | 'connectionId' | 'type' | 'remoteReviewId' | 'createdById'
>;
/** Still on the host. The open tab shows both. */
const OPEN_STATES: PromotionReviewState[] = ['open', 'changes_requested'];
const TERMINAL_STATES: PromotionReviewState[] = ['merged', 'closed'];
// Do not load runs for inbox rows. The summary does not use them.
const LIST_RELATIONS = {
connection: true,
createdBy: true,
approvedBy: true,
} as const;
/** The newest run carries the branch and the commit. Loaded only for one review. */
const DETAIL_RELATIONS = {
connection: true,
createdBy: true,
approvedBy: true,
runs: { config: { connection: true }, createdBy: true },
} as const;
@Service()
export class PromotionReviewRepository extends BaseRepository<PromotionReview> {
constructor(dataSource: DataSource, transactionRunner: TransactionRunner) {
super(PromotionReview, dataSource.manager, transactionRunner);
}
async insertReview(
review: NewPromotionReview,
ctx: OperationContext = {},
): Promise<PromotionReview> {
return await this.managerFor(ctx).save(PromotionReview, this.create(review));
}
async findByIdWithRelations(id: string): Promise<PromotionReview | null> {
return await this.findOne({ where: { id }, relations: DETAIL_RELATIONS });
}
/**
* Newest first, because the inbox sorts every review kind by creation time.
* `id` breaks ties. Reviews can share `createdAt`, and offset pages need a
* stable order.
*/
async listReviews(options: { tab?: PromotionReviewTab; offset: number; limit: number }) {
const where: FindOptionsWhere<PromotionReview> = {};
if (options.tab === 'open') where.state = In(OPEN_STATES);
if (options.tab === 'closed') where.state = In(TERMINAL_STATES);
const [data, count] = await this.findAndCount({
where,
relations: LIST_RELATIONS,
order: { createdAt: 'DESC', id: 'DESC' },
skip: options.offset,
take: options.limit,
});
return { count, data };
}
}

View File

@ -0,0 +1,27 @@
import { BaseRepository, type OperationContext, TransactionRunner } from '@n8n/db';
import { Service } from '@n8n/di';
import { DataSource } from '@n8n/typeorm';
import { PromotionRun } from '../entities/promotion-run.entity';
/** The id is generated on insert. `reviewId` stays NULL until a review is attached. */
export type NewPromotionRun = Pick<
PromotionRun,
'configId' | 'createdById' | 'direction' | 'type' | 'details' | 'reviewId'
>;
@Service()
export class PromotionRunRepository extends BaseRepository<PromotionRun> {
constructor(dataSource: DataSource, transactionRunner: TransactionRunner) {
super(PromotionRun, dataSource.manager, transactionRunner);
}
async insertRun(run: NewPromotionRun, ctx: OperationContext = {}): Promise<PromotionRun> {
return await this.managerFor(ctx).save(PromotionRun, this.create(run));
}
/** The only column that changes after insert. An amend inserts a new run instead. */
async attachReview(runId: string, reviewId: string, ctx: OperationContext = {}): Promise<void> {
await this.managerFor(ctx).update(PromotionRun, { id: runId }, { reviewId });
}
}

View File

@ -26,6 +26,15 @@ export class PromotionsModule implements ModuleInterface {
'./database/entities/promotion-connection-project.entity.js'
);
const { PromotionConfig } = await import('./database/entities/promotion-config.entity.js');
return [PromotionProvider, PromotionConnection, PromotionConnectionProject, PromotionConfig];
const { PromotionRun } = await import('./database/entities/promotion-run.entity.js');
const { PromotionReview } = await import('./database/entities/promotion-review.entity.js');
return [
PromotionProvider,
PromotionConnection,
PromotionConnectionProject,
PromotionConfig,
PromotionRun,
PromotionReview,
];
}
}