【第30回】診断ツールと同じ仕組みを自分で作る|D1(嫁子が教えるTS講座)

今回のゴール

前回(Cloudflare Workers)で作った関数に、データの保存を足します。全体の流れは講座の目次へ。


嫁子: D1はCloudflareのデータベースで、中身は第25回でやったSQLiteなの。

だから前に覚えたSQLがそのまま使えるよ。新しく覚えることは、実はそんなに多くないからね。


1. つなぎ方は「設定に書く」

Workersでは、データベースの接続情報をコードに書きません。設定ファイルに「これを使う」と書くと、env から触れるようになります。

wrangler.jsonc に足します。

{
    "name": "my-worker",
    "main": "src/index.ts",
    "compatibility_date": "2026-09-11",
    "d1_databases": [
        {
            "binding": "DB",
            "database_name": "my-app-db",
            "database_id": "local-placeholder"
        }
    ]
}

binding に書いた名前が、そのままコードでの呼び名になります。ここでは DB なので env.DB です。

この方式をバインディングと呼びます。第23回で .env.local に接続情報を書いたのとは考え方が違って、接続文字列を持ち歩かずに済みます。

嫁子: 鍵を書かなくていいのは、地味だけど大きいよ。

第23回でさんざん「漏らすな」って言ったでしょ。そもそも書く場所が無ければ、漏らしようがないからね。


2. 設定が、そのまま型になる

ここがTypeScriptの講座として面白いところです。次を実行します。

npm run cf-typegen

すると型定義ファイルが作られ、中にこう書かれます。

interface __BaseEnv_Env {
    DB: D1Database;
}

設定ファイルに DB と書いたから、env.DB が D1Database 型になったということです。手で型を書いていません。

第26回のPrismaでは「スキーマから型が作られる」と書きました。ここでは設定から型が作られます。仕組みは違いますが、1か所書けば型がついてくるという発想は同じです。

設定を変えたら、もう一度実行してください。バインディングを足したのに env に出てこないときは、たいていこれを忘れています。


3. テーブルを作る

SQLをファイルに書きます。schema.sql を作ってください。

CREATE TABLE IF NOT EXISTS items (
  id        INTEGER PRIMARY KEY AUTOINCREMENT,
  name      TEXT    NOT NULL,
  done      INTEGER NOT NULL DEFAULT 0,
  createdAt TEXT    NOT NULL DEFAULT CURRENT_TIMESTAMP
);

第25回で読み方を覚えた CREATE TABLE です。今度は自分で書きます。

流し込むコマンドはこれです。

npx wrangler d1 execute my-app-db --local --file=./schema.sql

--local が付いているのが重要です。これを付けている間は手元のファイルに対して実行され、Cloudflareのアカウントは要りません。前回と同じで、試すだけなら登録なしで進みます。

嫁子: 本番のデータベースを作るときは --local を外すの。外した瞬間に本物へ行くから、そこだけ気をつけてね。

第25回で言った WHERE の付け忘れと同じで、1語の差で対象が変わるやつだよ。


4. 読み書きする

src/index.ts から使います。

export default {
    async fetch(request, env, ctx): Promise<Response> {
        const url = new URL(request.url);

        if (url.pathname === "/api/items" && request.method === "GET") {
            const { results } = await env.DB.prepare(
                "SELECT id, name, done FROM items ORDER BY id DESC"
            ).all();

            return Response.json(results);
        }

        if (url.pathname === "/api/items" && request.method === "POST") {
            const body = await request.json<{ name?: unknown }>();
            const name = String(body.name ?? "").trim();

            if (name === "") {
                return Response.json({ error: "名前が空です" }, { status: 400 });
            }

            const item = await env.DB.prepare(
                "INSERT INTO items (name) VALUES (?) RETURNING id, name, done"
            )
                .bind(name)
                .first();

            return Response.json(item, { status: 201 });
        }

        return new Response("Not Found", { status: 404 });
    },
} satisfies ExportedHandler<Env>;

読み方は3つだけです。

書き方 意味
.prepare(SQL) SQLを用意する
.bind(値) ? の場所に値を入れる
.all() / .first() / .run() 全部取る/1件取る/実行だけ

入力チェックは第24回と同じようにサーバー側で書いています。置き場所がWorkersに変わっても、第23回の原則は変わりません。

? を必ず使う

いちばん大事なのはここです。値を文字列として直接つなげないでください。

// ✕ 絶対にやらない
`INSERT INTO items (name) VALUES ('${name}')`

// ○ ? と bind を使う
"INSERT INTO items (name) VALUES (?)"

上のように書くと、入力した文字がSQLの一部として解釈されてしまいます。名前欄に細工した文字を入れるだけで、テーブルを消すこともできてしまう、という有名な攻撃につながります。

嫁子: AIが書いたSQLを見るときの検査項目、もう1つ追加ね。

第25回で「UPDATE と DELETE に WHERE があるか」って言ったでしょ。それに「値が ? になっているか」を足して2つ。この2つだけ見れば、大きな事故はだいたい防げるよ。


5. 動かして確かめる

起動します。

npm run dev

第24回でやったように、窓口だけ先に確認します。実際に試すと、こう返ってきました。

POST {"name":"牛乳"} → 201 {"id":1,"name":"牛乳","done":0}
POST {"name":"卵"}   → 201 {"id":2,"name":"卵","done":0}
POST {"name":"   "}  → 400 {"error":"名前が空です"}
GET  /api/items      → 200 [{"id":2,...},{"id":1,...}]

wrangler dev を止めて起動し直しても、データは残っています。手元のファイルに保存されているためです。

done が 0 なのはなぜか

気づいた方もいると思います。done が false ではなく 0 で返っています。

SQLiteには真偽値の型がなく、0 と 1 で表すためです。第25回で型の対応表を出しましたが、実際に触ると、ここで差が出ます。

そのまま画面に渡すと、第6回で見た落とし穴を踏みます。0 は条件判定で false 扱いなので、たまたま動いてしまうこともありますが、done === false のような比較は成立しません。

受け取った側で変換しておくのが安全です。

const items = results.map((row) => ({
    ...row,
    done: row.done === 1,
}));

嫁子: 第26回のPrismaは、これを勝手にやってくれてたの。false で返ってきたでしょ。

D1を直接触るなら、境界で自分で直す。どっちが良いという話じゃなくて、やってくれる道具を使うか、自分でやるかの違いだね。


6. Prismaとの違い

第26回と並べると、選び方がはっきりします。

Prisma(第26回) D1を直接
SQL ほぼ書かない 自分で書く
型 スキーマから生成 設定から生成(バインディングのみ)
取得した行の型 列ごとに付く 自分で指定する
真偽値・日付 変換してくれる 自分で変換する
導入 パッケージと設定が要る Workersに最初から入っている

小さいものほどD1を直接触るほうが速く、大きくなるほどPrismaの型が効いてきます。

このブログに診断ツールを1つ足す、くらいの規模なら直接で十分です。逆に画面が10枚あるアプリなら、第26回で書いた「列を足すと型も更新される」の価値が大きくなります。


つまずきポイント

database_id は、本番用を作ると実際の値が入ります。手元で試すだけなら仮の文字列で動きますが、公開するときは npx wrangler d1 create で作った値に置き換えます(ここでアカウントが必要です)。

--local と本番は別のデータベースです。手元で入れたデータは本番には行きません。逆も同じです。

取得した行の型は自動では付きません。.all() の結果は素のままなので、使う側で形を指定するか、上のように変換してから渡してください。


今回のまとめ


次にやること

次回が最終回で、ここまでで学んだものを実際にこのブログへ組み込みます。静的なサイトはそのままに、小さなツールを1つ足す、という形です。

今回の宿題:done を 1 に更新する窓口(PUT か PATCH)を足してみてください。WHERE id = ? を忘れないことが、そのまま今日の復習になります。

嫁子: 第6部に入ってから、新しく覚えることが減ってると思わない?

前に覚えたものが、置き場所を変えて出てきてるだけだからね。それが分かるようになったのが、この講座で得たものだと思うよ。