Repository: n8n-io/n8n
File: packages/@n8n/db/src/migrations/common/1771500000000-MigrateExternalSecretsToEntityStorage.ts (TypeScript)

Code before:
import { Container } from '@n8n/di';
import { Cipher } from 'n8n-core';
import { jsonParse } from 'n8n-workflow';

import type { IrreversibleMigration, MigrationContext } from '../migration-types';

interface ProviderData {
	connected: boolean;
	connectedAt: string | Date | null;
	settings: Record<string, unknown>;
}

interface ExternalSecretsSettings {
	[providerName: string]: ProviderData;
}

const EXTERNAL_SECRETS_DB_KEY = 'feature.externalSecrets';

/**
 * Migrates external secrets provider settings from a single encrypted JSON blob
 * in the `settings` table to individual entity rows in the `secrets_provider_connection` table.
 *
 * Existing providers are migrated as global providers (no project access rows).
 * The old settings row is preserved for backward compatibility.
 */
export class MigrateExternalSecretsToEntityStorage1771500000000 implements IrreversibleMigration {
	private readonly cipher = Container.get(Cipher);

	async up({ escape, runQuery, logger, migrationName }: MigrationContext) {
		const settingsTable = escape.tableName('settings');
		const keyCol = escape.columnName('key');
		const valueCol = escape.columnName('value');

		// Step 1: Read the encrypted settings blob
		const rows: Array<{ value: string }> = await runQuery(
			`SELECT ${valueCol} FROM ${settingsTable} WHERE ${keyCol} = '${EXTERNAL_SECRETS_DB_KEY}';`,
		);

		if (rows.length === 0) {
			logger.info(`[${migrationName}] No external secrets settings found, skipping`);
			return;
		}

		const [row] = rows;

		// Step 2: Decrypt the blob
		let allSettings: ExternalSecretsSettings;
		try {
			const decrypted = this.cipher.decrypt(row.value);

Review comment (suggestion):
Hey team, I was a bit conflicted about keeping or removing the setting entry, in the end I kept it but this means that we'll need another migration to remove it. 

Let me know what you think    

Code after:
import { Container } from '@n8n/di';
import { Cipher } from 'n8n-core';
import { jsonParse } from 'n8n-workflow';

import type { IrreversibleMigration, MigrationContext } from '../migration-types';

interface ProviderData {
	connected: boolean;
	connectedAt: string | Date | null;
	settings: Record<string, unknown>;
}

interface ExternalSecretsSettings {
	[providerName: string]: ProviderData;
}

const EXTERNAL_SECRETS_DB_KEY = 'feature.externalSecrets';

/**
 * Migrates external secrets provider settings from a single encrypted JSON blob
 * in the `settings` table to individual entity rows in the `secrets_provider_connection` table.
 *
 * Existing providers are migrated as global providers (no project access rows).
 * The old settings row is preserved for backward compatibility.
 */
export class MigrateExternalSecretsToEntityStorage1771500000000 implements IrreversibleMigration {
	private readonly cipher = Container.get(Cipher);

	async up(context: MigrationContext) {
		const allSettings = await this.readSettings(context);
		if (!allSettings) return;

		const providerNames = Object.keys(allSettings);
		if (providerNames.length === 0) {
			context.logger.info(
				`[${context.migrationName}] External secrets settings blob is empty, skipping`,
			);
			return;
		}

		for (const providerName of providerNames) {
			await this.migrateProvider(context, providerName, allSettings[providerName]);
		}

		context.logger.info(
			`[${context.migrationName}] Migration complete. Old settings row preserved in settings table.`,
		);
	}