node:sqlite と DO SqlStorage を1つのIFに畳む — コア無改修でローカル/エッジ両対応にする設計

Ember 編集部 ・ 2026-07-15 ・ 約6分 ・ web-backend

Ember のサーバコードは、 同じドメインロジックのまま 2つの環境で動きます。 ローカル開発: Node 22 + node:sqlite ( tsx watch で即時リロード、シードデータ、E2E テスト) 本番: Cloudflare Workers + Durable Object の SqlStorage(月額0円構成) 環境ごとの分岐は起動エントリの2ファイルだけで、記事・タスク・ポイント台帳などのコアロジックには Workers 由来のコードが1行もありません。これを成立させているのが、 5メソッドだけの同期 DB ドライバ・インターフェース です。 この記事では、そのインターフェース設計(なぜ「同期」に揃えることが決定的に重要か)、Hono による HTTP 層の一本化、マイグレーションツールなしのスキーマ管理、BLOB とトランザクションの差異吸収、そして「実 DB + 実サーバで流す E2E」と本番へのデータ移行まで、実装をそのまま貼りながら解説します。 「ローカルの開発体験は捨てたくない、でも本番はエッジで無料で動かしたい」という人に、一番効く型だと思います。

ドライバ IF は5メソッドで足りる コアロジックはこの5つ(実際は薄いヘルパ経由)しか使いません。起動時に環境側が実体を注入します。 Node 側は node:sqlite を、Workers 側は DO の SqlStorage をこの IF に包むだけです。どちらも数十行で書けます。 「同期」に揃えることが全て この設計で一番重要な判断は、 IF を同期にしたこと です。 node:sqlite (や better sqlite3)も、DO の sql.exec() も、どちらも同期 API です。ここで安易に async get(...) にしてしまうと、ドメインロジック全体に await が伝播し、次のような性質が全部壊れます。 transaction(() = { ... }) の中身が同期関数で済む(トランザクション中に イベントループへ制御を返さないので、並行リクエストによる交錯を考えなくていい) 「残高を読んで、検査して、書く」が素直に書ける テストコードから直接ドメイン関数を呼べる 「DB 層は async が当たり前」という感覚で IF を切ると、せっかく両環境が同期 DB を持っているのに、その利点を自分から捨てることになります。 抽象化は『両方の実体が共有する最も強い性質』に合わせて切る — 今回なら同期性です。 HTTP 層は Hono 一本化 Hono はランタイム非依存なので、同じ buildApp() を両方から使えます。 Express を選んでいたらこの一本化はできませんでした。「Workers でも動かすかもしれない」可能性が少しでもあるなら、HTTP 層は最初から fetch ベースのフレームワークにしておくのが安全です。 スキーマ管理: マイグレーションツールなし スキーマは CREATE TABLE IF NOT EXISTS の羅列を起動毎に流し、カラム追加は「失敗したら無視する ALTER」で吸収しています。 行儀の良いマイグレーション管理(バージョンテーブル、up/down)と比べれば原始的ですが、 両環境で同じコードパスが走る こと、DO のような「デプロイ時に任意コマンドを叩けない」環境でも自己完結することが利点です。破壊的変更(カラム削除・型変更)を伴わない限り、この方式で長く保ちます。 差異はドライバ層で2点だけ吸収する 環境差はきれいに2箇所へ閉じ込められました。 1. BLOB の型。 DO SqlStorage のバインド型は null / number / string / ArrayBuffer のみで、 Uint8Array を受けません。 2. トランザクションの流儀。 Node 側は BEGIN / COMMIT を発行、DO 側は transactionSync() 。ただし DO の transactionSync は ネスト不可 なので、「すでにトランザクション中なら外側に合流する」処理を挟みます。 内側のエラーは外側まで伝播するので、「合流」してもロールバックの単位は壊れません(ネストした部分トランザクションが必要になったらこの手は使えませんが、そうなる前に設計を疑うべきです)。 テストとシードは「実物」で モックは使っていません。E2E テストは実サーバ + 実 SQLite に対して HTTP で流し、シードは「ユーザー・記事・過去4週間の経済活動」を実際のドメイン関数を呼んで投入します。 シードが ドメイン関数経由 なので、シード自体が回帰テストを兼ねる(不変条件を破るデータがそもそも作れない) ローカルの見た目が常に「生きたサービス」になり、UI の確認が早い DB が軽い SQLite だからこそ、テストごとに作り直す運用が現実的になります。 本番へのデータ移行 ローカルで作ったデータを本番(DO の SQLite)へ持ち込む必要がありました。DO のストレージには外から直接触れないので、 全テーブルを JSON 行データとして dump → 管理 API で流し込む 方式にしています。 インポート側は許可テーブルの一覧を持ち、管理者認可のもとで INSERT OR REPLACE するだけの素朴な実装です。「エクスポートできない DB は運用できない」— どんな小さなサービスでも、この経路だけは最初に作っておくべきでした(実際、本番移行当日に一発で通ったのはこれのおかげです)。 トレードオフを引き受ける この設計が捨てたもの: ORM・クエリビルダ(生 SQL のみ)、複数書き込みノード(単一 DO 前提)、自動マイグレーション。引き換えに得たもの: 環境差ゼロのコア、同期トランザクション、月額0円の本番、そして「ローカルで再現しないバグ」がほぼ存在しない開発体験。 次回は認証編 — IDaaS を使わず Google / X の OAuth(PKCE)を自前実装し、メールアドレスを平文で持たない話をします。