createContentStore(@event/core)が content/*.json の書き込みを一手に担う。3リポでほぼ同一だった実装のうち、楽観ロックと GitHub-fresh 読みを備えた ivs-cms 版を正典として採取した。
fs-dev / Octokit-prod の二重書き込み
- dev(
NODE_ENV !== "production"):content/<name>.jsonへ fs 書き込み。編集がそのまま working tree に現れ、通常の git フローで commit できる - prod: Octokit
createOrUpdateFileContentsで GitHub に直接 commit(GITHUB_TOKEN/GITHUB_REPO_OWNER/GITHUB_REPO_NAME/GITHUB_BRANCH)。commit が Vercel rebuild をトリガし、ISR / revalidateTag で反映される。commit message はcommitPrefix(既定chore(cms))、committer 表記はcommitterオプションで指定する
sha256 楽観ロック protocol(5 ステップ)
「後勝ちで他人の編集を黙って消す」事故を封じる。プロトコルは以下の5段:
- hash の定義 —
hash = sha256(直列化 JSON テキスト)。git blob SHA ではない(git 側の実装詳細に依存しない) - 読み —
readResourceWithHash(name, fallback)が{ data, hash }を返す。クライアントはこの hash を ETag として保持する - 書き —
writeResource(name, data, { expectedHash })。書き込み直前に現在値を再 hash し、不一致なら{ ok: false, code: "conflict" }を返して上書き拒否する - 成功時の hash 返却 — 戻り値の
hashは「今書いた内容」の sha256。次の保存の基準はこの値に確実に一致する - 本番の読みは GitHub 最新 — 本番の fs はデプロイ時点で凍結されたバンドルなので、
readResourceWithHashは prod では GitHub HEAD を先に読む(失敗時のみ fs fallback)
ステップ 4・5 が本番の重要不変条件「読みの hash 基準と書きの競合判定基準は同じソースであること」を守る。これが無いと、デプロイ後最初の commit の時点で「fs(凍結バンドル)の hash」と「GitHub HEAD の hash」が乖離し、保存が永久に 409 する既知バグになる(第46章)。
conflict 時のユーザー向けメッセージは固定文言で、UI はそのまま表示すればよい:
> 他のセッションが先に更新しました。ページを再読み込みして最新を取得してから再編集してください。
stale sha リトライ
sync 等の連続書き戻しでは、別 commit 直後に stale な git sha で update すると GitHub が 409/422 を返す。expectedHash によるロックをかけていない書き込みに限り、最新 sha を取り直して最大3回リトライする。ロックあり時の 409 は本物の競合なのでリトライしない。
If-Match REST route
admin editor 向けには createCmsRouteHandlers(@event/cms)が同じ protocol を HTTP に写像する:
// app/api/admin/[resource]/route.ts
const h = createCmsRouteHandlers({ registry, store, isAuthed, afterWrite });
export const GET = h.GET;
export const PUT = h.PUT;
| メソッド | 挙動 |
|---|---|
| GET | { data, hash, kind, schema } + ETag: <hash> |
| PUT | If-Match: <sha256> で楽観ロック。conflict は 409、検証エラーは 400、成功時 ETag = 「今コミットした内容」の hash |
PUT は body のシェイプを吸収し(list は配列 or {items}、singleton は {value} or 平坦 object)、validatePayload → beforeWrite(last-writer-wins のタイムスタンプ刻印等)→ writeResource → afterWrite(revalidatePath/Tag・KV ミラー・write-through)の順に通す。next/cache への依存はアプリ側 hook に押し出してあり、package は next 非依存を保つ。
client 対向は putResource(resource, body, expectedHash)(@event/cms の client.ts)で、409 → code: "conflict"、401 → code: "unauthorized" の型付き結果を返す。
KV ミラー
保存反映を rebuild を待たず公開側へ即時化したい場合、afterWrite で KV へミラーする。セッションカード overlay は @event/session-card/persist がこのパターンを内蔵している(第19章)。KV は best-effort であり、失敗しても content には保存済み — リビルドで必ず追いつく。