n8n:db-migrations について
n8n:db-migrationsは、n8nのコントリビューター向けのデータベース移行スキルです。事前チェックリストに加え、複数データベースでの設計、テスト、ロールバックの指針をまとめています。
主な機能
- 01移行レビュー前のチェックリスト
- 02スキーマとデータ移行の指針
- 03PostgreSQLとSQLiteの互換性ルール
- 04ロールバックとテストの手順
活用シーン
- 01レビュー提出前の移行準備
- 02移行タイプと配置先の選択
- 03データベース間のスキーマ変更確認
インストール
コマンドをコピーしてターミナルで実行します。
npx skills add https:/ / github.com/ n8n-io/ n8n --skill n8n:db-migrationsインストール後、Claude Code で/n8n:db-migrationsを入力して読み込まれたか確認します。
詳細
翻訳後に原文が更新されています。最新の内容は原文をご確認ください。
n8n のマイグレーションガイドライン
原則: @n8n-io/migrations-review チームが、すべてのマイグレーション PR を審査します。指摘される修正内容は予測可能です。レビューを依頼する前に、事前チェックリストに沿って確認してください。このドキュメントの残りの部分では、各項目の理由と、さらに詳しいトピックを説明します。
目次
概要
ディレクトリ構造
packages/@n8n/db/src/migrations/
├── common/ # Default — DSL handles SQLite + Postgres
├── postgresdb/ # PostgreSQL-specific migrations
├── sqlite/ # SQLite-specific migrations
├── dsl/ # Schema builder DSL (table, column, indices)
├── __tests__/ # Migration tests
├── migration-types.ts
└── migration-helpers.ts
マイグレーションの種類
| インターフェース | 使用する場面 |
|---|---|
ReversibleMigration | きれいに元に戻せるスキーマ変更(カラムの追加/削除、テーブルの作成/削除)。正常に動作する down() が必要です。 |
IrreversibleMigration | データの変換、破壊的な変更、または down() を実行するとデータが失われる変更。down() は使用できません。 |
MigrationContext API
信頼できる情報源:packages/@n8n/db/src/migrations/migration-types.ts。正確なシグネチャが不明な場合は、ソースを確認してください。
interface MigrationContext {
// Database info
dbType: 'postgresdb' | 'sqlite';
isSqlite: boolean;
isPostgres: boolean;
tablePrefix: string;
dbName: string;
// Schema DSL
schemaBuilder: { createTable, dropTable, addColumns, dropColumns, column,
createIndex, dropIndex, addForeignKey, dropForeignKey,
addNotNull, dropNotNull };
// Query execution
runQuery<T>(sql: string, namedParameters?: object): Promise<T>;
runInBatches<T>(query: string, operation: (rows: T[]) => Promise<void>, limit?: number): Promise<void>;
copyTable(from: string, to: string, fromFields?: string[], toFields?: string[], batchSize?: number): Promise<void>;
// Utilities
escape: { tableName(n: string): string; columnName(n: string): string; indexName(n: string): string };
parseJson<T>(data: string | T): T;
loadSurveyFromDisk(): string | null;
logger: Logger;
migrationName: string;
queryRunner: QueryRunner; // Avoid direct use — prefer runQuery()
}
DSL の型マッピングリファレンス
信頼できる情報源:packages/@n8n/db/src/migrations/dsl/column.ts。
| DSL の型 | PostgreSQL | SQLite |
|---|---|---|
int | int | integer |
bigint | bigint | integer |
smallint | smallint | integer |
varchar(N) | varchar(N) | varchar(N) (長さは強制されません) |
text | text | text |
json | json | text |
uuid | uuid | varchar |
bool | boolean | boolean |
double | double precision | real |
binary | bytea | blob |
timestampTimezone | timestamptz | datetime |
timestampNoTimezone | timestamp | datetime |
timestamp (非推奨) | timestamp | datetime |
タイムスタンプの各型のデフォルト精度は 3 ms です。.timestampTimezone(6) で変更できます。
事前チェックリスト
レビューを依頼する前に、以下を確認してください。各項目はレビュー担当者から実際に繰り返し指摘されている内容です。リンク先では、そのルールについて説明しています。
- マイグレーションは
pnpm --filter=@n8n/db migration:newでひな形を作成しました(タイムスタンプと登録は自動で行われます。migration-timestamplint ルールでずれを検出できます)。— マイグレーションの作成 - 識別子には
escape.tableName(...)/escape.columnName(...)を使用しています。n8n_tableプレフィックスを手書きしないでください。— 識別子は必ずエスケープする - 値の意味に合ったカラム型を選択します。 UUID にはネイティブの
uuid、タイムスタンプにはtimestampTimezone()、数値には数値型、ブール値にはbool、構造化データにはjsonを使用します。万能型としてvarcharを使用しないでください。— カラム型 - そのカテゴリ内で、妥当な範囲で最も制限の強い型を選択します。 範囲が許す場合は
bigintではなくint/smallint、長さに上限のない文字列にはvarchar(255)ではなくtextを使用します。バージョン番号にdoubleを使用しないでください。— カラム型 - デフォルトでは
notNullを指定し、正当な理由がある場合に限り解除します。 PK は暗黙的に NOT NULL です。マイグレーションのnotNullをエンティティの NULL 許容設定と一致させます。— NOT NULL とエンティティの整合性 - Enum のようなカラムには
.withEnumCheck([...])と.comment('値の説明')の両方を指定します。不透明な ID、Unix タイムスタンプ、JSON の構造にも.comment()を付けます。— Enum のような文字列に制約を設ける、カラムにコメントを追加する - すべての参照カラムに、削除時の動作を明示した FK を指定します。 SQLite の再作成サイクルで FK が重複するリスクがある場合は、FK に明示的な名前を付けます。多相的な
(typeCol, idCol)パターンは避けてください。— 外部キー制約、一般的な設計ガイダンス - インデックスを実際のクエリパターンに合わせます。 一意制約を設定するとインデックスも作成されます。複合 PK はそのプレフィックスにインデックスを作成します。
withIndexOn(...)をエンティティの@Index(...)と一致させます。— インデックス管理 - 疎な一意カラムには、
WHERE col IS NOT NULLを指定した部分インデックスを使用します。— インデックス管理 - 複合インデックスのカラム順を実際の
WHERE/ORDER BYの使用方法に合わせます。— インデックス管理 - エンティティとマイグレーションの整合性:カラム型、
notNull、デフォルト値、FK、@Indexデコレーターがすべて一致しています。— スキーマとエンティティのずれ -
addColumns、dropColumns、addNotNull、dropNotNull、addEnumCheck、dropEnumCheckを使用する場合: 対象テーブルを参照する FK があるか確認しました。ある場合は、withFKsDisabled = true as constを設定する(common/のマイグレーションの場合はsqlite/サブクラスで設定する)か、NULL 許容またはデフォルト値付きのカラムには生のALTER TABLE ADD COLUMNを使用します。— SQLite のテーブル再作成リスク - マイグレーション本体で、実行中のアプリの値をインポートしていません。 型やユーティリティコードはローカルに記述します。— エンティティを値としてインポートしない
-
async down()をローカルでテストしました:SQLite と Postgres の両方でpnpm start && pnpm start -- db:revert && pnpm startを実行しました。— 可逆性 - 1 つのマイグレーションにつき論理的な変更は 1 つとし、無関係なテーブル変更は別々のファイルに分けます。— 独立したスキーマ変更をまとめない
-
up()/down()が意図のリストとして読めます。 どちらかの本体が画面に収まらないほど長くなる場合、またはスキーマ操作と複数文の生 SQL によるデータ移行が混在する場合は、データ移行を同じクラスのprivate asyncメソッド(例:private async backfillFromX(ctx))に切り出します。最上位のメソッドは処理を実装するのではなく、統括する役割にします。 - 過去の実装例は踏襲するのではなく、修正すべき基準です。 チェックリストと古いマイグレーションの内容が食い違う場合(例:冗長な
.primary.notNull、識別子を手動で引用符で囲む、.comment()の欠落など)、新しいコードではチェックリストを優先し、違反をそのまま引き継がないでください。古いコードに該当箇所を見つけた場合は、PR で指摘してください。 -
pnpm db:schema:docsでスキーマドキュメントを再生成し、docs/generated/の変更をコミットしました。DB Tests CI ジョブは、ドキュメントが古い場合に失敗します。— スキーマドキュメント チェックリストは最低限の基準であり、上限ではありません。 項目に不備があれば、レビューを依頼する前に修正してください。
共通ガイダンス
すべてのマイグレーション(スキーマ変更、データ変更、共通、DB 固有)に適用されるルールです。何かを書く前に、このセクションを読んでください。
マイグレーションの作成
一時的なタイムスタンプの回避策: このリポジトリには現在、未来の日付のマイグレーションがあり、最新のタイムスタンプは
1784000000008(2026-07-14T03:33:20.008Z)です。この時刻を実際の時間が過ぎるまでは、Date.now()で作成したマイグレーションはデプロイ済みの最新マイグレーションより前に並び、後続のマイグレーションをすでに適用したデータベースで、順不同に実行される可能性があります。この期間はジェネレーターを使ってください。必要に応じてmax + 1を選択します。背景については PR #30511 を参照してください。
マイグレーションファイルの名前は {TIMESTAMP}-{DescriptiveName}.ts です。タイムスタンプは、このパッケージ内にあるすべての既存マイグレーション(common/、postgresdb/、sqlite/ を含む)より厳密に大きくなければなりません。TypeORM は未記録のマイグレーションをタイムスタンプ順に実行するため、現在の最大値より小さい値を挿入すると、後続のマイグレーションをすでに実行したデータベースで順序が壊れます。
ジェネレーターを使用してください。安全なタイムスタンプを選択し、ひな形を作成して、マイグレーションのインデックスファイルを再生成します(sqlite/index.ts と postgresdb/index.ts は gitignore 対象のビルド成果物で、ディスク上のファイルから scripts/generate-migration-index.mjs によって生成されます。編集やコミットはしないでください)。
pnpm --filter=@n8n/db migration:new <Name> [--folder=common|postgresdb|sqlite]
<Name> は PascalCase で、変更内容を表します(例:AddTracingToExecution)。--folder の既定値は common です。方言固有のマイグレーションに限り、postgresdb または sqlite を使用してください。ジェネレーターは Date.now() が現在の最新値を上回る場合はその値を、そうでない場合は max + 1 を選択します。
@n8n/code-health の migration-timestamp ルールは、Lint 時に両方の不変条件(厳密な順序と、根拠のない遠い未来の値を作成しないこと)をチェックします。ジェネレーターが簡単な方法であり、このルールが安全網です。
マイグレーションの適用と取り消し
保留中のマイグレーションは、n8n の通常の起動時に適用されます。ローカルのチェックアウトでは、対象のコードバージョンで pnpm start を実行すると、手動で適用できます。
最後に適用した取り消し可能なマイグレーションを取り消すには、CLI コマンドを使用します。
n8n db:revert
ローカルのチェックアウトでは、パッケージスクリプトを通じて同じコマンドを実行します。
pnpm start -- db:revert
マイグレーションテーブルを編集したり、手書きの SQL を実行したりして、マイグレーションを取り消してはいけません。db:revert はマイグレーションの down() メソッドを実行し、TypeORM のマイグレーション管理情報を保持します。
ディレクトリの選び方
single schema change, DSL covers it → common/
Postgres-only feature (gen_random_uuid,
ALTER COLUMN TYPE, partial expr index) → postgresdb/
SQLite needs different recipe or to skip
CASCADE on table recreate → sqlite/ (subclass common/, set withFKsDisabled = true as const)
変更が Postgres にしか必要ない場合は、ファイルを postgresdb/ のみに置いてください。if (isPostgres) ガードを使った、何もしない SQLite マイグレーションは作成しないでください。DB ごとに分ける場合については、クロスデータベース互換性を参照してください。
クラスの構成
import type { MigrationContext, ReversibleMigration } from '../migration-types';
export class AddFooBar1700000000000 implements ReversibleMigration {
async up({ schemaBuilder: { addColumns, column, createIndex }, escape }: MigrationContext) {
// ...
}
async down({ schemaBuilder: { dropIndex, dropColumns } }: MigrationContext) {
// ...
}
}
ReversibleMigration(既定)ではupとdownの両方が必要です。down()でデータが復元不可能な形で失われる場合に限り、IrreversibleMigrationを使用してください。可逆性を参照してください。withFKsDisabled = true as constは、外部キーから参照されているテーブルを再作成するsqlite/のサブクラスに限り使用してください(それ以外の場合、SQLite の CASCADE によってデータが消失します)。
適切なコードの衛生状態を保つ
マイグレーションのクラスもクラスです。up() を 200 行の手続きにしてはいけません。長い論理的なステップは、処理内容を表す名前(backfillSlugs など)を付けたプライベートメソッドに分割してください。そうすれば、up() は短いステップ呼び出しの一覧になります。1 行だけのステップは抽出しないでください。 本体が DSL 呼び出し 1 つだけのメソッドは情報を加えません。呼び出し箇所だけですでに処理内容が分かります。
// 🚫: everything inline in up()
export class MigrateThing1234567890000 implements IrreversibleMigration {
async up(ctx: MigrationContext) {
// 80 lines of mixed DDL, raw SQL, batched updates, logging...
}
}
// ✅: up() is a table of contents; only multi-step work gets its own method
export class MigrateThing1234567890000 implements IrreversibleMigration {
async up(ctx: MigrationContext) {
const { schemaBuilder: { addColumns, column, createIndex } } = ctx;
// One-liner DSL calls stay inline — naming them adds no information.
await addColumns('my_table', [column('slug').varchar(255)], { recreatesOnSqlite: true });
// The non-trivial step gets a named method.
await this.backfillSlugs(ctx);
await createIndex('my_table', ['slug'], true);
}
private async backfillSlugs({ escape, runQuery, runInBatches, logger, migrationName }: MigrationContext) {
const table = escape.tableName('my_table');
await runInBatches<{ id: string; name: string }>(
`SELECT id, name FROM ${table} WHERE slug IS NULL`,
async (rows) => {
for (const row of rows) {
try {
const slug = row.name.toLowerCase().replace(/\s+/g, '-');
await runQuery(`UPDATE ${table} SET slug = :slug WHERE id = :id`, { slug, id: row.id });
} catch (error) {
logger.warn(`[${migrationName}] Failed to backfill row ${row.id}: ${(error as Error).message}`);
}
}
},
);
}
}
理由: マイグレーションは、書くときよりも読むときのほうが多いものです。レビュー時、インシデント対応時、あるいは何年も経ってから誰かがカラムの存在理由を理解しようとするときに読まれます。名前を付けたステップは、そのままドキュメントにもなります。また、差分を確認しやすくなり、変更が「新しいステップを追加した」のか「既存のステップを書き換えた」のかを、レビュアーがひと目で判断できます。可逆なマイグレーションでは、同じプライベートヘルパーを逆順に呼び出せるため、さらに役立ちます。
queryRunner よりも runQuery() を優先する
MigrationContext の runQuery() を使って SQL を実行してください。マイグレーションから queryRunner.query() や queryRunner.manager.* を呼び出さないでください。
理由: runQuery() は名前付きパラメーターのバインドを一貫して処理しますが、識別子には引き続き escape.tableName()、escape.columnName()、escape.indexName() が必要です。queryRunner.query() はパラメーター用のヘルパーを回避します。queryRunner.manager の呼び出しは、時間とともに変化する TypeORM エンティティの定義にマイグレーションを結び付けます。v1.0 で動作していたマイグレーションも、エンティティの形状が変われば v2.0 で壊れる可能性があります。
エンティティを値としてインポートしない
import { Entity } を使って ORM メソッドを呼び出さないでください。代わりに、runQuery() を使って生の SQL を実行してください。
// 🚫 value import; ties migration to current entity shape
import { ApiKey } from '../../entities';
await queryRunner.manager.update(ApiKey, { id }, { scopes });
// ✅ inline row type, raw SQL
type ApiKeyRow = { id: string; scopes: string };
await runQuery(`UPDATE ${table} SET scopes = :scopes WHERE id = :id`, { scopes, id });
型のみのインポート(import type { Entity })はクエリ結果の型付けに使用できますが、名前の変更や構造の変更があり得るエンティティへの依存を避けるため、type WorkflowRow = { id: string; nodes: string } のようなインライン型を優先してください。
理由: マイグレーションは過去の記録であり、作成時点のスキーマに対して動作しなければなりません。現在のエンティティをインポートすると、後のリファクタリングによって古いマイグレーションの意味が気付かないうちに変わってしまいます。
識別子は必ずエスケープする
すべての識別子に escape.tableName()、escape.columnName()、escape.indexName() を使用してください。${tablePrefix}my_table を手作業で組み立てたり、"model_tmp" のような引用符付きの名前をハードコードしたりしないでください。
理由: DB の種類、テーブルプレフィックス、引用符の規則は Postgres と SQLite で異なります。escape.* ヘルパーは適切な規則を適用します。手動での補間はいずれ、どちらかの DB で誤ったものになります。
別パッケージからのインポートよりインライン化を優先する
@n8n/db はすでに n8n-workflow に依存していますが、マイグレーションが他のワークスペースパッケージからインポートするものが増えるほど、壊れやすくなります。可能な場合は、小さな定数や型をインライン化してください。n8n-workflow から jsonParse をインポートする代わりに、MigrationContext の parseJson() を使用してください。
理由: n8n-workflow から ERROR_TRIGGER_NODE_TYPE をインポートするマイグレーションは、その定数が存在し、その値が維持されることに恒久的に依存します。数年後のリファクタリングで定数の名前が変更または削除されると、新しいデータベースへのインストール時にマイグレーションが壊れます。
許容される例外:動作が安定しており、インラインでの実装が相当な量になるユーティリティ(例:generateNanoId)。
ロギング
MigrationContext の logger を使用してください。console.log は使用しないでください。
logger.info(`[${migrationName}] Processing ${count} workflows`);
logger.warn(`[${migrationName}] Skipping row ${id}: missing required field`);
関連のないスキーマ変更をまとめない
1 ファイルにつき 1 つの論理的変更にしてください。複数の無関係なテーブルを変更する場合は、分割してください。レビューでよくある指摘は、「マイグレーション名が実際の変更内容を正しく表していない。2 つの処理を行っている」というものです。workflow_entity に列を追加し、さらに audit_log を作成するマイグレーションは、2 つのマイグレーションに分けてください。
マージ済みのマイグレーションを編集しない
リリース後、マイグレーションは変更できません。新しいマイグレーションを作成してください。以前のマイグレーションで追加した列を削除する場合は、別の後続マイグレーションで行ってください(通常は後のリリースで実施します。詳細は列を非推奨にしてから、後続のマイグレーションで削除するを参照してください)。
ユーザー入力ではない値をパラメーター化しない
値がマイグレーション自体から取得される場合は、リテラルをインライン化してください。名前付きパラメーターは実行時の値に使用します。マイグレーション本文の定数は、SQL に直接記述できます。
命名とエンティティの規約
- テーブル名:snake_case を使用します。新しいテーブルには
_entity接尾辞を付けません(古い規約のみで使用)。 - カラム名:コードでは camelCase を使用します。カラム名にテーブル名を繰り返し含めないでください(
user.userEmail→user.email)。 - 定数:SCREAMING_CASE ではなく、camelCase を使用します。
- エンティティ名の上書き:エンティティクラス名とテーブル名が異なる場合は、
@Entity({ name: 'snake_case_name' })を明示的に設定します。 - TypeORM のリレーション:直接参照する代わりに
Relation<T>を使用します。これにより、既知の循環インポートの問題を回避できます。 - 抽象エンティティ:該当する場合は
WithTimestampsまたはWithTimestampsAndStringIdを継承します。これが確立された標準です。 - メリットのある具体的な読み取りパターンがない限り、非正規化しないでください。 重複するカラムがある場合は、PR の説明でその理由を示してください。
スキーマのマイグレーション
スキーマ変更には DSL を使用する
追加、削除、変更にはスキーマビルダー DSL を使用してください。これにより、データベース間の型マッピングが自動的に処理されます。ヘルパーが不足している場合は、追加するか、その旨を提起してください。
export class CreateMyTable1234567890000 implements ReversibleMigration {
async up({ schemaBuilder: { createTable, column } }: MigrationContext) {
await createTable('my_table')
.withColumns(
column('id').int.primary.autoGenerate2, // Use autoGenerate2, not autoGenerate
column('name').varchar(255).notNull,
column('workflowId').varchar(36).notNull,
column('config').json, // Maps to json (PG) / text (SQLite)
column('isActive').bool.notNull.default(false),
)
.withTimestamps // Adds createdAt + updatedAt
.withIndexOn(['workflowId'])
.withForeignKey('workflowId', {
tableName: 'workflow_entity',
columnName: 'id',
onDelete: 'CASCADE', // Always explicit
});
}
async down({ schemaBuilder: { dropTable } }: MigrationContext) {
await dropTable('my_table');
}
}
SQLite でのテーブル再作成のリスク
6 つの DSL メソッドは、SQLite でテーブル全体の再作成を引き起こします。TypeORM は内部で一時コピーを作成し、元のテーブルを削除してから名前を変更します。
| メソッド | TypeORM の内部呼び出し |
|---|---|
addColumns() | queryRunner.addColumns() |
dropColumns() | queryRunner.dropColumns() |
addNotNull() | queryRunner.changeColumn() |
dropNotNull() | queryRunner.changeColumn() |
addEnumCheck() | queryRunner.changeColumn() |
dropEnumCheck() | queryRunner.changeColumn() |
6 つすべてで、最後のオプションパラメーターに recreatesOnSqlite: true が必要です。省略すると TypeScript が呼び出しを拒否します。
危険性: 対象テーブルに、他のテーブルからの CASCADE を伴う外部キー制約がある場合、再作成中の DROP TABLE によって連鎖削除が実行され、参照元テーブルの行が消去されます。
判断フロー:
- 対象テーブルに、他のテーブルからの外部キー制約がありますか?
- いいえ → DSL メソッドを直接(確認パラメーターを指定して)安全に使用できます。
- はい → ステップ 2 に進みます。
- これは、追加するすべてのカラムが NULL 許容またはデフォルト値付きの
addColumns呼び出しですか?- はい → 代わりに生の
ALTER TABLE ADD COLUMNを使用します(テーブルの再作成を完全に回避できます)。
実例についてはawait runQuery( `ALTER TABLE ${escape.tableName('my_table')} ADD COLUMN ${escape.columnName('col')} TEXT`, );1733133775640-AddMockedNodesColumnToTestDefinition.tsを参照してください。 - いいえ → ステップ 3 に進みます。
- はい → 代わりに生の
- マイグレーションクラスで
withFKsDisabled = true as constを設定します。共通マイグレーションの場合は、sqlite/に共通マイグレーションを継承し、このフラグを追加する SQLite サブクラスを作成します。// sqlite/1234567890000-MyMigration.ts import { MyMigration1234567890000 as BaseMigration } from '../common/1234567890000-MyMigration'; export class MyMigration1234567890000 extends BaseMigration { withFKsDisabled = true as const; }
withFKsDisabled の動作: マイグレーションラッパーは up()/down() の前に PRAGMA foreign_keys=OFF を呼び出し、手動トランザクション内でマイグレーションを実行してから、外部キーを再び有効にします。これにより、内部のテーブル削除中に CASCADE が実行されるのを防ぎます。また、SQLite では PRAGMA の変更中にトランザクションをネストできないため、TypeORM のデフォルトトランザクションを避ける目的で transaction = false も設定します。
注: Postgres では、これらのメソッドは
ALTER TABLEを直接使用し、テーブルを再作成しません。このリスクがあるのは SQLite のみです。
カラムの型
値の意味に合ったカラム型を選びます。 文字列以外の値を受け入れる用途で、安易に varchar を使わないでください。数値を文字列として保存すると、並べ替え順、範囲クエリ、SUM/AVG 集計が正しく機能しなくなります。
- 日付だけが必要な場合は、
timestampではなくDATEを使います。 - バイト数や測定値には、数値型(
bigint、int、smallint)を使います。varcharは使わないでください。 - 値が実際に UUID の場合は、
varchar(36)よりネイティブのuuidを使います。Postgres はuuidを 16 バイトで保存しますが、varchar(36)では約 37 バイトです。この差は、結合されるテーブルやインデックスが増えるほど大きくなります。 - ブール値には
bool、構造化データにはjsonを使います。 timestampTimezone()(デフォルトの精度は 3 ミリ秒)またはtimestampNoTimezone()を意図的に選びます。.timestamp()は非推奨です。
そのカテゴリ内で、妥当な範囲の最も小さい型を選びます。
- 小さく範囲が限られたカウンターや列挙値には
smallintを使い、オーバーフローが起こりそうにない限りbigintよりintを使います。 intではオーバーフローする可能性がある単調増加するカウンター(インサイト/使用量カウンター)には、あらかじめbigintを使います。- バージョンのようなフィールドに
doubleを使わないでください。浮動小数点数の精度が問題になります。文字列を使うか、メジャーとマイナーに分けてください。 - 実際の長さ制限がないユーザー入力文字列には、
varchar(255)よりtextを使います。(SQLite はvarchar(N)の長さを完全に無視します。必要に応じてアプリケーション層で検証してください。)
NOT NULL とエンティティの整合性
- 主キーは暗黙的に
NOT NULLです。再宣言しないでください。 - マイグレーションの
notNullは、エンティティの null 許容性アノテーションと一致させる必要があります。不一致があると、TypeORM が処理できない実行時の null が発生します。 - デフォルトでは
NOT NULLにし、明確な理由がある場合にのみ緩和します(「これは NULL を許容する必要があるか。必要なら、どのような場合か」)。
カラムにコメントを追加する
名前だけでは用途が明らかでないカラムには、.comment() を使います。特に、JSON ブロブ、フラグ、不透明な ID、Unix タイムスタンプ、外部システム由来の値を持つカラムには必ず追加してください。コメントはスキーマに反映されますが、コードコメントは反映されません。
column('config').json.comment('Serialized node parameters at time of publish'),
column('isArchived').bool.notNull.default(false).comment('Soft-delete flag; filtered out in list queries'),
列挙値のような文字列に制約を設ける
少数の値のいずれかだけを保持すべきカラムには、.withEnumCheck([...]) を使います。生 SQL で CHECK 制約を追加する場合は、明示的に名前を付けてください。
デフォルト値は現実的な初期状態を反映する
status カラムのデフォルトに終端状態を設定しないでください。状態が遷移する場合は、"done" より "running" のほうが適切です。
主キー
すべてのテーブルに主キーが必要です。型は次の順で選びます。
- 整数 —
column('id').int.primary.autoGenerate2。新しいテーブルではこれを推奨します。コンパクトで、結合が高速で、並び順に予想外の問題が起きません。autoGenerate2は Postgres のIDENTITYを使用します(非推奨のserialベースのautoGenerateより推奨されます)。 - UUID —
column('id').uuid.primary。ID をクライアント側で生成する場合、URL で公開する場合、または推測されにくくする必要がある場合に使います。node:cryptoのrandomUUID()を使ってアプリケーションコードで UUID を生成してください。.uuidに.autoGenerate2を連結しないでください(DSL がエラーをスローします。DEFAULT uuid_generate_v4()はpublicのuuid-ossp拡張が必要なため、Supabase などのマネージド Postgres では失敗します)。.varchar(36)ではなく.uuidを使います。 - 文字列 — UUID 形式ではない ID(例:nanoid 形式の ID)には
column('id').varchar(36).primaryを使います。慣例として、エンティティ ID の nanoid の長さは 16 です。
関連するテーブル間で ID カラムの型を統一してください。 「同じ種類の ID」に uuid と varchar(36) を混在させると、JOIN で問題が起きやすくなります。
把握しておくべき DSL の動作:
.primaryはすでにnotNullを暗黙的に指定します。.primaryと.notNullを連結しないでください。冗長です。.primaryはすでに主キーインデックスを作成します。別途.withIndexOn(['id'])を追加しないでください。
複合主キーは標準機能です — 対象となる各カラムに .primary を連結します。自然キーが使える場合は、代理の id を省略してください。
await createTable('membership')
.withColumns(
column('userId').uuid.primary,
column('roleId').uuid.primary,
);
外部キー制約
外部キーはデフォルトで設定します。設定しない場合は、その理由が必要です。 多相参照(ある列が、隣接する型列に基づいて異なるテーブルを参照する場合)については、一般的な設計ガイダンスを参照してください。
onDelete は明示的に指定してください。 データベースのデフォルト値に依存しないでください。PR の説明で「[親]が削除されたらどうなるか」に答えてください。
| リレーションシップの種類 | onDelete | 例 |
|---|---|---|
| 親がないと子に意味がない | CASCADE | annotation_tag_mapping → annotation |
| 子を親より長く保持する(履歴を残す) | SET NULL | workflow_publish_history.userId → user |
| 監査/統計/履歴テーブル | NO ACTION または SET NULL | workflow_statistics → workflow_entity |
| 参照先の削除を防ぐ必要がある | RESTRICT | (ビジネスロジック上、孤児化が許されない場合に使用) |
SET NULL の場合、外部キー列は NULL を許容する必要があります。CASCADE の場合は、カスケードの深さに上限があるか検討してください。カスケードが長く連鎖すると、1 回の削除で多数のテーブルがロックされる可能性があります。
SQLite の注意点:
- テーブルの削除と再作成を繰り返すと、up/down を繰り返すうちに外部キーが重複して残ることがあります。down マイグレーションで対象を指定できるよう、外部キーには明示的に名前を付けてください。
- 外部キーで保護されたインデックスを持つエンジンでは、down マイグレーションが失敗することがあります(
Cannot drop index 'X': needed in a foreign key constraint)。すべてのエンジンで、両方向のマイグレーションをテストしてください。
NULL の結合および一意性に関するセマンティクスは、エンジンによって異なります。 NULL と非 NULL の比較では、少なくとも SQLite では行が除外されます。NULL を含む部分一意性の扱いは、Postgres と SQLite で異なります。列が NULL を許容し、一意制約または結合条件に含まれる場合は、慎重に設計してください。
インデックスの管理
// Creating indices
await schemaBuilder.createIndex('my_table', ['columnA', 'columnB']);
await schemaBuilder.createIndex('my_table', ['email'], true); // unique
// Partial unique index — uniqueness only on non-null rows
await schemaBuilder.createIndex(
'my_table',
['externalRef'],
true, // isUnique
undefined, // customIndexName
'"externalRef" IS NOT NULL', // whereClause
);
// Dropping indices (defensively)
await schemaBuilder.dropIndex('my_table', ['columnA'], { skipIfMissing: true });
ベストプラクティス:
- インデックスは慎重に追加し、速度向上を計測できた場合にのみ追加してください。 インデックスを追加すると、挿入や更新が遅くなり、ディスク容量も消費します。「念のため」に追加しないでください。現実的なデータセットでクエリを実行し、遅いことを確認してからインデックスを追加し、クエリプランナーがそれを使用してクエリが速くなったことを確認してください。測定可能な改善を示せない場合は、そのインデックスをリリースしないでください。
- 一意制約を設定するとインデックスも作成されます。二重に作成しないでください。 複合主キーは先頭の列にもインデックスを作成するため、先頭列を対象にした別のインデックスは冗長です。
- 外部キー列にインデックスを設定してください。 すべての操作で結合とカスケード削除が外部キーにアクセスします。インデックスのない外部キーは、子テーブルのシーケンシャルスキャンになります。
- 複合インデックスでは列の順序が重要です。
(A, B)のインデックスは、WHERE A = ?とWHERE A = ? AND B = ?には使えますが、WHERE B = ?には使えません。インデックスの ORDER BY 方向は、クエリのORDER BYと一致させる必要があります(例:(sessionId, createdAt ASC, id DESC))。 - カーディナリティの低い列だけ(ブール値や値が 2~3 種類のステータス列など)にはインデックスを設定しないでください。 インデックスを省略するか、部分インデックスにしてください。Postgres と SQLite(3.8.0 以降)はどちらも、インデックスに
WHERE句を指定できます。 - 値がまばらに一意となる列には部分一意インデックスを使用してください。
WHERE col IS NOT NULLを追加して NULL 行を除外すると、インデックスが小さくなり、NULL のまとまりに対する一意性チェックも不要になります。 - 一意インデックスは一意性を保証するとともに、検索も高速化します。 一意制約とインデックスを別々に組み合わせるより、一意インデックスを優先してください。
- 使われていないインデックスは削除してください。 クエリプランで使われなくなったら、後続のマイグレーションで削除してください。
- DSL を使ってインデックスに名前を付けてください。 名前を手作業で作成しないでください。DSL は一貫したプレフィックスを付けるため、環境間で名前が揃います。
- DSL のインデックスを
@Indexでエンティティにも反映してください。 マイグレーションによって実行時のインデックスが作成され、エンティティのデコレーターによって新規 DB のセットアップでも整合性が保たれます。 - 新しいテーブルを定義する場合は、別途
createIndex(...)を呼び出すのではなく、.withIndexOn(...)を使用してください。
可逆性
ReversibleMigration:down()は実際に動作しなければなりません。カラムを縮小する場合は、データを適切に切り詰めてください。テーブルを削除する場合は、そのテーブルにデータが格納されている可能性を考慮してください。IrreversibleMigration:up()が適切なdown()に必要な情報を破棄する場合に使用してください。手間のかかるdown()コードを避けるために使ってはいけません。例:以前の状態を保存せずに値を上書きするバックフィル、平文を保持しない暗号化処理、行単位の詳細が失われる集計。- 空または壊れた
down()は絶対に書かないでください。 元に戻せない場合は、IrreversibleMigrationを使用してください。 down()は新しいオブジェクトを削除するだけでなく、以前のスキーマを復元する必要があります。down()の実行後にup()を再度問題なく実行できる状態にしてください。- 両方のエンジンで down マイグレーションをテストしてください。
pnpm start && pnpm start -- db:revert && pnpm startを実行します。down()の失敗は、外部キーで保護されたインデックスがカラムの削除を妨げる場合によく発生します。
スキーマとエンティティの不整合
スキーマ、エンティティ、OpenAPI の型は一致している必要があります。次のような不整合が頻繁に見つかります。
- エンティティでは
notNullがなくなっているのに、マイグレーションでは設定されている(またはその逆)。 - エンティティでは
stringとなっているが、カラムの型は異なる。 @Indexの定義がエンティティに存在しない。upとエンティティで、デフォルト値や制約が一致していない。
データの整合性を保つために新しいカラムが必要な場合(例:active が TRUE のときは必ず activeVersionId が設定される必要がある場合)、マイグレーションの CHECK 制約とアプリケーションコードのランタイム不変条件の両方で強制してください。
カラムは非推奨化してから、後続リリースで削除する
書き込みを停止したのと同じリリースでカラムを削除しないでください。1 リリース待ってから削除します。これにより、ローリングデプロイを保護し、「書き込みを停止する」変更で予期しない問題が発生した場合に、迅速にロールバックできます。
データマイグレーション
データマイグレーションでは、JSON の解析、カラムのバックフィル、テーブル間のデータ移行、無効なデータのクリーンアップなどにより、既存の行を変換します。
常に不正なデータやレガシーデータに対処する
これは、マイグレーションのバグが発生する最大の原因です。
- JSON の解析は try/catch で囲んでください。 警告を記録してその行をスキップし、マイグレーション全体をクラッシュさせないでください。
MigrationContextのparseJson()を使用してください。生のJSON.parseよりもエッジケースに適切に対処できます。 - プロパティにアクセスする前に null/undefined を確認してください。
node.type && isTriggerNode(node.type)。 - 反復処理の前に
Array.isArray()を使ってください。 - データ構造の過去の全バージョンを考慮してください。 現在のバージョンだけでは不十分です。今日リリースするマイグレーションが、最後に更新されたのが 2 年前のデータベースで実行されることもあります。
- SQL で無効な行を除外してください。
WHERE workflowId IS NOT NULL。
await runInBatches<Row>(selectQuery, async (rows) => {
for (const row of rows) {
try {
const nodes = parseJson(row.nodes);
if (!Array.isArray(nodes)) continue; // guard against unexpected shape
for (const node of nodes) {
if (!node.type) continue; // skip nodes missing required fields
// ... transform ...
}
await runQuery(`UPDATE ${table} SET nodes = :nodes WHERE id = :id`, {
nodes: JSON.stringify(nodes),
id: row.id,
});
} catch (error) {
logger.warn(`[${migrationName}] Failed to process row ${row.id}: ${error.message}. Skipping.`);
}
}
});
変換処理は Node ではなく SQL に任せる
行を Node に取得して書き戻すのではなく、INSERT … SELECT、UPDATE … FROM、DELETE … WHERE を優先してください。テーブル全体を JS メモリに読み込む方法は、大規模なインスタンスでは遅く、OOM が発生しやすくなります。同じ処理をデータベース内で行えば、はるかに高速です。
SQL だけでは変換を表現できない場合は、runInBatches を使用してください。Node 側で解析する前に、SQL の LIKE/WHERE で早い段階からフィルタリングし、処理する行数を減らしてください。
バッチ操作を使用する
数百万行に達する可能性があるテーブルに対して、上限なしで SELECT * を実行しないでください。
// ✅: batched processing
await runInBatches<Workflow>(
`SELECT id, nodes FROM ${tableName} WHERE ${condition}`,
async (workflows) => {
for (const workflow of workflows) {
// ... process each workflow ...
}
},
100, // batch size (default: 100, use 100-500)
);
// ✅: batched table copy
await copyTable('old_table', 'new_table', ['col1', 'col2'], ['col1', 'col2'], 500);
テーブル全体の逐次スキャンは、大規模なインスタンスでは非常に低速です。反復処理が必要な場合は、バッチ処理にしてください。
バックフィルの挿入順序を意図的に決める
マイグレーションで、後続の処理から順序を参照できる行(自動インクリメント ID、デフォルトのソート順、「新しいものを先に表示」する UI リストなど)を書き込む場合は、ソースの SELECT に明示的な ORDER BY を追加してください。通常は updatedAt または createdAt を使用します。これがないと、データベースが任意の順序を選び、以前のスキーマで暗黙に保たれていた時系列が失われます。
スキーマとデータを混在させたマイグレーション
カラムの追加とデータのバックフィルを同時に行うマイグレーションでは、関心事ごとにメソッドを分けて明確に構成してください。
export class AddAndBackfillColumn1234567890000 implements IrreversibleMigration {
async up(ctx: MigrationContext) {
await ctx.schemaBuilder.addColumns(
'my_table',
[ctx.schemaBuilder.column('newCol').text],
{ recreatesOnSqlite: true },
);
await this.backfillNewCol(ctx);
}
private async backfillNewCol({ escape, runQuery, runInBatches }: MigrationContext) {
const table = escape.tableName('my_table');
await runInBatches<{ id: string; oldCol: string }>(
`SELECT id, oldCol FROM ${table}`,
async (rows) => {
for (const row of rows) {
const transformed = transform(row.oldCol);
await runQuery(`UPDATE ${table} SET newCol = :val WHERE id = :id`, {
val: transformed,
id: row.id,
});
}
},
);
}
}
スキーマ変更とデータのバックフィルでは、障害の発生パターン、トランザクションへの影響、必要なテストが異なります。メソッドを分けておけばレビューしやすくなり、元に戻せる場合は down() から同じヘルパーを逆順に呼び出せます。
削除する場合は、古い行をフォールバックとして残すことを優先する
セルフホスト型のインスタンスには、想定外のデータ構造がある場合があります。マイグレーションの結果、データが欠落したり整合性が崩れたりした場合、古い行が唯一の復旧手段です。
デフォルトでは、2 リリースに分けた拡張・縮小方式にします。
- リリース N(このマイグレーション): 新しい場所に書き込み、古い場所には残します。
- リリース N+1(別途実施する後続のマイグレーション。新しいコードが本番環境で動作していることを確認してから実施): 古い場所を削除します。
古い場所が本当に一時的なもの(例:このマイグレーションで作成した一時テーブル)である場合、またはコンプライアンス要件により即時削除が必要な場合に限り、間の期間を省略してください。その場合は、マイグレーションを IrreversibleMigration としてマークし、PR の説明でトレードオフを明記します。
非正規化されたカラムの同期を保つ
データが 2 つのテーブル間で重複している場合(例:workflow_entity.nodes と workflow_history.nodes)、バックフィルでは両方のコピーを更新する必要があります。非正規化されたデータの不整合は、数週間後に発覚することが多く、たいていは本番環境で見つかります。
高負荷テーブルでの JSON 部分文字列スキャンを追加しない
WHERE settings::text LIKE '%foo%' を使うのではなく、実際のカラム(例:isArchived に似た isDraft)を追加してください。JSON データの部分文字列スキャンは全テーブルスキャンになり、インデックスも使われません。
高負荷の行に大きなデータを直接保存しない
オプトアウト可能な大きなカラムはサイドテーブルに移してください。バックアップ、レプリケーション、読み取り性能のすべてにメリットがあります。また、行のペイロードが小さくなることで、高負荷テーブルの行レベルロックも短縮されます。
テーブルのコピー時にデータ整合性を検証する
入れ替えの前に、コピー元と一時テーブルの件数を比較し、不一致があればエラーを発生させてください。コピー中のデータ欠落を黙って見過ごすことは、最悪の障害パターンのひとつです。欠落データに誰かが気づくまで表面化しないためです。
マイグレーションのトランザクション内で SQL をアトミックに実行する
トランザクション内での大規模な DDL を許可しないエンジンでは、一部のマイグレーションで transaction = false as const を上書き指定します。DSL/ラッパーは、withFKsDisabled = true の場合に transaction = false を自動的に設定します。それ以外の場合はトランザクションの設定を変更しないでください。TypeORM はデフォルトで各マイグレーションをトランザクションで囲みます。
データベース間の互換性
SQLite と Postgres で単一のマイグレーションファイルを使うか、分けるか
- 小さな差異(単一のステートメント、CHECK 制約、構文のわずかな違い):マイグレーションは
common/に 1 つだけ置き、isSqlite/isPostgresで分岐します。 - 大きな差異(テーブル再作成の方法が異なる、中間手順が異なる、SQL の基本構造が異なる):
postgresdb/とsqlite/に別々のファイルを作成してください。if (isSqlite) { ... }ブロックだらけの共通マイグレーションより、目的を絞った 2 つのファイルの方が読みやすく、レビューしやすくなります。
変更が Postgres にのみ必要な場合は、ファイルを postgresdb/ に置くだけにしてください。if (isPostgres) を使った何もしない SQLite マイグレーションは作成しないでください。SQLite のカラム追加では、共通マイグレーションで十分か、SQLite のサブクラスまたは生の ALTER TABLE による方法が必要かを判断する前に、SQLite テーブル再作成のリスクの判断フローに従ってください。
SQLite は最新の構文に対応している
UPSERT/ON CONFLICT DO NOTHINGは SQLite で使えます。INSERT OR REPLACEではなく、Postgres と同じ構文を使ってください。- SQLite には実際の
JSON型があります。 - SQLite は
ALTER TABLE ... RENAME TOに対応しています。
INSERT OR REPLACE ≠ ON CONFLICT DO NOTHING。 OR REPLACE は上書きしますが、ON CONFLICT DO NOTHING は無視します。SQLite はどちらにも対応しているため、同じコードパスで Postgres のセマンティクスに合う方を選んでください。
Postgres のバージョンに対応した UUID 生成
gen_random_uuid() の実行には Postgres ≥ 13 が必要です。n8n は Postgres 12 のサポートを終了したため、uuid_generate_v4()(uuid-ossp 拡張が必要で、Supabase などのマネージドサービスで問題が発生します)よりもこちらを優先してください。UUID の PK には、アプリケーションレベルで randomUUID() を使用して生成してください。詳細は主キーを参照してください。
SQLite は varchar(N) の長さを強制しません
長さが重要な場合は、アプリケーション層で検証してください。
ブール値のデフォルト値の表示はエンジンによって異なります
DEFAULT (false)、DEFAULT 0、DEFAULT FALSE などがあります。DSL に任せ、SQL を直接記述してブール値のデフォルト値を設定しないでください。
削除して再作成するより ALTER を優先する
名前を変更する場合は、ALTER TABLE ... RENAME TO を使用してください。高速かつアトミックで、データ損失のリスクもありません。
テスト
すべてのデータマイグレーションには、インテグレーションテストを含めてください。 スキーマのみのマイグレーションは、通常 DSL の呼び出しを読めばレビューできます。一方、データマイグレーションはそうはいきません。行の形式、JSON の構造、NULL の処理、エッジケースに関する前提が含まれており、代表的なデータに対してマイグレーションを実行して初めて明らかになるためです。
データマイグレーションは各データベースで一度だけ、本番データに対して実行され、問題なく再試行する機会はありません。不適切なマイグレーションのコストは顧客に影響するインシデントであり、テストのコストは10分です。
テストは packages/cli/test/migration/ に置き、マイグレーションファイルと一致する名前を付けてください(例:1773000000000-create-credential-dependency-table.test.ts)。@n8n/backend-test-utils のヘルパーを使用してください。
initDbUpToMigration(MigrationName)は、作成するマイグレーションの直前までのすべてのマイグレーションを実行し、本番環境でそのマイグレーションが実行される時とまったく同じ状態で DB を準備します。runSingleMigration(MigrationName)は、その状態に対して、指定したマイグレーションのみを実行します。
ヘルパー API の全容:packages/@n8n/backend-test-utils/MIGRATION_TESTING.md。
import { initDbUpToMigration, runSingleMigration } from '@n8n/backend-test-utils';
describe('AddAndBackfillColumn1234567890000', () => {
beforeEach(async () => {
await initDbUpToMigration('AddAndBackfillColumn1234567890000');
});
it('backfills newCol from oldCol', async () => {
// Seed rows in the pre-migration schema
await dataSource.query(`INSERT INTO my_table (id, oldCol) VALUES ('1', 'foo')`);
await runSingleMigration('AddAndBackfillColumn1234567890000');
const [row] = await dataSource.query(`SELECT newCol FROM my_table WHERE id = '1'`);
expect(row.newCol).toBe('transformed-foo');
});
it('skips rows with NULL oldCol without crashing', async () => {
await dataSource.query(`INSERT INTO my_table (id, oldCol) VALUES ('1', NULL)`);
await runSingleMigration('AddAndBackfillColumn1234567890000');
// assert no error and row still exists
});
});
フィクスチャは raw SQL のみで挿入してください。 リポジトリはスキーマとともに変更されるため、古いテストは時間がたつと動作しなくなります。context.escape.tableName(...) と context.runQuery(sql, params) を直接使用してください。
テスト名には SQL ではなく動作を記述してください。 'runs UPDATE on my_table' ではなく、'backfills newCol from oldCol' のようにします。
テスト対象:
- 正常系(一般的な行を正しく変換すること)。
- マイグレーションで処理するとしている各エッジケース(NULL フィールド、不正な形式の JSON、キーの欠落、レガシースキーマのバージョン)。
- 該当する場合は冪等性(マイグレーションを2回実行しても変換が二重に適用されないこと)。
- DB の種類によってマイグレーションの処理が分岐する場合は、SQLite と Postgres の両方。
一般的な設計指針
ここでは、特定の PR コメントに限らない、広く適用できるデータベース設計の原則を紹介します。DB が無料で処理できる作業を手動で調整するなど、誤った設計のコストがコードベースに現れるため、念頭に置く価値があります。
多態的な (typeCol, idCol) ペアは避ける
「多態的な」カラムペアとは、兄弟カラムの型に応じて異なるテーブルを参照する1つのカラムのことです。たとえば、dependencyType: 'externalSecretProvider' | ... と dependencyId: string の組み合わせです。SQL の外部キー(FK)は厳密に1つのテーブルを参照するため、多態的な idCol には FK 宣言を設定できません。
その結果:
- 挿入時の検証が行われません(どの行にも一致しない
dependencyIdを挿入できます)。 - 親の削除時にカスケードや制限が適用されません。アプリケーションコードで、削除された行を参照している可能性のあるすべてのテーブルを手動で確認し、トランザクション内で依存する行を削除する必要があります(この対応の実例として、
credential_dependencyとsecrets_provider_connectionの削除処理を参照してください)。 - 構造上、孤児行が発生する可能性があります。
代替案:
- 関係の種類ごとに結合テーブルを分ける(
credential_external_secret_dependency、credential_node_dependencyなど)。それぞれに実際の FK を設定します。「すべての依存関係」が必要なクエリでは UNION を使います。 - 対象ごとに NULL 許容の FK を1つずつ設けるとともに、ちょうど1つだけが設定されるよう CHECK 制約を設定します。各カラムに実際の FK を設定します。
- スーパータイプテーブル:親を独自の型カラムを持つ単一の
dependency_targetに集約し、そのテーブルへの FK を1つ設定します。
スキーマのドキュメント
データベーススキーマのドキュメントは docs/generated/ 以下にあります。docs/generated/sqlite-schema/ と docs/generated/postgres-schema/ に、テーブルごとの Markdown ページと Mermaid ER 図が格納されています。これらは tbls によってマイグレーションから自動生成されます(使い捨てのデータベースにすべてのマイグレーションを適用し、結果を解析します)。そのため、スキーマを変更するマイグレーションを行うと、ドキュメントが古くなります。
マイグレーションとともに再生成し、コミットしてください。
pnpm db:schema:docs # rewrites docs/generated/ — requires Docker (and `brew install tbls` locally)
pnpm db:schema:check # verify only; what the DB Tests CI job runs
DB Tests CI ジョブでは、コミットされたドキュメントがマイグレーションと一致しない場合、PR が失敗します(各マトリックスの実行環境で、それぞれのデータベースを検証します)。docs/generated/ 以下のファイルを手動で編集しないでください。再生成のたびに上書きされます。