From 16dc6fe4037c84652ee34847cfe5e72e9e06a42f Mon Sep 17 00:00:00 2001 From: Charlie Kolb Date: Wed, 7 Oct 2026 08:39:18 +0000 Subject: [PATCH] refactor(core): Enforce TypeORM persistence boundary with Code Health (#40384) --- .../qa-dx/ratchets-and-allowlists.md | 3 +- .code-health-baseline.json | 644 ++++++++++++++++++ AGENTS.md | 14 +- .../@n8n/backend-services/eslint.config.mjs | 6 - .../@n8n/backend-services/oxlint.config.mts | 6 - .../db/src/services/auth.roles.service.ts | 1 - .../@n8n/db/src/services/db-lock.service.ts | 1 - .../@n8n/eslint-config/src/rules/index.ts | 2 - .../misplaced-n8n-typeorm-import.test.ts | 60 -- .../src/rules/misplaced-n8n-typeorm-import.ts | 66 -- .../src/rules/no-guardrail-disable.test.ts | 31 +- packages/cli/AGENTS.md | 36 +- packages/cli/eslint.config.mjs | 139 ---- packages/cli/oxlint.config.mts | 142 ---- .../insights/backend/oxlint.config.mts | 28 - packages/quality/policy/code-health/README.md | 1 + .../quality/policy/code-health/src/index.ts | 8 + .../typeorm-persistence-boundary.rule.test.ts | 185 +++++ .../typeorm-persistence-boundary.rule.ts | 234 +++++++ 19 files changed, 1112 insertions(+), 495 deletions(-) delete mode 100644 packages/@n8n/eslint-config/src/rules/misplaced-n8n-typeorm-import.test.ts delete mode 100644 packages/@n8n/eslint-config/src/rules/misplaced-n8n-typeorm-import.ts create mode 100644 packages/quality/policy/code-health/src/rules/typeorm-persistence-boundary.rule.test.ts create mode 100644 packages/quality/policy/code-health/src/rules/typeorm-persistence-boundary.rule.ts diff --git a/.agents/review-rules/qa-dx/ratchets-and-allowlists.md b/.agents/review-rules/qa-dx/ratchets-and-allowlists.md index c8099069916..de97a02c783 100644 --- a/.agents/review-rules/qa-dx/ratchets-and-allowlists.md +++ b/.agents/review-rules/qa-dx/ratchets-and-allowlists.md @@ -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. diff --git a/.code-health-baseline.json b/.code-health-baseline.json index 2593dc7b6d2..f4e106e2860 100644 --- a/.code-health-baseline.json +++ b/.code-health-baseline.json @@ -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" + } ] } } diff --git a/AGENTS.md b/AGENTS.md index 6bae8bcc6c0..fcb911e2ee3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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** diff --git a/packages/@n8n/backend-services/eslint.config.mjs b/packages/@n8n/backend-services/eslint.config.mjs index d964293b6df..65fb1ff69aa 100644 --- a/packages/@n8n/backend-services/eslint.config.mjs +++ b/packages/@n8n/backend-services/eslint.config.mjs @@ -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.', diff --git a/packages/@n8n/backend-services/oxlint.config.mts b/packages/@n8n/backend-services/oxlint.config.mts index 5a11f5a4451..a02ff6e1161 100644 --- a/packages/@n8n/backend-services/oxlint.config.mts +++ b/packages/@n8n/backend-services/oxlint.config.mts @@ -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.', diff --git a/packages/@n8n/db/src/services/auth.roles.service.ts b/packages/@n8n/db/src/services/auth.roles.service.ts index 19efaee46b3..0d80317e4de 100644 --- a/packages/@n8n/db/src/services/auth.roles.service.ts +++ b/packages/@n8n/db/src/services/auth.roles.service.ts @@ -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'; diff --git a/packages/@n8n/db/src/services/db-lock.service.ts b/packages/@n8n/db/src/services/db-lock.service.ts index fb603135fa3..56b1802e123 100644 --- a/packages/@n8n/db/src/services/db-lock.service.ts +++ b/packages/@n8n/db/src/services/db-lock.service.ts @@ -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'; diff --git a/packages/@n8n/eslint-config/src/rules/index.ts b/packages/@n8n/eslint-config/src/rules/index.ts index b45172b2894..bdb864f867e 100644 --- a/packages/@n8n/eslint-config/src/rules/index.ts +++ b/packages/@n8n/eslint-config/src/rules/index.ts @@ -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, diff --git a/packages/@n8n/eslint-config/src/rules/misplaced-n8n-typeorm-import.test.ts b/packages/@n8n/eslint-config/src/rules/misplaced-n8n-typeorm-import.test.ts deleted file mode 100644 index e0eb8a7e746..00000000000 --- a/packages/@n8n/eslint-config/src/rules/misplaced-n8n-typeorm-import.test.ts +++ /dev/null @@ -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' } }, - ], - }, - ], -}); diff --git a/packages/@n8n/eslint-config/src/rules/misplaced-n8n-typeorm-import.ts b/packages/@n8n/eslint-config/src/rules/misplaced-n8n-typeorm-import.ts deleted file mode 100644 index bac1cf53ae2..00000000000 --- a/packages/@n8n/eslint-config/src/rules/misplaced-n8n-typeorm-import.ts +++ /dev/null @@ -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 }, - }); - } - } - } - }, - }; - }, -}); diff --git a/packages/@n8n/eslint-config/src/rules/no-guardrail-disable.test.ts b/packages/@n8n/eslint-config/src/rules/no-guardrail-disable.test.ts index 997fa963984..6f778780828 100644 --- a/packages/@n8n/eslint-config/src/rules/no-guardrail-disable.test.ts +++ b/packages/@n8n/eslint-config/src/rules/no-guardrail-disable.test.ts @@ -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], }, diff --git a/packages/cli/AGENTS.md b/packages/cli/AGENTS.md index c439ac56e5b..55ce1eeb38a 100644 --- a/packages/cli/AGENTS.md +++ b/packages/cli/AGENTS.md @@ -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 diff --git a/packages/cli/eslint.config.mjs b/packages/cli/eslint.config.mjs index 33e549ef00a..ea28c1905a0 100644 --- a/packages/cli/eslint.config.mjs +++ b/packages/cli/eslint.config.mjs @@ -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 diff --git a/packages/cli/oxlint.config.mts b/packages/cli/oxlint.config.mts index 7b7bacf0c14..107597dedfb 100644 --- a/packages/cli/oxlint.config.mts +++ b/packages/cli/oxlint.config.mts @@ -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 diff --git a/packages/modules/insights/backend/oxlint.config.mts b/packages/modules/insights/backend/oxlint.config.mts index 23e1fdacee5..23a0f5690f6 100644 --- a/packages/modules/insights/backend/oxlint.config.mts +++ b/packages/modules/insights/backend/oxlint.config.mts @@ -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', - }, - }, - ], }); diff --git a/packages/quality/policy/code-health/README.md b/packages/quality/policy/code-health/README.md index 789438eb6c5..77960d57cb2 100644 --- a/packages/quality/policy/code-health/README.md +++ b/packages/quality/policy/code-health/README.md @@ -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 diff --git a/packages/quality/policy/code-health/src/index.ts b/packages/quality/policy/code-health/src/index.ts index 668e8025d96..f8db6331776 100644 --- a/packages/quality/policy/code-health/src/index.ts +++ b/packages/quality/policy/code-health/src/index.ts @@ -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 { + 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 { + 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';\n@TypeOrm.Entity()\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 {}\n", + ); + write( + 'packages/example/src/domain/store.ts', + "import * as TypeOrm from '@n8n/typeorm';\nexport class DomainStore extends TypeOrm.Repository {}\n", + ); + write( + 'packages/example/src/records/store.ts', + "import { BaseRepository } from '@n8n/db';\nimport { In } from '@n8n/typeorm';\nexport class RecordStore extends BaseRepository {}\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 {}\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([]); + }); +}); diff --git a/packages/quality/policy/code-health/src/rules/typeorm-persistence-boundary.rule.ts b/packages/quality/policy/code-health/src/rules/typeorm-persistence-boundary.rule.ts new file mode 100644 index 00000000000..96264072a15 --- /dev/null +++ b/packages/quality/policy/code-health/src/rules/typeorm-persistence-boundary.rule.ts @@ -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 { + 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 { + 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, + ); + } +}