ビルド・ツーリング
リポジトリ: drizzle-team/drizzle-orm 分析日: 2026-03-04
概要
drizzle-orm は tsup/Turborepo/dprint を軸にしたビルド・フォーマットパイプラインを構築している。特筆すべきは、444 ファイルからなる drizzle-orm パッケージの CJS/ESM デュアルパブリッシュを、カスタムビルドスクリプト(scripts/build.ts)と AST ベースのインポートパス書き換え(scripts/fix-imports.ts)で実現している点である。tsup の出力を信頼せず、tsc による型生成と recast による後処理を組み合わせることで、@arethetypeswrong/cli(attw)検証を CI で通過する高品質なパッケージ成果物を生産している。
背景にある原則
ビルド出力のアトミック性:
dist.newに全成果物を書き出し、最後にdist.new->distへリネームすることで、ビルド途中の壊れたdistを参照するリスクを排除している(drizzle-orm/scripts/build.ts:49,75-76)。さらにworkspace:./drizzle-orm/distプロトコルで他パッケージがdistを直接参照するため、この原子性は依存パッケージの整合性にも直結する。ソースコードが exports map の唯一の真実: 444 ファイルの exports map を手動で管理するのは非現実的。
src/**/*.tsを glob してエントリポイントを自動生成し、package.jsonのexportsフィールドにビルド時に書き込む。これにより、ソースファイルの追加だけで新しいサブパス(drizzle-orm/pg-core等)が自動的にエクスポートされる(drizzle-orm/scripts/build.ts:8-44)。タスクグラフによるビルド順序の宣言的管理:
turbo.jsonでパッケージ間の依存をdependsOnとして宣言し、ビルド順序を Turborepo に委ねる。drizzle-orm#buildがハブとなり、他の全パッケージがこれに依存する星形のグラフを形成している(turbo.json:32-33, 51-52, 73-74等)。フォーマッタとリンタの責務分離: dprint をフォーマッタ、ESLint をリンタとして明確に分離している。dprint は WASM プラグインで高速にコードスタイルを強制し、ESLint は
no-instanceof、require-entity-kind、import/extensionsなどの意味的ルールに集中する(.eslintrc.yaml、dprint.json)。
実例と分析
カスタムビルドスクリプトによるデュアルパブリッシュ
drizzle-orm の scripts/build.ts は以下の 5 段階で動作する:
dist.newを削除して前回のビルド残骸をクリーンアップ- tsup と tsc を並列実行: tsup が CJS/ESM の JS ファイルを生成し、tsc が
.d.ts型定義ファイルを生成する。型定義は.d.tsと.d.ctsの 2 形式にコピーされる version.tsを個別ビルド:package.jsonから JSON import するこのファイルだけ--no-configで個別にビルドする- AST ベースのインポートパス修正:
fix-imports.tsが recast で全ファイルの AST を走査し、CJS ファイルは.cjs拡張子、ESM ファイルは.js拡張子にインポートパスを書き換える。同時にパスエイリアス~/を相対パスに解決する - アトミックスワップ:
package.json(exports map 自動生成済み)とREADME.mdをコピーし、distを削除してdist.newをdistにリネーム
Turborepo のタスクグラフ設計
turbo.json のタスク依存は星形トポロジーを形成している:
drizzle-orm#build (ハブ)
├── drizzle-kit#build
├── drizzle-zod#build
├── drizzle-typebox#build
├── drizzle-valibot#build
├── drizzle-arktype#build
├── drizzle-seed#build
├── eslint-plugin-drizzle#build
└── integration-tests#build (+ drizzle-seed#build にも依存)//#lint(ルートタスク)は ^test:types と drizzle-orm#build に依存しており、型チェックとビルドが完了してからリントが走る。test と pack は build と test:types の両方に依存し、リリースフローの後段に位置する。
各タスクの inputs は精密に指定されており、src/**/*.ts、tsconfig.json、tsup.config.ts、scripts/build.ts など関連ファイルのみがキャッシュキーに含まれる。dist/** と dist-dts/** が outputs として宣言されているため、変更がなければキャッシュヒットでスキップされる。
dprint によるフォーマット戦略
dprint は TypeScript、JSON、Markdown の 3 つの WASM プラグインを使用する。設定はミニマルで、タブ使用とシングルクォートが主な指定である。excludes で dist、dist-dts、dist.new などのビルド成果物を除外し、フォーマッタがビルド出力に触れることを防いでいる。
ルートの package.json で lint: dprint check --list-different、lint:fix: dprint fmt と定義され、Turborepo の //#lint タスクとして実行される。
カスタム ESLint プラグインによるドメインルール
eslint-plugin-drizzle-internal は require-entity-kind ルールを 1 つだけ提供する。全クラスに static readonly [entityKind]: string プロパティを強制し、自動修正機能付きで違反時にプロパティを挿入する。これは instanceof の代わりに is() 関数でエンティティ判定するアーキテクチャを支える。no-instanceof/no-instanceof ルールが instanceof の使用を禁止し、例外箇所は eslint-disable コメントで明示的に許可する。
@arethetypeswrong/cli による型正当性検証
CI の attw ジョブが全 8 パッケージに対して bunx attw package.tgz を実行し、CJS/ESM の型定義が正しく解決されることを検証する。release ジョブは test と attw の両方に依存しており、型の不整合があるパッケージはリリースされない。drizzle-kit はビルドスクリプト内でも attw --pack dist を直接実行している。
コード例
// drizzle-orm/scripts/build.ts:8-44
// exports map 自動生成 — src 配下の全 .ts ファイルから導出
const entries = await glob("src/**/*.ts");
pkg.exports = entries.reduce<
Record<string, {
import: { types?: string; default: string; };
require: { types: string; default: string; };
default: string;
types: string;
}>
>(
(acc, rawEntry) => {
const entry = rawEntry.match(/src\/(.*)\.ts/)![1]!;
const exportsEntry = entry === "index" ? "." : "./" + entry.replace(/\/index$/, "");
const importEntry = `./${entry}.js`;
const requireEntry = `./${entry}.cjs`;
acc[exportsEntry] = {
import: {
types: `./${entry}.d.ts`,
default: importEntry,
},
require: {
types: `./${entry}.d.cts`,
default: requireEntry,
},
types: `./${entry}.d.ts`,
default: importEntry,
};
return acc;
},
{},
);// drizzle-orm/scripts/fix-imports.ts:27-66
// CJS ファイルのインポートパスを AST レベルで .cjs に書き換え
const cjsFiles = await glob("dist.new/**/*.{cjs,d.cts}");
await Promise.all(cjsFiles.map(async (file) => {
const code = parse(await fs.readFile(file, "utf8"), { parser });
visit(code, {
visitImportDeclaration(path) {
path.value.source.value = fixImportPath(path.value.source.value, file, ".cjs");
this.traverse(path);
},
visitExportAllDeclaration(path) {
path.value.source.value = fixImportPath(path.value.source.value, file, ".cjs");
this.traverse(path);
},
visitCallExpression(path) {
if (path.value.callee.type === "Identifier" && path.value.callee.name === "require") {
path.value.arguments[0].value = fixImportPath(path.value.arguments[0].value, file, ".cjs");
}
this.traverse(path);
},
// ...
});
await fs.writeFile(file, print(code).code);
}));// drizzle-orm/tsup.config.ts:1-19
// tsup 設定 — バンドルなし・分割なしでファイル単位トランスパイル
import { globSync } from "glob";
import { defineConfig } from "tsup";
const entries = globSync("src/**/*.ts");
export default defineConfig({
entry: entries,
outDir: "dist.new",
format: ["cjs", "esm"],
bundle: false,
splitting: false,
sourcemap: true,
outExtension({ format }) {
return {
js: format === "cjs" ? ".cjs" : ".js",
};
},
tsconfig: "tsconfig.build.json",
});// drizzle-orm/scripts/build.ts:49-76
// 並列ビルド + アトミックスワップ
await fs.remove("dist.new");
await Promise.all([
(async () => {
await $`tsup`.stdio("pipe", "pipe", "pipe");
})(),
(async () => {
await $`tsc -p tsconfig.dts.json`.stdio("pipe", "pipe", "pipe");
await cpy("dist-dts/**/*.d.ts", "dist.new", {
rename: (basename) => basename.replace(/\.d\.ts$/, ".d.cts"),
});
await cpy("dist-dts/**/*.d.ts", "dist.new", {
rename: (basename) => basename.replace(/\.d\.ts$/, ".d.ts"),
});
})(),
]);
// ...
await fs.remove("dist");
await fs.rename("dist.new", "dist");# .eslintrc.yaml:24-40 (抜粋)
# import 拡張子強制 + 循環参照検出
rules:
import/no-cycle: error
import/no-self-import: error
import/extensions:
- error
- always
- ignorePackages: true
'no-instanceof/no-instanceof': 'error'
'drizzle-internal/require-entity-kind': 'error'パターンカタログ
Staging Directory Pattern (分類: ビルド)
- 解決する問題: ビルド途中に不完全な成果物を他プロセスが参照するリスク
- 適用条件: ビルド出力が他パッケージから直接参照されるモノレポ環境
- コード例:
drizzle-orm/scripts/build.ts:49,75-76(dist.new->distアトミックスワップ) - 注意点:
workspace:./drizzle-orm/distのような直接パス参照がある場合に特に重要
Source-Derived Exports Map (分類: 生成)
- 解決する問題: 数百のサブパスエクスポートを手動管理する非現実性
- 適用条件: ソースディレクトリ構造がそのまま公開 API 構造に対応するライブラリ
- コード例:
drizzle-orm/scripts/build.ts:8-44 - 注意点: ソース構造の変更が即座に公開 API に影響するため、意図しないエクスポートに注意が必要
AST-Based Import Rewriting (分類: 変換)
- 解決する問題: CJS/ESM デュアルパブリッシュ時の拡張子不一致
- 適用条件:
bundle: falseでファイル単位トランスパイルし、CJS と ESM で異なる拡張子が必要な場合 - コード例:
drizzle-orm/scripts/fix-imports.ts:27-66 - 注意点: 正規表現による文字列置換では動的 import や re-export のパスを見逃すため、AST 走査が必要
Good Patterns
- tsup と tsc の並列実行による型生成分離: tsup は JS トランスパイルに専念させ(
dts: false相当)、型定義はtsc -p tsconfig.dts.jsonで別途生成する。tsup の DTS 生成は大規模プロジェクトで不安定になりうるため、tsc に委ねることで信頼性を確保している。両者をPromise.allで並列実行し、ビルド時間も最小化している。
// drizzle-orm/scripts/build.ts:51-63
await Promise.all([
(async () => {
await $`tsup`.stdio("pipe", "pipe", "pipe");
})(),
(async () => {
await $`tsc -p tsconfig.dts.json`.stdio("pipe", "pipe", "pipe");
await cpy("dist-dts/**/*.d.ts", "dist.new", {/* .d.cts コピー */});
await cpy("dist-dts/**/*.d.ts", "dist.new", {/* .d.ts コピー */});
})(),
]);workspace:./pkg/distによる dist 直接参照: pnpm のworkspace:プロトコルでdistディレクトリを直接指すことで、モノレポ内のパッケージ間依存を「ビルド済み成果物」に対して解決する。これにより、消費側のパッケージは常にビルド後の CJS/ESM バンドルを参照し、ソースコードへの依存を排除する。
// package.json:25
"drizzle-orm": "workspace:./drizzle-orm/dist"- Turborepo の精密な inputs/outputs 指定: 各ビルドタスクの
inputsにsrc/**/*.ts、tsconfig.json、tsup.config.ts、scripts/build.tsを明示し、outputsにdist/**を指定する。無関係なファイル変更でキャッシュが無効化されることを防ぎ、CI ビルドの実行時間を最適化する。
// turbo.json:33-49
"drizzle-orm#build": {
"inputs": [
"src/**/*.ts", "package.json", "README.md", "../README.md",
"tsconfig.json", "tsconfig.*.json", "tsup.config.ts",
"scripts/build.ts", "scripts/fix-imports.ts", "../tsconfig.json"
],
"outputs": ["dist/**", "dist-dts/**"]
}- CI での
@arethetypeswrong/cliゲート: リリースパイプラインでattwジョブを必須ゲートとして配置し、CJS/ESM の型解決に不整合がある場合にリリースをブロックする。これは「ビルドが通る」と「型が正しく解決される」は別問題であるという認識に基づく。
Anti-Patterns / 注意点
- tsup の DTS 生成への過信: tsup には
dts: trueオプションがあるが、drizzle-orm はこれを使わず tsc で型を生成している。大量のファイルやパスエイリアスがある場合、tsup の DTS 生成は不完全になりうる。ただし drizzle-kit ではtsup.build({ dts: true })を使用しており、パッケージの規模に応じた使い分けが見られる。
// Bad: 大規模パッケージで tsup に DTS 生成を任せる
export default defineConfig({
dts: true, // 数百ファイルでは不安定になりうる
});
// Better: tsc で型生成を分離し並列実行
await Promise.all([
$`tsup`, // JS のみ
$`tsc -p tsconfig.dts.json`, // 型定義のみ
]);- 正規表現ベースのインポートパス書き換え: 文字列置換でインポートパスの拡張子を変更するアプローチは、動的 import、
export * from、require()呼び出し内のパスを見逃すリスクがある。drizzle-orm が recast による AST 走査を採用しているのはこの問題への対処である。
// Bad: 正規表現による文字列置換
content = content.replace(/from '\.\/(.+)\.js'/g, "from './$1.cjs'");
// Better: AST 走査で全ノード型を網羅
visit(code, {
visitImportDeclaration(path) {/* ... */},
visitExportAllDeclaration(path) {/* ... */},
visitCallExpression(path) {/* require() も捕捉 */},
visitAwaitExpression(path) {/* 動的 import も捕捉 */},
});- exports map の手動管理: エントリポイントが数十を超えるパッケージで exports map を手動で維持すると、追加漏れや不整合が頻発する。
// Bad: 手動で数百エントリを管理
"exports": {
".": { /* ... */ },
"./pg-core": { /* ... */ },
"./mysql-core": { /* ... */ }
// 新しいサブパスを追加し忘れる
}
// Better: ソースから自動生成
const entries = await glob('src/**/*.ts');
pkg.exports = entries.reduce((acc, entry) => { /* ... */ }, {});導出ルール
[MUST]CJS/ESM デュアルパブリッシュ時は、CI に@arethetypeswrong/cli(attw)検証を組み込み、型解決の正当性をリリースゲートにする- 根拠: drizzle-orm は全 8 パッケージに対して attw を CI 必須ゲートとし、
releaseジョブがattwの成功に依存する設計にしている(.github/workflows/release-latest.yaml:222-312)
- 根拠: drizzle-orm は全 8 パッケージに対して attw を CI 必須ゲートとし、
[MUST]モノレポでビルド成果物を他パッケージが参照する場合、ステージングディレクトリ(dist.new)でビルドし、完了後にアトミックにリネームする- 根拠:
workspace:./drizzle-orm/distで直接参照されるため、ビルド途中の壊れたdistは依存パッケージのビルド失敗を引き起こす(drizzle-orm/scripts/build.ts:49,75-76)
- 根拠:
[MUST]Turborepo のタスク定義ではinputsとoutputsを精密に指定し、キャッシュの有効性を最大化する- 根拠: ビルドスクリプト自体(
scripts/build.ts、scripts/fix-imports.ts)をinputsに含めないとスクリプト変更時にキャッシュが無効化されない(turbo.json:33-49)
- 根拠: ビルドスクリプト自体(
[SHOULD]エントリポイントが多数あるパッケージの exports map は、ソースディレクトリから自動生成する- 根拠: drizzle-orm は 444 のソースファイルから exports map をビルド時に自動生成し、手動管理のコストとエラーを排除している(
drizzle-orm/scripts/build.ts:8-44)
- 根拠: drizzle-orm は 444 のソースファイルから exports map をビルド時に自動生成し、手動管理のコストとエラーを排除している(
[SHOULD]大規模パッケージでは tsup の DTS 生成を使わず、tsc で型定義を別途生成して並列実行する- 根拠: drizzle-orm は tsup と tsc を
Promise.allで並列実行し、tsup は JS トランスパイル、tsc は型生成に責務を分離している(drizzle-orm/scripts/build.ts:51-63)
- 根拠: drizzle-orm は tsup と tsc を
[SHOULD]フォーマッタ(dprint/Prettier)とリンタ(ESLint)は責務を分離し、フォーマッタはコードスタイル、リンタは意味的ルールに集中させる- 根拠: dprint がスタイル強制、ESLint が
import/no-cycle、no-instanceof、require-entity-kindなどのセマンティックルールに専念する分離設計(dprint.json、.eslintrc.yaml)
- 根拠: dprint がスタイル強制、ESLint が
[SHOULD]CJS/ESM デュアルパブリッシュでインポートパスの拡張子を変換する場合は、正規表現ではなく AST 走査を使う- 根拠:
fix-imports.tsは recast でimport、export、require()、動的import()の全ノード型を網羅し、正規表現では見逃しうるパターンを確実に変換する(drizzle-orm/scripts/fix-imports.ts:29-66)
- 根拠:
[AVOID]モノレポの lint タスクをビルド完了前に実行すること- 根拠:
//#lintはdrizzle-orm#buildと^test:typesにdependsOnを設定し、ビルド・型チェック完了後にのみ実行される。ビルド前にリントすると、生成された型情報が不足しimport/extensions等のルールが誤検出する(turbo.json:4-16)
- 根拠:
適用チェックリスト
- [ ] CJS/ESM デュアルパブリッシュしているパッケージで
attw検証を CI に組み込んでいるか - [ ] ビルド成果物を他パッケージが直接参照する場合、ステージングディレクトリ経由のアトミックスワップを使っているか
- [ ] Turborepo の
inputsにビルドスクリプト自体(build.ts等)を含めているか - [ ] exports map のエントリ数が 10 を超える場合、自動生成を検討したか
- [ ] tsup で DTS 生成に問題がある場合、tsc への分離を検討したか
- [ ] dprint/Prettier とESLint の責務が重複していないか(フォーマットルールを ESLint から除去しているか)
- [ ] インポートパスの拡張子変換に正規表現を使っている場合、動的 import や re-export の見逃しがないか
- [ ] Turborepo のタスクグラフで lint が build に依存しているか