From bb76c3c99b5f52c2c210231d51caf645368af4a1 Mon Sep 17 00:00:00 2001 From: mfsiega <93014743+mfsiega@users.noreply.github.com> Date: Wed, 7 Oct 2026 11:36:58 +0000 Subject: [PATCH] feat(core): Add the migration workflow owner table (no-changelog) (#40223) Co-authored-by: Claude Fable 5.1 --- docs/generated/postgres-schema/README.md | 13 ++ .../public.migration_workflow_owner.md | 97 +++++++++ docs/generated/postgres-schema/public.user.md | 13 +- .../postgres-schema/public.workflow_entity.md | 12 +- docs/generated/sqlite-schema/README.md | 13 ++ .../sqlite-schema/migration_workflow_owner.md | 105 +++++++++ docs/generated/sqlite-schema/user.md | 13 +- .../sqlite-schema/workflow_entity.md | 12 +- ...97000-CreateMigrationWorkflowOwnerTable.ts | 55 +++++ .../src/modules/breaking-changes/README.md | 2 +- ...kflow-owner.repository.integration.test.ts | 199 ++++++++++++++++++ .../breaking-changes.module.ts | 5 +- .../migration-workflow-owner.entity.ts | 52 +++++ .../migration-workflow-owner.repository.ts | 72 +++++++ 14 files changed, 657 insertions(+), 6 deletions(-) create mode 100644 docs/generated/postgres-schema/public.migration_workflow_owner.md create mode 100644 docs/generated/sqlite-schema/migration_workflow_owner.md create mode 100644 packages/@n8n/db/src/migrations/common/1791366497000-CreateMigrationWorkflowOwnerTable.ts create mode 100644 packages/cli/src/modules/breaking-changes/__tests__/migration-workflow-owner.repository.integration.test.ts create mode 100644 packages/cli/src/modules/breaking-changes/database/entities/migration-workflow-owner.entity.ts create mode 100644 packages/cli/src/modules/breaking-changes/database/repositories/migration-workflow-owner.repository.ts diff --git a/docs/generated/postgres-schema/README.md b/docs/generated/postgres-schema/README.md index d219fc46f2a..d14920a4bfd 100644 --- a/docs/generated/postgres-schema/README.md +++ b/docs/generated/postgres-schema/README.md @@ -102,6 +102,7 @@ Auto-generated from the PostgreSQL migrations in @n8n/db. Do not edit by hand. | [public.mcp_registry_server](public.mcp_registry_server.md) | 7 | | BASE TABLE | | [public.migration_finding](public.migration_finding.md) | 10 | | BASE TABLE | | [public.migration_finding_sync](public.migration_finding_sync.md) | 3 | | BASE TABLE | +| [public.migration_workflow_owner](public.migration_workflow_owner.md) | 7 | | BASE TABLE | | [public.oauth_access_tokens](public.oauth_access_tokens.md) | 3 | | BASE TABLE | | [public.oauth_authorization_codes](public.oauth_authorization_codes.md) | 13 | | BASE TABLE | | [public.oauth_clients](public.oauth_clients.md) | 10 | | BASE TABLE | @@ -326,6 +327,9 @@ erDiagram "public.instance_ai_threads" }o--|| "public.project" : "FOREIGN KEY (#quot;projectId#quot;) REFERENCES project(id) ON DELETE CASCADE" "public.instance_credential_assignment" }o--|| "public.credentials_entity" : "FOREIGN KEY (#quot;credentialId#quot;) REFERENCES credentials_entity(id) ON DELETE RESTRICT" "public.migration_finding" }o--|| "public.workflow_entity" : "FOREIGN KEY (#quot;workflowId#quot;) REFERENCES workflow_entity(id) ON DELETE CASCADE" +"public.migration_workflow_owner" }o--o| "public.user" : "FOREIGN KEY (#quot;userId#quot;) REFERENCES #quot;user#quot;(id) ON DELETE SET NULL" +"public.migration_workflow_owner" }o--o| "public.user" : "FOREIGN KEY (#quot;assignedById#quot;) REFERENCES #quot;user#quot;(id) ON DELETE SET NULL" +"public.migration_workflow_owner" |o--|| "public.workflow_entity" : "FOREIGN KEY (#quot;workflowId#quot;) REFERENCES workflow_entity(id) ON DELETE CASCADE" "public.oauth_access_tokens" }o--|| "public.user" : "FOREIGN KEY (#quot;userId#quot;) REFERENCES #quot;user#quot;(id) ON DELETE CASCADE" "public.oauth_access_tokens" }o--|| "public.oauth_clients" : "FOREIGN KEY (#quot;clientId#quot;) REFERENCES oauth_clients(id) ON DELETE CASCADE" "public.oauth_authorization_codes" }o--|| "public.user" : "FOREIGN KEY (#quot;userId#quot;) REFERENCES #quot;user#quot;(id) ON DELETE CASCADE" @@ -1363,6 +1367,15 @@ erDiagram timestamp_3__with_time_zone syncedAt varchar_16_ targetVersion } +"public.migration_workflow_owner" { + timestamp_3__with_time_zone assignedAt + uuid assignedById FK + timestamp_3__with_time_zone createdAt + varchar_16_ source + timestamp_3__with_time_zone updatedAt + uuid userId FK + varchar_36_ workflowId FK +} "public.oauth_access_tokens" { varchar clientId FK varchar token diff --git a/docs/generated/postgres-schema/public.migration_workflow_owner.md b/docs/generated/postgres-schema/public.migration_workflow_owner.md new file mode 100644 index 00000000000..e05f66ebb37 --- /dev/null +++ b/docs/generated/postgres-schema/public.migration_workflow_owner.md @@ -0,0 +1,97 @@ +# public.migration_workflow_owner + +## Columns + +| Name | Type | Default | Nullable | Children | Parents | Comment | +| ---- | ---- | ------- | -------- | -------- | ------- | ------- | +| assignedAt | timestamp(3) with time zone | | true | | | When a person assigned the owner. NULL for a suggestion. | +| assignedById | uuid | | true | | [public.user](public.user.md) | Who assigned the owner. NULL for a suggestion, or after that user was deleted. | +| createdAt | timestamp(3) with time zone | CURRENT_TIMESTAMP(3) | false | | | | +| source | varchar(16) | | false | | | MigrationOwnerSource enum: "suggested" by the heuristic, or "assigned" by a person. | +| updatedAt | timestamp(3) with time zone | CURRENT_TIMESTAMP(3) | false | | | | +| userId | uuid | | true | | [public.user](public.user.md) | The owner. NULL after the user was deleted. | +| workflowId | varchar(36) | | false | | [public.workflow_entity](public.workflow_entity.md) | | + +## Constraints + +| Name | Type | Definition | +| ---- | ---- | ---------- | +| CHK_migration_workflow_owner_source | CHECK | CHECK (((source)::text = ANY ((ARRAY['suggested'::character varying, 'assigned'::character varying])::text[]))) | +| FK_59cde25bbb0edbb82286e4806d2 | FOREIGN KEY | FOREIGN KEY ("workflowId") REFERENCES workflow_entity(id) ON DELETE CASCADE | +| FK_ca859918be95d42fa6f698791c3 | FOREIGN KEY | FOREIGN KEY ("userId") REFERENCES "user"(id) ON DELETE SET NULL | +| FK_e6e5e4cab49ec23a544ffc9337a | FOREIGN KEY | FOREIGN KEY ("assignedById") REFERENCES "user"(id) ON DELETE SET NULL | +| PK_59cde25bbb0edbb82286e4806d2 | PRIMARY KEY | PRIMARY KEY ("workflowId") | +| migration_workflow_owner_createdAt_not_null | n | NOT NULL "createdAt" | +| migration_workflow_owner_source_not_null | n | NOT NULL source | +| migration_workflow_owner_updatedAt_not_null | n | NOT NULL "updatedAt" | +| migration_workflow_owner_workflowId_not_null | n | NOT NULL "workflowId" | + +## Indexes + +| Name | Definition | +| ---- | ---------- | +| IDX_ca859918be95d42fa6f698791c | CREATE INDEX "IDX_ca859918be95d42fa6f698791c" ON public.migration_workflow_owner USING btree ("userId") | +| PK_59cde25bbb0edbb82286e4806d2 | CREATE UNIQUE INDEX "PK_59cde25bbb0edbb82286e4806d2" ON public.migration_workflow_owner USING btree ("workflowId") | + +## Relations + +```mermaid +erDiagram + +"public.migration_workflow_owner" }o--o| "public.user" : "FOREIGN KEY (#quot;assignedById#quot;) REFERENCES #quot;user#quot;(id) ON DELETE SET NULL" +"public.migration_workflow_owner" }o--o| "public.user" : "FOREIGN KEY (#quot;userId#quot;) REFERENCES #quot;user#quot;(id) ON DELETE SET NULL" +"public.migration_workflow_owner" |o--|| "public.workflow_entity" : "FOREIGN KEY (#quot;workflowId#quot;) REFERENCES workflow_entity(id) ON DELETE CASCADE" + +"public.migration_workflow_owner" { + timestamp_3__with_time_zone assignedAt + uuid assignedById FK + timestamp_3__with_time_zone createdAt + varchar_16_ source + timestamp_3__with_time_zone updatedAt + uuid userId FK + varchar_36_ workflowId FK +} +"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.workflow_entity" { + boolean active + varchar_36_ activeVersionId FK + json connections + timestamp_3__with_time_zone createdAt + text description + varchar_36_ id + boolean isArchived + json meta + varchar_128_ name + json nodeGroups + json nodes + varchar_36_ parentFolderId FK + json pinData + json settings + varchar sourceWorkflowId + json staticData + integer triggerCount + timestamp_3__with_time_zone updatedAt + integer versionCounter + character_36_ versionId +} +``` + +--- + +> 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 e7bf93daf79..bc50e97c0a3 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.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.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.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) | | | +| 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.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.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) | | | | lastActiveAt | date | | true | | | | | lastName | varchar(32) | | true | | | | | mfaEnabled | boolean | false | false | | | | @@ -65,6 +65,8 @@ erDiagram "public.instance_ai_pending_confirmations" }o--|| "public.user" : "FOREIGN KEY (#quot;userId#quot;) REFERENCES #quot;user#quot;(id) ON DELETE CASCADE" "public.instance_ai_thread_grants" }o--|| "public.user" : "FOREIGN KEY (#quot;userId#quot;) REFERENCES #quot;user#quot;(id) ON DELETE CASCADE" "public.instance_ai_thread_tabs" }o--|| "public.user" : "FOREIGN KEY (#quot;userId#quot;) REFERENCES #quot;user#quot;(id) ON DELETE CASCADE" +"public.migration_workflow_owner" }o--o| "public.user" : "FOREIGN KEY (#quot;userId#quot;) REFERENCES #quot;user#quot;(id) ON DELETE SET NULL" +"public.migration_workflow_owner" }o--o| "public.user" : "FOREIGN KEY (#quot;assignedById#quot;) REFERENCES #quot;user#quot;(id) ON DELETE SET NULL" "public.oauth_access_tokens" }o--|| "public.user" : "FOREIGN KEY (#quot;userId#quot;) REFERENCES #quot;user#quot;(id) ON DELETE CASCADE" "public.oauth_authorization_codes" }o--|| "public.user" : "FOREIGN KEY (#quot;userId#quot;) REFERENCES #quot;user#quot;(id) ON DELETE CASCADE" "public.oauth_refresh_tokens" }o--|| "public.user" : "FOREIGN KEY (#quot;userId#quot;) REFERENCES #quot;user#quot;(id) ON DELETE CASCADE" @@ -300,6 +302,15 @@ erDiagram timestamp_3__with_time_zone updatedAt uuid userId FK } +"public.migration_workflow_owner" { + timestamp_3__with_time_zone assignedAt + uuid assignedById FK + timestamp_3__with_time_zone createdAt + varchar_16_ source + timestamp_3__with_time_zone updatedAt + uuid userId FK + varchar_36_ workflowId FK +} "public.oauth_access_tokens" { varchar clientId FK varchar token diff --git a/docs/generated/postgres-schema/public.workflow_entity.md b/docs/generated/postgres-schema/public.workflow_entity.md index 4e49ffec0fb..acd94f415e9 100644 --- a/docs/generated/postgres-schema/public.workflow_entity.md +++ b/docs/generated/postgres-schema/public.workflow_entity.md @@ -9,7 +9,7 @@ | connections | json | | false | | | | | createdAt | timestamp(3) with time zone | CURRENT_TIMESTAMP(3) | false | | | | | description | text | | true | | | | -| id | varchar(36) | | false | [public.agent_workflow_dependency](public.agent_workflow_dependency.md) [public.ai_builder_temporary_workflow](public.ai_builder_temporary_workflow.md) [public.chat_hub_messages](public.chat_hub_messages.md) [public.chat_hub_sessions](public.chat_hub_sessions.md) [public.evaluation_collection](public.evaluation_collection.md) [public.evaluation_config](public.evaluation_config.md) [public.execution_entity](public.execution_entity.md) [public.insights_metadata](public.insights_metadata.md) [public.migration_finding](public.migration_finding.md) [public.poller_state](public.poller_state.md) [public.processed_data](public.processed_data.md) [public.shared_workflow](public.shared_workflow.md) [public.test_run](public.test_run.md) [public.webhook_entity](public.webhook_entity.md) [public.workflow_builder_session](public.workflow_builder_session.md) [public.workflow_dependency](public.workflow_dependency.md) [public.workflow_history](public.workflow_history.md) [public.workflow_publication_retry_state](public.workflow_publication_retry_state.md) [public.workflow_publication_trigger_status](public.workflow_publication_trigger_status.md) [public.workflow_publish_history](public.workflow_publish_history.md) [public.workflow_published_version](public.workflow_published_version.md) [public.workflow_review_request_workflow](public.workflow_review_request_workflow.md) [public.workflow_suggestion](public.workflow_suggestion.md) [public.workflows_tags](public.workflows_tags.md) | | | +| id | varchar(36) | | false | [public.agent_workflow_dependency](public.agent_workflow_dependency.md) [public.ai_builder_temporary_workflow](public.ai_builder_temporary_workflow.md) [public.chat_hub_messages](public.chat_hub_messages.md) [public.chat_hub_sessions](public.chat_hub_sessions.md) [public.evaluation_collection](public.evaluation_collection.md) [public.evaluation_config](public.evaluation_config.md) [public.execution_entity](public.execution_entity.md) [public.insights_metadata](public.insights_metadata.md) [public.migration_finding](public.migration_finding.md) [public.migration_workflow_owner](public.migration_workflow_owner.md) [public.poller_state](public.poller_state.md) [public.processed_data](public.processed_data.md) [public.shared_workflow](public.shared_workflow.md) [public.test_run](public.test_run.md) [public.webhook_entity](public.webhook_entity.md) [public.workflow_builder_session](public.workflow_builder_session.md) [public.workflow_dependency](public.workflow_dependency.md) [public.workflow_history](public.workflow_history.md) [public.workflow_publication_retry_state](public.workflow_publication_retry_state.md) [public.workflow_publication_trigger_status](public.workflow_publication_trigger_status.md) [public.workflow_publish_history](public.workflow_publish_history.md) [public.workflow_published_version](public.workflow_published_version.md) [public.workflow_review_request_workflow](public.workflow_review_request_workflow.md) [public.workflow_suggestion](public.workflow_suggestion.md) [public.workflows_tags](public.workflows_tags.md) | | | | isArchived | boolean | false | false | | | | | meta | json | | true | | | | | name | varchar(128) | | false | | | | @@ -75,6 +75,7 @@ erDiagram "public.execution_entity" }o--|| "public.workflow_entity" : "FOREIGN KEY (#quot;workflowId#quot;) REFERENCES workflow_entity(id) ON DELETE CASCADE" "public.insights_metadata" }o--o| "public.workflow_entity" : "FOREIGN KEY (#quot;workflowId#quot;) REFERENCES workflow_entity(id) ON DELETE SET NULL" "public.migration_finding" }o--|| "public.workflow_entity" : "FOREIGN KEY (#quot;workflowId#quot;) REFERENCES workflow_entity(id) ON DELETE CASCADE" +"public.migration_workflow_owner" |o--|| "public.workflow_entity" : "FOREIGN KEY (#quot;workflowId#quot;) REFERENCES workflow_entity(id) ON DELETE CASCADE" "public.poller_state" }o--|| "public.workflow_entity" : "FOREIGN KEY (#quot;workflowId#quot;) REFERENCES workflow_entity(id) ON DELETE CASCADE" "public.processed_data" }o--|| "public.workflow_entity" : "FOREIGN KEY (#quot;workflowId#quot;) REFERENCES workflow_entity(id) ON DELETE CASCADE" "public.shared_workflow" }o--|| "public.workflow_entity" : "FOREIGN KEY (#quot;workflowId#quot;) REFERENCES workflow_entity(id) ON DELETE CASCADE" @@ -237,6 +238,15 @@ erDiagram timestamp_3__with_time_zone updatedAt varchar_36_ workflowId FK } +"public.migration_workflow_owner" { + timestamp_3__with_time_zone assignedAt + uuid assignedById FK + timestamp_3__with_time_zone createdAt + varchar_16_ source + timestamp_3__with_time_zone updatedAt + uuid userId FK + varchar_36_ workflowId FK +} "public.poller_state" { timestamp_3__with_time_zone backoffUntil integer consecutiveErrors diff --git a/docs/generated/sqlite-schema/README.md b/docs/generated/sqlite-schema/README.md index c59e4173d7a..1ec1288e327 100644 --- a/docs/generated/sqlite-schema/README.md +++ b/docs/generated/sqlite-schema/README.md @@ -102,6 +102,7 @@ Auto-generated from the SQLite migrations in @n8n/db. Do not edit by hand. | [mcp_registry_server](mcp_registry_server.md) | 7 | | table | | [migration_finding](migration_finding.md) | 10 | | table | | [migration_finding_sync](migration_finding_sync.md) | 3 | | table | +| [migration_workflow_owner](migration_workflow_owner.md) | 7 | | table | | [oauth_access_tokens](oauth_access_tokens.md) | 3 | | table | | [oauth_authorization_codes](oauth_authorization_codes.md) | 13 | | table | | [oauth_clients](oauth_clients.md) | 10 | | table | @@ -308,6 +309,9 @@ erDiagram "instance_ai_threads" }o--|| "project" : "FOREIGN KEY (projectId) REFERENCES project (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE" "instance_credential_assignment" }o--|| "credentials_entity" : "FOREIGN KEY (credentialId) REFERENCES credentials_entity (id) ON UPDATE NO ACTION ON DELETE RESTRICT MATCH NONE" "migration_finding" }o--|| "workflow_entity" : "FOREIGN KEY (workflowId) REFERENCES workflow_entity (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE" +"migration_workflow_owner" }o--o| "user" : "FOREIGN KEY (assignedById) REFERENCES user (id) ON UPDATE NO ACTION ON DELETE SET NULL MATCH NONE" +"migration_workflow_owner" }o--o| "user" : "FOREIGN KEY (userId) REFERENCES user (id) ON UPDATE NO ACTION ON DELETE SET NULL MATCH NONE" +"migration_workflow_owner" |o--|| "workflow_entity" : "FOREIGN KEY (workflowId) REFERENCES workflow_entity (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE" "oauth_access_tokens" }o--|| "user" : "FOREIGN KEY (userId) REFERENCES user (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE" "oauth_access_tokens" }o--|| "oauth_clients" : "FOREIGN KEY (clientId) REFERENCES oauth_clients (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE" "oauth_authorization_codes" }o--|| "oauth_clients" : "FOREIGN KEY (clientId) REFERENCES oauth_clients (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE" @@ -1349,6 +1353,15 @@ erDiagram datetime_3_ syncedAt varchar_16_ targetVersion PK } +"migration_workflow_owner" { + datetime_3_ assignedAt + varchar assignedById FK + datetime_3_ createdAt + varchar_16_ source + datetime_3_ updatedAt + varchar userId FK + varchar_36_ workflowId PK +} "oauth_access_tokens" { varchar clientId FK varchar token PK diff --git a/docs/generated/sqlite-schema/migration_workflow_owner.md b/docs/generated/sqlite-schema/migration_workflow_owner.md new file mode 100644 index 00000000000..37920366272 --- /dev/null +++ b/docs/generated/sqlite-schema/migration_workflow_owner.md @@ -0,0 +1,105 @@ +# migration_workflow_owner + +## Description + +
+Table Definition + +```sql +CREATE TABLE "migration_workflow_owner" ("workflowId" varchar(36) PRIMARY KEY NOT NULL, "userId" varchar, "source" varchar(16) NOT NULL, "assignedById" varchar, "assignedAt" datetime(3), "createdAt" datetime(3) NOT NULL DEFAULT (STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')), "updatedAt" datetime(3) NOT NULL DEFAULT (STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')), CONSTRAINT "CHK_migration_workflow_owner_source" CHECK ("source" IN ('suggested', 'assigned')), CONSTRAINT "FK_59cde25bbb0edbb82286e4806d2" FOREIGN KEY ("workflowId") REFERENCES "workflow_entity" ("id") ON DELETE CASCADE, CONSTRAINT "FK_ca859918be95d42fa6f698791c3" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE SET NULL, CONSTRAINT "FK_e6e5e4cab49ec23a544ffc9337a" FOREIGN KEY ("assignedById") REFERENCES "user" ("id") ON DELETE SET NULL) +``` + +
+ +## Columns + +| Name | Type | Default | Nullable | Children | Parents | Comment | +| ---- | ---- | ------- | -------- | -------- | ------- | ------- | +| assignedAt | datetime(3) | | true | | | | +| assignedById | varchar | | true | | [user](user.md) | | +| createdAt | datetime(3) | STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW') | false | | | | +| source | varchar(16) | | false | | | | +| updatedAt | datetime(3) | STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW') | false | | | | +| userId | varchar | | true | | [user](user.md) | | +| workflowId | varchar(36) | | false | | [workflow_entity](workflow_entity.md) | | + +## Constraints + +| Name | Type | Definition | +| ---- | ---- | ---------- | +| - | CHECK | CHECK ("source" IN ('suggested', 'assigned')) | +| - (Foreign key ID: 0) | FOREIGN KEY | FOREIGN KEY (assignedById) REFERENCES user (id) ON UPDATE NO ACTION ON DELETE SET NULL MATCH NONE | +| - (Foreign key ID: 1) | FOREIGN KEY | FOREIGN KEY (userId) REFERENCES user (id) ON UPDATE NO ACTION ON DELETE SET NULL MATCH NONE | +| - (Foreign key ID: 2) | FOREIGN KEY | FOREIGN KEY (workflowId) REFERENCES workflow_entity (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE | +| sqlite_autoindex_migration_workflow_owner_1 | PRIMARY KEY | PRIMARY KEY (workflowId) | +| workflowId | PRIMARY KEY | PRIMARY KEY (workflowId) | + +## Indexes + +| Name | Definition | +| ---- | ---------- | +| IDX_ca859918be95d42fa6f698791c | CREATE INDEX "IDX_ca859918be95d42fa6f698791c" ON "migration_workflow_owner" ("userId") | +| sqlite_autoindex_migration_workflow_owner_1 | PRIMARY KEY (workflowId) | + +## Relations + +```mermaid +erDiagram + +"migration_workflow_owner" }o--o| "user" : "FOREIGN KEY (assignedById) REFERENCES user (id) ON UPDATE NO ACTION ON DELETE SET NULL MATCH NONE" +"migration_workflow_owner" }o--o| "user" : "FOREIGN KEY (userId) REFERENCES user (id) ON UPDATE NO ACTION ON DELETE SET NULL MATCH NONE" +"migration_workflow_owner" |o--|| "workflow_entity" : "FOREIGN KEY (workflowId) REFERENCES workflow_entity (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE" + +"migration_workflow_owner" { + datetime_3_ assignedAt + varchar assignedById FK + datetime_3_ createdAt + varchar_16_ source + datetime_3_ updatedAt + varchar userId FK + varchar_36_ workflowId PK +} +"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 +} +"workflow_entity" { + boolean active + varchar_36_ activeVersionId FK + TEXT connections + datetime_3_ createdAt + TEXT description + varchar_36_ id PK + boolean isArchived + TEXT meta + varchar_128_ name + TEXT nodeGroups + TEXT nodes + varchar_36_ parentFolderId FK + TEXT pinData + TEXT settings + varchar sourceWorkflowId + TEXT staticData + INTEGER triggerCount + datetime_3_ updatedAt + INTEGER versionCounter + varchar_36_ versionId +} +``` + +--- + +> 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 8315e040518..19aa81493a2 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) [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) [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) [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) | | | +| 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) [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) [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) | | | | lastActiveAt | date | | true | | | | | lastName | varchar(32) | | true | | | | | mfaEnabled | boolean | FALSE | false | | | | @@ -71,6 +71,8 @@ erDiagram "instance_ai_pending_confirmations" }o--|| "user" : "FOREIGN KEY (userId) REFERENCES user (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE" "instance_ai_thread_grants" |o--|| "user" : "FOREIGN KEY (userId) REFERENCES user (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE" "instance_ai_thread_tabs" |o--|| "user" : "FOREIGN KEY (userId) REFERENCES user (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE" +"migration_workflow_owner" }o--o| "user" : "FOREIGN KEY (assignedById) REFERENCES user (id) ON UPDATE NO ACTION ON DELETE SET NULL MATCH NONE" +"migration_workflow_owner" }o--o| "user" : "FOREIGN KEY (userId) REFERENCES user (id) ON UPDATE NO ACTION ON DELETE SET NULL MATCH NONE" "oauth_access_tokens" }o--|| "user" : "FOREIGN KEY (userId) REFERENCES user (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE" "oauth_authorization_codes" }o--|| "user" : "FOREIGN KEY (userId) REFERENCES user (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE" "oauth_refresh_tokens" }o--|| "user" : "FOREIGN KEY (userId) REFERENCES user (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE" @@ -306,6 +308,15 @@ erDiagram datetime_3_ updatedAt varchar userId PK } +"migration_workflow_owner" { + datetime_3_ assignedAt + varchar assignedById FK + datetime_3_ createdAt + varchar_16_ source + datetime_3_ updatedAt + varchar userId FK + varchar_36_ workflowId PK +} "oauth_access_tokens" { varchar clientId FK varchar token PK diff --git a/docs/generated/sqlite-schema/workflow_entity.md b/docs/generated/sqlite-schema/workflow_entity.md index 02d2d028f81..42a76174d77 100644 --- a/docs/generated/sqlite-schema/workflow_entity.md +++ b/docs/generated/sqlite-schema/workflow_entity.md @@ -20,7 +20,7 @@ CREATE TABLE "workflow_entity" ("id" varchar(36) PRIMARY KEY NOT NULL, "name" va | connections | TEXT | | true | | | | | createdAt | datetime(3) | STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW') | false | | | | | description | TEXT | | true | | | | -| id | varchar(36) | | false | [agent_workflow_dependency](agent_workflow_dependency.md) [ai_builder_temporary_workflow](ai_builder_temporary_workflow.md) [chat_hub_messages](chat_hub_messages.md) [chat_hub_sessions](chat_hub_sessions.md) [evaluation_collection](evaluation_collection.md) [evaluation_config](evaluation_config.md) [execution_entity](execution_entity.md) [insights_metadata](insights_metadata.md) [migration_finding](migration_finding.md) [poller_state](poller_state.md) [processed_data](processed_data.md) [shared_workflow](shared_workflow.md) [test_run](test_run.md) [workflow_builder_session](workflow_builder_session.md) [workflow_dependency](workflow_dependency.md) [workflow_history](workflow_history.md) [workflow_publication_retry_state](workflow_publication_retry_state.md) [workflow_publication_trigger_status](workflow_publication_trigger_status.md) [workflow_publish_history](workflow_publish_history.md) [workflow_published_version](workflow_published_version.md) [workflow_review_request_workflow](workflow_review_request_workflow.md) [workflow_suggestion](workflow_suggestion.md) [workflows_tags](workflows_tags.md) | | | +| id | varchar(36) | | false | [agent_workflow_dependency](agent_workflow_dependency.md) [ai_builder_temporary_workflow](ai_builder_temporary_workflow.md) [chat_hub_messages](chat_hub_messages.md) [chat_hub_sessions](chat_hub_sessions.md) [evaluation_collection](evaluation_collection.md) [evaluation_config](evaluation_config.md) [execution_entity](execution_entity.md) [insights_metadata](insights_metadata.md) [migration_finding](migration_finding.md) [migration_workflow_owner](migration_workflow_owner.md) [poller_state](poller_state.md) [processed_data](processed_data.md) [shared_workflow](shared_workflow.md) [test_run](test_run.md) [workflow_builder_session](workflow_builder_session.md) [workflow_dependency](workflow_dependency.md) [workflow_history](workflow_history.md) [workflow_publication_retry_state](workflow_publication_retry_state.md) [workflow_publication_trigger_status](workflow_publication_trigger_status.md) [workflow_publish_history](workflow_publish_history.md) [workflow_published_version](workflow_published_version.md) [workflow_review_request_workflow](workflow_review_request_workflow.md) [workflow_suggestion](workflow_suggestion.md) [workflows_tags](workflows_tags.md) | | | | isArchived | boolean | FALSE | false | | | | | meta | TEXT | | true | | | | | name | varchar(128) | | false | | | | @@ -74,6 +74,7 @@ erDiagram "execution_entity" }o--|| "workflow_entity" : "FOREIGN KEY (workflowId) REFERENCES workflow_entity (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE" "insights_metadata" }o--o| "workflow_entity" : "FOREIGN KEY (workflowId) REFERENCES workflow_entity (id) ON UPDATE NO ACTION ON DELETE SET NULL MATCH NONE" "migration_finding" }o--|| "workflow_entity" : "FOREIGN KEY (workflowId) REFERENCES workflow_entity (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE" +"migration_workflow_owner" |o--|| "workflow_entity" : "FOREIGN KEY (workflowId) REFERENCES workflow_entity (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE" "poller_state" |o--|| "workflow_entity" : "FOREIGN KEY (workflowId) REFERENCES workflow_entity (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE" "processed_data" |o--|| "workflow_entity" : "FOREIGN KEY (workflowId) REFERENCES workflow_entity (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE" "shared_workflow" |o--|| "workflow_entity" : "FOREIGN KEY (workflowId) REFERENCES workflow_entity (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE" @@ -236,6 +237,15 @@ erDiagram datetime_3_ updatedAt varchar_36_ workflowId FK } +"migration_workflow_owner" { + datetime_3_ assignedAt + varchar assignedById FK + datetime_3_ createdAt + varchar_16_ source + datetime_3_ updatedAt + varchar userId FK + varchar_36_ workflowId PK +} "poller_state" { datetime_3_ backoffUntil INTEGER consecutiveErrors diff --git a/packages/@n8n/db/src/migrations/common/1791366497000-CreateMigrationWorkflowOwnerTable.ts b/packages/@n8n/db/src/migrations/common/1791366497000-CreateMigrationWorkflowOwnerTable.ts new file mode 100644 index 00000000000..fcddc20515b --- /dev/null +++ b/packages/@n8n/db/src/migrations/common/1791366497000-CreateMigrationWorkflowOwnerTable.ts @@ -0,0 +1,55 @@ +import type { MigrationContext, ReversibleMigration } from '../migration-types'; + +const OWNER_TABLE = 'migration_workflow_owner'; + +// Inlined on purpose: a migration must not import live enums from other packages. +const OWNER_SOURCES = ['suggested', 'assigned']; + +/** + * The user responsible for fixing a workflow's migration findings, one row per + * workflow. `source` tells a heuristic suggestion from a choice a person made, + * so a re-run of the heuristic never overwrites an assignment. The row goes + * with its workflow. A deleted user leaves the row with no user, which reads + * as unassigned. + */ +export class CreateMigrationWorkflowOwnerTable1791366497000 implements ReversibleMigration { + async up({ schemaBuilder: { createTable, column } }: MigrationContext) { + await createTable(OWNER_TABLE) + .withColumns( + column('workflowId').varchar(36).primary, + column('userId').uuid.comment('The owner. NULL after the user was deleted.'), + column('source') + .varchar(16) + .notNull.withEnumCheck(OWNER_SOURCES) + .comment( + 'MigrationOwnerSource enum: "suggested" by the heuristic, or "assigned" by a person.', + ), + column('assignedById').uuid.comment( + 'Who assigned the owner. NULL for a suggestion, or after that user was deleted.', + ), + column('assignedAt') + .timestampTimezone() + .comment('When a person assigned the owner. NULL for a suggestion.'), + ) + .withTimestamps.withIndexOn(['userId']) + .withForeignKey('workflowId', { + tableName: 'workflow_entity', + columnName: 'id', + onDelete: 'CASCADE', + }) + .withForeignKey('userId', { + tableName: 'user', + columnName: 'id', + onDelete: 'SET NULL', + }) + .withForeignKey('assignedById', { + tableName: 'user', + columnName: 'id', + onDelete: 'SET NULL', + }); + } + + async down({ schemaBuilder: { dropTable } }: MigrationContext) { + await dropTable(OWNER_TABLE); + } +} diff --git a/packages/cli/src/modules/breaking-changes/README.md b/packages/cli/src/modules/breaking-changes/README.md index f3da1bcc5ae..1d616c9d230 100644 --- a/packages/cli/src/modules/breaking-changes/README.md +++ b/packages/cli/src/modules/breaking-changes/README.md @@ -22,7 +22,7 @@ breaking-changes/ ... index.ts # Side-effect imports for all rules database/ - entities/ # migration_finding + migration_finding_sync tables + entities/ # migration_finding, migration_finding_sync, migration_workflow_owner tables repositories/ # Use-case-named DB access (BaseRepository + OperationContext) sync/ migration-finding-diff.ts # Pure diff of scan hits against stored findings diff --git a/packages/cli/src/modules/breaking-changes/__tests__/migration-workflow-owner.repository.integration.test.ts b/packages/cli/src/modules/breaking-changes/__tests__/migration-workflow-owner.repository.integration.test.ts new file mode 100644 index 00000000000..e37c27fb64d --- /dev/null +++ b/packages/cli/src/modules/breaking-changes/__tests__/migration-workflow-owner.repository.integration.test.ts @@ -0,0 +1,199 @@ +import { createWorkflow, testDb, testModules } from '@n8n/backend-test-utils'; +import { TransactionRunner, UserRepository } from '@n8n/db'; +import type { User } from '@n8n/db'; +import { Container } from '@n8n/di'; + +import { createMember } from '../../../../test/integration/shared/db/users'; +import { MigrationWorkflowOwnerRepository } from '../database/repositories/migration-workflow-owner.repository'; + +// Runs against the real database: the primary key, the insert-or-ignore and the +// foreign keys live in the schema, so a mocked manager would not exercise them. + +const ctx = {}; + +let repository: MigrationWorkflowOwnerRepository; +let alice: User; +let bob: User; + +beforeAll(async () => { + await testModules.loadModules(['breaking-changes']); + await testDb.init(); + repository = Container.get(MigrationWorkflowOwnerRepository); +}); + +beforeEach(async () => { + await repository.delete({}); + await testDb.truncate(['WorkflowEntity', 'User']); + [alice, bob] = await Promise.all([createMember(), createMember()]); +}); + +afterAll(async () => { + await testDb.terminate(); +}); + +describe('MigrationWorkflowOwnerRepository', () => { + describe('replaceSuggestions', () => { + test('inserts one suggested row per suggestion, for the batch only', async () => { + const [first, second, other] = await Promise.all([ + createWorkflow(), + createWorkflow(), + createWorkflow(), + ]); + + await repository.replaceSuggestions( + [first.id, second.id], + [ + { workflowId: first.id, userId: alice.id }, + { workflowId: other.id, userId: bob.id }, + ], + ctx, + ); + + const rows = await repository.findByWorkflowIds([first.id, second.id, other.id], ctx); + expect(rows).toHaveLength(1); + expect(rows[0]).toMatchObject({ + workflowId: first.id, + userId: alice.id, + source: 'suggested', + assignedById: null, + assignedAt: null, + }); + }); + + test('replaces an earlier suggestion and drops one that has no candidate any more', async () => { + const [first, second] = await Promise.all([createWorkflow(), createWorkflow()]); + await repository.replaceSuggestions( + [first.id, second.id], + [ + { workflowId: first.id, userId: alice.id }, + { workflowId: second.id, userId: alice.id }, + ], + ctx, + ); + + await repository.replaceSuggestions( + [first.id, second.id], + [{ workflowId: first.id, userId: bob.id }], + ctx, + ); + + const rows = await repository.findByWorkflowIds([first.id, second.id], ctx); + expect(rows).toHaveLength(1); + expect(rows[0]).toMatchObject({ workflowId: first.id, userId: bob.id, source: 'suggested' }); + }); + + test('leaves an assigned owner as it is', async () => { + const workflow = await createWorkflow(); + const assignedAt = new Date('2026-03-01T12:00:00.000Z'); + await repository.insert({ + workflowId: workflow.id, + userId: alice.id, + source: 'assigned', + assignedById: bob.id, + assignedAt, + }); + + await repository.replaceSuggestions( + [workflow.id], + [{ workflowId: workflow.id, userId: bob.id }], + ctx, + ); + + const [row] = await repository.findByWorkflowIds([workflow.id], ctx); + expect(row).toMatchObject({ userId: alice.id, source: 'assigned', assignedById: bob.id }); + expect(row.assignedAt?.getTime()).toBe(assignedAt.getTime()); + }); + + test('rolls back the delete when the insert fails, so earlier suggestions survive', async () => { + const workflow = await createWorkflow(); + await repository.replaceSuggestions( + [workflow.id], + [{ workflowId: workflow.id, userId: alice.id }], + ctx, + ); + + await expect( + repository.replaceSuggestions( + [workflow.id], + [{ workflowId: workflow.id, userId: 'not-a-user' }], + ctx, + ), + ).rejects.toThrow(); + + const [row] = await repository.findByWorkflowIds([workflow.id], ctx); + expect(row).toMatchObject({ userId: alice.id, source: 'suggested' }); + }); + + test('does nothing for an empty batch', async () => { + await repository.replaceSuggestions([], [], ctx); + + expect(await repository.count()).toBe(0); + }); + + test('rolls back with the surrounding transaction', async () => { + const workflow = await createWorkflow(); + const txRunner = Container.get(TransactionRunner); + + await expect( + txRunner.run({}, async (txCtx) => { + await repository.replaceSuggestions( + [workflow.id], + [{ workflowId: workflow.id, userId: alice.id }], + txCtx, + ); + throw new Error('abort'); + }), + ).rejects.toThrow('abort'); + + expect(await repository.count()).toBe(0); + }); + + test('rejects a source outside the enum', async () => { + const workflow = await createWorkflow(); + + await expect( + repository.insert({ + workflowId: workflow.id, + userId: alice.id, + source: 'guessed' as never, + assignedById: null, + }), + ).rejects.toThrow(); + }); + }); + + describe('findByWorkflowIds', () => { + test('returns an empty list for an empty id array', async () => { + expect(await repository.findByWorkflowIds([], ctx)).toEqual([]); + }); + }); + + describe('foreign keys', () => { + test('deletes the row with its workflow', async () => { + const workflow = await createWorkflow(); + await repository.replaceSuggestions( + [workflow.id], + [{ workflowId: workflow.id, userId: alice.id }], + ctx, + ); + + await testDb.truncate(['WorkflowEntity']); + + expect(await repository.count()).toBe(0); + }); + + test('keeps the row without a user when the user is deleted', async () => { + const workflow = await createWorkflow(); + await repository.replaceSuggestions( + [workflow.id], + [{ workflowId: workflow.id, userId: alice.id }], + ctx, + ); + + await Container.get(UserRepository).delete({ id: alice.id }); + + const [row] = await repository.findByWorkflowIds([workflow.id], ctx); + expect(row).toMatchObject({ workflowId: workflow.id, userId: null, source: 'suggested' }); + }); + }); +}); diff --git a/packages/cli/src/modules/breaking-changes/breaking-changes.module.ts b/packages/cli/src/modules/breaking-changes/breaking-changes.module.ts index eafb5a61917..1cb15ce8d66 100644 --- a/packages/cli/src/modules/breaking-changes/breaking-changes.module.ts +++ b/packages/cli/src/modules/breaking-changes/breaking-changes.module.ts @@ -35,7 +35,10 @@ export class BreakingChangesModule implements ModuleInterface { const { MigrationFindingSync } = await import( './database/entities/migration-finding-sync.entity.js' ); + const { MigrationWorkflowOwner } = await import( + './database/entities/migration-workflow-owner.entity.js' + ); - return [MigrationFinding, MigrationFindingSync]; + return [MigrationFinding, MigrationFindingSync, MigrationWorkflowOwner]; } } diff --git a/packages/cli/src/modules/breaking-changes/database/entities/migration-workflow-owner.entity.ts b/packages/cli/src/modules/breaking-changes/database/entities/migration-workflow-owner.entity.ts new file mode 100644 index 00000000000..ed56949d67d --- /dev/null +++ b/packages/cli/src/modules/breaking-changes/database/entities/migration-workflow-owner.entity.ts @@ -0,0 +1,52 @@ +import { DateTimeColumn, WithTimestamps } from '@n8n/db'; +import type { User, WorkflowEntity } from '@n8n/db'; +import { + Column, + Entity, + Index, + JoinColumn, + ManyToOne, + PrimaryColumn, + type Relation, +} from '@n8n/typeorm'; + +/** `suggested` by the heuristic, or `assigned` by a person. Only a person changes an assignment. */ +export type MigrationOwnerSource = 'suggested' | 'assigned'; + +/** + * The user responsible for fixing a workflow's migration findings, one row per + * workflow. The row goes with its workflow. A deleted user leaves `userId` null, + * which reads as unassigned. + */ +@Entity({ name: 'migration_workflow_owner' }) +@Index(['userId']) +export class MigrationWorkflowOwner extends WithTimestamps { + @PrimaryColumn({ type: 'varchar', length: 36 }) + workflowId: string; + + @ManyToOne('WorkflowEntity', { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'workflowId' }) + workflow: Relation; + + @Column({ type: 'uuid', nullable: true }) + userId: string | null; + + @ManyToOne('User', { onDelete: 'SET NULL', nullable: true }) + @JoinColumn({ name: 'userId' }) + user: Relation | null; + + @Column({ type: 'varchar', length: 16 }) + source: MigrationOwnerSource; + + /** Who assigned the owner. `null` for a suggestion. */ + @Column({ type: 'uuid', nullable: true }) + assignedById: string | null; + + @ManyToOne('User', { onDelete: 'SET NULL', nullable: true }) + @JoinColumn({ name: 'assignedById' }) + assignedBy: Relation | null; + + /** When a person assigned the owner. `null` for a suggestion. */ + @DateTimeColumn({ nullable: true }) + assignedAt: Date | null; +} diff --git a/packages/cli/src/modules/breaking-changes/database/repositories/migration-workflow-owner.repository.ts b/packages/cli/src/modules/breaking-changes/database/repositories/migration-workflow-owner.repository.ts new file mode 100644 index 00000000000..97b9403cce4 --- /dev/null +++ b/packages/cli/src/modules/breaking-changes/database/repositories/migration-workflow-owner.repository.ts @@ -0,0 +1,72 @@ +import { BaseRepository, type OperationContext, TransactionRunner } from '@n8n/db'; +import { Service } from '@n8n/di'; +import { DataSource, In } from '@n8n/typeorm'; + +import { MigrationWorkflowOwner } from '../entities/migration-workflow-owner.entity'; + +/** The owner the heuristic proposes for one workflow. */ +export interface OwnerSuggestion { + workflowId: string; + userId: string; +} + +@Service() +export class MigrationWorkflowOwnerRepository extends BaseRepository { + constructor(dataSource: DataSource, transactionRunner: TransactionRunner) { + super(MigrationWorkflowOwner, dataSource.manager, transactionRunner); + } + + async findByWorkflowIds( + workflowIds: string[], + ctx: OperationContext, + ): Promise { + if (workflowIds.length === 0) return []; + return await this.managerFor(ctx).find(MigrationWorkflowOwner, { + where: { workflowId: In(workflowIds) }, + }); + } + + /** + * Writes the heuristic's suggestions for a batch of workflows. A workflow in + * `workflowIds` without a suggestion loses any earlier suggestion. A workflow a + * person assigned keeps its owner: an assignment is never overwritten here. + */ + async replaceSuggestions( + workflowIds: string[], + suggestions: OwnerSuggestion[], + ctx: OperationContext, + ): Promise { + if (workflowIds.length === 0) return; + + // Both writes succeed or fail together, so a failed insert cannot leave the + // batch without the suggestions it had before. + await this.runInTransaction(ctx, async (manager) => { + // Earlier suggestions for the batch go first, so each workflow ends with at most + // one row. The insert then ignores every workflow that still has a row, which is + // exactly the assigned ones. + await manager.delete(MigrationWorkflowOwner, { + workflowId: In(workflowIds), + source: 'suggested', + }); + + const batch = new Set(workflowIds); + const rows = suggestions + .filter((suggestion) => batch.has(suggestion.workflowId)) + .map((suggestion) => ({ + ...suggestion, + source: 'suggested' as const, + assignedById: null, + assignedAt: null, + })); + if (rows.length === 0) return; + + await manager + .createQueryBuilder() + .insert() + .into(MigrationWorkflowOwner) + .values(rows) + .orIgnore() + .execute(); + }); + } +}