🗄️ Turso × SvelteKit 完全ソースコード徹底解説
Turso(libSQL/SQLite互換のエッジDB)をSvelteKitで使い倒すための決定版リファレンス。アカウント作成から CRUD、Drizzle ORM、+page.server.ts でのロード関数、form actions、Embedded Replica、Cloudflare Pages デプロイ、監査ログ・楽観ロックまで全て公式ドキュメント基軸で TypeScript ソースコードを徹底解説します。
🗃️ Section 1: Tursoとは何か
Turso は libSQL(SQLite のオープンフォーク)を基盤とするエッジ対応のクラウドデータベースサービスです。 ChiselStrike 社(現 Turso 社)が SQLite をサーバーサイド・分散環境で使えるように拡張した libSQL に、クラウドホスティング・レプリケーション・エッジ実行能力を重ねたのが Turso の本質です。
なぜ SQLite(libSQL)か
SQLite は「単一ファイルで完結する」「ゼロ運用(サーバープロセス不要)」「ACID トランザクション」という3つの特性を持つ唯一の組み込みデータベースです。 Postgres や MySQL のように常駐デーモンを立てる必要がなく、開発環境でもファイルを1つ置くだけで本番同等の SQL が動きます。 Turso はこの超軽量な特性を保ちながら、クラウド上での HTTP/WebSocket プロトコル越しアクセスと複数リージョンへのレプリケーションを実現しています。
競合サービス比較
| サービス | 種別 | プロトコル | エッジ性能 | 無料枠 | 特徴 |
|---|---|---|---|---|---|
| Turso | libSQL(SQLite 派生) | HTTP / WebSocket | ◎ エッジ優先設計 | 5 GB / 500 M 行読み | Embedded Replica・マルチDB・ブランチ |
| Cloudflare D1 | SQLite 互換 | Workers バインディング | ◎ Cloudflare 専用 | 5 GB / 25 B 行読み | Workers 外からは使いにくい |
| Neon | Postgres 互換 | PostgreSQL Wire | △ コールドスタートあり | 0.5 GB / 191 h コンピュート | ブランチ機能・Serverless Driver |
| PlanetScale | MySQL 互換(Vitess) | MySQL Wire / HTTP | ○ グローバルルーティング | 無料プラン廃止(2024) | スキーマブランチ・ゼロダウンタイム DDL |
| Supabase | Postgres | PostgreSQL Wire / REST | △ 専用リージョン固定 | 500 MB / 2 プロジェクト | Auth・Storage・Edge Functions 一体型 |
| Firebase RTDB | NoSQL(JSON ツリー) | WebSocket / REST | ◎ リアルタイム配信 | 1 GB / 10 GB/月 転送 | リアルタイム同期特化・SQL 不可 |
Turso 独自機能
- Embedded Replicas — アプリケーションプロセスと同じホスト上にローカル SQLite ファイルとして DB を複製し、読み取りをマイクロ秒単位で完結させる機能。 VM・VPS・モバイルアプリで特に有効。Reads are served from the local file; writes propagate to the remote primary(公式ドキュメント準拠)。
- Multi-DB-per-organization — 1 つの Turso 組織アカウントで複数(無料プランは 100 個、上位プランは無制限)の独立した DB を管理できる。マルチテナント SaaS に最適。
- DB Branching — 本番 DB をブランチとして分岐させ、スキーマ変更のテストや PR ごとのプレビュー環境を作成できる(Neon の branching に相当する機能)。
- Groups — 複数 DB を1つのグループにまとめ、同一ロケーション設定・共有 WAL ファイルで管理する機能。グループ単位でロケーションを追加すると全 DB にレプリカが展開される。
料金プラン(2025年5月時点・公式ページ準拠)
| プラン | 月額 | ストレージ | 行読み/月 | 行書き/月 | DB 数 |
|---|---|---|---|---|---|
| Free | $0 | 5 GB | 500 M | 10 M | 100 |
| Developer | $4.99 | 9 GB (+$0.75/GB) | 2.5 B | 25 M | 無制限 |
| Scaler | $24.92(年払い) | 24 GB (+$0.50/GB) | 100 B | 100 M | 無制限 |
| Pro | $416.58(年払い) | 50 GB (+$0.45/GB) | 250 B | 250 M | 無制限 |
出典: https://turso.tech/pricing(2025年5月時点)
⚙️ Section 2: アカウント作成と最初の DB
Turso CLI インストール
Turso CLI は Homebrew(macOS)または curl スクリプト(Linux / WSL)でインストールします。Windows ネイティブ(WSL なし)の場合は PowerShell スクリプトを使います。
macOS(Homebrew)
brew install tursodatabase/tap/turso Linux / WSL(curl スクリプト)
curl -sSfL https://get.tur.so/install.sh | bash Windows ネイティブ(PowerShell)
irm get.tur.so/install.ps1 | iex サインアップ / ログイン
初回はブラウザが開き、GitHub や Google アカウントで認証します。
# 新規登録(ブラウザが開く)
turso auth signup
# 既存アカウントでログイン
turso auth login
# ログイン状態確認
turso auth whoami データベース作成
--location フラグでリージョンを指定します。
指定しない場合は CLI の実行地点に最も近いリージョンが自動選択されます。
日本からアクセスする場合は nrt(東京)を明示するのがベストプラクティスです。
# 東京リージョンで DB を作成
turso db create my-svelte-db --location nrt
# DB 一覧を確認
turso db list
# 特定グループ内の DB だけ確認
turso db list --group my-group 主要リージョンコード
| コード | ロケーション | 地域 |
|---|---|---|
| nrt | 東京 | アジア太平洋 |
| hnd | 羽田(東京第2) | アジア太平洋 |
| sin | シンガポール | アジア太平洋 |
| syd | シドニー | オセアニア |
| iad | バージニア北部 | 北米東部 |
| sjc | サンノゼ | 北米西部 |
| lhr | ロンドン | 欧州 |
| fra | フランクフルト | 欧州 |
| gru | サンパウロ | 南米 |
| bom | ムンバイ | アジア南部 |
全ロケーション一覧: turso db locations コマンドで確認可能。
接続 URL と認証トークンの取得
SvelteKit の環境変数に設定する2つの値を取得します。
# DB の詳細情報(URL を含む)を表示
turso db show my-svelte-db
# URL だけを取り出す(環境変数にコピーしやすい形式)
turso db show my-svelte-db --url
# 認証トークン生成(デフォルトは無期限)
turso db tokens create my-svelte-db
# 有効期限付きトークン(例: 7日間)
turso db tokens create my-svelte-db --expiration 7d turso db show --url の出力(libsql://... 形式)を TURSO_DATABASE_URL に、 turso db tokens create の出力(JWT 文字列)を TURSO_AUTH_TOKEN に設定します。
これらは SEC4 で詳述する .env ファイルへ書きます。インタラクティブシェルで動作確認
CLI から直接 SQL を実行して DB の動作確認ができます。
# シェルに接続
turso db shell my-svelte-db
# シェル内で SQL を実行(例)
CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT NOT NULL);
INSERT INTO users VALUES (1, 'Alice');
SELECT * FROM users;
# シェルを終了
.quit🚀 Section 3: SvelteKit プロジェクト準備
プロジェクト作成
Bun または npm でプロジェクトを作成します。本ガイドでは Bun を推奨します(Windows でもほぼ同等に動作)。
Bun(推奨)
bun create svelte@latest my-app
cd my-app
bun install npm(代替)
npm create svelte@latest my-app
cd my-app
npm install 対話式セットアップで以下を選択します:
- Which Svelte app template? →
Skeleton project(最小構成) - Add type checking with TypeScript? →
Yes, using TypeScript syntax - Add ESLint? →
Yes - Add Prettier? →
Yes - Add Vitest? → プロジェクト規模に応じて任意
Turso クライアント・ORM のインストール
@libsql/client は Turso(libSQL)へ接続するための公式 TypeScript クライアントです。
Drizzle ORM を使う場合は追加で drizzle-orm と drizzle-kit をインストールします。
# Turso 公式クライアント(必須)
bun add @libsql/client
# Drizzle ORM(スキーマ型安全化・マイグレーション管理に推奨)
bun add drizzle-orm
bun add -D drizzle-kit 推奨フォルダ構成
DB 接続ロジックは必ず src/lib/server/ 配下に置きます。
SvelteKit は src/lib/server/ をサーバー専用ディレクトリとして扱い、
クライアントバンドルへの混入を ビルド時に静的に防止 します
(公式: server-only-modules)。
接続文字列・シークレットが意図せずブラウザに送信されるリスクをゼロにできます。
my-app/
├── src/
│ ├── lib/
│ │ └── server/ ← クライアントに絶対渡らない領域
│ │ ├── db.ts ← createClient() の初期化
│ │ └── schema.ts ← Drizzle テーブル定義
│ └── routes/
│ └── +page.server.ts ← load() / actions() からのみ db を参照
├── drizzle/ ← drizzle-kit が生成するマイグレーションファイル
├── drizzle.config.ts ← drizzle-kit の設定
├── .env ← シークレット(gitignore 必須)
└── svelte.config.js src/lib/db.ts(server サブディレクトリなし)や src/routes/db.ts に置いた場合、
SvelteKit のツリーシェイキングが効かず TURSO_AUTH_TOKEN がクライアントバンドルに混入することがあります。
必ず src/lib/server/ 直下に置いてください。svelte.config.js でエイリアス設定
長い相対パスを避けるため、kit.alias でショートカットを定義します。
以下の設定で import { db } from '$db' のように参照できます。
// svelte.config.js
import adapter from '@sveltejs/adapter-auto';
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
/** @type { import('@sveltejs/kit').Config } */
const config = {
preprocess: vitePreprocess(),
kit: {
adapter: adapter(),
alias: {
$db: 'src/lib/server/db.ts',
$schema: 'src/lib/server/schema.ts'
}
}
};
export default config; src/lib/server/ 配下のモジュールを +page.svelte や +layout.svelte からインポートしようとすると、
ビルド時にエラーを throw して人間の誤操作を防ぎます。
DB 初期化コードがここにある限り、クライアントへのシークレット漏洩は構造的に不可能です
(参照: svelte.dev/docs/kit/server-only-modules)。🔐 Section 4: 環境変数の設計
.env ファイルの設定
プロジェクトルートに .env ファイルを作成し、SEC2 で取得した2つの値を書き込みます。
このファイルは必ず .gitignore に含め、リポジトリにコミットしてはなりません。
# .env (git に絶対コミットしない)
TURSO_DATABASE_URL="libsql://my-svelte-db-yourname.turso.io"
TURSO_AUTH_TOKEN="eyJhbGciOiJFZERTQSIsInR5cCI6IkpXVCJ9..." # .gitignore に必ず含めること
.env
.env.local
.env.*.local git rm --cached .env でトラッキングを外し、Turso のダッシュボードで
旧トークンを即失効させ新トークンを発行します。GitHub では履歴に残るためリポジトリの完全な履歴書き換えが必要になります。SvelteKit の4種環境変数モジュール
SvelteKit は Node.js の process.env を直接使うのではなく、
型安全かつセキュリティ境界が明確な4種類のモジュールを提供しています
(参照: svelte.dev/docs/kit/$env-static-private)。
| モジュール | クライアント公開 | 値の決定タイミング | 主な用途 |
|---|---|---|---|
| $env/static/private | 不可 | ビルド時(定数化) | DB トークン・API シークレット(推奨) |
| $env/dynamic/private | 不可 | 実行時(動的参照) | Cloudflare Workers バインディング |
| $env/static/public | 可 | ビルド時(定数化) | PUBLIC_API_URL・PUBLIC_APP_NAME など |
| $env/dynamic/public | 可 | 実行時(動的参照) | 環境別フラグ・機能フラグ |
Turso では $env/static/private を使う
$env/static/private はビルド時に値を定数として埋め込みます。
これにより3つのメリットが得られます:
- 型安全: TypeScript の型推論が効き、変数名の typo をビルド時に検出できる
- クライアント完全除外: SvelteKit がビルド時にサーバー専用と判定し、バンドルから削除する(
$env/static/privateを+page.svelteからインポートするとビルドエラーになる) - デッドコード除去: 値が定数になるため、未使用の環境変数参照が最適化される
// src/lib/server/db.ts
import { createClient } from '@libsql/client';
import { TURSO_DATABASE_URL, TURSO_AUTH_TOKEN } from '$env/static/private';
export const client = createClient({
url: TURSO_DATABASE_URL,
authToken: TURSO_AUTH_TOKEN
}); $env/dynamic/private を使うべきケース
Cloudflare Workers / Cloudflare Pages(adapter-cloudflare)にデプロイする場合、環境変数は wrangler.toml のバインディングとして実行時に注入されます。
この場合は ビルド時には値が存在しないため、$env/static/private では取得できません。
代わりに $env/dynamic/private または platform.env を使います。
// Cloudflare Pages 向け: +page.server.ts での例
import { createClient } from '@libsql/client';
export const load = async ({ platform }) => {
// platform.env は Cloudflare Workers のバインディング
const db = createClient({
url: platform?.env?.TURSO_DATABASE_URL ?? '',
authToken: platform?.env?.TURSO_AUTH_TOKEN ?? ''
});
const result = await db.execute('SELECT * FROM users');
return { users: result.rows };
}; - Vercel / Netlify / Railway / Node サーバー:
$env/static/private(ビルド時注入) - Cloudflare Pages(adapter-cloudflare):
$env/dynamic/privateまたはplatform.env - ローカル開発: どちらも
.envから自動読み込み(SvelteKit が vite の dotenv 機能で処理)
🔌 Section 5: libSQLクライアント生成パターン
Turso との通信はすべて @libsql/client の createClient() から始まります。
SvelteKit では src/lib/server/db.ts に
シングルトンとして配置するのが定石です。
シングルトンパターン(推奨構成)
// src/lib/server/db.ts
import { createClient, type Client } from '@libsql/client';
import { TURSO_DATABASE_URL, TURSO_AUTH_TOKEN } from '$env/static/private';
export const db: Client = createClient({
url: TURSO_DATABASE_URL,
authToken: TURSO_AUTH_TOKEN,
});なぜシングルトンで問題ないのか
- libSQL クライアントは ステートレス かつ スレッドセーフ
- 通信は HTTP/HTTPS ベースのため コネクションプール管理が不要(PostgreSQL とは根本的に異なる設計)
- Node.js / Bun はモジュールを初回 import 時に一度だけ評価するためシングルトンが自然に成立する
- Cloudflare Workers 環境はリクエストごとに隔離されるが、libSQL クライアントは軽量なため複数インスタンスになっても問題なし
createClient() — 全オプション一覧
| オプション | 型 | 必須 | 説明 |
|---|---|---|---|
| url | string | 必須 | DB の接続先 URL(スキーマは下記参照) |
| authToken | string? | 任意 | Turso の JWT 認証トークン(libsql:// 接続時は実質必須) |
| syncUrl | string? | 任意 | Embedded Replica 使用時のリモート DB URL(url はローカル file: を指定) |
| syncInterval | number? | 任意 | 自動同期間隔(秒)。Embedded Replica 専用。省略時は手動 sync() のみ |
| encryptionKey | string? | 任意 | ローカル SQLite ファイルの暗号化キー(libSQL 拡張機能が必要) |
| tls | boolean? | 任意 | TLS を強制するか否か(デフォルト: true。ローカル sqld 開発で false に設定可) |
| intMode | 'number' | 'bigint' | 'string' | 任意 | INTEGER 値の JS 表現方法(デフォルト: 'number') |
| concurrency | number? | 任意 | 同時リクエスト数の上限(デフォルト: 20) |
intMode の使い分け
| モード | JS の型 | 安全範囲 | 用途 |
|---|---|---|---|
| 'number' | number | 2^53 未満(約 9 京) | デフォルト。通常の id・count はこれで十分 |
| 'bigint' | BigInt | 制限なし(全 64bit 範囲) | 2^53 超の rowid・Unix ms タイムスタンプ。JSON.stringify 時は変換が必要 |
| 'string' | string | 制限なし | JSON シリアライズで BigInt 問題を回避したいとき。アプリ側で Number() 変換が必要 |
url スキーマの種類
// 1. 本番: Turso クラウド(HTTPS + JWT 認証)
url: 'libsql://your-db-name.turso.io'
// 2. ローカル SQLite ファイル(開発・Embedded Replica のローカル側)
url: 'file:local.db'
url: 'file:/absolute/path/to/local.db'
// 3. ローカル sqld 開発サーバー(docker run ghcr.io/tursodatabase/libsql-server)
// TLS なし → tls: false が必要
url: 'http://127.0.0.1:8080'
// 4. メモリ内 SQLite(テスト用、プロセス終了で消える)
url: ':memory:'@libsql/client vs @libsql/client/web — ランタイム別の選択
| 比較項目 | @libsql/client(Node.js版) | @libsql/client/web(Edge版) |
|---|---|---|
| 対応ランタイム | Node.js / Bun / Deno(Node compat) | Cloudflare Workers / Edge Runtime |
| 内部通信方式 | WebSocket + HTTP(node: モジュール依存) | fetch API のみ(ブラウザ互換 API) |
| Embedded Replica | 対応 | 非対応 |
| ローカル file: DB | 対応 | 非対応 |
| Cloudflare Pages / Workers | 非対応(node: 依存) | 対応 |
| 遅延(レイテンシ) | 低め(WebSocket 常時接続可) | やや高め(fetch ごとに接続) |
| import 文 | from '@libsql/client' | from '@libsql/client/web' |
@libsql/client/web を使う必要があります。
vite.config.ts の resolve.alias で切り替えるか、環境変数で条件分岐するパターンが一般的です。⚡ Section 6: CRUD基礎 — execute / batch / transaction
公式 TypeScript SDK Reference に定義された全 API を網羅します。libSQL クライアントが提供するメソッドは execute / batch / transaction / executeMultiple / sync / close の 6 つです。
ResultSet オブジェクトの構造
| プロパティ | 型 | 説明 |
|---|---|---|
| rows | Row[] | クエリ結果の行配列。INSERT/UPDATE/DELETE は空配列 |
| columns | string[] | SELECT した列名の順序付き配列 |
| columnTypes | string[] | 各列の宣言型('INTEGER', 'TEXT' 等)。スキーマ検査に使用 |
| rowsAffected | number | INSERT/UPDATE/DELETE で変更された行数 |
| lastInsertRowid | bigint | undefined | 最後に挿入された行の rowid。INSERT 以外は undefined |
| toJSON() | object | JSON シリアライズ可能な形式に変換(BigInt を string に変換) |
コード例 1: execute() — 単一 SQL 文の実行
// シンプル形(パラメータなし)
const result = await db.execute('SELECT * FROM users');
// positional バインド(? プレースホルダー)
const r2 = await db.execute({
sql: 'SELECT * FROM users WHERE id = ? AND active = ?',
args: [42, 1],
});
// named バインド(:name プレースホルダー)
const r3 = await db.execute({
sql: 'SELECT * FROM users WHERE email = :email',
args: { email: 'user@example.com' },
});
// rows を型付きオブジェクトに変換
const users = result.rows.map((row) => ({
id: Number(row.id),
name: String(row.name),
email: String(row.email),
}));コード例 2: batch() — 複数文を1往復で実行
batch は複数の SQL 文を 1 回の HTTP リクエストでサーバーに送り、暗黙的なトランザクションで実行します。成功すると自動コミット、失敗すると自動ロールバック。
| mode | 動作 | 用途 |
|---|---|---|
| 'write' | 書き込みトランザクション。全文が原子的に実行 | INSERT/UPDATE/DELETE の複数文一括 |
| 'read' | 読み取り専用。レプリカで実行可能 | 複数 SELECT を1往復でまとめるとき |
| 'deferred' | 最初の書き込みが来るまで読み取りモード(デフォルト) | 読み書き混在で SQLite デフォルト動作に合わせたいとき |
// CREATE TABLE + 複数 INSERT を 1 往復で実行
const results = await db.batch([
{
sql: `CREATE TABLE IF NOT EXISTS posts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
body TEXT,
created_at INTEGER NOT NULL
)`,
args: [],
},
{
sql: 'INSERT INTO posts (title, body, created_at) VALUES (?, ?, ?)',
args: ['First Post', 'Hello Turso!', Date.now()],
},
{
sql: 'INSERT INTO posts (title, body, created_at) VALUES (?, ?, ?)',
args: ['Second Post', 'SvelteKit is great.', Date.now()],
},
], 'write');
console.log('挿入行数:', results[1].rowsAffected); // 1
console.log('最初の rowid:', results[1].lastInsertRowid); // 1nコード例 3: transaction() — 明示的なインタラクティブトランザクション
batch と異なり、実行中に中間結果を参照しながら次の SQL を決定できます。ただし複数往復が発生するため batch より遅い点を踏まえて使い分けてください。
const tx = await db.transaction('write');
try {
const stockRes = await tx.execute({
sql: 'SELECT stock FROM products WHERE id = ?',
args: [productId],
});
const stock = Number(stockRes.rows[0]?.stock ?? 0);
if (stock < quantity) {
await tx.rollback();
return { error: '在庫不足' };
}
await tx.execute({
sql: 'UPDATE products SET stock = stock - ? WHERE id = ?',
args: [quantity, productId],
});
await tx.execute({
sql: 'INSERT INTO orders (product_id, qty, created_at) VALUES (?, ?, ?)',
args: [productId, quantity, Date.now()],
});
await tx.commit();
return { success: true };
} catch (e) {
await tx.rollback();
throw e;
}コード例 4: executeMultiple() / sync() / close()
// executeMultiple — セミコロン区切り SQL を一括実行(マイグレーション用、パラメータ不可)
await db.executeMultiple(`
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
email TEXT UNIQUE NOT NULL,
created_at INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_users_email ON users(email);
`);
// sync — Embedded Replica のローカルコピーをリモートと手動同期
await db.sync();
// close — クライアントを終了し内部リソースを解放
db.close();BigInt の扱いに注意
lastInsertRowid は常に bigint 型で返ります。
intMode: 'number'(デフォルト)でも rowid が 2^53 を超えると精度が落ちます。
const newId = Number(result.lastInsertRowid); // 安全範囲内の場合
const safe = result.toJSON(); // BigInt が string に変換される🛡️ Section 7: プリペアドステートメントと SQL Injection 防止
SQL Injection は「ユーザー入力を SQL 文字列に直接埋め込む」ことで発生する最も古典的かつ深刻な脆弱性です。 libSQL では パラメータバインドを使うことで根本的に防止できます。
絶対やってはいけない — 文字列結合でクエリを組み立てる
// ❌ 文字列結合でクエリを組み立てる
const name = formData.get('name');
await db.execute(`SELECT * FROM users WHERE name = '$${name}'`);
// name に ' OR 1=1 -- が入ると全件流出する ' OR 1=1 -- を入力すると
WHERE 句が無効化され全ユーザーのデータが返ります。DROP TABLE や UPDATE も実行可能になります。正しい方法 — パラメータバインド(positional / named)
// ✅ positional バインド(? プレースホルダー)
await db.execute({
sql: 'SELECT * FROM users WHERE name = ?',
args: [name],
});
// ✅ named バインド(:プレースホルダー名)
await db.execute({
sql: 'SELECT * FROM users WHERE email = :email AND active = :active',
args: { email: userEmail, active: 1 },
});
// ✅ INSERT でも同様
await db.execute({
sql: 'INSERT INTO posts (title, body, user_id) VALUES (?, ?, ?)',
args: [title, body, userId],
}); LIKE 句のワイルドカードエスケープ
パラメータバインドを使っても、LIKE 句の % と _ はユーザー入力に含まれると意図しないワイルドカードになります。
// ✅ ESCAPE 句でエスケープする
function escapeLike(str: string): string {
return str
.replace(/\/g, '\\')
.replace(/%/g, '\%')
.replace(/_/g, '\_');
}
await db.execute({
sql: "SELECT * FROM products WHERE name LIKE ? ESCAPE '\\'",
args: [`$${escapeLike(userInput)}%`],
});動的 IN 句の安全な生成
// ❌ 文字列結合(危険)
// `WHERE id IN ($${ids.join(',')})`
// ✅ ? プレースホルダーを動的に生成
const ids = [1, 2, 3, 4, 5];
const placeholders = ids.map(() => '?').join(', ');
const result = await db.execute({
sql: `SELECT * FROM posts WHERE user_id IN ($${placeholders})`,
args: ids,
});テーブル名・カラム名はパラメータ化できない
テーブル名やカラム名は SQL 構造の一部のため ? で置き換えることができません。動的な場合は 許可リスト(allowlist)方式で防御します。
const ALLOWED_SORT_COLUMNS = ['name', 'created_at', 'email'] as const;
type SortColumn = typeof ALLOWED_SORT_COLUMNS[number];
function isSortColumn(col: string): col is SortColumn {
return (ALLOWED_SORT_COLUMNS as readonly string[]).includes(col);
}
const sortBy = url.searchParams.get('sort') ?? 'created_at';
if (!isSortColumn(sortBy)) throw new Error('不正なソート列');
await db.execute(`SELECT * FROM users ORDER BY $${sortBy} DESC`);Drizzle ORM を使う場合
Drizzle ORM は内部でパラメータバインドを自動的に行うため、通常のクエリビルダーを使う限り SQL Injection の心配はありません。
ただし sql`...` テンプレートタグで生 SQL を書く場合は、
ユーザー入力を直接埋め込まず sql.placeholder() を使用してください。
📐 Section 8: スキーマ設計とマイグレーション
libSQL は SQLite 互換のため、型システムも SQLite に準じます。 すべての値は 5 つのストレージクラスのいずれかに収まります (公式: Datatypes In SQLite)。
5 つのストレージクラス
| ストレージクラス | 格納される値 | バイト数 |
|---|---|---|
| NULL | NULL 値 | 0 |
| INTEGER | 符号付き整数(±2^63 まで) | 値の大きさにより 0, 1, 2, 3, 4, 6, 8 バイト |
| REAL | 8 バイト IEEE 754 浮動小数点数 | 8 |
| TEXT | UTF-8 / UTF-16 テキスト文字列 | 可変長 |
| BLOB | バイナリデータ(入力をそのまま格納) | 可変長 |
型アフィニティ — 列宣言型から決まる変換ルール
SQLite / libSQL は列の宣言型を厳密には強制せず、「型アフィニティ」でどの型に寄せるかを決定します。宣言型の文字列をルール順に検査し、最初にマッチしたアフィニティが適用されます。
| アフィニティ | マッチするキーワード | 宣言型の例 |
|---|---|---|
| INTEGER | "INT" を含む(最優先) | INT, INTEGER, TINYINT, SMALLINT, BIGINT, INT2, INT8 |
| TEXT | "CHAR", "CLOB", "TEXT" を含む | CHARACTER(20), VARCHAR(255), TEXT, CLOB, NVARCHAR(100) |
| BLOB | "BLOB" を含む or 型指定なし | BLOB, (型指定なし) |
| REAL | "REAL", "FLOA", "DOUB" を含む | REAL, FLOAT, DOUBLE, DOUBLE PRECISION |
| NUMERIC | 上記にマッチしない全て | NUMERIC, DECIMAL(10,5), BOOLEAN, DATE, DATETIME |
出典: SQLite 公式 Datatypes In SQLite3(ルール適用順序、上から優先)
日付・時刻の扱い方 — 2 方式の比較
| 比較項目 | ISO 8601 TEXT | Unix epoch INTEGER (ms) |
|---|---|---|
| 保存例 | '2026-05-03T14:30:00Z' | 1746280200000 |
| 人間可読性 | 高い(そのまま読める) | 低い(ツールが必要) |
| ソート精度 | 正確(辞書順 = 時系列順) | 正確(数値大小 = 時系列順) |
| インデックス効率 | 普通(TEXT 比較) | 高い(INTEGER 比較) |
| Drizzle での型 | text('created_at') | integer('created_at') |
| 推奨度 | デバッグ・小規模向け | 本番推奨(Drizzle デフォルト) |
// Unix epoch ms で保存・取得する(推奨パターン)
await db.execute({
sql: 'INSERT INTO posts (title, created_at) VALUES (?, ?)',
args: ['Hello', Date.now()],
});
const result = await db.execute('SELECT * FROM posts ORDER BY created_at DESC');
const posts = result.rows.map((row) => ({
title: String(row.title),
createdAt: new Date(Number(row.created_at)),
}));主キーの選択
| 方式 | 宣言 | 特徴 | 推奨シーン |
|---|---|---|---|
| ROWID 連動整数 | INTEGER PRIMARY KEY | rowid と同じ値。自動増分(削除行 id の再利用あり) | 単一サーバー・小規模・最速 INSERT |
| AUTOINCREMENT | INTEGER PRIMARY KEY AUTOINCREMENT | 削除行の id を再利用しない。sqlite_sequence テーブルを消費 | id の再利用を絶対に防ぎたいとき |
| UUID v4 | TEXT PRIMARY KEY | 128bit ランダム。衝突確率は無視できるが、ランダム挿入でインデックス断片化 | 分散生成・外部 API との連携 |
| ULID | TEXT PRIMARY KEY | 時刻プレフィックス + ランダム。辞書順が時系列順と一致 | 分散環境推奨 |
WITHOUT ROWID の使いどころ
WITHOUT ROWID を付けると rowid を廃止して主キーを直接 B-Tree のキーとして使うクラスタインデックス相当の構造になります
(公式: WITHOUT ROWID Tables)。
-- 複合主キーで主キー検索が支配的な場合に有効
CREATE TABLE kv_store (
namespace TEXT NOT NULL,
key TEXT NOT NULL,
value TEXT,
PRIMARY KEY (namespace, key)
) WITHOUT ROWID;
-- 注意: INTEGER PRIMARY KEY は WITHOUT ROWID と併用できない(rowid の別名のため)外部キー制約は毎接続で有効化が必要
SQLite / libSQL はデフォルトで外部キー制約が 無効です。REFERENCES 句を宣言しても PRAGMA を実行しない限り制約が働きません。
export const db: Client = createClient({
url: TURSO_DATABASE_URL,
authToken: TURSO_AUTH_TOKEN,
});
await db.execute('PRAGMA foreign_keys = ON');インデックスと部分インデックス
-- 通常のインデックス
CREATE INDEX idx_users_email ON users(email);
-- 複合インデックス(user_id で絞り込み + created_at でソート)
CREATE INDEX idx_posts_user_date ON posts(user_id, created_at DESC);
-- 部分インデックス(active = 1 のユーザーのみ → インデックスが小さく高速)
CREATE INDEX idx_active_users ON users(email) WHERE active = 1;
-- UNIQUE 制約付きインデックス
CREATE UNIQUE INDEX idx_users_email_unique ON users(email);マイグレーション戦略 3 方式の比較
| 方式 | 実装方法 | 型安全 | 自動生成 | 推奨度 |
|---|---|---|---|---|
| 手書き SQL | turso db shell < migrate.sql | なし | なし | 小規模・学習用 |
| Drizzle Kit | drizzle-kit generate && migrate | あり | あり | 推奨(個人〜中規模) |
| Atlas | atlas migrate apply | あり | あり | 大規模チーム向け |
本番マイグレーション運用フロー(Turso ブランチ DB 活用)
- 1
Step 1: ブランチ DB 作成
本番 DB をブランチとして複製する
turso db create staging-branch --from-db production-db本番 DB のスナップショットから新しい独立 DB を作成。本番に影響ゼロ。
- 2
Step 2: マイグレーションのテスト適用
ブランチ DB にマイグレーションを適用
TURSO_DATABASE_URL=$(turso db show staging-branch --url) npx drizzle-kit migrateエラーが発生しても本番には一切影響しない。
- 3
Step 3: 動作確認
ブランチ DB を指す環境でアプリを動作確認
スキーマ変更後のアプリが正常に動作するか、テストスクリプトやステージング環境で確認する。
- 4
Step 4: 本番に適用
問題なければ本番 DB に同じマイグレーションを適用
TURSO_DATABASE_URL=$(turso db show production-db --url) npx drizzle-kit migrate - 5
Step 5: ブランチ DB 削除
不要になったブランチ DB を削除してコストを抑える
turso db destroy staging-branch
🧩 Section 9: Drizzle ORM 統合
Drizzle ORM は TypeScript-first の SQL クエリビルダー兼 ORM です。SQL 風の DSL でクエリを書くため「Drizzle 独自文法を覚える」コストが低く、生成されるクエリが予測しやすい点が特徴です。libSQL(Turso)に正式対応しており、Edge 環境でも動作します。
なぜ Drizzle を選ぶか
| 観点 | Drizzle | Prisma |
|---|---|---|
| 型安全性 | ✅ TypeScript-first(推論で型が付く) | ✅ 生成コードで型付き |
| Edge 対応 | ✅ Workers / Bun / Node.js | △ Accelerate 経由のみ |
| バンドルサイズ | ✅ 軽量(生成コードなし) | ❌ 重い(バイナリ同梱) |
| SQL 親和性 | ✅ SQL 風 DSL(予測しやすい) | △ Prisma 独自 API |
| Turso 公式サポート | ✅ ドキュメントに専用ガイド | △ libSQL アダプター(非公式) |
インストール
bun add drizzle-orm @libsql/client
bun add -d drizzle-kit 4ファイル構成の全体像
① drizzle.config.ts
Drizzle Kit の設定。スキーマファイルの場所・マイグレーション出力先・接続情報を定義する。
② src/lib/server/schema.ts
テーブル定義・リレーション・型エクスポート。TypeScript で書くことで型推論の起点になる。
③ src/lib/server/db.ts
libSQL クライアントを生成し Drizzle インスタンスを初期化。アプリ全体で import して使う。
④ drizzle/ ディレクトリ
drizzle-kit generate が生成するマイグレーション SQL ファイル群。git 管理に含める。
① drizzle.config.ts
// drizzle.config.ts
import 'dotenv/config';
import { defineConfig } from 'drizzle-kit';
export default defineConfig({
schema: './src/lib/server/schema.ts',
out: './drizzle',
dialect: 'turso',
dbCredentials: {
url: process.env.TURSO_DATABASE_URL!,
authToken: process.env.TURSO_AUTH_TOKEN!,
},
}); dialect: 'turso' を指定することで Drizzle Kit が libSQL 向けの SQL を生成します。out に指定したディレクトリにマイグレーションファイルが出力されます。
② schema.ts — テーブル定義(users + posts・外部キー cascade)
// src/lib/server/schema.ts
import { sqliteTable, text, integer, index } from 'drizzle-orm/sqlite-core';
import { sql } from 'drizzle-orm';
import { relations } from 'drizzle-orm';
export const users = sqliteTable('users', {
id: text('id').primaryKey(),
email: text('email').notNull().unique(),
name: text('name').notNull(),
createdAt: integer('created_at', { mode: 'timestamp_ms' })
.notNull()
.default(sql`(unixepoch() * 1000)`),
}, (t) => ({
emailIdx: index('users_email_idx').on(t.email),
}));
export const posts = sqliteTable('posts', {
id: text('id').primaryKey(),
userId: text('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }),
title: text('title').notNull(),
body: text('body').notNull(),
publishedAt: integer('published_at', { mode: 'timestamp_ms' }),
});
export const usersRelations = relations(users, ({ many }) => ({ posts: many(posts) }));
export const postsRelations = relations(posts, ({ one }) => ({
user: one(users, { fields: [posts.userId], references: [users.id] }),
}));
export type User = typeof users.$inferSelect;
export type NewUser = typeof users.$inferInsert; カラム型モード一覧
| カラム関数 | mode オプション | TypeScript 型 | 用途 |
|---|---|---|---|
| text | mode: 'json' | unknown | JSON オブジェクトを文字列として格納 |
| integer | mode: 'timestamp_ms' | Date | ミリ秒 Unix タイム(JS Date と自動変換) |
| integer | mode: 'timestamp' | Date | 秒 Unix タイム(小数なし) |
| integer | mode: 'boolean' | boolean | 0/1 を true/false に自動変換 |
| blob | mode: 'buffer' | Buffer | バイナリデータ(画像・ファイル等) |
③ db.ts — Drizzle インスタンス生成
// src/lib/server/db.ts
import { drizzle } from 'drizzle-orm/libsql';
import { createClient } from '@libsql/client';
import { TURSO_DATABASE_URL, TURSO_AUTH_TOKEN } from '$env/static/private';
import * as schema from './schema';
const client = createClient({ url: TURSO_DATABASE_URL, authToken: TURSO_AUTH_TOKEN });
export const db = drizzle(client, { schema }); { schema } を渡すことでリレーショナルクエリ(db.query.users.findMany)が使えます。渡さなければ通常の SQL 風クエリのみ利用可能です。
CLI 操作フロー
- 1
スキーマ変更後
bunx drizzle-kit generate
drizzle/0000_xxx.sqlを生成。schema.ts との差分を自動検出して ALTER TABLE / CREATE TABLE を生成する。 - 2
開発・本番環境に適用
bunx drizzle-kit migrate
未適用のマイグレーション SQL を Turso DB に実行する。
__drizzle_migrationsテーブルで適用済みを追跡。 - 3
データ確認・デバッグ
bunx drizzle-kit studio
ローカル Web GUI(
https://local.drizzle.studio)が起動し、テーブルの閲覧・編集ができる。本番 DB 確認時にも使える。
型安全 CRUD サンプル集
1. SELECT(where + orderBy + limit)
import { db } from '$lib/server/db';
import { posts, users } from '$lib/server/schema';
import { desc, like, and, eq } from 'drizzle-orm';
const rows = await db
.select()
.from(posts)
.where(and(like(posts.title, '%Turso%'), eq(posts.userId, userId)))
.orderBy(desc(posts.publishedAt))
.limit(20); 2. INSERT returning
const [newPost] = await db
.insert(posts)
.values({ id: crypto.randomUUID(), userId, title, body, publishedAt: new Date() })
.returning(); 3. UPDATE
await db
.update(posts)
.set({ title: newTitle })
.where(eq(posts.id, postId)); 4. DELETE
await db
.delete(posts)
.where(eq(posts.id, postId)); 5. JOIN(SELECT + innerJoin)
const rows = await db
.select({
postTitle: posts.title,
authorName: users.name,
})
.from(posts)
.innerJoin(users, eq(posts.userId, users.id)); 6. リレーショナルクエリ(db.query API)
// schema に relations を定義済みで drizzle(client, { schema }) とした場合のみ使える
const usersWithPosts = await db.query.users.findMany({
with: { posts: true },
where: (u, { eq }) => eq(u.email, 'alice@example.com'),
}); db.query API は内部で複数の SQL クエリを発行します(JOIN か IN 句に変換)。schema に relations() 定義が必要です。定義がない場合は JOIN を手動で書いてください。📥 Section 10: +page.server.ts ロード関数
SvelteKit の load 関数はページに必要なデータをサーバーまたはクライアントで取得します。Turso(DB)へ直接アクセスするには +page.server.ts を使います。
3種のロード関数の違い
| ファイル | 実行場所 | クライアント送信 | DB直接アクセス |
|---|---|---|---|
| +page.ts | 共有(サーバー + クライアント) | はい(SSR + CSR) | ❌ 秘密情報を使えない |
| +page.server.ts | サーバーのみ | データのみ(コードは非公開) | ✅ 環境変数・DB クライアント使用可 |
| +layout.server.ts | サーバーのみ | データのみ(レイアウト全体に共有) | ✅ 認証情報・共通データに最適 |
posts 一覧ページの完全コード例
// src/routes/posts/+page.server.ts
import type { PageServerLoad } from './$types';
import { db } from '$lib/server/db';
import { posts, users } from '$lib/server/schema';
import { desc, eq } from 'drizzle-orm';
export const load: PageServerLoad = async ({ url, depends, setHeaders }) => {
depends('app:posts');
setHeaders({ 'cache-control': 'private, max-age=10' });
const limit = Number(url.searchParams.get('limit') ?? 20);
const rows = await db
.select({
id: posts.id,
title: posts.title,
author: users.name,
publishedAt: posts.publishedAt,
})
.from(posts)
.innerJoin(users, eq(posts.userId, users.id))
.orderBy(desc(posts.publishedAt))
.limit(limit);
return { posts: rows };
}; depends('app:posts')
依存キーを登録。invalidate('app:posts') を呼ぶとこのページの load が再実行される。
setHeaders()
レスポンスヘッダーを設定。cache-control を付けると CDN・ブラウザがキャッシュする。
url.searchParams
URL クエリパラメータをサーバー側で読める。ページネーションやフィルタに使う。
params を使った詳細ページ([id])
// src/routes/posts/[id]/+page.server.ts
import { error } from '@sveltejs/kit';
import type { PageServerLoad } from './$types';
import { db } from '$lib/server/db';
import { posts } from '$lib/server/schema';
import { eq } from 'drizzle-orm';
export const load: PageServerLoad = async ({ params }) => {
const [post] = await db.select().from(posts).where(eq(posts.id, params.id));
if (!post) throw error(404, 'Post not found');
return { post };
}; params.id は URL の [id] セグメントに対応します。throw error(404, ...) は SvelteKit のエラーページを表示します。
応用パターン
- 1
Streaming load
重い処理は await しない
Promise を return すると SvelteKit がストリーミングで解決します。
return { fast: await fastQuery(), slow: slowQuery() }と書くとfastは即座にレンダリングし、slowは解決次第差し込まれます。 - 2
parent() で親データを取得
レイアウトの load 結果を継承
const { user } = await parent();で+layout.server.tsが返したデータを取得できます。認証ユーザー情報などを共通で使い回すのに最適です。 - 3
クライアント側から再フェッチ
invalidate('app:posts') で再取得
フォーム送信後などに
import { invalidate } from '$app/navigation'を使ってinvalidate('app:posts')を呼ぶと、ページ遷移なしで load 関数が再実行されます。
📝 Section 11: Form Actions と書き込み
SvelteKit の form actions は HTML フォームをサーバーサイドで処理するための仕組みです。use:enhance ディレクティブを組み合わせることで、JavaScript が有効な環境では SPA-like な体験(ページリロードなし)に自動アップグレードされます。
+page.server.ts — actions 定義
// src/routes/posts/new/+page.server.ts
import type { Actions, PageServerLoad } from './$types';
import { fail, redirect } from '@sveltejs/kit';
import { db } from '$lib/server/db';
import { posts } from '$lib/server/schema';
export const actions: Actions = {
create: async ({ request, locals }) => {
const data = await request.formData();
const title = data.get('title')?.toString().trim() ?? '';
const body = data.get('body')?.toString().trim() ?? '';
if (title.length < 3 || title.length > 200) {
return fail(400, { title, body, error: 'タイトルは3〜200文字' });
}
if (!locals.user) {
return fail(401, { error: '未ログイン' });
}
const id = crypto.randomUUID();
await db.insert(posts).values({
id,
userId: locals.user.id,
title,
body,
publishedAt: new Date(),
});
throw redirect(303, `/posts/$${id}`);
},
}; +page.svelte — フォーム UI
<!-- src/routes/posts/new/+page.svelte -->
<script lang="ts">
import { enhance } from '$app/forms';
let { form } = $props();
</script>
<form method="POST" action="?/create" use:enhance>
<input name="title" value={form?.title ?? ''} required />
{#if form?.error}<p class="text-red-600">{form.error}</p>{/if}
<textarea name="body">{form?.body ?? ''}</textarea>
<button type="submit">投稿</button>
</form> 各要素の解説
- 1
Progressive Enhancement
use:enhance の動作
JavaScript なし環境でも通常の HTML フォームとして動作します。JS が有効な場合は fetch でサブミット → ページリロードなしでレスポンスを処理します。
- 2
バリデーションエラー時
fail() でフォーム値を保持する理由
fail(400, { title, body, error } )の第2引数がformプロパティとして返ってきます。これにより入力途中の値が消えず、ユーザーが修正しやすくなります。 - 3
成功時のリダイレクト
redirect() を throw する理由
throw redirect(303, url)は SvelteKit 内部での型安全な制御フローです。通常のreturnと区別するため throw を使います。303 は POST 後の GET リダイレクト(PRG パターン)に対応します。 - 4
CSRF 対策
SvelteKit はデフォルトで origin チェック有効
SvelteKit はリクエストの
Originヘッダーを検証し、異なるオリジンからの POST を自動で拒否します。csrf.checkOrigin: falseにしない限り追加対策は不要です。
複数アクションの定義
export const actions: Actions = {
create: async ({ request }) => { /* ... */ },
delete: async ({ request }) => {
const data = await request.formData();
const id = data.get('id')?.toString() ?? '';
await db.delete(posts).where(eq(posts.id, id));
return { success: true };
},
update: async ({ request }) => { /* ... */ },
}; フォーム側からは action="?/delete" のように ?/アクション名 で呼び分けます。複数アクションを1ファイルに集約することで、関連する操作をまとめて管理できます。
ファイルアップロードと BLOB 保存
const data = await request.formData();
const file = data.get('avatar') as File;
if (file && file.size > 0) {
const buffer = Buffer.from(await file.arrayBuffer());
// Turso BLOB に直接保存(小サイズ限定)
await db.update(users).set({ avatar: buffer }).where(eq(users.id, userId));
// 大サイズは R2 / S3 に委譲してURLだけ保存
// const url = await uploadToR2(buffer, file.name);
// await db.update(users).set({ avatarUrl: url }).where(eq(users.id, userId));
} ⚡ Section 12: Embedded Replica
Embedded Replica は Turso が提供する最も強力な機能の一つです。アプリケーションサーバーと同じホスト上にローカル SQLite ファイルを置き、読み取りをネットワーク往復なしで完結させます。
仕組みの全体像
[SvelteKit Server / Bun / Node.js]
│
├── 読み取り → ローカル SQLite ファイル (local-replica.db)
│ ↑ ネットワーク往復なし / レイテンシ 0.1ms 以下
│
└── 書き込み → リモート Turso プライマリ
↓ 自動同期(syncInterval または手動 sync())
ローカルファイルへ反映 パフォーマンス比較
| 構成 | 読み取りレイテンシ | 書き込みレイテンシ | 使用場所の制約 |
|---|---|---|---|
| リモートのみ | 50〜150ms(ネットワーク往復) | 50〜150ms | Cloudflare Workers 含む全環境 |
| Embedded Replica | 0.1ms 以下(ローカルファイル) | 50〜150ms(リモートへ書き込み) | Node.js / Bun サーバーのみ(ディスク必要) |
db.ts — Embedded Replica 版
// src/lib/server/db.ts(Embedded Replica 版)
import { createClient } from '@libsql/client';
import { TURSO_DATABASE_URL, TURSO_AUTH_TOKEN } from '$env/static/private';
export const db = createClient({
url: 'file:local-replica.db', // ローカルレプリカファイル
syncUrl: TURSO_DATABASE_URL, // 同期元リモート Turso
authToken: TURSO_AUTH_TOKEN,
syncInterval: 60, // 60秒ごとに自動同期
}); url: 'file:...' でローカルファイルを指定し、syncUrl にリモート Turso の URL を指定します。ファイルが存在しない初回起動時は自動でリモートから全データをコピーします。
Read your writes — 書き込み直後の整合性確保
// 書き込み
await db.execute({
sql: 'INSERT INTO posts VALUES (?, ?, ?)',
args: [id, title, body],
});
// 書き込み直後に読む場合は手動 sync() で整合性を保証
await db.sync();
// この時点でローカルファイルに書き込んだデータが反映されている
const rows = await db.execute({ sql: 'SELECT * FROM posts WHERE id = ?', args: [id] }); syncInterval vs 手動 sync() の使い分け
syncInterval: 60(自動)
バックグラウンドで定期同期。読み取り専用が主体のページで有効。書き込み直後の読み取りには数十秒の遅延が生じる可能性がある。
手動 await db.sync()
書き込み後すぐに読み取りが必要な場面で使う。フォーム送信後にリストページにリダイレクトする前に呼ぶことで「投稿したのに一覧に出ない」問題を防ぐ。
運用上の注意点
- 1
.gitignore
local-replica.db をコミットしない
レプリカファイルはリモートから自動生成されるため git 管理に含める必要はありません。
.gitignoreにlocal-replica.dbを追加してください。 - 2
ディスク容量
リモート DB と同サイズのディスクが必要
Embedded Replica はリモート DB のフルコピーをローカルに持ちます。100MB の DB なら 100MB のディスクが必要です。VPS / Fly.io / Railway などのディスク付きプラットフォームで使用してください。
- 3
Cloudflare Workers / Pages Functions
ローカルディスクが使えないため不可
Cloudflare Workers はステートレスなエフェメラル環境のためローカルファイルを永続化できません。Cloudflare 環境ではリモートのみ構成を使い、Cache API や KV を読み取りキャッシュとして併用することを検討してください。
☁️ Section 13: Cloudflare Pages デプロイ
SvelteKit を Cloudflare Pages にデプロイするには公式アダプター @sveltejs/adapter-cloudflare を使います。Turso との組み合わせでは クライアントの版(web 版)の選択 が最重要ポイントです。
1. アダプターのインストール
bun add -D @sveltejs/adapter-cloudflare 2. svelte.config.js の設定
// svelte.config.js
import adapter from '@sveltejs/adapter-cloudflare';
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
export default {
preprocess: vitePreprocess(),
kit: {
adapter: adapter({
routes: {
include: ['/*'],
exclude: ['<all>'],
},
},
},
};
3. Cloudflare Pages ダッシュボード — 環境変数設定
- Settings → Environment variables を開く
- Production / Preview それぞれに
TURSO_DATABASE_URLとTURSO_AUTH_TOKENを追加 - 認証トークンは必ず Encrypted で登録(プレーンテキスト禁止)
4. ビルド設定
| 項目 | 値 |
|---|---|
| Build command | bun run build |
| Build output | .svelte-kit/cloudflare |
| Root directory | / |
| Node.js version | 20.x(Bun を使うときは Wrangler の互換層) |
5. @libsql/client/web の使い分け
Cloudflare Workers / Pages のランタイムは Node.js の fs / net モジュールを持ちません。そのため fetch ベースの web 版だけが正常に動作します。
// src/lib/server/db.ts(Cloudflare Pages 用)
// Cloudflare Workers / Pages では必ず /web 版を import する
import { createClient } from '@libsql/client/web';
import { TURSO_DATABASE_URL, TURSO_AUTH_TOKEN } from '$env/static/private';
export const db = createClient({
url: TURSO_DATABASE_URL,
authToken: TURSO_AUTH_TOKEN,
}); クライアント版の比較
| 版 | 対応ランタイム | Embedded Replica | 通信方式 |
|---|---|---|---|
| @libsql/client | Node.js / Bun / Deno | ✅ | HTTP/2 + WebSocket |
| @libsql/client/web | Cloudflare Workers / Edge | ❌ | fetch のみ |
6. $env/dynamic/private 経由パターン
ビルド時に環境変数が確定しない場合(プレビュー環境など)は dynamic/private を使います。
import { env } from '$env/dynamic/private';
export const db = createClient({
url: env.TURSO_DATABASE_URL,
authToken: env.TURSO_AUTH_TOKEN,
}); 🛠️ Section 14: プロ向け運用パターン
14-1: 監査ログ(audit_log + トランザクション)
本処理と監査ログを 同一トランザクション内で atomic に書き込むことで、ロールバック時にログが残る不整合を防ぎます。
スキーマ
// src/lib/server/schema.ts
import { sqliteTable, text, integer } from 'drizzle-orm/sqlite-core';
import { sql } from 'drizzle-orm';
export const auditLog = sqliteTable('audit_log', {
id: text('id').primaryKey(),
actorId: text('actor_id').notNull(),
action: text('action').notNull(), // 'user.rename', 'post.delete' 等
targetId: text('target_id'),
payload: text('payload', { mode: 'json' }),
createdAt: integer('created_at', { mode: 'timestamp_ms' })
.notNull()
.default(sql`(unixepoch() * 1000)`),
}); トランザクション内で atomic 書き込み
// src/lib/server/user.ts
import { client } from './db'; // @libsql/client インスタンス
async function updateUserWithAudit(
userId: string,
newName: string,
actorId: string,
) {
const tx = await client.transaction('write');
try {
await tx.execute({
sql: 'UPDATE users SET name = ? WHERE id = ?',
args: [newName, userId],
});
await tx.execute({
sql: `INSERT INTO audit_log
(id, actor_id, action, target_id, payload, created_at)
VALUES (?, ?, ?, ?, ?, ?)`,
args: [
crypto.randomUUID(),
actorId,
'user.rename',
userId,
JSON.stringify({ newName }),
Date.now(),
],
});
await tx.commit();
} catch (e) {
await tx.rollback();
throw e;
}
}14-2: 楽観ロック(version 列)
複数ユーザーが同じレコードを同時編集すると「最後が勝つ」問題が発生します。version 列を使った楽観ロックで 競合を検出して安全に拒否します。
スキーマ追加
// posts テーブルに version 列を追加
version: integer('version').notNull().default(0), 更新コード(version 一致のもののみ更新)
import { db } from '$lib/server/db';
import { sql } from 'drizzle-orm';
async function updatePostTitle(
id: string,
newTitle: string,
expectedVersion: number,
) {
const result = await db.run(
sql`UPDATE posts
SET title = $${newTitle},
version = version + 1
WHERE id = $${id}
AND version = $${expectedVersion}`
);
if (result.rowsAffected === 0) {
throw new Error(
'競合検出: 他のユーザーが更新しました。最新を取得してリトライしてください。'
);
}
}14-3: ページネーション(カーソル方式 vs OFFSET 方式)
| 方式 | 速度 | 重複・欠落 | 推奨用途 |
|---|---|---|---|
| OFFSET | 大量ページで遅い | データ追加で発生 | 静的コンテンツ・管理画面 |
| カーソル | id インデックスで高速 | 発生しない | SNS フィード・無限スクロール |
カーソル方式の実装(Drizzle ORM)
// src/routes/api/posts/+server.ts
import { db } from '$lib/server/db';
import { posts } from '$lib/server/schema';
import { gt, asc } from 'drizzle-orm';
export async function GET({ url }: { url: URL }) {
const cursor = url.searchParams.get('cursor'); // 最後に取得した id
const rows = await db
.select()
.from(posts)
.where(cursor ? gt(posts.id, cursor) : undefined)
.orderBy(asc(posts.id))
.limit(21); // 1件多く取って次ページ有無判定
const hasNext = rows.length > 20;
const items = hasNext ? rows.slice(0, 20) : rows;
const nextCursor = hasNext ? items[items.length - 1].id : null;
return Response.json({ items, nextCursor });
}14-4: バッチ INSERT のパフォーマンス
| 方式 | 1,000件の目安 | 備考 |
|---|---|---|
| 個別 execute × 1,000 | 数十秒 | 1往復1件、最悪 |
| transaction 内ループ | 数百 ms | 1コミット・クライアント側ループ |
| batch(stmts) | 数百 ms | 1往復で全ステートメント |
| VALUES 多重(100件チャンク) | 100 ms 未満 | 最良。SQLite の変数上限に注意 |
VALUES 多重チャンクの実装
// src/lib/server/bulk.ts
import { client } from './db'; // @libsql/client インスタンス
interface Row {
id: string;
name: string;
}
async function bulkInsert(rows: Row[]) {
const CHUNK = 100; // SQLite のバインド変数上限(999)を考慮
for (let i = 0; i < rows.length; i += CHUNK) {
const slice = rows.slice(i, i + CHUNK);
const placeholders = slice.map(() => '(?, ?)').join(', ');
const args = slice.flatMap((r) => [r.id, r.name]);
await client.execute({
sql: `INSERT INTO users (id, name) VALUES $${placeholders}`,
args,
});
}
} SQLITE_LIMIT_VARIABLE_NUMBER)。
2列×100件 = 200変数なので 100件チャンクは安全です。列数が多い場合はチャンクサイズを調整してください。⚠️ Section 15: 落とし穴とベストプラクティス
⚠️ 1. @libsql/client を +page.svelte で import しない
DB クライアントはサーバー専用です。$lib/server/ 配下に置き、クライアントサイドのバンドルに含めないこと。含めると SvelteKit のビルドエラーになります。
⚠️ 2. PRAGMA foreign_keys を毎接続で ON にする
SQLite の外部キー制約はデフォルトで無効です。接続時に PRAGMA foreign_keys = ON を実行しないと CASCADE DELETE が効かず、孤立レコードが発生します。
⚠️ 3. タイムスタンプは Unix epoch ms (INTEGER) か ISO8601 (TEXT) で統一
混在させると比較・ソートが壊れます。プロジェクト内で1つの方式に統一し、Drizzle の mode: 'timestamp_ms' または mode: 'string' を揃えて設定してください。
⚠️ 4. 大量データの fetch は LIMIT 必須
result.rows は取得した全件をメモリに展開します。上限なしで巨大テーブルを取得すると OOM やタイムアウトが発生します。必ず .limit(N) かカーソルページネーションを使ってください。
⚠️ 5. Drizzle の relations() は db.query.* 専用
通常の db.select() には relations は影響しません。JOIN を書くか db.query.posts.findMany({ with: { author: true } }) の形式を使ってください。
⚠️ 6. マイグレーションは追記オンリー
過去の drizzle/migrations/ 以下の SQL ファイルを書き換えてはいけません。Drizzle は各ファイルのハッシュを記録しており、変更するとマイグレーションが失敗します。修正は必ず新しいマイグレーションファイルとして追加してください。
⚠️ 7. turso db shell で本番を直接叩かない
Turso のブランチ機能でテスト用 DB を作成し、検証してから本番 DB に適用する流れを徹底してください。本番 shell での誤 DELETE は即座にデータ損失につながります。
⚠️ 8. Cloudflare Workers では Embedded Replica 不可
Workers ランタイムはファイルシステムへの永続書き込みをサポートしません。サブミリ秒読み込みが必要なら Node.js サーバー(Vercel / Fly.io など)を使ってください。
⚠️ 9. 大量並行書き込みには向かない
SQLite は単一ライター制約があります(WAL モードでも書き手は1プロセス)。数千 writes/sec を超えるワークロードには Postgres ベースの DB を検討してください。
⚠️ 10. JSON カラムは mode: 'json' を指定
Drizzle の text('payload', { mode: 'json' }) を使うと INSERT/SELECT 時に自動で JSON.stringify / JSON.parse されます。指定しないと文字列のまま保存・取得されます。
⚠️ 11. PRAGMA journal_mode = WAL は libSQL でデフォルト
Turso の libSQL は WAL モードで起動します。明示的に設定する必要はありません。ローカルの SQLite ファイルも同様に Embedded Replica 経由なら WAL が有効です。
⚠️ 12. Auth トークンの再生成は無停止で可能
Turso ダッシュボードで新しいトークンを発行し、環境変数を更新した後に古いトークンを無効化する手順を踏めば、サービスを停止せずにローテーションできます。
⚠️ 13. WHERE 句のインデックスは慎重に
不要なインデックスは INSERT/UPDATE/DELETE のたびにメンテナンスされるため、書き込み速度を低下させます。EXPLAIN QUERY PLAN で実際にインデックスが使われるか確認してから追加してください。
⚠️ 14. returning() は INSERT / UPDATE / DELETE で使える
Drizzle の .returning() を使うと、変更後の行を別途 SELECT しなくても1往復で取得できます。await db.insert(users).values(row).returning() のように書きます。
⚠️ 15. .env を絶対 git に入れない
.gitignore に .env と local-replica.db(Embedded Replica のローカルファイル)を必ず含めてください。誤ってコミットした場合は即座にトークンを失効させ、git filter-repo で履歴から削除してください。
🎯 Section 16: まとめ
✅ Turso が向く用途
- ▸SaaS のテナントごとに DB を分離したいマルチ DB 構成
- ▸ブログ・CMS など低書き込み・高読み込みのワークロード
- ▸Embedded Replica によるエッジでのサブミリ秒読み込み
- ▸SQLite 互換でローカル開発を完全に本番同等にしたいプロジェクト
- ▸個人開発・小規模 SaaS で運用コストをゼロに近づけたいケース
❌ 向かない用途
- ▸数千 writes/sec を超える大量並行書き込み(SQLite 単一ライター制約)
- ▸地理分散書き込み(プライマリは 1 拠点に固定)
- ▸フルテキスト検索を本格運用したいケース(FTS5 はあるが Postgres tsvector に劣る)
- ▸PostgreSQL 固有の型や拡張(UUID ネイティブ・ARRAY・JSONB)が必須な要件
学習ロードマップ — Turso × SvelteKit 習得の順番
- 1
Step 1
libSQL クライアント直接操作
@libsql/clientのexecute/batch/transactionで生 SQL を書ける段階。SQLite の型・PRAGMA・WAL に慣れる。ここを飛ばすと ORM の裏側が見えなくなる。 - 2
Step 2
Drizzle ORM 導入
スキーマ定義 →
drizzle-kit pushでテーブル作成 →db.select / insert / update / deleteを型安全に書けるようにする。relations()とdb.query.*の使い分けを理解する。 - 3
Step 3
SvelteKit 統合(load 関数 + form actions)
+page.server.tsのload関数で DB から取得してPageDataに渡す。+page.server.tsのactionsでフォーム送信を受けて INSERT / UPDATE する。エラーハンドリングとfail()の使い方まで。 - 4
Step 4
Embedded Replica で読み込みを高速化
Node.js サーバー(Vercel / Fly.io など)で
syncUrl+authTokenを設定してローカルレプリカを同期させる。syncIntervalを調整してレイテンシと鮮度のバランスを取る。Cloudflare Workers では使えないことを理解する。 - 5
Step 5
本番デプロイ + 運用パターン
Cloudflare Pages / Vercel へのデプロイ、環境変数の管理、マイグレーション戦略(
drizzle-kit migrate)、監査ログ・楽観ロック・バッチ INSERT の実装。ブランチ DB でステージング検証してから本番適用するフローを確立する。
💡 まとめ
Turso は「SQLite の開発体験 × クラウドのスケール × エッジのレイテンシ」を1つのサービスで実現する、個人開発から小規模 SaaS に最適なデータベースです。
SvelteKit との組み合わせでは、+page.server.ts の load 関数と form actions が DB アクセスの自然な場所となり、
Drizzle ORM による型安全なクエリが開発速度を大幅に向上させます。
まずは libSQL クライアントの直接操作から始め、Drizzle ORM → SvelteKit 統合 → Embedded Replica → 本番デプロイの順に積み上げていくことで、
SQLite の単純さを保ちながらプロダクションレディな構成を最短で手に入れることができます。