From 806a2aa94be7be56737a69c3e0f256c270e9a970 Mon Sep 17 00:00:00 2001 From: Robin Braumann <50590409+bjorger@users.noreply.github.com> Date: Fri, 9 Oct 2026 07:42:23 +0000 Subject: [PATCH] refactor(core): Use typed package requirement consumers (#40621) --- packages/@n8n/api-types/src/dto/index.ts | 1 + .../packages/import-blocking-issue.openapi.ts | 12 +- .../packages/import-blocking-issue.schema.ts | 23 +- .../cli/src/__tests__/package-error.test.ts | 52 +++-- .../cli/src/commands/package/package-error.ts | 60 +++-- packages/cli/BREAKING-CHANGES.md | 11 + .../__tests__/rest-error-response.test.ts | 9 +- .../export-folder.integration.test.ts | 16 +- .../export-project.integration.test.ts | 29 ++- ...kflow-with-credentials.integration.test.ts | 16 +- ...kflow-with-data-tables.integration.test.ts | 24 +- ...ort-workflow-with-tags.integration.test.ts | 15 +- ...orkflow-with-variables.integration.test.ts | 28 ++- .../export-workflow.integration.test.ts | 109 ++++++--- .../__tests__/fixtures/package-fixtures.ts | 18 +- .../import-data-tables.integration.test.ts | 6 +- .../import-package.integration.test.ts | 7 +- .../import-projects.integration.test.ts | 220 +++++++++++++++--- ...ort-workflow-with-tags.integration.test.ts | 14 +- ...orkflow-with-variables.integration.test.ts | 12 +- .../__tests__/import-blocked.error.test.ts | 6 +- .../engine/__tests__/import-result.test.ts | 3 +- .../engine/__tests__/import-telemetry.test.ts | 11 +- .../engine/__tests__/package-layout.test.ts | 2 +- .../engine/import-orchestrator.ts | 176 +++++++++++--- .../n8n-packages/engine/import-result.ts | 13 +- .../__tests__/credential-importer.test.ts | 25 +- .../__tests__/credential-matcher.test.ts | 41 ++-- .../__tests__/credential-missing-mode.test.ts | 15 +- .../__tests__/credential.exporter.test.ts | 15 +- .../entities/credential/credential-matcher.ts | 1 - .../credential/credential-missing-mode.ts | 3 +- .../credential/credential.exporter.ts | 7 +- .../entities/credential/credential.types.ts | 2 - .../__tests__/data-table-importer.test.ts | 6 +- .../__tests__/data-table.exporter.test.ts | 9 +- .../data-table/data-table-importer.ts | 9 +- .../data-table/data-table.exporter.ts | 5 +- .../entities/data-table/data-table.types.ts | 2 - .../tag/__tests__/tag-importer.test.ts | 14 +- .../entities/tag/tag-import-decision.ts | 6 +- .../n8n-packages/entities/tag/tag-importer.ts | 49 +--- .../n8n-packages/entities/tag/tag.exporter.ts | 8 +- .../n8n-packages/entities/tag/tag.types.ts | 21 +- .../__tests__/variable-importer.test.ts | 4 +- .../__tests__/variable.exporter.test.ts | 48 +++- .../entities/variable/variable-importer.ts | 17 +- .../entities/variable/variable.exporter.ts | 5 +- .../entities/variable/variable.types.ts | 36 +-- .../__tests__/missing-node-type-mode.test.ts | 55 ++++- .../__tests__/sub-workflow-ordering.test.ts | 2 +- .../__tests__/workflow-publisher.test.ts | 4 +- .../workflow-requirement.exporter.test.ts | 13 +- .../workflow/missing-node-type-mode.ts | 3 +- .../entities/workflow/node-type-usage.ts | 12 +- .../workflow/sub-workflow-ordering.ts | 3 +- .../workflow/workflow-requirement.exporter.ts | 4 +- .../n8n-packages/n8n-packages.types.ts | 24 +- .../spec/__tests__/manifest.schema.test.ts | 30 ++- .../__tests__/requirements.schema.test.ts | 33 ++- .../spec/requirement-consumers.ts | 19 ++ .../n8n-packages/spec/requirements.schema.ts | 18 +- .../__tests__/import-manifest-bridge.test.ts | 28 ++- .../promotion-change.service.test.ts | 14 +- .../__tests__/working-copy-updater.test.ts | 2 +- .../promotions.ee/import-manifest-bridge.ts | 41 ++-- .../promotions.ee/promotion-change.service.ts | 14 +- .../schemas/importBlockingIssue.generated.yml | 48 ++-- .../packageRequirementConsumer.generated.yml | 12 + .../public-api/n8n-packages-import.test.ts | 4 +- 70 files changed, 1077 insertions(+), 547 deletions(-) create mode 100644 packages/cli/src/modules/n8n-packages/spec/requirement-consumers.ts create mode 100644 packages/cli/src/public-api/v1/shared/spec/schemas/packageRequirementConsumer.generated.yml diff --git a/packages/@n8n/api-types/src/dto/index.ts b/packages/@n8n/api-types/src/dto/index.ts index b38eeeecc4a..148c586dd62 100644 --- a/packages/@n8n/api-types/src/dto/index.ts +++ b/packages/@n8n/api-types/src/dto/index.ts @@ -491,6 +491,7 @@ export { export { ExportPackageRequestDto } from './packages/export-package-request.dto'; export { importBlockingIssueSchema, + packageRequirementConsumerSchema, type ImportBlockingIssue, } from './packages/import-blocking-issue.schema'; export { diff --git a/packages/@n8n/api-types/src/dto/packages/import-blocking-issue.openapi.ts b/packages/@n8n/api-types/src/dto/packages/import-blocking-issue.openapi.ts index f78a2103ac8..08d3db2324a 100644 --- a/packages/@n8n/api-types/src/dto/packages/import-blocking-issue.openapi.ts +++ b/packages/@n8n/api-types/src/dto/packages/import-blocking-issue.openapi.ts @@ -205,10 +205,10 @@ export const tagUnresolvedFieldDocs = { existingName: { description: 'For `rename-drift`: the current name of the same-id target tag.', }, - usedByWorkflows: { + usedBy: { description: - 'Package workflow ids (non-skipped) that reference the source tag — not workflows ' + - 'attached to the contested target tag.', + 'Package consumers that reference the source tag. This excludes skipped consumers ' + + 'and workflows attached to the contested target tag.', }, } as const satisfies Record; @@ -263,8 +263,8 @@ export const variableLimitExceededFieldDocs = { description: 'Number of new variable rows the import would create (destination-deduplicated).', }, names: { description: 'The unique variable names the import would create.' }, - usedByWorkflows: { - description: 'Package workflow ids that reference any of the listed variables.', + usedBy: { + description: 'Package consumers that reference any of the listed variables.', }, } as const satisfies Record; @@ -278,7 +278,7 @@ export const variableLimitExceededIssueOpenApi: ZodOpenAPIMetadata = { export const missingNodeTypeFieldDocs = { nodeType: { description: "Full node type name as used by the package's workflows." }, typeVersion: { description: "Node type version the package's workflows use." }, - usedByWorkflows: { description: 'Package workflow ids that use this node type and version.' }, + usedBy: { description: 'Package consumers that use this node type and version.' }, } as const satisfies Record; export const missingNodeTypeIssueOpenApi: ZodOpenAPIMetadata = { diff --git a/packages/@n8n/api-types/src/dto/packages/import-blocking-issue.schema.ts b/packages/@n8n/api-types/src/dto/packages/import-blocking-issue.schema.ts index cff5b0308d0..9216902b9c2 100644 --- a/packages/@n8n/api-types/src/dto/packages/import-blocking-issue.schema.ts +++ b/packages/@n8n/api-types/src/dto/packages/import-blocking-issue.schema.ts @@ -42,6 +42,13 @@ import { import { dataTableColumnTypeSchema } from '../../schemas/data-table.schema'; import { policyViolationSchema } from '../../schemas/policy-violation.schema'; +export const packageRequirementConsumerSchema = z + .object({ + kind: z.literal('workflow'), + id: z.string().min(1), + }) + .openapi('PackageRequirementConsumer'); + /** * A reason an import cannot proceed, matching the `BlockingIssue` union in * `packages/cli/src/modules/n8n-packages/n8n-packages.types.ts`. A type-level test in @@ -121,7 +128,7 @@ const credentialUnresolvedIssueSchema = z targetId: z.string().optional().openapi(credentialUnresolvedFieldDocs.targetId), expectedType: z.string().optional().openapi(credentialUnresolvedFieldDocs.expectedType), actualType: z.string().optional().openapi(credentialUnresolvedFieldDocs.actualType), - usedByWorkflows: z.array(z.string()), + usedBy: z.array(packageRequirementConsumerSchema), }) .openapi(credentialUnresolvedIssueOpenApi); @@ -265,7 +272,7 @@ const dataTableUnresolvedIssueSchema = z .optional() .openapi(dataTableUnresolvedFieldDocs.conflictingTableId), currentName: z.string().optional().openapi(dataTableUnresolvedFieldDocs.currentName), - usedByWorkflows: z.array(z.string()), + usedBy: z.array(packageRequirementConsumerSchema), }) .openapi(dataTableUnresolvedIssueOpenApi); @@ -287,7 +294,7 @@ const tagUnresolvedIssueSchema = z .openapi(tagUnresolvedFieldDocs.missingScope), existingTagId: z.string().optional().openapi(tagUnresolvedFieldDocs.existingTagId), existingName: z.string().optional().openapi(tagUnresolvedFieldDocs.existingName), - usedByWorkflows: z.array(z.string()).openapi(tagUnresolvedFieldDocs.usedByWorkflows), + usedBy: z.array(packageRequirementConsumerSchema).openapi(tagUnresolvedFieldDocs.usedBy), }) .openapi(tagUnresolvedIssueOpenApi); @@ -295,7 +302,7 @@ const variableUnresolvedIssueSchema = z .object({ type: z.literal('variable-unresolved'), name: z.string().openapi(variableUnresolvedFieldDocs.name), - usedByWorkflows: z.array(z.string()), + usedBy: z.array(packageRequirementConsumerSchema), }) .openapi(variableUnresolvedIssueOpenApi); @@ -304,7 +311,7 @@ const variableConflictIssueSchema = z type: z.literal('variable-conflict'), name: z.string().openapi(variableConflictFieldDocs.name), projectId: z.string().optional().openapi(variableConflictFieldDocs.projectId), - usedByWorkflows: z.array(z.string()), + usedBy: z.array(packageRequirementConsumerSchema), }) .openapi(variableConflictIssueOpenApi); @@ -316,7 +323,9 @@ const variableLimitExceededIssueSchema = z remaining: z.number().int().openapi(variableLimitExceededFieldDocs.remaining), requested: z.number().int().openapi(variableLimitExceededFieldDocs.requested), names: z.array(z.string()).openapi(variableLimitExceededFieldDocs.names), - usedByWorkflows: z.array(z.string()).openapi(variableLimitExceededFieldDocs.usedByWorkflows), + usedBy: z + .array(packageRequirementConsumerSchema) + .openapi(variableLimitExceededFieldDocs.usedBy), }) .openapi(variableLimitExceededIssueOpenApi); @@ -325,7 +334,7 @@ const missingNodeTypeIssueSchema = z type: z.literal('missing-node-type'), nodeType: z.string().openapi(missingNodeTypeFieldDocs.nodeType), typeVersion: z.number().openapi(missingNodeTypeFieldDocs.typeVersion), - usedByWorkflows: z.array(z.string()).openapi(missingNodeTypeFieldDocs.usedByWorkflows), + usedBy: z.array(packageRequirementConsumerSchema).openapi(missingNodeTypeFieldDocs.usedBy), }) .openapi(missingNodeTypeIssueOpenApi); diff --git a/packages/@n8n/cli/src/__tests__/package-error.test.ts b/packages/@n8n/cli/src/__tests__/package-error.test.ts index 01a5de26baf..57c4a8b07bf 100644 --- a/packages/@n8n/cli/src/__tests__/package-error.test.ts +++ b/packages/@n8n/cli/src/__tests__/package-error.test.ts @@ -58,7 +58,7 @@ describe('toPackagesError', () => { { kind: 'remove-column', column: 'note', type: 'string', destructive: true }, { kind: 'add-column', column: 'BaaId', type: 'string', destructive: false }, ], - usedByWorkflows: ['wf1'], + usedBy: [{ kind: 'workflow', id: 'wf1' }], }, ], }), @@ -66,7 +66,7 @@ describe('toPackagesError', () => { const hint = (result as ApiError).hint ?? ''; expect(hint).toContain( - 'data table "First" (dt1) does not match the package schema (missing columns: BaaId), used by workflow(s) wf1', + 'data table "First" (dt1) does not match the package schema (missing columns: BaaId), used by workflow wf1', ); expect(hint).toContain( '--data-table-schema-conflict-policy=overwrite would: remove column note (data lost), add column BaaId', @@ -84,14 +84,14 @@ describe('toPackagesError', () => { name: 'Sales', currentName: 'Orders', conflictingTableId: 'sales1', - usedByWorkflows: ['wf1'], + usedBy: [{ kind: 'workflow', id: 'wf1' }], }, ], }), ); expect((result as ApiError).hint ?? '').toContain( - 'data table "Orders" (orders1) cannot be renamed to "Sales": the name is also used by table sales1, used by workflow(s) wf1', + 'data table "Orders" (orders1) cannot be renamed to "Sales": the name is also used by table sales1, used by workflow wf1', ); }); @@ -141,7 +141,10 @@ describe('toPackagesError', () => { type: 'credential-unresolved', kind: 'not_found', sourceId: 'c1', - usedByWorkflows: ['w1', 'w2'], + usedBy: [ + { kind: 'workflow', id: 'w1' }, + { kind: 'workflow', id: 'w2' }, + ], }, ], }), @@ -149,17 +152,33 @@ describe('toPackagesError', () => { const hint = (result as ApiError).hint ?? ''; expect(hint).toContain('credential c1 unresolved (not_found)'); - expect(hint).toContain('w1, w2'); + expect(hint).toContain('workflow w1, workflow w2'); }); - it('lists variable-unresolved issues for a 422', () => { + it.each([ + { + usedBy: [{ kind: 'workflow', id: 'w1' }], + expected: 'workflow w1', + }, + { + usedBy: [{ kind: 'agent', id: 'a1' }], + expected: 'agent a1', + }, + { + usedBy: [ + { kind: 'workflow', id: 'shared' }, + { kind: 'agent', id: 'shared' }, + ], + expected: 'workflow shared, agent shared', + }, + ])('lists variable-unresolved issues for $expected', ({ usedBy, expected }) => { const result = toPackagesError( new ApiError(422, 'Import blocked', undefined, { issues: [ { type: 'variable-unresolved', name: 'var1', - usedByWorkflows: ['w1', 'w2'], + usedBy, }, ], }), @@ -167,7 +186,7 @@ describe('toPackagesError', () => { const hint = (result as ApiError).hint ?? ''; expect(hint).toContain('variable "var1" unresolved'); - expect(hint).toContain('w1, w2'); + expect(hint).toContain(`used by ${expected}`); }); it('lists variable-conflict issues for a 409, naming the scope', () => { @@ -178,9 +197,9 @@ describe('toPackagesError', () => { type: 'variable-conflict', name: 'API_URL', projectId: 'p1', - usedByWorkflows: ['w1'], + usedBy: [{ kind: 'workflow', id: 'w1' }], }, - { type: 'variable-conflict', name: 'DB_URL', usedByWorkflows: ['w2'] }, + { type: 'variable-conflict', name: 'DB_URL', usedBy: [{ kind: 'workflow', id: 'w2' }] }, ], }), ); @@ -202,7 +221,10 @@ describe('toPackagesError', () => { remaining: 1, requested: 3, names: ['API_URL', 'DB_URL', 'TOKEN'], - usedByWorkflows: ['w1', 'w2'], + usedBy: [ + { kind: 'workflow', id: 'w1' }, + { kind: 'workflow', id: 'w2' }, + ], }, ], }), @@ -212,7 +234,7 @@ describe('toPackagesError', () => { expect(hint).toContain('variable limit reached: 3 new variable(s)'); expect(hint).toContain('API_URL, DB_URL, TOKEN'); expect(hint).toContain('1 of 5 remaining'); - expect(hint).toContain('w1, w2'); + expect(hint).toContain('workflow w1, workflow w2'); }); it('lists tag-unresolved issues for a 409', () => { @@ -225,13 +247,13 @@ describe('toPackagesError', () => { sourceId: 't1', name: 'prod', existingName: 'production', - usedByWorkflows: ['w1'], + usedBy: [{ kind: 'workflow', id: 'w1' }], }, { type: 'tag-unresolved', kind: 'permission-denied', missingScope: 'tag:create', - usedByWorkflows: ['w2'], + usedBy: [{ kind: 'workflow', id: 'w2' }], }, ], }), diff --git a/packages/@n8n/cli/src/commands/package/package-error.ts b/packages/@n8n/cli/src/commands/package/package-error.ts index 617b9546994..27c7f4f58e2 100644 --- a/packages/@n8n/cli/src/commands/package/package-error.ts +++ b/packages/@n8n/cli/src/commands/package/package-error.ts @@ -1,5 +1,7 @@ import { ApiError } from '../../client'; +type RequirementConsumer = { kind: string; id: string }; + type BlockingIssue = | { type: 'workflow-conflict'; @@ -30,22 +32,22 @@ type BlockingIssue = transition: 'archive' | 'unarchive'; } | { type: 'folder-removal-forbidden'; folderId: string; name: string; projectId: string } - | { type: 'credential-unresolved'; kind: string; sourceId: string; usedByWorkflows: string[] } - | { type: 'variable-unresolved'; name: string; usedByWorkflows: string[] } - | { type: 'variable-conflict'; name: string; projectId?: string; usedByWorkflows: string[] } + | { type: 'credential-unresolved'; kind: string; sourceId: string; usedBy: RequirementConsumer[] } + | { type: 'variable-unresolved'; name: string; usedBy: RequirementConsumer[] } + | { type: 'variable-conflict'; name: string; projectId?: string; usedBy: RequirementConsumer[] } | { type: 'variable-limit-exceeded'; limit: number; remaining: number; requested: number; names: string[]; - usedByWorkflows: string[]; + usedBy: RequirementConsumer[]; } | { type: 'missing-node-type'; nodeType: string; typeVersion: number; - usedByWorkflows: string[]; + usedBy: RequirementConsumer[]; } | { type: 'tag-unresolved'; @@ -53,7 +55,7 @@ type BlockingIssue = sourceId?: string; name?: string; missingScope?: string; - usedByWorkflows: string[]; + usedBy: RequirementConsumer[]; } | { type: 'data-table-unresolved'; @@ -67,7 +69,7 @@ type BlockingIssue = overwriteChanges?: DataTableSchemaChange[]; currentName?: string; conflictingTableId?: string; - usedByWorkflows: string[]; + usedBy: RequirementConsumer[]; }; type DataTableSchemaChange = { destructive?: boolean } & ( @@ -93,6 +95,14 @@ function describeSchemaChange(change: DataTableSchemaChange): string { } } +function formatConsumers(consumers: RequirementConsumer[] | undefined): string { + if (!Array.isArray(consumers)) return ''; + return consumers + .filter((consumer) => typeof consumer?.kind === 'string' && typeof consumer.id === 'string') + .map(({ kind, id }) => `${kind} ${id}`) + .join(', '); +} + function formatIssue(issue: unknown): string { if (typeof issue !== 'object' || issue === null) return JSON.stringify(issue); const it = issue as Partial & Record; @@ -123,38 +133,38 @@ function formatIssue(issue: unknown): string { return `folder "${it.name}" (${it.folderId}) in project ${it.projectId} is not in the package and would be removed, but you lack permission to remove it`; } if (it.type === 'credential-unresolved') { - const usedBy = Array.isArray(it.usedByWorkflows) ? it.usedByWorkflows.join(', ') : ''; - return `credential ${it.sourceId} unresolved (${it.kind}), used by workflow(s) ${usedBy}`; + const usedBy = formatConsumers(it.usedBy); + return `credential ${it.sourceId} unresolved (${it.kind}), used by ${usedBy}`; } if (it.type === 'variable-unresolved') { - const usedBy = Array.isArray(it.usedByWorkflows) ? it.usedByWorkflows.join(', ') : ''; - return `variable "${it.name}" unresolved, used by workflow(s) ${usedBy}`; + const usedBy = formatConsumers(it.usedBy); + return `variable "${it.name}" unresolved, used by ${usedBy}`; } if (it.type === 'variable-conflict') { - const usedBy = Array.isArray(it.usedByWorkflows) ? it.usedByWorkflows.join(', ') : ''; + const usedBy = formatConsumers(it.usedBy); const scope = it.projectId ? `project ${it.projectId}` : 'the global scope'; - return `variable "${it.name}" in ${scope} holds a different value, used by workflow(s) ${usedBy}`; + return `variable "${it.name}" in ${scope} holds a different value, used by ${usedBy}`; } if (it.type === 'variable-limit-exceeded') { - const usedBy = Array.isArray(it.usedByWorkflows) ? it.usedByWorkflows.join(', ') : ''; + const usedBy = formatConsumers(it.usedBy); const names = Array.isArray(it.names) ? it.names.join(', ') : ''; - return `variable limit reached: ${it.requested} new variable(s) (${names}) with ${it.remaining} of ${it.limit} remaining, used by workflow(s) ${usedBy}`; + return `variable limit reached: ${it.requested} new variable(s) (${names}) with ${it.remaining} of ${it.limit} remaining, used by ${usedBy}`; } if (it.type === 'missing-node-type') { - const usedBy = Array.isArray(it.usedByWorkflows) ? it.usedByWorkflows.join(', ') : ''; - return `node type ${it.nodeType} @ v${it.typeVersion} missing on this instance, used by workflow(s) ${usedBy}`; + const usedBy = formatConsumers(it.usedBy); + return `node type ${it.nodeType} @ v${it.typeVersion} missing on this instance, used by ${usedBy}`; } if (it.type === 'tag-unresolved') { - const usedBy = Array.isArray(it.usedByWorkflows) ? it.usedByWorkflows.join(', ') : ''; + const usedBy = formatConsumers(it.usedBy); if (it.kind === 'permission-denied') { - return `tag import requires the ${it.missingScope} scope, needed by workflow(s) ${usedBy}`; + return `tag import requires the ${it.missingScope} scope, needed by ${usedBy}`; } - return `tag "${it.name}" (${it.sourceId}) unresolved (${it.kind}), used by workflow(s) ${usedBy}`; + return `tag "${it.name}" (${it.sourceId}) unresolved (${it.kind}), used by ${usedBy}`; } if (it.type === 'data-table-unresolved') { - const usedBy = Array.isArray(it.usedByWorkflows) ? it.usedByWorkflows.join(', ') : ''; + const usedBy = formatConsumers(it.usedBy); if (it.kind === 'permission-denied') { - return `data table import requires the ${it.missingScope} scope, needed by workflow(s) ${usedBy}`; + return `data table import requires the ${it.missingScope} scope, needed by ${usedBy}`; } if (it.kind === 'schema-incompatible') { const reasons = [ @@ -167,15 +177,15 @@ function formatIssue(issue: unknown): string { const changes = it.overwriteChanges?.length ? `\n --data-table-schema-conflict-policy=overwrite would: ${it.overwriteChanges.map((change) => (change.destructive ? `${describeSchemaChange(change)} (data lost)` : describeSchemaChange(change))).join(', ')}` : ''; - return `data table "${it.name}" (${it.sourceId}) does not match the package schema (${reasons.join('; ')}), used by workflow(s) ${usedBy}${changes}`; + return `data table "${it.name}" (${it.sourceId}) does not match the package schema (${reasons.join('; ')}), used by ${usedBy}${changes}`; } if (it.kind === 'name-conflict') { const action = it.currentName ? `"${it.currentName}" (${it.sourceId}) cannot be renamed to "${it.name}"` : `"${it.name}" (${it.sourceId}) cannot be created`; - return `data table ${action}: the name is also used by table ${it.conflictingTableId}, used by workflow(s) ${usedBy}`; + return `data table ${action}: the name is also used by table ${it.conflictingTableId}, used by ${usedBy}`; } - return `data table "${it.name}" (${it.sourceId}) unresolved (${it.kind}), used by workflow(s) ${usedBy}`; + return `data table "${it.name}" (${it.sourceId}) unresolved (${it.kind}), used by ${usedBy}`; } return JSON.stringify(issue); } diff --git a/packages/cli/BREAKING-CHANGES.md b/packages/cli/BREAKING-CHANGES.md index 6a52444482a..d36bcfcf861 100644 --- a/packages/cli/BREAKING-CHANGES.md +++ b/packages/cli/BREAKING-CHANGES.md @@ -2,6 +2,17 @@ This list shows all the versions which include breaking changes and how to upgrade. +# Unreleased + +## n8n Packages (preview) + +Package manifest requirements now use `usedBy: [{ "kind": "workflow", "id": "workflow-id" }]`. +The `usedByWorkflows` field is no longer supported. +Re-export packages after upgrading. +Regenerate existing Promotions snapshots. +Package import errors also use `usedBy` for resource consumers. +Update API clients to read each consumer's `kind` and `id`. + # 2.0.0 ### What changed? diff --git a/packages/cli/src/errors/__tests__/rest-error-response.test.ts b/packages/cli/src/errors/__tests__/rest-error-response.test.ts index 8c295868734..210ae3f1c80 100644 --- a/packages/cli/src/errors/__tests__/rest-error-response.test.ts +++ b/packages/cli/src/errors/__tests__/rest-error-response.test.ts @@ -3,6 +3,7 @@ import { serializeInternalRestError, serializePublicApiError } from '@n8n/backen import { LicenseEulaRequiredError } from '@/errors/response-errors/license-eula-required.error'; import { WorkflowPublishBlockedError } from '@/errors/response-errors/workflow-publish-blocked.error'; import { toImportBlockedError } from '@/modules/n8n-packages/engine/import-blocked.error'; +import type { BlockingIssue } from '@/modules/n8n-packages/n8n-packages.types'; import { PromotionsWorkflowsMovedCrossProjectError } from '@/modules/promotions.ee/promotions-selective-push.error'; import { PolicyViolationError } from '@/policy/policy-violation.error'; @@ -76,12 +77,12 @@ describe('rest-error-response', () => { }); it('serializePublicApiError: 422 with issues when only credentials are unresolved', () => { - const issues = [ + const issues: BlockingIssue[] = [ { type: 'credential-unresolved' as const, kind: 'not_found' as const, sourceId: 'cred-1', - usedByWorkflows: ['wf-1'], + usedBy: [{ kind: 'workflow', id: 'wf-1' }], }, ]; const descriptor = classifyRestError(toImportBlockedError(issues)); @@ -92,7 +93,7 @@ describe('rest-error-response', () => { }); it('serializePublicApiError: 409 with issues when a workflow conflicts', () => { - const issues = [ + const issues: BlockingIssue[] = [ { type: 'workflow-conflict' as const, sourceWorkflowId: 'wf-1', @@ -103,7 +104,7 @@ describe('rest-error-response', () => { type: 'credential-unresolved' as const, kind: 'not_found' as const, sourceId: 'cred-1', - usedByWorkflows: ['wf-1'], + usedBy: [{ kind: 'workflow', id: 'wf-1' }], }, ]; const descriptor = classifyRestError(toImportBlockedError(issues)); diff --git a/packages/cli/src/modules/n8n-packages/__tests__/export-folder.integration.test.ts b/packages/cli/src/modules/n8n-packages/__tests__/export-folder.integration.test.ts index 5c2f96d3b44..c759ef3f991 100644 --- a/packages/cli/src/modules/n8n-packages/__tests__/export-folder.integration.test.ts +++ b/packages/cli/src/modules/n8n-packages/__tests__/export-folder.integration.test.ts @@ -246,7 +246,11 @@ describe('folder package export', () => { expect(childEntry?.target).toBe(`workflows/external-child-${externalChild.id}`); expect(entries.find((e) => e.name === `${childEntry!.target}/workflow.json`)).toBeDefined(); expect(manifest.requirements?.workflows).toEqual([ - { id: externalChild.id, name: externalChild.name, usedByWorkflows: [parent.id] }, + { + id: externalChild.id, + name: externalChild.name, + usedBy: [{ kind: 'workflow', id: parent.id }], + }, ]); }); @@ -470,12 +474,16 @@ describe('folder package export — with contained workflows', () => { id: credential.id, name: credential.name, type: 'httpHeaderAuth', - usedByWorkflows: [workflow.id], + usedBy: [{ kind: 'workflow', id: workflow.id }], }, ], // Folder packages fold node type usage too (shared WorkflowExporter path). nodeTypes: [ - { type: 'n8n-nodes-base.httpRequest', typeVersion: 1, usedByWorkflows: [workflow.id] }, + { + type: 'n8n-nodes-base.httpRequest', + typeVersion: 1, + usedBy: [{ kind: 'workflow', id: workflow.id }], + }, ], }); expect( @@ -512,7 +520,7 @@ describe('folder package export — with contained workflows', () => { ]); expect(manifest.requirements).toEqual({ nodeTypes: expect.any(Array), - variables: [{ name: 'API_URL', usedByWorkflows: [workflow.id] }], + variables: [{ name: 'API_URL', usedBy: [{ kind: 'workflow', id: workflow.id }] }], }); expect( entries.find((e) => e.name === `${manifest.variables![0].target}/variable.json`), diff --git a/packages/cli/src/modules/n8n-packages/__tests__/export-project.integration.test.ts b/packages/cli/src/modules/n8n-packages/__tests__/export-project.integration.test.ts index 85613f7baf4..e6c5abcba6f 100644 --- a/packages/cli/src/modules/n8n-packages/__tests__/export-project.integration.test.ts +++ b/packages/cli/src/modules/n8n-packages/__tests__/export-project.integration.test.ts @@ -285,7 +285,7 @@ describe('project package export', () => { expect(manifest.workflows!.map(({ id }) => id).sort()).toEqual([parent.id, child.id].sort()); expect(manifest.requirements?.workflows).toEqual([ - { id: child.id, name: child.name, usedByWorkflows: [parent.id] }, + { id: child.id, name: child.name, usedBy: [{ kind: 'workflow', id: parent.id }] }, ]); }); @@ -431,14 +431,18 @@ describe('project package export — with folders / workflows', () => { ).toBeDefined(); expect(manifest.requirements).toEqual({ nodeTypes: [ - { type: 'n8n-nodes-base.httpRequest', typeVersion: 1, usedByWorkflows: [workflow.id] }, + { + type: 'n8n-nodes-base.httpRequest', + typeVersion: 1, + usedBy: [{ kind: 'workflow', id: workflow.id }], + }, ], credentials: [ { id: credential.id, name: credential.name, type: 'httpHeaderAuth', - usedByWorkflows: [workflow.id], + usedBy: [{ kind: 'workflow', id: workflow.id }], }, ], }); @@ -468,7 +472,7 @@ describe('project package export — with folders / workflows', () => { expect(entries.find((e) => e.name === `${variableEntry.target}/variable.json`)).toBeDefined(); expect(manifest.requirements).toEqual({ nodeTypes: expect.any(Array), - variables: [{ name: 'API_URL', usedByWorkflows: [workflow.id] }], + variables: [{ name: 'API_URL', usedBy: [{ kind: 'workflow', id: workflow.id }] }], }); const events = emitSpy.mock.calls.filter(([name]) => name === 'n8n-package-exported'); @@ -525,10 +529,14 @@ describe('project package export — with folders / workflows', () => { expect(manifest.credentials![0].target).toMatch( new RegExp(`^${projectAEntry.target}/credentials/[^/]+$`), ); - const usedByWorkflows = (manifest.requirements?.credentials ?? []).flatMap( - (c) => c.usedByWorkflows, + const usedBy = (manifest.requirements?.credentials ?? []).flatMap((c) => c.usedBy); + expect(usedBy).toHaveLength(2); + expect(usedBy).toEqual( + expect.arrayContaining([ + { kind: 'workflow', id: workflowA.id }, + { kind: 'workflow', id: workflowB.id }, + ]), ); - expect(usedByWorkflows.sort()).toEqual([workflowA.id, workflowB.id].sort()); }); it('exports a root workflow shared across two projects only under its owner', async () => { @@ -729,7 +737,10 @@ describe('project package export — workflow selection', () => { expect(manifest.folders!.map(({ id }) => id).sort()).toEqual([inProgress.id, nested.id].sort()); expect(manifest.credentials!.map(({ id }) => id)).toEqual([selectedCredential.id]); expect(manifest.requirements?.credentials).toEqual([ - expect.objectContaining({ id: selectedCredential.id, usedByWorkflows: [selected.id] }), + expect.objectContaining({ + id: selectedCredential.id, + usedBy: [{ kind: 'workflow', id: selected.id }], + }), ]); const projectTarget = manifest.projects![0].target; @@ -773,7 +784,7 @@ describe('project package export — workflow selection', () => { expect(manifest.workflows!.map(({ id }) => id)).toEqual([parent.id]); expect(manifest.requirements?.workflows).toEqual([ - expect.objectContaining({ id: child.id, usedByWorkflows: [parent.id] }), + expect.objectContaining({ id: child.id, usedBy: [{ kind: 'workflow', id: parent.id }] }), ]); }); diff --git a/packages/cli/src/modules/n8n-packages/__tests__/export-workflow-with-credentials.integration.test.ts b/packages/cli/src/modules/n8n-packages/__tests__/export-workflow-with-credentials.integration.test.ts index d814cd58c90..6361e297110 100644 --- a/packages/cli/src/modules/n8n-packages/__tests__/export-workflow-with-credentials.integration.test.ts +++ b/packages/cli/src/modules/n8n-packages/__tests__/export-workflow-with-credentials.integration.test.ts @@ -82,7 +82,7 @@ describe('workflow package export — with credentials', () => { id: credential.id, name: credential.name, type: 'httpHeaderAuth', - usedByWorkflows: [workflow.id], + usedBy: [{ kind: 'workflow', id: workflow.id }], }, ], }); @@ -198,8 +198,12 @@ describe('workflow package export — with credentials', () => { expect(manifest.credentials![0].id).toBe(credential.id); expect(manifest.requirements?.credentials).toHaveLength(1); - expect(manifest.requirements!.credentials![0].usedByWorkflows.sort()).toEqual( - [wfA.id, wfB.id].sort(), + expect(manifest.requirements!.credentials![0].usedBy).toHaveLength(2); + expect(manifest.requirements!.credentials![0].usedBy).toEqual( + expect.arrayContaining([ + { kind: 'workflow', id: wfA.id }, + { kind: 'workflow', id: wfB.id }, + ]), ); const credentialFiles = entries.filter((e) => e.name.endsWith('/credential.json')); @@ -240,7 +244,7 @@ describe('workflow package export — with credentials', () => { id: credential.id, name: credential.name, type: credential.type, - usedByWorkflows: [child.id], + usedBy: [{ kind: 'workflow', id: child.id }], }, ]); expect(manifest.requirements).not.toHaveProperty('subWorkflows'); @@ -269,7 +273,7 @@ describe('workflow package export — with credentials', () => { id: 'does-not-exist', name: 'Stale cred name', type: 'httpHeaderAuth', - usedByWorkflows: [workflow.id], + usedBy: [{ kind: 'workflow', id: workflow.id }], }, ], }); @@ -316,7 +320,7 @@ describe('workflow package export — with credentials', () => { id: credential.id, name: credential.name, type: 'httpHeaderAuth', - usedByWorkflows: [workflow.id], + usedBy: [{ kind: 'workflow', id: workflow.id }], }, ], }); diff --git a/packages/cli/src/modules/n8n-packages/__tests__/export-workflow-with-data-tables.integration.test.ts b/packages/cli/src/modules/n8n-packages/__tests__/export-workflow-with-data-tables.integration.test.ts index 9e647450095..f00cf2a9c0f 100644 --- a/packages/cli/src/modules/n8n-packages/__tests__/export-workflow-with-data-tables.integration.test.ts +++ b/packages/cli/src/modules/n8n-packages/__tests__/export-workflow-with-data-tables.integration.test.ts @@ -80,7 +80,7 @@ describe('workflow package export — with data tables', () => { { id: dataTable.id, name: 'Customers', - usedByWorkflows: [workflow.id], + usedBy: [{ kind: 'workflow', id: workflow.id }], }, ], }); @@ -185,7 +185,7 @@ describe('workflow package export — with data tables', () => { { id: dataTable.id, name: 'Customers', - usedByWorkflows: [workflow.id], + usedBy: [{ kind: 'workflow', id: workflow.id }], }, ]); expect(entries.map((e) => e.name)).toContain( @@ -221,7 +221,7 @@ describe('workflow package export — with data tables', () => { { id: dataTable.id, name: 'Customers', - usedByWorkflows: [child.id], + usedBy: [{ kind: 'workflow', id: child.id }], }, ]); expect(entries.map((e) => e.name)).toContain( @@ -252,8 +252,12 @@ describe('workflow package export — with data tables', () => { const { manifest, entries } = await readExport(stream); expect(manifest.requirements?.dataTables).toHaveLength(1); - expect(manifest.requirements!.dataTables![0].usedByWorkflows.sort()).toEqual( - [wfA.id, wfB.id].sort(), + expect(manifest.requirements!.dataTables![0].usedBy).toHaveLength(2); + expect(manifest.requirements!.dataTables![0].usedBy).toEqual( + expect.arrayContaining([ + { kind: 'workflow', id: wfA.id }, + { kind: 'workflow', id: wfB.id }, + ]), ); expect(entries.filter((e) => e.name.endsWith('/data-table.json'))).toHaveLength(1); }); @@ -347,7 +351,7 @@ describe('workflow package export — with data tables', () => { { id: dataTable.id, name: 'Customers', - usedByWorkflows: [workflow.id], + usedBy: [{ kind: 'workflow', id: workflow.id }], }, ]); expect(entries.map((e) => e.name)).toContain( @@ -379,7 +383,7 @@ describe('workflow package export — with data tables', () => { { id: dataTable.id, name: 'Customers', - usedByWorkflows: [workflow.id], + usedBy: [{ kind: 'workflow', id: workflow.id }], }, ]); // Folder packages keep data tables top-level, unlike project packages. @@ -412,7 +416,7 @@ describe('workflow package export — with data tables', () => { { id: dataTable.id, name: 'Customers', - usedByWorkflows: [workflow.id], + usedBy: [{ kind: 'workflow', id: workflow.id }], }, ]); }); @@ -438,7 +442,7 @@ describe('workflow package export — with data tables', () => { { id: dataTable.id, name: 'Customers', - usedByWorkflows: [workflow.id], + usedBy: [{ kind: 'workflow', id: workflow.id }], }, ]); }); @@ -463,7 +467,7 @@ describe('workflow package export — with data tables', () => { { id: dataTable.id, name: 'Customers', - usedByWorkflows: [workflow.id], + usedBy: [{ kind: 'workflow', id: workflow.id }], }, ]); }); diff --git a/packages/cli/src/modules/n8n-packages/__tests__/export-workflow-with-tags.integration.test.ts b/packages/cli/src/modules/n8n-packages/__tests__/export-workflow-with-tags.integration.test.ts index 68b14d51094..4637f153c78 100644 --- a/packages/cli/src/modules/n8n-packages/__tests__/export-workflow-with-tags.integration.test.ts +++ b/packages/cli/src/modules/n8n-packages/__tests__/export-workflow-with-tags.integration.test.ts @@ -77,8 +77,8 @@ describe('workflow package export — with tags', () => { { id: beta.id, name: 'beta', target: `tags/beta-${beta.id}` }, ]); expect(manifest.requirements!.tags).toEqual([ - { id: alpha.id, name: 'alpha', usedByWorkflows: [workflow.id] }, - { id: beta.id, name: 'beta', usedByWorkflows: [workflow.id] }, + { id: alpha.id, name: 'alpha', usedBy: [{ kind: 'workflow', id: workflow.id }] }, + { id: beta.id, name: 'beta', usedBy: [{ kind: 'workflow', id: workflow.id }] }, ]); const serialized = workflowJson(entries, manifest.workflows![0].target); @@ -117,7 +117,14 @@ describe('workflow package export — with tags', () => { ]); expect(tagFiles(entries)).toHaveLength(1); expect(manifest.requirements!.tags).toEqual([ - { id: tag.id, name: 'shared', usedByWorkflows: [wfA.id, wfB.id] }, + { + id: tag.id, + name: 'shared', + usedBy: [ + { kind: 'workflow', id: wfA.id }, + { kind: 'workflow', id: wfB.id }, + ], + }, ]); for (const entry of manifest.workflows!) { @@ -198,7 +205,7 @@ describe('workflow package export — with tags', () => { expect(manifest.tags).toEqual([{ id: tag.id, name: 'prod', target: `tags/prod-${tag.id}` }]); expect(manifest.requirements!.tags).toEqual([ - { id: tag.id, name: 'prod', usedByWorkflows: [sub.id] }, + { id: tag.id, name: 'prod', usedBy: [{ kind: 'workflow', id: sub.id }] }, ]); const subEntry = manifest.workflows!.find((entry) => entry.id === sub.id)!; diff --git a/packages/cli/src/modules/n8n-packages/__tests__/export-workflow-with-variables.integration.test.ts b/packages/cli/src/modules/n8n-packages/__tests__/export-workflow-with-variables.integration.test.ts index 22884c640ea..96e8597cd47 100644 --- a/packages/cli/src/modules/n8n-packages/__tests__/export-workflow-with-variables.integration.test.ts +++ b/packages/cli/src/modules/n8n-packages/__tests__/export-workflow-with-variables.integration.test.ts @@ -73,7 +73,7 @@ describe('workflow package export — with variables', () => { ]); expect(manifest.requirements).toEqual({ nodeTypes: expect.any(Array), - variables: [{ name: 'API_URL', usedByWorkflows: [workflow.id] }], + variables: [{ name: 'API_URL', usedBy: [{ kind: 'workflow', id: workflow.id }] }], }); const files = variableFiles(entries); @@ -117,7 +117,7 @@ describe('workflow package export — with variables', () => { ]); expect(manifest.requirements).toEqual({ nodeTypes: expect.any(Array), - variables: [{ name: 'API_URL', usedByWorkflows: [workflow.id] }], + variables: [{ name: 'API_URL', usedBy: [{ kind: 'workflow', id: workflow.id }] }], }); const files = variableFiles(entries); @@ -150,7 +150,7 @@ describe('workflow package export — with variables', () => { ]); expect(manifest.requirements).toEqual({ nodeTypes: expect.any(Array), - variables: [{ name: 'API_URL', usedByWorkflows: [workflow.id] }], + variables: [{ name: 'API_URL', usedBy: [{ kind: 'workflow', id: workflow.id }] }], }); expect(variableFiles(entries)).toHaveLength(1); }); @@ -183,7 +183,7 @@ describe('workflow package export — with variables', () => { expect(manifest).not.toHaveProperty('variables'); expect(manifest.requirements).toEqual({ nodeTypes: expect.any(Array), - variables: [{ name: 'API_URL', usedByWorkflows: [workflow.id] }], + variables: [{ name: 'API_URL', usedBy: [{ kind: 'workflow', id: workflow.id }] }], }); expect(variableFiles(entries)).toEqual([]); expect(JSON.stringify(manifest)).not.toContain('example.com'); @@ -216,7 +216,7 @@ describe('workflow package export — with variables', () => { }, ]); expect(manifest.requirements!.variables).toEqual([ - { name: 'API_URL', usedByWorkflows: [workflow.id] }, + { name: 'API_URL', usedBy: [{ kind: 'workflow', id: workflow.id }] }, ]); const files = variableFiles(entries); @@ -251,8 +251,12 @@ describe('workflow package export — with variables', () => { expect(manifest.variables).toHaveLength(1); expect(manifest.requirements!.variables).toHaveLength(1); - expect(manifest.requirements!.variables![0].usedByWorkflows.sort()).toEqual( - [wfA.id, wfB.id].sort(), + expect(manifest.requirements!.variables![0].usedBy).toHaveLength(2); + expect(manifest.requirements!.variables![0].usedBy).toEqual( + expect.arrayContaining([ + { kind: 'workflow', id: wfA.id }, + { kind: 'workflow', id: wfB.id }, + ]), ); expect(variableFiles(entries)).toHaveLength(1); }); @@ -279,7 +283,7 @@ describe('workflow package export — with variables', () => { ]); expect(manifest.requirements).toEqual({ nodeTypes: expect.any(Array), - variables: [{ name: 'legacy-key', usedByWorkflows: [workflow.id] }], + variables: [{ name: 'legacy-key', usedBy: [{ kind: 'workflow', id: workflow.id }] }], }); expect(variableFiles(entries)).toHaveLength(1); }); @@ -299,7 +303,7 @@ describe('workflow package export — with variables', () => { expect(manifest).not.toHaveProperty('variables'); expect(manifest.requirements).toEqual({ nodeTypes: expect.any(Array), - variables: [{ name: 'DOES_NOT_EXIST', usedByWorkflows: [workflow.id] }], + variables: [{ name: 'DOES_NOT_EXIST', usedBy: [{ kind: 'workflow', id: workflow.id }] }], }); expect(variableFiles(entries)).toEqual([]); }); @@ -326,7 +330,7 @@ describe('workflow package export — with variables', () => { expect(manifest).not.toHaveProperty('variables'); expect(manifest.requirements).toEqual({ nodeTypes: expect.any(Array), - variables: [{ name: 'API_URL', usedByWorkflows: [workflow.id] }], + variables: [{ name: 'API_URL', usedBy: [{ kind: 'workflow', id: workflow.id }] }], }); expect(variableFiles(entries)).toEqual([]); expect(JSON.stringify(manifest)).not.toContain('example.com'); @@ -441,7 +445,7 @@ describe('workflow package export — with variables', () => { expect(manifest).not.toHaveProperty('variables'); expect(manifest.requirements).toEqual({ nodeTypes: expect.any(Array), - variables: [{ name: 'PRIVATE_VAR', usedByWorkflows: [workflow.id] }], + variables: [{ name: 'PRIVATE_VAR', usedBy: [{ kind: 'workflow', id: workflow.id }] }], }); expect(variableFiles(entries)).toEqual([]); }); @@ -506,7 +510,7 @@ describe('workflow package export — with variables', () => { expect(manifest.requirements).toEqual({ nodeTypes: expect.any(Array), - variables: [{ name: 'API_URL', usedByWorkflows: [workflow.id] }], + variables: [{ name: 'API_URL', usedBy: [{ kind: 'workflow', id: workflow.id }] }], }); // Stubs still travel (name/type only) — the scope gate is value-only. const files = variableFiles(entries); diff --git a/packages/cli/src/modules/n8n-packages/__tests__/export-workflow.integration.test.ts b/packages/cli/src/modules/n8n-packages/__tests__/export-workflow.integration.test.ts index 41951115e03..ffac6baf1cd 100644 --- a/packages/cli/src/modules/n8n-packages/__tests__/export-workflow.integration.test.ts +++ b/packages/cli/src/modules/n8n-packages/__tests__/export-workflow.integration.test.ts @@ -253,9 +253,16 @@ describe('workflow package export', () => { { type: 'n8n-nodes-base.manualTrigger', typeVersion: 1, - usedByWorkflows: [wfA.id, wfB.id], + usedBy: [ + { kind: 'workflow', id: wfA.id }, + { kind: 'workflow', id: wfB.id }, + ], + }, + { + type: 'n8n-nodes-base.set', + typeVersion: 3.4, + usedBy: [{ kind: 'workflow', id: wfB.id }], }, - { type: 'n8n-nodes-base.set', typeVersion: 3.4, usedByWorkflows: [wfB.id] }, ], }); }); @@ -328,8 +335,16 @@ describe('workflow package export', () => { expect(manifest.requirements?.workflows).toHaveLength(2); expect(manifest.requirements?.workflows).toEqual( expect.arrayContaining([ - { id: workflowB.id, name: workflowB.name, usedByWorkflows: [workflowA.id] }, - { id: workflowC.id, name: workflowC.name, usedByWorkflows: [workflowB.id] }, + { + id: workflowB.id, + name: workflowB.name, + usedBy: [{ kind: 'workflow', id: workflowA.id }], + }, + { + id: workflowC.id, + name: workflowC.name, + usedBy: [{ kind: 'workflow', id: workflowB.id }], + }, ]), ); }); @@ -374,12 +389,14 @@ describe('workflow package export', () => { const { manifest } = await readExport(stream); expect(manifest.requirements).toEqual({ - workflows: [{ id: child.id, name: child.name, usedByWorkflows: [parent.id] }], + workflows: [ + { id: child.id, name: child.name, usedBy: [{ kind: 'workflow', id: parent.id }] }, + ], nodeTypes: [ { type: 'n8n-nodes-base.executeWorkflow', typeVersion: 1, - usedByWorkflows: [parent.id], + usedBy: [{ kind: 'workflow', id: parent.id }], }, ], }); @@ -426,12 +443,14 @@ describe('workflow package export', () => { const { manifest } = await readExport(stream); expect(manifest.requirements).toEqual({ - workflows: [{ id: child.id, name: child.name, usedByWorkflows: [parent.id] }], + workflows: [ + { id: child.id, name: child.name, usedBy: [{ kind: 'workflow', id: parent.id }] }, + ], nodeTypes: [ { type: '@n8n/n8n-nodes-langchain.toolWorkflow', typeVersion: 2.2, - usedByWorkflows: [parent.id], + usedBy: [{ kind: 'workflow', id: parent.id }], }, ], }); @@ -464,7 +483,13 @@ describe('workflow package export', () => { const { manifest } = await readExport(stream); expect(manifest.requirements).toEqual({ - workflows: [{ id: errorHandler.id, name: errorHandler.name, usedByWorkflows: [parent.id] }], + workflows: [ + { + id: errorHandler.id, + name: errorHandler.name, + usedBy: [{ kind: 'workflow', id: parent.id }], + }, + ], }); }); @@ -491,17 +516,21 @@ describe('workflow package export', () => { workflowIds: [parentA.id, parentB.id, child.id], }); const { manifest } = await readExport(stream); - const expectedUsedByWorkflows = manifest + const expectedUsedBy = manifest .workflows!.map(({ id }) => id) - .filter((id) => id === parentA.id || id === parentB.id); + .filter((id) => id === parentA.id || id === parentB.id) + .map((id) => ({ kind: 'workflow', id })); expect(manifest.requirements).toEqual({ - workflows: [{ id: child.id, name: child.name, usedByWorkflows: expectedUsedByWorkflows }], + workflows: [{ id: child.id, name: child.name, usedBy: expectedUsedBy }], nodeTypes: [ { type: 'n8n-nodes-base.executeWorkflow', typeVersion: 1, - usedByWorkflows: [parentA.id, parentB.id], + usedBy: [ + { kind: 'workflow', id: parentA.id }, + { kind: 'workflow', id: parentB.id }, + ], }, ], }); @@ -568,12 +597,20 @@ describe('workflow package export', () => { id: credential.id, name: credential.name, type: 'httpHeaderAuth', - usedByWorkflows: [workflowC.id], + usedBy: [{ kind: 'workflow', id: workflowC.id }], }, ]); expect(manifest.requirements?.workflows).toEqual([ - { id: workflowB.id, name: workflowB.name, usedByWorkflows: [workflowA.id] }, - { id: workflowC.id, name: workflowC.name, usedByWorkflows: [workflowB.id] }, + { + id: workflowB.id, + name: workflowB.name, + usedBy: [{ kind: 'workflow', id: workflowA.id }], + }, + { + id: workflowC.id, + name: workflowC.name, + usedBy: [{ kind: 'workflow', id: workflowB.id }], + }, ]); }); @@ -677,8 +714,16 @@ describe('workflow package export', () => { [workflowA.id, workflowB.id].sort(), ); expect(manifest.requirements?.workflows).toEqual([ - { id: workflowB.id, name: workflowB.name, usedByWorkflows: [workflowA.id] }, - { id: workflowA.id, name: workflowA.name, usedByWorkflows: [workflowB.id] }, + { + id: workflowB.id, + name: workflowB.name, + usedBy: [{ kind: 'workflow', id: workflowA.id }], + }, + { + id: workflowA.id, + name: workflowA.name, + usedBy: [{ kind: 'workflow', id: workflowB.id }], + }, ]); }); }); @@ -709,7 +754,7 @@ describe('workflow package export', () => { { id: parent.id, name: 'Parent', target: expect.any(String) }, ]); expect(manifest.requirements?.workflows).toEqual([ - { id: child.id, name: child.name, usedByWorkflows: [parent.id] }, + { id: child.id, name: child.name, usedBy: [{ kind: 'workflow', id: parent.id }] }, ]); // Only the parent's workflow.json travels; the referenced child stays out. expect(entries.filter((e) => e.name.endsWith('/workflow.json'))).toHaveLength(1); @@ -739,7 +784,7 @@ describe('workflow package export', () => { expect(manifest.requirements?.credentials).toBeUndefined(); expect(entries.some((e) => e.name.includes('credential'))).toBe(false); expect(manifest.requirements?.workflows).toEqual([ - { id: child.id, name: child.name, usedByWorkflows: [parent.id] }, + { id: child.id, name: child.name, usedBy: [{ kind: 'workflow', id: parent.id }] }, ]); }); @@ -784,11 +829,11 @@ describe('workflow package export', () => { id: credential.id, name: credential.name, type: credential.type, - usedByWorkflows: [parent.id], + usedBy: [{ kind: 'workflow', id: parent.id }], }, ]); expect(manifest.requirements?.workflows).toEqual([ - { id: child.id, name: child.name, usedByWorkflows: [parent.id] }, + { id: child.id, name: child.name, usedBy: [{ kind: 'workflow', id: parent.id }] }, ]); }); @@ -810,7 +855,7 @@ describe('workflow package export', () => { { id: cheddar.id, name: 'CHEDDAR', target: expect.any(String) }, ]); expect(manifest.requirements?.workflows).toEqual([ - { id: brie.id, name: 'BRIE', usedByWorkflows: [cheddar.id] }, + { id: brie.id, name: 'BRIE', usedBy: [{ kind: 'workflow', id: cheddar.id }] }, ]); expect(entries.filter((e) => e.name.endsWith('/workflow.json'))).toHaveLength(1); }); @@ -836,7 +881,11 @@ describe('workflow package export', () => { const { manifest } = await exportReferenceOnly(owner, [workflowA.id]); expect(manifest.requirements?.workflows).toEqual([ - { id: workflowB.id, name: workflowB.name, usedByWorkflows: [workflowA.id] }, + { + id: workflowB.id, + name: workflowB.name, + usedBy: [{ kind: 'workflow', id: workflowA.id }], + }, ]); }); @@ -860,7 +909,7 @@ describe('workflow package export', () => { const { manifest } = await exportReferenceOnly(member, [parent.id]); expect(manifest.requirements?.workflows).toEqual([ - { id: foreignWorkflow.id, usedByWorkflows: [parent.id] }, + { id: foreignWorkflow.id, usedBy: [{ kind: 'workflow', id: parent.id }] }, ]); expect(manifest.requirements?.workflows?.[0]).not.toHaveProperty('name'); }); @@ -880,7 +929,7 @@ describe('workflow package export', () => { expect(manifest.workflows!.map(({ id }) => id).sort()).toEqual([parent.id, child.id].sort()); expect(entries.filter((e) => e.name.endsWith('/workflow.json'))).toHaveLength(2); expect(manifest.requirements?.workflows).toEqual([ - { id: child.id, name: child.name, usedByWorkflows: [parent.id] }, + { id: child.id, name: child.name, usedBy: [{ kind: 'workflow', id: parent.id }] }, ]); }); @@ -903,7 +952,11 @@ describe('workflow package export', () => { { id: parent.id, name: 'Parent', target: expect.any(String) }, ]); expect(manifest.requirements?.workflows).toEqual([ - { id: errorHandler.id, name: errorHandler.name, usedByWorkflows: [parent.id] }, + { + id: errorHandler.id, + name: errorHandler.name, + usedBy: [{ kind: 'workflow', id: parent.id }], + }, ]); }); }); @@ -1113,7 +1166,7 @@ describe('workflow package export', () => { id: publishedCredential.id, name: publishedCredential.name, type: 'httpHeaderAuth', - usedByWorkflows: [workflow.id], + usedBy: [{ kind: 'workflow', id: workflow.id }], }, ]); }); diff --git a/packages/cli/src/modules/n8n-packages/__tests__/fixtures/package-fixtures.ts b/packages/cli/src/modules/n8n-packages/__tests__/fixtures/package-fixtures.ts index b64193a465f..10d2928db61 100644 --- a/packages/cli/src/modules/n8n-packages/__tests__/fixtures/package-fixtures.ts +++ b/packages/cli/src/modules/n8n-packages/__tests__/fixtures/package-fixtures.ts @@ -151,13 +151,15 @@ export function workflowRequirementsFromWorkflows( if (!referencedId) return; const existing = byId.get(referencedId); if (existing) { - if (!existing.usedByWorkflows.includes(workflowId)) existing.usedByWorkflows.push(workflowId); + if (!existing.usedBy.some(({ id }) => id === workflowId)) { + existing.usedBy.push({ kind: 'workflow', id: workflowId }); + } return; } byId.set(referencedId, { id: referencedId, name: nameById.get(referencedId) ?? referencedId, - usedByWorkflows: [workflowId], + usedBy: [{ kind: 'workflow', id: workflowId }], }); }; @@ -206,8 +208,8 @@ export function credentialRequirementsFromWorkflows( const existing = byId.get(details.id); if (existing) { - if (!existing.usedByWorkflows.includes(workflow.id)) { - existing.usedByWorkflows.push(workflow.id); + if (!existing.usedBy.some(({ id }) => id === workflow.id)) { + existing.usedBy.push({ kind: 'workflow', id: workflow.id }); } continue; } @@ -216,7 +218,7 @@ export function credentialRequirementsFromWorkflows( id: details.id, name: details.name, type: credentialType, - usedByWorkflows: [workflow.id], + usedBy: [{ kind: 'workflow', id: workflow.id }], }); } } @@ -321,7 +323,11 @@ export function dataTableRequirement( table: SerializedDataTable, usedByWorkflows: string[], ): PackageDataTableRequirement { - return { id: table.id, name: table.name, usedByWorkflows }; + return { + id: table.id, + name: table.name, + usedBy: usedByWorkflows.map((id) => ({ kind: 'workflow', id })), + }; } export function serializedFolder(overrides: Partial = {}): SerializedFolder { diff --git a/packages/cli/src/modules/n8n-packages/__tests__/import-data-tables.integration.test.ts b/packages/cli/src/modules/n8n-packages/__tests__/import-data-tables.integration.test.ts index bc1130e9f37..c119c7371c6 100644 --- a/packages/cli/src/modules/n8n-packages/__tests__/import-data-tables.integration.test.ts +++ b/packages/cli/src/modules/n8n-packages/__tests__/import-data-tables.integration.test.ts @@ -383,7 +383,7 @@ describe('workflow package import — with data tables', () => { overwriteChanges: [ { kind: 'add-column', column: 'signed_up_at', type: 'date', destructive: false }, ], - usedByWorkflows: ['wf-0'], + usedBy: [{ kind: 'workflow', id: 'wf-0' }], }); expect(await workflowRepository.count()).toBe(0); @@ -533,7 +533,7 @@ describe('workflow package import — with data tables', () => { kind: 'missing', sourceId: table.id, name: table.name, - usedByWorkflows: ['wf-0'], + usedBy: [{ kind: 'workflow', id: 'wf-0' }], }, ); @@ -551,6 +551,7 @@ describe('workflow package import — with data tables', () => { await expectBlocked(importPackage({ user: owner, projectId: project.id, packageBuffer }), { type: 'data-table-unresolved', kind: 'module-disabled', + usedBy: [{ kind: 'workflow', id: 'wf-0' }], }); } finally { isActive.mockRestore(); @@ -882,6 +883,7 @@ describe('workflow package import — with data tables', () => { type: 'data-table-unresolved', kind: 'permission-denied', missingScope: 'dataTable:update', + usedBy: [{ kind: 'workflow', id: 'wf-0' }], }); expect(await columnsOf(table.id)).toEqual([{ name: 'email', type: 'string', index: 0 }]); diff --git a/packages/cli/src/modules/n8n-packages/__tests__/import-package.integration.test.ts b/packages/cli/src/modules/n8n-packages/__tests__/import-package.integration.test.ts index 933e6f7d6b5..c7b57bd6045 100644 --- a/packages/cli/src/modules/n8n-packages/__tests__/import-package.integration.test.ts +++ b/packages/cli/src/modules/n8n-packages/__tests__/import-package.integration.test.ts @@ -2829,13 +2829,16 @@ describe('Package import missing node type mode', () => { type: 'missing-node-type', nodeType: 'n8n-nodes-community.chatBot', typeVersion: 1, - usedByWorkflows: ['wf-alpha', 'wf-beta'], + usedBy: [ + { kind: 'workflow', id: 'wf-alpha' }, + { kind: 'workflow', id: 'wf-beta' }, + ], }, { type: 'missing-node-type', nodeType: 'n8n-nodes-base.manualTrigger', typeVersion: 9, - usedByWorkflows: ['wf-gamma'], + usedBy: [{ kind: 'workflow', id: 'wf-gamma' }], }, ]), }, diff --git a/packages/cli/src/modules/n8n-packages/__tests__/import-projects.integration.test.ts b/packages/cli/src/modules/n8n-packages/__tests__/import-projects.integration.test.ts index 926be6d62ef..9ac388c6121 100644 --- a/packages/cli/src/modules/n8n-packages/__tests__/import-projects.integration.test.ts +++ b/packages/cli/src/modules/n8n-packages/__tests__/import-projects.integration.test.ts @@ -34,6 +34,7 @@ import { LicenseMocker } from '@test-integration/license'; import { initNodeTypes } from '@test-integration/utils'; import { N8nPackagesService } from '../n8n-packages.service'; +import type { PackageVariableRequirement } from '../spec/requirements.schema'; import { importPackageRequest } from './fixtures/import-request'; import type { ImportPackageRequest, @@ -374,7 +375,9 @@ describe('project shell import', () => { }, ], manifestExtras: { - requirements: { workflows: [{ id: sub.id, name: 'Sub', usedByWorkflows: ['WF'] }] }, + requirements: { + workflows: [{ id: sub.id, name: 'Sub', usedBy: [{ kind: 'workflow', id: 'WF' }] }], + }, }, }); @@ -694,7 +697,16 @@ describe('project shell import', () => { ], manifestExtras: { requirements: { - tags: [{ id: 'TAG1', name: 'shared', usedByWorkflows: ['WFA', 'WFB'] }], + tags: [ + { + id: 'TAG1', + name: 'shared', + usedBy: [ + { kind: 'workflow', id: 'WFA' }, + { kind: 'workflow', id: 'WFB' }, + ], + }, + ], }, }, }); @@ -738,7 +750,16 @@ describe('project shell import', () => { ], manifestExtras: { requirements: { - tags: [{ id: 'TAG1', name: 'prod', usedByWorkflows: ['WFA', 'WFB'] }], + tags: [ + { + id: 'TAG1', + name: 'prod', + usedBy: [ + { kind: 'workflow', id: 'WFA' }, + { kind: 'workflow', id: 'WFB' }, + ], + }, + ], }, }, }); @@ -790,8 +811,8 @@ describe('project shell import', () => { manifestExtras: { requirements: { tags: [ - { id: 'X', name: 'prod', usedByWorkflows: ['WFA'] }, - { id: 'H', name: 'staging', usedByWorkflows: ['WFB'] }, + { id: 'X', name: 'prod', usedBy: [{ kind: 'workflow', id: 'WFA' }] }, + { id: 'H', name: 'staging', usedBy: [{ kind: 'workflow', id: 'WFB' }] }, ], }, }, @@ -812,7 +833,7 @@ describe('project shell import', () => { sourceId: 'X', name: 'prod', existingTagId: 'H', - usedByWorkflows: ['WFA'], + usedBy: [{ kind: 'workflow', id: 'WFA' }], }), ], }, @@ -983,13 +1004,13 @@ describe('project shell import', () => { type: 'missing-node-type', nodeType: 'n8n-nodes-community.chatBot', typeVersion: 1, - usedByWorkflows: ['WFA'], + usedBy: [{ kind: 'workflow', id: 'WFA' }], }, { type: 'missing-node-type', nodeType: 'n8n-nodes-community.chatBot', typeVersion: 1, - usedByWorkflows: ['WFB'], + usedBy: [{ kind: 'workflow', id: 'WFB' }], }, ], }, @@ -1039,13 +1060,13 @@ describe('project shell import', () => { * Two team projects — brie (P1, workflow WFA) and stilton (P2, workflow WFB) — with an * optional bundled variable `catalog` and the variable `requirements` its workflows declare. * A catalog entry's `target` encodes scope: `variables/x` is global, `projects//variables/x` - * is that project's. Requirements are what the workflows reference (by `usedByWorkflows`). + * is that project's. Requirements use `usedBy` to identify their workflow consumers. */ const twoProjectPackage = async ( opts: { catalog?: Array<{ id: string; name: string; target: string }>; variables?: PackageVariableEntry[]; - requirements?: Array<{ name: string; usedByWorkflows: string[] }>; + requirements?: PackageVariableRequirement[]; } = {}, ) => await buildEntityPackageBuffer({ @@ -1073,7 +1094,15 @@ describe('project shell import', () => { /** Both projects' workflows reference `API_URL` as a name-only requirement (nothing bundled). */ const apiUrlPackage = async () => await twoProjectPackage({ - requirements: [{ name: 'API_URL', usedByWorkflows: ['WFA', 'WFB'] }], + requirements: [ + { + name: 'API_URL', + usedBy: [ + { kind: 'workflow', id: 'WFA' }, + { kind: 'workflow', id: 'WFB' }, + ], + }, + ], }); /** Single team project brie (P1, workflow WFA) whose workflow needs a top-level (global) variable. */ @@ -1091,7 +1120,7 @@ describe('project shell import', () => { manifestExtras: { variables: [{ id: 'v1', name: 'GLOBAL_VAR', target: 'variables/global_var' }], requirements: { - variables: [{ name: 'GLOBAL_VAR', usedByWorkflows: ['WFA'] }], + variables: [{ name: 'GLOBAL_VAR', usedBy: [{ kind: 'workflow', id: 'WFA' }] }], }, }, }); @@ -1101,8 +1130,20 @@ describe('project shell import', () => { await createVariable('GLOBAL_URL', 'https://global.example.com'); const packageBuffer = await twoProjectPackage({ requirements: [ - { name: 'GLOBAL_URL', usedByWorkflows: ['WFA', 'WFB'] }, - { name: 'ABSENT_VAR', usedByWorkflows: ['WFA', 'WFB'] }, + { + name: 'GLOBAL_URL', + usedBy: [ + { kind: 'workflow', id: 'WFA' }, + { kind: 'workflow', id: 'WFB' }, + ], + }, + { + name: 'ABSENT_VAR', + usedBy: [ + { kind: 'workflow', id: 'WFA' }, + { kind: 'workflow', id: 'WFB' }, + ], + }, ], }); @@ -1145,8 +1186,20 @@ describe('project shell import', () => { await createVariable('GLOBAL_URL', 'https://global.example.com'); const packageBuffer = await twoProjectPackage({ requirements: [ - { name: 'GLOBAL_URL', usedByWorkflows: ['WFA', 'WFB'] }, - { name: 'ABSENT_VAR', usedByWorkflows: ['WFA', 'WFB'] }, + { + name: 'GLOBAL_URL', + usedBy: [ + { kind: 'workflow', id: 'WFA' }, + { kind: 'workflow', id: 'WFB' }, + ], + }, + { + name: 'ABSENT_VAR', + usedBy: [ + { kind: 'workflow', id: 'WFA' }, + { kind: 'workflow', id: 'WFB' }, + ], + }, ], }); @@ -1158,8 +1211,16 @@ describe('project shell import', () => { expect((error as UnprocessableRequestError).message).toMatch(/Import blocked/); // One issue per consuming workflow of the unresolved name. expect((error as UnprocessableRequestError).meta?.issues).toEqual([ - { type: 'variable-unresolved', name: 'ABSENT_VAR', usedByWorkflows: ['WFA'] }, - { type: 'variable-unresolved', name: 'ABSENT_VAR', usedByWorkflows: ['WFB'] }, + { + type: 'variable-unresolved', + name: 'ABSENT_VAR', + usedBy: [{ kind: 'workflow', id: 'WFA' }], + }, + { + type: 'variable-unresolved', + name: 'ABSENT_VAR', + usedBy: [{ kind: 'workflow', id: 'WFB' }], + }, ]); expect(await findProject('P1')).toBeNull(); @@ -1190,7 +1251,11 @@ describe('project shell import', () => { expect(error).toBeInstanceOf(UnprocessableRequestError); // API_URL resolves in P1 (seeded above) but not P2, so only WFB blocks. expect((error as UnprocessableRequestError).meta?.issues).toEqual([ - { type: 'variable-unresolved', name: 'API_URL', usedByWorkflows: ['WFB'] }, + { + type: 'variable-unresolved', + name: 'API_URL', + usedBy: [{ kind: 'workflow', id: 'WFB' }], + }, ]); expect(await workflowStates()).toEqual(workflowsBefore); }); @@ -1209,8 +1274,8 @@ describe('project shell import', () => { { id: 'v2', name: 'GLOBAL_VAR', target: 'variables/global_var' }, ], requirements: [ - { name: 'PROJECT_VAR', usedByWorkflows: ['WFA'] }, - { name: 'GLOBAL_VAR', usedByWorkflows: ['WFB'] }, + { name: 'PROJECT_VAR', usedBy: [{ kind: 'workflow', id: 'WFA' }] }, + { name: 'GLOBAL_VAR', usedBy: [{ kind: 'workflow', id: 'WFB' }] }, ], }); @@ -1324,7 +1389,15 @@ describe('project shell import', () => { { id: 'v1', name: 'API_URL', target: 'projects/brie/variables/api_url' }, { id: 'v2', name: 'API_URL', target: 'projects/stilton/variables/api_url' }, ], - requirements: [{ name: 'API_URL', usedByWorkflows: ['WFA', 'WFB'] }], + requirements: [ + { + name: 'API_URL', + usedBy: [ + { kind: 'workflow', id: 'WFA' }, + { kind: 'workflow', id: 'WFB' }, + ], + }, + ], }); const result = await importProjects(owner, packageBuffer, undefined, { @@ -1373,7 +1446,7 @@ describe('project shell import', () => { manifestExtras: { variables: [{ id: 'v1', name: 'THEIRS', target: 'projects/brie/variables/theirs' }], requirements: { - variables: [{ name: 'THEIRS', usedByWorkflows: ['WFB'] }], + variables: [{ name: 'THEIRS', usedBy: [{ kind: 'workflow', id: 'WFB' }] }], }, }, }); @@ -1407,8 +1480,20 @@ describe('project shell import', () => { { id: 'v3', name: 'API_URL', target: 'projects/stilton/variables/api_url' }, ], requirements: [ - { name: 'SHARED_URL', usedByWorkflows: ['WFA', 'WFB'] }, - { name: 'API_URL', usedByWorkflows: ['WFA', 'WFB'] }, + { + name: 'SHARED_URL', + usedBy: [ + { kind: 'workflow', id: 'WFA' }, + { kind: 'workflow', id: 'WFB' }, + ], + }, + { + name: 'API_URL', + usedBy: [ + { kind: 'workflow', id: 'WFA' }, + { kind: 'workflow', id: 'WFB' }, + ], + }, ], }); @@ -1448,7 +1533,15 @@ describe('project shell import', () => { licenseMocker.setQuota('quota:maxVariables', 1); const packageBuffer = await twoProjectPackage({ catalog: [{ id: 'v1', name: 'SHARED_URL', target: 'variables/shared_url' }], - requirements: [{ name: 'SHARED_URL', usedByWorkflows: ['WFA', 'WFB'] }], + requirements: [ + { + name: 'SHARED_URL', + usedBy: [ + { kind: 'workflow', id: 'WFA' }, + { kind: 'workflow', id: 'WFB' }, + ], + }, + ], }); const result = await importProjects(owner, packageBuffer, undefined, { @@ -1549,8 +1642,8 @@ describe('project shell import', () => { { id: 'v2', name: 'VAR_B', target: 'projects/stilton/variables/var_b' }, ], requirements: [ - { name: 'VAR_A', usedByWorkflows: ['WFA'] }, - { name: 'VAR_B', usedByWorkflows: ['WFB'] }, + { name: 'VAR_A', usedBy: [{ kind: 'workflow', id: 'WFA' }] }, + { name: 'VAR_B', usedBy: [{ kind: 'workflow', id: 'WFB' }] }, ], }); @@ -1567,7 +1660,10 @@ describe('project shell import', () => { remaining: 1, requested: 2, names: ['VAR_A', 'VAR_B'], - usedByWorkflows: ['WFA', 'WFB'], + usedBy: [ + { kind: 'workflow', id: 'WFA' }, + { kind: 'workflow', id: 'WFB' }, + ], }, ]); expect(await findProject('P1')).toBeNull(); @@ -1595,7 +1691,15 @@ describe('project shell import', () => { variable: { name: 'API_URL', type: 'string', value: 'https://stilton.example.com' }, }, ], - requirements: [{ name: 'API_URL', usedByWorkflows: ['WFA', 'WFB'] }], + requirements: [ + { + name: 'API_URL', + usedBy: [ + { kind: 'workflow', id: 'WFA' }, + { kind: 'workflow', id: 'WFB' }, + ], + }, + ], }); const result = await importProjects(owner, packageBuffer, undefined, { @@ -1632,7 +1736,15 @@ describe('project shell import', () => { variable: { name: 'SHARED_URL', type: 'string', value: 'https://shared.example.com' }, }, ], - requirements: [{ name: 'SHARED_URL', usedByWorkflows: ['WFA', 'WFB'] }], + requirements: [ + { + name: 'SHARED_URL', + usedBy: [ + { kind: 'workflow', id: 'WFA' }, + { kind: 'workflow', id: 'WFB' }, + ], + }, + ], }); const result = await importProjects(owner, packageBuffer, undefined, { @@ -1657,7 +1769,15 @@ describe('project shell import', () => { variable: { name: 'API_URL', type: 'string', value: 'https://brie.example.com' }, }, ], - requirements: [{ name: 'API_URL', usedByWorkflows: ['WFA', 'WFB'] }], + requirements: [ + { + name: 'API_URL', + usedBy: [ + { kind: 'workflow', id: 'WFA' }, + { kind: 'workflow', id: 'WFB' }, + ], + }, + ], }); const result = await importProjects(owner, packageBuffer, undefined, { @@ -1688,7 +1808,15 @@ describe('project shell import', () => { variable: { name: 'SHARED_URL', type: 'string', value }, }, ], - requirements: [{ name: 'SHARED_URL', usedByWorkflows: ['WFA', 'WFB'] }], + requirements: [ + { + name: 'SHARED_URL', + usedBy: [ + { kind: 'workflow', id: 'WFA' }, + { kind: 'workflow', id: 'WFB' }, + ], + }, + ], }); const perProjectPackage = async (brie: string, stilton: string) => @@ -1705,7 +1833,15 @@ describe('project shell import', () => { variable: { name: 'API_URL', type: 'string', value: stilton }, }, ], - requirements: [{ name: 'API_URL', usedByWorkflows: ['WFA', 'WFB'] }], + requirements: [ + { + name: 'API_URL', + usedBy: [ + { kind: 'workflow', id: 'WFA' }, + { kind: 'workflow', id: 'WFB' }, + ], + }, + ], }); it('reports one update when both projects resolve the same global variable', async () => { @@ -1753,8 +1889,16 @@ describe('project shell import', () => { expect(error).toBeInstanceOf(ConflictError); expect((error as ConflictError).meta?.issues).toEqual([ - { type: 'variable-conflict', name: 'SHARED_URL', usedByWorkflows: ['WFA'] }, - { type: 'variable-conflict', name: 'SHARED_URL', usedByWorkflows: ['WFB'] }, + { + type: 'variable-conflict', + name: 'SHARED_URL', + usedBy: [{ kind: 'workflow', id: 'WFA' }], + }, + { + type: 'variable-conflict', + name: 'SHARED_URL', + usedBy: [{ kind: 'workflow', id: 'WFB' }], + }, ]); expect(await findProject('P1')).toBeNull(); expect(await findProject('P2')).toBeNull(); @@ -1778,8 +1922,8 @@ describe('project shell import', () => { expect(error).toBeInstanceOf(ConflictError); // One issue per scope, as the `fail` policy reports the same collision. expect((error as ConflictError).meta?.issues).toEqual([ - { type: 'variable-conflict', name: 'API_URL', usedByWorkflows: ['WFA'] }, - { type: 'variable-conflict', name: 'API_URL', usedByWorkflows: ['WFB'] }, + { type: 'variable-conflict', name: 'API_URL', usedBy: [{ kind: 'workflow', id: 'WFA' }] }, + { type: 'variable-conflict', name: 'API_URL', usedBy: [{ kind: 'workflow', id: 'WFB' }] }, ]); expect(await findProject('P1')).toBeNull(); expect(await findProject('P2')).toBeNull(); diff --git a/packages/cli/src/modules/n8n-packages/__tests__/import-workflow-with-tags.integration.test.ts b/packages/cli/src/modules/n8n-packages/__tests__/import-workflow-with-tags.integration.test.ts index c8715661de0..4f95a0e9cda 100644 --- a/packages/cli/src/modules/n8n-packages/__tests__/import-workflow-with-tags.integration.test.ts +++ b/packages/cli/src/modules/n8n-packages/__tests__/import-workflow-with-tags.integration.test.ts @@ -251,7 +251,7 @@ describe('workflow package import — with tags', () => { sourceId: tag.id, name: 'prod', existingName: 'production', - usedByWorkflows: [workflow.id], + usedBy: [{ kind: 'workflow', id: workflow.id }], }), ], }, @@ -411,8 +411,8 @@ describe('workflow package import — with tags', () => { manifestExtras: { requirements: { tags: [ - { id: 'tag-a', name: 'prod', usedByWorkflows: ['wf-0'] }, - { id: 'tag-b', name: ' prod ', usedByWorkflows: ['wf-0'] }, + { id: 'tag-a', name: 'prod', usedBy: [{ kind: 'workflow', id: 'wf-0' }] }, + { id: 'tag-b', name: ' prod ', usedBy: [{ kind: 'workflow', id: 'wf-0' }] }, ], }, }, @@ -433,14 +433,14 @@ describe('workflow package import — with tags', () => { kind: 'name-collision', sourceId: 'tag-a', name: 'prod', - usedByWorkflows: ['wf-0'], + usedBy: [{ kind: 'workflow', id: 'wf-0' }], }), expect.objectContaining({ type: 'tag-unresolved', kind: 'name-collision', sourceId: 'tag-b', name: 'prod', - usedByWorkflows: ['wf-0'], + usedBy: [{ kind: 'workflow', id: 'wf-0' }], }), ], }, @@ -651,7 +651,9 @@ describe('workflow package import — with tags', () => { const packageBuffer = await buildEntityPackageBuffer({ workflows: [{ target: 'workflows/wf-0', workflow }], manifestExtras: { - requirements: { tags: [{ id: 'tag-1', name: 'prod', usedByWorkflows: ['wf-0'] }] }, + requirements: { + tags: [{ id: 'tag-1', name: 'prod', usedBy: [{ kind: 'workflow', id: 'wf-0' }] }], + }, }, }); const targetProject = await createTeamProject('Target', owner); diff --git a/packages/cli/src/modules/n8n-packages/__tests__/import-workflow-with-variables.integration.test.ts b/packages/cli/src/modules/n8n-packages/__tests__/import-workflow-with-variables.integration.test.ts index bfd6c78ba94..0d13c295a4b 100644 --- a/packages/cli/src/modules/n8n-packages/__tests__/import-workflow-with-variables.integration.test.ts +++ b/packages/cli/src/modules/n8n-packages/__tests__/import-workflow-with-variables.integration.test.ts @@ -712,7 +712,7 @@ describe('workflow package import — with variables', () => { remaining: 0, requested: 1, names: ['API_URL'], - usedByWorkflows: [workflow.id], + usedBy: [{ kind: 'workflow', id: workflow.id }], }, ], }, @@ -1341,7 +1341,7 @@ describe('workflow package import — with variables', () => { type: 'variable-conflict', name: 'API_URL', projectId: targetProject.id, - usedByWorkflows: [workflow.id], + usedBy: [{ kind: 'workflow', id: workflow.id }], }, ], }, @@ -1365,7 +1365,11 @@ describe('workflow package import — with variables', () => { }).catch((e: unknown) => e); expect((error as ConflictError).meta?.issues).toEqual([ - { type: 'variable-conflict', name: 'API_URL', usedByWorkflows: [workflow.id] }, + { + type: 'variable-conflict', + name: 'API_URL', + usedBy: [{ kind: 'workflow', id: workflow.id }], + }, ]); }); @@ -1450,7 +1454,7 @@ describe('workflow package import — with variables', () => { type: 'variable-conflict', name: 'API_URL', projectId: targetProject.id, - usedByWorkflows: [workflow.id], + usedBy: [{ kind: 'workflow', id: workflow.id }], }, ]); expect((await variablesInProject(targetProject.id))[0].value).toBe(''); diff --git a/packages/cli/src/modules/n8n-packages/engine/__tests__/import-blocked.error.test.ts b/packages/cli/src/modules/n8n-packages/engine/__tests__/import-blocked.error.test.ts index c0f75b549d1..97b838eff09 100644 --- a/packages/cli/src/modules/n8n-packages/engine/__tests__/import-blocked.error.test.ts +++ b/packages/cli/src/modules/n8n-packages/engine/__tests__/import-blocked.error.test.ts @@ -23,13 +23,13 @@ const credentialUnresolved: BlockingIssue = { type: 'credential-unresolved', kind: 'not_found', sourceId: 'c1', - usedByWorkflows: ['w1'], + usedBy: [{ kind: 'workflow', id: 'w1' }], }; const variableUnresolved: BlockingIssue = { type: 'variable-unresolved', name: 'API_URL', - usedByWorkflows: ['w1'], + usedBy: [{ kind: 'workflow', id: 'w1' }], }; const workflowLineageConflict: BlockingIssue = { @@ -49,7 +49,7 @@ const tagUnresolved = ( kind, sourceId: 't1', name: 'prod', - usedByWorkflows: ['w1'], + usedBy: [{ kind: 'workflow', id: 'w1' }], }); describe('toImportBlockedError', () => { diff --git a/packages/cli/src/modules/n8n-packages/engine/__tests__/import-result.test.ts b/packages/cli/src/modules/n8n-packages/engine/__tests__/import-result.test.ts index 1ec70576ab0..6dda0c2d062 100644 --- a/packages/cli/src/modules/n8n-packages/engine/__tests__/import-result.test.ts +++ b/packages/cli/src/modules/n8n-packages/engine/__tests__/import-result.test.ts @@ -11,7 +11,7 @@ const requirement = (id: string, usedByWorkflows: string[]): PackageCredentialRe id, name: id, type: 'githubApi', - usedByWorkflows, + usedBy: usedByWorkflows.map((workflowId) => ({ kind: 'workflow', id: workflowId })), }); const prepared = (sourceWorkflowId: string): PreparedWorkflow => @@ -27,7 +27,6 @@ describe('identifyRequirements', () => { const scoped = identifyRequirements(requirements, [prepared('W1')]); - // credA stays (W1 is in scope) but with W2 trimmed off; credB drops entirely (W3 is out of scope). expect(scoped).toEqual([requirement('credA', ['W1'])]); }); }); diff --git a/packages/cli/src/modules/n8n-packages/engine/__tests__/import-telemetry.test.ts b/packages/cli/src/modules/n8n-packages/engine/__tests__/import-telemetry.test.ts index 833f67ce15d..28e03383c6d 100644 --- a/packages/cli/src/modules/n8n-packages/engine/__tests__/import-telemetry.test.ts +++ b/packages/cli/src/modules/n8n-packages/engine/__tests__/import-telemetry.test.ts @@ -33,7 +33,7 @@ const requirement = (id: string): PackageCredentialRequirement => ({ id, name: id, type: 'githubApi', - usedByWorkflows: ['ignored'], + usedBy: [{ kind: 'workflow', id: 'ignored' }], }); const scope = (input: { @@ -106,16 +106,15 @@ const scope = (input: { ...Array.from({ length: vars.matched }, (_, i) => `matched-var-${i}`), ...overwrittenVariableNames, ], - missing: missingVariableNames.map((name) => ({ name, usedByWorkflows: [] })), + missing: missingVariableNames.map((name) => ({ name })), creations: [...createdVariableNames, ...stubbedVariableNames, ...existingVariableNames].map( - (name) => ({ name, usedByWorkflows: [] }), + (name) => ({ name }), ), - conflicts: overwrittenVariableNames.map((name) => ({ name, usedByWorkflows: [] })), + conflicts: overwrittenVariableNames.map((name) => ({ name })), overwrites: overwrittenVariableNames.map((name) => ({ variableId: `id-of-${name}`, name, value: 'from-package', - usedByWorkflows: [], })), }, variableResult: { @@ -158,7 +157,7 @@ const scope = (input: { requirements: (tags.requirementIds ?? []).map((id) => ({ id, name: `name-of-${id}`, - usedByWorkflows: ['ignored'], + usedBy: [{ kind: 'workflow', id: 'ignored' }], })), missingMode: 'create', conflictPolicy: 'skip', diff --git a/packages/cli/src/modules/n8n-packages/engine/__tests__/package-layout.test.ts b/packages/cli/src/modules/n8n-packages/engine/__tests__/package-layout.test.ts index ccb2a9ed3dc..fbf9e19d41c 100644 --- a/packages/cli/src/modules/n8n-packages/engine/__tests__/package-layout.test.ts +++ b/packages/cli/src/modules/n8n-packages/engine/__tests__/package-layout.test.ts @@ -65,7 +65,7 @@ describe('package-layout', () => { bundledVariables?: Map, ) => placeByLayout({ - requirements: [{ name: 'API_URL', usedByWorkflows: ['wf-1'] }], + requirements: [{ name: 'API_URL', usedBy: [{ kind: 'workflow', id: 'wf-1' }] }], manifestVariables, scopePrefix, bundledVariables, diff --git a/packages/cli/src/modules/n8n-packages/engine/import-orchestrator.ts b/packages/cli/src/modules/n8n-packages/engine/import-orchestrator.ts index c76a53f837d..472c3e6e417 100644 --- a/packages/cli/src/modules/n8n-packages/engine/import-orchestrator.ts +++ b/packages/cli/src/modules/n8n-packages/engine/import-orchestrator.ts @@ -15,6 +15,7 @@ import { DataTableImporter } from '../entities/data-table/data-table-importer'; import type { DataTableImportPlan, DataTableImportRequest, + DataTableResolutionFailure, } from '../entities/data-table/data-table.types'; import type { FolderImportContext, @@ -27,7 +28,11 @@ import type { FolderRemovalPlan } from '../entities/folder/folder-removal.types' import { FolderRemover } from '../entities/folder/folder-remover'; import { TagImporter } from '../entities/tag/tag-importer'; import { contestedReconcileTargetFailures, droppedTagIds } from '../entities/tag/tag.types'; -import type { TagImportPlan, TagImportRequest } from '../entities/tag/tag.types'; +import type { + TagImportPlan, + TagImportRequest, + TagResolutionFailure, +} from '../entities/tag/tag.types'; import { VariableImporter } from '../entities/variable/variable-importer'; import { divergentOverwrites } from '../entities/variable/variable.types'; import type { @@ -65,7 +70,11 @@ import type { RemovedWorkflowSummary, ResolvedImportFolderProperties, } from '../n8n-packages.types'; -import type { PackageWorkflowRequirement } from '../spec/requirements.schema'; +import { mergeRequirementConsumers } from '../spec/requirement-consumers'; +import type { + PackageRequirementConsumer, + PackageWorkflowRequirement, +} from '../spec/requirements.schema'; import { ContentImportPolicyGate, contentImportTransport } from './content-import-policy'; import { toImportBlockedError } from './import-blocked.error'; import { assertDataTableWritesAllowed, assertVariableWritesAllowed } from './import-gates'; @@ -176,25 +185,54 @@ export class ImportOrchestrator { } } + const issues = await this.collectPackageBlockingIssues(plans); + if (issues.length > 0) throw toImportBlockedError(issues); + } + + private async collectPackageBlockingIssues(plans: ImportPlan[]): Promise { const issues = plans.flatMap((plan) => plan.blockingIssues); issues.push( - ...contestedReconcileTargetFailures( - plans.map((plan) => ({ - tagPlan: plan.tagPlan, - workflows: plan.workflowPlan.items.filter((item) => item.action !== 'skip'), - })), - ).map((failure): BlockingIssue => ({ type: 'tag-unresolved', ...failure })), + ...contestedReconcileTargetFailures(plans.map((plan) => plan.tagPlan)).map((failure) => + toTagBlockingIssue(failure, plans), + ), ); - for (const conflict of divergentOverwrites(overwrites)) { - issues.push({ type: 'variable-conflict', ...conflict }); + const divergent = new Set( + divergentOverwrites(plans.flatMap((plan) => plan.variablePlan.overwrites)), + ); + for (const { input, variablePlan } of plans) { + issues.push( + ...variablePlan.overwrites + .filter((overwrite) => divergent.has(overwrite)) + .map( + ({ variableId, value, ...conflict }): BlockingIssue => ({ + type: 'variable-conflict', + ...conflict, + usedBy: variableConsumers(input.variableRequest, conflict.name), + }), + ), + ); } - const quotaFailure = await this.variableImporter.quotaFailure(creations); - if (quotaFailure) issues.push({ type: 'variable-limit-exceeded', ...quotaFailure }); + const quotaFailure = await this.variableImporter.quotaFailure( + plans.flatMap((plan) => plan.variablePlan.creations), + ); + if (quotaFailure) { + const consumers = plans.flatMap(({ input, variablePlan }) => { + const createdNames = new Set(variablePlan.creations.map(({ name }) => name)); + return (input.variableRequest.requirements ?? []) + .filter(({ name }) => createdNames.has(name)) + .flatMap(({ usedBy }) => usedBy); + }); + issues.push({ + type: 'variable-limit-exceeded', + ...quotaFailure, + usedBy: mergeRequirementConsumers(consumers), + }); + } - if (issues.length > 0) throw toImportBlockedError(issues); + return issues; } async plan(input: ImportOrchestrationInput): Promise { @@ -269,6 +307,7 @@ export class ImportOrchestrator { credentialPlan, credentialRequest, folderPlan, + dataTableRequest, dataTablePlan, variableRequest, variablePlan, @@ -389,6 +428,7 @@ export class ImportOrchestrator { credentialPlan, credentialRequest, folderPlan, + dataTableRequest, dataTablePlan, variableRequest, variablePlan, @@ -402,6 +442,7 @@ export class ImportOrchestrator { credentialPlan: CredentialResolution; credentialRequest: CredentialBindingRequest; folderPlan: FolderImportPlan; + dataTableRequest: DataTableImportRequest; dataTablePlan: DataTableImportPlan; variableRequest: VariableImportRequest; variablePlan: VariableImportPlan; @@ -439,25 +480,35 @@ export class ImportOrchestrator { ...folderRemovalPlan.failures.map( (failure): BlockingIssue => ({ type: 'folder-removal-forbidden', ...failure }), ), - ...dataTablePlan.failures.map( - (failure): BlockingIssue => ({ type: 'data-table-unresolved', ...failure }), + ...dataTablePlan.failures.map((failure) => + toDataTableBlockingIssue(failure, dataTableRequest, dataTablePlan), + ), + ...tagPlan.failures.map((failure) => + toTagBlockingIssue(failure, [{ tagPlan, workflowPlan }]), ), - ...tagPlan.failures.map((failure): BlockingIssue => ({ type: 'tag-unresolved', ...failure })), ...this.credentialImporter .blockingFailures(credentialRequest, credentialPlan) - .map(toCredentialBlockingIssue), - ...this.variableImporter - .blockingFailures(variableRequest, variablePlan) - .map((failure): BlockingIssue => ({ type: 'variable-unresolved', ...failure })), - ...this.variableImporter - .blockingConflicts(variableRequest, variablePlan) - .map((conflict): BlockingIssue => ({ type: 'variable-conflict', ...conflict })), + .map((failure) => toCredentialBlockingIssue(failure, credentialRequest)), + ...this.variableImporter.blockingFailures(variableRequest, variablePlan).map( + (failure): BlockingIssue => ({ + type: 'variable-unresolved', + ...failure, + usedBy: variableConsumers(variableRequest, failure.name), + }), + ), + ...this.variableImporter.blockingConflicts(variableRequest, variablePlan).map( + (conflict): BlockingIssue => ({ + type: 'variable-conflict', + ...conflict, + usedBy: variableConsumers(variableRequest, conflict.name), + }), + ), ...missingNodeTypeBlockingFailures(missingNodeTypeMode, missingNodeTypes).map( - ({ type, typeVersion, usedByWorkflows }): BlockingIssue => ({ + (requirement): BlockingIssue => ({ type: 'missing-node-type', - nodeType: type, - typeVersion, - usedByWorkflows, + nodeType: requirement.type, + typeVersion: requirement.typeVersion, + usedBy: requirement.usedBy, }), ), ]; @@ -472,8 +523,11 @@ function withBlockedFromPublish( return { ...outcome, blockedFromPublish }; } -function toCredentialBlockingIssue(failure: CredentialResolutionFailure): BlockingIssue { - const { kind, sourceId, targetId, expectedType, actualType, usedByWorkflows } = failure; +function toCredentialBlockingIssue( + failure: CredentialResolutionFailure, + request: CredentialBindingRequest, +): BlockingIssue { + const { kind, sourceId, targetId, expectedType, actualType } = failure; return { type: 'credential-unresolved', kind, @@ -481,6 +535,68 @@ function toCredentialBlockingIssue(failure: CredentialResolutionFailure): Blocki ...(targetId ? { targetId } : {}), ...(expectedType ? { expectedType } : {}), ...(actualType ? { actualType } : {}), - usedByWorkflows, + usedBy: mergeRequirementConsumers( + request.requirements?.find(({ id }) => id === sourceId)?.usedBy ?? [], + ), }; } + +function toDataTableBlockingIssue( + failure: DataTableResolutionFailure, + request: DataTableImportRequest, + plan: DataTableImportPlan, +): BlockingIssue { + let requirements = request.requirements ?? []; + if (failure.sourceId) { + requirements = requirements.filter(({ id }) => id === failure.sourceId); + } else if (failure.kind === 'permission-denied') { + const writtenIds = new Set( + failure.missingScope === 'dataTable:create' + ? plan.creations.map(({ id }) => id) + : plan.updates.map(({ table }) => table.id), + ); + requirements = requirements.filter(({ id }) => writtenIds.has(id)); + } + + return { + type: 'data-table-unresolved', + ...failure, + usedBy: mergeRequirementConsumers(requirements.flatMap(({ usedBy }) => usedBy)), + }; +} + +function toTagBlockingIssue( + failure: TagResolutionFailure, + plans: Array>, +): BlockingIssue { + const consumers = plans.flatMap(({ tagPlan, workflowPlan }) => { + let tagIds: string[]; + if (failure.sourceId) { + tagIds = [failure.sourceId]; + } else if (failure.missingScope === 'tag:create') { + tagIds = tagPlan.creations.map(({ id }) => id); + } else { + tagIds = [...tagPlan.renames, ...tagPlan.reconciles].map(({ id }) => id); + } + + return workflowPlan.items + .filter((item) => item.action !== 'skip' && item.tagIds?.some((id) => tagIds.includes(id))) + .map( + ({ sourceWorkflowId }): PackageRequirementConsumer => ({ + kind: 'workflow', + id: sourceWorkflowId, + }), + ); + }); + + return { type: 'tag-unresolved', ...failure, usedBy: mergeRequirementConsumers(consumers) }; +} + +function variableConsumers( + request: VariableImportRequest, + name: string, +): PackageRequirementConsumer[] { + return mergeRequirementConsumers( + request.requirements?.find((requirement) => requirement.name === name)?.usedBy ?? [], + ); +} diff --git a/packages/cli/src/modules/n8n-packages/engine/import-result.ts b/packages/cli/src/modules/n8n-packages/engine/import-result.ts index d3626c92886..fd07da1d3d6 100644 --- a/packages/cli/src/modules/n8n-packages/engine/import-result.ts +++ b/packages/cli/src/modules/n8n-packages/engine/import-result.ts @@ -22,7 +22,10 @@ import type { PackageImportBindings, } from '../n8n-packages.types'; import type { PackageManifest } from '../spec/manifest.schema'; -import type { PackageCredentialRequirement } from '../spec/requirements.schema'; +import type { + PackageCredentialRequirement, + PackageRequirementConsumer, +} from '../spec/requirements.schema'; export function toPackageSummary(manifest: PackageManifest): ImportPackageSummary { return { @@ -161,9 +164,9 @@ export function unionTagSummaries(summaries: ImportTagSummary[]): ImportTagSumma } /** - * Keeps only the requirements used by the imported workflows, trimming `usedByWorkflows` to match. + * Keep only requirements and consumers for the imported workflows. */ -export function identifyRequirements( +export function identifyRequirements( requirements: T[] | undefined, workflows: PreparedWorkflow[], ): T[] | undefined { @@ -173,9 +176,9 @@ export function identifyRequirements( return requirements .map((requirement) => ({ ...requirement, - usedByWorkflows: requirement.usedByWorkflows.filter((id) => importedIds.has(id)), + usedBy: requirement.usedBy.filter(({ id }) => importedIds.has(id)), })) - .filter((requirement) => requirement.usedByWorkflows.length > 0); + .filter((requirement) => requirement.usedBy.length > 0); } /** diff --git a/packages/cli/src/modules/n8n-packages/entities/credential/__tests__/credential-importer.test.ts b/packages/cli/src/modules/n8n-packages/entities/credential/__tests__/credential-importer.test.ts index 9ae23e9c52c..f8e38e2609e 100644 --- a/packages/cli/src/modules/n8n-packages/entities/credential/__tests__/credential-importer.test.ts +++ b/packages/cli/src/modules/n8n-packages/entities/credential/__tests__/credential-importer.test.ts @@ -51,7 +51,7 @@ describe('CredentialImporter', () => { ): CredentialRequirement => ({ name: 'Source GitHub', type: 'githubApi', - usedByWorkflows: ['wf-1'], + usedBy: [{ kind: 'workflow', id: 'wf-1' }], ...overrides, }); @@ -63,7 +63,6 @@ describe('CredentialImporter', () => { sourceId: credential.id, name: credential.name, type: credential.type, - usedByWorkflows: credential.usedByWorkflows, ...overrides, }); @@ -95,7 +94,7 @@ describe('CredentialImporter', () => { id: 'cred-manifest', name: 'Manifest GitHub', type: 'githubApi', - usedByWorkflows: ['wf-1'], + usedBy: [{ kind: 'workflow', id: 'wf-1' }], }, ]); const credentialResolution = await importer.plan(context, request); @@ -109,7 +108,12 @@ describe('CredentialImporter', () => { credentialsService.getCredentialsAUserCanUseInAWorkflow.mockResolvedValue([]); const request = bindingRequest([ - { id: 'cred-missing', name: 'Missing', type: 'githubApi', usedByWorkflows: ['wf-1'] }, + { + id: 'cred-missing', + name: 'Missing', + type: 'githubApi', + usedBy: [{ kind: 'workflow', id: 'wf-1' }], + }, ]); const credentialResolution = await importer.plan(context, request); @@ -120,7 +124,6 @@ describe('CredentialImporter', () => { sourceId: 'cred-missing', name: 'Missing', type: 'githubApi', - usedByWorkflows: ['wf-1'], }, ]); expect(importer.blockingFailures(request, credentialResolution)).toEqual([ @@ -129,7 +132,6 @@ describe('CredentialImporter', () => { sourceId: 'cred-missing', name: 'Missing', type: 'githubApi', - usedByWorkflows: ['wf-1'], }, ]); }); @@ -145,7 +147,7 @@ describe('CredentialImporter', () => { id: 'source-cred', name: 'Source GitHub', type: 'githubApi', - usedByWorkflows: ['wf-1'], + usedBy: [{ kind: 'workflow', id: 'wf-1' }], }, ], { credentialBindings: new Map([['source-cred', 'target-cred']]) }, @@ -172,7 +174,6 @@ describe('CredentialImporter', () => { kind: 'source_not_found', sourceId: 'missing-source', targetId: 'target-cred', - usedByWorkflows: [], }, ]); }); @@ -188,7 +189,7 @@ describe('CredentialImporter', () => { id: 'source-cred', name: 'Source GitHub', type: 'githubApi', - usedByWorkflows: ['wf-1'], + usedBy: [{ kind: 'workflow', id: 'wf-1' }], }, ], { credentialBindings: new Map([['source-cred', 'target-missing']]) }, @@ -203,7 +204,6 @@ describe('CredentialImporter', () => { name: 'Source GitHub', type: 'githubApi', targetId: 'target-missing', - usedByWorkflows: ['wf-1'], }, ]); }); @@ -215,7 +215,10 @@ describe('CredentialImporter', () => { const missingCredential = packageCredential({ id: 'missing-cred', name: 'Missing GitHub', - usedByWorkflows: ['wf-1', 'wf-2'], + usedBy: [ + { kind: 'workflow', id: 'wf-1' }, + { kind: 'workflow', id: 'wf-2' }, + ], }); const request = bindingRequest([missingCredential], { missingMode: 'create-stub' }); const resolution = { diff --git a/packages/cli/src/modules/n8n-packages/entities/credential/__tests__/credential-matcher.test.ts b/packages/cli/src/modules/n8n-packages/entities/credential/__tests__/credential-matcher.test.ts index 12c5606f45f..8d600a33d52 100644 --- a/packages/cli/src/modules/n8n-packages/entities/credential/__tests__/credential-matcher.test.ts +++ b/packages/cli/src/modules/n8n-packages/entities/credential/__tests__/credential-matcher.test.ts @@ -6,6 +6,7 @@ import type { CredentialTypes } from '@/credential-types'; import type { CredentialsService } from '@/credentials/credentials.service'; import { BadRequestError } from '@n8n/errors'; +import type { PackageCredentialRequirement } from '../../../spec/requirements.schema'; import type { CredentialMatcherContext, UsableCredential } from '../credential-matcher'; import { CredentialMatcherFactory } from '../credential-matcher-factory'; import { createFailure } from '../credential.types'; @@ -66,11 +67,11 @@ describe('IdBasedCredentialMatcher', () => { }); it('reports a missing credential when the user cannot use it in the target project', async () => { - const requirement = { + const requirement: PackageCredentialRequirement = { id: 'cred-global', name: 'Global', type: 'httpBasicAuth', - usedByWorkflows: ['wf-1'], + usedBy: [{ kind: 'workflow', id: 'wf-1' }], }; const result = await matcherFactory.getMatcher('id-only').match([requirement], context); @@ -85,11 +86,11 @@ describe('IdBasedCredentialMatcher', () => { it('does not query the database for unknown credential types', async () => { credentialTypes.recognizes.mockReturnValue(false); - const requirement = { + const requirement: PackageCredentialRequirement = { id: 'cred-x', name: 'Unknown', type: 'unknownCredentialType', - usedByWorkflows: ['wf-1'], + usedBy: [{ kind: 'workflow', id: 'wf-1' }], }; const result = await matcherFactory.getMatcher('id-only').match([requirement], context); @@ -109,7 +110,7 @@ describe('IdBasedCredentialMatcher', () => { id: 'cred-manifest', name: 'Manifest GitHub', type: 'githubApi', - usedByWorkflows: ['wf-1'], + usedBy: [{ kind: 'workflow', id: 'wf-1' }], }, ], context, @@ -130,7 +131,7 @@ describe('IdBasedCredentialMatcher', () => { id: 'cred-shared', name: 'Shared', type: 'githubApi', - usedByWorkflows: ['wf-1'], + usedBy: [{ kind: 'workflow', id: 'wf-1' }], }, ], context, @@ -151,7 +152,7 @@ describe('IdBasedCredentialMatcher', () => { id: 'cred-global', name: 'Global', type: 'httpBasicAuth', - usedByWorkflows: ['wf-1'], + usedBy: [{ kind: 'workflow', id: 'wf-1' }], }, ], context, @@ -166,11 +167,11 @@ describe('IdBasedCredentialMatcher', () => { // custom role granting workflow:import without credential:read yields an empty result. credentialsService.getCredentialsAUserCanUseInAWorkflow.mockResolvedValue([]); - const requirement = { + const requirement: PackageCredentialRequirement = { id: 'cred-manifest', name: 'Manifest GitHub', type: 'githubApi', - usedByWorkflows: ['wf-1'], + usedBy: [{ kind: 'workflow', id: 'wf-1' }], }; const result = await matcherFactory.getMatcher('id-only').match([requirement], context); @@ -191,7 +192,7 @@ describe('IdBasedCredentialMatcher', () => { id: 'cred-manifest', name: 'Manifest GitHub', type: 'githubApi', - usedByWorkflows: ['wf-1'], + usedBy: [{ kind: 'workflow', id: 'wf-1' }], }, ], context, @@ -206,11 +207,11 @@ describe('IdBasedCredentialMatcher', () => { usable('target-cred'), ]); - const requirement = { + const requirement: PackageCredentialRequirement = { id: 'source-cred', name: 'Source GitHub', type: 'githubApi', - usedByWorkflows: ['wf-1'], + usedBy: [{ kind: 'workflow', id: 'wf-1' }], }; const result = await matcherFactory.getMatcher('id-only').match([requirement], { @@ -227,11 +228,11 @@ describe('IdBasedCredentialMatcher', () => { usable('source-cred'), ]); - const requirement = { + const requirement: PackageCredentialRequirement = { id: 'source-cred', name: 'Source GitHub', type: 'githubApi', - usedByWorkflows: ['wf-1'], + usedBy: [{ kind: 'workflow', id: 'wf-1' }], }; const result = await matcherFactory.getMatcher('id-only').match([requirement], { @@ -250,11 +251,11 @@ describe('IdBasedCredentialMatcher', () => { usable('target-cred', 'slackApi'), ]); - const requirement = { + const requirement: PackageCredentialRequirement = { id: 'source-cred', name: 'Source GitHub', type: 'githubApi', - usedByWorkflows: ['wf-1'], + usedBy: [{ kind: 'workflow', id: 'wf-1' }], }; const result = await matcherFactory.getMatcher('id-only').match([requirement], { @@ -278,11 +279,11 @@ describe('NameAndTypeCredentialMatcher', () => { let context: CredentialMatcherContext; let matcherFactory: CredentialMatcherFactory; - const requirement = { + const requirement: PackageCredentialRequirement = { id: 'source-cred', name: 'Production GitHub', type: 'githubApi', - usedByWorkflows: ['wf-1'], + usedBy: [{ kind: 'workflow', id: 'wf-1' }], }; beforeEach(() => { @@ -552,11 +553,11 @@ describe('TypeOnlyCredentialMatcher', () => { let context: CredentialMatcherContext; let matcherFactory: CredentialMatcherFactory; - const requirement = { + const requirement: PackageCredentialRequirement = { id: 'source-cred', name: 'Irrelevant name', type: 'githubApi', - usedByWorkflows: ['wf-1'], + usedBy: [{ kind: 'workflow', id: 'wf-1' }], }; beforeEach(() => { diff --git a/packages/cli/src/modules/n8n-packages/entities/credential/__tests__/credential-missing-mode.test.ts b/packages/cli/src/modules/n8n-packages/entities/credential/__tests__/credential-missing-mode.test.ts index b0805bde5d3..df3ced3d432 100644 --- a/packages/cli/src/modules/n8n-packages/entities/credential/__tests__/credential-missing-mode.test.ts +++ b/packages/cli/src/modules/n8n-packages/entities/credential/__tests__/credential-missing-mode.test.ts @@ -14,7 +14,7 @@ describe('credentialBlockingFailures', () => { it('treats every unresolved reference as blocking', () => { const failure = createFailure( - { id: 'cred-1', name: 'X', type: 'githubApi', usedByWorkflows: ['wf-1'] }, + { id: 'cred-1', name: 'X', type: 'githubApi', usedBy: [{ kind: 'workflow', id: 'wf-1' }] }, 'not_found', ); @@ -27,7 +27,7 @@ describe('credentialBlockingFailures', () => { describe('create-stub', () => { it('treats not_found as non-blocking', () => { const failure = createFailure( - { id: 'cred-1', name: 'X', type: 'githubApi', usedByWorkflows: ['wf-1'] }, + { id: 'cred-1', name: 'X', type: 'githubApi', usedBy: [{ kind: 'workflow', id: 'wf-1' }] }, 'not_found', ); @@ -39,7 +39,12 @@ describe('credentialBlockingFailures', () => { it('still blocks not_found when an explicit binding target is missing', () => { const failure = { ...createFailure( - { id: 'cred-1', name: 'X', type: 'githubApi', usedByWorkflows: ['wf-1'] }, + { + id: 'cred-1', + name: 'X', + type: 'githubApi', + usedBy: [{ kind: 'workflow', id: 'wf-1' }], + }, 'not_found', ), targetId: 'target-missing', @@ -52,11 +57,11 @@ describe('credentialBlockingFailures', () => { it('still blocks unknown_type and source_not_found failures', () => { const unknownType = createFailure( - { id: 'cred-1', name: 'X', type: 'bad', usedByWorkflows: ['wf-1'] }, + { id: 'cred-1', name: 'X', type: 'bad', usedBy: [{ kind: 'workflow', id: 'wf-1' }] }, 'unknown_type', ); const sourceNotFound = createFailure( - { id: 'cred-2', name: 'Y', type: 'githubApi', usedByWorkflows: ['wf-2'] }, + { id: 'cred-2', name: 'Y', type: 'githubApi', usedBy: [{ kind: 'workflow', id: 'wf-2' }] }, 'source_not_found', ); diff --git a/packages/cli/src/modules/n8n-packages/entities/credential/__tests__/credential.exporter.test.ts b/packages/cli/src/modules/n8n-packages/entities/credential/__tests__/credential.exporter.test.ts index 913eb2bb6f8..9fe1f9dddfa 100644 --- a/packages/cli/src/modules/n8n-packages/entities/credential/__tests__/credential.exporter.test.ts +++ b/packages/cli/src/modules/n8n-packages/entities/credential/__tests__/credential.exporter.test.ts @@ -103,7 +103,7 @@ describe('CredentialExporter', () => { id: 'cred_1', name: 'My Credential', type: 'httpHeaderAuth', - usedByWorkflows: ['wf-1'], + usedBy: [{ kind: 'workflow', id: 'wf-1' }], }, ]); @@ -119,7 +119,7 @@ describe('CredentialExporter', () => { }); }); - it('dedupes by credential id and aggregates usedByWorkflows when requirements come from multiple workflows', async () => { + it('dedupes by credential id and aggregates workflow consumers when requirements come from multiple workflows', async () => { const { exporter, finder } = makeExporter(); finder.findCredentialForUser.mockResolvedValue(makeCredential()); const writer = new CapturingWriter(); @@ -143,7 +143,10 @@ describe('CredentialExporter', () => { id: 'cred_1', name: 'My Credential', type: 'httpHeaderAuth', - usedByWorkflows: ['wf-a', 'wf-b'], + usedBy: [ + { kind: 'workflow', id: 'wf-a' }, + { kind: 'workflow', id: 'wf-b' }, + ], }, ]); expect(writer.files).toHaveLength(1); @@ -208,7 +211,7 @@ describe('CredentialExporter', () => { id: 'cred_unavailable', name: 'Stale node name', type: 'httpHeaderAuth', - usedByWorkflows: ['wf-1'], + usedBy: [{ kind: 'workflow', id: 'wf-1' }], }, ]); expect(writer.files).toEqual([]); @@ -246,13 +249,13 @@ describe('CredentialExporter', () => { id: 'cred_1', name: 'My Credential', type: 'httpHeaderAuth', - usedByWorkflows: ['wf-1'], + usedBy: [{ kind: 'workflow', id: 'wf-1' }], }, { id: 'cred_unavailable', name: 'Unavailable', type: 'slackOAuth2Api', - usedByWorkflows: ['wf-1'], + usedBy: [{ kind: 'workflow', id: 'wf-1' }], }, ]); expect(writer.files).toHaveLength(1); diff --git a/packages/cli/src/modules/n8n-packages/entities/credential/credential-matcher.ts b/packages/cli/src/modules/n8n-packages/entities/credential/credential-matcher.ts index 73870bf79e9..1da3af09cd7 100644 --- a/packages/cli/src/modules/n8n-packages/entities/credential/credential-matcher.ts +++ b/packages/cli/src/modules/n8n-packages/entities/credential/credential-matcher.ts @@ -208,7 +208,6 @@ function orphanBindingFailures( kind: 'source_not_found', sourceId, targetId, - usedByWorkflows: [], }); } } diff --git a/packages/cli/src/modules/n8n-packages/entities/credential/credential-missing-mode.ts b/packages/cli/src/modules/n8n-packages/entities/credential/credential-missing-mode.ts index 11779f15e47..c009f2a2bd5 100644 --- a/packages/cli/src/modules/n8n-packages/entities/credential/credential-missing-mode.ts +++ b/packages/cli/src/modules/n8n-packages/entities/credential/credential-missing-mode.ts @@ -1,5 +1,6 @@ import type { CredentialResolution, CredentialResolutionFailure } from './credential.types'; import type { CredentialMissingMode } from '../../n8n-packages.types'; +import { getWorkflowConsumerIds } from '../../spec/requirement-consumers'; import type { PackageCredentialRequirement } from '../../spec/requirements.schema'; export function canStubNotFoundFailure(failure: CredentialResolutionFailure): boolean { @@ -38,7 +39,7 @@ export function workflowsBlockedFromPublish( for (const requirement of requirements ?? []) { if (!stubbedSourceIds.has(requirement.id)) continue; - for (const sourceWorkflowId of requirement.usedByWorkflows) { + for (const sourceWorkflowId of getWorkflowConsumerIds(requirement)) { blocked.add(sourceWorkflowId); } } diff --git a/packages/cli/src/modules/n8n-packages/entities/credential/credential.exporter.ts b/packages/cli/src/modules/n8n-packages/entities/credential/credential.exporter.ts index f50aa3509b2..8b34cf139f5 100644 --- a/packages/cli/src/modules/n8n-packages/entities/credential/credential.exporter.ts +++ b/packages/cli/src/modules/n8n-packages/entities/credential/credential.exporter.ts @@ -85,7 +85,12 @@ export class CredentialExporter { ); } - requirements.push({ id, name, type, usedByWorkflows }); + requirements.push({ + id, + name, + type, + usedBy: usedByWorkflows.map((workflowId) => ({ kind: 'workflow', id: workflowId })), + }); } return { entries, requirements }; diff --git a/packages/cli/src/modules/n8n-packages/entities/credential/credential.types.ts b/packages/cli/src/modules/n8n-packages/entities/credential/credential.types.ts index 79c6b95698f..ea73a0ca147 100644 --- a/packages/cli/src/modules/n8n-packages/entities/credential/credential.types.ts +++ b/packages/cli/src/modules/n8n-packages/entities/credential/credential.types.ts @@ -28,7 +28,6 @@ export type CredentialResolutionFailure = { expectedType?: string; /** For `type_mismatch`: the actual type of the resolved target credential. */ actualType?: string; - usedByWorkflows: string[]; }; export interface CredentialResolution { @@ -58,6 +57,5 @@ export function createFailure( sourceId: reference.id, name: reference.name, type: reference.type, - usedByWorkflows: [...reference.usedByWorkflows].sort(), }; } diff --git a/packages/cli/src/modules/n8n-packages/entities/data-table/__tests__/data-table-importer.test.ts b/packages/cli/src/modules/n8n-packages/entities/data-table/__tests__/data-table-importer.test.ts index bb4605a6e47..68d8b17c54b 100644 --- a/packages/cli/src/modules/n8n-packages/entities/data-table/__tests__/data-table-importer.test.ts +++ b/packages/cli/src/modules/n8n-packages/entities/data-table/__tests__/data-table-importer.test.ts @@ -33,7 +33,7 @@ function makeImporter() { function makeRequest(overrides: Partial = {}): DataTableImportRequest { return { - requirements: [{ id: 'dt1', name: 'Customers', usedByWorkflows: ['wf-1'] }], + requirements: [{ id: 'dt1', name: 'Customers', usedBy: [{ kind: 'workflow', id: 'wf-1' }] }], packageDataTables: [ { id: 'dt1', name: 'Customers', columns: [{ name: 'email', type: 'string', index: 0 }] }, ], @@ -66,7 +66,7 @@ describe('DataTableImporter.plan', () => { const plan = await importer.plan(context, makeRequest()); expect(plan.creations).toEqual([]); - expect(plan.failures).toEqual([{ kind: 'module-disabled', usedByWorkflows: ['wf-1'] }]); + expect(plan.failures).toEqual([{ kind: 'module-disabled' }]); }); it('fails with permission-denied when the user cannot create tables in the target project', async () => { @@ -76,7 +76,7 @@ describe('DataTableImporter.plan', () => { const plan = await importer.plan(context, makeRequest()); expect(plan.failures).toEqual([ - { kind: 'permission-denied', missingScope: 'dataTable:create', usedByWorkflows: ['wf-1'] }, + { kind: 'permission-denied', missingScope: 'dataTable:create' }, ]); expect(userHasScopesMock).toHaveBeenCalledWith(context.user, ['dataTable:create'], false, { projectId: context.projectId, diff --git a/packages/cli/src/modules/n8n-packages/entities/data-table/__tests__/data-table.exporter.test.ts b/packages/cli/src/modules/n8n-packages/entities/data-table/__tests__/data-table.exporter.test.ts index a7b312ab50a..28b3d9ae0ed 100644 --- a/packages/cli/src/modules/n8n-packages/entities/data-table/__tests__/data-table.exporter.test.ts +++ b/packages/cli/src/modules/n8n-packages/entities/data-table/__tests__/data-table.exporter.test.ts @@ -100,7 +100,7 @@ describe('DataTableExporter', () => { { id: 'dt1', name: 'Customers', - usedByWorkflows: ['wf-1'], + usedBy: [{ kind: 'workflow', id: 'wf-1' }], }, ]); @@ -116,7 +116,7 @@ describe('DataTableExporter', () => { }); }); - it('dedupes by table id and aggregates usedByWorkflows when requirements come from multiple workflows', async () => { + it('dedupes by table id and aggregates workflow consumers when requirements come from multiple workflows', async () => { const { exporter, dataTableService } = makeExporter(); dataTableService.findDataTablesByIdsForUser.mockResolvedValue([makeDataTable()]); const writer = new CapturingWriter(); @@ -140,7 +140,10 @@ describe('DataTableExporter', () => { { id: 'dt1', name: 'Customers', - usedByWorkflows: ['wf-a', 'wf-b'], + usedBy: [ + { kind: 'workflow', id: 'wf-a' }, + { kind: 'workflow', id: 'wf-b' }, + ], }, ]); expect(writer.files).toHaveLength(1); diff --git a/packages/cli/src/modules/n8n-packages/entities/data-table/data-table-importer.ts b/packages/cli/src/modules/n8n-packages/entities/data-table/data-table-importer.ts index 260eaf98b19..4af5b4c5001 100644 --- a/packages/cli/src/modules/n8n-packages/entities/data-table/data-table-importer.ts +++ b/packages/cli/src/modules/n8n-packages/entities/data-table/data-table-importer.ts @@ -63,7 +63,7 @@ export class DataTableImporter { return { creations: [], updates: [], - failures: [{ kind: 'module-disabled', usedByWorkflows: workflowsUsing(requirements) }], + failures: [{ kind: 'module-disabled' }], matchedCount: 0, }; } @@ -200,7 +200,6 @@ export class DataTableImporter { failures.push({ kind: 'permission-denied', missingScope: 'dataTable:create', - usedByWorkflows: workflowsUsing(creations.map(({ requirement }) => requirement)), }); } @@ -208,7 +207,6 @@ export class DataTableImporter { failures.push({ kind: 'permission-denied', missingScope: 'dataTable:update', - usedByWorkflows: workflowsUsing(updates.map(({ requirement }) => requirement)), }); } @@ -324,8 +322,3 @@ async function hasProjectScope( ): Promise { return await userHasScopes(context.user, [scope], false, { projectId: context.projectId }); } - -/** Sorted unique workflow ids referencing the given requirements. */ -function workflowsUsing(requirements: PackageDataTableRequirement[]): string[] { - return [...new Set(requirements.flatMap(({ usedByWorkflows }) => usedByWorkflows))].sort(); -} diff --git a/packages/cli/src/modules/n8n-packages/entities/data-table/data-table.exporter.ts b/packages/cli/src/modules/n8n-packages/entities/data-table/data-table.exporter.ts index 648b25f5255..99d716f2d7c 100644 --- a/packages/cli/src/modules/n8n-packages/entities/data-table/data-table.exporter.ts +++ b/packages/cli/src/modules/n8n-packages/entities/data-table/data-table.exporter.ts @@ -70,7 +70,10 @@ export class DataTableExporter { requirements.push({ id: dataTable.id, name: dataTable.name, - usedByWorkflows: usedByWorkflowsById.get(dataTable.id) ?? [], + usedBy: (usedByWorkflowsById.get(dataTable.id) ?? []).map((id) => ({ + kind: 'workflow', + id, + })), }); } diff --git a/packages/cli/src/modules/n8n-packages/entities/data-table/data-table.types.ts b/packages/cli/src/modules/n8n-packages/entities/data-table/data-table.types.ts index 99c4183ea59..636dcd1cb9b 100644 --- a/packages/cli/src/modules/n8n-packages/entities/data-table/data-table.types.ts +++ b/packages/cli/src/modules/n8n-packages/entities/data-table/data-table.types.ts @@ -48,7 +48,6 @@ export type DataTableResolutionFailure = { conflictingTableId?: string; /** For a rename `name-conflict`: the matched table's current name. */ currentName?: string; - usedByWorkflows: string[]; }; export function createFailure( @@ -72,7 +71,6 @@ export function createFailure( kind, sourceId: requirement.id, name: requirement.name, - usedByWorkflows: [...new Set(requirement.usedByWorkflows)].sort(), ...details, }; } diff --git a/packages/cli/src/modules/n8n-packages/entities/tag/__tests__/tag-importer.test.ts b/packages/cli/src/modules/n8n-packages/entities/tag/__tests__/tag-importer.test.ts index 776db5d9ebb..30c630e7da0 100644 --- a/packages/cli/src/modules/n8n-packages/entities/tag/__tests__/tag-importer.test.ts +++ b/packages/cli/src/modules/n8n-packages/entities/tag/__tests__/tag-importer.test.ts @@ -27,7 +27,7 @@ function makeImporter() { } const request: TagImportRequest = { - requirements: [{ id: 'tag-1', name: 'prod', usedByWorkflows: ['wf-1'] }], + requirements: [{ id: 'tag-1', name: 'prod', usedBy: [{ kind: 'workflow', id: 'wf-1' }] }], missingMode: 'create', conflictPolicy: 'skip', }; @@ -40,9 +40,7 @@ describe('TagImporter.plan', () => { const plan = await importer.plan(contextFor([]), request, appliedWorkflows); expect(plan.creations).toEqual([{ id: 'tag-1', name: 'prod' }]); - expect(plan.failures).toEqual([ - { kind: 'permission-denied', missingScope: 'tag:create', usedByWorkflows: ['wf-1'] }, - ]); + expect(plan.failures).toEqual([{ kind: 'permission-denied', missingScope: 'tag:create' }]); }); it('fails with permission-denied when the user lacks tag:update for a planned rename', async () => { @@ -56,9 +54,7 @@ describe('TagImporter.plan', () => { ); expect(plan.renames).toEqual([{ id: 'tag-1', from: 'production', to: 'prod' }]); - expect(plan.failures).toEqual([ - { kind: 'permission-denied', missingScope: 'tag:update', usedByWorkflows: ['wf-1'] }, - ]); + expect(plan.failures).toEqual([{ kind: 'permission-denied', missingScope: 'tag:update' }]); }); it('fails with permission-denied when the user lacks tag:update for a planned reconcile', async () => { @@ -72,9 +68,7 @@ describe('TagImporter.plan', () => { ); expect(plan.reconciles).toEqual([{ id: 'tag-1', name: 'prod', oldId: 'tag-9' }]); - expect(plan.failures).toEqual([ - { kind: 'permission-denied', missingScope: 'tag:update', usedByWorkflows: ['wf-1'] }, - ]); + expect(plan.failures).toEqual([{ kind: 'permission-denied', missingScope: 'tag:update' }]); }); }); diff --git a/packages/cli/src/modules/n8n-packages/entities/tag/tag-import-decision.ts b/packages/cli/src/modules/n8n-packages/entities/tag/tag-import-decision.ts index 1777a30c7f9..c64c25aab60 100644 --- a/packages/cli/src/modules/n8n-packages/entities/tag/tag-import-decision.ts +++ b/packages/cli/src/modules/n8n-packages/entities/tag/tag-import-decision.ts @@ -7,11 +7,7 @@ import type { } from './tag.types'; import { TagConflictPolicy, TagMissingMode } from '../../n8n-packages.types'; -/** A per-requirement failure; `TagImporter.plan` fills in `usedByWorkflows`. */ -export type TagDecisionFailure = Omit< - TagResolutionFailure, - 'kind' | 'sourceId' | 'name' | 'usedByWorkflows' -> & { +export type TagDecisionFailure = Omit & { kind: Exclude; sourceId: string; name: string; diff --git a/packages/cli/src/modules/n8n-packages/entities/tag/tag-importer.ts b/packages/cli/src/modules/n8n-packages/entities/tag/tag-importer.ts index d9b8f91bf64..2e5d7b47046 100644 --- a/packages/cli/src/modules/n8n-packages/entities/tag/tag-importer.ts +++ b/packages/cli/src/modules/n8n-packages/entities/tag/tag-importer.ts @@ -8,14 +8,7 @@ import { TagService } from '@/services/tag.service'; import { decideTagImportAction } from './tag-import-decision'; import type { TagDecisionFailure } from './tag-import-decision'; -import { sortedUnique } from './tag.types'; -import type { - ReferencingWorkflow, - TagImportPlan, - TagImportRequest, - TagRef, - TagResolutionFailure, -} from './tag.types'; +import type { ReferencingWorkflow, TagImportPlan, TagImportRequest, TagRef } from './tag.types'; import type { ImportContext } from '../../n8n-packages.types'; @Service() @@ -91,32 +84,18 @@ export class TagImporter { decisionFailures.push(...duplicateWrittenNameFailures(plan)); - const sourceWorkflowIdsReferencing = (tagId: string) => - appliedWorkflows - .filter(({ tagIds }) => tagIds?.includes(tagId)) - .map(({ sourceWorkflowId }) => sourceWorkflowId); - - plan.failures = decisionFailures.map((failure) => ({ - ...failure, - usedByWorkflows: sortedUnique(sourceWorkflowIdsReferencing(failure.sourceId)), - })); + plan.failures = decisionFailures; // Tags are global entities, so gate on the user's global scopes: project-level // workflow:import alone must not grant instance-wide tag writes. if (plan.creations.length > 0 && !hasGlobalScope(context.user, 'tag:create')) { - plan.failures.push( - permissionFailure( - 'tag:create', - plan.creations.map(({ id }) => id), - sourceWorkflowIdsReferencing, - ), - ); + plan.failures.push({ kind: 'permission-denied', missingScope: 'tag:create' }); } - const updatedTagIds = [...plan.renames, ...plan.reconciles].map(({ id }) => id); - if (updatedTagIds.length > 0 && !hasGlobalScope(context.user, 'tag:update')) { - plan.failures.push( - permissionFailure('tag:update', updatedTagIds, sourceWorkflowIdsReferencing), - ); + if ( + (plan.renames.length > 0 || plan.reconciles.length > 0) && + !hasGlobalScope(context.user, 'tag:update') + ) { + plan.failures.push({ kind: 'permission-denied', missingScope: 'tag:update' }); } return plan; @@ -207,15 +186,3 @@ function duplicateWrittenNameFailures(plan: TagImportPlan): TagDecisionFailure[] ), ]; } - -function permissionFailure( - missingScope: 'tag:create' | 'tag:update', - tagIds: string[], - sourceWorkflowIdsReferencing: (tagId: string) => string[], -): TagResolutionFailure { - return { - kind: 'permission-denied', - missingScope, - usedByWorkflows: sortedUnique(tagIds.flatMap(sourceWorkflowIdsReferencing)), - }; -} diff --git a/packages/cli/src/modules/n8n-packages/entities/tag/tag.exporter.ts b/packages/cli/src/modules/n8n-packages/entities/tag/tag.exporter.ts index 61e3bb44748..e87512b5eab 100644 --- a/packages/cli/src/modules/n8n-packages/entities/tag/tag.exporter.ts +++ b/packages/cli/src/modules/n8n-packages/entities/tag/tag.exporter.ts @@ -41,13 +41,13 @@ export class TagExporter { const requirementsByTagId = new Map(); for (const { workflowId, tag } of request.usages) { - const requirement = requirementsByTagId.get(tag.id) ?? { + const requirement: PackageTagRequirement = requirementsByTagId.get(tag.id) ?? { id: tag.id, name: tag.name, - usedByWorkflows: [], + usedBy: [], }; - if (!requirement.usedByWorkflows.includes(workflowId)) { - requirement.usedByWorkflows.push(workflowId); + if (!requirement.usedBy.some(({ id }) => id === workflowId)) { + requirement.usedBy.push({ kind: 'workflow', id: workflowId }); } requirementsByTagId.set(tag.id, requirement); } diff --git a/packages/cli/src/modules/n8n-packages/entities/tag/tag.types.ts b/packages/cli/src/modules/n8n-packages/entities/tag/tag.types.ts index e32fdc1c38b..6ce9ff22290 100644 --- a/packages/cli/src/modules/n8n-packages/entities/tag/tag.types.ts +++ b/packages/cli/src/modules/n8n-packages/entities/tag/tag.types.ts @@ -51,7 +51,6 @@ export type TagResolutionFailure = { existingTagId?: string; /** For `rename-drift`: the current name of the same-id target tag. */ existingName?: string; - usedByWorkflows: string[]; }; export interface TagImportRequest { @@ -81,10 +80,6 @@ export function droppedTagIds(plan: TagImportPlan): ReadonlySet { return new Set(plan.dropped.map(({ id }) => id)); } -export function sortedUnique(values: string[]): string[] { - return [...new Set(values)].sort(); -} - /** * Gates package tags that lay contradictory claims on one target tag row. * Example: the package carries tag X `prod` (project A) and tag H `staging` @@ -97,11 +92,9 @@ export function sortedUnique(values: string[]): string[] { * gate. Identical reconciles in several scopes are the multi-scope union case * and stay allowed. */ -export function contestedReconcileTargetFailures( - scopes: Array<{ tagPlan: TagImportPlan; workflows: ReferencingWorkflow[] }>, -): TagResolutionFailure[] { +export function contestedReconcileTargetFailures(plans: TagImportPlan[]): TagResolutionFailure[] { const reconcilesByKey = new Map(); - for (const { tagPlan } of scopes) { + for (const tagPlan of plans) { for (const reconcile of tagPlan.reconciles) { reconcilesByKey.set(JSON.stringify([reconcile.id, reconcile.oldId]), reconcile); } @@ -110,7 +103,7 @@ export function contestedReconcileTargetFailures( const reconciles = [...reconcilesByKey.values()]; const claimedTargetIds = new Set( - scopes.flatMap(({ tagPlan }) => [ + plans.flatMap((tagPlan) => [ ...tagPlan.matched.map(({ id }) => id), ...tagPlan.renames.map(({ id }) => id), ]), @@ -120,13 +113,6 @@ export function contestedReconcileTargetFailures( oldIdCounts.set(oldId, (oldIdCounts.get(oldId) ?? 0) + 1); } - const sourceWorkflowIdsReferencing = (tagId: string) => - scopes.flatMap(({ workflows }) => - workflows - .filter(({ tagIds }) => tagIds?.includes(tagId)) - .map(({ sourceWorkflowId }) => sourceWorkflowId), - ); - return reconciles .filter(({ oldId }) => claimedTargetIds.has(oldId) || (oldIdCounts.get(oldId) ?? 0) > 1) .map(({ id, name, oldId }) => ({ @@ -134,6 +120,5 @@ export function contestedReconcileTargetFailures( sourceId: id, name, existingTagId: oldId, - usedByWorkflows: sortedUnique(sourceWorkflowIdsReferencing(id)), })); } diff --git a/packages/cli/src/modules/n8n-packages/entities/variable/__tests__/variable-importer.test.ts b/packages/cli/src/modules/n8n-packages/entities/variable/__tests__/variable-importer.test.ts index 61cc638e5ba..caabea019bf 100644 --- a/packages/cli/src/modules/n8n-packages/entities/variable/__tests__/variable-importer.test.ts +++ b/packages/cli/src/modules/n8n-packages/entities/variable/__tests__/variable-importer.test.ts @@ -47,8 +47,8 @@ afterEach(() => { describe('VariableImporter.apply', () => { const projectCreationPlan: VariableImportPlan = { matched: [], - missing: [{ name: 'API_KEY', usedByWorkflows: ['wf-1'] }], - creations: [{ name: 'API_KEY', projectId: 'proj-target', usedByWorkflows: ['wf-1'] }], + missing: [{ name: 'API_KEY' }], + creations: [{ name: 'API_KEY', projectId: 'proj-target' }], conflicts: [], overwrites: [], }; diff --git a/packages/cli/src/modules/n8n-packages/entities/variable/__tests__/variable.exporter.test.ts b/packages/cli/src/modules/n8n-packages/entities/variable/__tests__/variable.exporter.test.ts index a8aeefe53d2..6eb4d6b235d 100644 --- a/packages/cli/src/modules/n8n-packages/entities/variable/__tests__/variable.exporter.test.ts +++ b/packages/cli/src/modules/n8n-packages/entities/variable/__tests__/variable.exporter.test.ts @@ -109,7 +109,9 @@ describe('VariableExporter', () => { expect(result.entries).toEqual([ { id: 'var_url', name: 'API_URL', target: 'variables/apiurl-var_url' }, ]); - expect(result.requirements).toEqual([{ name: 'API_URL', usedByWorkflows: ['wf-1'] }]); + expect(result.requirements).toEqual([ + { name: 'API_URL', usedBy: [{ kind: 'workflow', id: 'wf-1' }] }, + ]); expect(writer.directories).toEqual(['variables/apiurl-var_url']); expect(writer.files).toHaveLength(1); expect(writer.files[0].path).toBe('variables/apiurl-var_url/variable.json'); @@ -168,7 +170,9 @@ describe('VariableExporter', () => { expect(result.entries).toEqual([ { id: 'var_p', name: 'API_URL', target: 'variables/apiurl-var_p' }, ]); - expect(result.requirements).toEqual([{ name: 'API_URL', usedByWorkflows: ['wf-1'] }]); + expect(result.requirements).toEqual([ + { name: 'API_URL', usedBy: [{ kind: 'workflow', id: 'wf-1' }] }, + ]); }); it('bundles a global referenced from a member personal-project workflow', async () => { @@ -190,7 +194,9 @@ describe('VariableExporter', () => { expect(result.entries).toEqual([ { id: 'var_g', name: 'API_URL', target: 'variables/apiurl-var_g' }, ]); - expect(result.requirements).toEqual([{ name: 'API_URL', usedByWorkflows: ['wf-1'] }]); + expect(result.requirements).toEqual([ + { name: 'API_URL', usedBy: [{ kind: 'workflow', id: 'wf-1' }] }, + ]); }); }); @@ -218,7 +224,9 @@ describe('VariableExporter', () => { expect(result.entries).toEqual([ { id: 'var_g', name: 'API_URL', target: 'variables/apiurl-var_g' }, ]); - expect(result.requirements).toEqual([{ name: 'API_URL', usedByWorkflows: ['wf-1'] }]); + expect(result.requirements).toEqual([ + { name: 'API_URL', usedBy: [{ kind: 'workflow', id: 'wf-1' }] }, + ]); }); }); @@ -244,7 +252,9 @@ describe('VariableExporter', () => { }); expect(result.entries).toEqual([]); - expect(result.requirements).toEqual([{ name: 'API_URL', usedByWorkflows: ['wf-1'] }]); + expect(result.requirements).toEqual([ + { name: 'API_URL', usedBy: [{ kind: 'workflow', id: 'wf-1' }] }, + ]); expect(writer.files).toEqual([]); }); @@ -274,7 +284,15 @@ describe('VariableExporter', () => { expect(result.entries).toEqual([ { id: 'var_shared', name: 'API_URL', target: 'variables/apiurl-var_shared' }, ]); - expect(result.requirements).toEqual([{ name: 'API_URL', usedByWorkflows: ['wf-a', 'wf-b'] }]); + expect(result.requirements).toEqual([ + { + name: 'API_URL', + usedBy: [ + { kind: 'workflow', id: 'wf-a' }, + { kind: 'workflow', id: 'wf-b' }, + ], + }, + ]); }); }); @@ -298,7 +316,9 @@ describe('VariableExporter', () => { expect(result.entries).toEqual([ { id: 'var_url', name: 'API_URL', target: 'variables/apiurl-var_url' }, ]); - expect(result.requirements).toEqual([{ name: 'API_URL', usedByWorkflows: ['wf-1'] }]); + expect(result.requirements).toEqual([ + { name: 'API_URL', usedBy: [{ kind: 'workflow', id: 'wf-1' }] }, + ]); expect(writer.files).toHaveLength(1); expect(writer.files[0].path).toBe('variables/apiurl-var_url/variable.json'); expect(jsonParse(writer.files[0].content)).toEqual({ name: 'API_URL', type: 'string' }); @@ -306,7 +326,7 @@ describe('VariableExporter', () => { }); describe('requirement grouping', () => { - it('collapses duplicate (workflow, name) requirements into a single usedByWorkflows entry', async () => { + it('collapses duplicate (workflow, name) requirements into a single workflow consumer', async () => { const deps = makeExporter(); const variable = makeVariable({ id: 'var_url' }); wireVariables(deps, { @@ -322,7 +342,9 @@ describe('VariableExporter', () => { includeVariableValues: true, }); - expect(result.requirements).toEqual([{ name: 'API_URL', usedByWorkflows: ['wf-1'] }]); + expect(result.requirements).toEqual([ + { name: 'API_URL', usedBy: [{ kind: 'workflow', id: 'wf-1' }] }, + ]); expect(result.entries).toHaveLength(1); expect(writer.files).toHaveLength(1); }); @@ -382,7 +404,13 @@ describe('VariableExporter', () => { 'variables/apiurl-var_g', ]); expect(result.requirements).toEqual([ - { name: 'API_URL', usedByWorkflows: ['wf-global', 'wf-scoped'] }, + { + name: 'API_URL', + usedBy: [ + { kind: 'workflow', id: 'wf-global' }, + { kind: 'workflow', id: 'wf-scoped' }, + ], + }, ]); }); }); diff --git a/packages/cli/src/modules/n8n-packages/entities/variable/variable-importer.ts b/packages/cli/src/modules/n8n-packages/entities/variable/variable-importer.ts index 23697a6c4e3..2e5f609131f 100644 --- a/packages/cli/src/modules/n8n-packages/entities/variable/variable-importer.ts +++ b/packages/cli/src/modules/n8n-packages/entities/variable/variable-importer.ts @@ -1,5 +1,6 @@ import { Service } from '@n8n/di'; import { hasGlobalScope } from '@n8n/permissions'; +import uniqBy from 'lodash/uniqBy'; import { pickVariableForProject } from 'n8n-workflow'; import { VariablesService } from '@/environments.ee/variables/variables.service.ee'; @@ -12,12 +13,7 @@ import { variableMissingModeCreates, variableMissingModeUsesPackageValue, } from './variable-missing-mode'; -import { - computeVariableLimitFailure, - createFailure, - dedupeCreationsByDestination, - destinationKey, -} from './variable.types'; +import { computeVariableLimitFailure, destinationKey } from './variable.types'; import type { VariableApplyResult, VariableConflict, @@ -76,27 +72,24 @@ export class VariableImporter { } const scope = picked.project ? { projectId: picked.project.id } : {}; - const usedByWorkflows = [...new Set(requirement.usedByWorkflows)].sort(); - conflicts.push({ name: requirement.name, ...scope, usedByWorkflows }); + conflicts.push({ name: requirement.name, ...scope }); if (overwritesConflicts) { overwrites.push({ variableId: picked.id, name: requirement.name, ...scope, value: packageValue, - usedByWorkflows, }); } continue; } - missing.push(createFailure(requirement)); + missing.push({ name: requirement.name }); if (createsMissing) { const value = usesPackageValue ? requirement.packageValue : undefined; creations.push({ name: requirement.name, ...(requirement.globalPlacement ? {} : { projectId: context.projectId }), ...(value !== undefined ? { value } : {}), - usedByWorkflows: [...new Set(requirement.usedByWorkflows)].sort(), }); } } @@ -109,7 +102,7 @@ export class VariableImporter { if (creations.length === 0) return undefined; return computeVariableLimitFailure( - dedupeCreationsByDestination(creations), + uniqBy(creations, destinationKey), await this.variablesService.getRemainingVariableQuota(), ); } diff --git a/packages/cli/src/modules/n8n-packages/entities/variable/variable.exporter.ts b/packages/cli/src/modules/n8n-packages/entities/variable/variable.exporter.ts index 6f7b6c21010..363d48f5a90 100644 --- a/packages/cli/src/modules/n8n-packages/entities/variable/variable.exporter.ts +++ b/packages/cli/src/modules/n8n-packages/entities/variable/variable.exporter.ts @@ -89,7 +89,10 @@ export class VariableExporter { ); } - requirements.push({ name, usedByWorkflows }); + requirements.push({ + name, + usedBy: usedByWorkflows.map((id) => ({ kind: 'workflow', id })), + }); } return { entries, requirements }; diff --git a/packages/cli/src/modules/n8n-packages/entities/variable/variable.types.ts b/packages/cli/src/modules/n8n-packages/entities/variable/variable.types.ts index 6731656378e..c3e0afced15 100644 --- a/packages/cli/src/modules/n8n-packages/entities/variable/variable.types.ts +++ b/packages/cli/src/modules/n8n-packages/entities/variable/variable.types.ts @@ -37,20 +37,17 @@ export interface VariableImportRequest { export interface VariableResolutionFailure { name: string; - usedByWorkflows: string[]; } export interface VariableCreation { name: string; projectId?: string; value?: string; - usedByWorkflows: string[]; } export interface VariableConflict { name: string; projectId?: string; - usedByWorkflows: string[]; } export interface VariableOverwrite { @@ -58,7 +55,6 @@ export interface VariableOverwrite { name: string; projectId?: string; value: string; - usedByWorkflows: string[]; } export interface VariableLimitFailure { @@ -66,14 +62,6 @@ export interface VariableLimitFailure { remaining: number; requested: number; names: string[]; - usedByWorkflows: string[]; -} - -export function createFailure(requirement: PackageVariableRequirement): VariableResolutionFailure { - return { - name: requirement.name, - usedByWorkflows: [...new Set(requirement.usedByWorkflows)].sort(), - }; } export function destinationKey(destination: { name: string; projectId?: string }): string { @@ -82,28 +70,11 @@ export function destinationKey(destination: { name: string; projectId?: string } : `global:${destination.name}`; } -export function dedupeCreationsByDestination(creations: VariableCreation[]): VariableCreation[] { - const byDestination = new Map(); - for (const creation of creations) { - const key = destinationKey(creation); - const existing = byDestination.get(key); - if (!existing) { - // Copied because the caller goes on to apply these creations. - byDestination.set(key, { ...creation, usedByWorkflows: [...creation.usedByWorkflows] }); - continue; - } - existing.usedByWorkflows = [ - ...new Set([...existing.usedByWorkflows, ...creation.usedByWorkflows]), - ].sort(); - } - return [...byDestination.values()]; -} - /** * Scopes resolve independently, so two can land on one row — a global neither shadows — and disagree * about its value, where the last write would silently win. */ -export function divergentOverwrites(overwrites: VariableOverwrite[]): VariableConflict[] { +export function divergentOverwrites(overwrites: VariableOverwrite[]): VariableOverwrite[] { const firstValueByRow = new Map(); const divergent = new Set(); for (const { variableId, value } of overwrites) { @@ -112,9 +83,7 @@ export function divergentOverwrites(overwrites: VariableOverwrite[]): VariableCo else if (seen !== value) divergent.add(variableId); } - return overwrites - .filter(({ variableId }) => divergent.has(variableId)) - .map(({ variableId, value, ...conflict }) => conflict); + return overwrites.filter(({ variableId }) => divergent.has(variableId)); } /** Reports the planned creations that do not fit the remaining quota. `quota` of `null` means unlimited. */ @@ -129,7 +98,6 @@ export function computeVariableLimitFailure( remaining: quota.remaining, requested: creations.length, names: [...new Set(creations.map((creation) => creation.name))].sort(), - usedByWorkflows: [...new Set(creations.flatMap((creation) => creation.usedByWorkflows))].sort(), }; } diff --git a/packages/cli/src/modules/n8n-packages/entities/workflow/__tests__/missing-node-type-mode.test.ts b/packages/cli/src/modules/n8n-packages/entities/workflow/__tests__/missing-node-type-mode.test.ts index 496f8f648fe..0ab1ea54fe8 100644 --- a/packages/cli/src/modules/n8n-packages/entities/workflow/__tests__/missing-node-type-mode.test.ts +++ b/packages/cli/src/modules/n8n-packages/entities/workflow/__tests__/missing-node-type-mode.test.ts @@ -1,3 +1,4 @@ +import type { MissingNodeTypeRequirement } from '../missing-node-type-mode'; import type { WorkflowEntity } from '@n8n/db'; import type { INode } from 'n8n-workflow'; @@ -56,7 +57,11 @@ describe('collectMissingNodeTypes', () => { const workflows = [prepared('wf-1', [{ type: 'n8n-nodes-base.unknown', typeVersion: 2 }])]; expect(collectMissingNodeTypes(workflows, getSupportedVersions)).toEqual([ - { type: 'n8n-nodes-base.unknown', typeVersion: 2, usedByWorkflows: ['wf-1'] }, + { + type: 'n8n-nodes-base.unknown', + typeVersion: 2, + usedBy: [{ kind: 'workflow', id: 'wf-1' }], + }, ]); }); @@ -64,7 +69,7 @@ describe('collectMissingNodeTypes', () => { const workflows = [prepared('wf-1', [{ type: 'n8n-nodes-base.set', typeVersion: 4 }])]; expect(collectMissingNodeTypes(workflows, getSupportedVersions)).toEqual([ - { type: 'n8n-nodes-base.set', typeVersion: 4, usedByWorkflows: ['wf-1'] }, + { type: 'n8n-nodes-base.set', typeVersion: 4, usedBy: [{ kind: 'workflow', id: 'wf-1' }] }, ]); }); @@ -82,8 +87,19 @@ describe('collectMissingNodeTypes', () => { ]; expect(collectMissingNodeTypes(workflows, getSupportedVersions)).toEqual([ - { type: 'n8n-nodes-base.unknown', typeVersion: 1, usedByWorkflows: ['wf-1', 'wf-2'] }, - { type: 'n8n-nodes-base.unknown', typeVersion: 2, usedByWorkflows: ['wf-2'] }, + { + type: 'n8n-nodes-base.unknown', + typeVersion: 1, + usedBy: [ + { kind: 'workflow', id: 'wf-1' }, + { kind: 'workflow', id: 'wf-2' }, + ], + }, + { + type: 'n8n-nodes-base.unknown', + typeVersion: 2, + usedBy: [{ kind: 'workflow', id: 'wf-2' }], + }, ]); }); @@ -95,13 +111,22 @@ describe('collectMissingNodeTypes', () => { ]; expect(collectMissingNodeTypes(workflows, getSupportedVersions)).toEqual([ - { type: 'n8n-nodes-base.unknown', typeVersion: 1, usedByWorkflows: ['wf-1', 'wf-2'] }, + { + type: 'n8n-nodes-base.unknown', + typeVersion: 1, + usedBy: [ + { kind: 'workflow', id: 'wf-1' }, + { kind: 'workflow', id: 'wf-2' }, + ], + }, ]); }); }); describe('missingNodeTypeBlockingFailures', () => { - const missing = [{ type: 'n8n-nodes-base.unknown', typeVersion: 1, usedByWorkflows: ['wf-1'] }]; + const missing: MissingNodeTypeRequirement[] = [ + { type: 'n8n-nodes-base.unknown', typeVersion: 1, usedBy: [{ kind: 'workflow', id: 'wf-1' }] }, + ]; it('fail treats every missing pair as blocking', () => { expect(missingNodeTypeBlockingFailures('fail', missing)).toEqual(missing); @@ -115,8 +140,22 @@ describe('missingNodeTypeBlockingFailures', () => { describe('workflowsWithMissingNodeTypes', () => { it('unions the workflow ids across every missing pair', () => { const blocked = workflowsWithMissingNodeTypes([ - { type: 'n8n-nodes-base.unknown', typeVersion: 1, usedByWorkflows: ['wf-1', 'wf-2'] }, - { type: 'n8n-nodes-base.other', typeVersion: 2, usedByWorkflows: ['wf-2', 'wf-3'] }, + { + type: 'n8n-nodes-base.unknown', + typeVersion: 1, + usedBy: [ + { kind: 'workflow', id: 'wf-1' }, + { kind: 'workflow', id: 'wf-2' }, + ], + }, + { + type: 'n8n-nodes-base.other', + typeVersion: 2, + usedBy: [ + { kind: 'workflow', id: 'wf-2' }, + { kind: 'workflow', id: 'wf-3' }, + ], + }, ]); expect(blocked).toEqual(new Set(['wf-1', 'wf-2', 'wf-3'])); diff --git a/packages/cli/src/modules/n8n-packages/entities/workflow/__tests__/sub-workflow-ordering.test.ts b/packages/cli/src/modules/n8n-packages/entities/workflow/__tests__/sub-workflow-ordering.test.ts index 132e10c3f73..738de7e631d 100644 --- a/packages/cli/src/modules/n8n-packages/entities/workflow/__tests__/sub-workflow-ordering.test.ts +++ b/packages/cli/src/modules/n8n-packages/entities/workflow/__tests__/sub-workflow-ordering.test.ts @@ -8,7 +8,7 @@ const wf = (sourceWorkflowId: string) => ({ sourceWorkflowId }); const requiredBy = (id: string, ...parents: string[]): PackageWorkflowRequirement => ({ id, name: id, - usedByWorkflows: parents, + usedBy: parents.map((id) => ({ kind: 'workflow', id })), }); const idsOf = (workflows: Array<{ sourceWorkflowId: string }>) => diff --git a/packages/cli/src/modules/n8n-packages/entities/workflow/__tests__/workflow-publisher.test.ts b/packages/cli/src/modules/n8n-packages/entities/workflow/__tests__/workflow-publisher.test.ts index 78a6dd7bc87..86c9aaa4484 100644 --- a/packages/cli/src/modules/n8n-packages/entities/workflow/__tests__/workflow-publisher.test.ts +++ b/packages/cli/src/modules/n8n-packages/entities/workflow/__tests__/workflow-publisher.test.ts @@ -339,7 +339,9 @@ describe('WorkflowPublisher', () => { user, persisted: [persisted('CHEDDAR'), persisted('BRIE')], policy: WorkflowPublishingPolicy.PublishAll, - subWorkflowRequirements: [{ id: 'BRIE', name: 'BRIE', usedByWorkflows: ['CHEDDAR'] }], + subWorkflowRequirements: [ + { id: 'BRIE', name: 'BRIE', usedBy: [{ kind: 'workflow', id: 'CHEDDAR' }] }, + ], }); expect(activationOrder()).toEqual(['BRIE', 'CHEDDAR']); diff --git a/packages/cli/src/modules/n8n-packages/entities/workflow/__tests__/workflow-requirement.exporter.test.ts b/packages/cli/src/modules/n8n-packages/entities/workflow/__tests__/workflow-requirement.exporter.test.ts index 58be94a1c0e..8df8efa68df 100644 --- a/packages/cli/src/modules/n8n-packages/entities/workflow/__tests__/workflow-requirement.exporter.test.ts +++ b/packages/cli/src/modules/n8n-packages/entities/workflow/__tests__/workflow-requirement.exporter.test.ts @@ -23,7 +23,7 @@ function makeExporter(accessibleWorkflows: Array<{ id: string; name: string }> = } describe('WorkflowRequirementExporter', () => { - it('groups requirements by referenced workflow and aggregates usedByWorkflows', async () => { + it('groups requirements by referenced workflow and aggregates workflow consumers', async () => { const { exporter, workflowFinder } = makeExporter(); const result = await exporter.export({ @@ -46,12 +46,15 @@ describe('WorkflowRequirementExporter', () => { { id: 'wf-child', name: 'Child', - usedByWorkflows: ['wf-parent-a', 'wf-parent-b'], + usedBy: [ + { kind: 'workflow', id: 'wf-parent-a' }, + { kind: 'workflow', id: 'wf-parent-b' }, + ], }, { id: 'wf-grandchild', name: 'Grandchild', - usedByWorkflows: ['wf-child'], + usedBy: [{ kind: 'workflow', id: 'wf-child' }], }, ]); // Every referenced workflow is in the package, so no name lookup is needed. @@ -71,8 +74,8 @@ describe('WorkflowRequirementExporter', () => { }); expect(result.requirements).toEqual([ - { id: 'wf-known', name: 'Known Child', usedByWorkflows: ['wf-parent'] }, - { id: 'wf-unknown', usedByWorkflows: ['wf-parent'] }, + { id: 'wf-known', name: 'Known Child', usedBy: [{ kind: 'workflow', id: 'wf-parent' }] }, + { id: 'wf-unknown', usedBy: [{ kind: 'workflow', id: 'wf-parent' }] }, ]); expect(result.requirements[1]).not.toHaveProperty('name'); expect(workflowFinder.findWorkflowsByIdsForUser).toHaveBeenCalledWith( diff --git a/packages/cli/src/modules/n8n-packages/entities/workflow/missing-node-type-mode.ts b/packages/cli/src/modules/n8n-packages/entities/workflow/missing-node-type-mode.ts index 108b2b2e72a..696b1e7be1b 100644 --- a/packages/cli/src/modules/n8n-packages/entities/workflow/missing-node-type-mode.ts +++ b/packages/cli/src/modules/n8n-packages/entities/workflow/missing-node-type-mode.ts @@ -1,6 +1,7 @@ import { collectNodeTypeUsage, type NodeTypeUsage } from './node-type-usage'; import type { PreparedWorkflow } from './workflow-import.types'; import type { MissingNodeTypeMode } from '../../n8n-packages.types'; +import { getWorkflowConsumerIds } from '../../spec/requirement-consumers'; /** A `(type, typeVersion)` pair the target instance cannot resolve. Field names align with the manifest requirements shape. */ export type MissingNodeTypeRequirement = NodeTypeUsage; @@ -48,5 +49,5 @@ export function missingNodeTypeBlockingFailures( /** Package workflow ids that should not be published because they use missing node types. */ export function workflowsWithMissingNodeTypes(missing: MissingNodeTypeRequirement[]): Set { - return new Set(missing.flatMap(({ usedByWorkflows }) => usedByWorkflows)); + return new Set(missing.flatMap(getWorkflowConsumerIds)); } diff --git a/packages/cli/src/modules/n8n-packages/entities/workflow/node-type-usage.ts b/packages/cli/src/modules/n8n-packages/entities/workflow/node-type-usage.ts index cfd30117e59..9fe9195745f 100644 --- a/packages/cli/src/modules/n8n-packages/entities/workflow/node-type-usage.ts +++ b/packages/cli/src/modules/n8n-packages/entities/workflow/node-type-usage.ts @@ -1,5 +1,7 @@ import type { INode } from 'n8n-workflow'; +import type { PackageNodeTypeRequirement } from '../../spec/requirements.schema'; + /** One workflow's node list, keyed by the id the usage entries should reference. */ export interface WorkflowNodeTypeSource { workflowId: string; @@ -7,15 +9,11 @@ export interface WorkflowNodeTypeSource { } /** A unique `(type, typeVersion)` pair and the workflows that use it. Matches the manifest requirements shape. */ -export interface NodeTypeUsage { - type: string; - typeVersion: number; - usedByWorkflows: string[]; -} +export type NodeTypeUsage = PackageNodeTypeRequirement; /** * Folds every node of the given workflows into unique `(type, typeVersion)` - * pairs with deduped `usedByWorkflows`. Disabled nodes still count — they + * pairs with unique workflow consumers. Disabled nodes still count — they * render on canvas and are part of the content. Pure — no registry lookups. */ export function collectNodeTypeUsage(workflows: WorkflowNodeTypeSource[]): NodeTypeUsage[] { @@ -41,6 +39,6 @@ export function collectNodeTypeUsage(workflows: WorkflowNodeTypeSource[]): NodeT return [...usage.values()].map(({ type, typeVersion, workflowIds }) => ({ type, typeVersion, - usedByWorkflows: [...workflowIds], + usedBy: [...workflowIds].map((id) => ({ kind: 'workflow', id })), })); } diff --git a/packages/cli/src/modules/n8n-packages/entities/workflow/sub-workflow-ordering.ts b/packages/cli/src/modules/n8n-packages/entities/workflow/sub-workflow-ordering.ts index 5bbaecef7b5..d1681b61cdd 100644 --- a/packages/cli/src/modules/n8n-packages/entities/workflow/sub-workflow-ordering.ts +++ b/packages/cli/src/modules/n8n-packages/entities/workflow/sub-workflow-ordering.ts @@ -1,3 +1,4 @@ +import { getWorkflowConsumerIds } from '../../spec/requirement-consumers'; import type { PackageWorkflowRequirement } from '../../spec/requirements.schema'; /** @@ -20,7 +21,7 @@ export function orderBySubWorkflowDependencies { + ([referencedWorkflowId, usedByWorkflows]): PackageWorkflowRequirement => { const name = workflowsById.get(referencedWorkflowId)?.name ?? missingWorkflowNamesById.get(referencedWorkflowId); @@ -52,7 +52,7 @@ export class WorkflowRequirementExporter { return { id: referencedWorkflowId, ...(name ? { name } : {}), - usedByWorkflows, + usedBy: usedByWorkflows.map((id) => ({ kind: 'workflow', id })), }; }, ); diff --git a/packages/cli/src/modules/n8n-packages/n8n-packages.types.ts b/packages/cli/src/modules/n8n-packages/n8n-packages.types.ts index a6f23f4f545..065a614c5d1 100644 --- a/packages/cli/src/modules/n8n-packages/n8n-packages.types.ts +++ b/packages/cli/src/modules/n8n-packages/n8n-packages.types.ts @@ -23,6 +23,7 @@ import type { WorkflowPublishingPolicy, } from './entities/workflow/workflow-publishing-policy.types'; import type { PackageManifest } from './spec/manifest.schema'; +import type { PackageRequirementConsumer } from './spec/requirements.schema'; export type { CredentialResolution } from './entities/credential/credential.types'; export { WorkflowPublishingPolicy } from './entities/workflow/workflow-publishing-policy.types'; @@ -557,24 +558,33 @@ export type BlockingIssue = expectedType?: string; /** For `type_mismatch`: the actual type of the resolved target credential. */ actualType?: string; - usedByWorkflows: string[]; + usedBy: PackageRequirementConsumer[]; } | ({ type: 'project-conflict' } & ProjectConflict) | ({ type: 'folder-conflict' } & FolderConflict) | ({ type: 'workflow-removal-forbidden' } & WorkflowRemovalFailure) | ({ type: 'workflow-removal-conflict' } & WorkflowRemovalConflict) | ({ type: 'folder-removal-forbidden' } & FolderRemovalFailure) - | ({ type: 'data-table-unresolved' } & DataTableResolutionFailure) - | ({ type: 'tag-unresolved' } & TagResolutionFailure) - | ({ type: 'variable-unresolved' } & VariableResolutionFailure) - | ({ type: 'variable-conflict' } & VariableConflict) - | ({ type: 'variable-limit-exceeded' } & VariableLimitFailure) + | ({ + type: 'data-table-unresolved'; + usedBy: PackageRequirementConsumer[]; + } & DataTableResolutionFailure) + | ({ type: 'tag-unresolved'; usedBy: PackageRequirementConsumer[] } & TagResolutionFailure) + | ({ + type: 'variable-unresolved'; + usedBy: PackageRequirementConsumer[]; + } & VariableResolutionFailure) + | ({ type: 'variable-conflict'; usedBy: PackageRequirementConsumer[] } & VariableConflict) + | ({ + type: 'variable-limit-exceeded'; + usedBy: PackageRequirementConsumer[]; + } & VariableLimitFailure) | { type: 'missing-node-type'; /** Node type this instance cannot resolve (at least not at `typeVersion`). */ nodeType: string; typeVersion: number; - usedByWorkflows: string[]; + usedBy: PackageRequirementConsumer[]; } | { type: 'policy-violation'; diff --git a/packages/cli/src/modules/n8n-packages/spec/__tests__/manifest.schema.test.ts b/packages/cli/src/modules/n8n-packages/spec/__tests__/manifest.schema.test.ts index b0b940f67eb..eca98460cd1 100644 --- a/packages/cli/src/modules/n8n-packages/spec/__tests__/manifest.schema.test.ts +++ b/packages/cli/src/modules/n8n-packages/spec/__tests__/manifest.schema.test.ts @@ -55,8 +55,18 @@ describe('packageManifestSchema', () => { ...validManifest, requirements: { credentials: [ - { id: 'cred-1', name: 'A', type: 'githubApi', usedByWorkflows: ['wf-abc'] }, - { id: 'cred-1', name: 'B', type: 'slackApi', usedByWorkflows: ['wf-abc'] }, + { + id: 'cred-1', + name: 'A', + type: 'githubApi', + usedBy: [{ kind: 'workflow', id: 'wf-abc' }], + }, + { + id: 'cred-1', + name: 'B', + type: 'slackApi', + usedBy: [{ kind: 'workflow', id: 'wf-abc' }], + }, ], }, }; @@ -69,8 +79,8 @@ describe('packageManifestSchema', () => { ...validManifest, requirements: { dataTables: [ - { id: 'dt-1', name: 'A', usedByWorkflows: ['wf-abc'] }, - { id: 'dt-1', name: 'B', usedByWorkflows: ['wf-abc'] }, + { id: 'dt-1', name: 'A', usedBy: [{ kind: 'workflow', id: 'wf-abc' }] }, + { id: 'dt-1', name: 'B', usedBy: [{ kind: 'workflow', id: 'wf-abc' }] }, ], }, }; @@ -82,7 +92,9 @@ describe('packageManifestSchema', () => { const manifest = { ...validManifest, requirements: { - workflows: [{ id: 'wf-child', name: 'Child workflow', usedByWorkflows: ['wf-abc'] }], + workflows: [ + { id: 'wf-child', name: 'Child workflow', usedBy: [{ kind: 'workflow', id: 'wf-abc' }] }, + ], }, }; @@ -96,8 +108,8 @@ describe('packageManifestSchema', () => { ...validManifest, requirements: { workflows: [ - { id: 'wf-child', name: 'Child A', usedByWorkflows: ['wf-abc'] }, - { id: 'wf-child', name: 'Child B', usedByWorkflows: ['wf-abc'] }, + { id: 'wf-child', name: 'Child A', usedBy: [{ kind: 'workflow', id: 'wf-abc' }] }, + { id: 'wf-child', name: 'Child B', usedBy: [{ kind: 'workflow', id: 'wf-abc' }] }, ], }, }; @@ -143,8 +155,8 @@ describe('packageManifestSchema', () => { ...validManifest, requirements: { variables: [ - { name: 'API_URL', usedByWorkflows: ['wf-abc'] }, - { name: 'API_URL', usedByWorkflows: ['wf-abc'] }, + { name: 'API_URL', usedBy: [{ kind: 'workflow', id: 'wf-abc' }] }, + { name: 'API_URL', usedBy: [{ kind: 'workflow', id: 'wf-abc' }] }, ], }, }; diff --git a/packages/cli/src/modules/n8n-packages/spec/__tests__/requirements.schema.test.ts b/packages/cli/src/modules/n8n-packages/spec/__tests__/requirements.schema.test.ts index 79c87fab106..e17b9bd3592 100644 --- a/packages/cli/src/modules/n8n-packages/spec/__tests__/requirements.schema.test.ts +++ b/packages/cli/src/modules/n8n-packages/spec/__tests__/requirements.schema.test.ts @@ -5,13 +5,13 @@ describe('packageRequirementsSchema', () => { id, name: 'My Credential', type: 'httpBasicAuth', - usedByWorkflows: ['wf-1'], + usedBy: [{ kind: 'workflow', id: 'wf-1' }], }); - const variable = (name: string) => ({ name, usedByWorkflows: ['wf-1'] }); + const variable = (name: string) => ({ name, usedBy: [{ kind: 'workflow', id: 'wf-1' }] }); const nodeType = (type: string, typeVersion: number) => ({ type, typeVersion, - usedByWorkflows: ['wf-1'], + usedBy: [{ kind: 'workflow', id: 'wf-1' }], }); it('accepts distinct entries per section', () => { @@ -25,13 +25,17 @@ describe('packageRequirementsSchema', () => { }); it('accepts a workflow requirement without a name', () => { - const requirements = { workflows: [{ id: 'wf-dep', usedByWorkflows: ['wf-1'] }] }; + const requirements = { + workflows: [{ id: 'wf-dep', usedBy: [{ kind: 'workflow', id: 'wf-1' }] }], + }; expect(() => packageRequirementsSchema.parse(requirements)).not.toThrow(); }); it('rejects a workflow requirement with an empty name', () => { - const requirements = { workflows: [{ id: 'wf-dep', name: '', usedByWorkflows: ['wf-1'] }] }; + const requirements = { + workflows: [{ id: 'wf-dep', name: '', usedBy: [{ kind: 'workflow', id: 'wf-1' }] }], + }; expect(() => packageRequirementsSchema.parse(requirements)).toThrow(); }); @@ -45,7 +49,11 @@ describe('packageRequirementsSchema', () => { }); it('rejects duplicate tag ids', () => { - const tag = (id: string) => ({ id, name: 'production', usedByWorkflows: ['wf-1'] }); + const tag = (id: string) => ({ + id, + name: 'production', + usedBy: [{ kind: 'workflow', id: 'wf-1' }], + }); const requirements = { tags: [tag('tag-1'), tag('tag-1')] }; expect(() => packageRequirementsSchema.parse(requirements)).toThrow(/Duplicate tag id: tag-1/); @@ -74,4 +82,17 @@ describe('packageRequirementsSchema', () => { expect(() => packageRequirementsSchema.parse(requirements)).toThrow(); }); + + it.each([ + {}, + { usedBy: [] }, + { usedBy: [{ kind: 'project', id: 'consumer' }] }, + { usedBy: [{ kind: 'workflow', id: '' }] }, + { usedBy: ['wf-1'] }, + { usedByWorkflows: ['wf-1'] }, + ])('rejects invalid or legacy consumer references: %j', (usage) => { + expect(() => + packageRequirementsSchema.parse({ variables: [{ name: 'REGION', ...usage }] }), + ).toThrow(); + }); }); diff --git a/packages/cli/src/modules/n8n-packages/spec/requirement-consumers.ts b/packages/cli/src/modules/n8n-packages/spec/requirement-consumers.ts new file mode 100644 index 00000000000..76b874419d8 --- /dev/null +++ b/packages/cli/src/modules/n8n-packages/spec/requirement-consumers.ts @@ -0,0 +1,19 @@ +import sortBy from 'lodash/sortBy'; +import uniqBy from 'lodash/uniqBy'; + +import type { PackageRequirementConsumer } from './requirements.schema'; + +export function getWorkflowConsumerIds(requirement: { + usedBy: PackageRequirementConsumer[]; +}): string[] { + return requirement.usedBy.map(({ id }) => id); +} + +export function mergeRequirementConsumers( + consumers: PackageRequirementConsumer[], +): PackageRequirementConsumer[] { + return sortBy( + uniqBy(consumers, ({ kind, id }) => `${kind}:${id}`), + ['kind', 'id'], + ); +} diff --git a/packages/cli/src/modules/n8n-packages/spec/requirements.schema.ts b/packages/cli/src/modules/n8n-packages/spec/requirements.schema.ts index 5d6e7327994..7e67593214a 100644 --- a/packages/cli/src/modules/n8n-packages/spec/requirements.schema.ts +++ b/packages/cli/src/modules/n8n-packages/spec/requirements.schema.ts @@ -1,16 +1,21 @@ +import { packageRequirementConsumerSchema } from '@n8n/api-types'; import { z } from 'zod'; +const requirementUsage = { + usedBy: z.array(packageRequirementConsumerSchema).min(1), +}; + export const packageCredentialRequirementSchema = z.object({ id: z.string().min(1), name: z.string().min(1), type: z.string().min(1), - usedByWorkflows: z.array(z.string().min(1)).min(1), + ...requirementUsage, }); export const packageDataTableRequirementSchema = z.object({ id: z.string().min(1), name: z.string().min(1), - usedByWorkflows: z.array(z.string().min(1)).min(1), + ...requirementUsage, }); // `name` is best-effort: a `reference-only` export lists workflows that are @@ -19,13 +24,13 @@ export const packageDataTableRequirementSchema = z.object({ export const packageWorkflowRequirementSchema = z.object({ id: z.string().min(1), name: z.string().min(1).optional(), - usedByWorkflows: z.array(z.string().min(1)).min(1), + ...requirementUsage, }); export const packageTagRequirementSchema = z.object({ id: z.string().min(1), name: z.string().min(1), - usedByWorkflows: z.array(z.string().min(1)).min(1), + ...requirementUsage, }); // Node types used by the packaged workflows, folded into unique @@ -35,7 +40,7 @@ export const packageNodeTypeRequirementSchema = z.object({ type: z.string().min(1), // `finite()`: JSON like `1e999` parses to Infinity (mirrors the workflow node schema). typeVersion: z.number().finite(), - usedByWorkflows: z.array(z.string().min(1)).min(1), + ...requirementUsage, }); // Variables are keyed by name, not id: a `$vars.` reference resolves @@ -45,7 +50,7 @@ export const packageNodeTypeRequirementSchema = z.object({ // any value the package carries travels in the bundled variable file. export const packageVariableRequirementSchema = z.object({ name: z.string().min(1), - usedByWorkflows: z.array(z.string().min(1)).min(1), + ...requirementUsage, }); function assertNoDuplicateKey( @@ -110,6 +115,7 @@ export const packageRequirementsSchema = z.object({ ), }); +export type PackageRequirementConsumer = z.infer; export type PackageCredentialRequirement = z.infer; export type PackageDataTableRequirement = z.infer; export type PackageWorkflowRequirement = z.infer; diff --git a/packages/cli/src/modules/promotions.ee/__tests__/import-manifest-bridge.test.ts b/packages/cli/src/modules/promotions.ee/__tests__/import-manifest-bridge.test.ts index 6cf9ac037fc..cc0328a68ac 100644 --- a/packages/cli/src/modules/promotions.ee/__tests__/import-manifest-bridge.test.ts +++ b/packages/cli/src/modules/promotions.ee/__tests__/import-manifest-bridge.test.ts @@ -101,13 +101,20 @@ describe('import-manifest-bridge', () => { ]); }); - it('unions usedByWorkflows when leftover and staging share a requirement key', async () => { + it('unions workflow consumers when leftover and staging share a requirement key', async () => { await writeTree({ 'manifest.json': leftoverManifest({ requirements: { tags: [ - { id: 't-shared', name: 'prod', usedByWorkflows: ['w1', 'w2'] }, - { id: 't-w1-only', name: 'draft', usedByWorkflows: ['w1'] }, + { + id: 't-shared', + name: 'prod', + usedBy: [ + { kind: 'workflow', id: 'w1' }, + { kind: 'workflow', id: 'w2' }, + ], + }, + { id: 't-w1-only', name: 'draft', usedBy: [{ kind: 'workflow', id: 'w1' }] }, ], }, }), @@ -123,7 +130,7 @@ describe('import-manifest-bridge', () => { sourceN8nVersion: '1.0.0', sourceId: 'inst-1', requirements: { - tags: [{ id: 't-shared', name: 'production', usedByWorkflows: ['w2'] }], + tags: [{ id: 't-shared', name: 'production', usedBy: [{ kind: 'workflow', id: 'w2' }] }], }, }), sourceId: 'inst-test', @@ -133,8 +140,15 @@ describe('import-manifest-bridge', () => { JSON.parse(await readFile(path.join(exportFolder, 'manifest.json'), 'utf-8')), ); expect(written.requirements?.tags).toEqual([ - { id: 't-shared', name: 'production', usedByWorkflows: ['w1', 'w2'] }, - { id: 't-w1-only', name: 'draft', usedByWorkflows: ['w1'] }, + { + id: 't-shared', + name: 'production', + usedBy: [ + { kind: 'workflow', id: 'w1' }, + { kind: 'workflow', id: 'w2' }, + ], + }, + { id: 't-w1-only', name: 'draft', usedBy: [{ kind: 'workflow', id: 'w1' }] }, ]); }); @@ -142,7 +156,7 @@ describe('import-manifest-bridge', () => { await writeTree({ 'manifest.json': leftoverManifest({ requirements: { - tags: [{ id: 't-dropped', name: 'prod', usedByWorkflows: ['w2'] }], + tags: [{ id: 't-dropped', name: 'prod', usedBy: [{ kind: 'workflow', id: 'w2' }] }], }, }), 'projects/alpha/workflows/w2/workflow.json': JSON.stringify({ id: 'w2', name: 'W2' }), diff --git a/packages/cli/src/modules/promotions.ee/__tests__/promotion-change.service.test.ts b/packages/cli/src/modules/promotions.ee/__tests__/promotion-change.service.test.ts index 8c7021ecc68..66bed429f61 100644 --- a/packages/cli/src/modules/promotions.ee/__tests__/promotion-change.service.test.ts +++ b/packages/cli/src/modules/promotions.ee/__tests__/promotion-change.service.test.ts @@ -27,8 +27,14 @@ const manifest: PackageManifest = { ], requirements: { variables: [ - { name: 'API_URL', usedByWorkflows: ['wfA', 'wfB'] }, - { name: 'TOKEN', usedByWorkflows: ['wfB'] }, + { + name: 'API_URL', + usedBy: [ + { kind: 'workflow', id: 'wfA' }, + { kind: 'workflow', id: 'wfB' }, + ], + }, + { name: 'TOKEN', usedBy: [{ kind: 'workflow', id: 'wfB' }] }, ], }, }; @@ -39,7 +45,9 @@ describe('scopeManifestToProject', () => { expect(ids(scoped.workflows)).toEqual(['wfA']); expect(ids(scoped.variables)).toEqual(['varA', 'varT']); - expect(scoped.requirements?.variables).toEqual([{ name: 'API_URL', usedByWorkflows: ['wfA'] }]); + expect(scoped.requirements?.variables).toEqual([ + { name: 'API_URL', usedBy: [{ kind: 'workflow', id: 'wfA' }] }, + ]); }); it('resolves a shared name to each project regardless of the entry order', () => { diff --git a/packages/cli/src/modules/promotions.ee/__tests__/working-copy-updater.test.ts b/packages/cli/src/modules/promotions.ee/__tests__/working-copy-updater.test.ts index a294241f9fb..355e227dd71 100644 --- a/packages/cli/src/modules/promotions.ee/__tests__/working-copy-updater.test.ts +++ b/packages/cli/src/modules/promotions.ee/__tests__/working-copy-updater.test.ts @@ -560,7 +560,7 @@ describe('WorkingCopyUpdater', () => { projects: [alpha], workflows: [wf('w1')], requirements: { - tags: [{ id: 't-old', name: 'prod', usedByWorkflows: ['w1'] }], + tags: [{ id: 't-old', name: 'prod', usedBy: [{ kind: 'workflow', id: 'w1' }] }], }, }), files: { diff --git a/packages/cli/src/modules/promotions.ee/import-manifest-bridge.ts b/packages/cli/src/modules/promotions.ee/import-manifest-bridge.ts index 1933daab743..ce9f27e709a 100644 --- a/packages/cli/src/modules/promotions.ee/import-manifest-bridge.ts +++ b/packages/cli/src/modules/promotions.ee/import-manifest-bridge.ts @@ -6,7 +6,11 @@ import { N8N_VERSION } from '@/constants'; import { MANIFEST_FILE } from '@/modules/n8n-packages/spec/constants'; import type { ManifestEntry, PackageManifest } from '@/modules/n8n-packages/spec/manifest.schema'; import { packageManifestSchema } from '@/modules/n8n-packages/spec/manifest.schema'; -import type { PackageRequirements } from '@/modules/n8n-packages/spec/requirements.schema'; +import { getWorkflowConsumerIds } from '@/modules/n8n-packages/spec/requirement-consumers'; +import type { + PackageRequirementConsumer, + PackageRequirements, +} from '@/modules/n8n-packages/spec/requirements.schema'; /** * TEMPORARY bridge: import still inventories a directory package from @@ -66,7 +70,7 @@ function dropSelectedRequirementUsers( return { ...leftover, ...(requirements ? { requirements } : { requirements: undefined }) }; } -function dropSelectedUsers( +function dropSelectedUsers( rows: T[] | undefined, selectedWorkflowIds: Set, ): T[] | undefined { @@ -74,9 +78,9 @@ function dropSelectedUsers( const kept = rows .map((row) => ({ ...row, - usedByWorkflows: row.usedByWorkflows.filter((id) => !selectedWorkflowIds.has(id)), + usedBy: row.usedBy.filter(({ id }) => !selectedWorkflowIds.has(id)), })) - .filter((row) => row.usedByWorkflows.length > 0); + .filter((row) => row.usedBy.length > 0); return kept.length > 0 ? kept : undefined; } @@ -266,7 +270,7 @@ function requirementsBlock( return requirements ? { requirements } : {}; } -function mergeRequirementRows( +function mergeRequirementRows( leftover: T[] | undefined, staging: T[] | undefined, remainingWorkflowIds: Set, @@ -275,31 +279,38 @@ function mergeRequirementRows( ): T[] | undefined { const byKey = new Map(); for (const row of leftover ?? []) { - const usedByWorkflows = remainingUsers( - row.usedByWorkflows.filter((id) => !selectedWorkflowIds.has(id)), + const usedBy = remainingUsers( + row.usedBy.filter(({ id }) => !selectedWorkflowIds.has(id)), remainingWorkflowIds, ); - if (usedByWorkflows.length === 0) continue; - byKey.set(keyOf(row), { ...row, usedByWorkflows }); + if (usedBy.length === 0) continue; + byKey.set(keyOf(row), { ...row, usedBy }); } for (const row of staging ?? []) { const key = keyOf(row); - const usedByWorkflows = remainingUsers( - [...(byKey.get(key)?.usedByWorkflows ?? []), ...row.usedByWorkflows], + const usedBy = remainingUsers( + [...(byKey.get(key)?.usedBy ?? []), ...row.usedBy], remainingWorkflowIds, ); - if (usedByWorkflows.length === 0) { + if (usedBy.length === 0) { byKey.delete(key); continue; } - byKey.set(key, { ...row, usedByWorkflows }); + byKey.set(key, { ...row, usedBy }); } const rows = [...byKey.values()]; return rows.length > 0 ? rows : undefined; } -function remainingUsers(ids: string[], remainingWorkflowIds: Set): string[] { - return [...new Set(ids.filter((id) => remainingWorkflowIds.has(id)))].sort(); +function remainingUsers( + usedBy: PackageRequirementConsumer[], + remainingWorkflowIds: Set, +): PackageRequirementConsumer[] { + return [ + ...new Set(getWorkflowConsumerIds({ usedBy }).filter((id) => remainingWorkflowIds.has(id))), + ] + .sort() + .map((id) => ({ kind: 'workflow', id })); } function compactRequirements(input: { diff --git a/packages/cli/src/modules/promotions.ee/promotion-change.service.ts b/packages/cli/src/modules/promotions.ee/promotion-change.service.ts index 67b2afa4e2d..653deb33bbb 100644 --- a/packages/cli/src/modules/promotions.ee/promotion-change.service.ts +++ b/packages/cli/src/modules/promotions.ee/promotion-change.service.ts @@ -37,7 +37,11 @@ import { type ManifestEntry, type PackageManifest, } from '@/modules/n8n-packages/spec/manifest.schema'; -import type { PackageRequirements } from '@/modules/n8n-packages/spec/requirements.schema'; +import { getWorkflowConsumerIds } from '@/modules/n8n-packages/spec/requirement-consumers'; +import type { + PackageRequirementConsumer, + PackageRequirements, +} from '@/modules/n8n-packages/spec/requirements.schema'; import type { SerializedWorkflow } from '@/modules/n8n-packages/spec/serialized/workflow.schema'; import { parsePackageFiles, type PackageFile } from './base-branch-files'; @@ -325,13 +329,13 @@ export function scopeManifestToProject( variables.set(entry.name, entry); } } - const scopeRows = (rows: T[] | undefined) => { + const scopeRows = (rows: T[] | undefined) => { const kept = rows ?.map((row) => ({ ...row, - usedByWorkflows: row.usedByWorkflows.filter((id) => workflowIds.has(id)), + usedBy: row.usedBy.filter(({ id }) => workflowIds.has(id)), })) - .filter((row) => row.usedByWorkflows.length > 0); + .filter((row) => row.usedBy.length > 0); return kept?.length ? kept : undefined; }; const { requirements } = manifest; @@ -432,7 +436,7 @@ function calculateDependencyImpact({ previous.some(({ path }) => changedPaths.has(path)) || (currentPath !== undefined && changedPaths.has(currentPath)); } - for (const workflowId of requirement.usedByWorkflows) { + for (const workflowId of getWorkflowConsumerIds(requirement)) { dependencyCounts.set(workflowId, (dependencyCounts.get(workflowId) ?? 0) + 1); if (dependencyChanged) affectedWorkflowIds.add(workflowId); } diff --git a/packages/cli/src/public-api/v1/shared/spec/schemas/importBlockingIssue.generated.yml b/packages/cli/src/public-api/v1/shared/spec/schemas/importBlockingIssue.generated.yml index 671e1ca80a0..760f4c1db70 100644 --- a/packages/cli/src/public-api/v1/shared/spec/schemas/importBlockingIssue.generated.yml +++ b/packages/cli/src/public-api/v1/shared/spec/schemas/importBlockingIssue.generated.yml @@ -155,15 +155,15 @@ oneOf: actualType: type: string description: 'For `type_mismatch`: the actual type of the resolved target credential.' - usedByWorkflows: + usedBy: type: array items: - type: string + $ref: packageRequirementConsumer.generated.yml required: - type - kind - sourceId - - usedByWorkflows + - usedBy description: A credential reference that could not be resolved in the target project. - type: object properties: @@ -457,14 +457,14 @@ oneOf: currentName: type: string description: "For a rename `name-conflict`: the matched table's current name." - usedByWorkflows: + usedBy: type: array items: - type: string + $ref: packageRequirementConsumer.generated.yml required: - type - kind - - usedByWorkflows + - usedBy description: A data table referenced by the package's workflows that could not be resolved against the target project — under `dataTableMissingMode=must-preexist` or `dataTableSchemaConflictPolicy`, or because the data table module is disabled on this instance, or the caller lacks the scope the resolution needs. - type: object properties: @@ -498,15 +498,15 @@ oneOf: existingName: type: string description: 'For `rename-drift`: the current name of the same-id target tag.' - usedByWorkflows: + usedBy: type: array items: - type: string - description: Package workflow ids (non-skipped) that reference the source tag — not workflows attached to the contested target tag. + $ref: packageRequirementConsumer.generated.yml + description: Package consumers that reference the source tag. This excludes skipped consumers and workflows attached to the contested target tag. required: - type - kind - - usedByWorkflows + - usedBy description: "A tag referenced by the package's workflows that could not be resolved on the target instance. `kind` distinguishes the cause: `rename-drift` (the same-id target tag carries a different name — under `tagConflictPolicy=fail`, or `rename` when the package name is held by another tag), `name-collision` (the id is free but the name belongs to a different tag under `tagMissingMode=create` with `tagConflictPolicy=fail`; also raised when two package tags collide with each other, or when the target tag a reconcile would re-key is claimed by another package tag), `invalid-name` / `invalid-id` (the package tag's name or id cannot be written on this instance), or `permission-denied` (the importing user lacks the global `tag:create` / `tag:update` scope the plan needs)." - type: object properties: @@ -517,14 +517,14 @@ oneOf: name: type: string description: Requirement name with no match in the target project or global scope. - usedByWorkflows: + usedBy: type: array items: - type: string + $ref: packageRequirementConsumer.generated.yml required: - type - name - - usedByWorkflows + - usedBy description: A variable reference that could not be resolved in the target project or the global scope, under `variableMissingMode=must-preexist`. - type: object properties: @@ -538,14 +538,14 @@ oneOf: projectId: type: string description: Project owning the resolved variable. Absent when it resolved at the global scope. - usedByWorkflows: + usedBy: type: array items: - type: string + $ref: packageRequirementConsumer.generated.yml required: - type - name - - usedByWorkflows + - usedBy description: A variable that resolved in the target project or the global scope, but whose value differs from the one the package bundles for it, under `variableConflictPolicy=fail`. Also reported under `overwrite`, once per scope, when the projects of a package resolve one row and disagree about the value it should hold. Values are never reported — only the name and the scope the variable was found in. - type: object properties: @@ -567,18 +567,18 @@ oneOf: items: type: string description: The unique variable names the import would create. - usedByWorkflows: + usedBy: type: array items: - type: string - description: Package workflow ids that reference any of the listed variables. + $ref: packageRequirementConsumer.generated.yml + description: Package consumers that reference any of the listed variables. required: - type - limit - remaining - requested - names - - usedByWorkflows + - usedBy description: Creating the package's variables under `create-stub` or `create-with-value` would exceed the instance variable quota (`quota:maxVariables`). Reported once for the whole import; nothing is created. - type: object properties: @@ -592,16 +592,16 @@ oneOf: typeVersion: type: number description: Node type version the package's workflows use. - usedByWorkflows: + usedBy: type: array items: - type: string - description: Package workflow ids that use this node type and version. + $ref: packageRequirementConsumer.generated.yml + description: Package consumers that use this node type and version. required: - type - nodeType - typeVersion - - usedByWorkflows + - usedBy description: A node type — or a version of a node type — used by a package workflow that this instance does not have, under `missingNodeTypeMode=fail`. One issue is reported per missing `(nodeType, typeVersion)` pair. - type: object properties: diff --git a/packages/cli/src/public-api/v1/shared/spec/schemas/packageRequirementConsumer.generated.yml b/packages/cli/src/public-api/v1/shared/spec/schemas/packageRequirementConsumer.generated.yml new file mode 100644 index 00000000000..7a420954fa0 --- /dev/null +++ b/packages/cli/src/public-api/v1/shared/spec/schemas/packageRequirementConsumer.generated.yml @@ -0,0 +1,12 @@ +type: object +properties: + kind: + type: string + enum: + - workflow + id: + type: string + minLength: 1 +required: + - kind + - id diff --git a/packages/cli/test/integration/public-api/n8n-packages-import.test.ts b/packages/cli/test/integration/public-api/n8n-packages-import.test.ts index dbd5071569d..4607a91fcda 100644 --- a/packages/cli/test/integration/public-api/n8n-packages-import.test.ts +++ b/packages/cli/test/integration/public-api/n8n-packages-import.test.ts @@ -80,7 +80,7 @@ async function buildImportPackage( ? { variables: [{ id: 'var-http-source', name: variable.name, target: variable.target }], requirements: { - variables: [{ name: variable.name, usedByWorkflows: [wfId] }], + variables: [{ name: variable.name, usedBy: [{ kind: 'workflow', id: wfId }] }], }, } : {}), @@ -467,7 +467,7 @@ describe('POST /n8n-packages/import', () => { type: 'missing-node-type', nodeType: 'n8n-nodes-community.chatBot', typeVersion: 1, - usedByWorkflows: ['wf-unknown-node'], + usedBy: [{ kind: 'workflow', id: 'wf-unknown-node' }], }, ], });