Documentation Drizzle

概要

この記事では、JavaScriptやTypeScriptのプロジェクトで使用できる軽量ORM「Drizzle ORM」を用いて、Cloudflareのサーバレスデータベース「Cloudflare D1」に接続する方法を詳しく解説します。Drizzle ORMは、型安全性が高く、直感的なSQLスタイルのクエリ構文で操作できるため、データベース操作を効率的かつ安全に行えます。Cloudflare D1はSQLiteベースのサーバレスデータベースで、特にスケーラビリティが求められるWebアプリケーションに適しています。

Drizzle ORMとは

Drizzle ORMは、JavaScriptとTypeScriptでのデータベース操作を簡単にし、型安全なクエリを可能にするORM(オブジェクトリレーショナルマッピング)ツールです。特徴は以下の通りです。

  • TypeScriptとの相性:型安全な設計により、データベース操作のエラーを事前に防ぎます。
  • 簡単なクエリ構文:SQLに似た構文で、データ操作がしやすい。
  • 効率的で軽量:高速で軽量な設計のため、パフォーマンスも良好です。

Cloudflare D1とは

Cloudflare D1は、Cloudflareのインフラ上で動作するSQLiteベースのサーバレスデータベースです。特徴は以下の通りです。

  • サーバレスでスケーラブル:インフラ管理不要で自動的にスケールします。
  • SQLiteベース:軽量なため、高速かつシンプルに操作可能。
  • Cloudflare Workersと統合可能:フロントエンドから直接アクセスでき、応答速度が早い。 Cloudflare D1とDrizzle ORMを組み合わせることで、スケーラブルでメンテナンスが容易なデータベース管理が可能になります。

Drizzle ORMを用いたCloudflare D1への接続手順

プロジェクトのセットアップ

Drizzle ORMとCloudflare D1を利用するには、まずNode.js環境でプロジェクトを準備します。

# プロジェクトのディレクトリを作成
mkdir drizzle-cloudflare-d1
cd drizzle-cloudflare-d1
# npmプロジェクトの初期化
npm init -y

必要なパッケージのインストール

Cloudflare D1との接続を可能にするため、必要なパッケージをインストールします。

# Drizzle ORM とD1用のドライバをインストール
npm install drizzle-orm

# Cloudflare Workers 開発ツール(Wrangler)をインストール
npm install -D wrangler

# Drizzle Kit(マイグレーションツール)をインストール
npm install -D drizzle-kit
  • drizzle-orm: Drizzle ORM本体(D1用のドライバも含む)
  • wrangler: Cloudflare Workersの開発・デプロイツール
  • drizzle-kit: スキーマからマイグレーションを生成するツール

Cloudflare D1データベースの作成と設定

Cloudflare D1を使用するには、Cloudflareアカウントが必要です。Wranglerコマンドを使用してデータベースを作成します。

# Cloudflareにログイン
npx wrangler login

# D1データベースを作成
npx wrangler d1 create my-database

# 出力例:
# ✅ Successfully created DB 'my-database'
#
# [[d1_databases]]
# binding = "DB"
# database_name = "my-database"
# database_id = "xxxx-xxxx-xxxx-xxxx"

出力された設定情報を wrangler.toml ファイルに追加します。

# wrangler.toml
name = "my-worker"
main = "src/index.ts"
compatibility_date = "2024-01-01"

[[d1_databases]]
binding = "DB"  # Workerコード内で使用する変数名
database_name = "my-database"
database_id = "xxxx-xxxx-xxxx-xxxx"  # 実際のデータベースID

この設定により、Workerコード内で env.DB としてD1データベースにアクセスできます。

Drizzle ORMとCloudflare D1の接続設定

Cloudflare Workers内でDrizzle ORMを使用してD1に接続します。Workersでは env オブジェクトから D1 インスタンスにアクセスできます。

// src/db/index.ts
import { drizzle } from 'drizzle-orm/d1';

// Cloudflare Workers の環境型定義
export interface Env {
  DB: D1Database;  // wrangler.toml で定義した binding 名
}

// Drizzle ORM インスタンスを作成する関数
export function getDb(env: Env) {
  return drizzle(env.DB);
}

Cloudflare Workers内でD1を使用する際は、Workers環境からD1インスタンス (env.DB) を受け取り、それをDrizzle ORMでラップして使用します。

テーブルのスキーマ定義

Drizzle ORMでは、データベーステーブルのスキーマをTypeScriptの型として定義します。以下はtasksテーブルを定義する例です。

// src/db/schema.ts
import { sqliteTable, integer, text } from 'drizzle-orm/sqlite-core';
import { sql } from 'drizzle-orm';

// tasks テーブルの定義
export const tasks = sqliteTable('tasks', {
  id: integer('id').primaryKey({ autoIncrement: true }),
  title: text('title').notNull(),
  description: text('description'),
  completed: integer('completed', { mode: 'boolean' }).notNull().default(false),
  priority: integer('priority').notNull().default(0),
  created_at: integer('created_at', { mode: 'timestamp' })
    .notNull()
    .default(sql`(unixepoch())`),
  updated_at: integer('updated_at', { mode: 'timestamp' })
    .notNull()
    .default(sql`(unixepoch())`)
});

// 型定義の自動生成
export type Task = typeof tasks.$inferSelect;
export type NewTask = typeof tasks.$inferInsert;

このスキーマ定義では、以下の機能を実装しています:

  • integer().primaryKey({ autoIncrement: true }): 自動インクリメントの主キー
  • text(): 文字列型(SQLiteでは TEXT 型)
  • integer({ mode: 'boolean' }): 真偽値(SQLiteでは 0/1 として保存)
  • integer({ mode: 'timestamp' }): タイムスタンプ(UNIXエポック秒)
  • sql``: SQLite関数の直接使用(デフォルト値に unixepoch()` 関数)
  • 型推論による型安全性($inferSelect, $inferInsert

基本的なデータ操作 - 挿入、取得、更新、削除

Drizzle ORMを使用して、Cloudflare D1上のテーブルに対して基本的なデータ操作を行います。

データの挿入

import { drizzle } from 'drizzle-orm/d1';
import { tasks, type NewTask } from './schema';
import { eq } from 'drizzle-orm';

// 単一タスクの挿入
async function insertTask(db: ReturnType<typeof drizzle>, data: NewTask) {
  const result = await db.insert(tasks).values(data).returning();
  return result[0];
}

// 使用例
const newTask = await insertTask(db, {
  title: 'Drizzle ORMを学ぶ',
  description: 'Cloudflare D1との連携方法を習得する',
  priority: 1
});

// 複数タスクの一括挿入
async function insertMultipleTasks(db: ReturnType<typeof drizzle>, taskList: NewTask[]) {
  return await db.insert(tasks).values(taskList).returning();
}

await insertMultipleTasks(db, [
  { title: 'タスク1', priority: 1 },
  { title: 'タスク2', priority: 2 },
  { title: 'タスク3', priority: 3 }
]);

データの取得

// 全タスク取得
async function getAllTasks(db: ReturnType<typeof drizzle>) {
  return await db.select().from(tasks);
}

// 条件付き取得
async function getTaskById(db: ReturnType<typeof drizzle>, id: number) {
  const result = await db
    .select()
    .from(tasks)
    .where(eq(tasks.id, id))
    .limit(1);
  return result[0];
}

// 未完了タスクの取得(優先度順)
import { and, desc } from 'drizzle-orm';

async function getIncompleteTasks(db: ReturnType<typeof drizzle>) {
  return await db
    .select()
    .from(tasks)
    .where(eq(tasks.completed, false))
    .orderBy(desc(tasks.priority), tasks.created_at);
}

// LIKE検索
import { like } from 'drizzle-orm';

async function searchTasks(db: ReturnType<typeof drizzle>, keyword: string) {
  return await db
    .select()
    .from(tasks)
    .where(like(tasks.title, `%${keyword}%`));
}

// ページネーション
async function getTasksWithPagination(
  db: ReturnType<typeof drizzle>,
  page: number = 1,
  pageSize: number = 10
) {
  const offset = (page - 1) * pageSize;
  return await db
    .select()
    .from(tasks)
    .limit(pageSize)
    .offset(offset);
}

データの更新

// タスクの完了状態を更新
async function completeTask(db: ReturnType<typeof drizzle>, id: number) {
  const result = await db
    .update(tasks)
    .set({
      completed: true,
      updated_at: new Date()
    })
    .where(eq(tasks.id, id))
    .returning();
  return result[0];
}

// 複数フィールドの更新
async function updateTask(
  db: ReturnType<typeof drizzle>,
  id: number,
  data: Partial<NewTask>
) {
  return await db
    .update(tasks)
    .set({
      ...data,
      updated_at: new Date()
    })
    .where(eq(tasks.id, id))
    .returning();
}

// 使用例
await updateTask(db, 1, {
  title: '更新されたタスク',
  priority: 5,
  description: '新しい説明'
});

// 一括更新(全タスクの優先度を1上げる)
import { sql } from 'drizzle-orm';

async function increasePriority(db: ReturnType<typeof drizzle>) {
  await db
    .update(tasks)
    .set({
      priority: sql`${tasks.priority} + 1`,
      updated_at: new Date()
    });
}

データの削除

// 単一タスクの削除
async function deleteTask(db: ReturnType<typeof drizzle>, id: number) {
  const result = await db
    .delete(tasks)
    .where(eq(tasks.id, id))
    .returning();
  return result[0];
}

// 完了済みタスクを全削除
async function deleteCompletedTasks(db: ReturnType<typeof drizzle>) {
  return await db
    .delete(tasks)
    .where(eq(tasks.completed, true))
    .returning();
}

// 古いタスクの削除(30日以上前)
import { lt } from 'drizzle-orm';

async function deleteOldTasks(db: ReturnType<typeof drizzle>, daysOld: number = 30) {
  const cutoffDate = new Date();
  cutoffDate.setDate(cutoffDate.getDate() - daysOld);
  const cutoffTimestamp = Math.floor(cutoffDate.getTime() / 1000);

  return await db
    .delete(tasks)
    .where(lt(tasks.created_at, new Date(cutoffTimestamp * 1000)))
    .returning();
}

Cloudflare Workersでの実装例

Cloudflare D1をCloudflare Workersから操作する、RESTful APIの完全な実装例を示します。

// src/index.ts
import { drizzle } from 'drizzle-orm/d1';
import { tasks, type NewTask } from './db/schema';
import { eq, desc } from 'drizzle-orm';

export interface Env {
  DB: D1Database;
}

// ルーターの実装
export default {
  async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
    const db = drizzle(env.DB);
    const url = new URL(request.url);
    const path = url.pathname;

    // CORS ヘッダー
    const corsHeaders = {
      'Access-Control-Allow-Origin': '*',
      'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS',
      'Access-Control-Allow-Headers': 'Content-Type',
      'Content-Type': 'application/json'
    };

    // OPTIONSリクエスト(CORS preflight)
    if (request.method === 'OPTIONS') {
      return new Response(null, { headers: corsHeaders });
    }

    try {
      // GET /tasks - 全タスク取得
      if (path === '/tasks' && request.method === 'GET') {
        const completed = url.searchParams.get('completed');
        const search = url.searchParams.get('search');

        let query = db.select().from(tasks);

        // フィルタリング
        if (completed !== null) {
          query = query.where(eq(tasks.completed, completed === 'true'));
        }

        const result = await query.orderBy(desc(tasks.priority), tasks.created_at);

        // 検索フィルタ(メモリ上で実行)
        let filteredResult = result;
        if (search) {
          filteredResult = result.filter(task =>
            task.title.toLowerCase().includes(search.toLowerCase())
          );
        }

        return new Response(JSON.stringify({
          success: true,
          data: filteredResult,
          count: filteredResult.length
        }), {
          headers: corsHeaders,
          status: 200
        });
      }

      // GET /tasks/:id - 特定タスク取得
      const taskIdMatch = path.match(/^\/tasks\/(\d+)$/);
      if (taskIdMatch && request.method === 'GET') {
        const id = parseInt(taskIdMatch[1]);

        const result = await db
          .select()
          .from(tasks)
          .where(eq(tasks.id, id))
          .limit(1);

        if (result.length === 0) {
          return new Response(JSON.stringify({
            success: false,
            error: 'Task not found'
          }), {
            headers: corsHeaders,
            status: 404
          });
        }

        return new Response(JSON.stringify({
          success: true,
          data: result[0]
        }), {
          headers: corsHeaders,
          status: 200
        });
      }

      // POST /tasks - 新規タスク作成
      if (path === '/tasks' && request.method === 'POST') {
        const body = await request.json() as NewTask;

        // バリデーション
        if (!body.title || body.title.trim() === '') {
          return new Response(JSON.stringify({
            success: false,
            error: 'Title is required'
          }), {
            headers: corsHeaders,
            status: 400
          });
        }

        const result = await db
          .insert(tasks)
          .values({
            title: body.title,
            description: body.description || null,
            priority: body.priority || 0,
            completed: false
          })
          .returning();

        return new Response(JSON.stringify({
          success: true,
          message: 'Task created successfully',
          data: result[0]
        }), {
          headers: corsHeaders,
          status: 201
        });
      }

      // PUT /tasks/:id - タスク更新
      if (taskIdMatch && request.method === 'PUT') {
        const id = parseInt(taskIdMatch[1]);
        const body = await request.json() as Partial<NewTask>;

        const result = await db
          .update(tasks)
          .set({
            ...body,
            updated_at: new Date()
          })
          .where(eq(tasks.id, id))
          .returning();

        if (result.length === 0) {
          return new Response(JSON.stringify({
            success: false,
            error: 'Task not found'
          }), {
            headers: corsHeaders,
            status: 404
          });
        }

        return new Response(JSON.stringify({
          success: true,
          message: 'Task updated successfully',
          data: result[0]
        }), {
          headers: corsHeaders,
          status: 200
        });
      }

      // DELETE /tasks/:id - タスク削除
      if (taskIdMatch && request.method === 'DELETE') {
        const id = parseInt(taskIdMatch[1]);

        const result = await db
          .delete(tasks)
          .where(eq(tasks.id, id))
          .returning();

        if (result.length === 0) {
          return new Response(JSON.stringify({
            success: false,
            error: 'Task not found'
          }), {
            headers: corsHeaders,
            status: 404
          });
        }

        return new Response(JSON.stringify({
          success: true,
          message: 'Task deleted successfully'
        }), {
          headers: corsHeaders,
          status: 200
        });
      }

      // 404 Not Found
      return new Response(JSON.stringify({
        success: false,
        error: 'Not found'
      }), {
        headers: corsHeaders,
        status: 404
      });

    } catch (error: any) {
      console.error('Error:', error);
      return new Response(JSON.stringify({
        success: false,
        error: error.message || 'Internal server error'
      }), {
        headers: corsHeaders,
        status: 500
      });
    }
  }
};

このWorkerは、完全なRESTful APIを実装しており、以下の機能を提供します:

  • GET /tasks: 全タスク取得(フィルタリング・検索機能付き)
  • GET /tasks/:id: 特定タスク取得
  • POST /tasks: 新規タスク作成
  • PUT /tasks/:id: タスク更新
  • DELETE /tasks/:id: タスク削除
  • CORS対応: ブラウザからのアクセスに対応

マイグレーションの実行

Drizzle Kitを使用してスキーマからマイグレーションSQLを生成し、D1に適用します。

// drizzle.config.ts
import type { Config } from 'drizzle-kit';

export default {
  schema: './src/db/schema.ts',
  out: './migrations',
  driver: 'd1',
  dbCredentials: {
    wranglerConfigPath: './wrangler.toml',
    dbName: 'my-database'
  }
} satisfies Config;

マイグレーション実行手順:

# 1. スキーマからマイグレーションSQLを生成
npx drizzle-kit generate:sqlite

# 2. ローカルD1にマイグレーションを適用
npx wrangler d1 execute my-database --local --file=./migrations/0000_initial.sql

# 3. 本番D1にマイグレーションを適用
npx wrangler d1 execute my-database --remote --file=./migrations/0000_initial.sql

# 4. マイグレーション履歴の確認
npx wrangler d1 execute my-database --command "SELECT * FROM __drizzle_migrations"

デプロイ方法

# ローカルでの開発サーバー起動
npx wrangler dev

# 本番環境へのデプロイ
npx wrangler deploy

# デプロイ後のログ確認
npx wrangler tail

package.jsonのスクリプト設定

{
  "scripts": {
    "dev": "wrangler dev",
    "deploy": "wrangler deploy",
    "generate": "drizzle-kit generate:sqlite",
    "migrate:local": "wrangler d1 execute my-database --local --file=./migrations/0000_initial.sql",
    "migrate:remote": "wrangler d1 execute my-database --remote --file=./migrations/0000_initial.sql"
  }
}

エラー対処とヒント

1. D1バインディングエラー

問題: env.DB is undefined エラーが発生する

対処法:

  • wrangler.toml のバインディング名が正しいか確認
  • ローカル開発時は --local フラグを使用
  • 型定義が正しく設定されているか確認
// 正しい型定義
export interface Env {
  DB: D1Database;  // wrangler.toml の binding 名と一致
}

2. マイグレーションエラー

問題: マイグレーション適用時にエラーが発生する

対処法:

# ローカルD1の状態を確認
npx wrangler d1 execute my-database --local --command "SELECT name FROM sqlite_master WHERE type='table'"

# D1をリセット(開発時のみ)
npx wrangler d1 execute my-database --local --command "DROP TABLE IF EXISTS tasks"

# マイグレーションを再適用
npx wrangler d1 execute my-database --local --file=./migrations/0000_initial.sql

3. SQLite固有の制限

注意点:

  • SQLiteには SERIAL 型がない → INTEGER PRIMARY KEY AUTOINCREMENT を使用
  • BOOLEAN型は整数(0/1)として保存される
  • タイムスタンプはUNIXエポック秒で保存
  • JOINの最適化が限定的
// SQLiteでの真偽値の扱い
const completed = integer('completed', { mode: 'boolean' });  // 0 or 1

// SQLiteでのタイムスタンプ
const created_at = integer('created_at', { mode: 'timestamp' })
  .default(sql`(unixepoch())`);

4. D1の制限とベストプラクティス

Cloudflare D1の制限:

  • データベースサイズ: 2GB(Freeプラン)
  • 1日あたりのリクエスト数: 100,000回(Freeプラン)
  • クエリ実行時間: 30秒まで
  • トランザクションサイズ: 最大1MB

ベストプラクティス:

// 1. インデックスの活用
export const tasks = sqliteTable('tasks', {
  // ...
}, (table) => ({
  // 頻繁に検索するカラムにインデックスを作成
  completedIdx: index('completed_idx').on(table.completed),
  priorityIdx: index('priority_idx').on(table.priority)
}));

// 2. バッチ処理
async function batchInsert(db: ReturnType<typeof drizzle>, items: NewTask[]) {
  const batchSize = 100;
  for (let i = 0; i < items.length; i += batchSize) {
    const batch = items.slice(i, i + batchSize);
    await db.insert(tasks).values(batch);
  }
}

// 3. エラーハンドリング
try {
  await db.insert(tasks).values({ title: 'Test' });
} catch (error: any) {
  if (error.message?.includes('UNIQUE constraint')) {
    // ユニーク制約違反の処理
    console.error('Duplicate entry');
  } else {
    throw error;
  }
}

5. ローカル開発のヒント

# ローカルD1の永続化ディレクトリを確認
# .wrangler/state/v3/d1/miniflare-D1DatabaseObject/{database-id}.sqlite

# SQLiteブラウザでローカルDBを直接確認
# https://sqlitebrowser.org/

# Wranglerのログレベルを上げる
npx wrangler dev --log-level debug

6. 型安全性の確保

// Zodスキーマでリクエストバリデーション
import { z } from 'zod';

const taskSchema = z.object({
  title: z.string().min(1).max(200),
  description: z.string().max(1000).optional(),
  priority: z.number().int().min(0).max(10).optional(),
  completed: z.boolean().optional()
});

// Workerでの使用
const body = taskSchema.parse(await request.json());

まとめ

Drizzle ORMを用いてCloudflare D1に接続することで、型安全なデータ操作が可能です。Cloudflare D1は軽量なSQLiteをベースとしており、スケーラビリティとパフォーマンスに優れています。さらに、Drizzle ORMの型安全なクエリ構文によって、データベース操作の信頼性が向上します。サーバレス環境で効率的にデータを管理したい場合に、Drizzle ORMとCloudflare D1の組み合わせは非常に有用です。

主な特徴:

  • 型安全性: TypeScriptの完全な型推論により、開発時にエラーを検出
  • エッジコンピューティング: Cloudflareのグローバルネットワークで高速レスポンス
  • SQLiteベース: 軽量で高速なSQLite互換データベース
  • サーバレス: インフラ管理不要で自動スケーリング
  • 無料枠: 開発・小規模プロジェクトに十分な無料プランを提供
  • Wrangler統合: CLIツールによる簡単なデプロイとマイグレーション

開発のポイント:

  1. スキーマ定義はTypeScriptで型安全に記述
  2. Drizzle Kitでマイグレーションを自動生成
  3. ローカル開発は wrangler dev --local で実施
  4. 本番デプロイ前に必ずマイグレーションを適用
  5. D1の制限(サイズ、リクエスト数)を考慮した設計

参考文献

円