さとまたwiki

🗄️ Turso × SvelteKit 完全ソースコード徹底解説

Turso(libSQL/SQLite互換のエッジDB)をSvelteKitで使い倒すための決定版リファレンス。アカウント作成から CRUD、Drizzle ORM、+page.server.ts でのロード関数、form actions、Embedded Replica、Cloudflare Pages デプロイ、監査ログ・楽観ロックまで全て公式ドキュメント基軸で TypeScript ソースコードを徹底解説します。

🧡 SvelteKit Turso libSQL Drizzle ORM TypeScript

🗃️ Section 1: Tursoとは何か

Turso は libSQL(SQLite のオープンフォーク)を基盤とするエッジ対応のクラウドデータベースサービスです。 ChiselStrike 社(現 Turso 社)が SQLite をサーバーサイド・分散環境で使えるように拡張した libSQL に、クラウドホスティング・レプリケーション・エッジ実行能力を重ねたのが Turso の本質です。

なぜ SQLite(libSQL)か

SQLite は「単一ファイルで完結する」「ゼロ運用(サーバープロセス不要)」「ACID トランザクション」という3つの特性を持つ唯一の組み込みデータベースです。 Postgres や MySQL のように常駐デーモンを立てる必要がなく、開発環境でもファイルを1つ置くだけで本番同等の SQL が動きます。 Turso はこの超軽量な特性を保ちながら、クラウド上での HTTP/WebSocket プロトコル越しアクセスと複数リージョンへのレプリケーションを実現しています。

💡 libSQL と SQLite の違い libSQL は SQLite の互換スーパーセット。既存の SQLite SQL 構文は全て動作し、さらに HTTP クライアントアクセス・複数ライタ(WAL2)・暗号化拡張が追加されています。 逆に Postgres 互換ではありません。JSON 関数・FTS5・R*Tree は使えますが、PostgreSQL 固有の型(UUID ネイティブ、ARRAY 型など)は使えない点に注意が必要です。

競合サービス比較

サービス種別プロトコルエッジ性能無料枠特徴
TursolibSQL(SQLite 派生)HTTP / WebSocket◎ エッジ優先設計5 GB / 500 M 行読みEmbedded Replica・マルチDB・ブランチ
Cloudflare D1SQLite 互換Workers バインディング◎ Cloudflare 専用5 GB / 25 B 行読みWorkers 外からは使いにくい
NeonPostgres 互換PostgreSQL Wire△ コールドスタートあり0.5 GB / 191 h コンピュートブランチ機能・Serverless Driver
PlanetScaleMySQL 互換(Vitess)MySQL Wire / HTTP○ グローバルルーティング無料プラン廃止(2024)スキーマブランチ・ゼロダウンタイム DDL
SupabasePostgresPostgreSQL Wire / REST△ 専用リージョン固定500 MB / 2 プロジェクトAuth・Storage・Edge Functions 一体型
Firebase RTDBNoSQL(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$05 GB500 M10 M100
Developer$4.999 GB (+$0.75/GB)2.5 B25 M無制限
Scaler$24.92(年払い)24 GB (+$0.50/GB)100 B100 M無制限
Pro$416.58(年払い)50 GB (+$0.45/GB)250 B250 M無制限

出典: https://turso.tech/pricing(2025年5月時点)

⚠️ 個人開発での選択指針 趣味・小規模 SaaS なら Free プランで十分(100 DB / 5 GB / 500 M 行読み)。月間アクティブユーザーが数千人を超えてきたら Developer ($4.99) へ移行し、チーム開発・監査ログが必要になったら Scaler を検討する。

⚙️ 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
⚠️ Windows ネイティブの注意 Turso CLI の Windows サポートは WSL 経由が推奨です。Windows ネイティブの PowerShell スクリプトは動作しますが、一部のコマンドで挙動の差異が報告されています。 開発環境が Windows の場合は WSL2 + Ubuntu 上で作業することを強く推奨します。

サインアップ / ログイン

初回はブラウザが開き、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-ormdrizzle-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/server/ 外への DB 配置は危険 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;
💡 SvelteKit のサーバー専用モジュール規約 SvelteKit では 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
⚠️ .env を git add した場合の対処 誤ってコミットした場合は 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/clientcreateClient() から始まります。 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() — 全オプション一覧

オプション必須説明
urlstring必須DB の接続先 URL(スキーマは下記参照)
authTokenstring?任意Turso の JWT 認証トークン(libsql:// 接続時は実質必須)
syncUrlstring?任意Embedded Replica 使用時のリモート DB URL(url はローカル file: を指定)
syncIntervalnumber?任意自動同期間隔(秒)。Embedded Replica 専用。省略時は手動 sync() のみ
encryptionKeystring?任意ローカル SQLite ファイルの暗号化キー(libSQL 拡張機能が必要)
tlsboolean?任意TLS を強制するか否か(デフォルト: true。ローカル sqld 開発で false に設定可)
intMode'number' | 'bigint' | 'string'任意INTEGER 値の JS 表現方法(デフォルト: 'number')
concurrencynumber?任意同時リクエスト数の上限(デフォルト: 20)

intMode の使い分け

モードJS の型安全範囲用途
'number'number2^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'
Cloudflare Pages x SvelteKit の注意: Cloudflare Pages は Workers ランタイムで動くため @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 オブジェクトの構造

プロパティ説明
rowsRow[]クエリ結果の行配列。INSERT/UPDATE/DELETE は空配列
columnsstring[]SELECT した列名の順序付き配列
columnTypesstring[]各列の宣言型('INTEGER', 'TEXT' 等)。スキーマ検査に使用
rowsAffectednumberINSERT/UPDATE/DELETE で変更された行数
lastInsertRowidbigint | undefined最後に挿入された行の rowid。INSERT 以外は undefined
toJSON()objectJSON シリアライズ可能な形式に変換(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],
});
なぜ安全か: パラメータはサーバー側でリテラル値として扱われ、SQL 構文の一部として解析されません。

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 つのストレージクラス

ストレージクラス格納される値バイト数
NULLNULL 値0
INTEGER符号付き整数(±2^63 まで)値の大きさにより 0, 1, 2, 3, 4, 6, 8 バイト
REAL8 バイト IEEE 754 浮動小数点数8
TEXTUTF-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 TEXTUnix 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 KEYrowid と同じ値。自動増分(削除行 id の再利用あり)単一サーバー・小規模・最速 INSERT
AUTOINCREMENTINTEGER PRIMARY KEY AUTOINCREMENT削除行の id を再利用しない。sqlite_sequence テーブルを消費id の再利用を絶対に防ぎたいとき
UUID v4TEXT PRIMARY KEY128bit ランダム。衝突確率は無視できるが、ランダム挿入でインデックス断片化分散生成・外部 API との連携
ULIDTEXT 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 方式の比較

方式実装方法型安全自動生成推奨度
手書き SQLturso db shell < migrate.sqlなしなし小規模・学習用
Drizzle Kitdrizzle-kit generate && migrateありあり推奨(個人〜中規模)
Atlasatlas migrate applyありあり大規模チーム向け

本番マイグレーション運用フロー(Turso ブランチ DB 活用)

  1. 1

    Step 1: ブランチ DB 作成

    本番 DB をブランチとして複製する

    turso db create staging-branch --from-db production-db

    本番 DB のスナップショットから新しい独立 DB を作成。本番に影響ゼロ。

  2. 2

    Step 2: マイグレーションのテスト適用

    ブランチ DB にマイグレーションを適用

    TURSO_DATABASE_URL=$(turso db show staging-branch --url) npx drizzle-kit migrate

    エラーが発生しても本番には一切影響しない。

  3. 3

    Step 3: 動作確認

    ブランチ DB を指す環境でアプリを動作確認

    スキーマ変更後のアプリが正常に動作するか、テストスクリプトやステージング環境で確認する。

  4. 4

    Step 4: 本番に適用

    問題なければ本番 DB に同じマイグレーションを適用

    TURSO_DATABASE_URL=$(turso db show production-db --url) npx drizzle-kit migrate
  5. 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 を選ぶか

観点DrizzlePrisma
型安全性✅ 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 型用途
textmode: 'json'unknownJSON オブジェクトを文字列として格納
integermode: 'timestamp_ms'Dateミリ秒 Unix タイム(JS Date と自動変換)
integermode: 'timestamp'Date秒 Unix タイム(小数なし)
integermode: 'boolean'boolean0/1 を true/false に自動変換
blobmode: '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. 1

    スキーマ変更後

    bunx drizzle-kit generate

    drizzle/0000_xxx.sql を生成。schema.ts との差分を自動検出して ALTER TABLE / CREATE TABLE を生成する。

  2. 2

    開発・本番環境に適用

    bunx drizzle-kit migrate

    未適用のマイグレーション SQL を Turso DB に実行する。__drizzle_migrations テーブルで適用済みを追跡。

  3. 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. 1

    Streaming load

    重い処理は await しない

    Promise を return すると SvelteKit がストリーミングで解決します。return { fast: await fastQuery(), slow: slowQuery() } と書くと fast は即座にレンダリングし、slow は解決次第差し込まれます。

  2. 2

    parent() で親データを取得

    レイアウトの load 結果を継承

    const { user } = await parent();+layout.server.ts が返したデータを取得できます。認証ユーザー情報などを共通で使い回すのに最適です。

  3. 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. 1

    Progressive Enhancement

    use:enhance の動作

    JavaScript なし環境でも通常の HTML フォームとして動作します。JS が有効な場合は fetch でサブミット → ページリロードなしでレスポンスを処理します。

  2. 2

    バリデーションエラー時

    fail() でフォーム値を保持する理由

    fail(400, { title, body, error } ) の第2引数が form プロパティとして返ってきます。これにより入力途中の値が消えず、ユーザーが修正しやすくなります。

  3. 3

    成功時のリダイレクト

    redirect() を throw する理由

    throw redirect(303, url) は SvelteKit 内部での型安全な制御フローです。通常の return と区別するため throw を使います。303 は POST 後の GET リダイレクト(PRG パターン)に対応します。

  4. 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));
}
💡 ファイルサイズの目安 Turso の BLOB カラムに直接保存できるのは 1MB 以下の小サイズに留めることを推奨します。サムネイルやアバター画像(圧縮済み)は直接保存可。動画・高解像度画像は R2 / S3 / Cloudflare Images に委譲してください。

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〜150msCloudflare Workers 含む全環境
Embedded Replica0.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. 1

    .gitignore

    local-replica.db をコミットしない

    レプリカファイルはリモートから自動生成されるため git 管理に含める必要はありません。.gitignorelocal-replica.db を追加してください。

  2. 2

    ディスク容量

    リモート DB と同サイズのディスクが必要

    Embedded Replica はリモート DB のフルコピーをローカルに持ちます。100MB の DB なら 100MB のディスクが必要です。VPS / Fly.io / Railway などのディスク付きプラットフォームで使用してください。

  3. 3

    Cloudflare Workers / Pages Functions

    ローカルディスクが使えないため不可

    Cloudflare Workers はステートレスなエフェメラル環境のためローカルファイルを永続化できません。Cloudflare 環境ではリモートのみ構成を使い、Cache API や KV を読み取りキャッシュとして併用することを検討してください。

💡 Embedded Replica が最も効果的なシナリオ 読み取りが書き込みの 10 倍以上あるブログ・ドキュメントサイト・ダッシュボード系アプリで特に効果的です。VPS(Fly.io / Railway / Hetzner)にデプロイして使うと、Vercel の Edge Functions より高いスループットを低コストで実現できます。

☁️ 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 ダッシュボード — 環境変数設定

  1. Settings → Environment variables を開く
  2. Production / Preview それぞれに TURSO_DATABASE_URLTURSO_AUTH_TOKEN を追加
  3. 認証トークンは必ず Encrypted で登録(プレーンテキスト禁止)

4. ビルド設定

項目
Build commandbun run build
Build output.svelte-kit/cloudflare
Root directory/
Node.js version20.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/clientNode.js / Bun / DenoHTTP/2 + WebSocket
@libsql/client/webCloudflare Workers / Edgefetch のみ

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,
});
⚠️ 注意: Cloudflare Pages では Embedded Replica 不可 Cloudflare Workers ランタイムはファイルシステムへの永続書き込みをサポートしないため、ローカルに SQLite ファイルを保持する Embedded Replica は動作しません。 読み込み高速化(サブミリ秒レイテンシ)が必要な場合は、Vercel / Fly.io などの Node.js サーバー環境を選んでください。

🛠️ 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 内ループ数百 ms1コミット・クライアント側ループ
batch(stmts)数百 ms1往復で全ステートメント
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 の変数上限 SQLite はデフォルトで 1 クエリあたり最大 999 個のバインド変数を受け付けます(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.envlocal-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. 1

    Step 1

    libSQL クライアント直接操作

    @libsql/clientexecute / batch / transaction で生 SQL を書ける段階。SQLite の型・PRAGMA・WAL に慣れる。ここを飛ばすと ORM の裏側が見えなくなる。

  2. 2

    Step 2

    Drizzle ORM 導入

    スキーマ定義 → drizzle-kit push でテーブル作成 → db.select / insert / update / delete を型安全に書けるようにする。relations()db.query.* の使い分けを理解する。

  3. 3

    Step 3

    SvelteKit 統合(load 関数 + form actions)

    +page.server.tsload 関数で DB から取得して PageData に渡す。+page.server.tsactions でフォーム送信を受けて INSERT / UPDATE する。エラーハンドリングと fail() の使い方まで。

  4. 4

    Step 4

    Embedded Replica で読み込みを高速化

    Node.js サーバー(Vercel / Fly.io など)で syncUrl + authToken を設定してローカルレプリカを同期させる。syncInterval を調整してレイテンシと鮮度のバランスを取る。Cloudflare Workers では使えないことを理解する。

  5. 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 の単純さを保ちながらプロダクションレディな構成を最短で手に入れることができます。