データベース統合 — Drizzle / Prisma 戦略

SvelteKit でデータベースを扱うには 「どの DB を使うか」「どの ORM を使うか」 の 2 軸で決まります。本ページではこの 2 軸の比較と、Drizzle セットアップ、Remote Functions 連携、マイグレーション運用までを通しで解説します。

隣接ページとの役割分担

「SQL インジェクション対策」は セキュリティ対策 に集約しています。「サーバー専用モジュールの配置」は $lib/server を参照。本ページは DB レイヤの設計判断 に絞ります。

DB 選択フロー

SQLite はファイルベースで運用が極めて楽。Turso や LiteFS のようなレプリケーション基盤と組み合わせれば中規模まで耐えます。PostgreSQL は機能・拡張性のバランスが取れた万能選手。MySQL は歴史的選択肢で、PlanetScale のような Vitess ベースのスケール基盤が選べる。

DB 比較

DB特徴強み弱み適したホスティング
SQLiteファイルベース、組み込み運用シンプル、高速、ゼロ設定単一書き込み、ネットワーク非対応adapter-node + Turso/LiteFS、Bun
PostgreSQLリレーショナル、機能豊富JSON/GIS/全文検索、拡張機能コネクション数の制約Neon / Supabase / Vercel Postgres / RDS
MySQLリレーショナル、歴史長い普及度、ツール豊富JSON 関連の表現力が PG より低いPlanetScale / RDS / Aurora
D1Cloudflare の SQLite ベースWorkers との一体運用、低レイテンシプレビュー段階の機能ありCloudflare Workers/Pages
MongoDBドキュメント DB柔軟なスキーマ、地理分散リレーション弱い、トランザクション制約MongoDB Atlas

ORM 比較 — Drizzle vs Prisma

観点DrizzlePrisma
スタイルSQL に近い query builderスキーマファイル + 高レベル API
TypeScript 型クエリから自動推論(schema が source of truth)prisma generate で生成
バンドルサイズ軽量(純粋 TS)重い(Rust エンジン同梱)
Edge 対応✅ Workers/Edge で動く⚠️ Driver Adapters 経由(追加設定必要)
マイグレーションdrizzle-kit で SQL ベースprisma migrate で宣言的
学習コストSQL 知識があれば即理解スキーマ DSL を覚える
本ガイドの推奨度⭐⭐⭐⭐⭐ 新規実装、Edge 環境⭐⭐⭐⭐ チームで共有しやすいスキーマが必要なとき
なぜ Drizzle 推奨か

Drizzle は schema.ts がそのまま TypeScript 型 になります。クエリも db.select().from(users).where(eq(users.id, 1)) のように TS で書け、別ファイル(Prisma の schema.prisma)を見に行く必要がない。Edge 環境(Cloudflare Workers)でも素直に動きます。SvelteKit と特に相性が良い ORM です。

Drizzle セットアップ — PostgreSQL の例

導入

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

スキーマ定義

// src/lib/server/db/schema.ts
import { pgTable, serial, text, timestamp, integer, uuid } from 'drizzle-orm/pg-core';
import { relations } from 'drizzle-orm';

export const users = pgTable('users', {
  id: uuid('id').primaryKey().defaultRandom(),
  email: text('email').notNull().unique(),
  name: text('name').notNull(),
  passwordHash: text('password_hash').notNull(),
  createdAt: timestamp('created_at').notNull().defaultNow()
});

export const posts = pgTable('posts', {
  id: serial('id').primaryKey(),
  title: text('title').notNull(),
  body: text('body').notNull(),
  authorId: uuid('author_id').notNull().references(() => users.id),
  createdAt: timestamp('created_at').notNull().defaultNow()
});

// リレーション定義(クエリ時の include 用)
export const usersRelations = relations(users, ({ many }) => ({
  posts: many(posts)
}));

export const postsRelations = relations(posts, ({ one }) => ({
  author: one(users, { fields: [posts.authorId], references: [users.id] })
}));

// TypeScript 型は自動推論
export type User = typeof users.$inferSelect;
export type NewUser = typeof users.$inferInsert;
export type Post = typeof posts.$inferSelect;

クライアント

// src/lib/server/db/index.ts
import { drizzle } from 'drizzle-orm/postgres-js';
import postgres from 'postgres';
import { DATABASE_URL } from '$env/static/private';
import * as schema from './schema';

const queryClient = postgres(DATABASE_URL, {
  max: 10,                  // コネクションプール最大
  idle_timeout: 20,
  connect_timeout: 10
});

export const db = drizzle(queryClient, { schema });
export type DB = typeof db;
`$lib/server` の配下に置く

DB 接続を含むモジュールは $lib/server/ 配下(または *.server.ts)に置いてください。SvelteKit はこの規約のファイルがクライアント側にバンドルされるとビルドエラーで止めてくれます。DATABASE_URL 等のシークレット漏洩を防ぐ重要な機構です。

クエリパターン

取得(select)

import { db } from '$lib/server/db';
import { users, posts } from '$lib/server/db/schema';
import { eq, desc } from 'drizzle-orm';

// 単一取得
const user = await db.select().from(users).where(eq(users.id, userId)).limit(1);

// JOIN 込み(relations 経由)
const userWithPosts = await db.query.users.findFirst({
  where: eq(users.id, userId),
  with: { posts: { orderBy: desc(posts.createdAt), limit: 10 } }
});

挿入

const [newUser] = await db.insert(users).values({
  email: 'alice@example.com',
  name: 'Alice',
  passwordHash: await hashPassword('...')
}).returning();

更新

await db.update(users).set({ name: 'Alice 2' }).where(eq(users.id, userId));

削除

await db.delete(posts).where(eq(posts.id, postId));

トランザクション

import { db } from '$lib/server/db';

await db.transaction(async (tx) => {
  const [order] = await tx.insert(orders).values({ userId }).returning();

  for (const item of items) {
    await tx.insert(orderItems).values({ orderId: order.id, ...item });
  }

  await tx.update(users).set({ totalOrders: sql`${users.totalOrders} + 1` })
    .where(eq(users.id, userId));
});

トランザクション内で throw するとロールバックされます。

Remote Functions との連携

Drizzle と Remote Functions(2.27+)は相性抜群です。スキーマから型が流れ、Remote Functions のスキーマ検証もそのままパス。

// src/routes/posts.remote.ts
import { query, command } from '$app/server';
import { z } from 'zod';
import { db } from '$lib/server/db';
import { posts } from '$lib/server/db/schema';
import { eq, desc } from 'drizzle-orm';
import { getRequestEvent } from '$app/server';

export const listPosts = query(
  z.object({ limit: z.number().min(1).max(100).default(10) }),
  async ({ limit }) => {
    return db.query.posts.findMany({
      with: { author: { columns: { name: true, email: true } } },
      orderBy: desc(posts.createdAt),
      limit
    });
  }
);

export const createPost = command(
  z.object({
    title: z.string().min(1).max(200),
    body: z.string().min(1)
  }),
  async (input) => {
    const { locals } = getRequestEvent();
    if (!locals.user) throw new Error('Unauthorized');

    const [post] = await db.insert(posts).values({
      ...input,
      authorId: locals.user.id
    }).returning();

    await listPosts({ limit: 10 }).refresh();
    return post;
  }
);

export const deletePost = command(z.object({ id: z.number() }), async ({ id }) => {
  const { locals } = getRequestEvent();
  if (!locals.user) throw new Error('Unauthorized');

  const [post] = await db.select().from(posts).where(eq(posts.id, id)).limit(1);
  if (post.authorId !== locals.user.id) throw new Error('Forbidden');

  await db.delete(posts).where(eq(posts.id, id));
  await listPosts({ limit: 10 }).refresh();
});

クライアントは型安全に呼べます:

<script lang="ts">
  import { listPosts, createPost, deletePost } from './posts.remote';

  const posts = listPosts({ limit: 10 });
</script>

{#if posts.current}
  {#each posts.current as post (post.id)}
    <article>
      <h2>{post.title}</h2>
      <p>By {post.author.name}</p>
      <button onclick={() => deletePost({ id: post.id })}>削除</button>
    </article>
  {/each}
{/if}

マイグレーション管理

drizzle.config.ts

import type { Config } from 'drizzle-kit';

export default {
  schema: './src/lib/server/db/schema.ts',
  out: './drizzle',
  dialect: 'postgresql',
  dbCredentials: { url: process.env.DATABASE_URL! }
} satisfies Config;

マイグレーション生成と適用

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

# 開発用に DB へ直接 push(マイグレーションファイル経由しない)
npx drizzle-kit push

# 本番ではマイグレーションを適用
npx drizzle-kit migrate

CI でのマイグレーション適用:

- name: Run migrations
  run: npx drizzle-kit migrate
  env:
    DATABASE_URL: ${{ secrets.DATABASE_URL }}
本番への push は厳禁

drizzle-kit pushマイグレーションファイルを作らずに直接 DB スキーマを変更 します。開発環境ではプロトタイピングに便利ですが、本番では必ず generate → レビュー → migrate のフローを通してください。

コネクションプーリング

SvelteKit を サーバーレス環境(Vercel、Cloudflare、AWS Lambda)で動かす場合、リクエストごとに DB 接続を作ると 接続枯渇 に陥ります。

対策 1: 外部プーラー(PgBouncer / Supabase Pooler)

// .env
DATABASE_URL=postgres://user:pass@pooler.example.com:6543/db?pgbouncer=true

PgBouncer(または Neon/Supabase の組み込みプーラー)を経由することで、サーバーレス関数の起動回数に左右されずに DB 側の接続数を抑えられます。

対策 2: HTTP ベースの DB ドライバ

  • Neon: @neondatabase/serverless
  • Supabase: @supabase/supabase-js
  • PlanetScale: @planetscale/database
import { neon } from '@neondatabase/serverless';
import { drizzle } from 'drizzle-orm/neon-http';

const sql = neon(DATABASE_URL);
export const db = drizzle(sql);

HTTP ベースは TCP コネクションを張りっぱなしにしない ため、サーバーレスと相性最高。ただしストリーミング応答やトランザクションの長時間保持はできません。

N+1 問題の解決

「N 件のレコードに対して、それぞれ関連レコードを 1 件ずつ取りに行く」アンチパターン。

// ❌ N+1: posts.length 回の追加クエリが走る
const posts = await db.select().from(posts);
for (const post of posts) {
  post.author = await db.select().from(users).where(eq(users.id, post.authorId));
}

Drizzle の with で JOIN 解決:

// ✅ 1 クエリで解決
const posts = await db.query.posts.findMany({
  with: { author: true }
});

または IN でバッチ:

const posts = await db.select().from(posts);
const authorIds = posts.map((p) => p.authorId);
const authors = await db.select().from(users).where(inArray(users.id, authorIds));
const authorMap = new Map(authors.map((a) => [a.id, a]));
const enriched = posts.map((p) => ({ ...p, author: authorMap.get(p.authorId) }));

チェックリスト

  • DB 接続を $lib/server/ に置く(クライアント漏洩防止)
  • シークレットは $env/static/private または $env/dynamic/private
  • コネクションプール をプラットフォームに合わせて選択(HTTP ドライバ or 外部プーラー)
  • マイグレーションは generate → コミット → migrate のフロー
  • トランザクション で複数テーブル更新の整合性を確保
  • N+1 を with または inArray で解決
  • インデックスschema.ts で明示(unique, index
  • returning() で更新後の値を取得(追加クエリ回避)
  • Zod スキーマ を Drizzle スキーマから派生(drizzle-zod
  • DB は Remote Functions と組み合わせる(型が流れる)

関連ページ

次のステップ

  1. Remote Functions で型安全な RPC + DB のセット
  2. 認証・認可 で User テーブルと Better Auth を連携
  3. モニタリング で N+1 やスロークエリを検出