イベントの公開コンテンツは DB ではなく content/*.json に置く。git がバージョン管理と監査ログを兼ね、編集は commit(第14章)、反映は rebuild / ISR で行う。読み出しの正は @event/cms の createContentReader である。
createContentReader — real + dev sample overlay
import { createContentReader } from "@event/cms";
const reader = createContentReader(); // 既定 <cwd>/content
const news = await reader.readContent<NewsItem[]>("news"); // sample merge 済み
const raw = await reader.readReal<NewsItem[]>("news"); // 素の real のみ
ファイル規約:
| パス | 役割 |
|---|---|
content/<name>.json | real データ(git 管理・本番の正) |
content/sample/<name>.json | チーム共有の sample(git 管理) |
content/sample/<name>.local.json | gitignored の個人 override。sample を丸ごと置換する |
merge 規則:
- production(
NODE_ENV=productionまたはVERCEL)では sample を一切読まない。dev 用ダミーデータの本番漏れを構造的に防ぐ - list: real ⊕ sample の id-merge。real 優先で、real に存在しない id の sample 行だけを後ろに足す(
id/ legacy_id両対応) - singleton: real が「空」(null または全フィールドが空値)のときだけ sample を返す。1フィールドでも real に値があれば real が勝つ
書き込み系(createCmsActions / createCmsRouteHandlers)は必ず readReal を基準にする。sample がマージされた値を保存すると dev のダミーが real に固定化されるためである。
createResourceRegistry — 三重宣言の廃止
spikes では RESOURCES 配列 / SINGLETONS Set / ResourceSchema.shape の3箇所を手動同期していた(ドリフト源)。registry は schema 配列だけを宣言し、残りを全て派生させる:
import { createResourceRegistry } from "@event/cms";
export const registry = createResourceRegistry([NEWS_SCHEMA, SETTINGS_SCHEMA]);
registry.resources; // ["news", "settings"]
registry.isSingleton("news"); // false — schema.shape から派生
registry.getSchema("news"); // ResourceSchema | null
registry.visible; // hidden: true を除いた admin ナビ用一覧
schema 名の重複は生成時に throw する(黙って上書きしない)。
projections — publicFields allowlist
非公開フィールド(バイヤー私書・原価・連絡先メモ等)を public render に渡さない防御は allowlist で行う。denylist(Omit)は新フィールドの追加時に黙って漏れるが、allowlist は漏れない。
// schema 側で公開フィールドを宣言
const BUYERS_SCHEMA: ResourceSchema = {
name: "buyers", label: "バイヤー", shape: "list",
publicFields: ["name", "logoUrl", "industry", "summary"],
fields: [/* private フィールドも全部ここに定義してよい */],
};
// LP の server component で
import { pickPublicList } from "@event/cms";
const items = pickPublicList(BUYERS_SCHEMA, await reader.readContent("buyers") ?? []);
pickPublicFields は idField(既定 "id")を常に含め、publicFields 未定義の schema では素通しする(公開のみのリソースに宣言を強いない)。
サーバサイド default 注入
リソース固有の自動値(news の publishedAt 等)は createCmsActions の serverDefaults フックで注入する。client からの値に依存しないため、時刻偽装や欠落が起きない:
export const cms = createCmsActions({
registry, store, reader, requireAuth: requireAdmin,
serverDefaults: (name, values) =>
name === "news" && !values.publishedAt
? { ...values, publishedAt: new Date().toISOString() }
: values,
});
なお docs/DB_MCP_DESIGN.md の二層原則(固定コア + 拡張レイヤ)はこの JSON モデルにも適用される。4S 所有の LOCKED フィールド(@event/schema の LOCKED_SESSION_FIELDS)へは CMS からもここの save 経路からも書き込めない(第13章の locked guard)。