140文字の要望でホームページが変わるAfter 6 Chaosを作っています。
投稿機能には、サイト全体で1日10件、同じ接続元からは60秒に1件という制限を設けました。さらに、受け付けた投稿を改修するのは「受付日の翌日03:00 JST以降」です。
この三つを実装すると、日付境界、同時送信、保存失敗時の扱いがつながってきます。ここでは、Cloudflare WorkersとD1を使った実装の要点を紹介します。コードは2026年10月30日の正式リリース時点の受付処理をもとにしています。
最初に受付ルールを決める
| 項目 | ルール |
|---|---|
| 本文 | 1文字以上140文字以内 |
| 日次上限 | サイト全体で10件 |
| 1日の境界 | 日本時間00:00 |
| 連投間隔 | 同じ接続元は60秒 |
| 件数に含めるもの | 保存に成功した投稿 |
| 改修の対象時刻 | 受付日の翌日03:00 JST |
| 採用の保証 | 受付時点ではしない |
ここでいう1日は、直近24時間ではなく日本時間の暦日です。連投制限は、最後に保存できた時刻から数えます。拒否された送信で待ち時間を延ばさないようにします。
文字数は書記素単位で数える
JavaScriptの string.length はUTF-16のコード単位数です。入力画面の文字数には、結合された絵文字などを一つとして扱える Intl.Segmenter を使っています。
const segmenter = new Intl.Segmenter('ja', {
granularity: 'grapheme',
});
export function countCharacters(value) {
return [...segmenter.segment(value)].length;
}
export function validateSubmission(value) {
if (typeof value !== 'string' || !value.trim()) {
throw new Error('1文字以上入力してください。');
}
if (countCharacters(value) > 140) {
throw new Error('140文字以内で入力してください。');
}
return value;
}
クライアントの表示とサーバーの検証で、同じ関数を使います。trim() は空白だけの入力を弾くために使い、保存する原文は加工していません。文字数に加えて、APIではリクエスト本文のバイト数にも上限を設けています。
日本時間の当日範囲をUTCで求める
DBには、サーバーが生成したUTCのISO文字列を保存します。比較する境界も同じ形式にそろえます。
const hour = 3_600_000;
export function dayWindow(now = Date.now()) {
const day = new Date(now + 9 * hour)
.toISOString()
.slice(0, 10);
const start = Date.parse(day + 'T00:00:00+09:00');
return {
day,
startAt: new Date(start).toISOString(),
resetAt: new Date(start + 24 * hour).toISOString(),
};
}
export function nextRevisionAt(createdAt) {
const time = typeof createdAt === 'number'
? createdAt
: Date.parse(createdAt);
return new Date(
Date.parse(dayWindow(time).resetAt) + 3 * hour,
).toISOString();
}
日本時間の受付日の終わりを求め、そこへ3時間足しています。この実装はUTC+9の日本時間を対象としています。夏時間のある地域へそのまま流用する場合は、タイムゾーンの扱いを変更する必要があります。
日時の例を日本時間で並べると、次のようになります。
図1:同じ深夜帯でも、受付日が変わると改修対象の日が変わります。
| 投稿の日時 | 改修対象になる日時 |
|---|---|
| 10月29日23:59 | 10月30日03:00 |
| 10月30日00:00 | 10月31日03:00 |
| 10月30日02:30 | 10月31日03:00 |
深夜2時半の投稿でも、30分後の改修に入るわけではありません。「受付日の翌日」という条件を、画面の案内とAPIで共通にしています。
件数の確認と保存を一つのSQLにする
画面で送信ボタンを無効にするだけでは、APIへの送信を制限できません。また、件数をSELECTしてから別の処理でINSERTすると、その間に別の投稿が入る可能性があります。
そこで、保存できる条件をINSERT文の中に置きます。
テーブルの要点は次の二つです。
CREATE TABLE submissions (
id TEXT PRIMARY KEY NOT NULL,
content TEXT NOT NULL,
created_at TEXT NOT NULL
);
CREATE TABLE submission_throttles (
client_key TEXT PRIMARY KEY NOT NULL,
last_at INTEGER NOT NULL
);
投稿保存に使うSQLは次の形です。
INSERT INTO submissions (id, content, created_at)
SELECT ?, ?, ?
WHERE (
SELECT COUNT(*)
FROM submissions
WHERE created_at >= ? AND created_at < ?
) < ?
AND NOT EXISTS (
SELECT 1
FROM submission_throttles
WHERE client_key = ? AND last_at > ?
)
RETURNING id;
パラメーターは順に、投稿ID、原文、現在日時、当日の開始、翌日の開始、上限10、接続元キー、現在時刻から60秒引いた値です。値はprepared statementの bind() へ渡します。
連投条件を last_at > now - 60_000 にしているため、59秒後は拒否し、60秒ちょうどなら受け付けます。
条件を満たさない場合は、INSERTされず、RETURNINGの結果が空になります。SQLの実行自体は成功しても、投稿を受け付けたとは限りません。結果行の有無を判定に使います。
投稿保存と連投記録を同じbatchに入れる
投稿だけ保存されて連投記録に失敗したり、その逆になったりしないよう、D1の batch() でまとめて実行します。
連投時刻の更新にも条件を付けます。
INSERT INTO submission_throttles (client_key, last_at)
SELECT ?, ?
WHERE EXISTS (
SELECT 1 FROM submissions WHERE id = ?
)
ON CONFLICT(client_key)
DO UPDATE SET last_at = excluded.last_at;
このとき参照する投稿IDは、今回の送信のために生成したものです。最初のINSERTでその投稿が保存された場合だけ、連投時刻を更新します。
全体の順序は次のとおりです。
-
条件付きINSERTで投稿を保存する。
-
今回の投稿が存在する場合だけ連投時刻を更新する。
-
当日の件数と、接続元の最終受付時刻を取得する。
-
古くなった連投記録を削除する。
CloudflareのD1 Databaseドキュメントでは、batch内の文を順に実行し、途中の文が失敗した場合は全体を中止またはロールバックする仕様が説明されています。
ここでは、エラーによるロールバックに加えて、「受付条件に合わず0行だった」という正常終了も扱っています。後続の更新に存在条件を付けることで、拒否した送信が待機時間を延ばすことを防いでいます。
接続元は、Cloudflare側で付与されるIP情報をハッシュ化したキーで扱い、取得できない場合はCookieで補助します。これは連投を抑えるための識別であり、厳密な一人一枠の仕組みではありません。同じ回線を使う人同士が待機制限を共有することもあります。
未処理と今処理できる投稿を分ける
受付時刻の条件を満たしていても、すでに改修した投稿は対象に含めません。まず、投稿IDと改修履歴を照合して未処理の pending を作り、その中から時刻条件を満たす ready を求めます。
export function readyPosts(posts, now = Date.now()) {
return posts.filter(
post => Date.parse(nextRevisionAt(post.createdAt)) <= now,
);
}
pending があることと、今の実行で処理すべき投稿があることは別です。定期改修はreadyを使います。
現在の予約は毎日03:05 JSTですが、処理対象の判定はサーバー側のこの関数で行います。改修が遅れた場合も、期限を迎えた未処理投稿はreadyに残ります。
APIの結果を画面にそのまま伝える
投稿APIは、保存できた場合は201、上限または連投制限は429、DBなどの保存エラーは503を返します。入力不正は400です。
429では Retry-After と再試行可能な時刻を返し、画面で「今日の枠が満員」と「あと何秒待つ必要がある」を分けて表示します。
満員の場合は入力欄を案内の漫画に差し替えます。日付が変わると再び入力できるよう、画面に持っているquotaの有効期限も確認します。前日の「残り0件」を持ち続けて、翌日も送信できない状態にしないためです。
保存に失敗した場合は、入力した文章を残します。逆に、保存成功後の履歴再取得だけが失敗した場合は「投稿は保存しました」と知らせます。保存失敗と誤認させて、同じ投稿を送り直してもらわないための区別です。
境界と失敗時のテストを書く
受付処理では、次のようなケースをテストしています。
| ケース | 期待する結果 |
|---|---|
| 日本時間23:59:59と00:00:00 | 受付日が切り替わる |
| 翌日02:59:59と03:00:00 | readyへの追加が境界で切り替わる |
| 10件保存後の11件目 | 保存せずdailyで拒否 |
| 同じ接続元の59秒後と60秒後 | 前者を拒否し後者を受付 |
| 無効な本文 | 件数も連投記録も増やさない |
| 連投記録の保存でDBエラー | 投稿保存もロールバック |
| 複数の受付呼び出し | 上限と同一接続元の制限を維持 |
テストではNode.jsのSQLiteを使ったD1互換の小さなアダプターで、batchをトランザクションとして実行しています。複数の呼び出しをまとめるケースもありますが、このアダプターは同期的にSQLを実行するため、本番D1への並列負荷試験とは区別しています。
2026年10月30日のソースで、受付以外も含む49件のテストが通過しました。受付に関する境界を関数として切り出したことで、実際の深夜0時や3時を待たずに確認できます。
この実装で中心になったのは、保存してよい条件をDBへ置くことと、保存された事実に合わせて後続処理を進めることでした。画面の受付案内、連投時刻、日次件数、翌日の改修対象を、同じ投稿の保存結果と時刻ルールから決めています。
