公開原稿とデータを管理するサーバー
サーバーは公開状態・公開時刻・原稿の版を確認してから資料を返す。本文・メタデータ・検索資料が同じ公開版を使うよう、取得と公開処理をつなぐ。
サーバー・DB・CMSへ公開原稿を保存して取得する
申請期限を11月5日から6日へ直したのに、本文だけが変わり、検索用要約には5日が残ったとする。検索・回答ツールは読む場所によって違う期限を得る。公開原稿を一つの版として確定し、本文・要約・日付を一緒に作るのは、この衝突を減らすためだ。CMSの保存・公開構造が外部へ届ける事実を決める。
サーバー(server)はネットワーク要求を受けて応答するプログラムと、その実行機器を指す。ウェブサーバーはファイルを渡すか要求をアプリへ転送し、アプリケーションサーバーはデータ取得やHTML生成などのサービス処理を実行する。マネージドホスティングでは両方をプラットフォームが提供することがある。
DB(Database)は記事・公開状態・日付・関係を保存するデータベースだ。CMS(Content Management System)は資料を編集・公開する管理機能だ。DBのスキーマ(schema)は項目・型・関係を定める構造で、SQLは関係DBを検索・変更する言語、ORMはコードのオブジェクトとDB資料を結ぶ道具だ。どの道具でも公開状態と公開時刻を取得条件へ含める。
DBインデックス(index)は必要な行を速く見つけるデータ構造で、検索エンジンのインデックスとは役割が異なる。slugや公開状態・公開日で検索するときの実行計画を見て決める。ページごとに不要な全件取得を繰り返せば、SSR応答が遅くなり、DB負荷が増す。
トランザクション(transaction)は複数のDB変更をまとめて成功または取り消す処理単位だ。リビジョン(revision)は原稿の版だ。公開版の切り替え時に本文・タイトル・日付を一緒に変えると、画面とメタデータが別原稿を読む問題を減らせる。外部の検索用ストレージへの送信は別の作業として記録し、失敗したら再試行する。
認証(authentication)は本人確認、認可(authorization)は利用できる資料の確認だ。セッション(session)はログイン状態を保つ情報、クッキー(cookie)はブラウザーがサーバーへ送り返せるよう保存した値だ。公開記事はログインなしで読めるようにし、下書き・管理者取得ではサーバーで認証と権限を確認する。同じURLでユーザー別に内容が変わるなら、共有キャッシュへ混ざらないよう設定する。
公開判定を一か所で定義する
公開条件はサーバーの取得層で適用する。以下のTypeScriptは、次の文書と時間にあるidentity-time.mjsからisPublicを取り込む。そのファイルをlib/identity-time.mjsへ保存して使う。Date.parseは2月30日を別の日へ補正する場合があるため、数値変換の成功だけで暦の日付を検証しない。
import { isPublic } from './identity-time.mjs'
type Post = {
status: 'draft' | 'published' | 'withdrawn'
publishedAt: string | null // 判明している初回公開日:DateまたはDateTime
publicFrom: string | null // このサーバーで公開を始める正確な時刻
deletedAt: string | null
}
export function publicPosts(posts: Post[], now = Date.now()) {
return posts.filter(post => isPublic(post, now))
}
type Postは入力項目と型を定義する。publishedAtは初回公開日で、日付だけが判明している場合や不明の場合もある。publicFromはこのサーバーで公開を始める正確な時刻だ。分ければ過去の資料を今日移しても旧公開日を保てる。isPublicは日付形式を検証し、published状態、未削除、publicFrom到達を判定する。不正入力はエラーにし、正常な下書きは一覧から除く。
例えばsrc/lib/public-content.tsに条件を定義し、一覧・詳細取得、サイトマップ、公開APIが同じ意味の条件を使うようにする。DBで絞る場合も同じ条件をSQLへ適用し、全資料をクライアントへ渡して絞り込まない。再公開・撤回・予約公開では、状態遷移後に一覧と詳細が一緒に変わるようキャッシュ更新範囲もまとめる。
公開の可否を判定した後で検索掲載方針を適用する。公開記事を検索対象とするサイトでは、一覧・詳細・サイトマップが同じ公開版を読む。管理画面と下書きでは、サーバーでログイン状態と閲覧権限を検査する。
同じ公開条件を使うと、検索へ知らせたURLと実際に開く記事を一致させられる。一覧が公開と判断してリンクを作り、詳細が非公開と判断して404を返すと、発見したリンクが読める文書につながらない。予約前の記事が先にサイトマップへ入る場合も同じだ。isPublicは各経路が同じ時刻と状態を基準に応答する部分だ。
公開版から本文と派生データを一緒に作る
CMSのあるサイトでは編集中原稿と現在の公開原稿を分ける。公開詳細・メタデータ・サイトマップは公開版、管理者プレビューは作業版を読む。同じURLへの要求のたびに作業版の変更が公開画面へ漏れないようにする。
例えばcontent_idは文書、revisionは内容の版を識別する。公開するrevisionから本文・タイトル・要約・出典・共有画像・検索データを作ってから有効な公開版を切り替える。同じDB内の更新はトランザクションにまとめられる。外部検索ストレージがあれば公開処理と一緒に同期作業を記録し、適用revision・失敗・再試行状態を別に保存する。
独自検索やサイトのチャットボットには公開版の本文と出典を送る。公開直後に同じ文を検索し、新しい内容が返るか確認する。撤回記事は検索結果とチャットボットが参照できる資料からも除く必要がある。
撤回時は詳細原文、一覧、サイトマップ、関連記事、独自検索・チャットボットの利用可否を一緒に変え、関連キャッシュを無効にする。復元時は、以前の公開版の出典とメディアが現在も公開可能か確認する。却下・遮断した原文が過去版の復元で再露出しないようにする。
同じ公開要求の再試行を区別するため、要求IDと期待revisionを保存する。publish-42がrevision 7を8へ変えた後、応答送信だけ失敗したなら、再度のpublish-42には既存revision 8を返す。別要求がrevision 8をすでに修正したのに期待値7で保存しようとすれば競合を返す。ネットワーク再試行が二重公開や他者の編集の上書きを起こすのを防ぐ。
独自RAGが旧公開版を検索し続ければ、画面を直してもチャットボットは旧期限を答えうる。公開revisionを検索資料と一緒に保存すると、検索された原稿を追跡できる。取得した断片と公開ページのrevisionを比較し、違えば同期作業を直す。外部検索・AIの保存資料は、そのサービスの再収集と処理結果で確認する。
公開切り替えと外部更新の間の失敗を扱う
DBの公開版切り替えと外部CDN更新は別システムで起きる。DBだけ変わって更新要求が失敗すれば、経路の一部に旧内容が残る。公開トランザクションで文書ID・版・対象パスを更新作業として一緒に保存し、処理側が成否と再試行を記録するようにできる。
同じ公開作業が再度来たら、文書IDと期待版で適用済みか確認する。修正は新公開版へ切り替え、撤回はすべての取得経路で返さないようにする。キャッシュを使う場合は、原本取得より先に保存済み応答を返す経路も調べる。
並行して取得する本文とメタデータには、同じ要求で得た公開原稿を渡す。同名の関数を2回呼んでも、毎回DBを読めば取得の間に公開版が変わることがある。次の実装例は要求内で結果を再利用し、長期キャッシュは別に選択する。
文書の版と事実の期間をともに保存する
content IDは文書の同一性、revisionは修正版、publishedAtは初回公開時点、contentModifiedAtは実質的な修正時点だ。有効なrevisionを確定してから本文・metadata・一覧へ同じ資料を渡す。作業版保存時のupdatedAtで公開版の修正日を上書きしない。
2025年の記録の誤字を2026年に直すとrevisionは上がるが、適用期間は2025年のままだ。文書と時間のモデルは、この違いを公開状態と適用期間に分けて保存する。
小さなCMSで公開・修正・撤回・復元を実行する
lifecycle.mjsとlifecycle.test.mjsをidentity-time.mjsの隣に保存し、node --test lifecycle.test.mjsで実行する。資料はメモリーに保存し、ローカルHTTPサーバーがJSONを返す。/listは一覧、/sitemapはサイトマップ用URL、/guides/opening-hoursは詳細データだ。
// Node.js 22. 独立した実習用メモリーCMS。終了すると資料は消える。
import { isPublic, describeDocument } from './identity-time.mjs';
export function createLibrary() {
let draft = { id: 'hours-current', path: '/guides/opening-hours', revision: 0,
title: '図書館の開館時間', text: '日曜日10–17時。第1日曜日と祝日は休館。',
status: 'draft', deletedAt: null, publishedAt: null, publicFrom: null,
contentModifiedAt: null, eventDate: null,
validFrom: '2026-10-01T00:00:00+09:00', validUntil: null };
let active = structuredClone(draft);
const history = [], cache = new Map();
function saveDraft(text) { draft = { ...draft, text }; }
function change(action, at, { purge = true, revision } = {}) {
if (!['publish', 'withdraw', 'restore'].includes(action)) throw new Error('Invalid action');
const selected = action === 'restore' ? history.find(x => x.revision === revision) : draft;
if (!selected) throw new Error('Unknown revision');
const next = action === 'withdraw' ? { ...active, status: 'withdrawn' }
: { ...selected, status: 'published',
publishedAt: active.publishedAt ?? at, publicFrom: at,
contentModifiedAt: active.publishedAt === null ? null : at };
next.revision = active.revision + 1;
describeDocument(next, at); // 全入力を検証してから一度に切り替える。
active = next; history.push(structuredClone(next));
draft = structuredClone(next);
if (purge) cache.clear();
return next.revision;
}
function read(path, at, { bypass = false } = {}) {
// このモデルの撤回は詳細要求で現在の状態を再検査する。
if (path === active.path && !isPublic(active, Date.parse(at)))
return { status: 404, body: null };
if (!bypass && cache.has(path)) return structuredClone(cache.get(path));
const info = describeDocument(active, at);
const body = path === '/list' ? (info ? [{ id: active.id, title: active.title, revision: active.revision }] : [])
: path === '/sitemap' ? (info ? [info.canonical] : [])
: path === active.path && info ? { ...active, ...info } : null;
const result = { status: body === null ? 404 : 200, body };
if (!bypass) cache.set(path, result);
return structuredClone(result);
}
return { saveDraft, change, read, clearCache: () => cache.clear() };
}
saveDraftは作業版だけを変更する。changeは公開・撤回・復元を選び、すべての日付検証を通った資料をactiveへ一度に代入する。historyは過去の公開版をコピーして保存する。復元は過去本文を新revisionで公開し、初回公開日を維持する。復元で変わる公開内容には現在の修正時刻を記録する。
readは同じactiveから詳細・一覧・サイトマップを作る。詳細要求はキャッシュを読む前に現在の公開可否を再確認する。changeは既定で実習の全キャッシュを空にする。実際のDBとCDNは一つのメモリー内で原子的に変わらないため、運用ではキャッシュ更新の失敗記録と再試行が必要だ。
import test from 'node:test';
import assert from 'node:assert/strict';
import { createServer } from 'node:http';
import { createLibrary } from './lifecycle.mjs';
const at = '2026-10-24T09:00:00+09:00';
const later = '2026-10-24T10:00:00+09:00';
test('HTTP取得で下書き→公開→修正→撤回→復元を照合する', async () => {
const cms = createLibrary(); let clock = at;
const server = createServer((req, res) => {
const result = cms.read(req.url, clock);
res.writeHead(result.status, { 'Content-Type': 'application/json' });
res.end(JSON.stringify(result.body));
});
await new Promise(resolve => server.listen(0, '127.0.0.1', resolve));
const origin = `http://127.0.0.1:${server.address().port}`;
const rows = [];
async function inspect(state, revision) {
const responses = await Promise.all(['/guides/opening-hours', '/list', '/sitemap'].map(async path => {
const r = await fetch(origin + path); return { path, status: r.status, body: await r.json() };
}));
const [detail, list, sitemap] = responses;
assert.equal(detail.status, revision === null ? 404 : 200);
assert.equal(list.body.length, revision === null ? 0 : 1);
assert.equal(sitemap.body.length, revision === null ? 0 : 1);
if (revision !== null) {
assert.equal(detail.body.revision, revision); assert.equal(list.body[0].revision, revision);
assert.equal(sitemap.body[0], detail.body.canonical);
}
rows.push({ state, responses }); return detail.body;
}
try {
await inspect('draft', null);
cms.change('publish', at); const first = await inspect('published', 1);
cms.saveDraft('日曜日10–16時。第1日曜日と祝日は休館。');
assert.equal((await inspect('draft edit remains private', 1)).text, first.text);
clock = later; cms.change('publish', later); const revised = await inspect('revised', 2);
assert.match(revised.text, /10–16/); assert.equal(revised.publishedAt, at);
cms.change('withdraw', later); await inspect('withdrawn', null);
cms.change('restore', later, { revision: 1 }); const restored = await inspect('restored', 4);
assert.equal(restored.text, first.text); assert.equal(restored.publishedAt, at);
assert.equal(restored.contentModifiedAt, later);
console.log(JSON.stringify(rows));
} finally { await new Promise(resolve => server.close(resolve)); }
});
test('無効化を省いた修正・撤回の失敗を再現し、全キャッシュを空にする', () => {
const cms = createLibrary(); cms.change('publish', at);
for (const path of ['/guides/opening-hours', '/list', '/sitemap']) cms.read(path, at);
cms.saveDraft('新しい開館時間'); cms.change('publish', later, { purge: false });
assert.notEqual(cms.read('/guides/opening-hours', later).body.text,
cms.read('/guides/opening-hours', later, { bypass: true }).body.text);
cms.change('withdraw', later, { purge: false });
assert.equal(cms.read('/guides/opening-hours', later).status, 404);
assert.equal(cms.read('/list', later).body.length, 1); // 誤って残った一覧
assert.equal(cms.read('/sitemap', later).body.length, 1);
cms.clearCache();
assert.deepEqual(cms.read('/list', later).body, []);
assert.deepEqual(cms.read('/sitemap', later).body, []);
});
最初のテストは空の一覧と詳細404から始め、公開後200、作業版修正の非公開維持、新公開版の本文、撤回後404・空一覧・空サイトマップ、旧本文復元を確認する。二つ目はpurge: falseで障害を入れ、詳細は撤回されても一覧・サイトマップが旧キャッシュを返す失敗を再現する。clearCache後に両経路が空になるかを確認し、修正範囲を検証する。
撤回後も一覧キャッシュがリンクを返す失敗は、検索が提供終了した記事を発見し続ける状況に当たる。キャッシュを空にして一覧・サイトマップからURLが消えれば、新しく提供する発見経路は整う。外部検索へ保存済みの資料の状態は、その後の収集記録で確認する。コードの公開判定と外部資料の更新は別の場所で起きる。
公開状態を確認して画像変換結果を再利用する
CMSの写真IDは、ファイルパスと公開状態を探すキーだ。実際のCMSでは現在の公開記事が参照しているか、公開開始時刻を過ぎたか、撤回されたかを調べる。本文だけ撤回して原本URLを開いたままにすると、検索・共有・直接要求はファイルへ到達できる。原本と派生版の両経路が同じ公開判定を通る必要がある。
次のmedia-store.mjsは写真1枚と公開状態をメモリーに持つ。公開確認後、原本ハッシュ・幅・WebP設定で変換結果を探す。同じ幅の同時要求はMap内の同じPromiseを待つ。変換完了後に公開状態と世代番号を再確認し、待機中に撤回された要求も遮断する。
import sharp from 'sharp';
import { createHash } from 'node:crypto';
// 写真1枚の実習用ストレージ。原本と変換結果をpublicへ書かない。
export async function createMediaStore(input) {
const source = Buffer.from(input);
if (source.length > 20 * 1024 * 1024) throw new Error('Image exceeds 20 MiB');
const options = { limitInputPixels: 40_000_000 };
const meta = await sharp(source, options).metadata();
if (!['jpeg', 'png', 'webp'].includes(meta.format) || (meta.pages ?? 1) !== 1)
throw new Error('Single JPEG, PNG or WebP required');
const hash = createHash('sha256').update(source).digest('hex');
const variants = new Map();
let published = true;
let generation = 0;
let transforms = 0;
const headers = { 'Cache-Control': 'private, no-store, max-age=0' };
const missing = () => ({ status: 404, headers, body: Buffer.from('Not found') });
return {
setPublished(value) {
if (typeof value !== 'boolean') throw new Error('Boolean required');
published = value; generation++;
},
stats: () => ({ transforms, variants: variants.size }),
async read(width = null) {
if (!published) return missing();
const observedGeneration = generation;
if (width === null) return {
status: 200, headers: { ...headers, 'Content-Type': 'image/' + meta.format }, body: source,
};
if (![320, 640, 1280].includes(width))
return { status: 400, headers, body: Buffer.from('Unsupported width') };
const key = `${hash}:${width}:webp-q78-v1`;
if (!variants.has(key)) {
transforms++;
const pending = sharp(source, options).rotate()
.resize({ width, withoutEnlargement: true }).webp({ quality: 78 })
.toBuffer({ resolveWithObject: true });
variants.set(key, pending);
pending.catch(() => variants.delete(key));
}
const result = await variants.get(key);
// 変換待ち中に撤回・再公開が起きた要求も停止する。
if (!published || generation !== observedGeneration) return missing();
return { status: 200, headers: { ...headers, 'Content-Type': 'image/webp' },
body: result.data, width: result.info.width, height: result.info.height };
},
};
}
Buffer.fromは入力バイトをコピーし、SHA-256ハッシュは原本内容を識別する。rotateは写真の回転情報を適用する。resizeのwithoutEnlargementは小さい原本の無理な拡大を防ぐ。toBufferのinfoから実出力の幅・高さを得る。この値をsrcsetへ使うことで、小さい原本に1280wを誤記せずに済む。sharpのサイズ変換
setPublished(false)が実習の撤回だ。再起動すると原本と派生版はすべて消える。CMS接続時はpublished変数を公開版取得へ変え、派生ファイルを外部から直接アクセスできないストレージへ置く。複数サーバーなら全要求が同じ公開状態を読む必要がある。入力容量・ピクセル数・形式の検査はコード内にあり、処理時間と同時作業数の制限はアップロード・変換の実行環境で加える。
この例は原本URLも公開するため、位置・撮影情報を含めて提供するか先に決める。メタデータを除いた公開用派生版だけを提供するサービスなら、原本経路は管理者権限に制限する。一度渡したファイルや送信中の応答は後の撤回で回収できない。新しい要求のアクセス条件と、配布済み資料の扱いを分ける。