▍AI Dev Lab
pg-pad

pg-pad ができるまで — PGlite で PostgreSQL をブラウザで動かす

ブラウザだけで本物のPostgreSQLが動くSQLプレイグラウンドの開発ログ。PGlite(WebAssembly版Postgres)をCDNから読み込む方法と、SQL道場との棲み分けを記録。

「PostgreSQL のウィンドウ関数、ちょっと試したいだけなんだけどな」

この「ちょっと試したい」が、毎回どうにも重い。Docker を起動して、postgres:16 を pull して、psql で入って、テーブルを作って、データを入れて——動かしたかったのは RANK OVER (PARTITION BY ...) の 1 行だけなのに、そこに辿り着くまでが長い。

ブラウザのタブを開いた 3 秒後には SELECT version; が返ってくる。 そういう場所が欲しかった。

それが pg-pad です。SQLite ではなく、本物の PostgreSQL がブラウザの中で動きます。


#🎯 なぜ SQLite ではダメだったのか

ラボにはすでに SQL道場 があります。こちらは sql.js(SQLite の WebAssembly 版)を使った学習サービスで、課題を解きながら SELECT から GROUP BY までを身に付ける構成です。

だから「ブラウザで SQL」というニーズ自体はすでに埋まっている——はずでした。ところが自分で使っていて、埋まっていない穴が 2 つあることに気づきます。

#穴 1: 方言が違う

SQLite と PostgreSQL は、基本の SELECT こそ同じでも、実務で確かめたい部分ほど食い違います。

  • serial / SEQUENCE が SQLite には無い
  • RANK DENSE_RANK などのウィンドウ関数の対応状況が違う
  • EXPLAIN の出力形式がまったく別物
  • 型の扱い(SQLite は型が緩い)

「Postgres でこう書けるか」を確かめたいときに SQLite で試すのは、確認になっていません。

#穴 2: 課題形式が邪魔になるときがある

SQL道場は正誤判定があるからこそ学習に効くのですが、「自分の書きたいクエリを自由に投げたい」ときには、その枠が邪魔になります。

学ぶための SQL と 確かめるための SQL は、必要な道具が違う。だから pg-pad は課題を一切持たず、エディタと実行ボタンと結果テーブルだけにしました。


#🏗️ 技術選定:PGlite という選択肢

PostgreSQL をブラウザで動かす、というのは数年前なら冗談みたいな話でした。それを現実にしたのが PGlite(@electric-sql/pglite)です。PostgreSQL 本体を WebAssembly にビルドし、サーバープロセスなしでライブラリとして呼べるようにしたもの。

決め手は 3 つでした。

  1. 本物の Postgres である — 互換実装ではなく、PostgreSQL のソースをそのまま WASM にしている
  2. 単体で完結する — サーバーもワーカーの前提もなく、new PGlite だけで立ち上がる
  3. サイズが現実的 — 数 MB 台で、初回だけ落とせば以降はキャッシュに乗る

#🔌 CDN サイドロードで読み込む

pg-pad の実装で一番のポイントは、PGlite を npm 依存として入れず、CDN から動的に読み込んでいるところです。

ts
const PGLITE_CDN = "https://cdn.jsdelivr.net/npm/@electric-sql/pglite@0.4.6/dist/index.js"

async function getDb: Promise<PGliteLike> {
 if (!dbPromise) {
 dbPromise = (async => {
 const url = PGLITE_CDN
 const mod = (await import(/* webpackIgnore: true */ /* @vite-ignore */ url)) as PGliteModule
 const db = new mod.PGlite
 await db.exec(SEED_SQL)
 return db
 })
 }
 return dbPromise
}

見ての通り、import に渡しているのは変数です。ここが肝で、リテラル文字列を書くとバンドラが静的に解決しようとしてビルドに巻き込まれます。変数にしておくと解決を諦めてくれるので、ブラウザネイティブの dynamic import としてそのまま実行時に走る。webpackIgnore / @vite-ignore のコメントは、その意図を明示するための保険です。

wasm ファイルは PGlite 側が dist/ からの相対で解決してくれるので、こちらで面倒を見る必要はありません。

このパターンは、ラボの PyPad(Pyodide で Python を動かすサービス)でも同じ形を使っています。重いランタイムを「使うときだけ、CDN から」読む——これが一度確立すると、以降のサービスで使い回せる資産になります。

#dbPromise を使い回す理由

getDb が返すのは DB インスタンスではなく Promise そのものをキャッシュしたものです。これは、初期化中に実行ボタンを連打されても PGlite が二重に起動しないようにするためです。Promise を保持しておけば、後続の呼び出しは全部同じ初期化を待つことになります。


#🌱 開いた瞬間に触れる状態を作る

「環境構築ゼロ」を謳う以上、開いた直後に空っぽのデータベースが出てくるのでは意味がありません。起動と同時にサンプルデータを流し込んでいます。

sql
CREATE TABLE employees (
 id serial PRIMARY KEY,
 name text NOT NULL,
 dept text NOT NULL,
 salary integer NOT NULL,
 hired date NOT NULL
);
INSERT INTO employees (name, dept, salary, hired) VALUES
 ('Sato Yuki', 'Engineering', 6200000, '2021-04-01'),
 ('Tanaka Aoi', 'Engineering', 7400000, '2019-07-15'),
 ...

employees(社員 7 名・4 部署)と projects(プロジェクト 3 件、lead_id で社員を参照)の 2 テーブル。この構成にしたのは、JOIN・GROUP BY・ウィンドウ関数のすべてが意味を持つ最小構成だからです。

部署が 4 つあるので GROUP BY dept が成立し、給与に幅があるので PARTITION BY dept ORDER BY salary DESC の順位付けに意味が出る。外部キーがあるので JOIN が自然に書ける。テーブルが 1 つだと、このどれも試せません。

サンプルクエリもボタンで用意しました。

ts
{
 label: "ウィンドウ関数 (部署内 順位)",
 sql: "SELECT name, dept, salary,\n RANK OVER (PARTITION BY dept ORDER BY salary DESC) AS rank_in_dept\nFROM employees;",
},
{
 label: "EXPLAIN",
 sql: "EXPLAIN SELECT * FROM employees WHERE salary > 6000000;",
},

SELECT version; を入れてあるのは半分ネタで、半分は本気です。これを実行すると PostgreSQL のバージョン文字列がそのまま返ってくる。「本当に Postgres が動いている」ことが、説明ではなく出力で伝わります。


#😓 苦労したところ

#複数文を投げたときにどれを表示するか

exec は複数の SQL 文をまとめて実行でき、結果は文の数だけ配列で返ってきます。ところが CREATE TABLE や INSERT は結果セットを持たない。素直に全部並べると、空のテーブルがいくつも表示されて何が起きたのか分かりません。

そこで、列を持つ結果だけを拾って、最後のものを表示する方針にしました。

ts
const shown = results.filter((r) => r.fields && r.fields.length > 0)
const last = shown[shown.length - 1] ?? null

CREATE TABLE ...; INSERT ...; SELECT ...; と 3 文投げたときに、期待どおり最後の SELECT の結果が出ます。実行した文の数と行数はステータス行に出しているので、「3 文 実行 ・ 7 行 ・ 42 ms」という形で全体の様子は分かる。

#起動待ちをどう見せるか

PGlite の初回起動は、CDN からの取得と WASM の初期化で数秒かかります。ここで何も出さないと「壊れている」と判断されて離脱されます。

対策として、ページ表示直後にウォームアップを走らせるようにしました。

ts
useEffect( => {
 let alive = true
 warmup.then((ok) => {
 if (!alive) return
 setReady(ok)
 setStatus(ok ? "準備 完了 — Ctrl/⌘ + Enter で 実行" : "起動 に 失敗 (ネットワーク を 確認)")
 })
 return => { alive = false }
}, [])

ユーザーがサンプルクエリを眺めている間に裏で起動が終わるので、実際に実行ボタンを押す頃には準備ができている。ステータス表示を「PostgreSQL を 起動 中…」→「準備 完了」と変えることで、待ち時間が正体不明の空白にならないようにしています。

alive フラグは、初期化が終わる前にページを離れた場合に状態更新を止めるためのものです。

#リセットの実装

いろいろ試した後に初期状態へ戻したくなるので、リセットを付けました。中身は身も蓋もなく、キャッシュした Promise を捨てて作り直すだけです。

ts
export async function resetDb: Promise<void> {
 dbPromise = null
 await getDb
}

DROP TABLE を並べて掃除する実装も考えましたが、ユーザーが勝手に作ったテーブルまで面倒を見ることになって際限がない。丸ごと捨てるほうが確実でした。


#🔭 今後の拡張

  • クエリ履歴 — 実行したものを残して呼び戻せるように
  • CSV / JSON のインポート — 自分のデータで試せると用途が一段広がる
  • 結果のエクスポート — 集計した表をそのまま持ち出せるように
  • スキーマビューア — 今あるテーブルと列を横に表示する
  • 永続化 — PGlite は IndexedDB へのデータ保存に対応しているので、タブを閉じても続きから触れる

#💡 このサービスから言えること

「サーバーが要る」と思われている機能が、実はもうブラウザで動く——このケースは思っているより多いはずです。PostgreSQL がその筆頭でした。

サーバーを持たないと決めると、運用コストがゼロになるだけでなく、入力したデータがどこにも送られないという性質がついてきます。業務のスキーマを試しに貼っても、それは自分の端末から出ません。プライバシーの説明が「送っていません」ではなく「送る先がありません」になるのは、かなり強い。

もうひとつ。SQL道場と pg-pad は、どちらも「ブラウザで SQL」ですが役割が違います。学ぶための道具と、確かめるための道具は分けたほうがいい。 1 つのサービスに機能を足して両方に対応させると、どちらの目的にとっても中途半端になります。並べて置いて、必要なほうを選んでもらう。そのほうが結果的に両方使ってもらえる、という感触があります。

[ ./next_action ]

読んだら、 pg-pad を実際に動かす。

この開発ログは pg-pad をどう作ったかの記録です。 読み終わったらそのままサービス本体へ戻って、 実物で価値を確かめてください。

[ ./related_logs ]

関連する開発ログ

all logs →
ShadowTwin

立体パズルの別解を作る:同じ影と最少の積み木

ShadowTwinの開発記録。3方向の図から別々の最少配置を2つ探す仕組み、5問の独立全探索、スマホの横並び比較、保存・再編集と入力検査を解説します。実照明の計算や能力診断ではありません。

read log →
SlitCard

スリットアニメーションを作る:4コマの合成と透明原稿の設計

SlitCardの開発記録。4コマの帯を交互に合成し、すき間と黒帯で別のコマを見せる仕組みを解説。同縮尺の台紙・透明シートSVG、50mm確認線、手動プレビュー・初期非表示、再編集と実出力検査を整理します。

read log →
WindowLight

日当たりシミュレーションを作る:窓の光を床へ投影する幾何モデル

WindowLightの開発記録。太陽位置近似から窓の4隅を床へ投影し、凸多角形を部屋で切り取る設計を解説。国立天文台の16時刻との限定照合、大気差・低高度・実際の採光との違い、SVGと設定の再編集を整理します。

read log →
GridLights

電源構成の比較を作る:割合と実量を100個の電球で読み解く

電源構成比較GridLightsの開発記録。12か国の年次発電データを最大剰余法で100個の電球に変換し、割合とTWhを比較する設計を解説。欠損を0補完しない7分類、予想と実値、出典付きSVGと実データ検証を整理します。

read log →
SoundLoom

音のお絵かきを作る:実WAVから絵を測り直す設計

音のお絵かきの開発記録。描画を正弦波のPCMとWAVへ変換し、保存音声の周波数をGoertzel法で測って比較する仕組みを解説。音量・測定窓・量子化の限界と、編集・離脱時の古い結果を抑止する検証を整理します。

read log →
MazeLoom

宝探し迷路を作る:寄り道まで含めた最短ルートの探索

宝探し迷路の開発記録。通路を木として生成し、現在地と3つの宝の取得状態を組み合わせて最短ルートを探す仕組みを解説。問題コードの版固定、画面と解答付きPDFの一致、手数と印刷の検証を整理します。

read log →