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 TABLEINSERT は結果セットを持たない。素直に全部並べると、空のテーブルがいくつも表示されて何が起きたのか分かりません。

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

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 →
ToonCast

ToonCast ができるまで — AnimeGANv2 をブラウザで動かす

AnimeGANv2 の小さな ONNX (約9MB) を onnxruntime-web (単一スレッド WASM=COOP/COEP不要、 color-revive で承認済みライブラリの再利用) で実行。 512x512・[-1,1] 正規化で推論し、 結果を元解像度に戻して表示する設計記録。 写真は端末内処理。

read log →
ColorRevive

ColorRevive ができるまで — onnxruntime-web で白黒写真をカラー化

DeOldify の量子化 ONNX を onnxruntime-web (CDN side-load・単一スレッド WASM=COOP/COEP不要) で実行。 256x256 でモデル推論し、 輝度は元写真・色だけ AI を YCbCr で再合成して輪郭を保つ設計記録。 写真は端末内処理。

read log →
PhotoTwin

PhotoTwin ができるまで — CLIP画像埋め込みで似た写真を見つける

CLIP (Xenova/clip-vit-base-patch32) の image-feature-extraction を transformers.js の CDN ESM で side-load し、 各写真を正規化ベクトル化。 cosine 類似度で重複・似た写真をブラウザ内で検出する設計記録 (新ライブラリ追加なし=what-cam と同じ CLIP の再利用)。

read log →
AkinFind

AkinFind ができるまで — ブラウザ内で完結する embeddings 意味検索

多言語の文章埋め込みモデル (Xenova/multilingual-e5-small) を transformers.js の CDN ESM で side-load し、 各文を正規化ベクトル化。 cosine 類似度で意味検索と似ている文ペア検出を全て端末内で行う設計記録。

read log →
WhatCam

WhatCam ができるまで — CLIP のゼロショット画像分類をブラウザで動かす

CLIP (Xenova/clip-vit-base-patch32) を transformers.js の CDN ESM で side-load し、 写真と候補ラベルの近さをブラウザ内で計算。 日本語ラベルを英語プロンプトに変換し、 図鑑と自由入力の両モードで「これ何?」を判定する設計記録。

read log →
DepthCast

DepthCast ができるまで — 1枚の写真をAIの深度推定で立体にする

Depth Anything (transformers.js) を CDN ESM で side-load し、 1枚の写真から深度マップを推定。 WebGL2 フラグメントシェーダで深度に比例した視差 (iterative backward parallax) を作り、 赤青アナグリフ / WebM 書き出しまで端末内で完結させた設計記録。

read log →