自社サイトの検証記事(公開時点で 22 本。Lab の記事は 2026-10-01 現在 56 本)の末尾に「この検証は役に立ちましたか?」のボタンを付けました。Cloudflare の外の SaaS も認証も足さず、Cloudflare Pages Functions+D1(無料枠)だけで、保存するのは slug と件数の 2 列だけ。実装から本番の E2E まで同日。
小さくできたのは、作り込んだからではなく、「何を守らないか」を先に決めたからです。
この記事の位置づけ(2026-09-26 追記)
- この記事で新しく示すこと: 「守らないもの」を先に決めて、保存を slug と件数の 2 列だけにした最小の実装。
- 当てはまる範囲: Cloudflare Pages Functions+D1(無料枠)・当方のサイトです。
数字の元データと再現の手順は、検証の記録(Lab)にまとめています。
守る/守らない(先に決めた 1 枚)
| 守る | 守らない |
|---|---|
| 加算の取りこぼし(同時押しで数が減らない) | 厳密な一人一票 |
| 存在する記事の slug だけ数える(ゴミ行を作らない) | incognito や別ブラウザからの再投票 |
| JS が無い・API が落ちている・未結線でも記事は読める | ライク数の監査・実績や営業への利用 |
| D1 に IP・UA・時刻を保存せず、投票判定にも使わない(Cloudflare 自体のリクエスト処理・ログは本記事の範囲外) | 不正投票の分析 |
右列を守ろうとすると、IP のハッシュ・レート制限・ログ・通知……と増えていく。右列を「対象外」と決めた瞬間、左列は Functions 約 30 行(注記込み 31 行)で足りた。
実装(Pages Functions+D1)
// functions/api/like/[slug].ts(抜粋)
const SLUG = /^[a-z0-9][a-z0-9-]{1,80}$/;
async function isLabSlug(env, request, slug) {
// 記事が実在する slug だけ数える(ゴミの行を作らない)。自分の静的ファイルに HEAD で聞く
const r = await env.ASSETS.fetch(new Request(new URL(`/lab/${slug}/`, request.url), { method: 'HEAD' }));
return r.ok;
}
export const onRequestGet = async ({ params, env }) => {
if (!env.LIKES) return json({ ok: false, reason: 'unbound' }, 503); // 未結線なら 503。画面には何も出ない
const slug = String(params.slug ?? '');
if (!SLUG.test(slug)) return json({ ok: false }, 400);
const row = await env.LIKES.prepare('SELECT n FROM likes WHERE slug = ?').bind(slug).first();
return json({ ok: true, n: row?.n ?? 0 });
};
export const onRequestPost = async ({ params, env, request }) => {
if (!env.LIKES) return json({ ok: false, reason: 'unbound' }, 503);
const slug = String(params.slug ?? '');
if (!SLUG.test(slug)) return json({ ok: false }, 400);
if (!(await isLabSlug(env, request, slug))) return json({ ok: false }, 404);
const row = await env.LIKES
.prepare('INSERT INTO likes (slug, n) VALUES (?, 1) ON CONFLICT(slug) DO UPDATE SET n = n + 1 RETURNING n')
.bind(slug).first();
return json({ ok: true, n: row?.n ?? 1 });
};
表は likes(slug TEXT PRIMARY KEY, n INTEGER NOT NULL DEFAULT 0) の 1 つ。
D1 の無料枠は 2026-09-18 時点で 読み 500 万行/日・書き 10 万行/日・5 GB。「上限に当たるとクエリが実行できない」と公式にある(D1 Pricing)=無料は無制限ではない。ライク 1 回の書き込みは 1〜2 行ほどと見ています(公式は、インデックスが書き込み行を 1 行足すと説明しています。実際の書き込み行数は測っていません)。上限は遠いはずですが、上限に当たったときに画面がどうなるかは試していません。
加算は 1 文にした
SELECT して UPDATE する 2 段だと、同時に押されたときに読んだ値が古くなって取りこぼす。INSERT … ON CONFLICT DO UPDATE … RETURNING の 1 文にして、読む・足す・返すを分けなかった。
仕様として言えるのは 2 つ。SQLite は「データベースに触るコマンドはトランザクションが無ければ自動で開始し、文が終わると自動でコミットする」(SQLite: Transactions)。D1 は「auto-commit で動作し、batch() の各文は順に非並行で実行・コミットされる」(Cloudflare D1: D1 Database)。単一の prepare().first() の原子性を明示した文は、当方が読んだ範囲の公式には無かった。
実測として言えるのは 1 つ。ローカル(wrangler pages dev)で 並列 20 POST → 20(取りこぼし 0)。GET 0 → POST 3 回で 1, 2, 3。「原子的だと証明した」ではなく「1 文にした+並列 20 で 0」まで。
存在確認は POST だけ
isLabSlug は自分の静的ファイル /lab/<slug>/ に HEAD を投げて、存在する記事だけ数える。目的はゴミ行を増やさないこと(セキュリティ対策ではない)。GET は存在確認をせず、形式が正しい slug には n: 0 を返す(本番で確認: 無い slug に GET → {"ok":true,"n":0}・POST → 404)。
画面側
<!-- Helpful.astro(記事末尾): GET が ok のときだけ表示。JS 無し・503 なら何も出ない -->
押すと POST → 数字を 1 つ増やしてボタンを無効化・localStorage['helpful:<slug>'] = '1'。再読込後も無効のまま。重複防止はここだけ(サーバーは何も覚えない)。
結線でつまずいたところ
D1 の作成と CREATE TABLE はダッシュボードの Console で通った。Cloudflare Pages の設定画面での D1 binding の追加は、当方の環境ではダッシュボードの窓が小さく D1 の一覧が画面外に出て、3 回とも保存されなかった(保存後も一覧に出ず・API は 503 のまま)。当時の画面の経路は Settings → Functions → D1 bindings と記録していますが、現在の公式の表記は Settings > Bindings で、当時の表記そのものは再確認していません。原因が画面の側か当方の操作かは切り分けていません。wrangler.toml に書いて push したら通った。
[[d1_databases]]
binding = "LIKES"
database_name = "sumitsuke-likes"
database_id = "…"
ダッシュボードも wrangler.toml も正式な経路。当方の画面で前者が通らなかった、という観測。秘密(API キー)はダッシュボードのまま・vars だけを toml に写した。
本番 E2E
結線後に本番で 押す → 1 → 再読込でも無効 → 再 click でも 1(二重送信なし)まで確認し、テストの 1 票は DELETE した。手順と応答の全文は Lab 版に。
言わないこと
- 「水増しに耐える」とは言わない。localStorage を消せば再投票できる。対象外と決めた。
- 「原子的」を仕様として断言しない。1 文にした+ローカル並列 20 で 0、まで。
- ライク数は実績にも営業にも使わない(privacy に明記)。だから守らなくてよい。
- コメント機能(Giscus 等)は入れていない。外部で技術的な補足や再現報告が 30 日で目安 5 件出たら再訪する、とだけ決めた。
本番 E2E の全文・数値・検証の記録(型)は本家 Sumitsuke Lab → 静的サイトに「役に立った」を追加の SaaS 無しで付けたら、表 2 列・SQL 1 文・約 30 行で足りた——守らないものを先に決める。
AI の利用について
Functions と画面のコードは AI(Claude Code)が書き、守る/守らないの裁定・結線の操作・公開の判断は人が行いました。対象は自社サイトで、顧客案件は含みません。
関連
D1 の速さ(cold/warm)を測った話は 「エッジで速い」はずの Cloudflare D1 を、日本から REST API で叩くと東京の Turso に負けた。無料枠サーバレス DB 3 種の cold / warm 実測(速度の話。本記事は設計の話)。
次に読む: 自作の検査器を数か月運用して数えたら、捕まえた欠陥 5 件に対して検査器を直した回数が 22 件だった
この記事は Zenn にも同じ内容を掲載しています。
