Documentation Drizzle

概要

この記事では、Drizzle ORMのHTTP Proxyドライバを使用し、エッジランタイムやサーバレス環境からHTTP経由でデータベースに接続する方法を詳しく解説します。HTTP Proxyドライバは、直接的なTCP接続ができない環境(Cloudflare Workers、Vercel Edge Functions、Next.js Edge Runtimeなど)で、HTTPエンドポイント経由でデータベースクエリを実行できる仕組みです。

Drizzle HTTP Proxyとは

HTTP Proxyドライバの概要

Drizzle ORMのHTTP Proxyドライバは、以下のような環境で有用です:

  • エッジランタイム: Cloudflare Workers、Vercel Edge Functionsなど、TCPソケット接続が制限されている環境
  • サーバレス環境: コールドスタート時のコネクション確立時間を削減したい場合
  • セキュリティ: データベース接続をHTTPサーバー経由で一元管理し、クライアントから直接データベースに接続しない構成

HTTP Proxyの仕組み

┌─────────────┐      HTTP      ┌──────────────┐      TCP       ┌──────────┐
│ Edge        │ ────────────> │ Proxy Server │ ─────────────> │ Database │
│ Function    │    (Query)     │ (Node.js)    │   (Direct)     │          │
└─────────────┘                └──────────────┘                └──────────┘
  1. クライアント(Edge Function)がHTTP経由でクエリをProxyサーバーに送信
  2. Proxyサーバーがデータベースに直接接続してクエリを実行
  3. 結果をHTTPレスポンスとしてクライアントに返す

Drizzle ORMとは

Drizzle ORMは、JavaScriptやTypeScriptプロジェクト向けの軽量で型安全なORMです。HTTP Proxyドライバを使用することで、エッジ環境でも完全なDrizzle ORMの機能を利用できます。

HTTP Proxyを用いたデータベース接続手順

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

Proxyサーバーとクライアント用のパッケージをインストールします。

# Proxyサーバー用(Node.js環境)
npm install drizzle-orm pg express

# クライアント用(Edge環境)
npm install drizzle-orm

ステップ1: Proxyサーバーの構築

まず、データベースに直接接続できるNode.js環境でProxyサーバーを構築します。

// server/proxy-server.ts
import express from 'express';
import { drizzle } from 'drizzle-orm/node-postgres';
import { Pool } from 'pg';
import { migrate } from 'drizzle-orm/node-postgres/migrator';

const app = express();
app.use(express.json());

// PostgreSQL接続プールの作成
const pool = new Pool({
  connectionString: process.env.DATABASE_URL
});

const db = drizzle(pool);

// Proxyエンドポイント
app.post('/query', async (req, res) => {
  try {
    const { sql, params, method } = req.body;

    // 認証チェック(本番環境では必須)
    const authHeader = req.headers.authorization;
    if (!authHeader || authHeader !== `Bearer ${process.env.PROXY_SECRET}`) {
      return res.status(401).json({ error: 'Unauthorized' });
    }

    // SQLクエリを実行
    let result;
    if (method === 'all') {
      result = await pool.query(sql, params);
      res.json({ rows: result.rows });
    } else if (method === 'execute') {
      result = await pool.query(sql, params);
      res.json({ rows: result.rows, rowCount: result.rowCount });
    } else {
      res.status(400).json({ error: 'Invalid method' });
    }
  } catch (error: any) {
    console.error('Query error:', error);
    res.status(500).json({ error: error.message });
  }
});

// ヘルスチェックエンドポイント
app.get('/health', (req, res) => {
  res.json({ status: 'ok' });
});

const PORT = process.env.PORT || 3000;
app.listen(PORT, () => {
  console.log(`Drizzle Proxy Server running on port ${PORT}`);
});

環境変数の設定(.env):

DATABASE_URL=postgresql://user:password@localhost:5432/mydb
PROXY_SECRET=your-secret-key-here
PORT=3000

ステップ2: クライアント側の設定

Proxyサーバーに接続するクライアント(Edge Function)を実装します。

// client/db.ts(Edge Function内)
import { drizzle } from 'drizzle-orm/http-proxy';

// HTTP Proxy クライアントの作成
export const db = drizzle(async (sql, params, method) => {
  const response = await fetch('https://your-proxy-server.com/query', {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      'Authorization': `Bearer ${process.env.PROXY_SECRET}`
    },
    body: JSON.stringify({ sql, params, method })
  });

  if (!response.ok) {
    throw new Error(`Proxy request failed: ${response.statusText}`);
  }

  const result = await response.json();
  return { rows: result.rows };
});

ステップ3: スキーマ定義

共通のスキーマファイルを作成します(サーバーとクライアントで共有)。

// shared/schema.ts
import { pgTable, serial, varchar, text, timestamp, boolean } from 'drizzle-orm/pg-core';

export const users = pgTable('users', {
  id: serial('id').primaryKey(),
  name: varchar('name', { length: 100 }).notNull(),
  email: varchar('email', { length: 255 }).notNull().unique(),
  bio: text('bio'),
  is_active: boolean('is_active').default(true).notNull(),
  created_at: timestamp('created_at', { mode: 'date' }).defaultNow().notNull(),
  updated_at: timestamp('updated_at', { mode: 'date' }).defaultNow().$onUpdate(() => new Date())
});

export const posts = pgTable('posts', {
  id: serial('id').primaryKey(),
  user_id: serial('user_id').notNull().references(() => users.id),
  title: varchar('title', { length: 200 }).notNull(),
  content: text('content'),
  published: boolean('published').default(false),
  created_at: timestamp('created_at', { mode: 'date' }).defaultNow().notNull()
});

// 型定義
export type User = typeof users.$inferSelect;
export type NewUser = typeof users.$inferInsert;
export type Post = typeof posts.$inferSelect;
export type NewPost = typeof posts.$inferInsert;

基本的なデータ操作 - HTTP Proxy経由のCRUD

Edge Function内でHTTP Proxy経由でデータ操作を行う例を紹介します。

データの挿入

import { db } from './db';
import { users, type NewUser } from './schema';

// 単一ユーザーの挿入
async function createUser(data: NewUser) {
  const result = await db
    .insert(users)
    .values(data)
    .returning();
  return result[0];
}

// 使用例
const newUser = await createUser({
  name: '山田太郎',
  email: 'yamada@example.com',
  bio: 'フルスタックエンジニア'
});

// 複数ユーザーの一括挿入
async function createMultipleUsers(userList: NewUser[]) {
  return await db
    .insert(users)
    .values(userList)
    .returning();
}

データの取得

import { eq, like, and, desc } from 'drizzle-orm';

// 全ユーザー取得
async function getAllUsers() {
  return await db.select().from(users);
}

// IDで取得
async function getUserById(id: number) {
  const result = await db
    .select()
    .from(users)
    .where(eq(users.id, id))
    .limit(1);
  return result[0];
}

// メールアドレスで検索
async function getUserByEmail(email: string) {
  const result = await db
    .select()
    .from(users)
    .where(eq(users.email, email))
    .limit(1);
  return result[0];
}

// 名前で部分一致検索
async function searchUsers(nameQuery: string) {
  return await db
    .select()
    .from(users)
    .where(like(users.name, `%${nameQuery}%`));
}

// アクティブユーザーを取得(ソート付き)
async function getActiveUsers() {
  return await db
    .select()
    .from(users)
    .where(eq(users.is_active, true))
    .orderBy(desc(users.created_at));
}

// JOIN を使った取得(ユーザーと投稿)
import { posts } from './schema';

async function getUsersWithPosts() {
  return await db
    .select({
      user: users,
      post: posts
    })
    .from(users)
    .leftJoin(posts, eq(users.id, posts.user_id));
}

データの更新

// ユーザー情報の更新
async function updateUser(id: number, data: Partial<NewUser>) {
  const result = await db
    .update(users)
    .set({
      ...data,
      updated_at: new Date()
    })
    .where(eq(users.id, id))
    .returning();
  return result[0];
}

// 使用例
await updateUser(1, {
  name: '山田花子',
  bio: '更新された自己紹介'
});

// メールアドレスのみ更新
async function updateUserEmail(id: number, newEmail: string) {
  return await db
    .update(users)
    .set({ email: newEmail, updated_at: new Date() })
    .where(eq(users.id, id))
    .returning();
}

// 複数条件での一括更新
async function deactivateInactiveUsers(days: number) {
  const cutoffDate = new Date();
  cutoffDate.setDate(cutoffDate.getDate() - days);

  return await db
    .update(users)
    .set({ is_active: false })
    .where(
      and(
        eq(users.is_active, true),
        sql`${users.updated_at} < ${cutoffDate}`
      )
    );
}

データの削除

// ユーザー削除
async function deleteUser(id: number) {
  const result = await db
    .delete(users)
    .where(eq(users.id, id))
    .returning();
  return result[0];
}

// 条件付き削除
async function deleteInactiveUsers() {
  return await db
    .delete(users)
    .where(eq(users.is_active, false))
    .returning();
}

// カスケード削除(関連投稿も削除)
async function deleteUserWithPosts(userId: number) {
  // トランザクションで実行
  await db.transaction(async (tx) => {
    // まず投稿を削除
    await tx.delete(posts).where(eq(posts.user_id, userId));
    // 次にユーザーを削除
    await tx.delete(users).where(eq(users.id, userId));
  });
}

各プラットフォームでの実装例

Cloudflare Workers

// cloudflare-worker.ts
import { db } from './db';
import { users } from './schema';
import { eq } from 'drizzle-orm';

export interface Env {
  PROXY_URL: string;
  PROXY_SECRET: string;
}

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

    // GET /users
    if (url.pathname === '/users' && request.method === 'GET') {
      const allUsers = await db.select().from(users);
      return new Response(JSON.stringify(allUsers), {
        headers: { 'Content-Type': 'application/json' }
      });
    }

    // GET /users/:id
    const userMatch = url.pathname.match(/^\/users\/(\d+)$/);
    if (userMatch && request.method === 'GET') {
      const id = parseInt(userMatch[1]);
      const user = await db
        .select()
        .from(users)
        .where(eq(users.id, id))
        .limit(1);

      if (user.length === 0) {
        return new Response('Not found', { status: 404 });
      }

      return new Response(JSON.stringify(user[0]), {
        headers: { 'Content-Type': 'application/json' }
      });
    }

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

Vercel Edge Functions

// pages/api/users.ts
import { db } from '../../lib/db';
import { users } from '../../lib/schema';
import type { NextRequest } from 'next/server';

export const config = {
  runtime: 'edge'
};

export default async function handler(req: NextRequest) {
  try {
    const allUsers = await db.select().from(users);

    return new Response(JSON.stringify({
      success: true,
      data: allUsers
    }), {
      headers: { 'Content-Type': 'application/json' }
    });
  } catch (error: any) {
    return new Response(JSON.stringify({
      success: false,
      error: error.message
    }), {
      status: 500,
      headers: { 'Content-Type': 'application/json' }
    });
  }
}

セキュリティのベストプラクティス

1. 認証トークンの管理

// server/proxy-server.ts
import crypto from 'crypto';

// トークンのハッシュ化
const VALID_TOKENS = new Set([
  crypto.createHash('sha256').update(process.env.PROXY_SECRET!).digest('hex')
]);

app.post('/query', async (req, res) => {
  const authHeader = req.headers.authorization;

  if (!authHeader?.startsWith('Bearer ')) {
    return res.status(401).json({ error: 'Missing authorization' });
  }

  const token = authHeader.slice(7);
  const hashedToken = crypto.createHash('sha256').update(token).digest('hex');

  if (!VALID_TOKENS.has(hashedToken)) {
    return res.status(401).json({ error: 'Invalid token' });
  }

  // クエリ実行...
});

2. レートリミッティング

import rateLimit from 'express-rate-limit';

const limiter = rateLimit({
  windowMs: 15 * 60 * 1000, // 15分
  max: 100, // 100リクエストまで
  message: 'Too many requests from this IP'
});

app.post('/query', limiter, async (req, res) => {
  // クエリ実行...
});

3. クエリのサニタイズとバリデーション

app.post('/query', async (req, res) => {
  const { sql, params, method } = req.body;

  // 危険なSQL操作を防ぐ
  const dangerousPatterns = [
    /DROP\s+TABLE/i,
    /DELETE\s+FROM.*WHERE\s+1\s*=\s*1/i,
    /TRUNCATE/i
  ];

  if (dangerousPatterns.some(pattern => pattern.test(sql))) {
    return res.status(403).json({ error: 'Forbidden operation' });
  }

  // クエリ実行...
});

パフォーマンス最適化

コネクションプーリング

// Proxyサーバー側でコネクションプールを設定
import { Pool } from 'pg';

const pool = new Pool({
  connectionString: process.env.DATABASE_URL,
  max: 20,                  // 最大接続数
  idleTimeoutMillis: 30000, // アイドルタイムアウト
  connectionTimeoutMillis: 2000 // 接続タイムアウト
});

リクエストのキャッシング

import NodeCache from 'node-cache';

const cache = new NodeCache({ stdTTL: 60 }); // 60秒キャッシュ

app.post('/query', async (req, res) => {
  const { sql, params } = req.body;
  const cacheKey = `${sql}:${JSON.stringify(params)}`;

  // キャッシュチェック
  const cached = cache.get(cacheKey);
  if (cached) {
    return res.json(cached);
  }

  // クエリ実行
  const result = await pool.query(sql, params);
  const response = { rows: result.rows };

  // キャッシュに保存
  cache.set(cacheKey, response);

  res.json(response);
});

エラー対処と注意点

1. Proxy接続エラー

問題: fetch failed または Connection refused エラー

対処法:

// リトライ機能付きのfetchラッパー
async function fetchWithRetry(url: string, options: RequestInit, retries = 3) {
  for (let i = 0; i < retries; i++) {
    try {
      const response = await fetch(url, options);
      if (!response.ok) {
        throw new Error(`HTTP ${response.status}: ${response.statusText}`);
      }
      return response;
    } catch (error) {
      if (i === retries - 1) throw error;
      await new Promise(resolve => setTimeout(resolve, 1000 * (i + 1)));
    }
  }
}

// 使用例
export const db = drizzle(async (sql, params, method) => {
  const response = await fetchWithRetry('https://your-proxy.com/query', {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      'Authorization': `Bearer ${process.env.PROXY_SECRET}`
    },
    body: JSON.stringify({ sql, params, method })
  });

  return await response.json();
});

2. タイムアウトエラー

問題: クエリ実行時間が長すぎてタイムアウトする

対処法:

// Proxyサーバー側でタイムアウト設定
app.post('/query', async (req, res) => {
  const timeoutMs = 10000; // 10秒

  const timeoutPromise = new Promise((_, reject) => {
    setTimeout(() => reject(new Error('Query timeout')), timeoutMs);
  });

  try {
    const result = await Promise.race([
      pool.query(sql, params),
      timeoutPromise
    ]);

    res.json({ rows: result.rows });
  } catch (error: any) {
    if (error.message === 'Query timeout') {
      res.status(408).json({ error: 'Request timeout' });
    } else {
      res.status(500).json({ error: error.message });
    }
  }
});

3. CORS エラー

問題: ブラウザからProxyにアクセスするとCORSエラーが発生

対処法:

import cors from 'cors';

// 開発環境
app.use(cors());

// 本番環境(特定のオリジンのみ許可)
app.use(cors({
  origin: ['https://your-app.com', 'https://your-app.vercel.app'],
  credentials: true
}));

4. メモリリークとコネクション管理

問題: 長時間稼働後にメモリ使用量が増加する

対処法:

// コネクションプールのイベントハンドリング
pool.on('error', (err, client) => {
  console.error('Unexpected error on idle client', err);
});

pool.on('connect', (client) => {
  console.log('New client connected');
});

// Graceful shutdown
process.on('SIGTERM', async () => {
  console.log('SIGTERM received, closing pool...');
  await pool.end();
  process.exit(0);
});

5. HTTP Proxyの制限事項

注意点:

  • レイテンシ: TCP直接接続より遅い(HTTP オーバーヘッド)
  • トランザクション: 複雑なトランザクションは通常の接続を推奨
  • ストリーミング: 大量データのストリーミングには不向き
  • コスト: Proxyサーバーの稼働コストが追加で発生

推奨事項:

  • 読み取り専用クエリに使用
  • 短いクエリに最適
  • 書き込みはバッチ処理を検討
  • 本当にHTTP Proxyが必要か検討(直接接続が可能なら直接接続を使用)

6. デプロイのベストプラクティス

// 環境変数の検証
if (!process.env.DATABASE_URL) {
  throw new Error('DATABASE_URL is not set');
}

if (!process.env.PROXY_SECRET) {
  throw new Error('PROXY_SECRET is not set');
}

// ヘルスチェックの実装
app.get('/health', async (req, res) => {
  try {
    await pool.query('SELECT 1');
    res.json({
      status: 'healthy',
      timestamp: new Date().toISOString()
    });
  } catch (error) {
    res.status(503).json({
      status: 'unhealthy',
      error: error.message
    });
  }
});

// メトリクスの収集
let queryCount = 0;
let errorCount = 0;

app.post('/query', async (req, res) => {
  queryCount++;
  try {
    // クエリ実行...
  } catch (error) {
    errorCount++;
    throw error;
  }
});

app.get('/metrics', (req, res) => {
  res.json({
    queryCount,
    errorCount,
    successRate: ((queryCount - errorCount) / queryCount * 100).toFixed(2)
  });
});

まとめ

Drizzle ORMのHTTP Proxyドライバを使用することで、エッジランタイムやサーバレス環境からHTTP経由でデータベースにアクセスできるようになります。TCP接続が制限されている環境でも、完全なDrizzle ORMの機能を活用できる点が大きな利点です。

主な特徴:

  • エッジ対応: Cloudflare Workers、Vercel Edge Functionsなどで利用可能
  • 型安全性: 通常のDrizzle ORMと同じ型安全なクエリが使用可能
  • セキュリティ: Proxyサーバーで一元的にアクセス制御と認証を実装
  • 柔軟性: 既存のデータベースをそのまま使用可能
  • パフォーマンス: コネクションプーリングとキャッシングで最適化

使用を検討すべきケース:

  • エッジランタイムでデータベースアクセスが必要な場合
  • TCP接続が制限されている環境
  • データベース接続を一元管理したい場合

代替案を検討すべきケース:

  • TCP直接接続が可能な環境(より低レイテンシ)
  • 複雑なトランザクションが必要な場合
  • 大量データのストリーミングが必要な場合

HTTP Proxyドライバは、エッジコンピューティング時代のデータベースアクセスパターンとして、適切な場面で使用することで大きな価値を発揮します。

参考文献

円