コンテンツにスキップ

ADR-0053: DB の記事は PGlite を使い、Node のコードからは Unix ドメインソケットで pg から接続し、SQL は DB のプロセスの標準入出力で送る

Status

承認

Context

訪問者が Postgres の SQL を動かす形と、Node のコードから同じ DB に接続する形を決める必要がある。
記事には、pg のコードを、手元の Postgres に接続するときと同じ書き方で載せたい。

Decision

1. SQL だけを動かす記事

SQL だけを動かす記事は、Node の実行環境を使わず、PGlite を単体で動かす。

  • PGlite を動かすページは、プレイグラウンドのサブドメインの、isolation のヘッダを返さないパス(/sql/)に置く。
  • PGlite は、Web Worker で動かす。ツールのページのメインスレッドでは、SQL を実行しない。重いクエリの間も、画面の操作を受け付けるためである。
  • Web Worker が異常終了したときは、SQL の画面にエラーを出す。次の実行で、Web Worker を作り直す。DB の中身は、初期の状態に戻る。
  • PGlite で SQL を実行して結果の形にする関数、SqlDatabase の型、PGlite を Web Worker で動かす SqlDatabase の実装は、package(@fukuemon/sql-runtime)に置く。DB のプロセスのスクリプトも、同じ関数を使う。
  • PGlite は、利用者が実行のボタンを押したときのほかに、AI のエージェントが SQL を実行する操作を呼んだときにも読み込む。読み込んでから、操作を実行する。
    • 操作が呼ばれるのは、利用者がスイッチをオンにし、自分のエージェントをつないだ後である。
    • ADR-0045 の Decision 6 と同じく、エージェントの操作で読み込むことも、利用者の操作として扱う。
  • SQL だけを動かす記事には、isolation のヘッダが適用されない。外部の画像と埋め込みを使える。
  • 実行計画は、EXPLAIN (ANALYZE, BUFFERS) のテキストと、FORMAT JSON の構造の両方を使える。

2. Node のコードから DB に接続する記事

Node のコードから DB に接続する記事は、実行環境の中の PGlite に、Unix ドメインソケットで接続する。

  • 実行環境が、PGlite と pglite-socket を持つ Node のプロセスを起動する。ソケットのパスは /tmp/.s.PGSQL.5432 にし、maxConnections に 2 以上を渡す。
  • このプロセスのファイルと依存(PGlite、pglite-socket)は、訪問者の作業ディレクトリの外に置く(ADR-0052)。訪問者のファイルのツリー、作業ディレクトリ、package.json には出さない。
  • 訪問者が、端末で ls /tmp と ps を実行したときに、このプロセスとファイルが見えることは、受け入れる。
  • DB を使うかどうかは、プロジェクトの設定で選ぶ。DB を使わないプロジェクトでは、このプロセスを起動しない。
  • 実行環境は、起動のときに、プロジェクトの初期データの SQL を実行する。
  • 実行環境は、訪問者のプロセスに、次の環境変数を渡す。
    • PGHOST=/tmp、PGUSER=postgres、PGDATABASE=postgres
    • DATABASE_URL=postgres://postgres@%2Ftmp/postgres
  • 記事のコードは、new Pool() のように引数なしで書く形と、new Pool({ connectionString: process.env.DATABASE_URL }) と書く形の、どちらも使える。
  • TCP の localhost:5432 では待ち受けない。WebContainers の中の TCP のソケットでは、pg の接続が失敗するためである。
  • 実行環境は、DB を使うプロジェクトで、訪問者のプロセスに、Unix ドメインソケットに接続している間はプロセスを終了させない preload を追加する。
    • 訪問者が書いた短いスクリプトが、エラーなしでクエリの途中で終了しないようにするためである。
    • 記事には、node seed.js のような短いスクリプトと、HTTP のサーバーのコードの、どちらも書ける。

Unix ドメインソケットを採る理由は、WebContainers の TCP の実装に依らずに pg の接続が通ることと、環境変数で接続先を渡す書き方が、本物のアプリの書き方と同じであることである。

3. SQL のペインと Node のコードが使う DB

DB を使うプロジェクトでは、Node の実行環境のページに、SQL を実行するペインを置く。
SQL のペインと Node のコードは、同じ DB(実行環境の中の PGlite)を使う。
訪問者が、SQL のペインで作ったテーブルを、Node のコードから読めるようにするためである。

  • SQL のペインは、Node の実行環境というツールの一部として扱う。ADR-0031 の「1 つのページに、複数のツールを置かない」は、変えない。
  • SQL の入力欄と、結果の表のコンポーネントは、SQL だけを動かすツールのページ(/sql/)と共有する。

4. SQL を送る形式

SQL のペインは、DB のプロセスの標準入出力で SQL を送り、結果を受け取る。
標準入出力で送る理由は、プロセスもポートも増やさずに、Chromium、WebKit、Firefox で動くことである。

  • リクエストは、1 行の JSON にする({"id": "<連番>", "sql": "<SQL>"})。
  • レスポンスは、行頭に固定のプレフィックスを付けた、1 行の JSON にする。
    • 成功のときは、id、文ごとの結果の一覧、実行の時間を持つ。
    • 失敗のときは、id、エラー(メッセージ、SQLSTATE、位置)、実行の時間を持つ。
    • 型は、プレイグラウンドの Design Doc にある。
  • プレフィックスの無い行(DB のプロセスのログと、入力のエコー)は、実行環境が捨てる。
  • 複数の文を 1 回で送ったときは、最後の文の結果を表示する。1 つの文が失敗したときは、全部を取り消し、取り消したことをエラーの表示に書く。
  • SQL だけを動かすツール(Web Worker で動かす PGlite)も、同じ SqlResponse の形を返す。
  • DB のプロセスの状態は、SqlDatabase の status で持ち、NodeRuntime には持たせない。DB のプロセスが終了したときは、待っているリクエストにエラーを返し、SQL のペインにだけ、DB が止まったことをエラーとして出す。エラーの隣に、「実行環境を起動し直す」の操作を出す。起動し直した後は、再訪と同じに扱う。Node のコードと端末は、動かし続ける。

5. 結果の行の表示

SQL の結果は、100,000 行までを全て返し、ページ送りで見られるようにする。

  • 1 ページの行の数は、訪問者が選ぶ。
  • 1 つの結果が 100,000 行を超えるときは、先頭の 100,000 行だけを返し、全体の行の数と、切ったことを表示する。
  • 100,000 行にする理由は、Chromium で、100,000 行の結果を受け取るのに 15 秒、300,000 行で 39 秒かかり、1,000,000 行は 120 秒で返らなかったためである。
  • SQL だけを動かすツールも、同じ上限にする。

承認の条件

  • 複数の文のうち 1 つが、実行のときのエラー(制約の違反など)で失敗したときに、全部が取り消されることを確認している。確認したのは、構文エラーの 1 件である。
  • SQL だけを動かすツール(Web Worker で動かす PGlite)で、100,000 行の結果を返す時間を測っている。
  • @fukuemon/sql-runtime の Web Worker の実装が、apps/playground の Vite のビルドで動くことを確認している。メインスレッドで PGlite を動かしたときの画面への影響は、測っていない。
  • @fukuemon/node-runtime が export する Vite の plugin で、DB のプロセスのスクリプトに関数を束ねられることを確認している。

Verification

WebContainers 1.6.4 の中の Node 24.22.2 で、次のパッケージを動かした。

  • PGlite 0.5.16、@electric-sql/pglite-socket 0.2.19
  • pg 8.16.3、postgres.js 3.4.7、pg-gateway 0.3.0-beta.4

マシンは Apple M2、ブラウザは Playwright 1.65.0 の Chromium、WebKit、Firefox(headless)、配信は localhost である。
実機のブラウザは未確認である。

pg で接続できる方式

クライアントは、接続、select、パラメータ付きのクエリ、EXPLAIN (ANALYZE, BUFFERS)、begin と rollback を順に実行した。
クライアントのスクリプトは、25 秒で打ち切るタイマーを持つので、プロセスは、クエリの途中で終了しなかった。

方式 サーバー クライアント 結果
Unix ドメインソケット pglite-socket pg(host: "/tmp") 動く。1 つの接続、2 つの同時の接続、Pool
Unix ドメインソケット pglite-socket pg(引数なし。PGHOST などの環境変数) 動く
Unix ドメインソケット pglite-socket pg(postgres://postgres@%2Ftmp/postgres) 動く
Unix ドメインソケット pglite-socket postgres.js(path) 動く
TCP pglite-socket pg 動かない。invalid frontend message type 47
TCP pglite-socket postgres.js 動かない。25 秒で打ち切り
TCP 自前のブリッジ、pg-gateway pg、postgres.js 動かない。25 秒で打ち切り
TCP。net.Socket の write をラップする preload を、両方のプロセスに追加 pglite-socket pg 動く。1 つの接続、2 つの同時の接続、Pool
  • Unix ドメインソケットと、preload を追加した TCP の行は、3 つのブラウザで同じ結果だった。preload なしの TCP の行は、Chromium で測った。
  • WebContainers の中の TCP のソケットは、大きな ArrayBuffer の一部を指す Buffer を write に渡すと、サーバーに 65536 バイトを届けた。届いたバイト列の途中に、クライアントが書いたバイト列が入っていた。pg と pglite-socket は、どちらもこの形の Buffer を送る。
  • pglite-socket の maxConnections のデフォルトは 1 で、2 つ目の同時の接続は切られる。
  • Unix ドメインソケットで、pg の 1 回の実行(接続から切断まで)は、28〜54 ミリ秒だった。
  • Prisma、Drizzle、Knex のような、pg を中で使うライブラリは、試していない。

短いスクリプトの終了

Unix ドメインソケットの pglite-socket に、pg で接続する Node のコードを、次の表の形で実行した。
Pool と Client のスクリプトは、テーブルの作成、行の追加、行の数の select を順に実行し、end() を呼ぶ。
preload は、net.Socket の connect をラップし、パスを指定した接続が閉じるまで、setInterval のタイマーを動かす。
preload は、node -r で追加した。

Node のコードの形 Chromium WebKit Firefox
new Pool() でクエリを 3 つ実行して終わるスクリプト 1 つも終わらずに終了 最後まで実行 最後まで実行
new Client() で接続し、クエリを 3 つ実行して終わるスクリプト 接続の後に終了 1 つ目のクエリの後に終了 最後まで実行
同じスクリプトで、setInterval のタイマーを動かしておく 最後まで実行 最後まで実行 最後まで実行
HTTP のサーバーが待ち受けているプロセスの中で、同じクエリを実行する 最後まで実行 最後まで実行 最後まで実行
Pool と Client のスクリプトに、preload を追加する 最後まで実行 最後まで実行 最後まで実行
  • 途中で終了したプロセスの終了コードは 0 で、エラーは出なかった。
  • preload を追加したスクリプトは、end() の後に、終了コード 0 で終了した。
  • 原因は、WebContainers の中の Unix ドメインソケットが、Node のイベントループを動かし続ける対象になっていないこととみられる。WebContainers の中の実装は、確認していない。
  • net.Socket の write をラップする preload を追加した TCP では、Client のスクリプトは、3 つのブラウザで最後まで実行した。Pool のスクリプトは、行を入れた後、20 秒たっても終了しなかった。
  • preload を追加したプロセスで、接続を閉じないスクリプトが終了するかは、未確認である。
  • この preload は、HTTP の流れを記録する preload と同じプロセスに追加しても動き、NODE_OPTIONS で追加できた。結果は、ADR-0052 の Verification にある。

SQL を送る方法

実行環境の中で、1 つの Node のプロセスが、PGlite と、Unix ドメインソケットの pglite-socket を持つ。
ツールのページが、select、EXPLAIN (ANALYZE, BUFFERS)、Node のコードが入れた行の select を送り、結果を受け取るまでの時間を測った。
select と EXPLAIN は、5 回ずつ送った。

方法 Chromium WebKit Firefox
DB のプロセスの標準入出力 動く。1〜5 ミリ秒 動く。1〜8 ミリ秒 動く。3〜23 ミリ秒
pg で接続したままの、別のプロセスの標準入出力 動く。1〜7 ミリ秒 動く。2〜11 ミリ秒 動く。4〜25 ミリ秒
SQL ごとに、pg で接続する Node のプロセスを起動する 動く。255〜352 ミリ秒 動く。273〜326 ミリ秒 動く。313〜462 ミリ秒
ツールのページから、実行環境の中の HTTP のサーバーへ fetch する 動かない 動かない 動かない
実行環境の中の HTTP のサーバーが返すページを iframe で開き、postMessage で頼む 動く。6〜12 ミリ秒 動く。7〜14 ミリ秒 動かない
  • DB のプロセスの標準入出力では、ツールのページが、プロセスの入力に 1 行の JSON を書き、出力から同じ id の行を読んだ。
  • 動いた方法の全てで、EXPLAIN (ANALYZE, BUFFERS) の 10 行を受け取れた。cost、actual time、Buffers:、Planning Time、Execution Time を含む。
  • 動いた方法の全てで、Node のコードが pg で入れた行を、ツールのページで読めた。ツールのページから入れた行を、Node のコードが pg で読めた。
  • fetch は、ブラウザが CORS で止めた。サーバーのコードは Access-Control-Allow-Origin: * を返すが、Chromium のコンソールには、このヘッダがレスポンスに無いというエラーが出た。
  • iframe のページは、Firefox で読み込まれなかった。

大きな SQL と、大きな結果

DB のプロセスの標準入力に、1 行の JSON で SQL を書き、応答の行を読むまでの時間を測った。DB のプロセスは、PGlite の exec で実行した。

SQL サイズ Chromium WebKit Firefox
insert を 300 個と select を 1 個 14.5 KB 18 ミリ秒 19 ミリ秒 14 ミリ秒
insert を 3000 個と select を 1 個 153 KB 117 ミリ秒 80 ミリ秒 63 ミリ秒
1 MB の文字列リテラルを持つ select を 1 個 1 MB 72 ミリ秒 27 ミリ秒 57 ミリ秒
  • DB のプロセスが受け取ったバイト数は、送ったバイト数と同じだった。
  • 150 行目に構文エラーがある 300 行の SQL を送ると、エラーが返った。エラーの前の 149 個の insert は、入っていなかった。
  • プロセスの出力には、ツールのページが標準入力に書いた行が、そのまま出た。

5 つのカラム(整数、文字列、整数、32 文字の文字列、時刻)を持つ行を、generate_series で作って select し、応答の行を読むまでの時間を測った。

行の数 応答の行のサイズ Chromium WebKit Firefox
1,000 0.12 MB 0.14 秒 0.02 秒 0.03 秒
10,000 1.2 MB 1.3 秒 0.13 秒 0.33 秒
100,000 12.5 MB 15.0 秒 1.2 秒 3.2 秒
300,000 37.8 MB 38.6 秒 4.0 秒 9.2 秒
1,000,000 126.7 MB 120 秒で返らない 14.9 秒 32.5 秒
  • 時間の大半は、DB のプロセスの中の、クエリの実行と JSON への変換だった。ツールのページの JSON.parse は、1,000,000 行で 0.24〜0.30 秒だった。
  • ツールのページのメモリの使用量と、100,000 行を表に描く時間は、測っていない。
  • SQL だけを動かすツール(Web Worker で動かす PGlite)で、同じ行の数を返す時間は、測っていない。

DB のプロセスのファイルの置き場

DB のプロセスのスクリプトを /tmp の下のディレクトリに置き、依存を npm install --prefix <そのディレクトリ> で入れた。3 つのブラウザで同じ結果だった。

  • DB のプロセスが起動し、訪問者のプロセスから pg で接続できた。
  • 訪問者の package.json と node_modules に、PGlite と pglite-socket は入らなかった。作業ディレクトリの ls -a に、DB のプロセスのファイルは出なかった。
  • ls -a /tmp には、ディレクトリと .s.PGSQL.5432 が出た。ps には、DB のプロセスが node として出た。
  • 依存の npm install(9 つのパッケージ)は、3.1〜4.5 秒だった。

Considered Options

選択肢 強み 弱み
採用: Unix ドメインソケットで待ち受け、環境変数で接続先を渡す。接続している間はプロセスを終了させない preload を、訪問者のプロセスに追加する WebContainers の TCP の実装に依らない。記事のコードが、環境変数で接続する本物のアプリと同じ書き方になる。短いスクリプトも、HTTP のサーバーのコードも書ける host: "localhost" と port: 5432 を書いたコードは動かない。接続先が Unix ドメインソケットであることを、記事で説明する。preload が、Node の net.Socket の connect をラップする
Unix ドメインソケットで待ち受け、preload を追加しない。DB に接続するコードは、HTTP のサーバーの中で DB を使う形に限る Node に手を加えない 記事に、node seed.js のような短いスクリプトを書けない。訪問者が自分で書いたスクリプトが、エラーなしで途中で終了する
TCP で待ち受け、net.Socket の write をラップする preload を、全てのプロセスに追加する 記事のコードが、接続先の書き方まで手元と同じになる preload が、DB のプロセスと訪問者のプロセスの両方に要る。Pool を使う短いスクリプトが、20 秒たっても終了しなかった。WebContainers の中の動きに依る。HTTP のサーバーと、HTTP の記録の preload への影響を確認していない
Unix ドメインソケットと、preload を追加した TCP の両方で待ち受ける どちらの書き方も動く 2 つの方式を保守する。preload の弱みを、そのまま持つ
実行環境の中の PGlite を、HTTP で使う 追加の仕組みが少ない 記事のコードが、pg と違う書き方になる
SQL の画面と Node のコードが、別の DB を使う SQL の画面が、どの記事でも、SQL だけを動かすツールと同じく、Web Worker で動かす PGlite で動く。実行環境へ SQL を送る仕組みが要らない 訪問者が SQL の画面で作ったテーブルを、Node のコードから読めない

SQL のペインを置く場所の選択肢は次のとおり。

選択肢 強み 弱み
採用: Node の実行環境のページに、SQL のペインを置く 訪問者が、同じ画面で、SQL と Node のコードを行き来できる。トップレベルで開いたページでも使える Node の実行環境のページのペインが増える
記事に、Node の実行環境の iframe と、SQL のツールの iframe を並べ、記事のページが postMessage で中継する ページとツールが、1 対 1 のままになる 記事のページに、中継のスクリプトが要る。トップレベルで開いたときに、SQL を送れない

DB のプロセスとファイルを、訪問者から隠す範囲の選択肢は次のとおり。

選択肢 強み 弱み
採用: ファイルのツリー、作業ディレクトリ、package.json に出さない。ls /tmp と ps に出ることは、受け入れる 訪問者のプロジェクトに、実行環境のファイルが入らない。訪問者は、シェルを制限なしで使える 訪問者が、DB のプロセスを kill で止められる
シェルに、ps と /tmp を隠す手段を探す 訪問者から、プロセスとファイルが見えない WebContainers のシェルに、その手段があるかは未確認である。node -e で /tmp を読める
シェルを出さない 訪問者から、プロセスとファイルが見えない 訪問者が、自分でコマンドを試せない

実行環境の中の PGlite へ SQL を送る方法の選択肢は次のとおり。

選択肢 強み 弱み
採用: DB のプロセスの標準入出力 プロセスが増えない。ポートを使わない。3 つのブラウザで動く DB のプロセスの標準出力に、SQL のペインへの応答の行が流れる
pg で接続したままの、別のプロセスの標準入出力 SQL のペインも、Node のコードと同じ pg の接続で DB を使う プロセスが 1 つ増える
SQL ごとに、pg で接続する Node のプロセスを起動する 常に動くプロセスが増えない 1 回の実行が 255〜462 ミリ秒かかる
実行環境の中の HTTP のサーバーが返すページを iframe で開き、postMessage で頼む 標準入出力を使わない Firefox で動かない。ポートを 1 つ使い、訪問者のプレビューの一覧に出る

標準入出力で送る形式の選択肢は次のとおり。

選択肢 強み 弱み
採用: リクエストもレスポンスも 1 行の JSON にし、レスポンスにプレフィックスを付ける 1 MB の SQL まで動いた。SQL の中の改行は、JSON のエスケープで 1 行に収まる。エコーの行を捨てられる DB のプロセスのログが、プレフィックスで始まる行を出すと、レスポンスとして読む
長さを先頭に書くフレームにする 本体に何が入っていても区切れる プロセスの出力は、ログも流れるテキストのストリームで、フレームの途中にログが入ると読めない
SQL と結果を、実行環境のファイルに書いて渡す 出力のストリームに、ほかの行が入らない ファイルの監視が要る。1 回にかかる時間は、測っていない

結果の行の表示の選択肢は次のとおり。

選択肢 強み 弱み
採用: 全ての行を返し、ページ送りで見せる。100,000 行を超える結果だけを切る 訪問者が、結果の全体を見られる。大きすぎる結果で、ブラウザが長く待たない 100,000 行に近い結果は、Chromium で 15 秒かかる
1000 行で切る 応答が速い 訪問者が、1001 行目から後を見られない
上限を置かない 切る処理が無い Chromium で、1,000,000 行の結果が 120 秒で返らない
ページごとに、DB のプロセスに問い合わせる 1 回の応答が小さい ページを送るたびに、SQL を実行し直す。副作用のある SQL と、結果が変わる SQL を扱えない

PGlite を読み込む時期の選択肢は次のとおり。

選択肢 強み 弱み
採用: 実行のボタンを押したときのほかに、AI のエージェントが SQL を実行する操作を呼んだときにも読み込む 利用者は、エージェントをつないだ後に、実行のボタンを押さなくても、エージェントに実行させられる スイッチがオンの間は、利用者が実行のボタンを押していなくても、エージェントの操作で読み込む
PGlite を読み込んでいないときは、AI のエージェントからの操作が失敗を返す 読み込む時期が、実行のボタンを押したときの 1 つで済む 利用者が、先に実行のボタンを押すことになる

Consequences

Positive

  • 訪問者は、記事の pg のコードを、手元のアプリにそのまま持ち出せる。
  • 訪問者は、SQL のペインで実行計画を読みながら、同じ DB を Node のコードから使える。
  • 利用者は、エージェントをつないだ後に、実行のボタンを押さなくても、エージェントに SQL を実行させられる。
  • SQL だけを動かす記事は、WebContainers のライセンスとブラウザの対応に依らずに公開できる。
  • 訪問者が書いた短いスクリプトは、クエリを最後まで実行してから終了する。
  • SQL のペインのために、実行環境の中のプロセスとポートが増えない。

Negative

  • localhost:5432 に接続するコードは、実行環境の中で動かない。
  • Node のコードから DB に接続する記事は、PGlite と pglite-socket を npm で入れる転送量が増え、起動に 3.1〜4.5 秒が加わる。
  • DB を動かす形が 2 つになり、初期データを入れる処理と、SQL を送る先を、2 つの形で持つ。
  • 実行環境が訪問者のプロセスに追加する preload が、HTTP の流れを記録するものと合わせて 2 種類になる。接続している間はプロセスを終了させない preload は Node の net.Socket の connect をラップするので、Node と WebContainers のバージョンを上げるときに、短いスクリプトの動きを確認し直す。
  • DB のプロセスの標準出力に、SQL のペインへの応答の行が流れる。実行環境は、応答の行と、DB のプロセスのログを、プレフィックスで区別して読む。
  • 訪問者が、端末で ps と ls /tmp を実行すると、DB のプロセスとファイルが見える。訪問者は、DB のプロセスを kill で止められる。止めた後は、「実行環境を起動し直す」まで、SQL のペインと、Node のコードからの接続が使えない。
  • 100,000 行を超える結果は、先頭の 100,000 行だけを見られる。
  • 複数の文を 1 回で送り、1 つが失敗すると、成功した文も取り消される。
  • スイッチがオンの間は、利用者が実行のボタンを押していなくても、エージェントの操作で、PGlite を読み込む。