refactor(core): Enforce TypeORM persistence boundary with Code Health (#40384)

This commit is contained in:
Charlie Kolb
2026-10-07 08:39:18 +00:00
committed by GitHub
parent 779ddcc517
commit 16dc6fe403
19 changed files with 1112 additions and 495 deletions
@@ -14,7 +14,8 @@ Flag a diff that **adds** entries to any of these, and ask for the fix instead:
| `.code-health-baseline.json` | `@n8n/code-health` violations |
| `.boundaries-baseline.json` | `turbo boundaries` issue count |
| `packages/quality/testing/playwright/.janitor-baseline.json` | Playwright janitor findings |
| `packages/cli/eslint.config.mjs` | the `misplaced-n8n-typeorm-import` and public-API allowlists, each captioned "NEVER add to this list" |
| `.code-health-baseline.json` | the `typeorm-persistence-boundary` baseline, which only shrinks as business logic moves queries into repositories |
| `packages/cli/eslint.config.mjs` | the public-API allowlists captioned "NEVER add to this list" |
| `.code-health-baseline.json` (`lint-config-layering`) | package-wide rule downgrades left in package ESLint configs |
Removals are the healthy direction and need no comment.
+644
View File
@@ -1404,6 +1404,650 @@
"message": "Override \"langsmith\" duplicates a catalog entry for langsmith — set the override to \"catalog:\" or remove it",
"hash": "9ac3dbae3d22"
}
],
"packages/cli/src/binary-data/database.manager.ts": [
{
"rule": "typeorm-persistence-boundary",
"line": 2,
"message": "packages/cli imports the TypeORM re-export `In` from @n8n/db.",
"hash": "a4aeaccef8b6"
}
],
"packages/cli/src/credentials/credential-connection-status-provider.interface.ts": [
{
"rule": "typeorm-persistence-boundary",
"line": 1,
"message": "Business logic imports TypeORM directly.",
"hash": "4d287b55d52a"
}
],
"packages/cli/src/credentials/credential-connection-status-proxy.ts": [
{
"rule": "typeorm-persistence-boundary",
"line": 2,
"message": "Business logic imports TypeORM directly.",
"hash": "f425e2b10e7f"
}
],
"packages/cli/src/credentials/credential-dependency.service.ts": [
{
"rule": "typeorm-persistence-boundary",
"line": 8,
"message": "Business logic imports TypeORM directly.",
"hash": "abdb9e128ab8"
}
],
"packages/cli/src/credentials/credentials.controller.ts": [
{
"rule": "typeorm-persistence-boundary",
"line": 32,
"message": "Business logic imports TypeORM directly.",
"hash": "510cfb224f30"
}
],
"packages/cli/src/credentials/credentials.service.ee.ts": [
{
"rule": "typeorm-persistence-boundary",
"line": 12,
"message": "Business logic imports TypeORM directly.",
"hash": "f759a907bfa6"
}
],
"packages/cli/src/credentials/credentials.service.ts": [
{
"rule": "typeorm-persistence-boundary",
"line": 32,
"message": "Business logic imports TypeORM directly.",
"hash": "7f1b3146a6a5"
}
],
"packages/cli/src/executions/execution-persistence.ts": [
{
"rule": "typeorm-persistence-boundary",
"line": 7,
"message": "packages/cli imports the TypeORM re-export `EntityManager` from @n8n/db.",
"hash": "9c7a73941bb9"
},
{
"rule": "typeorm-persistence-boundary",
"line": 10,
"message": "packages/cli imports the TypeORM re-export `FindManyOptions` from @n8n/db.",
"hash": "2beb2d36dbee"
},
{
"rule": "typeorm-persistence-boundary",
"line": 11,
"message": "packages/cli imports the TypeORM re-export `FindOptionsWhere` from @n8n/db.",
"hash": "ef8617dcfa5a"
},
{
"rule": "typeorm-persistence-boundary",
"line": 18,
"message": "packages/cli imports the TypeORM re-export `In` from @n8n/db.",
"hash": "be7b5858aa7c"
},
{
"rule": "typeorm-persistence-boundary",
"line": 18,
"message": "packages/cli imports the TypeORM re-export `Not` from @n8n/db.",
"hash": "5faea85ebf23"
}
],
"packages/cli/src/permissions.ee/check-access.ts": [
{
"rule": "typeorm-persistence-boundary",
"line": 2,
"message": "packages/cli imports the TypeORM re-export `EntityManager` from @n8n/db.",
"hash": "ffa998a4f0e7"
}
],
"packages/cli/src/scheduling/durable-job-provisioner.ts": [
{
"rule": "typeorm-persistence-boundary",
"line": 5,
"message": "packages/cli imports the TypeORM re-export `EntityManager` from @n8n/db.",
"hash": "7341e70aa4d7"
},
{
"rule": "typeorm-persistence-boundary",
"line": 10,
"message": "packages/cli imports the TypeORM re-export `DataSource` from @n8n/db.",
"hash": "64f5ed0abb25"
}
],
"packages/cli/src/scheduling/durable-scheduler.ts": [
{
"rule": "typeorm-persistence-boundary",
"line": 4,
"message": "packages/cli imports the TypeORM re-export `DataSource` from @n8n/db.",
"hash": "aac4ac3f74d8"
}
],
"packages/cli/src/services/export.service.ts": [
{
"rule": "typeorm-persistence-boundary",
"line": 4,
"message": "Business logic imports TypeORM directly.",
"hash": "079882e3843e"
}
],
"packages/cli/src/services/folder.service.ts": [
{
"rule": "typeorm-persistence-boundary",
"line": 10,
"message": "Business logic imports TypeORM directly.",
"hash": "b71b84bbde01"
}
],
"packages/cli/src/services/hooks.service.ts": [
{
"rule": "typeorm-persistence-boundary",
"line": 15,
"message": "Business logic imports TypeORM directly.",
"hash": "112edc87061f"
}
],
"packages/cli/src/services/import.service.ts": [
{
"rule": "typeorm-persistence-boundary",
"line": 17,
"message": "Business logic imports TypeORM directly.",
"hash": "5f09782b0c0a"
}
],
"packages/cli/src/services/project.service.ee.ts": [
{
"rule": "typeorm-persistence-boundary",
"line": 33,
"message": "Business logic imports TypeORM directly.",
"hash": "5f5086c28a45"
}
],
"packages/cli/src/services/public-api-key.service.ts": [
{
"rule": "typeorm-persistence-boundary",
"line": 15,
"message": "Business logic imports TypeORM directly.",
"hash": "848bd2e47604"
}
],
"packages/cli/src/services/user.service.ts": [
{
"rule": "typeorm-persistence-boundary",
"line": 15,
"message": "packages/cli imports the TypeORM re-export `Not` from @n8n/db.",
"hash": "afb93b8300ff"
},
{
"rule": "typeorm-persistence-boundary",
"line": 16,
"message": "packages/cli imports the TypeORM re-export `In` from @n8n/db.",
"hash": "2fd9c6af03c1"
}
],
"packages/cli/src/workflows/workflow-creation.service.ts": [
{
"rule": "typeorm-persistence-boundary",
"line": 5,
"message": "packages/cli imports the TypeORM re-export `EntityManager` from @n8n/db.",
"hash": "db8f5c7d285b"
}
],
"packages/cli/src/workflows/workflow-finder.service.ts": [
{
"rule": "typeorm-persistence-boundary",
"line": 11,
"message": "Business logic imports TypeORM directly.",
"hash": "81b365efba5a"
}
],
"packages/cli/src/workflows/workflow-validation.service.ts": [
{
"rule": "typeorm-persistence-boundary",
"line": 4,
"message": "Business logic imports TypeORM directly.",
"hash": "fce2fb07a423"
}
],
"packages/cli/src/workflows/workflow.service.ee.ts": [
{
"rule": "typeorm-persistence-boundary",
"line": 22,
"message": "Business logic imports TypeORM directly.",
"hash": "1e39cc245dd5"
}
],
"packages/cli/src/workflows/workflow.service.ts": [
{
"rule": "typeorm-persistence-boundary",
"line": 23,
"message": "Business logic imports TypeORM directly.",
"hash": "ba3b76242d76"
}
],
"packages/cli/src/workflows/workflows.controller.ts": [
{
"rule": "typeorm-persistence-boundary",
"line": 39,
"message": "Business logic imports TypeORM directly.",
"hash": "63742f55819c"
}
],
"packages/cli/src/commands/import/credentials.ts": [
{
"rule": "typeorm-persistence-boundary",
"line": 19,
"message": "Business logic imports TypeORM directly.",
"hash": "a5cc02a1d765"
}
],
"packages/cli/src/evaluation.ee/test-runner/test-runner.service.ee.ts": [
{
"rule": "typeorm-persistence-boundary",
"line": 17,
"message": "Business logic imports TypeORM directly.",
"hash": "d34dbe0bf9e7"
}
],
"packages/cli/src/events/relays/telemetry.event-relay.ts": [
{
"rule": "typeorm-persistence-boundary",
"line": 8,
"message": "packages/cli imports the TypeORM re-export `In` from @n8n/db.",
"hash": "d8a744f2e2b8"
}
],
"packages/cli/src/executions/execution-data/db-store.ts": [
{
"rule": "typeorm-persistence-boundary",
"line": 1,
"message": "packages/cli imports the TypeORM re-export `In` from @n8n/db.",
"hash": "c19615f89980"
},
{
"rule": "typeorm-persistence-boundary",
"line": 2,
"message": "packages/cli imports the TypeORM re-export `EntityManager` from @n8n/db.",
"hash": "47398e9fd892"
}
],
"packages/cli/src/instance-settings-loader/loaders/log-streaming.instance-settings-loader.ts": [
{
"rule": "typeorm-persistence-boundary",
"line": 3,
"message": "packages/cli imports the TypeORM re-export `EntityManager` from @n8n/db.",
"hash": "b6baaab2dc92"
}
],
"packages/cli/src/modules/agents/agent-publish.service.ts": [
{
"rule": "typeorm-persistence-boundary",
"line": 15,
"message": "Business logic imports TypeORM directly.",
"hash": "9857b27c5bb1"
}
],
"packages/cli/src/modules/agents/agent-task.service.ts": [
{
"rule": "typeorm-persistence-boundary",
"line": 8,
"message": "Business logic imports TypeORM directly.",
"hash": "1799d75327d9"
}
],
"packages/cli/src/modules/agents/agents.service.ts": [
{
"rule": "typeorm-persistence-boundary",
"line": 18,
"message": "packages/cli imports the TypeORM re-export `In` from @n8n/db.",
"hash": "e893390cf6b7"
}
],
"packages/cli/src/modules/agents/instance-ai-builder-delegate.adapter.ts": [
{
"rule": "typeorm-persistence-boundary",
"line": 13,
"message": "Business logic imports TypeORM directly.",
"hash": "a2f27310e801"
}
],
"packages/cli/src/modules/breaking-changes/breaking-changes.service.ts": [
{
"rule": "typeorm-persistence-boundary",
"line": 12,
"message": "Business logic imports TypeORM directly.",
"hash": "680ac8daac26"
}
],
"packages/cli/src/modules/chat-hub/chat-hub-agent.service.ts": [
{
"rule": "typeorm-persistence-boundary",
"line": 10,
"message": "packages/cli imports the TypeORM re-export `EntityManager` from @n8n/db.",
"hash": "0aef6c89993c"
}
],
"packages/cli/src/modules/chat-hub/chat-hub-credentials.service.ts": [
{
"rule": "typeorm-persistence-boundary",
"line": 8,
"message": "Business logic imports TypeORM directly.",
"hash": "6125ed3d7f9b"
}
],
"packages/cli/src/modules/chat-hub/chat-hub-title.service.ts": [
{
"rule": "typeorm-persistence-boundary",
"line": 11,
"message": "packages/cli imports the TypeORM re-export `EntityManager` from @n8n/db.",
"hash": "57eab6d29eed"
}
],
"packages/cli/src/modules/chat-hub/chat-hub-tool.service.ts": [
{
"rule": "typeorm-persistence-boundary",
"line": 7,
"message": "packages/cli imports the TypeORM re-export `EntityManager` from @n8n/db.",
"hash": "7069ba20f7cd"
}
],
"packages/cli/src/modules/chat-hub/chat-hub-workflow.service.ts": [
{
"rule": "typeorm-persistence-boundary",
"line": 25,
"message": "Business logic imports TypeORM directly.",
"hash": "7305b701b234"
}
],
"packages/cli/src/modules/chat-hub/chat-hub.attachment.service.ts": [
{
"rule": "typeorm-persistence-boundary",
"line": 3,
"message": "Business logic imports TypeORM directly.",
"hash": "07380799ff46"
}
],
"packages/cli/src/modules/chat-hub/chat-hub.models.service.ts": [
{
"rule": "typeorm-persistence-boundary",
"line": 10,
"message": "packages/cli imports the TypeORM re-export `In` from @n8n/db.",
"hash": "830f87d4b9eb"
}
],
"packages/cli/src/modules/chat-hub/chat-hub.service.ts": [
{
"rule": "typeorm-persistence-boundary",
"line": 17,
"message": "packages/cli imports the TypeORM re-export `EntityManager` from @n8n/db.",
"hash": "55126ad50e59"
}
],
"packages/cli/src/modules/chat-hub/chat-hub.settings.service.ts": [
{
"rule": "typeorm-persistence-boundary",
"line": 11,
"message": "packages/cli imports the TypeORM re-export `EntityManager` from @n8n/db.",
"hash": "ee4b7ca996c4"
}
],
"packages/cli/src/modules/data-table/data-table-ddl.service.ts": [
{
"rule": "typeorm-persistence-boundary",
"line": 3,
"message": "Business logic imports TypeORM directly.",
"hash": "9d6b2c45c02a"
}
],
"packages/cli/src/modules/data-table/data-table.service.ts": [
{
"rule": "typeorm-persistence-boundary",
"line": 18,
"message": "Business logic imports TypeORM directly.",
"hash": "b1aa220b40ab"
}
],
"packages/cli/src/modules/external-secrets.ee/secrets-providers-connections.service.ee.ts": [
{
"rule": "typeorm-persistence-boundary",
"line": 20,
"message": "Business logic imports TypeORM directly.",
"hash": "45167590d2e2"
}
],
"packages/cli/src/modules/favorites/favorites.service.ts": [
{
"rule": "typeorm-persistence-boundary",
"line": 12,
"message": "Business logic imports TypeORM directly.",
"hash": "03cdaa860f2a"
}
],
"packages/cli/src/modules/instance-ai/instance-ai.adapter.service.ts": [
{
"rule": "typeorm-persistence-boundary",
"line": 117,
"message": "Business logic imports TypeORM directly.",
"hash": "afbc7f6b5ec7"
}
],
"packages/cli/src/modules/instance-ai/suspended-thread-persistence.service.ts": [
{
"rule": "typeorm-persistence-boundary",
"line": 3,
"message": "Business logic imports TypeORM directly.",
"hash": "5f8f9db2966f"
}
],
"packages/cli/src/modules/log-streaming.ee/log-streaming-destination.service.ts": [
{
"rule": "typeorm-persistence-boundary",
"line": 5,
"message": "Business logic imports TypeORM directly.",
"hash": "f31366dabfeb"
}
],
"packages/cli/src/modules/mcp/mcp-api-key.service.ts": [
{
"rule": "typeorm-persistence-boundary",
"line": 3,
"message": "Business logic imports TypeORM directly.",
"hash": "c2cbaae08659"
}
],
"packages/cli/src/modules/mcp/mcp.settings.service.ts": [
{
"rule": "typeorm-persistence-boundary",
"line": 8,
"message": "packages/cli imports the TypeORM re-export `EntityManager` from @n8n/db.",
"hash": "6ff9d5453466"
},
{
"rule": "typeorm-persistence-boundary",
"line": 11,
"message": "Business logic imports TypeORM directly.",
"hash": "4c5e31384fc0"
}
],
"packages/cli/src/modules/provisioning.ee/provisioning.service.ee.ts": [
{
"rule": "typeorm-persistence-boundary",
"line": 23,
"message": "Business logic imports TypeORM directly.",
"hash": "fd2fa9036a75"
}
],
"packages/cli/src/modules/provisioning.ee/role-mapping-rule.service.ee.ts": [
{
"rule": "typeorm-persistence-boundary",
"line": 16,
"message": "Business logic imports TypeORM directly.",
"hash": "d060301c09ad"
}
],
"packages/cli/src/modules/provisioning.ee/role-resolver.service.ee.ts": [
{
"rule": "typeorm-persistence-boundary",
"line": 4,
"message": "Business logic imports TypeORM directly.",
"hash": "15464f0f7a2a"
}
],
"packages/cli/src/modules/source-control.ee/source-control-context.factory.ts": [
{
"rule": "typeorm-persistence-boundary",
"line": 5,
"message": "Business logic imports TypeORM directly.",
"hash": "cbc873aadc22"
}
],
"packages/cli/src/modules/source-control.ee/source-control-export.service.ee.ts": [
{
"rule": "typeorm-persistence-boundary",
"line": 15,
"message": "Business logic imports TypeORM directly.",
"hash": "8e15c3127f0d"
}
],
"packages/cli/src/modules/source-control.ee/source-control-import.service.ee.ts": [
{
"rule": "typeorm-persistence-boundary",
"line": 4,
"message": "packages/cli imports the TypeORM re-export `FindOptionsWhere` from @n8n/db.",
"hash": "2ca6a59e5a14"
},
{
"rule": "typeorm-persistence-boundary",
"line": 29,
"message": "Business logic imports TypeORM directly.",
"hash": "e472973ab769"
}
],
"packages/cli/src/modules/source-control.ee/source-control-scoped.service.ts": [
{
"rule": "typeorm-persistence-boundary",
"line": 12,
"message": "Business logic imports TypeORM directly.",
"hash": "12e1f73d1d57"
}
],
"packages/cli/src/modules/source-control.ee/source-control-status.service.ee.ts": [
{
"rule": "typeorm-persistence-boundary",
"line": 7,
"message": "Business logic imports TypeORM directly.",
"hash": "627dc538505e"
}
],
"packages/cli/src/modules/workflow-index/workflow-dependency-query.service.ts": [
{
"rule": "typeorm-persistence-boundary",
"line": 17,
"message": "Business logic imports TypeORM directly.",
"hash": "27681e00a358"
}
],
"packages/cli/src/scheduling/poll-trigger-node/poll-trigger-job-registrar.ts": [
{
"rule": "typeorm-persistence-boundary",
"line": 4,
"message": "packages/cli imports the TypeORM re-export `EntityManager` from @n8n/db.",
"hash": "faaf4ec6e197"
}
],
"packages/cli/src/scheduling/schedule-trigger-node/schedule-trigger-job-registrar.ts": [
{
"rule": "typeorm-persistence-boundary",
"line": 4,
"message": "packages/cli imports the TypeORM re-export `EntityManager` from @n8n/db.",
"hash": "795cfafd58f5"
}
],
"packages/cli/src/workflows/workflow-history/workflow-history.service.ts": [
{
"rule": "typeorm-persistence-boundary",
"line": 12,
"message": "Business logic imports TypeORM directly.",
"hash": "4405da700deb"
}
],
"packages/cli/src/modules/agents/integrations/n8n-memory.ts": [
{
"rule": "typeorm-persistence-boundary",
"line": 45,
"message": "Business logic imports TypeORM directly.",
"hash": "3f27e7921159"
}
],
"packages/cli/src/modules/data-table/utils/sql-utils.ts": [
{
"rule": "typeorm-persistence-boundary",
"line": 10,
"message": "Business logic imports TypeORM directly.",
"hash": "9780aefcf874"
}
],
"packages/cli/src/modules/dynamic-credentials.ee/services/credential-connection-status.service.ts": [
{
"rule": "typeorm-persistence-boundary",
"line": 4,
"message": "packages/cli imports the TypeORM re-export `In` from @n8n/db.",
"hash": "00c000824ff4"
},
{
"rule": "typeorm-persistence-boundary",
"line": 13,
"message": "Business logic imports TypeORM directly.",
"hash": "15940453447b"
}
],
"packages/cli/src/modules/dynamic-credentials.ee/services/credential-resolver-workflow.service.ts": [
{
"rule": "typeorm-persistence-boundary",
"line": 1,
"message": "packages/cli imports the TypeORM re-export `In` from @n8n/db.",
"hash": "e867f51e2b7c"
}
],
"packages/cli/src/modules/dynamic-credentials.ee/services/credential-resolver.service.ts": [
{
"rule": "typeorm-persistence-boundary",
"line": 10,
"message": "Business logic imports TypeORM directly.",
"hash": "9b6ffe4b90ce"
}
],
"packages/cli/src/modules/instance-ai/storage/typeorm-agent-checkpoint-store.ts": [
{
"rule": "typeorm-persistence-boundary",
"line": 3,
"message": "Business logic imports TypeORM directly.",
"hash": "5cac6e5f7c07"
}
],
"packages/cli/src/modules/instance-ai/storage/typeorm-agent-memory.ts": [
{
"rule": "typeorm-persistence-boundary",
"line": 30,
"message": "Business logic imports TypeORM directly.",
"hash": "eb011718641f"
}
],
"packages/cli/src/modules/instance-ai/storage/typeorm-observation-log-store.ts": [
{
"rule": "typeorm-persistence-boundary",
"line": 20,
"message": "Business logic imports TypeORM directly.",
"hash": "e45963a37c9c"
}
],
"packages/cli/src/modules/token-exchange/services/trusted-key.service.ts": [
{
"rule": "typeorm-persistence-boundary",
"line": 6,
"message": "Business logic imports TypeORM directly.",
"hash": "b44bfdcf5ae3"
}
]
}
}
+9 -5
View File
@@ -215,12 +215,16 @@ const children = getChildNodes(workflow.connections, 'NodeName', 'main', 1);
### Persistence layer & the TypeORM boundary
TypeORM (`@n8n/typeorm`) must stay in the **persistence layer** — the `@n8n/db`
package or a backend module's own `database/` folder (entity/repository files).
TypeORM (`@n8n/typeorm`) must stay in the **persistence layer**. The shared
`@n8n/db` package and backend persistence adapters can import it. A module can
colocate or group its entities and repositories according to its domain.
Business logic — services, controllers, handlers, commands, factories — must not
import from `@n8n/typeorm` (including `@n8n/typeorm/...` subpaths). In
`packages/cli` this is enforced by the `misplaced-n8n-typeorm-import` lint rule;
a new import (or an inline `eslint-disable` of the rule) fails CI.
import from `@n8n/typeorm` (including `@n8n/typeorm/...` subpaths). The
`typeorm-persistence-boundary` Code Health rule recognizes entity and repository
declarations. It scans backend packages that depend on `@n8n/db` or
`@n8n/typeorm`. Tests, migrations, and explicit helper-only adapters are
exceptions. Existing business-logic imports stay in the shrink-only Code Health
baseline. A new business-logic import fails CI.
- **Pattern:** when a query needs operators (`In`, `IsNull`, `LessThan`,
`FindOptionsWhere`, …), put it behind a **use-case-named repository method**
@@ -7,16 +7,10 @@ export default defineConfig(
backendConfig,
{
rules: {
'n8n-local-rules/misplaced-n8n-typeorm-import': 'error',
'n8n-local-rules/no-guardrail-disable': [
'error',
{
guarded: [
{
rule: 'misplaced-n8n-typeorm-import',
message:
'Keep TypeORM in the persistence layer: put the query behind a use-case repository method in @n8n/db.',
},
{
rule: 'no-unsealed-workflow-entity-write',
message: 'Route the write through a token-gated `WorkflowRepository` method.',
@@ -7,16 +7,10 @@ export default defineConfig({
options: { typeAware: true },
ignorePatterns: ['coverage/**'],
rules: {
'n8n-local-rules/misplaced-n8n-typeorm-import': 'error',
'n8n-local-rules/no-guardrail-disable': [
'error',
{
guarded: [
{
rule: 'misplaced-n8n-typeorm-import',
message:
'Keep TypeORM in the persistence layer: put the query behind a use-case repository method in @n8n/db.',
},
{
rule: 'no-unsealed-workflow-entity-write',
message: 'Route the write through a token-gated `WorkflowRepository` method.',
@@ -14,7 +14,6 @@ import {
PROJECT_EDITOR_ROLE_SLUG,
} from '@n8n/permissions';
// eslint-disable-next-line n8n-local-rules/misplaced-n8n-typeorm-import
import { In } from '@n8n/typeorm';
import type { EntityManager } from '@n8n/typeorm';
@@ -1,6 +1,5 @@
import { DatabaseConfig } from '@n8n/config';
import { Service } from '@n8n/di';
// eslint-disable-next-line n8n-local-rules/misplaced-n8n-typeorm-import
import { DataSource, QueryFailedError } from '@n8n/typeorm';
import type { EntityManager } from '@n8n/typeorm';
import { OperationalError } from 'n8n-workflow';
@@ -6,7 +6,6 @@ import { NoUselessCatchThrowRule } from './no-useless-catch-throw.js';
import { NoSkippedTestsRule } from './no-skipped-tests.js';
import { NoInterpolationInRegularStringRule } from './no-interpolation-in-regular-string.js';
import { NoDynamicImportTemplateRule } from './no-dynamic-import-template.js';
import { MisplacedN8nTypeormImportRule } from './misplaced-n8n-typeorm-import.js';
import { NoGuardrailDisableRule } from './no-guardrail-disable.js';
import { NoTypeUnsafeEventEmitterRule } from './no-type-unsafe-event-emitter.js';
import { NoUntypedConfigClassFieldRule } from './no-untyped-config-class-field.js';
@@ -44,7 +43,6 @@ export const rules = {
'no-skipped-tests': NoSkippedTestsRule,
'no-interpolation-in-regular-string': NoInterpolationInRegularStringRule,
'no-dynamic-import-template': NoDynamicImportTemplateRule,
'misplaced-n8n-typeorm-import': MisplacedN8nTypeormImportRule,
'no-guardrail-disable': NoGuardrailDisableRule,
'no-type-unsafe-event-emitter': NoTypeUnsafeEventEmitterRule,
'no-untyped-config-class-field': NoUntypedConfigClassFieldRule,
@@ -1,60 +0,0 @@
import { RuleTester } from '@typescript-eslint/rule-tester';
import { MisplacedN8nTypeormImportRule } from './misplaced-n8n-typeorm-import.js';
const ruleTester = new RuleTester();
ruleTester.run('misplaced-n8n-typeorm-import', MisplacedN8nTypeormImportRule, {
valid: [
// Persistence layer (path contains `@n8n/db`) may import TypeORM freely.
{
code: "import { In } from '@n8n/typeorm';",
filename: '/repo/packages/@n8n/db/src/repositories/foo.repository.ts',
},
{
code: "import { In } from '@n8n/db';",
filename: '/repo/packages/@n8n/db/src/index.ts',
},
// Sanctioned `@n8n/db` exports are not TypeORM re-exports.
{
code: "import { TransactionRunner, WorkflowRepository, type User } from '@n8n/db';",
filename: '/repo/packages/cli/src/services/foo.service.ts',
},
// Unrelated imports are ignored.
{
code: "import { something } from 'other-package';",
filename: '/repo/packages/cli/src/services/foo.service.ts',
},
],
invalid: [
// Direct `@n8n/typeorm` import in business logic.
{
code: "import { In } from '@n8n/typeorm';",
filename: '/repo/packages/cli/src/services/foo.service.ts',
errors: [{ messageId: 'moveImport' }],
},
// Subpath import.
{
code: "import { Foo } from '@n8n/typeorm/browser';",
filename: '/repo/packages/cli/src/services/foo.service.ts',
errors: [{ messageId: 'moveImport' }],
},
// Relabeled operator import from `@n8n/db` — one error per guarded symbol.
{
code: "import { In, Not, WorkflowRepository } from '@n8n/db';",
filename: '/repo/packages/cli/src/services/foo.service.ts',
errors: [
{ messageId: 'noTypeormViaDb', data: { name: 'In' } },
{ messageId: 'noTypeormViaDb', data: { name: 'Not' } },
],
},
// Type-only relabel is still the anti-pattern.
{
code: "import type { FindOptionsWhere, EntityManager } from '@n8n/db';",
filename: '/repo/packages/cli/src/services/foo.service.ts',
errors: [
{ messageId: 'noTypeormViaDb', data: { name: 'FindOptionsWhere' } },
{ messageId: 'noTypeormViaDb', data: { name: 'EntityManager' } },
],
},
],
});
@@ -1,66 +0,0 @@
import { ESLintUtils } from '@typescript-eslint/utils';
/**
* TypeORM operators and driver types that `@n8n/db` re-exports from `@n8n/typeorm`.
* Importing one of these from `@n8n/db` in business logic relabels the dependency
* without decoupling it, so it's flagged just like a direct `@n8n/typeorm` import.
* Keep in sync with the `@n8n/typeorm` re-export block in `@n8n/db/src/index.ts`.
*/
const GUARDED_DB_REEXPORTS = new Set([
'In',
'Like',
'MoreThanOrEqual',
'Not',
'DataSource',
'FindManyOptions',
'FindOptionsWhere',
'EntityManager',
]);
export const MisplacedN8nTypeormImportRule = ESLintUtils.RuleCreator.withoutDocs({
meta: {
type: 'problem',
docs: {
description: 'Ensure `@n8n/typeorm` is imported only from within the `@n8n/db` package.',
},
messages: {
moveImport:
'Import `@n8n/typeorm` only in the persistence layer (`@n8n/db` or a module’s `database/` folder). In business logic, add a use-case repository method instead — do not relabel the import to `@n8n/db`.',
noTypeormViaDb:
'`{{name}}` is a TypeORM operator/driver type re-exported by `@n8n/db`; importing it here relabels the dependency without decoupling. Add a use-case repository method instead of using TypeORM in business logic.',
},
schema: [],
},
defaultOptions: [],
create(context) {
if (context.filename.includes('@n8n/db')) return {};
return {
ImportDeclaration(node) {
const source = node.source.value;
if (typeof source !== 'string') return;
if (source === '@n8n/typeorm' || source.startsWith('@n8n/typeorm/')) {
context.report({ node, messageId: 'moveImport' });
return;
}
if (source === '@n8n/db') {
for (const specifier of node.specifiers) {
if (
specifier.type === 'ImportSpecifier' &&
specifier.imported.type === 'Identifier' &&
GUARDED_DB_REEXPORTS.has(specifier.imported.name)
) {
context.report({
node: specifier,
messageId: 'noTypeormViaDb',
data: { name: specifier.imported.name },
});
}
}
}
},
};
},
});
@@ -1,7 +1,7 @@
import { RuleTester } from '@typescript-eslint/rule-tester';
import { NoGuardrailDisableRule } from './no-guardrail-disable.js';
import { NoUnsealedCredentialsEntityWriteRule } from './no-unsealed-credentials-entity-write.js';
import { NoUnsealedWorkflowEntityWriteRule } from './no-unsealed-workflow-entity-write.js';
import { MisplacedN8nTypeormImportRule } from './misplaced-n8n-typeorm-import.js';
// Register the guarded rules so ESLint can resolve the disable directives under test (an
// unknown rule name in a directive would emit an extra "rule not found" error). The `-audit`
@@ -11,7 +11,7 @@ const ruleTester = new RuleTester({
plugins: {
'n8n-local-rules': {
rules: {
'misplaced-n8n-typeorm-import': MisplacedN8nTypeormImportRule,
'no-unsealed-credentials-entity-write': NoUnsealedCredentialsEntityWriteRule,
'no-unsealed-workflow-entity-write': NoUnsealedWorkflowEntityWriteRule,
'no-unsealed-workflow-entity-write-audit': NoUnsealedWorkflowEntityWriteRule,
'no-guardrail-disable': NoGuardrailDisableRule,
@@ -23,15 +23,18 @@ const ruleTester = new RuleTester({
const options: [{ guarded: Array<{ rule: string; message: string }> }] = [
{
guarded: [
{ rule: 'misplaced-n8n-typeorm-import', message: 'Add the file to the allowlist.' },
{ rule: 'no-unsealed-credentials-entity-write', message: 'Use `updateCredentials`.' },
{ rule: 'no-unsealed-workflow-entity-write', message: 'Use `updateContent`.' },
],
},
];
const typeormHit = {
const credentialsHit = {
messageId: 'noDisable' as const,
data: { rule: 'misplaced-n8n-typeorm-import', message: 'Add the file to the allowlist.' },
data: {
rule: 'no-unsealed-credentials-entity-write',
message: 'Use `updateCredentials`.',
},
};
const sealHit = {
messageId: 'noDisable' as const,
@@ -39,7 +42,7 @@ const sealHit = {
};
const blanket = {
messageId: 'noBlanketDisable' as const,
data: { rules: 'misplaced-n8n-typeorm-import`, `no-unsealed-workflow-entity-write' },
data: { rules: 'no-unsealed-credentials-entity-write`, `no-unsealed-workflow-entity-write' },
};
ruleTester.run('no-guardrail-disable', NoGuardrailDisableRule, {
@@ -48,8 +51,8 @@ ruleTester.run('no-guardrail-disable', NoGuardrailDisableRule, {
{ code: '// eslint-disable-next-line no-console\nconsole.log("x");', options },
{ code: '/* eslint-disable no-console, no-debugger */', options },
// A plain comment mentioning the rule name is not a disable directive.
{ code: '// see misplaced-n8n-typeorm-import for context', options },
{ code: '// eslint-disable-next-line-ish misplaced-n8n-typeorm-import', options },
{ code: '// see no-unsealed-credentials-entity-write for context', options },
{ code: '// eslint-disable-next-line-ish no-unsealed-credentials-entity-write', options },
// A guarded name inside the justification or inside another rule ID is not a match.
{
code: '// eslint-disable-next-line no-console -- mirrors no-unsealed-workflow-entity-write\nconsole.log("x");',
@@ -61,7 +64,7 @@ ruleTester.run('no-guardrail-disable', NoGuardrailDisableRule, {
},
// With no guarded rules configured, nothing is reported.
{
code: '// eslint-disable-next-line n8n-local-rules/misplaced-n8n-typeorm-import\nexport const x = 1;',
code: '// eslint-disable-next-line n8n-local-rules/no-unsealed-credentials-entity-write\nexport const x = 1;',
},
{ code: '// eslint-disable-next-line\nexport const x = 1;' },
// Ceiling: a blanket directive on the first line of the file leaves no position outside its
@@ -71,9 +74,9 @@ ruleTester.run('no-guardrail-disable', NoGuardrailDisableRule, {
],
invalid: [
{
code: '// eslint-disable-next-line n8n-local-rules/misplaced-n8n-typeorm-import\nexport const x = 1;',
code: '// eslint-disable-next-line n8n-local-rules/no-unsealed-credentials-entity-write\nexport const x = 1;',
options,
errors: [typeormHit],
errors: [credentialsHit],
},
{
code: '// oxlint-disable-next-line n8n-local-rules/no-unsealed-workflow-entity-write\nexport const x = 1;',
@@ -98,9 +101,9 @@ ruleTester.run('no-guardrail-disable', NoGuardrailDisableRule, {
errors: [sealHit],
},
{
code: '/* eslint-disable no-console,\n n8n-local-rules/misplaced-n8n-typeorm-import, n8n-local-rules/no-unsealed-workflow-entity-write */',
code: '/* eslint-disable no-console,\n n8n-local-rules/no-unsealed-credentials-entity-write, n8n-local-rules/no-unsealed-workflow-entity-write */',
options,
errors: [typeormHit, sealHit],
errors: [credentialsHit, sealHit],
},
// A directive with no rule list, or one that names this rule, disables the guarded rules too.
{ code: '// eslint-disable-next-line\nexport const x = 1;', options, errors: [blanket] },
@@ -120,7 +123,7 @@ ruleTester.run('no-guardrail-disable', NoGuardrailDisableRule, {
errors: [blanket],
},
{
code: 'export const y = 1;\n/* eslint-disable n8n-local-rules/no-guardrail-disable, n8n-local-rules/misplaced-n8n-typeorm-import */',
code: 'export const y = 1;\n/* eslint-disable n8n-local-rules/no-guardrail-disable, n8n-local-rules/no-unsealed-credentials-entity-write */',
options,
errors: [blanket],
},
+12 -24
View File
@@ -7,37 +7,25 @@ for repo-wide conventions.
TypeORM belongs in the **persistence layer**, not in business logic.
**Allowed to import `@n8n/typeorm`** — entity and repository files, including the
ones co-located inside `src/modules/**`:
- `src/databases/**`
- a module's `database/entities/**` and `database/repositories/**`
- files named `*.entity.ts` or `*.repository.ts` (some modules keep these at the
module root)
These are exempted in `eslint.config.mjs` **by location**, so a genuine `@Entity`
or repository class is never flagged — including the few entity files that lack
the `.entity.ts` suffix (they live in a `database/entities/` folder).
**Allowed to import `@n8n/typeorm`** — files that declare an `@Entity` class or a
repository class that extends TypeORM `Repository` or n8n `BaseRepository`.
Their folder and filename do not affect the rule. Tests and migration tooling use
Code Health exceptions. Helper-only persistence adapters need a narrow,
auditable entry in the `typeorm-persistence-boundary` rule.
**Not allowed** — business logic (services, controllers, public-api handlers,
commands, factories) must not import `@n8n/typeorm` or `@n8n/typeorm/...`
subpaths. The `misplaced-n8n-typeorm-import` lint rule enforces this; a new
import — or an inline `eslint-disable` of the rule — fails CI. The same rule also
subpaths. The `typeorm-persistence-boundary` Code Health rule enforces this. A
new import fails CI. The same rule also
catches the **relabel dodge**: importing a TypeORM operator/driver type (`In`,
`Not`, `FindOptionsWhere`, `EntityManager`, …) from `@n8n/db`, which
re-exports them from `@n8n/typeorm` — that silences the direct-import check
without decoupling anything. Existing leaks of both kinds are tracked in two
`files`-scoped allowlists in `eslint.config.mjs` (direct `@n8n/typeorm` imports,
and `@n8n/db` relabels) that only ever shrink: never add to them, and never
suppress the rule inline.
without decoupling anything. Existing leaks of both kinds are tracked in the
shrink-only Code Health baseline. Do not add new baseline entries.
Distinct from that shrink-only ratchet, two files are **permanently** exempted in
`eslint.config.mjs` for legitimate TypeORM use outside the persistence tree —
these are sanctioned, not migration targets, so don't try to relocate them or
suppress the rule:
- `src/commands/db/revert.ts` — `MigrationExecutor` (CLI migration tooling)
- `src/security-audit/security-audit.repository.ts` — `PackagesRepository`
Distinct from that shrink-only ratchet, `src/commands/db/revert.ts` is an exact
exception for CLI migration tooling. Composition-based repositories use exact
exceptions. Repository subclasses need no exception.
Need an operator query (`In`, `IsNull`, `FindOptionsWhere`, …)? Add a
use-case-named repository method (plain parameters, domain-shaped return) rather
-139
View File
@@ -68,17 +68,11 @@ export default defineConfig(
{
rules: {
'n8n-local-rules/no-dynamic-import-template': 'error',
'n8n-local-rules/misplaced-n8n-typeorm-import': 'error',
// Ratchets: the allowlists below only shrink, so an inline disable is the one way to add a violation.
'n8n-local-rules/no-guardrail-disable': [
'error',
{
guarded: [
{
rule: 'misplaced-n8n-typeorm-import',
message:
'Keep TypeORM in the persistence layer: put the query behind a use-case repository method in @n8n/db.',
},
{
rule: 'no-repository-in-public-api-handler',
message: 'Call a service instead of reaching the repository.',
@@ -270,139 +264,6 @@ export default defineConfig(
'unicorn/filename-case': 'off',
},
},
{
// @n8n/typeorm belongs in the persistence layer; exempt entities/repositories.
// Path-based (not suffix-only) so entity files without the `.entity.ts` suffix are covered.
files: [
'./src/databases/**/*.ts',
'./src/modules/**/database/entities/**/*.ts',
'./src/modules/**/database/repositories/**/*.ts',
'./src/modules/**/*.entity.ts',
'./src/modules/**/*.repository.ts',
'./test/**/*.ts',
'./src/**/__tests__/**/*.ts',
],
rules: {
'n8n-local-rules/misplaced-n8n-typeorm-import': 'off',
},
},
{
// Permanent: legitimate TypeORM use outside the persistence tree. Do not remove.
// - db/revert.ts: MigrationExecutor (CLI migration tooling)
// - security-audit.repository.ts: PackagesRepository, relocation tracked separately
files: ['./src/commands/db/revert.ts', './src/security-audit/security-audit.repository.ts'],
rules: {
'n8n-local-rules/misplaced-n8n-typeorm-import': 'off',
},
},
{
// Ratchet allowlist: known @n8n/typeorm leaks pending migration to @n8n/db.
// NEVER add to this list — a new leak must fail CI. Entries are removed as each file migrates.
files: [
// credentials/
'./src/credentials/credential-connection-status-provider.interface.ts',
'./src/credentials/credential-connection-status-proxy.ts',
'./src/credentials/credential-dependency.service.ts',
'./src/credentials/credentials.controller.ts',
'./src/credentials/credentials.service.ee.ts',
'./src/credentials/credentials.service.ts',
// workflows/
'./src/workflows/workflow-finder.service.ts',
'./src/workflows/workflow-history/workflow-history.service.ts',
'./src/workflows/workflow-validation.service.ts',
'./src/workflows/workflow.service.ee.ts',
'./src/workflows/workflow.service.ts',
'./src/workflows/workflows.controller.ts',
// services/
'./src/services/export.service.ts',
'./src/services/folder.service.ts',
'./src/services/hooks.service.ts',
'./src/services/import.service.ts',
'./src/services/project.service.ee.ts',
'./src/services/public-api-key.service.ts',
// commands / controllers / eventbus / evaluation / public-api
'./src/commands/import/credentials.ts',
'./src/evaluation.ee/test-runner/test-runner.service.ee.ts',
// modules/** non-persistence services surfaced by narrowing the exemption
'./src/modules/agents/agent-knowledge.service.ts',
'./src/modules/agents/agent-publish.service.ts',
'./src/modules/agents/agent-task.service.ts',
'./src/modules/agents/builder/agents-builder.service.ts',
'./src/modules/agents/instance-ai-builder-delegate.adapter.ts',
'./src/modules/agents/integrations/n8n-memory.ts',
'./src/modules/agents/tools/workflow-tool-workflow-resolver.ts',
'./src/modules/breaking-changes/breaking-changes.service.ts',
'./src/modules/chat-hub/chat-hub-credentials.service.ts',
'./src/modules/chat-hub/chat-hub-workflow.service.ts',
'./src/modules/chat-hub/chat-hub.attachment.service.ts',
'./src/modules/data-table/data-table-ddl.service.ts',
'./src/modules/data-table/data-table.service.ts',
'./src/modules/data-table/utils/sql-utils.ts',
'./src/modules/dynamic-credentials.ee/services/credential-connection-status.service.ts',
'./src/modules/dynamic-credentials.ee/services/credential-resolver.service.ts',
'./src/modules/external-secrets.ee/secrets-providers-connections.service.ee.ts',
'./src/modules/favorites/favorites.service.ts',
'./src/modules/instance-ai/instance-ai.adapter.service.ts',
'./src/modules/instance-ai/mcp/instance-ai-mcp-registry.service.ts',
'./src/modules/instance-ai/storage/typeorm-agent-checkpoint-store.ts',
'./src/modules/instance-ai/storage/typeorm-agent-memory.ts',
'./src/modules/instance-ai/storage/typeorm-observation-log-store.ts',
'./src/modules/instance-ai/suspended-thread-persistence.service.ts',
'./src/modules/log-streaming.ee/log-streaming-destination.service.ts',
'./src/modules/mcp/mcp-api-key.service.ts',
'./src/modules/mcp/mcp.settings.service.ts',
'./src/modules/oauth-jwe/oauth-jwe-key.service.ts',
'./src/modules/provisioning.ee/provisioning.service.ee.ts',
'./src/modules/provisioning.ee/role-mapping-rule.service.ee.ts',
'./src/modules/provisioning.ee/role-resolver.service.ee.ts',
'./src/modules/source-control.ee/source-control-context.factory.ts',
'./src/modules/source-control.ee/source-control-export.service.ee.ts',
'./src/modules/source-control.ee/source-control-import.service.ee.ts',
'./src/modules/source-control.ee/source-control-scoped.service.ts',
'./src/modules/source-control.ee/source-control-status.service.ee.ts',
'./src/modules/token-exchange/services/trusted-key.service.ts',
'./src/modules/workflow-index/workflow-dependency-query.service.ts',
],
rules: {
'n8n-local-rules/misplaced-n8n-typeorm-import': 'off',
},
},
{
// Ratchet allowlist: known relabel leaks — business logic importing a TypeORM
// operator/driver type (`In`, `Not`, `EntityManager`, `FindOptionsWhere`, …) from
// `@n8n/db` instead of `@n8n/typeorm`. Same rule, same shrink-only contract:
// NEVER add to this list — a new relabel must fail CI. Entries removed as each file
// drops TypeORM in favor of a use-case repository method.
files: [
'./src/binary-data/database.manager.ts',
'./src/events/relays/telemetry.event-relay.ts',
'./src/executions/execution-data/db-store.ts',
'./src/executions/execution-persistence.ts',
'./src/executions/execution-recovery.service.ts',
'./src/instance-settings-loader/loaders/log-streaming.instance-settings-loader.ts',
'./src/modules/agents/agents.service.ts',
'./src/modules/chat-hub/chat-hub-agent.service.ts',
'./src/modules/chat-hub/chat-hub-title.service.ts',
'./src/modules/chat-hub/chat-hub-tool.service.ts',
'./src/modules/chat-hub/chat-hub.models.service.ts',
'./src/modules/chat-hub/chat-hub.service.ts',
'./src/modules/chat-hub/chat-hub.settings.service.ts',
'./src/modules/dynamic-credentials.ee/services/credential-resolver-workflow.service.ts',
'./src/permissions.ee/check-access.ts',
'./src/scheduling/durable-job-provisioner.ts',
'./src/scheduling/durable-scheduler.ts',
'./src/scheduling/poll-trigger-node/poll-trigger-job-registrar.ts',
'./src/scheduling/schedule-trigger-node/schedule-trigger-job-registrar.ts',
'./src/security-audit/risk-reporters/credentials-risk-reporter.ts',
'./src/services/role-cache.service.ts',
'./src/services/role.service.ts',
'./src/services/user.service.ts',
'./src/workflows/workflow-creation.service.ts',
],
rules: {
'n8n-local-rules/misplaced-n8n-typeorm-import': 'off',
},
},
{
// Sanctioned `@OnLeaderTakeover` users. Permanent, but additions need review:
// the system task runner itself, services that hold live resources on the
-142
View File
@@ -67,17 +67,11 @@ export default defineConfig({
ignorePatterns: ['scripts/**/*.mjs', 'vitest.*.ts', 'coverage/**'],
rules: {
'n8n-local-rules/no-dynamic-import-template': 'error',
'n8n-local-rules/misplaced-n8n-typeorm-import': 'error',
// Ratchets: the allowlists below only shrink, so an inline disable is the one way to add a violation.
'n8n-local-rules/no-guardrail-disable': [
'error',
{
guarded: [
{
rule: 'misplaced-n8n-typeorm-import',
message:
'Keep TypeORM in the persistence layer: put the query behind a use-case repository method in @n8n/db.',
},
{
rule: 'no-repository-in-public-api-handler',
message: 'Call a service instead of reaching the repository.',
@@ -270,142 +264,6 @@ export default defineConfig({
'unicorn/filename-case': 'off',
},
},
{
// @n8n/typeorm belongs in the persistence layer; exempt entities/repositories.
// Path-based (not suffix-only) so entity files without the `.entity.ts` suffix are covered.
files: [
'./src/databases/**/*.ts',
'./src/modules/**/database/entities/**/*.ts',
'./src/modules/**/database/repositories/**/*.ts',
'./src/modules/**/*.entity.ts',
'./src/modules/**/*.repository.ts',
'./test/**/*.ts',
'./src/**/__tests__/**/*.ts',
],
rules: {
'n8n-local-rules/misplaced-n8n-typeorm-import': 'off',
},
},
{
// Permanent: legitimate TypeORM use outside the persistence tree. Do not remove.
// - db/revert.ts: MigrationExecutor (CLI migration tooling)
// - security-audit.repository.ts: PackagesRepository, relocation tracked separately
files: ['./src/commands/db/revert.ts', './src/security-audit/security-audit.repository.ts'],
rules: {
'n8n-local-rules/misplaced-n8n-typeorm-import': 'off',
},
},
{
// Ratchet allowlist: known @n8n/typeorm leaks pending migration to @n8n/db.
// NEVER add to this list — a new leak must fail CI. Entries are removed as each file migrates.
files: [
// credentials/
'./src/credentials-helper.ts',
'./src/credentials/credential-connection-status-provider.interface.ts',
'./src/credentials/credential-connection-status-proxy.ts',
'./src/credentials/credential-dependency.service.ts',
'./src/credentials/credentials.controller.ts',
'./src/credentials/credentials.service.ee.ts',
'./src/credentials/credentials.service.ts',
// workflows/
'./src/workflows/workflow-finder.service.ts',
'./src/workflows/workflow-history/workflow-history.service.ts',
'./src/workflows/workflow-validation.service.ts',
'./src/workflows/workflow.service.ee.ts',
'./src/workflows/workflow.service.ts',
'./src/workflows/workflows.controller.ts',
// services/
'./src/services/export.service.ts',
'./src/services/folder.service.ts',
'./src/services/hooks.service.ts',
'./src/services/import.service.ts',
'./src/services/ownership-transfer/ownership-transfer-handler.registry.ts',
'./src/services/project.service.ee.ts',
'./src/services/public-api-key.service.ts',
// commands / controllers / eventbus / evaluation / public-api
'./src/commands/import/credentials.ts',
'./src/evaluation.ee/test-runner/test-runner.service.ee.ts',
// modules/** non-persistence services surfaced by narrowing the exemption
'./src/modules/agents/agent-knowledge.service.ts',
'./src/modules/agents/agent-publish.service.ts',
'./src/modules/agents/agent-task.service.ts',
'./src/modules/agents/builder/agents-builder.service.ts',
'./src/modules/agents/instance-ai-builder-delegate.adapter.ts',
'./src/modules/agents/integrations/n8n-memory.ts',
'./src/modules/agents/tools/workflow-tool-workflow-resolver.ts',
'./src/modules/breaking-changes/breaking-changes.service.ts',
'./src/modules/chat-hub/chat-hub-credentials.service.ts',
'./src/modules/chat-hub/chat-hub-workflow.service.ts',
'./src/modules/chat-hub/chat-hub.attachment.service.ts',
'./src/modules/data-table/data-table-ddl.service.ts',
'./src/modules/data-table/data-table.service.ts',
'./src/modules/data-table/utils/sql-utils.ts',
'./src/modules/dynamic-credentials.ee/services/credential-connection-status.service.ts',
'./src/modules/dynamic-credentials.ee/services/credential-resolver.service.ts',
'./src/modules/external-secrets.ee/secrets-providers-connections.service.ee.ts',
'./src/modules/favorites/favorites.service.ts',
'./src/modules/instance-ai/instance-ai.adapter.service.ts',
'./src/modules/instance-ai/mcp/instance-ai-mcp-registry.service.ts',
'./src/modules/instance-ai/storage/typeorm-agent-checkpoint-store.ts',
'./src/modules/instance-ai/storage/typeorm-agent-memory.ts',
'./src/modules/instance-ai/storage/typeorm-observation-log-store.ts',
'./src/modules/instance-ai/suspended-thread-persistence.service.ts',
'./src/modules/log-streaming.ee/log-streaming-destination.service.ts',
'./src/modules/mcp/mcp-api-key.service.ts',
'./src/modules/mcp/mcp.settings.service.ts',
'./src/modules/oauth-jwe/oauth-jwe-key.service.ts',
'./src/modules/provisioning.ee/provisioning.service.ee.ts',
'./src/modules/provisioning.ee/role-mapping-rule.service.ee.ts',
'./src/modules/provisioning.ee/role-resolver.service.ee.ts',
'./src/modules/source-control.ee/source-control-context.factory.ts',
'./src/modules/source-control.ee/source-control-export.service.ee.ts',
'./src/modules/source-control.ee/source-control-import.service.ee.ts',
'./src/modules/source-control.ee/source-control-scoped.service.ts',
'./src/modules/source-control.ee/source-control-status.service.ee.ts',
'./src/modules/token-exchange/services/trusted-key.service.ts',
'./src/modules/workflow-index/workflow-dependency-query.service.ts',
],
rules: {
'n8n-local-rules/misplaced-n8n-typeorm-import': 'off',
},
},
{
// Ratchet allowlist: known relabel leaks — business logic importing a TypeORM
// operator/driver type (`In`, `Not`, `EntityManager`, `FindOptionsWhere`, …) from
// `@n8n/db` instead of `@n8n/typeorm`. Same rule, same shrink-only contract:
// NEVER add to this list — a new relabel must fail CI. Entries removed as each file
// drops TypeORM in favor of a use-case repository method.
files: [
'./src/binary-data/database.manager.ts',
'./src/events/relays/telemetry.event-relay.ts',
'./src/executions/execution-data/db-store.ts',
'./src/executions/execution-persistence.ts',
'./src/executions/execution-recovery.service.ts',
'./src/executions/execution.service.ts',
'./src/instance-settings-loader/loaders/log-streaming.instance-settings-loader.ts',
'./src/modules/agents/agents.service.ts',
'./src/modules/chat-hub/chat-hub-agent.service.ts',
'./src/modules/chat-hub/chat-hub-title.service.ts',
'./src/modules/chat-hub/chat-hub-tool.service.ts',
'./src/modules/chat-hub/chat-hub.models.service.ts',
'./src/modules/chat-hub/chat-hub.service.ts',
'./src/modules/chat-hub/chat-hub.settings.service.ts',
'./src/modules/dynamic-credentials.ee/services/credential-resolver-workflow.service.ts',
'./src/permissions.ee/check-access.ts',
'./src/scheduling/durable-job-provisioner.ts',
'./src/scheduling/durable-scheduler.ts',
'./src/scheduling/poll-trigger-node/poll-trigger-job-registrar.ts',
'./src/scheduling/schedule-trigger-node/schedule-trigger-job-registrar.ts',
'./src/security-audit/risk-reporters/credentials-risk-reporter.ts',
'./src/services/role-cache.service.ts',
'./src/services/role.service.ts',
'./src/services/user.service.ts',
'./src/workflows/workflow-creation.service.ts',
],
rules: {
'n8n-local-rules/misplaced-n8n-typeorm-import': 'off',
},
},
{
// Sanctioned `@OnLeaderTakeover` users. Permanent, but additions need review:
// the system task runner itself, services that hold live resources on the
@@ -5,32 +5,4 @@ export default defineConfig({
extends: [backendConfig],
options: { typeAware: true },
ignorePatterns: ['coverage/**', 'dist/**'],
rules: {
'n8n-local-rules/misplaced-n8n-typeorm-import': 'error',
'n8n-local-rules/no-guardrail-disable': [
'error',
{
guarded: [
{
rule: 'misplaced-n8n-typeorm-import',
message:
'Keep TypeORM in the persistence layer. Put the query behind a use-case repository method.',
},
],
},
],
},
overrides: [
{
files: [
'./src/**/*.entity.ts',
'./src/**/*.repository.ts',
'./src/**/__tests__/**/*.ts',
'./src/**/*.test.ts',
],
rules: {
'n8n-local-rules/misplaced-n8n-typeorm-import': 'off',
},
},
],
});
@@ -10,6 +10,7 @@ Scans repository files for structural and dependency-policy violations:
- **Hardcoded catalog deps** — dependencies using a pinned version when `pnpm-workspace.yaml` already defines a catalog entry
- **Cross-package version drift** — the same dependency appearing in multiple packages with different versions
- **Encryption boundary coverage** — every package that depends on `n8n-core` or `@n8n/db` composes the encryption-boundary ESLint config at `error` severity and contains no ESLint directive that silences it
- **TypeORM persistence boundary** — business logic cannot import TypeORM directly or through guarded `@n8n/db` re-exports; entity and repository declarations can use any package layout
## Usage
@@ -12,6 +12,7 @@ import { SingleInstanceLibsRule } from './rules/single-instance-libs.rule.js';
import { SingleInstanceLockfileRule } from './rules/single-instance-lockfile.rule.js';
import { StaleOverridesRule } from './rules/stale-overrides.rule.js';
import { SubpathPurityRule } from './rules/subpath-purity.rule.js';
import { TypeormPersistenceBoundaryRule } from './rules/typeorm-persistence-boundary.rule.js';
import { WorkflowPrTargetSafetyRule } from './rules/workflow-pr-target-safety.rule.js';
export type { CodeHealthContext } from './context.js';
@@ -26,6 +27,7 @@ export { SingleInstanceLockfileRule } from './rules/single-instance-lockfile.rul
export { StaleOverridesRule } from './rules/stale-overrides.rule.js';
export { SubpathPurityRule } from './rules/subpath-purity.rule.js';
export type { SubpathSpec } from './rules/subpath-purity.rule.js';
export { TypeormPersistenceBoundaryRule } from './rules/typeorm-persistence-boundary.rule.js';
export { WorkflowPrTargetSafetyRule } from './rules/workflow-pr-target-safety.rule.js';
const defaultRuleSettings: RuleSettingsMap = {
@@ -65,6 +67,11 @@ const defaultRuleSettings: RuleSettingsMap = {
severity: 'error',
options: {},
},
'typeorm-persistence-boundary': {
enabled: true,
severity: 'error',
options: {},
},
'lint-config-layering': {
enabled: true,
severity: 'error',
@@ -154,6 +161,7 @@ export function createDefaultRunner(settings?: RuleSettingsMap): RuleRunner<Code
runner.registerRule(new SingleInstanceLibsRule());
runner.registerRule(new SingleInstanceLockfileRule());
runner.registerRule(new EncryptionBoundaryRule());
runner.registerRule(new TypeormPersistenceBoundaryRule());
runner.registerRule(new LintConfigLayeringRule());
runner.registerRule(new StaleOverridesRule());
runner.registerRule(new EndpointScopeCoverageRule());
@@ -0,0 +1,185 @@
import * as fs from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import type { CodeHealthContext } from '../context.js';
import { TypeormPersistenceBoundaryRule } from './typeorm-persistence-boundary.rule.js';
describe('TypeormPersistenceBoundaryRule', () => {
let rootDir: string;
let rule: TypeormPersistenceBoundaryRule;
beforeEach(() => {
rootDir = fs.mkdtempSync(path.join(os.tmpdir(), 'code-health-typeorm-boundary-'));
rule = new TypeormPersistenceBoundaryRule();
});
afterEach(() => {
fs.rmSync(rootDir, { recursive: true, force: true });
});
function write(relativePath: string, content: string): void {
const fullPath = path.join(rootDir, relativePath);
fs.mkdirSync(path.dirname(fullPath), { recursive: true });
fs.writeFileSync(fullPath, content);
}
function writePackage(name = '@n8n/example'): void {
write(
'packages/example/package.json',
JSON.stringify({
name,
dependencies: { '@n8n/db': 'workspace:*', '@n8n/typeorm': 'workspace:*' },
}),
);
}
async function analyze(): Promise<string[]> {
const context: CodeHealthContext = { rootDir };
const violations = await rule.analyze(context);
return violations.map(
(violation) =>
`${path.relative(rootDir, violation.file)}:${violation.line} ${violation.message}`,
);
}
it('rejects direct TypeORM imports from business logic in any folder', async () => {
writePackage();
write(
'packages/example/src/database/workflow.service.ts',
"import { In } from '@n8n/typeorm';\nexport class WorkflowService {}\n",
);
expect(await analyze()).toEqual([
expect.stringContaining('workflow.service.ts:1 Business logic imports TypeORM directly.'),
]);
});
it('accepts named and namespace entity declarations in different layouts', async () => {
writePackage();
write(
'packages/example/src/audit/model.ts',
"import { Entity as OrmEntity } from '@n8n/typeorm';\n@OrmEntity()\nexport class Audit {}\n",
);
write(
'packages/example/src/domain/record.ts',
"import * as TypeOrm from '@n8n/typeorm';\[email protected]()\nexport class Record {}\n",
);
expect(await analyze()).toEqual([]);
});
it('accepts TypeORM Repository and n8n BaseRepository subclasses', async () => {
writePackage();
write(
'packages/example/src/audit/store.ts',
"import { Repository as OrmRepository } from '@n8n/typeorm';\nexport class AuditStore extends OrmRepository<Audit> {}\n",
);
write(
'packages/example/src/domain/store.ts',
"import * as TypeOrm from '@n8n/typeorm';\nexport class DomainStore extends TypeOrm.Repository<Audit> {}\n",
);
write(
'packages/example/src/records/store.ts',
"import { BaseRepository } from '@n8n/db';\nimport { In } from '@n8n/typeorm';\nexport class RecordStore extends BaseRepository<Record> {}\n",
);
expect(await analyze()).toEqual([]);
});
it('does not infer persistence from a filename or imported repository', async () => {
writePackage();
write(
'packages/example/src/workflow.repository.ts',
"import { In } from '@n8n/typeorm';\nimport { WorkflowRepository } from '@n8n/db';\nexport class WorkflowService {}\n",
);
expect(await analyze()).toHaveLength(1);
});
it('always rejects guarded TypeORM re-exports from @n8n/db', async () => {
writePackage();
write(
'packages/example/src/audit/store.ts',
"import { BaseRepository, In } from '@n8n/db';\nexport class AuditStore extends BaseRepository<Audit> {}\n",
);
expect(await analyze()).toEqual([
expect.stringContaining('imports the TypeORM re-export `In` from @n8n/db'),
]);
});
it('rejects guarded TypeORM re-exports accessed through an @n8n/db namespace', async () => {
writePackage();
write(
'packages/example/src/workflow.service.ts',
"import * as db from '@n8n/db';\nexport const ids = db.In(['1']);\n",
);
expect(await analyze()).toEqual([
expect.stringContaining('accesses the TypeORM re-export `In` through the @n8n/db namespace'),
]);
});
it('skips tests but scans nested production test and migration folders', async () => {
writePackage();
const typeormImport = "import { In } from '@n8n/typeorm';\n";
write('packages/example/src/audit.test.ts', typeormImport);
write('packages/example/src/__tests__/audit.ts', typeormImport);
write('packages/example/src/domain/test/query.ts', typeormImport);
write('packages/example/src/domain/migrations/query.ts', typeormImport);
const violations = await analyze();
expect(violations).toHaveLength(2);
expect(
violations.every((violation) => violation.includes('Business logic imports TypeORM')),
).toBe(true);
});
it('supports explicit migration roots', async () => {
writePackage();
write(
'packages/example/src/database/migrations/1-Init.ts',
"import { QueryRunner } from '@n8n/typeorm';\n",
);
rule.configure({
options: { allowedDirectories: ['packages/example/src/database/migrations'] },
});
expect(await analyze()).toEqual([]);
});
it('supports exact exceptions for composition-based adapters', async () => {
writePackage();
write(
'packages/example/src/query-adapter.ts',
"import { DataSource } from '@n8n/typeorm';\nexport class QueryAdapter {}\n",
);
rule.configure({
options: { allowedFiles: ['packages/example/src/query-adapter.ts'] },
});
expect(await analyze()).toEqual([]);
});
it('does not scan the @n8n/db persistence package', async () => {
writePackage('@n8n/db');
write(
'packages/example/src/service.ts',
"import { In } from '@n8n/typeorm';\nexport class Service {}\n",
);
expect(await analyze()).toEqual([]);
});
it('does not scan the backend test utility package', async () => {
writePackage('@n8n/backend-test-utils');
write(
'packages/example/src/test-db.ts',
"import { In } from '@n8n/typeorm';\nexport class TestDb {}\n",
);
expect(await analyze()).toEqual([]);
});
});
@@ -0,0 +1,234 @@
import { BaseRule } from '@n8n/rules-engine';
import type { Violation } from '@n8n/rules-engine';
import { Node, Project, SyntaxKind } from 'ts-morph';
import type { ImportDeclaration, SourceFile } from 'ts-morph';
import * as path from 'node:path';
import type { CodeHealthContext } from '../context.js';
import {
findPackageJsonFiles,
parsePackageJson,
relativeDir,
} from '../utils/package-json-scanner.js';
const TYPEORM_PACKAGE = '@n8n/typeorm';
const DB_PACKAGE = '@n8n/db';
const GUARDED_DB_REEXPORTS = new Set([
'In',
'Like',
'MoreThanOrEqual',
'Not',
'DataSource',
'FindManyOptions',
'FindOptionsWhere',
'EntityManager',
]);
const DEFAULT_ALLOWED_FILES = [
// CLI migration tooling owns the TypeORM migration executor.
'packages/cli/src/commands/db/revert.ts',
// These adapters use composition instead of a repository base class.
'packages/cli/src/modules/data-table/data-table-rows.repository.ts',
'packages/cli/src/modules/instance-ai/repositories/instance-ai-conversation-history.repository.ts',
// Engine v2 keeps its persistence infrastructure and composition root in one package.
'packages/@n8n/engine/src/database/create-stores.ts',
'packages/@n8n/engine/src/database/data-source.ts',
'packages/@n8n/engine/src/database/typeorm-execution-store.ts',
'packages/@n8n/engine/src/database/typeorm-execution-view-store.ts',
'packages/@n8n/engine/src/database/typeorm-step-store.ts',
'packages/@n8n/engine/src/runtime/create-engine-runtime.ts',
];
const DEFAULT_ALLOWED_DIRECTORIES = [
'packages/cli/src/databases/migrations',
'packages/@n8n/engine/src/database/migrations',
];
// This package exists only to build database-backed test fixtures.
const DEFAULT_EXEMPT_PACKAGES = ['@n8n/backend-test-utils'];
const SOURCE_GLOBS = [
'src/**/*.ts',
'!src/**/*.test.ts',
'!src/**/*.spec.ts',
'!src/**/__tests__/**',
];
function stringArrayOption(value: unknown, fallback: string[]): string[] {
return Array.isArray(value) && value.every((entry): entry is string => typeof entry === 'string')
? value
: fallback;
}
function normalizedRelativePath(rootDir: string, filePath: string): string {
return path.relative(rootDir, filePath).split(path.sep).join('/');
}
function namedImportBindings(declaration: ImportDeclaration, importedName: string): string[] {
return declaration
.getNamedImports()
.filter((specifier) => specifier.getName() === importedName)
.map((specifier) => specifier.getAliasNode()?.getText() ?? specifier.getName());
}
function isPersistenceAdapter(file: SourceFile): boolean {
const typeormImports = file
.getImportDeclarations()
.filter((declaration) => declaration.getModuleSpecifierValue() === TYPEORM_PACKAGE);
const dbImports = file
.getImportDeclarations()
.filter((declaration) => declaration.getModuleSpecifierValue() === DB_PACKAGE);
const entityNames = new Set(
typeormImports.flatMap((declaration) => namedImportBindings(declaration, 'Entity')),
);
const repositoryNames = new Set([
...typeormImports.flatMap((declaration) => namedImportBindings(declaration, 'Repository')),
...dbImports.flatMap((declaration) => namedImportBindings(declaration, 'BaseRepository')),
]);
const namespaceNames = new Set(
typeormImports.flatMap((declaration) => {
const namespace = declaration.getNamespaceImport();
return namespace ? [namespace.getText()] : [];
}),
);
return file.getClasses().some((cls) => {
const hasEntityDecorator = cls.getDecorators().some((decorator) => {
const expression = decorator.getExpression();
const target = Node.isCallExpression(expression) ? expression.getExpression() : expression;
const name = target.getText();
return entityNames.has(name) || [...namespaceNames].some((ns) => name === `${ns}.Entity`);
});
if (hasEntityDecorator) return true;
const base = cls.getExtends()?.getExpression().getText();
return (
base !== undefined &&
(repositoryNames.has(base) || [...namespaceNames].some((ns) => base === `${ns}.Repository`))
);
});
}
/** Enforces the TypeORM persistence boundary without prescribing a package layout. */
export class TypeormPersistenceBoundaryRule extends BaseRule<CodeHealthContext> {
readonly id = 'typeorm-persistence-boundary';
readonly name = 'TypeORM Persistence Boundary';
readonly description =
'Business logic must not import TypeORM. Entity and repository declarations may import it in any package layout.';
readonly severity = 'error' as const;
async analyze(context: CodeHealthContext): Promise<Violation[]> {
const allowedFiles = new Set(
stringArrayOption(this.getOptions().allowedFiles, DEFAULT_ALLOWED_FILES),
);
const allowedDirectories = stringArrayOption(
this.getOptions().allowedDirectories,
DEFAULT_ALLOWED_DIRECTORIES,
);
const exemptPackages = new Set(
stringArrayOption(this.getOptions().exemptPackages, DEFAULT_EXEMPT_PACKAGES),
);
const violations: Violation[] = [];
for (const packageJsonPath of await findPackageJsonFiles(context.rootDir)) {
const packageInfo = parsePackageJson(packageJsonPath);
if (packageInfo.packageName === DB_PACKAGE || exemptPackages.has(packageInfo.packageName)) {
continue;
}
if (
!packageInfo.deps.some((dependency) =>
[TYPEORM_PACKAGE, DB_PACKAGE].includes(dependency.name),
)
) {
continue;
}
const packageDir = path.dirname(packageJsonPath);
const project = new Project({
skipAddingFilesFromTsConfig: true,
skipFileDependencyResolution: true,
});
const packagePath = relativeDir(context.rootDir, packageJsonPath);
const patterns = SOURCE_GLOBS.map((glob) => {
const prefix = glob.startsWith('!') ? '!' : '';
const relativeGlob = prefix ? glob.slice(1) : glob;
return `${prefix}${packageDir}/${relativeGlob}`.replaceAll('\\', '/');
});
project.addSourceFilesAtPaths(patterns);
for (const file of project.getSourceFiles()) {
const relativeFile = normalizedRelativePath(context.rootDir, file.getFilePath());
if (
allowedFiles.has(relativeFile) ||
allowedDirectories.some((directory) => relativeFile.startsWith(`${directory}/`))
) {
continue;
}
violations.push(...this.analyzeFile(file, packagePath));
}
}
return violations;
}
private analyzeFile(file: SourceFile, packagePath: string): Violation[] {
const violations: Violation[] = [];
const adapter = isPersistenceAdapter(file);
for (const declaration of file.getImportDeclarations()) {
const source = declaration.getModuleSpecifierValue();
if (source === TYPEORM_PACKAGE || source.startsWith(`${TYPEORM_PACKAGE}/`)) {
if (!adapter) {
violations.push(
this.importViolation(
declaration,
'Business logic imports TypeORM directly.',
'Add a use-case repository method, or move this query into a persistence adapter.',
),
);
}
continue;
}
if (source !== DB_PACKAGE) continue;
for (const specifier of declaration.getNamedImports()) {
const importedName = specifier.getName();
if (!GUARDED_DB_REEXPORTS.has(importedName)) continue;
violations.push(
this.importViolation(
specifier,
`${packagePath} imports the TypeORM re-export \`${importedName}\` from @n8n/db.`,
'Import TypeORM directly in a persistence adapter. Add a use-case repository method for business logic.',
),
);
}
const namespace = declaration.getNamespaceImport()?.getText();
if (!namespace) continue;
for (const access of file.getDescendantsOfKind(SyntaxKind.PropertyAccessExpression)) {
if (access.getExpression().getText() !== namespace) continue;
const importedName = access.getName();
if (!GUARDED_DB_REEXPORTS.has(importedName)) continue;
violations.push(
this.importViolation(
access,
`${packagePath} accesses the TypeORM re-export \`${importedName}\` through the @n8n/db namespace.`,
'Import TypeORM directly in a persistence adapter. Add a use-case repository method for business logic.',
),
);
}
}
return violations;
}
private importViolation(node: Node, message: string, suggestion: string): Violation {
const position = node.getSourceFile().getLineAndColumnAtPos(node.getStart());
return this.createViolation(
node.getSourceFile().getFilePath(),
position.line,
position.column,
message,
suggestion,
);
}
}