はじめに
アプリ開発をしていると、「複数のSQL文をセットで実行したい」場面に必ず遇遇します。たとえば、デフォルト項目を切り替えるとき——既存のデフォルトを解除してから、新しいレコードをデフォルトに設定する——この2つの操作は必ずセットで成功・失敗する必要があります。
こういった場面で使うのがSQLトランザクションです。
しかしTurso(クラウドSQLイトサービス)のHTTP APIを使っている場合、db.execute('BEGIN') を呼ぶとInternal Server Errorが発生します。本記事ではその理由と、正しい解決策である db.transaction() の使い方を解説します。
SQLトランザクションとは何か
トランザクションが必要な場面
データベース操作では「複数の操作が全部成功するか、全部失敗するかのどちらかでなければならない」という要件がよく出てきます。
代表的な例:
- 銀行振込:送金元の残高を減らす + 送金先の残高を増やす
- デフォルト切り替え:既存のデフォルトフラグをOFF + 新しいレコードのフラグをON
- 在庫管理:在庫を減らす + 注文レコードを作成する
どれも「片方だけ成功する」状態になるとデータが壊れます。これを防ぐ仕組みがトランザクションです。
BEGIN / COMMIT / ROLLBACK の役割
SQLのトランザクション制御には3つの命令があります。
命令 | 役割 |
|---|---|
| トランザクション開始。この後の操作は他確定状態になる |
| 全操作を確定。データベースに永続的に反映される |
| 全操作を取り消し。BEGINの直前の状態に戻る |
BEGIN;
UPDATE user_settings SET is_default = 0 WHERE user_id = 'u1' AND is_default = 1;
UPDATE user_settings SET is_default = 1 WHERE id = 'record-abc';
COMMIT;途中でエラーが発生した場合:
BEGIN;
UPDATE user_settings SET is_default = 0 WHERE user_id = 'u1' AND is_default = 1;
-- 成功
UPDATE user_settings SET is_default = 1 WHERE id = 'record-abc';
-- エラー発生!
ROLLBACK; -- BEGINより前の状態に戻る(1つ目のUPDATEも取り消される)自動コミットとの違い
トランザクションを使わない通常の INSERT / UPDATE は自動コミットモードで動作します。実行と同時にデータベースに確定されるため、失敗しても他の操作には影響しません。
自動コミット(トランザクションなし):
UPDATE ... → 即確定
UPDATE ... → 即確定(1つ目とは独立)
トランザクションあり:
BEGIN
UPDATE ... → 他確定(未反映)
UPDATE ... → 他確定(未反映)
COMMIT → 2つまとめて確定単純な1操作であれば自動コミットで十分です。複数の操作をセットで扱いたい場合にのみトランザクションが必要になります。
具体的なコード例で理解する
トランザクションなし(問題が起きるケース)
// ❌ トランザクションなし
// 1つ目が成功して2つ目が失敗した場合、デフォルトが0件になる
await db.execute({
sql: 'UPDATE user_settings SET is_default = 0 WHERE user_id = ? AND is_default = 1',
args: [userId],
})
await db.execute({
sql: 'UPDATE user_settings SET is_default = 1 WHERE id = ?',
args: [recordId],
}) // ここでエラーが起きると、is_default = 1 のレコードが存在しなくなるトランザクションあり(安全なケース)
// ✅ トランザクションあり(SQLite / PostgreSQL など)
await db.execute('BEGIN')
try {
await db.execute({
sql: 'UPDATE user_settings SET is_default = 0 WHERE user_id = ? AND is_default = 1',
args: [userId],
})
await db.execute({
sql: 'UPDATE user_settings SET is_default = 1 WHERE id = ?',
args: [recordId],
})
await db.execute('COMMIT')
} catch (error) {
await db.execute('ROLLBACK') // エラー時は全取り消し
throw error
}これで「2つのUPDATEが両方成功するか、両方失敗するか」が保証されます。
TursoのHTTP APIでBEGINが使えない理由
TursoのHTTPモードの仕組み
Tursoはクラウド上のSQLiteサービスで、@libsql/client というTypeScript SDKを通じてHTTPでアクセスします。
ここで重要なのが、HTTPはステートレスなプロトコルであるという点です。
通常のSQLite(ローカル接続):
接続 ───────────────────── 持続的な1本のコネクション
BEGIN → 操作A → 操作B → COMMIT
TursoのHTTP API:
execute('BEGIN') → HTTPリクエスト1本(独立)
execute('UPDATE') → HTTPリクエスト1本(独立)← BEGINとの関連なし
execute('COMMIT') → HTTPリクエスト1本(独立)ローカルのSQLiteでは1つの持続的なコネクション上でトランザクションが管理されます。しかしTursoのHTTP APIでは、db.execute() を呼ぶたびに独立したHTTPリクエストが発行されます。
なぜ db.execute('BEGIN') が失敗するのか
BEGIN を実行した後、次の execute() は別のHTTPリクエストとして送信されます。サーバー側はそれが同じトランザクションの続きであることを認識できないため、エラーが発生します。
// ❌ TursoのHTTP APIでは動作しない
await db.execute('BEGIN') // HTTPリクエスト① → トランザクション開始
await db.execute({ sql: '...' }) // HTTPリクエスト② → ①とは無関係な独立リクエスト
await db.execute('COMMIT') // HTTPリクエスト③ → アクティブなトランザクションなし → エラー!結果として Internal server error が返ってきます。
解決策:db.transaction() を使う
db.transaction() の仕組み
db.transaction() はTursoが正式にサポートするインタラクティブトランザクションAPIです。トランザクションオブジェクトを通じて操作を実行することで、複数のHTTPリクエストをまたいでも同一トランザクション内で処理できます。
db.batch() との違いは、操作の途中でSELECT結果を取得して条件分岐できる点です。「件数を確認してかINSERTする」「対象が存在するか確認してかUPDATEする」といった処理に適しています。
実際のコード比較(before / after)
Before(動作しない):
// ❌ HTTP APIでは失敗する
let transactionStarted = false
try {
await db.execute('BEGIN')
transactionStarted = true
await db.execute({
sql: 'UPDATE user_settings SET is_default = 0 WHERE user_id = ? AND is_default = 1',
args: [userId],
})
await db.execute({
sql: 'UPDATE user_settings SET is_default = 1 WHERE id = ?',
args: [recordId],
})
await db.execute('COMMIT')
} catch (error) {
if (transactionStarted) {
await db.execute('ROLLBACK')
}
throw error
}After(db.transaction() を使う):
// ✅ TursoのHTTP APIで正しく動作する
const tx = await db.transaction('write')
try {
const result = await tx.execute({
sql: 'SELECT item_id FROM user_settings WHERE id = ? AND user_id = ?',
args: [recordId, userId],
})
if (result.rows.length === 0) {
await tx.rollback()
return
}
await tx.execute({
sql: 'UPDATE user_settings SET is_default = 0 WHERE user_id = ? AND is_default = 1',
args: [userId],
})
await tx.execute({
sql: 'UPDATE user_settings SET is_default = 1 WHERE id = ?',
args: [recordId],
})
await tx.commit()
} catch (error) {
await tx.rollback()
throw error
}db.transaction() のトランザクションモード
モード | 用途 |
|---|---|
| 読み書きを伴う操作(SELECT + UPDATE / INSERT / DELETE) |
| 読み取りのみ(SELECT) |
| 読み取りから開始し、必要に応じて書き込みに移行 |
UPDATE / INSERT を含む操作には 'write' を指定します。
db.batch() との使い分け
|
| |
|---|---|---|
向いている用途 | 事前に確定した複数SQL文をまとめて実行 | 途中でSELECT結果を見て条件分岐が必要な処理 |
柔軟性 | 低い(SQL文を事前に全部決める必要がある) | 高い(実行中に条件分岐・エラーチェックが可能) |
コードの複雑さ | シンプル | やや多い(commit/rollbackの管理が必要) |
「事前にSQL文が全部決まっている」なら db.batch()、「途中で結果を確認しながら進める」なら db.transaction() が適しています。
まとめ
項目 | 内容 |
|---|---|
トランザクションとは | 複数のSQL操作を「全部成功か全部失敗か」で扱う仕組み |
BEGIN / COMMIT / ROLLBACK | SQLのトランザクション制御命令。ローカルDBでは正常に動作する |
TursoでBEGINが使えない理由 | HTTP APIはステートレスなため、execute()ごとに独立したリクエストになりトランザクション状態を維持できない |
解決策 |
|
db.batch() との違い | batch()はSQL文を事前確定して一括送信。transaction()は実行中に条件分岐できる柔軟な方式 |
Tursoに限らず、HTTP経由でデータベースにアクセスするサービスでは同様の制約が発生することがあります。「なぜ BEGIN が使えないのか」を理解した上で、db.transaction() や db.batch() を用途に応じて使い分けましょう。