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();
+ });
+ }
+}