AIエージェント在りし今、ORMは窓から投げ捨てろ

Codexでの一連の作業(Drizzle ORMを除いてSQLだけを使うようにした)を振り返り、煽り記事をAIに書いてもらいました。読みにくいと思うので、AIに要約させてください。どうぞ!

AIエージェント在りし今、ORMは窓から投げ捨てろ

ORMを使う理由のかなりの部分は、「SQLを人間が安全に書き続けるのはつらい」という現実への対処だった。

テーブル定義から型を作り、条件を組み立て、プレースホルダーを管理し、結果をアプリケーションの型へ変換する。変更のたびにSQLとTypeScriptの整合性を人間が確認するくらいなら、その面倒をライブラリへ押し込んだほうがよい。これは長い間、きわめて合理的な判断だった。

しかし、コードベースを横断してSQL、型、テスト、ドキュメントを同時に更新できるAIエージェントが実用になった今、その前提は少し変わったのではないか。

今回、SvelteKitをCloudflare Workers上で動かし、TiDB Serverlessを永続化先とするアプリケーションからDrizzle ORMを段階的に撤去した。結論から言えば、ORMが担っていた安全性を捨てたわけではない。安全性を「production実行時の抽象化」から「SQLスキーマ、明示的な型、実DBを使う契約テスト、エージェントの作業規約」へ移した。

この記事では、なぜ撤去したのか、AIエージェントが何を変えたのか、実際にどの順序で移行したのか、そしてエージェントへどのようなルールを与えたのかをまとめる。

ORMを外したかった理由

発端は、認証済みリクエストのたびに呼ばれる getUserFromId だった。この関数はセッション内のIDから利用者を取得するだけだが、そのためにORMを経由していた。

ORMと直接SQLの差をローカルで測ると、劇的な差ではない。ネットワーク越しのDBアクセスでは通信時間のほうが支配的になりやすく、ORMだけを外せば何十ミリ秒も速くなる、という話ではなかった。

それでも、このアプリケーションの実行環境はCPU時間を意識する必要があるCloudflare Workersだ。ローカルでは誤差に見える処理でも、productionでは1ミリ秒前後の差になる可能性がある。しかも getUserFromId は一部の重い管理処理ではなく、広い範囲のリクエストが通る経路にある。小さな固定費ほど、ホットパスでは無視しにくい。

もう一つの理由は、コードベースを調べるとORMの高度な機能をあまり使っていなかったことだ。すでに生SQLが混在し、複雑な集計や認可条件はSQLで表現していた。ORMは永続化層全体を統一する中心的な抽象化というより、単純なCRUDの一部を包む依存になっていた。

つまり、負担しているものは次のようになっていた。

  • Worker bundleへ入る依存と初期化コスト
  • ORMのスキーマ定義と実DBスキーマを二重管理するコスト
  • 生成されるSQLを推測または確認する認知負荷
  • 生SQLとORMが混在することで生じる二つの流儀

一方、得ていた恩恵は限定的だった。このバランスなら、直接SQLへ寄せる実験には十分な理由がある。

AIエージェントが変えたのは「SQLを書くコスト」だけではない

手書きSQLへの典型的な反論は正しい。列名を間違える。引数の順序を間違える。nullableな列をnullableでない型として扱う。mutationの件数を確認し忘れる。transactionの途中で別connectionを使う。そして、スキーマ変更とクエリ変更が簡単にずれる。

AIエージェントも、放っておけば同じ間違いをする。むしろ大量のコードを短時間で変更できるため、規約がなければ間違いを広範囲へ複製する危険がある。

重要なのは、AIがSQLを生成できることではない。AIに次の一連の作業を毎回要求できることだ。

  1. 正本のSQLスキーマを読む
  2. クエリと専用の結果型を同じdata-access moduleへ置く
  3. 実TiDBへクエリを流す
  4. 返却列、JavaScript上の型、nullability、件数を検査する
  5. mutation後の値を読み戻す
  6. transactionのcommitとrollbackを試す
  7. typecheck、test、lint、production buildを通す
  8. 実装と同じ変更で設計・運用文書を更新する

以前なら、この確認をすべて手作業で続けるコストがORM導入の強い根拠になった。AIエージェントは、その反復コストをかなり引き下げる。ORMが不要になる可能性は、「AIなら正しいSQLを書ける」ことよりも、「面倒だが機械的に検証できる作業を、変更のたびに完遂させられる」ことから生まれる。

型安全性をproductionのCPUで買わない

直接SQLへ移行するとき、最初に悩んだのは結果行の型だった。DBドライバの返却値を as unknown as UserRow[] と書くだけなら簡単だが、根拠のない二重castは単なる願望である。

かといって、productionの全リクエストで汎用schema validatorを使い、全行をparseするのも今回の目的に合わない。そもそもの動機がWorkerのCPU負荷とbundleを小さくすることだからだ。

そこで、安全性を事前検証へ移した。

export type MemberByIdRow = {
  id: number;
  email: string;
  n: string | null;
  role: string;
  groupId: number | null;
  disabled: number;
};

各クエリには、選択する列とaliasに正確に対応した名前付きrow型を定義する。DB結果のcastは、そのSQLを所有するserver-onlyのquery module内だけで許可する。そして対応する契約テストで、実TiDBが返す次の内容を確認する。

  • keyが完全に一致すること
  • 各値のJavaScript上の型
  • nullable列が実際に null になるケース
  • aggregateや計算列の型
  • zero-or-one、one、manyといったcardinality
  • アプリケーション型へ正規化した結果

特に注意したのが LEFT JOIN である。右側テーブルの列がスキーマ上 NOT NULL でも、対応する行がなければクエリ結果は null になる。したがって、結果型は元テーブルの列定義ではなく、クエリ全体の式から決めなければならない。

MacBook Neo購入

MacBook Neoを購入しました。

ちょっとしたメモを取るために、数年前に購入したChromebookを利用していましたが、とにかく動作の遅さに辟易していました。 何をするにも待たされる。精神衛生上良くないので、iPad+Bluetoothキーボードの組み合わせを利用しようかと考えていましたが、 どうせキーボードは必須で考えていたので、ノート型PCで良いかと思い直し、ちょうど新製品発表されたので購入しました。

メモリは8GBしかないので、本格的な開発に利用することは考えていませんが、SSD 256GBは心もとなく感じたので512GB版を購入。

良い点:圧倒的にスムーズな動作。ちゃんとPC。流石のApple製品クオリティ。廉価版製品とはいえ、スペック以外で見劣る箇所はない。

悪い点:Chromebookよりは重い、高価

価格について言えば、Apple製品はきれいに利用していればそれなりの価格で下取りに出せるので、実は他のPC製品より安いという考え方もできますね。

Chromeの代わりにSafariを利用し、必要なタブだけ利用。エディタもVS Codeではなくメモ取得にはnvim(+Markdown拡張)を利用するようにしました。 多くのメモリが必要な開発をするのであれば、OCI (Oracle Cloud Infrastructure) の24GBメモリを使えるインスタンスを利用すれば良いと考えています。

この程度の利用であればスワップを起こすこともなく、とても快適に利用できています。

NuPhy Node75を買った

NuPhy Node75を購入しました。

MX Keys Miniとの比較

ここ1年くらいMX Keys Miniを使っていました。電池持ちも無茶苦茶良い(多分1年で1,2回くらいしか充電していない)し、接続性も良く、便利この上ないのだけど、ただキータッチやキー表面加工が気になりだしていました。

最近では、無線3セットかつ有線接続が可能なものがかなり一般的になっていますね。何か良さそうなのは無いかな、と思っていたところ、かなり良さそうに思えたので、AliExpressで注文。注文してから1週間程度で到着しました。

購入したのはLow Profile, Lunar White, Blush nanoバージョンです。

個人的にはキー数の多い日本語キーボードの方が利便性が高いと思っているので、現時点でUS配列しか無いのがちょっと引っかかったものの、ここは妥協。

先に書いたBluetooth 3セットかつ有線に加えて、専用USBドングルでの無線接続も可能と、これ以上は不要なレベルの接続性を備えています。

また、交換用の色付きのキー(商品写真に写っているもの)と、ご丁寧にキープラーも付属しています。

ひとまず、IME On/OffはCtrl+Spaceを割り当てました。

個人的にはそれほど必要性は無いのですが、キー割当も完全に自由自在で、バックライトの点灯方法などもカスタマイズでき、至れり尽くせりの機能性があると思います。

ここまでキータッチ軽くなくても良いかな、という印象はありますが、これはこれで心地よいと思います。

MX Keys Miniもそうなのですが、このキーボードも、キー表面が上下方向にほぼ平面になっています(チルトスタンドで角度はつけられる)。一方Realforceだと、上下方向に若干湾曲しており(このページのキーボード側面の画像を見るとよく分かる)、タイピングがさらに自然に行えるようになっています。これも打鍵感の良さにかなり影響するので、High-profileの方がよかったかも?と思ってしまいました。High-profileはそれに加えて最上段のFnキーが少し離れているのもポイント高いですね。

キー種類も3種から選べるので、英字キーさえ気にならなければ、MX Keys Miniより純粋に良いと思います。おすすめです。

Cloudflare上でトランザクションが成功している(ように見える)のに、DBに反映されていない現象に遭遇した

Cloudflareで動作する、個人開発プロジェクトで、次のような問題に遭遇しました。

手元マシンの開発環境でnpm run devで実行し、ブラウザでアクセスしているときは、バックエンド処理でDBトランザクションが行われて成功し、(当然)DBも更新されるのに、いざCloudflare同じ処理を動かすと、DBトランザクションが成功している(ように見える)のに、DBが更新されていない。

まず、Cloudflareの実行時間制限にかかっているのか、と疑いましたが、エラーも全く起きていないので、これが原因ではなさそうでした。

アプリケーション上で他の箇所でもトランザクションを使っているところがあり、それらはCloudflareとローカル開発環境で動作の違いは無いのに、ある特定のリクエストだけ現象が起きていました。

ざっくりと、以下のような処理です。

const deleteById = async (db: TiDBServerlessDatabase<typeof schema>, form: FormData) => {
    const id = form.get('id')
    try {
        db.transaction(async (tx) => {
            await tx.delete(TableA).where(eq(TableA.id, id))
            await tx.delete(TableB).where(eq(TableB.id, id))
        })
    } catch (e) {
        return { status: 'fail', msg: e }
    }
    return { status: 'success', msg: '削除しました' }
}

Remixのactionとして動作しており、returnが行われると、HTTP応答がブラウザに帰ります。

上記コードの問題は何でしょうか???? 正解は↓

const deleteById = async (db: TiDBServerlessDatabase<typeof schema>, form: FormData) => {
    const id = form.get('id')
    try {
        await db.transaction(async (tx) => {
            await tx.delete(TableA).where(eq(TableA.id, id))
            await tx.delete(TableB).where(eq(TableB.id, id))
        })
    } catch (e) {
        return { status: 'fail', msg: e }
    }
    return { status: 'success', msg: '削除しました' }
}

db.transactionの前にawaitが抜けていました。それで、実際の動作順としてはreturnでHTTP応答が行われた後にトランザクションが動作していたようです。Cloudflareだと、HTTPリクエストが完了すると、直ちにworkerのインスタンスが破棄されるようで、トランザクションが終わるところまで処理が行われていなかったと思われます。手元のNode.jsだと当然そういうこともないので、awaitが抜けていてもトランザクションが完了し、DBが更新されていたわけです。

MX Keys Miniを買った

MX Keys Miniを購入した。

ここ2年半くらい、REALFORCE R3Sを仕事と個人で利用してきた。キーボードには大きな不満は無い。細かいところでは、黒モデルなので、白モデルより多少ねっとり(?)した感触があり、白にしておけば良かったかも?と感じたことと、白よりは汚れが気になる、というくらい。30gの荷重というのも、軽くて良い。

ところが、別のところに多少問題が起きていた。仕事用PCと個人用PCに同じキーボード(とマウス)を接続するために、安価なKVM(こういうやつ)を使っているのだけど、これが少し不安定で、たまにUSBの接続がしばらく切れたりする。作業中に発生すると、かなり煩わしい。1台目で症状が出て、別のメーカーの2台目を利用しているが、こちらも最近不安定になっている。

そういったこともあり、そろそろUSBケーブルでの接続をやめて、無線化しようかと思い始めた。

すでにタブレット用に所有していたK380sを試しに仕事で使ってみたところ、キータッチ自体は、さすがにREALFORCEと比べると快適とは言い難いけど、無理ではない感じで、それより70%キーボードのサイズ感と、無線というところが、大変気持ちよく感じた。

それで、少し奮発して、上記MX Keys Miniを購入した。REALFORCE RC1という選択もあったんだけど、ちょっと高いし、キーの最下段が少しずれている、というのも気になる(K380sも最下段は少しずれている)。どうせ無線接続ならNキーロールオーバーというのもあまり役立たないと思われるし。

それに合わせて、マウスも無線化した。M750でこちらもマルチポイント接続で切り替えというのも検討したけど、キーボードとマウスを毎回切り替えるのも、それはそれで手間のように感じたので、こちらは安いM185にした。マウスは小さいので机の上に複数個あってもそれほど気にならない(と思う)ので…

歴代キーボード

写真上から、REALFORCE R3S, MX Keys Mini, K380s。K380sはMX Keysと比べてもキーピッチが少し狭いので、全体的に横幅も小さくなっている。MX Keys MiniはREALFORCEと比べてわかるように、標準的なキーピッチ。

MX Keys Miniのキータッチは、悪くはないけど、個人的好みとしてはREALFORCEには流石に及ばない感じがした。変則サイズのキーもないし、ミスタッチしやすいということも全く無いので、特段の問題は無さそう。やはりこのサイズ感と線が出ていないところが良い。

Remixでは副作用のためのモジュールインポートは避けた方が良い

引き続きRemixで開発していたところ、クラアント側で動作させたいコードで、副作用モジュールインポートを行っていると問題が起きることがわかりました:

import 'hoge'

上記のようなimportです。このimportを行う側のコードでは、hogeの中身を名前で参照していませんので、このimportは、hogeのロード時1度限りのコード実行を目的としたものです。

今回このhogeの中身では、グローバルオブジェクトに新しいプロパティを付け加える処理を行っています。

$ npm run dev

で開発しているときには、上記のimportを行うコードは正しく実行されていたのですが、

$ npm run build
$ npm run start

にて、ビルドした成果物を動かすと、hogeが全く動作していませんでした(hogeで付け加えられるべきプロパティが存在しなかったため、エラーが起きた)。

散々悩んだ挙げ句、Module Constraintsで説明されている通り、Remixで上記のような副作用を目的としたモジュールのimportは行わない方が良さそうです。

今回の目的では、hogeの実行はimport時ではなく、ページ初期化後でも構わなかったため、hogeの中身をexport functionとし、副作用モジュールインポートを、関数のインポートに置き換えました。そして、当該functionをuseEffectの中から呼び出すようにました。この方法は、Lazy Initializationで紹介されているものです。

動作確認したバージョン

  • @remix-run/cloudflare: 2.10.3
  • remix-utils: 7.6.0
  • react: 18.3.1

RemixでSSRをバイパスするにはremix-utilsを使おう

Remixleafletを使用する開発で、SSRフレームワーク定番(参考1, 参考2)の"window is not defined"がサーバ側の端末で表示される現象に遭遇しました。

要するに、ブラウザでのみ動かしたいコードがサーバ側のSSRで動いてしまっているわけです。

Next.jsだとdynamicで囲って対処していましたが、Remixでどうやってやるのか調べました。

Remix Viteで一部をクライアントサイドのみにするでは、ReactのlazyとSuspenseを組み合わせて行けるとのことでしたが、私が試した限りではうまくいきませんでした(やり方が悪かっただけかもしれません)。

結局、remix-utilsClientOnlyを使って解決しました。

動作確認したバージョン

  • @remix-run/cloudflare: 2.10.2
  • remix-utils: 7.6.0
  • react: 18.3.1

フロントエンド界隈は開発が活発なので、この記事の内容もあっという間に陳腐化するかもしれませんが、備忘録として残しておきます。

Speed Wi-Fi HOME 5G L13で楽天モバイルを使う

楽天モバイルを契約してからしばらくの間、スマートフォンのテザリングでデータ通信を利用していました。 スマートフォン本体でスピードテストを行うと、300Mbps以上出ることがあるものの、 テザリングだと、おそらくスマートフォン側の仕様上そこまで性能が出なくて、せいぜい50-60Mbpsが良いところでした。

これでは回線性能を活かせないので、ホームルータをメルカリで購入しました。Speed Wi-Fi HOME 5G L13です。8000円台で購入できました。

最初から入っているinternetという名前のプロファイルは削除/編集できなかったので、rakutenという名前で楽天モバイルのAPNを入力したプロファイルを作成しただけで使用できるようになりました。

PCから802.11ac接続してスピードテストを行った結果画像です:

Speedtest

これで回線速度を活かせそうです。これだけあれば、自分の利用方法での範囲内では、光回線は不要そうです。レイテンシや上り速度の面では、光接続のほうが断然良いと思いますが。

LAN1ポートでPCに接続すると、なぜかオートネゴシエーションに失敗して1Gbpsではなく100Mbpsに落ちてしまう事があるのですが、これは必要に応じて調べたいと思います。とりあえず無線接続でも十分早いので。

MySQLもしくはPostgreSQLで, 1文で2行の特定のカラムの値をスワップする方法

DBに入った2行の、特定のカラムの値をスワップする方法。ちょっと日本語で検索しても良いのが出てこなかったけど、英語で良いのが出てきた(下記はMySQL限定):

Mysql: Swap data for different rows

UPDATE tbl a INNER JOIN tbl b ON a.id <> b.id
SET a.col = b.col
WHERE a.id IN (2, 3)
  AND b.id IN (2, 3)

上記は、テーブルtblのカラムcolの中身を、id=2,3のレコードでスワップする。

これ最初に思いついた人、頭良い。そもそもupdate文でこういうjoinが書けるのを正確に知らなかった。SQL奥深し。updateで使える構文を一度しっかり調べたほうが良さそう。

2023/5/23追記:PostgreSQLでは上記の方法では動かない。次のような感じでいける(参考資料:PostgreSQL, Swap data of certain column in two rows)

UPDATE tbl a set col = b.col
FROM tbl b
WHERE a.id IN (2, 3)
  AND b.id IN (2, 3)
  AND a.id <> b.id

ただ、実際に動作させてみると、colにUNIQUE制約がかかっている とエラーになってしまう。どうやらPostgreSQLの場合、内部的には同時に交換というよりは逐次で更新されているっぽい。さらにちなみに、上記のset colのところをset a.colとすると構文エラーになってしまうので注意。

楽天モバイルを再契約した

自宅ではExcite MEC光をしばらく利用してきましたが、VDSLでとても遅いのが気になっていました。 下りは24-25Mbps、上りは10Mbps程度が限界でした。これで月々3,500円と10%消費税で3850円はちょっと高いと感じていました。

以前契約していた楽天モバイル は、無料期間が終わったときに解約していましたが、再度契約してみました。 縛りがなくてすぐに解約できますし、通信量制限も取り払われたようなので。

Rakuten Hand 5Gというのが1円で購入できました。こちらをセットアップしてWiFiテザリングでスピードテストをしてみたところ、ばらつきはありますが、たとえば、Down 44Mbps / Up 30Mbpsといった値が出ました(19時台に測定)。自宅は残念ながら5GエリアではないのでLTE接続です。

確かにジッタやレイテンシの点では光回線の方が良いかと思いますが、帯域としてはこちらのほうが上のようですので、 早速Excite MEC光は解約申し込みしてしまいました。