refactor(core): Enforce package entity export decisions (no-changelog) (#35699)

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Nour Alhadi Mahmoud
2026-08-13 14:43:47 +00:00
committed by GitHub
parent fee8450999
commit 600354ebcb
9 changed files with 358 additions and 47 deletions
+44
View File
@@ -12,3 +12,47 @@
* type WithoutKind = DistributiveOmit<Event, never>;
*/
export type DistributiveOmit<T, K extends PropertyKey> = T extends unknown ? Omit<T, K> : 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> = (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<U> = 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 string> = [U] extends [never]
? ''
: JoinKeys<Exclude<U, LastOf<U>>> extends infer TRest extends string
? TRest extends ''
? `'${LastOf<U> & string}'`
: `${TRest}, '${LastOf<U> & string}'`
: never;
@@ -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,
}),
);
}
}
@@ -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,
}),
);
}
}
@@ -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,
}),
);
}
}
@@ -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<TEntity> = {
[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<TEntity> = Record<
EntityDataKeys<TEntity>,
'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<TEntity, TKeyHandling> = [
Exclude<EntityDataKeys<TEntity>, keyof TKeyHandling>,
] extends [never]
? [Exclude<keyof TKeyHandling, EntityDataKeys<TEntity>>] extends [never]
? PackageEntityKeyHandling<TEntity>
: `Export decisions include key(s) that do not exist on the entity: ${JoinKeys<
Extract<Exclude<keyof TKeyHandling, EntityDataKeys<TEntity>>, string>
>}`
: `Every entity key has a package export decision, missing export decision for key(s): ${JoinKeys<
Extract<Exclude<EntityDataKeys<TEntity>, keyof TKeyHandling>, string>
>}`;
// Entity keys exported unchanged (copy key handling).
type PackageCopiedEntityKeys<TEntity, TKeyHandling> = {
[K in EntityDataKeys<TEntity>]-?: K extends keyof TKeyHandling
? TKeyHandling[K] extends 'copy'
? K
: never
: never;
}[EntityDataKeys<TEntity>];
// Entity keys intentionally omitted from export (exclude key handling).
type PackageExcludedEntityKeys<TEntity, TKeyHandling> = {
[K in EntityDataKeys<TEntity>]-?: K extends keyof TKeyHandling
? TKeyHandling[K] extends 'exclude'
? K
: never
: never;
}[EntityDataKeys<TEntity>];
// Copied entity keys absent from the inferred payload.
type CopiedEntityKeysMissingFromPayload<TEntity, TKeyHandling, TPayload> = Exclude<
PackageCopiedEntityKeys<TEntity, TKeyHandling>,
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<TEntity, TKeyHandling>,
>() {
return <const TPayload extends TSerialized>(
payload: TPayload &
// Every copied key must be present, including optional keys.
Record<CopiedEntityKeysMissingFromPayload<TEntity, TKeyHandling, TPayload>, never> &
// Excluded entity keys cannot be emitted.
Partial<Record<PackageExcludedEntityKeys<TEntity, TKeyHandling>, never>> &
// Payload keys must exist in the serialized schema.
Record<Exclude<keyof TPayload, keyof TSerialized>, never>,
): TPayload => payload;
}
@@ -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 }
: {}),
}),
);
}
}
@@ -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`,
@@ -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 } : {}),
}),
);
}
}
@@ -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) } : {}),
}),
);
}
/**