diff --git a/packages/@n8n/utils/src/types.ts b/packages/@n8n/utils/src/types.ts index ba811ed07fa..1a160903795 100644 --- a/packages/@n8n/utils/src/types.ts +++ b/packages/@n8n/utils/src/types.ts @@ -12,3 +12,47 @@ * type WithoutKind = DistributiveOmit; */ export type DistributiveOmit = T extends unknown ? Omit : never; + +/** + * Converts a union into an intersection of its members. + * + * @example + * type Merged = UnionToIntersection<{ a: string } | { b: number }>; + * // => { a: string } & { b: number } + */ +export type UnionToIntersection = (U extends unknown ? (x: U) => void : never) extends ( + x: infer I, +) => void + ? I + : never; + +/** + * Picks one member of a union (the last one in the compiler's internal order). + * Mainly a building block for iterating over union members. + * + * @example + * type Last = LastOf<'a' | 'b'>; + * // => 'b' + */ +export type LastOf = UnionToIntersection< + U extends unknown ? () => U : never +> extends () => infer R + ? R + : never; + +/** + * Joins a union of string literals into a single quoted, comma-separated string literal. + * Useful for building readable custom compile-error messages. Uses single quotes so the + * result renders without escapes when the compiler prints it inside a double-quoted string. + * + * @example + * type Keys = JoinKeys<'a' | 'b'>; + * // => "'a', 'b'" + */ +export type JoinKeys = [U] extends [never] + ? '' + : JoinKeys>> extends infer TRest extends string + ? TRest extends '' + ? `'${LastOf & string}'` + : `${TRest}, '${LastOf & string}'` + : never; diff --git a/packages/cli/src/modules/n8n-packages/entities/credential/credential.serializer.ts b/packages/cli/src/modules/n8n-packages/entities/credential/credential.serializer.ts index af5c1bfeb6e..fc4262ce753 100644 --- a/packages/cli/src/modules/n8n-packages/entities/credential/credential.serializer.ts +++ b/packages/cli/src/modules/n8n-packages/entities/credential/credential.serializer.ts @@ -5,14 +5,39 @@ import { serializedCredentialSchema, type SerializedCredential, } from '../../spec/serialized/credential.schema'; +import { definePackageSerializationPayload } from '../package-serialization.types'; + +type CredentialPackageKeyHandling = { + id: 'copy'; + createdAt: 'exclude'; + updatedAt: 'exclude'; + name: 'copy'; + data: 'exclude'; + type: 'copy'; + shared: 'exclude'; + isManaged: 'exclude'; + isGlobal: 'exclude'; + isResolvable: 'exclude'; + resolvableAllowFallback: 'exclude'; + resolverId: 'exclude'; + usageScope: 'exclude'; +}; + +const serializePayload = definePackageSerializationPayload< + CredentialsEntity, + SerializedCredential, + CredentialPackageKeyHandling +>(); @Service() export class CredentialSerializer { serialize(credential: CredentialsEntity): SerializedCredential { - return serializedCredentialSchema.parse({ - id: credential.id, - name: credential.name, - type: credential.type, - }); + return serializedCredentialSchema.parse( + serializePayload({ + id: credential.id, + name: credential.name, + type: credential.type, + }), + ); } } diff --git a/packages/cli/src/modules/n8n-packages/entities/data-table/data-table.serializer.ts b/packages/cli/src/modules/n8n-packages/entities/data-table/data-table.serializer.ts index 2db9529446b..e569e0b22d1 100644 --- a/packages/cli/src/modules/n8n-packages/entities/data-table/data-table.serializer.ts +++ b/packages/cli/src/modules/n8n-packages/entities/data-table/data-table.serializer.ts @@ -1,25 +1,67 @@ import { Service } from '@n8n/di'; +import type { DataTableColumn } from '@/modules/data-table/data-table-column.entity'; import type { DataTable } from '@/modules/data-table/data-table.entity'; import { serializedDataTableSchema, + type SerializedDataTableColumn, type SerializedDataTable, } from '../../spec/serialized/data-table.schema'; +import { definePackageSerializationPayload } from '../package-serialization.types'; + +type DataTablePackageKeyHandling = { + id: 'copy'; + createdAt: 'exclude'; + updatedAt: 'exclude'; + name: 'copy'; + columns: 'transform'; + project: 'transform'; + projectId: 'transform'; +}; + +type DataTableColumnPackageKeyHandling = { + id: 'exclude'; + createdAt: 'exclude'; + updatedAt: 'exclude'; + dataTableId: 'exclude'; + name: 'copy'; + type: 'copy'; + index: 'copy'; + dataTable: 'exclude'; +}; + +const serializeDataTablePayload = definePackageSerializationPayload< + DataTable, + SerializedDataTable, + DataTablePackageKeyHandling +>(); + +const serializeColumnPayload = definePackageSerializationPayload< + DataTableColumn, + SerializedDataTableColumn, + DataTableColumnPackageKeyHandling +>(); @Service() export class DataTableSerializer { serialize(dataTable: DataTable): SerializedDataTable { - return serializedDataTableSchema.parse({ - id: dataTable.id, - name: dataTable.name, - columns: [...dataTable.columns] - .sort((a, b) => a.index - b.index) - .map((column) => ({ + const columns = [...dataTable.columns] + .sort((a, b) => a.index - b.index) + .map((column) => + serializeColumnPayload({ name: column.name, type: column.type, index: column.index, - })), - }); + }), + ); + + return serializedDataTableSchema.parse( + serializeDataTablePayload({ + id: dataTable.id, + name: dataTable.name, + columns, + }), + ); } } diff --git a/packages/cli/src/modules/n8n-packages/entities/folder/folder.serializer.ts b/packages/cli/src/modules/n8n-packages/entities/folder/folder.serializer.ts index f8d7ce23e9c..ccd945afee3 100644 --- a/packages/cli/src/modules/n8n-packages/entities/folder/folder.serializer.ts +++ b/packages/cli/src/modules/n8n-packages/entities/folder/folder.serializer.ts @@ -2,14 +2,36 @@ import type { Folder } from '@n8n/db'; import { Service } from '@n8n/di'; import { serializedFolderSchema, type SerializedFolder } from '../../spec/serialized/folder.schema'; +import { definePackageSerializationPayload } from '../package-serialization.types'; + +type FolderPackageKeyHandling = { + id: 'copy'; + createdAt: 'exclude'; + updatedAt: 'exclude'; + name: 'copy'; + parentFolderId: 'transform'; + parentFolder: 'exclude'; + subFolders: 'exclude'; + homeProject: 'transform'; + workflows: 'exclude'; + tags: 'exclude'; +}; + +const serializePayload = definePackageSerializationPayload< + Folder, + SerializedFolder, + FolderPackageKeyHandling +>(); @Service() export class FolderSerializer { serialize(folder: Folder, parentFolderId: string | null): SerializedFolder { - return serializedFolderSchema.parse({ - id: folder.id, - name: folder.name, - parentFolderId, - }); + return serializedFolderSchema.parse( + serializePayload({ + id: folder.id, + name: folder.name, + parentFolderId, + }), + ); } } diff --git a/packages/cli/src/modules/n8n-packages/entities/package-serialization.types.ts b/packages/cli/src/modules/n8n-packages/entities/package-serialization.types.ts new file mode 100644 index 00000000000..c49c1663976 --- /dev/null +++ b/packages/cli/src/modules/n8n-packages/entities/package-serialization.types.ts @@ -0,0 +1,77 @@ +/** + * The types below are hairy, but they exist for one reason: whenever a field is added to, + * renamed on, or removed from a core entity (workflow, credential, ...), package import/export + * must be updated too. Each serializer declares an export decision (`copy`, `transform`, + * `exclude`) for every entity key, and these types turn any drift between the entity and its + * decisions into a compile error — instead of silently dropping data from packages. + */ +import type { JoinKeys } from '@n8n/utils/types'; + +// Entity properties only; methods are ignored. +type EntityDataKeys = { + [K in keyof TEntity]-?: TEntity[K] extends (...args: never[]) => unknown ? never : K; +}[keyof TEntity]; + +// Forces every entity property to be classified for export. +type PackageEntityKeyHandling = Record< + EntityDataKeys, + 'copy' | 'transform' | 'exclude' +>; + +// Turns into a readable constraint error in two cases: +// - entity keys without a decision (e.g. `nodeGroups` added to the entity but not here) +// - decisions for keys not on the entity (e.g. `nodeGroups` renamed but still listed here) +type ExportDecisionConstraint = [ + Exclude, keyof TKeyHandling>, +] extends [never] + ? [Exclude>] extends [never] + ? PackageEntityKeyHandling + : `Export decisions include key(s) that do not exist on the entity: ${JoinKeys< + Extract>, string> + >}` + : `Every entity key has a package export decision, missing export decision for key(s): ${JoinKeys< + Extract, keyof TKeyHandling>, string> + >}`; + +// Entity keys exported unchanged (copy key handling). +type PackageCopiedEntityKeys = { + [K in EntityDataKeys]-?: K extends keyof TKeyHandling + ? TKeyHandling[K] extends 'copy' + ? K + : never + : never; +}[EntityDataKeys]; + +// Entity keys intentionally omitted from export (exclude key handling). +type PackageExcludedEntityKeys = { + [K in EntityDataKeys]-?: K extends keyof TKeyHandling + ? TKeyHandling[K] extends 'exclude' + ? K + : never + : never; +}[EntityDataKeys]; + +// Copied entity keys absent from the inferred payload. +type CopiedEntityKeysMissingFromPayload = Exclude< + PackageCopiedEntityKeys, + keyof TPayload +>; + +// Preserves exact payload keys while enforcing the export decisions and serialized schema. +// The handling map must give every entity key a decision, or this fails to compile with a +// message listing the undecided keys. +export function definePackageSerializationPayload< + TEntity, + TSerialized extends object, + TKeyHandling extends ExportDecisionConstraint, +>() { + return ( + payload: TPayload & + // Every copied key must be present, including optional keys. + Record, never> & + // Excluded entity keys cannot be emitted. + Partial, never>> & + // Payload keys must exist in the serialized schema. + Record, never>, + ): TPayload => payload; +} diff --git a/packages/cli/src/modules/n8n-packages/entities/project/project.serializer.ts b/packages/cli/src/modules/n8n-packages/entities/project/project.serializer.ts index 083945b479e..49ebde82121 100644 --- a/packages/cli/src/modules/n8n-packages/entities/project/project.serializer.ts +++ b/packages/cli/src/modules/n8n-packages/entities/project/project.serializer.ts @@ -5,18 +5,46 @@ import { serializedProjectSchema, type SerializedProject, } from '../../spec/serialized/project.schema'; +import { definePackageSerializationPayload } from '../package-serialization.types'; + +type ProjectPackageKeyHandling = { + id: 'copy'; + createdAt: 'exclude'; + updatedAt: 'exclude'; + name: 'copy'; + type: 'exclude'; + icon: 'copy'; + description: 'copy'; + customTelemetryTags: 'copy'; + projectRelations: 'exclude'; + sharedCredentials: 'exclude'; + sharedWorkflows: 'exclude'; + secretsProviderAccess: 'exclude'; + variables: 'exclude'; + roleMappingRules: 'exclude'; + creatorId: 'exclude'; + creator: 'exclude'; +}; + +const serializePayload = definePackageSerializationPayload< + Project, + SerializedProject, + ProjectPackageKeyHandling +>(); @Service() export class ProjectSerializer { serialize(project: Project): SerializedProject { - return serializedProjectSchema.parse({ - id: project.id, - name: project.name, - ...(project.description !== null ? { description: project.description } : {}), - ...(project.icon !== null ? { icon: project.icon } : {}), - ...(project.customTelemetryTags?.length - ? { customTelemetryTags: project.customTelemetryTags } - : {}), - }); + return serializedProjectSchema.parse( + serializePayload({ + id: project.id, + name: project.name, + ...(project.description !== null ? { description: project.description } : {}), + ...(project.icon !== null ? { icon: project.icon } : {}), + ...(project.customTelemetryTags?.length + ? { customTelemetryTags: project.customTelemetryTags } + : {}), + }), + ); } } 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 f564c545471..00ec5f5d9fd 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 @@ -1,12 +1,30 @@ +import type { TagEntity } from '@n8n/db'; import { Service } from '@n8n/di'; import type { PackageWriter } from '../../io/package-writer'; import { UniqueFilenameAllocator } from '../../io/unique-filename-allocator'; import type { ManifestEntry } from '../../spec/manifest.schema'; import type { PackageTagRequirement } from '../../spec/requirements.schema'; -import { serializedTagSchema } from '../../spec/serialized/tag.schema'; +import { serializedTagSchema, type SerializedTag } from '../../spec/serialized/tag.schema'; +import { definePackageSerializationPayload } from '../package-serialization.types'; import { compareTagsByName, type WorkflowTagUsage } from './tag.types'; +type TagPackageKeyHandling = { + id: 'copy'; + createdAt: 'exclude'; + updatedAt: 'exclude'; + name: 'copy'; + workflows: 'exclude'; + workflowMappings: 'exclude'; + folderMappings: 'exclude'; +}; + +const serializePayload = definePackageSerializationPayload< + TagEntity, + SerializedTag, + TagPackageKeyHandling +>(); + export interface TagExportRequest { usages: WorkflowTagUsage[]; writer: PackageWriter; @@ -41,7 +59,7 @@ export class TagExporter { for (const { id, name } of requirements) { const tagDirectory = allocator.allocate(name); - const serializedTag = serializedTagSchema.parse({ id, name }); + const serializedTag = serializedTagSchema.parse(serializePayload({ id, name })); request.writer.writeDirectory(tagDirectory); request.writer.writeFile( `${tagDirectory}/tag.json`, diff --git a/packages/cli/src/modules/n8n-packages/entities/variable/variable.serializer.ts b/packages/cli/src/modules/n8n-packages/entities/variable/variable.serializer.ts index e245ef90a35..be37b86e110 100644 --- a/packages/cli/src/modules/n8n-packages/entities/variable/variable.serializer.ts +++ b/packages/cli/src/modules/n8n-packages/entities/variable/variable.serializer.ts @@ -5,6 +5,21 @@ import { serializedVariableSchema, type SerializedVariable, } from '../../spec/serialized/variable.schema'; +import { definePackageSerializationPayload } from '../package-serialization.types'; + +type VariablePackageKeyHandling = { + id: 'exclude'; + key: 'transform'; + type: 'copy'; + value: 'copy'; + project: 'transform'; +}; + +const serializePayload = definePackageSerializationPayload< + Variables, + SerializedVariable, + VariablePackageKeyHandling +>(); @Service() export class VariableSerializer { @@ -12,10 +27,14 @@ export class VariableSerializer { variable: Variables, { includeValue = true }: { includeValue?: boolean } = {}, ): SerializedVariable { - return serializedVariableSchema.parse({ - name: variable.key, - type: variable.type, - ...(includeValue ? { value: variable.value } : {}), - }); + const type = serializedVariableSchema.shape.type.parse(variable.type); + + return serializedVariableSchema.parse( + serializePayload({ + name: variable.key, + type, + ...(includeValue ? { value: variable.value } : {}), + }), + ); } } diff --git a/packages/cli/src/modules/n8n-packages/entities/workflow/workflow.serializer.ts b/packages/cli/src/modules/n8n-packages/entities/workflow/workflow.serializer.ts index 668f3982f62..e7651db2943 100644 --- a/packages/cli/src/modules/n8n-packages/entities/workflow/workflow.serializer.ts +++ b/packages/cli/src/modules/n8n-packages/entities/workflow/workflow.serializer.ts @@ -6,14 +6,48 @@ import { serializedWorkflowSchema, type SerializedWorkflow, } from '../../spec/serialized/workflow.schema'; +import { definePackageSerializationPayload } from '../package-serialization.types'; import { compareTagsByName } from '../tag/tag.types'; -/** Fields restored from package workflow.json; the target instance assigns the rest. */ +type WorkflowPackageKeyHandling = { + id: 'copy'; + createdAt: 'exclude'; + updatedAt: 'exclude'; + name: 'copy'; + description: 'exclude'; + active: 'exclude'; + isArchived: 'copy'; + nodes: 'copy'; + connections: 'copy'; + settings: 'copy'; + staticData: 'exclude'; + meta: 'exclude'; + nodeGroups: 'exclude'; + tags: 'transform'; + tagMappings: 'exclude'; + shared: 'exclude'; + pinData: 'exclude'; + versionId: 'copy'; + activeVersionId: 'transform'; + activeVersion: 'exclude'; + versionCounter: 'exclude'; + triggerCount: 'exclude'; + parentFolder: 'transform'; + testRuns: 'exclude'; + sourceWorkflowId: 'exclude'; +}; + type WorkflowPackageContent = Pick< WorkflowEntity, 'name' | 'nodes' | 'connections' | 'isArchived' | 'settings' >; +const serializePayload = definePackageSerializationPayload< + WorkflowEntity, + SerializedWorkflow, + WorkflowPackageKeyHandling +>(); + @Service() export class WorkflowSerializer { serialize(workflow: WorkflowEntity, options: { includeTags: boolean }): SerializedWorkflow { @@ -23,18 +57,20 @@ export class WorkflowSerializer { ? [...(workflow.tags ?? [])].sort(compareTagsByName) : undefined; - return serializedWorkflowSchema.parse({ - id: workflow.id, - name: workflow.name, - nodes: workflow.nodes, - connections: workflow.connections, - settings: workflow.settings, - versionId: workflow.versionId, - parentFolderId: workflow.parentFolder?.id ?? null, - isPublished: workflow.activeVersionId === workflow.versionId, - isArchived: workflow.isArchived, - ...(tags ? { tagIds: tags.map((tag) => tag.id) } : {}), - }); + return serializedWorkflowSchema.parse( + serializePayload({ + id: workflow.id, + name: workflow.name, + nodes: workflow.nodes, + connections: workflow.connections, + settings: workflow.settings ? { ...workflow.settings } : undefined, + versionId: workflow.versionId, + parentFolderId: workflow.parentFolder?.id ?? null, + isPublished: workflow.activeVersionId === workflow.versionId, + isArchived: workflow.isArchived, + ...(tags ? { tagIds: tags.map((tag) => tag.id) } : {}), + }), + ); } /**