diff --git a/docs/generated/postgres-schema/README.md b/docs/generated/postgres-schema/README.md index 7b21b239c83..52084123841 100644 --- a/docs/generated/postgres-schema/README.md +++ b/docs/generated/postgres-schema/README.md @@ -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 diff --git a/docs/generated/postgres-schema/public.promotion_config.md b/docs/generated/postgres-schema/public.promotion_config.md index ef0bf07cc50..fd73b5b4a6f 100644 --- a/docs/generated/postgres-schema/public.promotion_config.md +++ b/docs/generated/postgres-schema/public.promotion_config.md @@ -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 +} ``` --- diff --git a/docs/generated/postgres-schema/public.promotion_connection.md b/docs/generated/postgres-schema/public.promotion_connection.md index 07f341ecc18..b450e1d4a56 100644 --- a/docs/generated/postgres-schema/public.promotion_connection.md +++ b/docs/generated/postgres-schema/public.promotion_connection.md @@ -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 diff --git a/docs/generated/postgres-schema/public.promotion_review.md b/docs/generated/postgres-schema/public.promotion_review.md new file mode 100644 index 00000000000..e8133ca581c --- /dev/null +++ b/docs/generated/postgres-schema/public.promotion_review.md @@ -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 "\!\". 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) diff --git a/docs/generated/postgres-schema/public.promotion_run.md b/docs/generated/postgres-schema/public.promotion_run.md new file mode 100644 index 00000000000..65ff3abf239 --- /dev/null +++ b/docs/generated/postgres-schema/public.promotion_run.md @@ -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) diff --git a/docs/generated/postgres-schema/public.user.md b/docs/generated/postgres-schema/public.user.md index 8db545537e7..24f19c09860 100644 --- a/docs/generated/postgres-schema/public.user.md +++ b/docs/generated/postgres-schema/public.user.md @@ -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 diff --git a/docs/generated/sqlite-schema/README.md b/docs/generated/sqlite-schema/README.md index 19db9d7643f..af3996f06fa 100644 --- a/docs/generated/sqlite-schema/README.md +++ b/docs/generated/sqlite-schema/README.md @@ -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 diff --git a/docs/generated/sqlite-schema/promotion_config.md b/docs/generated/sqlite-schema/promotion_config.md index 1362b193428..8636a5e62f1 100644 --- a/docs/generated/sqlite-schema/promotion_config.md +++ b/docs/generated/sqlite-schema/promotion_config.md @@ -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 +} ``` --- diff --git a/docs/generated/sqlite-schema/promotion_connection.md b/docs/generated/sqlite-schema/promotion_connection.md index 5674fd75eb5..c4d507dbe13 100644 --- a/docs/generated/sqlite-schema/promotion_connection.md +++ b/docs/generated/sqlite-schema/promotion_connection.md @@ -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 diff --git a/docs/generated/sqlite-schema/promotion_review.md b/docs/generated/sqlite-schema/promotion_review.md new file mode 100644 index 00000000000..750aae0f2c6 --- /dev/null +++ b/docs/generated/sqlite-schema/promotion_review.md @@ -0,0 +1,112 @@ +# promotion_review + +## Description + +
+Table Definition + +```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) +``` + +
+ +## 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) diff --git a/docs/generated/sqlite-schema/promotion_run.md b/docs/generated/sqlite-schema/promotion_run.md new file mode 100644 index 00000000000..5976728502d --- /dev/null +++ b/docs/generated/sqlite-schema/promotion_run.md @@ -0,0 +1,108 @@ +# promotion_run + +## Description + +
+Table Definition + +```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) +``` + +
+ +## 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) diff --git a/docs/generated/sqlite-schema/user.md b/docs/generated/sqlite-schema/user.md index 1402b80f8f9..995ae1e8161 100644 --- a/docs/generated/sqlite-schema/user.md +++ b/docs/generated/sqlite-schema/user.md @@ -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 diff --git a/packages/@n8n/api-types/src/dto/index.ts b/packages/@n8n/api-types/src/dto/index.ts index a6fdffed2f2..69e117051b9 100644 --- a/packages/@n8n/api-types/src/dto/index.ts +++ b/packages/@n8n/api-types/src/dto/index.ts @@ -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'; diff --git a/packages/@n8n/api-types/src/dto/promotions/__tests__/promotion-run.dto.test.ts b/packages/@n8n/api-types/src/dto/promotions/__tests__/promotion-run.dto.test.ts new file mode 100644 index 00000000000..9af17489ae0 --- /dev/null +++ b/packages/@n8n/api-types/src/dto/promotions/__tests__/promotion-run.dto.test.ts @@ -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(); + }); +}); diff --git a/packages/@n8n/api-types/src/dto/promotions/promotion-review.dto.ts b/packages/@n8n/api-types/src/dto/promotions/promotion-review.dto.ts new file mode 100644 index 00000000000..80df2faf2ef --- /dev/null +++ b/packages/@n8n/api-types/src/dto/promotions/promotion-review.dto.ts @@ -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; + +/** + * Where the review lives. The application checks this value. The database does not. + */ +export const promotionReviewTypeSchema = z.enum(['gitlab']); +export type PromotionReviewType = z.infer; + +/** + * Host reference for each review type. Code selects the schema from the type + * column. GitLab stores `!`. 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( + 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; + +/** + * 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 `!`. 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; + +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; + +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; + +/** 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; + +/** 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; diff --git a/packages/@n8n/api-types/src/dto/promotions/promotion-run.dto.ts b/packages/@n8n/api-types/src/dto/promotions/promotion-run.dto.ts new file mode 100644 index 00000000000..3939f4f85ed --- /dev/null +++ b/packages/@n8n/api-types/src/dto/promotions/promotion-run.dto.ts @@ -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; + +/** + * 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; + +/** 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( + type: T, + details: unknown, +): z.infer<(typeof promotionRunDetailsSchemas)[T]> { + return promotionRunDetailsSchemas[type].parse(details); +} diff --git a/packages/@n8n/db/src/migrations/common/1792139680417-CreatePromotionRunAndReviewTables.ts b/packages/@n8n/db/src/migrations/common/1792139680417-CreatePromotionRunAndReviewTables.ts new file mode 100644 index 00000000000..0a3c5bc57fe --- /dev/null +++ b/packages/@n8n/db/src/migrations/common/1792139680417-CreatePromotionRunAndReviewTables.ts @@ -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 "!". 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); + } +} diff --git a/packages/cli/src/modules/promotions.ee/__tests__/promotion-review.repository.integration.test.ts b/packages/cli/src/modules/promotions.ee/__tests__/promotion-review.repository.integration.test.ts new file mode 100644 index 00000000000..6ae76f70049 --- /dev/null +++ b/packages/cli/src/modules/promotions.ee/__tests__/promotion-review.repository.integration.test.ts @@ -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); + }); +}); diff --git a/packages/cli/src/modules/promotions.ee/database/entities/promotion-review.entity.ts b/packages/cli/src/modules/promotions.ee/database/entities/promotion-review.entity.ts new file mode 100644 index 00000000000..9d31d5269ac --- /dev/null +++ b/packages/cli/src/modules/promotions.ee/database/entities/promotion-review.entity.ts @@ -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 `!`. + * 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 | null; + + @ManyToOne(() => User, { onDelete: 'SET NULL', nullable: true }) + @JoinColumn({ name: 'createdById' }) + createdBy: Relation | null; + + @ManyToOne(() => User, { onDelete: 'SET NULL', nullable: true }) + @JoinColumn({ name: 'approvedById' }) + approvedBy: Relation | null; + + @OneToMany( + () => PromotionRun, + (run) => run.review, + ) + runs: Relation[]; +} diff --git a/packages/cli/src/modules/promotions.ee/database/entities/promotion-run.entity.ts b/packages/cli/src/modules/promotions.ee/database/entities/promotion-run.entity.ts new file mode 100644 index 00000000000..139fa1d5e3d --- /dev/null +++ b/packages/cli/src/modules/promotions.ee/database/entities/promotion-run.entity.ts @@ -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 | null; + + @ManyToOne(() => User, { onDelete: 'SET NULL', nullable: true }) + @JoinColumn({ name: 'createdById' }) + createdBy: Relation | null; + + @ManyToOne( + () => PromotionReview, + (review) => review.runs, + { + onDelete: 'SET NULL', + nullable: true, + }, + ) + @JoinColumn({ name: 'reviewId' }) + review: Relation | null; +} diff --git a/packages/cli/src/modules/promotions.ee/database/repositories/promotion-review.repository.ts b/packages/cli/src/modules/promotions.ee/database/repositories/promotion-review.repository.ts new file mode 100644 index 00000000000..7f53427f4d3 --- /dev/null +++ b/packages/cli/src/modules/promotions.ee/database/repositories/promotion-review.repository.ts @@ -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 { + constructor(dataSource: DataSource, transactionRunner: TransactionRunner) { + super(PromotionReview, dataSource.manager, transactionRunner); + } + + async insertReview( + review: NewPromotionReview, + ctx: OperationContext = {}, + ): Promise { + return await this.managerFor(ctx).save(PromotionReview, this.create(review)); + } + + async findByIdWithRelations(id: string): Promise { + 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 = {}; + 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 }; + } +} diff --git a/packages/cli/src/modules/promotions.ee/database/repositories/promotion-run.repository.ts b/packages/cli/src/modules/promotions.ee/database/repositories/promotion-run.repository.ts new file mode 100644 index 00000000000..918189c145f --- /dev/null +++ b/packages/cli/src/modules/promotions.ee/database/repositories/promotion-run.repository.ts @@ -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 { + constructor(dataSource: DataSource, transactionRunner: TransactionRunner) { + super(PromotionRun, dataSource.manager, transactionRunner); + } + + async insertRun(run: NewPromotionRun, ctx: OperationContext = {}): Promise { + 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 { + await this.managerFor(ctx).update(PromotionRun, { id: runId }, { reviewId }); + } +} diff --git a/packages/cli/src/modules/promotions.ee/promotions.module.ts b/packages/cli/src/modules/promotions.ee/promotions.module.ts index 8a30f8f9572..fd359cb2154 100644 --- a/packages/cli/src/modules/promotions.ee/promotions.module.ts +++ b/packages/cli/src/modules/promotions.ee/promotions.module.ts @@ -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, + ]; } }