diff --git a/OWNERS b/OWNERS index 4ad64dc6603..7808cd4ac4d 100644 --- a/OWNERS +++ b/OWNERS @@ -27,6 +27,7 @@ packages/workflow/stryker.config.mjs @n8n-io/qa-dx packages/@n8n/config/ @n8n-io/catalysts packages/@n8n/backend-common/ @n8n-io/catalysts packages/@n8n/backend-network/ @n8n-io/catalysts +packages/@n8n/backend-services/ @n8n-io/catalysts packages/@n8n/backend-test-utils/ @n8n-io/catalysts packages/@n8n/di/ @n8n-io/catalysts packages/@n8n/errors/ @n8n-io/catalysts diff --git a/codecov.yml b/codecov.yml index 3939969fbff..80065626909 100644 --- a/codecov.yml +++ b/codecov.yml @@ -60,6 +60,7 @@ component_management: - packages/extensions/** - packages/@n8n/api-types/** - packages/@n8n/backend-common/** + - packages/@n8n/backend-services/** - packages/@n8n/backend-test-utils/** - packages/@n8n/cli/** - packages/@n8n/client-oauth2/** diff --git a/packages/@n8n/backend-services/README.md b/packages/@n8n/backend-services/README.md new file mode 100644 index 00000000000..b1e1c1ceb7a --- /dev/null +++ b/packages/@n8n/backend-services/README.md @@ -0,0 +1,65 @@ +# @n8n/backend-services + +Backend services and HTTP error classes that many backend modules share. + +## Why this package exists + +Backend modules live in `packages/cli/src/modules/` today. Most of them +import a small set of services from the rest of `cli`. A module cannot move +into its own workspace package while those imports point at `@/...` paths in +`cli`. + +This package is the home for that shared set. It sits above `@n8n/db` and +below `n8n` (`cli`) in the dependency graph: + +```mermaid +flowchart TD + cli["n8n (cli) and backend modules"] --> bs["@n8n/backend-services"] + bs --> db["@n8n/db"] + bs --> bc["@n8n/backend-common"] + db --> bc +``` + +`@n8n/backend-common` holds the foundation that `@n8n/db` also needs (logger, +license state, module registry, locks). `@n8n/backend-services` holds business +and infrastructure services that need the persistence layer or that only +`cli` and the modules consume. + +## What lives here + +Nothing yet. The next PRs move the response errors, `UrlService`, `CacheService`, +`RedisClientService`, `ProtectedResourceRegistry`, `EventService`, `RoleService`, +the finder services and the scope checks here, one area at a time. + +## Compatibility with cli + +The package config mirrors `packages/cli`, so a file moved from `cli` compiles, +lints and tests here without edits: + +| Concern | Setup | Mirrors | +| --- | --- | --- | +| TypeScript | `common.go` + `backend.go`, `lib` es2023, `strictFunctionTypes`, `strictPropertyInitialization` and `useUnknownInCatchVariables` off | `packages/cli/tsconfig.json` | +| Lint | `oxlint --type-aware`, then the ESLint guardrails pass. `eslint.config.mjs` is the policy twin that code-health and the guardrails read | `packages/cli/oxlint.config.mts`, `eslint.guardrails.config.mjs` | +| Tests | Vitest with the decorators config, one fork per file, the same `N8N_USER_FOLDER` and `N8N_ENCRYPTION_KEY` setup | `packages/cli/vitest.config.base.ts`, `test/setup-test-folder.ts` | + +Two things do not carry over on purpose: + +- There is no `@/` path alias. `cli` tests load this package from `dist/` + and other consumers inline it from `src/`. `tsc` does not rewrite an alias + in `dist/`, so it resolves in neither. Use relative imports inside the + package. +- `cli` turns a set of rules down to `warn` for its whole tree. This package + keeps the shared backend layer as is. Fix such findings when you move a file. + +## Rules for adding code + +- The code must not import from `n8n` (`cli`). If it needs a `cli` seam, + define a narrow DI port here and bind it in `cli` at bootstrap. +- At least two backend modules must use the code. A service that one module + uses belongs to that module. +- Keep the file layout of `cli` (`errors/`, `services/`). A move with `git mv` + keeps the history readable. +- Add each new export to `src/index.ts`. Consumers import from the package + root only. +- Keep `oxlint.config.mts` and `eslint.config.mjs` in step. A ratchet + allowlist for a moved TypeORM leak goes into both. diff --git a/packages/@n8n/backend-services/eslint.config.mjs b/packages/@n8n/backend-services/eslint.config.mjs new file mode 100644 index 00000000000..c9c5ad26bf3 --- /dev/null +++ b/packages/@n8n/backend-services/eslint.config.mjs @@ -0,0 +1,39 @@ +import { defineConfig, globalIgnores } from 'eslint/config'; +import { backendConfig } from '@n8n/eslint-config/backend'; + +// Policy twin of oxlint.config.mts for the tools that read ESLint configs (guardrails, code-health). +export default defineConfig( + globalIgnores(['coverage/**', 'vitest.config.ts']), + 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.', + }, + ], + }, + ], + 'n8n-local-rules/no-type-unsafe-event-emitter': 'error', + }, + }, + { + files: ['./test/**/*.ts', './src/**/__tests__/**/*.ts'], + rules: { + 'n8n-local-rules/misplaced-n8n-typeorm-import': 'off', + 'n8n-local-rules/no-type-unsafe-event-emitter': 'off', + // `vi.importActual('x')` needs inline import types. + '@typescript-eslint/consistent-type-imports': ['error', { disallowTypeAnnotations: false }], + }, + }, +); diff --git a/packages/@n8n/backend-services/eslint.guardrails.config.mjs b/packages/@n8n/backend-services/eslint.guardrails.config.mjs new file mode 100644 index 00000000000..fcd0d8a5f90 --- /dev/null +++ b/packages/@n8n/backend-services/eslint.guardrails.config.mjs @@ -0,0 +1,43 @@ +import { defineConfig, globalIgnores } from 'eslint/config'; +import { functionalGuardrailsConfig } from '@n8n/eslint-config/functional-guardrails'; +import { localRulesPlugin } from '@n8n/eslint-config/plugin'; +import packageConfig from './eslint.config.mjs'; + +const directivePluginConfigs = packageConfig.flatMap(({ plugins }) => { + const externalPlugins = Object.fromEntries( + Object.entries(plugins ?? {}).filter(([name]) => name !== 'n8n-local-rules'), + ); + + return Object.keys(externalPlugins).length === 0 ? [] : [{ plugins: externalPlugins }]; +}); + +const dependencyRuleConfigs = packageConfig.flatMap(({ files, ignores, settings, rules }) => { + const rule = rules?.['import-x/no-extraneous-dependencies']; + if (!rule) return []; + + return [ + { + ...(files ? { files } : {}), + ...(ignores ? { ignores } : {}), + ...(settings ? { settings } : {}), + rules: { 'import-x/no-extraneous-dependencies': rule }, + }, + ]; +}); + +export default defineConfig( + globalIgnores([ + 'dist/**', + 'coverage/**', + 'eslint.config.mjs', + 'eslint.guardrails.config.mjs', + 'oxlint.config.mts', + 'vitest.config.ts', + ]), + { + plugins: { 'n8n-local-rules': localRulesPlugin }, + }, + functionalGuardrailsConfig, + ...directivePluginConfigs, + ...dependencyRuleConfigs, +); diff --git a/packages/@n8n/backend-services/oxlint.config.mts b/packages/@n8n/backend-services/oxlint.config.mts new file mode 100644 index 00000000000..c9ade75b8f7 --- /dev/null +++ b/packages/@n8n/backend-services/oxlint.config.mts @@ -0,0 +1,40 @@ +import { backendConfig } from '@n8n/oxlint-config/backend'; +import { defineConfig } from 'oxlint'; + +// Same rule set as packages/cli/oxlint.config.mts, so a file moved from cli lints the same way. +export default defineConfig({ + extends: [backendConfig], + ignorePatterns: ['coverage/**', 'eslint.guardrails.config.mjs'], + rules: { + 'n8n-local-rules/misplaced-n8n-typeorm-import': 'error', + // The narrow ESLint guardrail config owns no-unsealed-workflow-entity-write. + '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.', + }, + ], + }, + ], + 'n8n-local-rules/no-type-unsafe-event-emitter': 'error', + }, + overrides: [ + { + files: ['./test/**/*.ts', './src/**/__tests__/**/*.ts'], + // An override that names a jsPlugin rule must re-declare the plugin. + jsPlugins: ['@n8n/eslint-config/plugin'], + rules: { + 'n8n-local-rules/misplaced-n8n-typeorm-import': 'off', + 'n8n-local-rules/no-type-unsafe-event-emitter': 'off', + }, + }, + ], +}); diff --git a/packages/@n8n/backend-services/package.json b/packages/@n8n/backend-services/package.json new file mode 100644 index 00000000000..301f1fd47f3 --- /dev/null +++ b/packages/@n8n/backend-services/package.json @@ -0,0 +1,40 @@ +{ + "name": "@n8n/backend-services", + "version": "1.0.0", + "scripts": { + "clean": "rimraf dist .turbo", + "dev": "pnpm watch", + "typecheck": "tsc --noEmit", + "build": "tsc -p tsconfig.build.json", + "build:unchecked": "tsc -p tsconfig.build.json --noCheck", + "format": "biome format --write .", + "format:check": "biome ci .", + "lint": "pnpm run lint:base && pnpm run lint:guardrails", + "lint:base": "oxlint --type-aware . --quiet", + "lint:fix": "oxlint --type-aware . --fix && pnpm run lint:guardrails", + "lint:guardrails": "eslint --config eslint.guardrails.config.mjs . --quiet", + "watch": "tsc -p tsconfig.build.json --watch", + "test": "vitest run --passWithNoTests", + "test:unit": "vitest run --passWithNoTests", + "test:dev": "vitest --silent=false" + }, + "main": "dist/index.js", + "module": "src/index.ts", + "types": "dist/index.d.ts", + "files": [ + "dist/**/*" + ], + "devDependencies": { + "@n8n/eslint-config": "workspace:*", + "@n8n/oxlint-config": "workspace:*", + "@n8n/typescript-config": "workspace:*", + "@n8n/vitest-config": "workspace:*", + "@vitest/coverage-v8": "catalog:", + "eslint": "catalog:", + "oxlint": "catalog:", + "oxlint-tsgolint": "catalog:", + "typescript": "catalog:typescript", + "vitest": "catalog:" + }, + "license": "LicenseRef-n8n-sustainable-use" +} diff --git a/packages/@n8n/backend-services/src/index.ts b/packages/@n8n/backend-services/src/index.ts new file mode 100644 index 00000000000..cb0ff5c3b54 --- /dev/null +++ b/packages/@n8n/backend-services/src/index.ts @@ -0,0 +1 @@ +export {}; diff --git a/packages/@n8n/backend-services/test/setup-test-folder.ts b/packages/@n8n/backend-services/test/setup-test-folder.ts new file mode 100644 index 00000000000..6ec4522433a --- /dev/null +++ b/packages/@n8n/backend-services/test/setup-test-folder.ts @@ -0,0 +1,23 @@ +import { mkdirSync, mkdtempSync, writeFileSync } from 'fs'; +import { tmpdir } from 'os'; +import { join } from 'path'; + +// Same instance setup as packages/cli/test/setup-test-folder.ts, so a moved test finds it. +process.env.N8N_ENCRYPTION_KEY = 'test_key'; + +const baseDir = join(tmpdir(), 'n8n-tests/'); +mkdirSync(baseDir, { recursive: true }); + +const testDir = mkdtempSync(baseDir); +mkdirSync(join(testDir, '.n8n')); +process.env.N8N_USER_FOLDER = testDir; +process.env.N8N_ENFORCE_SETTINGS_FILE_PERMISSIONS = 'true'; + +writeFileSync( + join(testDir, '.n8n/config'), + JSON.stringify({ encryptionKey: 'test_key', instanceId: '123' }), + { + encoding: 'utf-8', + mode: 0o600, + }, +); diff --git a/packages/@n8n/backend-services/tsconfig.build.json b/packages/@n8n/backend-services/tsconfig.build.json new file mode 100644 index 00000000000..bdd2a8da3f9 --- /dev/null +++ b/packages/@n8n/backend-services/tsconfig.build.json @@ -0,0 +1,10 @@ +{ + "extends": ["./tsconfig.json", "@n8n/typescript-config/tsconfig.build.go.json"], + "compilerOptions": { + "composite": true, + "rootDir": "src", + "outDir": "dist" + }, + "include": ["src/**/*.ts"], + "exclude": ["test/**", "src/**/__tests__/**"] +} diff --git a/packages/@n8n/backend-services/tsconfig.json b/packages/@n8n/backend-services/tsconfig.json new file mode 100644 index 00000000000..80db2ea84ed --- /dev/null +++ b/packages/@n8n/backend-services/tsconfig.json @@ -0,0 +1,19 @@ +{ + "extends": [ + "@n8n/typescript-config/tsconfig.common.go.json", + "@n8n/typescript-config/tsconfig.backend.go.json" + ], + "compilerOptions": { + "emitDecoratorMetadata": true, + "experimentalDecorators": true, + "lib": ["es2023", "dom"], + "types": ["node", "vitest/globals"], + // Same relaxations as packages/cli/tsconfig.json, so a file moved from cli compiles unchanged. + "strict": true, + "strictFunctionTypes": false, + "strictPropertyInitialization": false, + // TODO: remove all options below this line + "useUnknownInCatchVariables": false + }, + "include": ["src/**/*.ts", "test/**/*.ts"] +} diff --git a/packages/@n8n/backend-services/vitest.config.ts b/packages/@n8n/backend-services/vitest.config.ts new file mode 100644 index 00000000000..de9e7e14a17 --- /dev/null +++ b/packages/@n8n/backend-services/vitest.config.ts @@ -0,0 +1,12 @@ +import { createVitestConfigWithDecorators } from '@n8n/vitest-config/node-decorators'; +import { configDefaults, mergeConfig } from 'vitest/config'; + +export default mergeConfig(createVitestConfigWithDecorators(), { + test: { + // One fork per file: tests register mocks in the shared `@n8n/di` Container. + pool: 'forks', + setupFiles: ['./test/setup-test-folder.ts'], + // Vitest's default exclude does not cover dist; stale compiled tests there fail as CJS. + exclude: [...configDefaults.exclude, '**/dist/**'], + }, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9372d8d0c67..f749ba02696 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1732,6 +1732,39 @@ importers: specifier: 'catalog:' version: 3.1.0(typescript@7.0.2)(vitest@4.1.9) + packages/@n8n/backend-services: + devDependencies: + '@n8n/eslint-config': + specifier: workspace:* + version: link:../eslint-config + '@n8n/oxlint-config': + specifier: workspace:* + version: link:../oxlint-config + '@n8n/typescript-config': + specifier: workspace:* + version: link:../typescript-config + '@n8n/vitest-config': + specifier: workspace:* + version: link:../vitest-config + '@vitest/coverage-v8': + specifier: 'catalog:' + version: 4.1.9(@vitest/browser@4.1.10)(vitest@4.1.9) + eslint: + specifier: 'catalog:' + version: 9.29.0(jiti@2.6.1)(supports-color@8.1.1) + oxlint: + specifier: 'catalog:' + version: 1.78.0(oxlint-tsgolint@0.21.1) + oxlint-tsgolint: + specifier: 'catalog:' + version: 0.21.1 + typescript: + specifier: catalog:typescript + version: 7.0.2 + vitest: + specifier: 'catalog:' + version: 4.1.9(@opentelemetry/api@1.9.1)(@types/node@20.19.41)(@vitest/browser-playwright@4.1.9)(@vitest/coverage-v8@4.1.9)(jsdom@23.0.1(bufferutil@4.0.9)(supports-color@8.1.1)(utf-8-validate@5.0.10))(vite@8.0.2(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)(@types/node@20.19.41)(esbuild@0.28.1)(jiti@2.6.1)(sass-embedded@1.98.0)(sass@1.98.0)(terser@5.16.1)(tsx@4.19.3)(yaml@2.8.3)) + packages/@n8n/backend-test-utils: dependencies: '@n8n/backend-common': diff --git a/scripts/backend-module/backend-module-guide.md b/scripts/backend-module/backend-module-guide.md index e1b3c623700..5558193b600 100644 --- a/scripts/backend-module/backend-module-guide.md +++ b/scripts/backend-module/backend-module-guide.md @@ -41,7 +41,7 @@ This is only a template - your module may not need all of these files, or it may Backend modules currently live at `packages/cli/src/modules`, so imports can be: - from inside the module dir -- from common packages like `@n8n/db`, `@n8n/backend-common`, `@n8n/backend-test-utils`, etc. +- from common packages like `@n8n/db`, `@n8n/backend-common`, `@n8n/backend-services`, `@n8n/backend-test-utils`, etc. - from `cli` - from third-party libs available in, or added to, `cli` diff --git a/scripts/lint-parity/layers.json b/scripts/lint-parity/layers.json index d328d62b2de..2c998cca6b4 100644 --- a/scripts/lint-parity/layers.json +++ b/scripts/lint-parity/layers.json @@ -26,6 +26,7 @@ "packages/@n8n/ai-workflow-builder.ee", "packages/@n8n/backend-common", "packages/@n8n/backend-network", + "packages/@n8n/backend-services", "packages/@n8n/backend-test-utils", "packages/@n8n/benchmark", "packages/@n8n/blob-storage",