From 4979df19707a18e567f2d742fa9f66aa3b4ddd11 Mon Sep 17 00:00:00 2001 From: mfsiega <93014743+mfsiega@users.noreply.github.com> Date: Tue, 30 Jun 2026 11:02:00 +0200 Subject: [PATCH] feat(core): Persist per-trigger workflow publication status (no-changelog) (#33191) Co-authored-by: Claude Opus 4.8 (1M context) --- docs/generated/postgres-schema/README.md | 12 ++ .../postgres-schema/public.workflow_entity.md | 12 +- .../public.workflow_history.md | 12 +- ...lic.workflow_publication_trigger_status.md | 92 +++++++++++ docs/generated/sqlite-schema/README.md | 12 ++ .../sqlite-schema/workflow_entity.md | 12 +- .../sqlite-schema/workflow_history.md | 12 +- .../workflow_publication_trigger_status.md | 99 ++++++++++++ packages/@n8n/db/src/entities/index.ts | 7 + .../workflow-publication-trigger-status.ts | 40 +++++ ...teWorkflowPublicationTriggerStatusTable.ts | 36 +++++ .../db/src/migrations/postgresdb/index.ts | 2 + .../@n8n/db/src/migrations/sqlite/index.ts | 2 + packages/@n8n/db/src/repositories/index.ts | 4 + .../workflow-publication-outbox.repository.ts | 25 +-- ...w-publication-trigger-status.repository.ts | 39 +++++ .../publication-status-reporter.test.ts | 148 ++++++++++++++++-- .../workflow-publication-applier.test.ts | 79 ++++++++-- ...rkflow-publication-outbox-consumer.test.ts | 6 +- .../publication/publication-result.ts | 30 +++- .../publication-status-reporter.ts | 102 +++++++++--- .../workflow-publication-applier.ts | 70 +++++++-- ...lication-trigger-status.repository.test.ts | 89 +++++++++++ 23 files changed, 849 insertions(+), 93 deletions(-) create mode 100644 docs/generated/postgres-schema/public.workflow_publication_trigger_status.md create mode 100644 docs/generated/sqlite-schema/workflow_publication_trigger_status.md create mode 100644 packages/@n8n/db/src/entities/workflow-publication-trigger-status.ts create mode 100644 packages/@n8n/db/src/migrations/common/1784000000040-CreateWorkflowPublicationTriggerStatusTable.ts create mode 100644 packages/@n8n/db/src/repositories/workflow-publication-trigger-status.repository.ts create mode 100644 packages/cli/test/integration/workflow-publication-trigger-status.repository.test.ts diff --git a/docs/generated/postgres-schema/README.md b/docs/generated/postgres-schema/README.md index 427dea8fea3..1b15aaf06e8 100644 --- a/docs/generated/postgres-schema/README.md +++ b/docs/generated/postgres-schema/README.md @@ -113,6 +113,7 @@ Auto-generated from the PostgreSQL migrations in @n8n/db. Do not edit by hand. | [public.workflow_entity](public.workflow_entity.md) | 20 | | BASE TABLE | | [public.workflow_history](public.workflow_history.md) | 11 | | BASE TABLE | | [public.workflow_publication_outbox](public.workflow_publication_outbox.md) | 7 | | BASE TABLE | +| [public.workflow_publication_trigger_status](public.workflow_publication_trigger_status.md) | 7 | | BASE TABLE | | [public.workflow_publish_history](public.workflow_publish_history.md) | 6 | | BASE TABLE | | [public.workflow_published_version](public.workflow_published_version.md) | 4 | | BASE TABLE | | [public.workflow_statistics](public.workflow_statistics.md) | 7 | | BASE TABLE | @@ -282,6 +283,8 @@ erDiagram "public.workflow_entity" }o--o| "public.workflow_history" : "FOREIGN KEY (#quot;activeVersionId#quot;) REFERENCES workflow_history(#quot;versionId#quot;) ON DELETE RESTRICT" "public.workflow_entity" }o--o| "public.folder" : "FOREIGN KEY (#quot;parentFolderId#quot;) REFERENCES folder(id) ON DELETE CASCADE" "public.workflow_history" }o--|| "public.workflow_entity" : "FOREIGN KEY (#quot;workflowId#quot;) REFERENCES workflow_entity(id) ON DELETE CASCADE" +"public.workflow_publication_trigger_status" }o--|| "public.workflow_entity" : "FOREIGN KEY (#quot;workflowId#quot;) REFERENCES workflow_entity(id) ON DELETE CASCADE" +"public.workflow_publication_trigger_status" }o--|| "public.workflow_history" : "FOREIGN KEY (#quot;versionId#quot;) REFERENCES workflow_history(#quot;versionId#quot;) ON DELETE CASCADE" "public.workflow_publish_history" }o--o| "public.user" : "FOREIGN KEY (#quot;userId#quot;) REFERENCES #quot;user#quot;(id) ON DELETE SET NULL" "public.workflow_publish_history" }o--|| "public.workflow_entity" : "FOREIGN KEY (#quot;workflowId#quot;) REFERENCES workflow_entity(id) ON DELETE CASCADE" "public.workflow_publish_history" }o--o| "public.workflow_history" : "FOREIGN KEY (#quot;versionId#quot;) REFERENCES workflow_history(#quot;versionId#quot;) ON DELETE SET NULL" @@ -1295,6 +1298,15 @@ erDiagram timestamp_3__with_time_zone updatedAt varchar_36_ workflowId } +"public.workflow_publication_trigger_status" { + timestamp_3__with_time_zone createdAt + text errorMessage + varchar_36_ nodeId + varchar_20_ status + timestamp_3__with_time_zone updatedAt + varchar_36_ versionId FK + varchar_36_ workflowId FK +} "public.workflow_publish_history" { timestamp_3__with_time_zone createdAt varchar_36_ event diff --git a/docs/generated/postgres-schema/public.workflow_entity.md b/docs/generated/postgres-schema/public.workflow_entity.md index d4669e00a45..0c2820d1d72 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.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.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_publish_history](public.workflow_publish_history.md) [public.workflow_published_version](public.workflow_published_version.md) [public.workflows_tags](public.workflows_tags.md) | | | +| id | varchar(36) | | false | [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.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_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.workflows_tags](public.workflows_tags.md) | | | | isArchived | boolean | false | false | | | | | meta | json | | true | | | | | name | varchar(128) | | false | | | | @@ -80,6 +80,7 @@ erDiagram "public.workflow_builder_session" }o--|| "public.workflow_entity" : "FOREIGN KEY (#quot;workflowId#quot;) REFERENCES workflow_entity(id) ON DELETE CASCADE" "public.workflow_dependency" }o--|| "public.workflow_entity" : "FOREIGN KEY (#quot;workflowId#quot;) REFERENCES workflow_entity(id) ON DELETE CASCADE" "public.workflow_history" }o--|| "public.workflow_entity" : "FOREIGN KEY (#quot;workflowId#quot;) REFERENCES workflow_entity(id) ON DELETE CASCADE" +"public.workflow_publication_trigger_status" }o--|| "public.workflow_entity" : "FOREIGN KEY (#quot;workflowId#quot;) REFERENCES workflow_entity(id) ON DELETE CASCADE" "public.workflow_publish_history" }o--|| "public.workflow_entity" : "FOREIGN KEY (#quot;workflowId#quot;) REFERENCES workflow_entity(id) ON DELETE CASCADE" "public.workflow_published_version" |o--|| "public.workflow_entity" : "FOREIGN KEY (#quot;workflowId#quot;) REFERENCES workflow_entity(id) ON DELETE RESTRICT" "public.workflows_tags" }o--|| "public.workflow_entity" : "FOREIGN KEY (#quot;workflowId#quot;) REFERENCES workflow_entity(id) ON DELETE CASCADE" @@ -274,6 +275,15 @@ erDiagram varchar_36_ workflowId FK integer workflowVersionId } +"public.workflow_publication_trigger_status" { + timestamp_3__with_time_zone createdAt + text errorMessage + varchar_36_ nodeId + varchar_20_ status + timestamp_3__with_time_zone updatedAt + varchar_36_ versionId FK + varchar_36_ workflowId FK +} "public.workflow_publish_history" { timestamp_3__with_time_zone createdAt varchar_36_ event diff --git a/docs/generated/postgres-schema/public.workflow_history.md b/docs/generated/postgres-schema/public.workflow_history.md index e832f5883a1..484518aedc8 100644 --- a/docs/generated/postgres-schema/public.workflow_history.md +++ b/docs/generated/postgres-schema/public.workflow_history.md @@ -13,7 +13,7 @@ | nodeGroups | json | '[]'::json | false | | | | | nodes | json | | false | | | | | updatedAt | timestamp(3) with time zone | CURRENT_TIMESTAMP(3) | false | | | | -| versionId | varchar(36) | | false | [public.workflow_entity](public.workflow_entity.md) [public.workflow_publish_history](public.workflow_publish_history.md) [public.workflow_published_version](public.workflow_published_version.md) | | | +| versionId | varchar(36) | | false | [public.workflow_entity](public.workflow_entity.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) | | | | workflowId | varchar(36) | | false | | [public.workflow_entity](public.workflow_entity.md) | | ## Constraints @@ -45,6 +45,7 @@ erDiagram "public.workflow_entity" }o--o| "public.workflow_history" : "FOREIGN KEY (#quot;activeVersionId#quot;) REFERENCES workflow_history(#quot;versionId#quot;) ON DELETE RESTRICT" +"public.workflow_publication_trigger_status" }o--|| "public.workflow_history" : "FOREIGN KEY (#quot;versionId#quot;) REFERENCES workflow_history(#quot;versionId#quot;) ON DELETE CASCADE" "public.workflow_publish_history" }o--o| "public.workflow_history" : "FOREIGN KEY (#quot;versionId#quot;) REFERENCES workflow_history(#quot;versionId#quot;) ON DELETE SET NULL" "public.workflow_published_version" }o--|| "public.workflow_history" : "FOREIGN KEY (#quot;publishedVersionId#quot;) REFERENCES workflow_history(#quot;versionId#quot;) ON DELETE RESTRICT" "public.workflow_history" }o--|| "public.workflow_entity" : "FOREIGN KEY (#quot;workflowId#quot;) REFERENCES workflow_entity(id) ON DELETE CASCADE" @@ -84,6 +85,15 @@ erDiagram integer versionCounter character_36_ versionId } +"public.workflow_publication_trigger_status" { + timestamp_3__with_time_zone createdAt + text errorMessage + varchar_36_ nodeId + varchar_20_ status + timestamp_3__with_time_zone updatedAt + varchar_36_ versionId FK + varchar_36_ workflowId FK +} "public.workflow_publish_history" { timestamp_3__with_time_zone createdAt varchar_36_ event diff --git a/docs/generated/postgres-schema/public.workflow_publication_trigger_status.md b/docs/generated/postgres-schema/public.workflow_publication_trigger_status.md new file mode 100644 index 00000000000..60540f855ae --- /dev/null +++ b/docs/generated/postgres-schema/public.workflow_publication_trigger_status.md @@ -0,0 +1,92 @@ +# public.workflow_publication_trigger_status + +## Columns + +| Name | Type | Default | Nullable | Children | Parents | Comment | +| ---- | ---- | ------- | -------- | -------- | ------- | ------- | +| createdAt | timestamp(3) with time zone | CURRENT_TIMESTAMP(3) | false | | | | +| errorMessage | text | | true | | | | +| nodeId | varchar(36) | | false | | | | +| status | varchar(20) | | false | | | | +| updatedAt | timestamp(3) with time zone | CURRENT_TIMESTAMP(3) | false | | | | +| versionId | varchar(36) | | false | | [public.workflow_history](public.workflow_history.md) | References workflow_history.versionId: the published version these statuses were recorded for | +| workflowId | varchar(36) | | false | | [public.workflow_entity](public.workflow_entity.md) | | + +## Constraints + +| Name | Type | Definition | +| ---- | ---- | ---------- | +| CHK_workflow_publication_trigger_status_status | CHECK | CHECK (((status)::text = ANY ((ARRAY['activated'::character varying, 'failed'::character varying])::text[]))) | +| FK_b7b496d8d1a21158c65f475cd88 | FOREIGN KEY | FOREIGN KEY ("workflowId") REFERENCES workflow_entity(id) ON DELETE CASCADE | +| FK_ef1994db9d0ac1b6a5c89b5f729 | FOREIGN KEY | FOREIGN KEY ("versionId") REFERENCES workflow_history("versionId") ON DELETE CASCADE | +| PK_14aa18b83513fb92d7523909e02 | PRIMARY KEY | PRIMARY KEY ("workflowId", "nodeId") | +| workflow_publication_trigger_status_createdAt_not_null | n | NOT NULL "createdAt" | +| workflow_publication_trigger_status_nodeId_not_null | n | NOT NULL "nodeId" | +| workflow_publication_trigger_status_status_not_null | n | NOT NULL status | +| workflow_publication_trigger_status_updatedAt_not_null | n | NOT NULL "updatedAt" | +| workflow_publication_trigger_status_versionId_not_null | n | NOT NULL "versionId" | +| workflow_publication_trigger_status_workflowId_not_null | n | NOT NULL "workflowId" | + +## Indexes + +| Name | Definition | +| ---- | ---------- | +| PK_14aa18b83513fb92d7523909e02 | CREATE UNIQUE INDEX "PK_14aa18b83513fb92d7523909e02" ON public.workflow_publication_trigger_status USING btree ("workflowId", "nodeId") | + +## Relations + +```mermaid +erDiagram + +"public.workflow_publication_trigger_status" }o--|| "public.workflow_history" : "FOREIGN KEY (#quot;versionId#quot;) REFERENCES workflow_history(#quot;versionId#quot;) ON DELETE CASCADE" +"public.workflow_publication_trigger_status" }o--|| "public.workflow_entity" : "FOREIGN KEY (#quot;workflowId#quot;) REFERENCES workflow_entity(id) ON DELETE CASCADE" + +"public.workflow_publication_trigger_status" { + timestamp_3__with_time_zone createdAt + text errorMessage + varchar_36_ nodeId + varchar_20_ status + timestamp_3__with_time_zone updatedAt + varchar_36_ versionId FK + varchar_36_ workflowId FK +} +"public.workflow_history" { + varchar_255_ authors + boolean autosaved + json connections + timestamp_3__with_time_zone createdAt + text description + varchar_128_ name + json nodeGroups + json nodes + timestamp_3__with_time_zone updatedAt + varchar_36_ versionId + varchar_36_ workflowId FK +} +"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/sqlite-schema/README.md b/docs/generated/sqlite-schema/README.md index f36a5d309f6..8a3d7535eaa 100644 --- a/docs/generated/sqlite-schema/README.md +++ b/docs/generated/sqlite-schema/README.md @@ -113,6 +113,7 @@ Auto-generated from the SQLite migrations in @n8n/db. Do not edit by hand. | [workflow_entity](workflow_entity.md) | 20 | | table | | [workflow_history](workflow_history.md) | 11 | | table | | [workflow_publication_outbox](workflow_publication_outbox.md) | 7 | | table | +| [workflow_publication_trigger_status](workflow_publication_trigger_status.md) | 7 | | table | | [workflow_publish_history](workflow_publish_history.md) | 6 | | table | | [workflow_published_version](workflow_published_version.md) | 4 | | table | | [workflow_statistics](workflow_statistics.md) | 7 | | table | @@ -267,6 +268,8 @@ erDiagram "workflow_entity" }o--o| "workflow_history" : "FOREIGN KEY (activeVersionId) REFERENCES workflow_history (versionId) ON UPDATE NO ACTION ON DELETE RESTRICT MATCH NONE" "workflow_entity" }o--o| "folder" : "FOREIGN KEY (parentFolderId) REFERENCES folder (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE" "workflow_history" }o--|| "workflow_entity" : "FOREIGN KEY (workflowId) REFERENCES workflow_entity (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE" +"workflow_publication_trigger_status" }o--|| "workflow_history" : "FOREIGN KEY (versionId) REFERENCES workflow_history (versionId) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE" +"workflow_publication_trigger_status" |o--|| "workflow_entity" : "FOREIGN KEY (workflowId) REFERENCES workflow_entity (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE" "workflow_publish_history" }o--o| "workflow_history" : "FOREIGN KEY (versionId) REFERENCES workflow_history (versionId) ON UPDATE NO ACTION ON DELETE SET NULL MATCH NONE" "workflow_publish_history" }o--o| "user" : "FOREIGN KEY (userId) REFERENCES user (id) ON UPDATE NO ACTION ON DELETE SET NULL MATCH NONE" "workflow_publish_history" }o--o| "workflow_history" : "FOREIGN KEY (versionId) REFERENCES workflow_history (versionId) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE" @@ -1285,6 +1288,15 @@ erDiagram datetime_3_ updatedAt varchar_36_ workflowId } +"workflow_publication_trigger_status" { + datetime_3_ createdAt + TEXT errorMessage + varchar_36_ nodeId PK + varchar_20_ status + datetime_3_ updatedAt + varchar_36_ versionId FK + varchar_36_ workflowId PK +} "workflow_publish_history" { datetime_3_ createdAt varchar_36_ event diff --git a/docs/generated/sqlite-schema/workflow_entity.md b/docs/generated/sqlite-schema/workflow_entity.md index 3976342822a..e903c4b4a20 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 | [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) [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_publish_history](workflow_publish_history.md) [workflow_published_version](workflow_published_version.md) [workflows_tags](workflows_tags.md) | | | +| id | varchar(36) | | false | [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) [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_trigger_status](workflow_publication_trigger_status.md) [workflow_publish_history](workflow_publish_history.md) [workflow_published_version](workflow_published_version.md) [workflows_tags](workflows_tags.md) | | | | isArchived | boolean | FALSE | false | | | | | meta | TEXT | | true | | | | | name | varchar(128) | | false | | | | @@ -78,6 +78,7 @@ erDiagram "workflow_builder_session" }o--|| "workflow_entity" : "FOREIGN KEY (workflowId) REFERENCES workflow_entity (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE" "workflow_dependency" }o--|| "workflow_entity" : "FOREIGN KEY (workflowId) REFERENCES workflow_entity (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE" "workflow_history" }o--|| "workflow_entity" : "FOREIGN KEY (workflowId) REFERENCES workflow_entity (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE" +"workflow_publication_trigger_status" |o--|| "workflow_entity" : "FOREIGN KEY (workflowId) REFERENCES workflow_entity (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE" "workflow_publish_history" }o--|| "workflow_entity" : "FOREIGN KEY (workflowId) REFERENCES workflow_entity (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE" "workflow_published_version" |o--|| "workflow_entity" : "FOREIGN KEY (workflowId) REFERENCES workflow_entity (id) ON UPDATE NO ACTION ON DELETE RESTRICT MATCH NONE" "workflow_published_version" |o--|| "workflow_entity" : "FOREIGN KEY (workflowId) REFERENCES workflow_entity (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE" @@ -265,6 +266,15 @@ erDiagram varchar_36_ workflowId FK INTEGER workflowVersionId } +"workflow_publication_trigger_status" { + datetime_3_ createdAt + TEXT errorMessage + varchar_36_ nodeId PK + varchar_20_ status + datetime_3_ updatedAt + varchar_36_ versionId FK + varchar_36_ workflowId PK +} "workflow_publish_history" { datetime_3_ createdAt varchar_36_ event diff --git a/docs/generated/sqlite-schema/workflow_history.md b/docs/generated/sqlite-schema/workflow_history.md index 2762dae636b..df889efd452 100644 --- a/docs/generated/sqlite-schema/workflow_history.md +++ b/docs/generated/sqlite-schema/workflow_history.md @@ -24,7 +24,7 @@ CREATE TABLE "workflow_history" ("versionId" varchar(36) PRIMARY KEY NOT NULL, " | nodeGroups | TEXT | '[]' | false | | | | | nodes | TEXT | | false | | | | | updatedAt | datetime(3) | STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW') | false | | | | -| versionId | varchar(36) | | false | [workflow_entity](workflow_entity.md) [workflow_publish_history](workflow_publish_history.md) [workflow_published_version](workflow_published_version.md) | | | +| versionId | varchar(36) | | false | [workflow_entity](workflow_entity.md) [workflow_publication_trigger_status](workflow_publication_trigger_status.md) [workflow_publish_history](workflow_publish_history.md) [workflow_published_version](workflow_published_version.md) | | | | workflowId | varchar(36) | | false | | [workflow_entity](workflow_entity.md) | | ## Constraints @@ -48,6 +48,7 @@ CREATE TABLE "workflow_history" ("versionId" varchar(36) PRIMARY KEY NOT NULL, " erDiagram "workflow_entity" }o--o| "workflow_history" : "FOREIGN KEY (activeVersionId) REFERENCES workflow_history (versionId) ON UPDATE NO ACTION ON DELETE RESTRICT MATCH NONE" +"workflow_publication_trigger_status" }o--|| "workflow_history" : "FOREIGN KEY (versionId) REFERENCES workflow_history (versionId) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE" "workflow_publish_history" }o--o| "workflow_history" : "FOREIGN KEY (versionId) REFERENCES workflow_history (versionId) ON UPDATE NO ACTION ON DELETE SET NULL MATCH NONE" "workflow_publish_history" }o--o| "workflow_history" : "FOREIGN KEY (versionId) REFERENCES workflow_history (versionId) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE" "workflow_published_version" }o--|| "workflow_history" : "FOREIGN KEY (publishedVersionId) REFERENCES workflow_history (versionId) ON UPDATE NO ACTION ON DELETE RESTRICT MATCH NONE" @@ -89,6 +90,15 @@ erDiagram INTEGER versionCounter varchar_36_ versionId } +"workflow_publication_trigger_status" { + datetime_3_ createdAt + TEXT errorMessage + varchar_36_ nodeId PK + varchar_20_ status + datetime_3_ updatedAt + varchar_36_ versionId FK + varchar_36_ workflowId PK +} "workflow_publish_history" { datetime_3_ createdAt varchar_36_ event diff --git a/docs/generated/sqlite-schema/workflow_publication_trigger_status.md b/docs/generated/sqlite-schema/workflow_publication_trigger_status.md new file mode 100644 index 00000000000..70ef8a62863 --- /dev/null +++ b/docs/generated/sqlite-schema/workflow_publication_trigger_status.md @@ -0,0 +1,99 @@ +# workflow_publication_trigger_status + +## Description + +
+Table Definition + +```sql +CREATE TABLE "workflow_publication_trigger_status" ("workflowId" varchar(36) NOT NULL, "nodeId" varchar(36) NOT NULL, "versionId" varchar(36) NOT NULL, "status" varchar(20) NOT NULL, "errorMessage" text, "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_workflow_publication_trigger_status_status" CHECK ("status" IN ('activated', 'failed')), CONSTRAINT "FK_b7b496d8d1a21158c65f475cd88" FOREIGN KEY ("workflowId") REFERENCES "workflow_entity" ("id") ON DELETE CASCADE, CONSTRAINT "FK_ef1994db9d0ac1b6a5c89b5f729" FOREIGN KEY ("versionId") REFERENCES "workflow_history" ("versionId") ON DELETE CASCADE, PRIMARY KEY ("workflowId", "nodeId")) +``` + +
+ +## Columns + +| Name | Type | Default | Nullable | Children | Parents | Comment | +| ---- | ---- | ------- | -------- | -------- | ------- | ------- | +| createdAt | datetime(3) | STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW') | false | | | | +| errorMessage | TEXT | | true | | | | +| nodeId | varchar(36) | | false | | | | +| status | varchar(20) | | false | | | | +| updatedAt | datetime(3) | STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW') | false | | | | +| versionId | varchar(36) | | false | | [workflow_history](workflow_history.md) | | +| workflowId | varchar(36) | | false | | [workflow_entity](workflow_entity.md) | | + +## Constraints + +| Name | Type | Definition | +| ---- | ---- | ---------- | +| - | CHECK | CHECK ("status" IN ('activated', 'failed')) | +| - (Foreign key ID: 0) | FOREIGN KEY | FOREIGN KEY (versionId) REFERENCES workflow_history (versionId) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE | +| - (Foreign key ID: 1) | FOREIGN KEY | FOREIGN KEY (workflowId) REFERENCES workflow_entity (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE | +| nodeId | PRIMARY KEY | PRIMARY KEY (nodeId) | +| sqlite_autoindex_workflow_publication_trigger_status_1 | PRIMARY KEY | PRIMARY KEY (workflowId, nodeId) | +| workflowId | PRIMARY KEY | PRIMARY KEY (workflowId) | + +## Indexes + +| Name | Definition | +| ---- | ---------- | +| sqlite_autoindex_workflow_publication_trigger_status_1 | PRIMARY KEY (workflowId, nodeId) | + +## Relations + +```mermaid +erDiagram + +"workflow_publication_trigger_status" }o--|| "workflow_history" : "FOREIGN KEY (versionId) REFERENCES workflow_history (versionId) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE" +"workflow_publication_trigger_status" |o--|| "workflow_entity" : "FOREIGN KEY (workflowId) REFERENCES workflow_entity (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE" + +"workflow_publication_trigger_status" { + datetime_3_ createdAt + TEXT errorMessage + varchar_36_ nodeId PK + varchar_20_ status + datetime_3_ updatedAt + varchar_36_ versionId FK + varchar_36_ workflowId PK +} +"workflow_history" { + varchar_255_ authors + boolean autosaved + TEXT connections + datetime_3_ createdAt + TEXT description + varchar_128_ name + TEXT nodeGroups + TEXT nodes + datetime_3_ updatedAt + varchar_36_ versionId PK + varchar_36_ workflowId FK +} +"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/packages/@n8n/db/src/entities/index.ts b/packages/@n8n/db/src/entities/index.ts index 8d3df22ad8e..9a27f47f233 100644 --- a/packages/@n8n/db/src/entities/index.ts +++ b/packages/@n8n/db/src/entities/index.ts @@ -46,6 +46,10 @@ import { WorkflowPublicationOutbox, WorkflowPublicationOutboxStatus, } from './workflow-publication-outbox'; +import { + WorkflowPublicationTriggerStatus, + type WorkflowPublicationTriggerStatusType, +} from './workflow-publication-trigger-status'; import { WorkflowPublishHistory } from './workflow-publish-history'; import { WorkflowPublishedVersion } from './workflow-published-version'; import { WorkflowStatistics } from './workflow-statistics'; @@ -89,6 +93,8 @@ export { WorkflowHistory, WorkflowPublicationOutbox, WorkflowPublicationOutboxStatus, + WorkflowPublicationTriggerStatus, + type WorkflowPublicationTriggerStatusType, WorkflowPublishedVersion, WorkflowPublishHistory, ExecutionData, @@ -136,6 +142,7 @@ export const entities = { AuthProviderSyncHistory, WorkflowHistory, WorkflowPublicationOutbox, + WorkflowPublicationTriggerStatus, WorkflowPublishedVersion, WorkflowPublishHistory, ExecutionData, diff --git a/packages/@n8n/db/src/entities/workflow-publication-trigger-status.ts b/packages/@n8n/db/src/entities/workflow-publication-trigger-status.ts new file mode 100644 index 00000000000..10fd1e47f03 --- /dev/null +++ b/packages/@n8n/db/src/entities/workflow-publication-trigger-status.ts @@ -0,0 +1,40 @@ +import { Column, Entity, JoinColumn, ManyToOne, PrimaryColumn, Relation } from '@n8n/typeorm'; + +import { WithTimestamps } from './abstract-entity'; +import type { WorkflowEntity } from './workflow-entity'; +import type { WorkflowHistory } from './workflow-history'; + +export type WorkflowPublicationTriggerStatusType = 'activated' | 'failed'; + +/** + * Per-trigger outcome of the most recent version-advancing publication for a + * workflow. Full-replaced on each completed/partial/failed publication; the + * row composition is the source of truth for the published/partial/failed + * headline surfaced to the UI. + */ +@Entity({ name: 'workflow_publication_trigger_status' }) +export class WorkflowPublicationTriggerStatus extends WithTimestamps { + @PrimaryColumn({ type: 'varchar', length: 36 }) + workflowId: string; + + @PrimaryColumn({ type: 'varchar', length: 36 }) + nodeId: string; + + /** The `workflow_history` version these statuses were recorded for. */ + @Column({ type: 'varchar', length: 36 }) + versionId: string; + + @Column({ type: 'varchar', length: 20 }) + status: WorkflowPublicationTriggerStatusType; + + @Column({ type: 'text', nullable: true }) + errorMessage: string | null; + + @ManyToOne('WorkflowEntity', { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'workflowId' }) + workflow: Relation; + + @ManyToOne('WorkflowHistory', { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'versionId', referencedColumnName: 'versionId' }) + publishedVersion: Relation; +} diff --git a/packages/@n8n/db/src/migrations/common/1784000000040-CreateWorkflowPublicationTriggerStatusTable.ts b/packages/@n8n/db/src/migrations/common/1784000000040-CreateWorkflowPublicationTriggerStatusTable.ts new file mode 100644 index 00000000000..44f38e9f134 --- /dev/null +++ b/packages/@n8n/db/src/migrations/common/1784000000040-CreateWorkflowPublicationTriggerStatusTable.ts @@ -0,0 +1,36 @@ +import type { MigrationContext, ReversibleMigration } from '../migration-types'; + +const tableName = 'workflow_publication_trigger_status'; + +export class CreateWorkflowPublicationTriggerStatusTable1784000000040 + implements ReversibleMigration +{ + async up({ schemaBuilder: { createTable, column } }: MigrationContext) { + await createTable(tableName) + .withColumns( + column('workflowId').varchar(36).notNull.primary, + column('nodeId').varchar(36).notNull.primary, + column('versionId') + .varchar(36) + .notNull.comment( + 'References workflow_history.versionId: the published version these statuses were recorded for', + ), + column('status').varchar(20).notNull.withEnumCheck(['activated', 'failed']), + column('errorMessage').text, + ) + .withForeignKey('workflowId', { + tableName: 'workflow_entity', + columnName: 'id', + onDelete: 'CASCADE', + }) + .withForeignKey('versionId', { + tableName: 'workflow_history', + columnName: 'versionId', + onDelete: 'CASCADE', + }).withTimestamps; + } + + async down({ schemaBuilder: { dropTable } }: MigrationContext) { + await dropTable(tableName); + } +} diff --git a/packages/@n8n/db/src/migrations/postgresdb/index.ts b/packages/@n8n/db/src/migrations/postgresdb/index.ts index 66d7a858029..91b93be0055 100644 --- a/packages/@n8n/db/src/migrations/postgresdb/index.ts +++ b/packages/@n8n/db/src/migrations/postgresdb/index.ts @@ -213,6 +213,7 @@ import { CreateInstanceAiThreadGrantTable1784000000036 } from '../common/1784000 import { DropAgentDescriptionFromAgents1784000000037 } from '../common/1784000000037-DropAgentDescriptionFromAgents'; import { SetChatHubEnabledFromUsage1784000000038 } from '../common/1784000000038-SetChatHubEnabledFromUsage'; import { DropAgentExecutionFallbackColumns1784000000039 } from '../common/1784000000039-DropAgentExecutionFallbackColumns'; +import { CreateWorkflowPublicationTriggerStatusTable1784000000040 } from '../common/1784000000040-CreateWorkflowPublicationTriggerStatusTable'; import type { Migration } from '../migration-types'; export const postgresMigrations: Migration[] = [ @@ -431,4 +432,5 @@ export const postgresMigrations: Migration[] = [ DropAgentDescriptionFromAgents1784000000037, SetChatHubEnabledFromUsage1784000000038, DropAgentExecutionFallbackColumns1784000000039, + CreateWorkflowPublicationTriggerStatusTable1784000000040, ]; diff --git a/packages/@n8n/db/src/migrations/sqlite/index.ts b/packages/@n8n/db/src/migrations/sqlite/index.ts index c1e9ab80ea1..249fc8d5069 100644 --- a/packages/@n8n/db/src/migrations/sqlite/index.ts +++ b/packages/@n8n/db/src/migrations/sqlite/index.ts @@ -206,6 +206,7 @@ import { AddUniqueAgentFileNames1784000000035 } from '../common/1784000000035-Ad import { CreateInstanceAiThreadGrantTable1784000000036 } from '../common/1784000000036-CreateInstanceAiThreadGrantTable'; import { SetChatHubEnabledFromUsage1784000000038 } from '../common/1784000000038-SetChatHubEnabledFromUsage'; import { DropAgentExecutionFallbackColumns1784000000039 } from '../common/1784000000039-DropAgentExecutionFallbackColumns'; +import { CreateWorkflowPublicationTriggerStatusTable1784000000040 } from '../common/1784000000040-CreateWorkflowPublicationTriggerStatusTable'; const sqliteMigrations: Migration[] = [ InitialMigration1588102412422, @@ -415,6 +416,7 @@ const sqliteMigrations: Migration[] = [ DropAgentDescriptionFromAgents1784000000037, SetChatHubEnabledFromUsage1784000000038, DropAgentExecutionFallbackColumns1784000000039, + CreateWorkflowPublicationTriggerStatusTable1784000000040, ]; export { sqliteMigrations }; diff --git a/packages/@n8n/db/src/repositories/index.ts b/packages/@n8n/db/src/repositories/index.ts index 3414e9bc189..8f999a19d02 100644 --- a/packages/@n8n/db/src/repositories/index.ts +++ b/packages/@n8n/db/src/repositories/index.ts @@ -48,6 +48,10 @@ export { SharedWorkflowRepository } from './shared-workflow.repository'; export { SharedCredentialsRepository } from './shared-credentials.repository'; export { WorkflowRepository } from './workflow.repository'; export { WorkflowPublicationOutboxRepository } from './workflow-publication-outbox.repository'; +export { + WorkflowPublicationTriggerStatusRepository, + type TriggerStatusRow, +} from './workflow-publication-trigger-status.repository'; export { WorkflowPublishedVersionRepository, type PublishedWorkflowDataForExecution, diff --git a/packages/@n8n/db/src/repositories/workflow-publication-outbox.repository.ts b/packages/@n8n/db/src/repositories/workflow-publication-outbox.repository.ts index f6a9fc065a4..639eeb2c2e7 100644 --- a/packages/@n8n/db/src/repositories/workflow-publication-outbox.repository.ts +++ b/packages/@n8n/db/src/repositories/workflow-publication-outbox.repository.ts @@ -251,18 +251,22 @@ export class WorkflowPublicationOutboxRepository extends Repository { - const result = await this.update( + /** Mark a claimed record as successfully processed. Pass `trx` to enroll in an existing transaction. */ + async markCompleted(id: number, trx?: EntityManager): Promise { + const manager = trx ?? this.manager; + const result = await manager.update( + WorkflowPublicationOutbox, { id, status: Status.InProgress }, { status: Status.Completed, errorMessage: null }, ); this.assertSingleRowAffected(result.affected, id, Status.Completed); } - /** Mark a claimed record as failed and record the error for diagnostics. */ - async markFailed(id: number, errorMessage: string): Promise { - const result = await this.update( + /** Mark a claimed record as failed and record the error for diagnostics. Pass `trx` to enroll in an existing transaction. */ + async markFailed(id: number, errorMessage: string, trx?: EntityManager): Promise { + const manager = trx ?? this.manager; + const result = await manager.update( + WorkflowPublicationOutbox, { id, status: Status.InProgress }, { status: Status.Failed, errorMessage }, ); @@ -272,10 +276,13 @@ export class WorkflowPublicationOutboxRepository extends Repository { - const result = await this.update( + async markPartialSuccess(id: number, errorMessage: string, trx?: EntityManager): Promise { + const manager = trx ?? this.manager; + const result = await manager.update( + WorkflowPublicationOutbox, { id, status: Status.InProgress }, { status: Status.PartialSuccess, errorMessage }, ); diff --git a/packages/@n8n/db/src/repositories/workflow-publication-trigger-status.repository.ts b/packages/@n8n/db/src/repositories/workflow-publication-trigger-status.repository.ts new file mode 100644 index 00000000000..92fd8a32390 --- /dev/null +++ b/packages/@n8n/db/src/repositories/workflow-publication-trigger-status.repository.ts @@ -0,0 +1,39 @@ +import { Service } from '@n8n/di'; +import { DataSource, EntityManager, Repository } from '@n8n/typeorm'; + +import { WorkflowPublicationTriggerStatus } from '../entities'; + +export type TriggerStatusRow = { + nodeId: string; + versionId: string; + status: WorkflowPublicationTriggerStatus['status']; + errorMessage: string | null; +}; + +@Service() +export class WorkflowPublicationTriggerStatusRepository extends Repository { + constructor(dataSource: DataSource) { + super(WorkflowPublicationTriggerStatus, dataSource.manager); + } + + /** Replace all rows for a workflow with `rows`, atomically. Pass `trx` to enroll in an existing transaction. */ + async replaceForWorkflow( + workflowId: string, + rows: TriggerStatusRow[], + trx?: EntityManager, + ): Promise { + const run = async (em: EntityManager) => { + await em.delete(WorkflowPublicationTriggerStatus, { workflowId }); + if (rows.length === 0) return; + await em.insert( + WorkflowPublicationTriggerStatus, + rows.map((row) => ({ workflowId, ...row })), + ); + }; + await (trx ? run(trx) : this.manager.transaction(run)); + } + + async findByWorkflowId(workflowId: string): Promise { + return await this.findBy({ workflowId }); + } +} diff --git a/packages/cli/src/workflows/publication/__tests__/publication-status-reporter.test.ts b/packages/cli/src/workflows/publication/__tests__/publication-status-reporter.test.ts index dc97a68f47f..a7acbf04350 100644 --- a/packages/cli/src/workflows/publication/__tests__/publication-status-reporter.test.ts +++ b/packages/cli/src/workflows/publication/__tests__/publication-status-reporter.test.ts @@ -1,5 +1,11 @@ import type { Logger } from '@n8n/backend-common'; -import type { WorkflowPublicationOutbox, WorkflowPublicationOutboxRepository } from '@n8n/db'; +import type { + EntityManager, + WorkflowPublicationOutbox, + WorkflowPublicationOutboxRepository, + WorkflowPublicationTriggerStatusRepository, +} from '@n8n/db'; +import type { Mock } from 'vitest'; import { mock } from 'vitest-mock-extended'; import type { ErrorReporter } from 'n8n-core'; @@ -12,9 +18,13 @@ describe('PublicationStatusReporter', () => { logger.scoped.mockReturnValue(logger); const errorReporter = mock(); - const outboxRepository = mock(); + const outboxRepository = mock({ + manager: mock(), + }); const activationErrorsService = mock(); const push = mock(); + const triggerStatusRepository = mock(); + const entityManager = mock(); const reporter = new PublicationStatusReporter( logger, @@ -22,6 +32,7 @@ describe('PublicationStatusReporter', () => { outboxRepository, activationErrorsService, push, + triggerStatusRepository, ); function makeRecord( @@ -46,12 +57,41 @@ describe('PublicationStatusReporter', () => { outboxRepository.markPartialSuccess.mockResolvedValue(undefined); activationErrorsService.deregister.mockResolvedValue(undefined); activationErrorsService.register.mockResolvedValue(undefined); + triggerStatusRepository.replaceForWorkflow.mockResolvedValue(undefined); + (outboxRepository.manager.transaction as unknown as Mock).mockImplementation( + async (runInTransaction: (trx: EntityManager) => Promise) => + await runInTransaction(entityManager), + ); }); - test('completed marks the record completed and clears activation errors', async () => { - await reporter.report(makeRecord(), { type: 'completed' }); + test('completed writes trigger rows, marks the record completed, and clears activation errors', async () => { + await reporter.report(makeRecord(), { + type: 'completed', + triggerStatuses: [ + { nodeId: 'a', nodeName: 'Webhook', status: 'activated' }, + { nodeId: 'b', nodeName: 'Schedule', status: 'activated' }, + ], + }); - expect(outboxRepository.markCompleted).toHaveBeenCalledWith(1); + expect(triggerStatusRepository.replaceForWorkflow).toHaveBeenCalledWith( + 'wf-1', + [ + { + nodeId: 'a', + versionId: 'v-2', + status: 'activated', + errorMessage: null, + }, + { + nodeId: 'b', + versionId: 'v-2', + status: 'activated', + errorMessage: null, + }, + ], + entityManager, + ); + expect(outboxRepository.markCompleted).toHaveBeenCalledWith(1, entityManager); expect(activationErrorsService.deregister).toHaveBeenCalledWith('wf-1'); expect(outboxRepository.markFailed).not.toHaveBeenCalled(); expect(push.broadcast).toHaveBeenCalledWith({ @@ -60,10 +100,15 @@ describe('PublicationStatusReporter', () => { }); }); - test('unpublished marks the record completed, clears errors, and pushes deactivation', async () => { + test('unpublished clears trigger rows, marks the record completed, clears errors, and pushes deactivation', async () => { await reporter.report(makeRecord(), { type: 'unpublished' }); - expect(outboxRepository.markCompleted).toHaveBeenCalledWith(1); + expect(triggerStatusRepository.replaceForWorkflow).toHaveBeenCalledWith( + 'wf-1', + [], + entityManager, + ); + expect(outboxRepository.markCompleted).toHaveBeenCalledWith(1, entityManager); expect(activationErrorsService.deregister).toHaveBeenCalledWith('wf-1'); expect(outboxRepository.markFailed).not.toHaveBeenCalled(); expect(push.broadcast).toHaveBeenCalledWith({ @@ -77,7 +122,7 @@ describe('PublicationStatusReporter', () => { async (reason) => { await reporter.report(makeRecord(), { type: 'skipped', reason }); - expect(outboxRepository.markCompleted).toHaveBeenCalledWith(1); + expect(outboxRepository.markCompleted).toHaveBeenCalledWith(1, entityManager); expect(activationErrorsService.deregister).toHaveBeenCalledWith('wf-1'); expect(outboxRepository.markFailed).not.toHaveBeenCalled(); expect(push.broadcast).not.toHaveBeenCalled(); @@ -101,8 +146,13 @@ describe('PublicationStatusReporter', () => { await reporter.report(makeRecord(), { type: 'failed', error }); + expect(triggerStatusRepository.replaceForWorkflow).not.toHaveBeenCalled(); expect(errorReporter.error).toHaveBeenCalledWith(error, { shouldBeLogged: true }); - expect(outboxRepository.markFailed).toHaveBeenCalledWith(1, 'registration failed'); + expect(outboxRepository.markFailed).toHaveBeenCalledWith( + 1, + 'registration failed', + entityManager, + ); expect(outboxRepository.markCompleted).not.toHaveBeenCalled(); expect(push.broadcast).toHaveBeenCalledWith({ type: 'workflowFailedToActivate', @@ -110,21 +160,87 @@ describe('PublicationStatusReporter', () => { }); }); - test('partial marks partial_success, registers per-node detail, and pushes the failures', async () => { + test('failed with triggerStatuses writes rows before marking failed', async () => { + const error = new Error('partial registration failed'); + + await reporter.report(makeRecord(), { + type: 'failed', + error, + triggerStatuses: [ + { nodeId: 'a', nodeName: 'Webhook', status: 'activated' }, + { nodeId: 'b', nodeName: 'Schedule', status: 'failed', errorMessage: 'cron unavailable' }, + ], + }); + + expect(triggerStatusRepository.replaceForWorkflow).toHaveBeenCalledWith( + 'wf-1', + [ + { + nodeId: 'a', + versionId: 'v-2', + status: 'activated', + errorMessage: null, + }, + { + nodeId: 'b', + versionId: 'v-2', + status: 'failed', + errorMessage: 'cron unavailable', + }, + ], + entityManager, + ); + expect(outboxRepository.markFailed).toHaveBeenCalledWith( + 1, + 'partial registration failed', + entityManager, + ); + }); + + test('partial marks partial_success, writes all trigger rows, and pushes the failures without registering activation errors', async () => { await reporter.report(makeRecord(), { type: 'partial', - activatedNodeIds: ['a'], - failures: [ - { nodeId: 'b', nodeName: 'Schedule', error: new Error('cron unavailable') }, - { nodeId: 'c', nodeName: 'Kafka', error: new Error('broker down') }, + triggerStatuses: [ + { nodeId: 'a', nodeName: 'Webhook', status: 'activated' }, + { nodeId: 'b', nodeName: 'Schedule', status: 'failed', errorMessage: 'cron unavailable' }, + { nodeId: 'c', nodeName: 'Kafka', status: 'failed', errorMessage: 'broker down' }, ], }); const expectedMessage = 'Some triggers failed to activate: "Schedule": cron unavailable; "Kafka": broker down'; - expect(outboxRepository.markPartialSuccess).toHaveBeenCalledWith(1, expectedMessage); - expect(activationErrorsService.register).toHaveBeenCalledWith('wf-1', expectedMessage); + expect(outboxRepository.markPartialSuccess).toHaveBeenCalledWith( + 1, + expectedMessage, + entityManager, + ); + expect(triggerStatusRepository.replaceForWorkflow).toHaveBeenCalledWith( + 'wf-1', + [ + { + nodeId: 'a', + versionId: 'v-2', + status: 'activated', + errorMessage: null, + }, + { + nodeId: 'b', + versionId: 'v-2', + status: 'failed', + errorMessage: 'cron unavailable', + }, + { + nodeId: 'c', + versionId: 'v-2', + status: 'failed', + errorMessage: 'broker down', + }, + ], + entityManager, + ); + // CAT-3432: partial path must NOT register activation errors + expect(activationErrorsService.register).not.toHaveBeenCalled(); expect(push.broadcast).toHaveBeenCalledWith({ type: 'workflowPartiallyActivated', data: { diff --git a/packages/cli/src/workflows/publication/__tests__/workflow-publication-applier.test.ts b/packages/cli/src/workflows/publication/__tests__/workflow-publication-applier.test.ts index 52fec479fa3..12ca55cff7f 100644 --- a/packages/cli/src/workflows/publication/__tests__/workflow-publication-applier.test.ts +++ b/packages/cli/src/workflows/publication/__tests__/workflow-publication-applier.test.ts @@ -212,7 +212,10 @@ describe('WorkflowPublicationApplier', () => { const result = await applier.apply(makeRecord()); - expect(result).toEqual({ type: 'completed' }); + expect(result).toEqual({ + type: 'completed', + triggerStatuses: [{ nodeId: 'a', nodeName: 'a', status: 'activated' }], + }); expect(workflowPublishedVersionRepository.setPublishedVersion).toHaveBeenCalledWith( 'wf-1', 'v-2', @@ -230,7 +233,13 @@ describe('WorkflowPublicationApplier', () => { const result = await applier.apply(makeRecord()); - expect(result).toEqual({ type: 'completed' }); + expect(result).toEqual({ + type: 'completed', + triggerStatuses: [ + { nodeId: 'a', nodeName: 'a', status: 'activated' }, + { nodeId: 'b', nodeName: 'b', status: 'activated' }, + ], + }); expect(workflowTriggerActivator.deactivate).not.toHaveBeenCalled(); expect(workflowTriggerActivator.activate).toHaveBeenCalledWith( expect.objectContaining({ id: 'wf-1' }), @@ -254,7 +263,10 @@ describe('WorkflowPublicationApplier', () => { const result = await applier.apply(makeRecord()); - expect(result).toEqual({ type: 'completed' }); + expect(result).toEqual({ + type: 'completed', + triggerStatuses: [{ nodeId: 'a', nodeName: 'a', status: 'activated' }], + }); expect(workflowTriggerActivator.getUnregisteredNonWebhookTriggerNodeIds).toHaveBeenCalledWith( 'wf-1', [trigger], @@ -274,7 +286,10 @@ describe('WorkflowPublicationApplier', () => { const result = await applier.apply(makeRecord()); - expect(result).toEqual({ type: 'completed' }); + expect(result).toEqual({ + type: 'completed', + triggerStatuses: [{ nodeId: 'a', nodeName: 'a', status: 'activated' }], + }); expect(workflowTriggerActivator.activate).not.toHaveBeenCalled(); expect(workflowTriggerActivator.deactivate).not.toHaveBeenCalled(); expect(workflowPublishedVersionRepository.setPublishedVersion).toHaveBeenCalledWith( @@ -293,7 +308,10 @@ describe('WorkflowPublicationApplier', () => { const result = await applier.apply(makeRecord()); - expect(result).toEqual({ type: 'completed' }); + expect(result).toEqual({ + type: 'completed', + triggerStatuses: [{ nodeId: 'a', nodeName: 'a', status: 'activated' }], + }); expect(workflowTriggerActivator.getNodesWithUnregisteredWebhooks).toHaveBeenCalledWith( expect.objectContaining({ id: 'wf-1' }), newVersion, @@ -313,7 +331,13 @@ describe('WorkflowPublicationApplier', () => { const result = await applier.apply(makeRecord()); - expect(result).toEqual({ type: 'completed' }); + expect(result).toEqual({ + type: 'completed', + triggerStatuses: [ + { nodeId: 'a', nodeName: 'a', status: 'activated' }, + { nodeId: 'b', nodeName: 'b', status: 'activated' }, + ], + }); expect(workflowTriggerActivator.activate).toHaveBeenCalledWith( expect.objectContaining({ id: 'wf-1' }), newVersion, @@ -326,7 +350,10 @@ describe('WorkflowPublicationApplier', () => { const result = await applier.apply(makeRecord()); - expect(result).toEqual({ type: 'completed' }); + expect(result).toEqual({ + type: 'completed', + triggerStatuses: [{ nodeId: 'a', nodeName: 'a', status: 'activated' }], + }); expect(workflowTriggerActivator.deactivate).toHaveBeenCalledWith( expect.objectContaining({ id: 'wf-1' }), oldVersion, @@ -365,7 +392,10 @@ describe('WorkflowPublicationApplier', () => { const result = await applier.apply(makeRecord()); - expect(result).toEqual({ type: 'completed' }); + expect(result).toEqual({ + type: 'completed', + triggerStatuses: [{ nodeId: 'a', nodeName: 'a', status: 'activated' }], + }); expect(workflowTriggerActivator.deactivate).toHaveBeenCalledWith( expect.objectContaining({ id: 'wf-1' }), oldVersion, @@ -423,8 +453,10 @@ describe('WorkflowPublicationApplier', () => { expect(result).toEqual({ type: 'partial', - activatedNodeIds: ['a'], - failures: [{ nodeId: 'b', nodeName: 'b', error }], + triggerStatuses: [ + { nodeId: 'a', nodeName: 'a', status: 'activated' }, + { nodeId: 'b', nodeName: 'b', status: 'failed', errorMessage: 'third-party unavailable' }, + ], }); // The new version is published despite the partial activation; no deactivation. expect(workflowPublishedVersionRepository.setPublishedVersion).toHaveBeenCalledWith( @@ -446,8 +478,10 @@ describe('WorkflowPublicationApplier', () => { expect(result).toEqual({ type: 'partial', - activatedNodeIds: ['a'], - failures: [{ nodeId: 'b', nodeName: 'b', error }], + triggerStatuses: [ + { nodeId: 'a', nodeName: 'a', status: 'activated' }, + { nodeId: 'b', nodeName: 'b', status: 'failed', errorMessage: error.message }, + ], }); expect(workflowPublishedVersionRepository.setPublishedVersion).toHaveBeenCalledWith( 'wf-1', @@ -466,7 +500,13 @@ describe('WorkflowPublicationApplier', () => { const result = await applier.apply(makeRecord()); // A single failure passes its error through, preserving the type. - expect(result).toEqual({ type: 'failed', error }); + expect(result).toEqual({ + type: 'failed', + error, + triggerStatuses: [ + { nodeId: 'b', nodeName: 'b', status: 'failed', errorMessage: error.message }, + ], + }); expect(workflowPublishedVersionRepository.setPublishedVersion).toHaveBeenCalledWith( 'wf-1', 'v-2', @@ -485,7 +525,13 @@ describe('WorkflowPublicationApplier', () => { const result = await applier.apply(makeRecord()); - expect(result).toEqual({ type: 'partial', activatedNodeIds: [], failures }); + expect(result).toEqual({ + type: 'partial', + triggerStatuses: [ + { nodeId: 'b', nodeName: 'b', status: 'failed', errorMessage: deterministic.message }, + { nodeId: 'c', nodeName: 'c', status: 'failed', errorMessage: 'third-party unavailable' }, + ], + }); }); test('treats a first publication (no published-version mapping yet) as all-added', async () => { @@ -494,7 +540,10 @@ describe('WorkflowPublicationApplier', () => { const result = await applier.apply(makeRecord()); - expect(result).toEqual({ type: 'completed' }); + expect(result).toEqual({ + type: 'completed', + triggerStatuses: [{ nodeId: 'a', nodeName: 'a', status: 'activated' }], + }); expect(workflowTriggerActivator.deactivate).not.toHaveBeenCalled(); expect(workflowTriggerActivator.activate).toHaveBeenCalledWith( expect.objectContaining({ id: 'wf-1' }), diff --git a/packages/cli/src/workflows/publication/__tests__/workflow-publication-outbox-consumer.test.ts b/packages/cli/src/workflows/publication/__tests__/workflow-publication-outbox-consumer.test.ts index 1cd45691317..8bc71468779 100644 --- a/packages/cli/src/workflows/publication/__tests__/workflow-publication-outbox-consumer.test.ts +++ b/packages/cli/src/workflows/publication/__tests__/workflow-publication-outbox-consumer.test.ts @@ -62,7 +62,7 @@ describe('WorkflowPublicationOutboxConsumer', () => { vi.useFakeTimers(); tracing.startSpan.mockImplementation(async (_opts, spanCb) => await spanCb(mock())); outboxRepository.claimNextPendingRecord.mockResolvedValue(null); - applier.apply.mockResolvedValue({ type: 'completed' }); + applier.apply.mockResolvedValue({ type: 'completed', triggerStatuses: [] }); reporter.report.mockResolvedValue(undefined); consumer = createConsumer(); }); @@ -205,7 +205,7 @@ describe('WorkflowPublicationOutboxConsumer', () => { await new Promise((resolve) => { releaseApply = resolve; }); - return { type: 'completed' }; + return { type: 'completed', triggerStatuses: [] }; }); consumer.startPolling(); @@ -235,7 +235,7 @@ describe('WorkflowPublicationOutboxConsumer', () => { describe('processRecord', () => { test('applies the record then reports the result', async () => { const record = makeRecord(); - const result: PublicationResult = { type: 'completed' }; + const result: PublicationResult = { type: 'completed', triggerStatuses: [] }; applier.apply.mockResolvedValue(result); await consumer.processRecord(record); diff --git a/packages/cli/src/workflows/publication/publication-result.ts b/packages/cli/src/workflows/publication/publication-result.ts index cc00431907d..93ba390364d 100644 --- a/packages/cli/src/workflows/publication/publication-result.ts +++ b/packages/cli/src/workflows/publication/publication-result.ts @@ -1,5 +1,3 @@ -import type { TriggerActivationFailure } from '@/workflows/triggers/workflow-trigger-activator'; - /** * Why a publication did no trigger work and was completed without advancing any * triggers. The outcome is still a success (the record is marked completed); the @@ -11,6 +9,26 @@ export type PublicationSkipReason = /** The workflow is no longer active, so there are no triggers to reconcile. */ | 'workflow-inactive'; +/** A trigger that activated successfully; carries no error. */ +type ActivatedTriggerPublicationStatus = { + nodeId: string; + nodeName: string; + status: 'activated'; +}; + +/** A trigger that failed to activate; always carries the failure message. */ +export type FailedTriggerPublicationStatus = { + nodeId: string; + nodeName: string; + status: 'failed'; + errorMessage: string; +}; + +/** The activation status of a single trigger node after a publication attempt. */ +export type TriggerPublicationStatus = + | ActivatedTriggerPublicationStatus + | FailedTriggerPublicationStatus; + /** * The outcome of applying a single publication outbox record, as produced by * {@link WorkflowPublicationApplier} and consumed by @@ -20,7 +38,7 @@ export type PublicationSkipReason = */ export type PublicationResult = /** Triggers reconciled (or no change needed); the published version advanced. */ - | { type: 'completed' } + | { type: 'completed'; triggerStatuses: TriggerPublicationStatus[] } /** * The workflow was unpublished: the triggers of the previously published * version were torn down and the `workflow_published_version` mapping removed. @@ -34,8 +52,8 @@ export type PublicationResult = /** * The published version advanced and some triggers are running, but others * failed to register. The record is marked `partial_success` and the workflow - * stays published (no auto-unpublish); the failures carry per-node detail. + * stays published (no auto-unpublish); per-trigger detail is in `triggerStatuses`. */ - | { type: 'partial'; activatedNodeIds: Array; failures: TriggerActivationFailure[] } + | { type: 'partial'; triggerStatuses: TriggerPublicationStatus[] } /** The publication failed; the record is failed and the error is reported. */ - | { type: 'failed'; error: Error }; + | { type: 'failed'; error: Error; triggerStatuses?: TriggerPublicationStatus[] }; diff --git a/packages/cli/src/workflows/publication/publication-status-reporter.ts b/packages/cli/src/workflows/publication/publication-status-reporter.ts index 688dcc0901b..16e0d410ff2 100644 --- a/packages/cli/src/workflows/publication/publication-status-reporter.ts +++ b/packages/cli/src/workflows/publication/publication-status-reporter.ts @@ -1,21 +1,27 @@ import { Logger } from '@n8n/backend-common'; -import { WorkflowPublicationOutbox, WorkflowPublicationOutboxRepository } from '@n8n/db'; +import { + WorkflowPublicationOutbox, + WorkflowPublicationOutboxRepository, + WorkflowPublicationTriggerStatusRepository, + type TriggerStatusRow, +} from '@n8n/db'; import { Service } from '@n8n/di'; import { ErrorReporter } from 'n8n-core'; import { ActivationErrorsService } from '@/activation-errors.service'; import { Push } from '@/push'; import type { + FailedTriggerPublicationStatus, PublicationResult, PublicationSkipReason, + TriggerPublicationStatus, } from '@/workflows/publication/publication-result'; -import type { TriggerActivationFailure } from '@/workflows/triggers/workflow-trigger-activator'; /** * Turns a {@link PublicationResult} into terminal state. This is the only place * that writes terminal outbox statuses and the only place that maps a result to - * its side effects: clearing or (in a later phase) recording activation errors, - * and pushing publication status to the UI. + * its side effects: persisting per-trigger status rows, clearing legacy activation + * errors on success, and pushing publication status to the UI. */ @Service() export class PublicationStatusReporter { @@ -25,6 +31,7 @@ export class PublicationStatusReporter { private readonly outboxRepository: WorkflowPublicationOutboxRepository, private readonly activationErrorsService: ActivationErrorsService, private readonly push: Push, + private readonly triggerStatusRepository: WorkflowPublicationTriggerStatusRepository, ) { this.logger = this.logger.scoped('workflow-publication'); } @@ -32,7 +39,7 @@ export class PublicationStatusReporter { async report(record: WorkflowPublicationOutbox, result: PublicationResult): Promise { switch (result.type) { case 'completed': { - await this.complete(record); + await this.complete(record, this.toRows(record, result.triggerStatuses)); this.push.broadcast({ type: 'workflowActivated', data: { workflowId: record.workflowId, activeVersionId: record.publishedVersionId }, @@ -41,7 +48,7 @@ export class PublicationStatusReporter { } case 'unpublished': { - await this.complete(record); + await this.complete(record, /*triggerStatuses=*/ []); this.push.broadcast({ type: 'workflowDeactivated', data: { workflowId: record.workflowId }, @@ -68,14 +75,24 @@ export class PublicationStatusReporter { } case 'failed': { + const { triggerStatuses } = result; + await this.outboxRepository.manager.transaction(async (trx) => { + if (triggerStatuses) { + await this.triggerStatusRepository.replaceForWorkflow( + record.workflowId, + this.toRows(record, triggerStatuses), + trx, + ); + } + await this.outboxRepository.markFailed(record.id, result.error.message, trx); + }); this.errorReporter.error(result.error, { shouldBeLogged: true }); - await this.outboxRepository.markFailed(record.id, result.error.message); this.pushFailedToActivate(record.workflowId, result.error.message); return; } case 'partial': { - await this.reportPartial(record, result.failures); + await this.reportPartial(record, result.triggerStatuses); return; } } @@ -83,27 +100,36 @@ export class PublicationStatusReporter { /** * Reports a partial publication: the new version stays published with the - * surviving triggers running. Records a `partial_success` status, registers a - * structured per-node activation error so it surfaces on reload, and pushes the - * failure detail to connected clients. The workflow is not unpublished. + * surviving triggers running. Marks the outbox record `partial_success`, + * full-replaces the workflow's per-trigger status rows, and pushes the + * per-node failure detail to connected clients. The workflow is not unpublished. * * The push is leader-local for now; multi-main pubsub routing is tracked as * follow-up work (see CAT-3423). */ private async reportPartial( record: WorkflowPublicationOutbox, - failures: TriggerActivationFailure[], + triggerStatuses: TriggerPublicationStatus[], ): Promise { + const failures = triggerStatuses.filter( + (s): s is FailedTriggerPublicationStatus => s.status === 'failed', + ); const errorMessage = this.formatActivationError(failures); this.logger.warn('Workflow partially published; some triggers failed to activate', { workflowId: record.workflowId, outboxId: record.id, - failedNodeIds: failures.map((failure) => failure.nodeId), + failedNodeIds: failures.map((s) => s.nodeId), }); - await this.outboxRepository.markPartialSuccess(record.id, errorMessage); - await this.activationErrorsService.register(record.workflowId, errorMessage); + await this.outboxRepository.manager.transaction(async (trx) => { + await this.triggerStatusRepository.replaceForWorkflow( + record.workflowId, + this.toRows(record, triggerStatuses), + trx, + ); + await this.outboxRepository.markPartialSuccess(record.id, errorMessage, trx); + }); this.push.broadcast({ type: 'workflowPartiallyActivated', @@ -111,19 +137,32 @@ export class PublicationStatusReporter { workflowId: record.workflowId, activeVersionId: record.publishedVersionId, errorMessage, - failedNodes: failures.map((failure) => ({ - nodeId: failure.nodeId, - nodeName: failure.nodeName, - errorMessage: failure.error.message, + failedNodes: failures.map((triggerStatus) => ({ + nodeId: triggerStatus.nodeId, + nodeName: triggerStatus.nodeName, + errorMessage: triggerStatus.errorMessage, })), }, }); } + /** Maps trigger publication statuses to repository row objects, stamping the published version. */ + private toRows( + record: WorkflowPublicationOutbox, + statuses: TriggerPublicationStatus[], + ): TriggerStatusRow[] { + return statuses.map((triggerStatus) => ({ + nodeId: triggerStatus.nodeId, + versionId: record.publishedVersionId, + status: triggerStatus.status, + errorMessage: triggerStatus.status === 'failed' ? triggerStatus.errorMessage : null, + })); + } + /** Builds a human-readable message naming each failed node and its error. */ - private formatActivationError(failures: TriggerActivationFailure[]): string { + private formatActivationError(failures: FailedTriggerPublicationStatus[]): string { const detail = failures - .map((failure) => `"${failure.nodeName}": ${failure.error.message}`) + .map((status) => `"${status.nodeName}": ${status.errorMessage}`) .join('; '); return `Some triggers failed to activate: ${detail}`; @@ -137,9 +176,24 @@ export class PublicationStatusReporter { }); } - /** Marks the record completed and clears any activation errors for the workflow. */ - private async complete(record: WorkflowPublicationOutbox): Promise { - await this.outboxRepository.markCompleted(record.id); + /** + * Marks the record completed and clears any activation errors for the workflow. + * If there are any per-trigger statuses passed in, they are persisted in the same transaction. + */ + private async complete( + record: WorkflowPublicationOutbox, + triggerStatuses?: TriggerStatusRow[], + ): Promise { + await this.outboxRepository.manager.transaction(async (trx) => { + if (triggerStatuses !== undefined) { + await this.triggerStatusRepository.replaceForWorkflow( + record.workflowId, + triggerStatuses, + trx, + ); + } + await this.outboxRepository.markCompleted(record.id, trx); + }); await this.activationErrorsService.deregister(record.workflowId); } diff --git a/packages/cli/src/workflows/publication/workflow-publication-applier.ts b/packages/cli/src/workflows/publication/workflow-publication-applier.ts index 630f480ccf2..5b5f61bfc9b 100644 --- a/packages/cli/src/workflows/publication/workflow-publication-applier.ts +++ b/packages/cli/src/workflows/publication/workflow-publication-applier.ts @@ -8,9 +8,13 @@ import { WorkflowRepository, } from '@n8n/db'; import { Service } from '@n8n/di'; +import type { INode } from 'n8n-workflow'; import { ensureError } from 'n8n-workflow'; -import type { PublicationResult } from '@/workflows/publication/publication-result'; +import type { + PublicationResult, + TriggerPublicationStatus, +} from '@/workflows/publication/publication-result'; import { computeTriggerDiff } from '@/workflows/publication/trigger-diff'; import { isTransientActivationError } from '@/workflows/triggers/trigger-activation-retry'; import { @@ -127,7 +131,13 @@ export class WorkflowPublicationApplier { // triggers keep running and re-read the new version on their next fire. if (toAdd.size === 0 && toRemove.size === 0) { await this.advancePublishedVersion(record); - return { type: 'completed' }; + return { + type: 'completed', + triggerStatuses: this.buildTriggerStatuses(desiredTriggerNodes, { + activated: [], + failures: [], + }), + }; } // Must happen BEFORE advancing the version, using the currently published @@ -142,7 +152,7 @@ export class WorkflowPublicationApplier { try { if (toAdd.size > 0) { const outcome = await this.workflowTriggerActivator.activate(workflow, newVersion, toAdd); - return this.classifyActivationOutcome(outcome); + return this.classifyActivationOutcome(outcome, desiredTriggerNodes); } if (toRemove.size > 0) { @@ -152,7 +162,13 @@ export class WorkflowPublicationApplier { return { type: 'failed', error: ensureError(e) }; } - return { type: 'completed' }; + return { + type: 'completed', + triggerStatuses: this.buildTriggerStatuses(desiredTriggerNodes, { + activated: [], + failures: [], + }), + }; } /** @@ -191,23 +207,45 @@ export class WorkflowPublicationApplier { } /** - * Maps a per-node activation outcomes to a combined publication result. + * Maps per-node activation outcomes to a combined publication result, attaching + * the full desired trigger set's statuses to every version-advancing result. */ - private classifyActivationOutcome(outcome: TriggerActivationOutcome): PublicationResult { - if (outcome.failures.length === 0) return { type: 'completed' }; + private classifyActivationOutcome( + outcome: TriggerActivationOutcome, + desiredTriggerNodes: INode[], + ): PublicationResult { + const triggerStatuses = this.buildTriggerStatuses(desiredTriggerNodes, outcome); + if (outcome.failures.length === 0) return { type: 'completed', triggerStatuses }; - const allDeterministic = outcome.failures.every( - (failure) => !isTransientActivationError(failure.error), - ); + const allDeterministic = outcome.failures.every((f) => !isTransientActivationError(f.error)); if (outcome.activated.length === 0 && allDeterministic) { - return { type: 'failed', error: this.toActivationError(outcome.failures) }; + return { type: 'failed', error: this.toActivationError(outcome.failures), triggerStatuses }; } - return { - type: 'partial', - activatedNodeIds: outcome.activated, - failures: outcome.failures, - }; + return { type: 'partial', triggerStatuses }; + } + + /** + * Builds per-trigger statuses for the full set of desired trigger nodes. + * Nodes in `outcome.failures` are marked `failed`; all others are `activated`, + * including unchanged-but-still-running triggers that were not in `toAdd`. + */ + private buildTriggerStatuses( + desiredTriggerNodes: INode[], + outcome: TriggerActivationOutcome, + ): TriggerPublicationStatus[] { + const failureByNodeId = new Map(outcome.failures.map((f) => [f.nodeId, f])); + return desiredTriggerNodes.map((node): TriggerPublicationStatus => { + const failure = failureByNodeId.get(node.id); + return failure + ? { + nodeId: node.id, + nodeName: node.name, + status: 'failed', + errorMessage: failure.error.message, + } + : { nodeId: node.id, nodeName: node.name, status: 'activated' }; + }); } /** diff --git a/packages/cli/test/integration/workflow-publication-trigger-status.repository.test.ts b/packages/cli/test/integration/workflow-publication-trigger-status.repository.test.ts new file mode 100644 index 00000000000..d92a897c2d4 --- /dev/null +++ b/packages/cli/test/integration/workflow-publication-trigger-status.repository.test.ts @@ -0,0 +1,89 @@ +import { createWorkflow, createWorkflowHistory, testDb } from '@n8n/backend-test-utils'; +import type { IWorkflowDb } from '@n8n/db'; +import { + WorkflowHistoryRepository, + WorkflowPublicationTriggerStatusRepository, + WorkflowRepository, +} from '@n8n/db'; +import { Container } from '@n8n/di'; + +/** Seeds `workflow_history` rows so the trigger-status versionId FK is satisfiable. */ +async function seedVersions(workflow: IWorkflowDb, versionIds: string[]): Promise { + for (const versionId of versionIds) { + await createWorkflowHistory(workflow, undefined, undefined, { versionId }); + } +} + +describe('WorkflowPublicationTriggerStatusRepository', () => { + let repo: WorkflowPublicationTriggerStatusRepository; + let workflowRepository: WorkflowRepository; + let workflowHistoryRepository: WorkflowHistoryRepository; + + // Shared fixture for the non-destructive tests; the CASCADE tests create their + // own workflow/version since they delete the parent. + let workflow: IWorkflowDb; + + beforeAll(async () => { + await testDb.init(); + repo = Container.get(WorkflowPublicationTriggerStatusRepository); + workflowRepository = Container.get(WorkflowRepository); + workflowHistoryRepository = Container.get(WorkflowHistoryRepository); + + workflow = await createWorkflow(); + await seedVersions(workflow, ['v1', 'v2']); + }); + afterEach(async () => { + await testDb.truncate(['WorkflowPublicationTriggerStatus']); + }); + afterAll(async () => await testDb.terminate()); + + it('replaceForWorkflow inserts rows then overwrites them', async () => { + await repo.replaceForWorkflow(workflow.id, [ + { nodeId: 'n1', versionId: 'v1', status: 'activated', errorMessage: null }, + { nodeId: 'n2', versionId: 'v1', status: 'failed', errorMessage: 'boom' }, + ]); + expect(await repo.findByWorkflowId(workflow.id)).toHaveLength(2); + + await repo.replaceForWorkflow(workflow.id, [ + { nodeId: 'n1', versionId: 'v2', status: 'activated', errorMessage: null }, + ]); + const rows = await repo.findByWorkflowId(workflow.id); + expect(rows).toHaveLength(1); + expect(rows[0]).toMatchObject({ nodeId: 'n1', versionId: 'v2', status: 'activated' }); + }); + + it('replaceForWorkflow with an empty list clears all rows', async () => { + await repo.replaceForWorkflow(workflow.id, [ + { nodeId: 'n1', versionId: 'v1', status: 'activated', errorMessage: null }, + ]); + await repo.replaceForWorkflow(workflow.id, []); + expect(await repo.findByWorkflowId(workflow.id)).toHaveLength(0); + }); + + it('FK CASCADE deletes trigger status rows when parent workflow is deleted', async () => { + const ownWorkflow = await createWorkflow(); + await seedVersions(ownWorkflow, ['v-wf-cascade']); + await repo.replaceForWorkflow(ownWorkflow.id, [ + { nodeId: 'n1', versionId: 'v-wf-cascade', status: 'activated', errorMessage: null }, + { nodeId: 'n2', versionId: 'v-wf-cascade', status: 'failed', errorMessage: 'boom' }, + ]); + expect(await repo.findByWorkflowId(ownWorkflow.id)).toHaveLength(2); + + await workflowRepository.delete(ownWorkflow.id); + + expect(await repo.findByWorkflowId(ownWorkflow.id)).toEqual([]); + }); + + it('FK CASCADE deletes trigger status rows when the referenced version is deleted', async () => { + const ownWorkflow = await createWorkflow(); + await seedVersions(ownWorkflow, ['v-version-cascade']); + await repo.replaceForWorkflow(ownWorkflow.id, [ + { nodeId: 'n1', versionId: 'v-version-cascade', status: 'activated', errorMessage: null }, + ]); + expect(await repo.findByWorkflowId(ownWorkflow.id)).toHaveLength(1); + + await workflowHistoryRepository.delete({ versionId: 'v-version-cascade' }); + + expect(await repo.findByWorkflowId(ownWorkflow.id)).toEqual([]); + }); +});