Cloudflare D1完全ガイド:エッジで動くSQLiteデータベース


Cloudflare D1完全ガイド:エッジで動くSQLiteデータベース

Cloudflare D1は、Cloudflareのグローバルネットワーク上で動作するサーバーレスSQLiteデータベースです。このガイドでは、セットアップから実践的な開発まで徹底解説します。

Cloudflare D1とは?

D1は、Cloudflareのエッジネットワーク上でSQLiteデータベースを実行できるサービスです。

主な特徴

  • エッジ配置: グローバルに分散されたデータベース
  • 低レイテンシ: ユーザーに最も近いロケーションで実行
  • SQLite互換: 標準的なSQLiteの構文をサポート
  • Workers統合: Cloudflare Workersからシームレスにアクセス
  • 無料枠: 月間100,000リード、100,000ライト無料

ユースケース

  • グローバルアプリ: 低レイテンシが必要なアプリケーション
  • JAMstack: 静的サイト + エッジAPI
  • IoT: センサーデータの収集・集計
  • 分析: リアルタイムアクセス解析
  • キャッシュ: 頻繁にアクセスするデータの保存

制限事項

  • データベースサイズ: 最大500MB(有料プランで拡張可能)
  • クエリ実行時間: 最大30秒
  • 同時接続: Workers経由のみ(直接接続不可)
  • トランザクション: サポート済み(2026年現在)

セットアップ

前提条件

# Node.js 16以上
node --version

# Wrangler CLI(Cloudflare Workers CLI)
npm install -g wrangler

# ログイン
wrangler login

プロジェクト作成

# Workers プロジェクト作成
npm create cloudflare@latest my-d1-app
cd my-d1-app

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

出力例:

✅ Successfully created DB 'my-database'

[[d1_databases]]
binding = "DB"
database_name = "my-database"
database_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"

wrangler.toml設定

name = "my-d1-app"
main = "src/index.ts"
compatibility_date = "2026-02-05"

# D1データベースのバインディング
[[d1_databases]]
binding = "DB"
database_name = "my-database"
database_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"

# 環境設定
[env.production]
[[env.production.d1_databases]]
binding = "DB"
database_name = "my-database-prod"
database_id = "yyyyyyyy-yyyy-yyyy-yyyy-yyyyyyyyyyyy"

データベース初期化

マイグレーションファイル作成

-- migrations/0001_init.sql
CREATE TABLE IF NOT EXISTS users (
  id INTEGER PRIMARY KEY AUTOINCREMENT,
  email TEXT NOT NULL UNIQUE,
  name TEXT NOT NULL,
  created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);

CREATE INDEX idx_users_email ON users(email);

CREATE TABLE IF NOT EXISTS posts (
  id INTEGER PRIMARY KEY AUTOINCREMENT,
  user_id INTEGER NOT NULL,
  title TEXT NOT NULL,
  content TEXT,
  published BOOLEAN DEFAULT 0,
  created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
  updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
  FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);

CREATE INDEX idx_posts_user_id ON posts(user_id);
CREATE INDEX idx_posts_published ON posts(published);

マイグレーション実行

# ローカル環境
wrangler d1 execute my-database --local --file=./migrations/0001_init.sql

# 本番環境
wrangler d1 execute my-database --file=./migrations/0001_init.sql

データの投入

# SQLファイルから
wrangler d1 execute my-database --local --file=./seed.sql

# コマンドラインから
wrangler d1 execute my-database --local --command="INSERT INTO users (email, name) VALUES ('user@example.com', 'Test User')"

Workers での基本的な使い方

TypeScript型定義

// src/types.ts
export interface Env {
  DB: D1Database;
}

export interface User {
  id: number;
  email: string;
  name: string;
  created_at: string;
}

export interface Post {
  id: number;
  user_id: number;
  title: string;
  content: string | null;
  published: boolean;
  created_at: string;
  updated_at: string;
}

基本的なCRUD操作

// src/index.ts
import { Env, User, Post } from './types';

export default {
  async fetch(request: Request, env: Env): Promise<Response> {
    const url = new URL(request.url);
    const path = url.pathname;

    // ルーティング
    if (path === '/users' && request.method === 'GET') {
      return getUsers(env);
    } else if (path === '/users' && request.method === 'POST') {
      return createUser(request, env);
    } else if (path.startsWith('/users/')) {
      const userId = path.split('/')[2];
      if (request.method === 'GET') {
        return getUser(userId, env);
      } else if (request.method === 'PUT') {
        return updateUser(userId, request, env);
      } else if (request.method === 'DELETE') {
        return deleteUser(userId, env);
      }
    }

    return new Response('Not Found', { status: 404 });
  },
};

// ユーザー一覧取得
async function getUsers(env: Env): Promise<Response> {
  const { results } = await env.DB.prepare(
    'SELECT * FROM users ORDER BY created_at DESC LIMIT 100'
  ).all<User>();

  return Response.json(results);
}

// ユーザー作成
async function createUser(request: Request, env: Env): Promise<Response> {
  const { email, name } = await request.json<{ email: string; name: string }>();

  if (!email || !name) {
    return Response.json({ error: 'Email and name are required' }, { status: 400 });
  }

  try {
    const result = await env.DB.prepare(
      'INSERT INTO users (email, name) VALUES (?, ?) RETURNING *'
    )
      .bind(email, name)
      .first<User>();

    return Response.json(result, { status: 201 });
  } catch (error) {
    return Response.json({ error: 'Email already exists' }, { status: 409 });
  }
}

// ユーザー取得
async function getUser(userId: string, env: Env): Promise<Response> {
  const user = await env.DB.prepare('SELECT * FROM users WHERE id = ?')
    .bind(userId)
    .first<User>();

  if (!user) {
    return Response.json({ error: 'User not found' }, { status: 404 });
  }

  return Response.json(user);
}

// ユーザー更新
async function updateUser(
  userId: string,
  request: Request,
  env: Env
): Promise<Response> {
  const { name } = await request.json<{ name: string }>();

  if (!name) {
    return Response.json({ error: 'Name is required' }, { status: 400 });
  }

  const result = await env.DB.prepare(
    'UPDATE users SET name = ? WHERE id = ? RETURNING *'
  )
    .bind(name, userId)
    .first<User>();

  if (!result) {
    return Response.json({ error: 'User not found' }, { status: 404 });
  }

  return Response.json(result);
}

// ユーザー削除
async function deleteUser(userId: string, env: Env): Promise<Response> {
  const result = await env.DB.prepare('DELETE FROM users WHERE id = ?')
    .bind(userId)
    .run();

  if (result.meta.changes === 0) {
    return Response.json({ error: 'User not found' }, { status: 404 });
  }

  return Response.json({ success: true });
}

高度なクエリパターン

バッチクエリ

// 複数のクエリを一度に実行
async function getUserWithPosts(userId: string, env: Env) {
  const [user, posts] = await env.DB.batch([
    env.DB.prepare('SELECT * FROM users WHERE id = ?').bind(userId),
    env.DB.prepare('SELECT * FROM posts WHERE user_id = ?').bind(userId),
  ]);

  return {
    user: user.results[0],
    posts: posts.results,
  };
}

トランザクション

// トランザクション(複数の操作を原子的に実行)
async function transferPost(
  postId: string,
  fromUserId: string,
  toUserId: string,
  env: Env
) {
  try {
    await env.DB.batch([
      env.DB.prepare('BEGIN TRANSACTION'),
      env.DB.prepare('UPDATE posts SET user_id = ? WHERE id = ? AND user_id = ?').bind(
        toUserId,
        postId,
        fromUserId
      ),
      env.DB.prepare('COMMIT'),
    ]);

    return { success: true };
  } catch (error) {
    await env.DB.prepare('ROLLBACK').run();
    throw error;
  }
}

JOINクエリ

// ユーザーと投稿をJOIN
async function getPostsWithAuthors(env: Env) {
  const { results } = await env.DB.prepare(`
    SELECT
      posts.*,
      users.name as author_name,
      users.email as author_email
    FROM posts
    JOIN users ON posts.user_id = users.id
    WHERE posts.published = 1
    ORDER BY posts.created_at DESC
    LIMIT 20
  `).all();

  return results;
}

集計クエリ

// ユーザーごとの投稿数
async function getUserPostCounts(env: Env) {
  const { results } = await env.DB.prepare(`
    SELECT
      users.id,
      users.name,
      COUNT(posts.id) as post_count
    FROM users
    LEFT JOIN posts ON users.id = posts.user_id
    GROUP BY users.id, users.name
    ORDER BY post_count DESC
  `).all();

  return results;
}

// 日別の投稿数
async function getDailyPostCounts(env: Env) {
  const { results } = await env.DB.prepare(`
    SELECT
      DATE(created_at) as date,
      COUNT(*) as count
    FROM posts
    WHERE created_at >= DATE('now', '-30 days')
    GROUP BY DATE(created_at)
    ORDER BY date DESC
  `).all();

  return results;
}

フルテキスト検索

// FTS5を使った全文検索
async function setupFullTextSearch(env: Env) {
  await env.DB.prepare(`
    CREATE VIRTUAL TABLE posts_fts USING fts5(title, content, content='posts', content_rowid='id');
  `).run();

  await env.DB.prepare(`
    CREATE TRIGGER posts_fts_insert AFTER INSERT ON posts BEGIN
      INSERT INTO posts_fts(rowid, title, content) VALUES (new.id, new.title, new.content);
    END;
  `).run();

  await env.DB.prepare(`
    CREATE TRIGGER posts_fts_update AFTER UPDATE ON posts BEGIN
      UPDATE posts_fts SET title = new.title, content = new.content WHERE rowid = new.id;
    END;
  `).run();

  await env.DB.prepare(`
    CREATE TRIGGER posts_fts_delete AFTER DELETE ON posts BEGIN
      DELETE FROM posts_fts WHERE rowid = old.id;
    END;
  `).run();
}

// 検索実行
async function searchPosts(query: string, env: Env) {
  const { results } = await env.DB.prepare(`
    SELECT posts.* FROM posts
    JOIN posts_fts ON posts.id = posts_fts.rowid
    WHERE posts_fts MATCH ?
    ORDER BY rank
  `)
    .bind(query)
    .all();

  return results;
}

Drizzle ORMとの統合

セットアップ

npm install drizzle-orm
npm install -D drizzle-kit

スキーマ定義

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

export const users = sqliteTable('users', {
  id: integer('id').primaryKey({ autoIncrement: true }),
  email: text('email').notNull().unique(),
  name: text('name').notNull(),
  createdAt: text('created_at').default(sql`CURRENT_TIMESTAMP`),
});

export const posts = sqliteTable('posts', {
  id: integer('id').primaryKey({ autoIncrement: true }),
  userId: integer('user_id')
    .notNull()
    .references(() => users.id, { onDelete: 'cascade' }),
  title: text('title').notNull(),
  content: text('content'),
  published: integer('published', { mode: 'boolean' }).default(false),
  createdAt: text('created_at').default(sql`CURRENT_TIMESTAMP`),
  updatedAt: text('updated_at').default(sql`CURRENT_TIMESTAMP`),
});

export type User = typeof users.$inferSelect;
export type NewUser = typeof users.$inferInsert;
export type Post = typeof posts.$inferSelect;
export type NewPost = typeof posts.$inferInsert;

Drizzleでのクエリ

// src/db.ts
import { drizzle } from 'drizzle-orm/d1';
import { eq, desc, and, like } from 'drizzle-orm';
import { users, posts, User, NewUser, Post, NewPost } from './schema';
import { Env } from './types';

export function getDb(env: Env) {
  return drizzle(env.DB);
}

// ユーザー操作
export async function createUser(env: Env, data: NewUser): Promise<User> {
  const db = getDb(env);
  const [user] = await db.insert(users).values(data).returning();
  return user;
}

export async function getUserById(env: Env, id: number): Promise<User | undefined> {
  const db = getDb(env);
  return db.select().from(users).where(eq(users.id, id)).get();
}

export async function getAllUsers(env: Env): Promise<User[]> {
  const db = getDb(env);
  return db.select().from(users).orderBy(desc(users.createdAt)).all();
}

export async function updateUser(
  env: Env,
  id: number,
  data: Partial<NewUser>
): Promise<User | undefined> {
  const db = getDb(env);
  const [updated] = await db.update(users).set(data).where(eq(users.id, id)).returning();
  return updated;
}

export async function deleteUser(env: Env, id: number): Promise<void> {
  const db = getDb(env);
  await db.delete(users).where(eq(users.id, id));
}

// 投稿操作
export async function createPost(env: Env, data: NewPost): Promise<Post> {
  const db = getDb(env);
  const [post] = await db.insert(posts).values(data).returning();
  return post;
}

export async function getPostsByUser(env: Env, userId: number): Promise<Post[]> {
  const db = getDb(env);
  return db
    .select()
    .from(posts)
    .where(eq(posts.userId, userId))
    .orderBy(desc(posts.createdAt))
    .all();
}

export async function getPublishedPosts(env: Env): Promise<Post[]> {
  const db = getDb(env);
  return db
    .select()
    .from(posts)
    .where(eq(posts.published, true))
    .orderBy(desc(posts.createdAt))
    .all();
}

export async function searchPosts(env: Env, query: string): Promise<Post[]> {
  const db = getDb(env);
  return db
    .select()
    .from(posts)
    .where(
      and(
        eq(posts.published, true),
        like(posts.title, `%${query}%`)
      )
    )
    .all();
}

// JOIN操作
export async function getPostsWithAuthors(env: Env) {
  const db = getDb(env);
  return db
    .select({
      post: posts,
      author: users,
    })
    .from(posts)
    .leftJoin(users, eq(posts.userId, users.id))
    .where(eq(posts.published, true))
    .all();
}

Workerでの使用

// src/index.ts
import { Env } from './types';
import * as db from './db';

export default {
  async fetch(request: Request, env: Env): Promise<Response> {
    const url = new URL(request.url);
    const path = url.pathname;

    try {
      // GET /users
      if (path === '/users' && request.method === 'GET') {
        const users = await db.getAllUsers(env);
        return Response.json(users);
      }

      // POST /users
      if (path === '/users' && request.method === 'POST') {
        const data = await request.json();
        const user = await db.createUser(env, data);
        return Response.json(user, { status: 201 });
      }

      // GET /posts
      if (path === '/posts' && request.method === 'GET') {
        const search = url.searchParams.get('search');
        const posts = search
          ? await db.searchPosts(env, search)
          : await db.getPublishedPosts(env);
        return Response.json(posts);
      }

      // GET /posts/with-authors
      if (path === '/posts/with-authors' && request.method === 'GET') {
        const posts = await db.getPostsWithAuthors(env);
        return Response.json(posts);
      }

      return new Response('Not Found', { status: 404 });
    } catch (error) {
      console.error(error);
      return Response.json({ error: 'Internal Server Error' }, { status: 500 });
    }
  },
};

パフォーマンス最適化

インデックスの活用

-- よく検索するカラムにインデックス
CREATE INDEX idx_posts_user_published ON posts(user_id, published);

-- 複合インデックス
CREATE INDEX idx_posts_search ON posts(published, created_at DESC);

-- ユニークインデックス
CREATE UNIQUE INDEX idx_users_email ON users(email);

クエリの最適化

// 悪い例: N+1クエリ
async function getPostsWithAuthorsBad(env: Env) {
  const posts = await env.DB.prepare('SELECT * FROM posts').all();

  for (const post of posts.results) {
    const author = await env.DB.prepare('SELECT * FROM users WHERE id = ?')
      .bind(post.user_id)
      .first();
    post.author = author;
  }

  return posts.results;
}

// 良い例: JOINを使う
async function getPostsWithAuthorsGood(env: Env) {
  const { results } = await env.DB.prepare(`
    SELECT
      posts.*,
      json_object('id', users.id, 'name', users.name, 'email', users.email) as author
    FROM posts
    JOIN users ON posts.user_id = users.id
  `).all();

  return results.map(row => ({
    ...row,
    author: JSON.parse(row.author),
  }));
}

ページネーション

// OFFSET/LIMIT方式
async function getPostsPaginated(env: Env, page: number, pageSize: number) {
  const offset = (page - 1) * pageSize;

  const { results } = await env.DB.prepare(`
    SELECT * FROM posts
    ORDER BY created_at DESC
    LIMIT ? OFFSET ?
  `)
    .bind(pageSize, offset)
    .all();

  return results;
}

// カーソルベースページネーション(より効率的)
async function getPostsCursor(env: Env, cursor: number | null, pageSize: number) {
  const query = cursor
    ? 'SELECT * FROM posts WHERE id < ? ORDER BY id DESC LIMIT ?'
    : 'SELECT * FROM posts ORDER BY id DESC LIMIT ?';

  const params = cursor ? [cursor, pageSize] : [pageSize];

  const { results } = await env.DB.prepare(query).bind(...params).all();

  return {
    data: results,
    nextCursor: results.length > 0 ? results[results.length - 1].id : null,
  };
}

デプロイと運用

ローカル開発

# ローカルで実行(ローカルD1を使用)
wrangler dev --local

# リモートD1を使用
wrangler dev --remote

デプロイ

# 本番デプロイ
wrangler deploy

# 環境指定
wrangler deploy --env production

モニタリング

// ログの追加
export default {
  async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
    const start = Date.now();

    try {
      const response = await handleRequest(request, env);

      const duration = Date.now() - start;
      console.log(`Request completed in ${duration}ms`);

      return response;
    } catch (error) {
      console.error('Request failed:', error);
      throw error;
    }
  },
};

まとめ

Cloudflare D1は、エッジコンピューティングの世界にSQLiteの力をもたらします。

主な利点

  • グローバル低レイテンシ: ユーザーに近い場所でデータにアクセス
  • シンプル: SQLiteの使いやすさとWorkersの統合
  • スケーラブル: Cloudflareのグローバルネットワークで自動スケール
  • コスト効率: 無料枠で小規模アプリは十分

適したユースケース

  • JAMstackアプリケーション
  • エッジAPI
  • グローバルSaaS
  • リアルタイム分析

D1を活用して、次世代のエッジアプリケーションを構築しましょう。