mirror of
https://github.com/n8n-io/n8n.git
synced 2026-09-01 15:47:41 +08:00
refactor(core): Enforce package entity export decisions (no-changelog) (#35699)
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
committed by
GitHub
parent
fee8450999
commit
600354ebcb
@@ -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) } : {}),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user