【#10】 OpenClaw を読み解く — すべては SQLite に還る、状態の置き場所
本記事のコード参照は OpenClaw
mainのcee2aca409(version2026.6.10)時点。行番号は更新でズレ得ます。
連載「OpenClaw を読み解く」
#01 で「状態は SQLite に集約」「Kysely で生 SQL を避ける」「マイグレーションは database-first」という3つの原則を予告しました。今回はその実装を src/state/・src/plugin-state/・src/infra/ から読み解きます。巨大アプリの「データの置き場所」をどう一貫させているか、です。
二つの器 — 共有とエージェント、状態の住み分け
OpenClaw のランタイム状態は2つの SQLite に分かれます。
共有状態 DB(グローバル状態 + プラグイン KV)。パスは src/state/openclaw-state-db.paths.ts:40。
export function resolveOpenClawStateSqlitePath(env = process.env): string {
return path.join(resolveOpenClawStateSqliteDir(env), "openclaw.sqlite");
}
実体は ~/.openclaw/state/openclaw.sqlite。
エージェント単位 DB(エージェントスコープの状態/キャッシュ)。パスは src/state/openclaw-agent-db.paths.ts:20。
export function resolveOpenClawAgentSqlitePath(options): string {
const agentId = normalizeAgentId(options.agentId);
return path.resolve(
options.path ??
path.join(
path.dirname(resolveOpenClawStateSqliteDir(options.env ?? process.env)),
"agents", agentId, "agent", "openclaw-agent.sqlite",
),
);
}
実体は ~/.openclaw/agents/<agentId>/agent/openclaw-agent.sqlite。
AGENTS.md の使い分けルールはこうです。
Use the shared state DB (
state/openclaw.sqlite) for global runtime state and plugin KV data. Use the per-agent DB (agents/<agentId>/agent/openclaw-agent.sqlite) for agent-scoped state/cache. Use a dedicated SQLite DB only when schema, volume, or lifecycle clearly does not fit those stores.
「グローバルは共有 DB、エージェント固有はエージェント DB、どちらにも合わない場合だけ専用 DB」。新しいデータの置き場所を迷ったら、まずこの2つから選ぶ。これが状態の散逸を防ぐ単純で強い規律です。
ファイルは厳格なパーミッションで作られます(src/state/openclaw-state-db.ts:37)。
const OPENCLAW_STATE_DIR_MODE = 0o700;
const OPENCLAW_STATE_FILE_MODE = 0o600;
ディレクトリは 0700、ファイルは 0600。状態 DB は認証情報やセッションを含みうるので、所有者だけが読める権限に固定します。WAL/SHM/journal といったサイドカーの取り回しは、定常ランタイムではなく旧ストアの移行時にだけ必要になるため、マイグレーション側に集約されています(src/infra/state-migrations.ts の PLUGIN_STATE_SQLITE_SIDECAR_SUFFIXES = ["", "-shm", "-wal", "-journal"] など、サイドカー4種を扱う)。
生 SQL を書かないという矜持 — Kysely の流儀
AGENTS.md の規約。
SQLite runtime access uses Kysely helpers, not raw SQL statement strings, except schema DDL, migrations, low-level DB bootstrap, or narrowly justified SQLite primitives.
Kysely は型安全なクエリビルダです。ヘルパは src/infra/kysely-sync.ts に集約されます。
export function getNodeSqliteKysely<Database>(db: DatabaseSync): Kysely<Database> {
const existing = kyselyByDatabase.get(db);
if (existing) return existing as Kysely<Database>;
const kysely = new KyselyInstance<Database>({
dialect: new CompileOnlyNodeSqliteKyselyDialect(),
});
kyselyByDatabase.set(db, kysely as Kysely<unknown>);
return kysely;
}
export function executeSqliteQuerySync<Row>(db, query: CompilableQuery<Row>): QueryResult<Row> {
return executeCompiledSqliteQuerySync<Row>(db, query.compile());
}
ポイントは CompileOnlyNodeSqliteKyselyDialect — Kysely をクエリのコンパイル(SQL 文字列 + パラメータの生成)にだけ使い、実行は Node 標準の node:sqlite(DatabaseSync)で同期的に行う、というハイブリッドです。Kysely インスタンスは DB ごとにキャッシュされます。
代表的なクエリ(スキーマメタの upsert, src/state/openclaw-state-db.ts:835)。
const kysely = getNodeSqliteKysely<OpenClawStateMetadataDatabase>(db);
executeSqliteQuerySync(db,
kysely.insertInto("schema_meta")
.values({ meta_key: "primary", role: "global",
schema_version: OPENCLAW_STATE_SCHEMA_VERSION, /* ... */ })
.onConflict((c) => c.column("meta_key").doUpdateSet({ /* ... */ })));
型付きの insertInto(...).values(...).onConflict(...) で、UPSERT を文字列連結なしに書けます。SQL インジェクションの隙がなく、テーブル名・カラム名が型チェックされるのが効きどころです。
プラグインの持ち物も DB に — KV ストアという置き場
プラグインの永続データは共有 DB の plugin_state_entries テーブルに入ります(src/state/openclaw-state-schema.sql)。
CREATE TABLE IF NOT EXISTS plugin_state_entries (
plugin_id TEXT NOT NULL,
namespace TEXT NOT NULL,
entry_key TEXT NOT NULL,
value_json TEXT NOT NULL,
created_at INTEGER NOT NULL,
expires_at INTEGER,
PRIMARY KEY (plugin_id, namespace, entry_key)
);
(plugin_id, namespace, entry_key) の複合主キーで、プラグインごと・名前空間ごとに分離。expires_at で TTL を持てます。操作(src/plugin-state/plugin-state-store.sqlite.ts)は register/upsert・lookup・consume(読んで即削除)・entries(一覧)・sweep(TTL 期限切れの掃除)。読み取りは TTL を考慮します。
.where("plugin_id", "=", params.pluginId)
.where("namespace", "=", params.namespace)
.where("entry_key", "=", params.key)
.where((eb) => eb.or([eb("expires_at", "is", null), eb("expires_at", ">", params.now)]))
「期限なし、または未来に期限」のものだけを返す。書き込みは runOpenClawStateWriteTransaction() で ACID 保証のトランザクション内に置かれます。プラグインは #04 の SDK 経由でこの KV を使い、自前のファイルを作りません——「プラグインのスクラッチデータも SQLite」という AGENTS.md の徹底です。
過去を引き受ける場所 — database-first と doctor
最も特徴的なのがマイグレーション哲学です。AGENTS.md:
State/storage migrations are database-first. Runtime reads/writes the canonical store only. Old file stores, sidecars, aliases, and fallback readers belong in
openclaw doctor --fixmigration code only, never steady-state runtime.
つまり、ランタイムは正準ストア(現行 SQLite)しか触らない。古いファイルストアやサイドカーを読むコードは、openclaw doctor --fix のマイグレーションコードにだけ存在する。定常ランタイムにフォールバックリーダーを置かない。
実例: レガシーなプラグイン状態サイドカー(plugin-state/state.sqlite)からの移行(src/infra/state-migrations.ts:352)。
function readLegacyPluginStateSidecarRows(sourcePath: string): LegacyPluginStateSidecarRow[] {
const sqlite = requireNodeSqlite();
const db = new sqlite.DatabaseSync(sourcePath, { readOnly: true });
try {
return db.prepare(`
SELECT plugin_id, namespace, entry_key, value_json, created_at, expires_at
FROM plugin_state_entries ORDER BY plugin_id ASC, namespace ASC, entry_key ASC
`).all() as LegacyPluginStateSidecarRow[];
} finally { db.close(); }
}
ここでは生 SQL を使っていることに注目してください。AGENTS.md が「マイグレーションは Kysely 例外」と明記しているとおり、レガシー読み取りは migration コードの特権です。読み取った行は Kysely 経由で共有 DB に onConflict(...).doNothing() で投入され、旧ファイルは .migrated サフィックスでアーカイブされます。
スキーマバージョンは OPENCLAW_STATE_SCHEMA_VERSION(src/state/openclaw-state-db.ts:34)で管理し、PRAGMA user_version に保存。新カラム追加は**加算的(additive)**で、ensureAdditiveStateColumns() が対応し、バージョン bump を伴いません。破壊的修復(複合主キーの是正など)は doctor ロジックに閉じ込められます。
なぜここまで厳しくするのか — 一本道がもたらす平穏
この章の規律は一見オーバーエンジニアリングに見えるかもしれません。が、#01 の「正準パスは一つ」と繋げると腑に落ちます。
- ランタイムに「もし新形式が無ければ旧形式を読む」分岐を作らないから、状態読み込みコードが1本に保たれる。
- 互換の責務を doctor に一極集中させるから、「いつ・どこで・何を移行したか」が追える。
- すべてが SQLite だから、バックアップ・検査・移行が単一の仕組みで済む。
AGENTS.md の「Cache/transient state gets no compat migration ... Prefer delete/drop/rebuild over import.(キャッシュ・一時状態は互換移行せず、削除・再構築を優先)」という割り切りも、この哲学の延長です。失っても困らない状態は、移行せず捨てて作り直す。
まとめ — 正準ストアと、一極集中の互換
- 状態は 二層 SQLite(共有 DB / エージェント DB)に集約。厳格なファイルパーミッション。
- クエリは Kysely でコンパイル +
node:sqliteで同期実行。生 SQL は DDL/マイグレーション等の例外のみ。 - プラグイン KV は共有 DB の
plugin_state_entries(複合主キー + TTL)。プラグインは自前ファイルを作らない。 - マイグレーションは database-first: ランタイムは正準ストアのみ、互換は
openclaw doctor --fixに一極集中。
次回予告 — 周縁にして重要な、メディアと外部プロトコルへ
#11 は、ここまで触れてこなかった周辺だが重要な領域、**メディア生成・理解と外部プロトコル(MCP / ACP)**です。画像・動画・音声の生成と理解をどう capability として束ねるか、そして MCP サーバ/クライアントと ACP(Agent Client Protocol)で外部ツール・外部エージェントとどう繋がるかを読み解きます。
