一部の情報は非公開です。アクセスキーをお持ちの方は完全版をご覧いただけます。
新エネルギー・建設機材のレンタル企業
部門をまたぐ依頼を回す社内カンバン
Google スプレッドシートと Apps Script で回していた部門間の依頼を、Cloudflare のエッジ上で動くリアルタイムのカンバンにしました。依頼は別テーブルにせず、タスクと同じモデルを共有しています。17 日、一人で公開まで。
概要
クライアントの部門間の依頼は、もともと Google スプレッドシートと Apps Script の上で回っていました。誰が誰に頼み、どこまで進んでいるかは、1 枚の共有シートと手作業のリマインドで維持されていた状態です。Vulcan はこの流れをカンバンのシステムにしたものです。
部門をまたぐ依頼のために別のテーブルは作っていません。依頼は普通のタスクそのもので、担当部門が作成者の部門ではない、という一点だけが違います。おかげで依頼の管理とタスクの管理は、同じテーブル・同じクエリ・同じカンバンを共有できます。
依頼を出すウィザードは、まず作成者自身の部門に下書きを作り、送信の時点で担当部門を書き換えて受け手側の採番を取ります。取りやめた下書きは自分のカンバンに残るだけで、相手のキューを汚しません。そのためにテーブルを 1 つ増やす必要もありません。
アーキテクチャ
フロントは SvelteKit の SPA モードで、アプリ全体でサーバーサイドレンダリングを切っています。カンバンは操作の多い画面で SEO の要件もなく、SSR で得られるものがない一方、あらゆる操作で 2 つのレンダリング経路に気を配ることになるからです。
データはこのアプリ自身の BFF エンドポイントだけを通ります。全部で 42 本です。エンドポイントは Worker 上でデータベースに直接バインドされ、ブラウザがデータベースのハンドルを持つことはありません。データアクセスは共有の Drizzle クエリ層に集約してあり、アプリ側で SQL は書きません。
実行環境はすべて Cloudflare のエッジです。Worker がアプリを載せ、D1 がデータ、R2 が添付ファイル、KV が個人設定、Durable Object が状態の同期を担当します。ログインは Google OAuth で、セッションは社内の他の 2 アプリと共有しています。
技術的な難所
複数人の同期:無効化のシグナルだけを配る
誰かがタスクを直したら他の人のカンバンも追随してほしい。とはいえ CRDT は持ち込んでいません。Durable Object はブロードキャストの中継に徹し、流すのは無効になったクエリキーのプレフィックスだけです。受け取ったクライアントは該当するキャッシュを期限切れにして取り直します。データの唯一の真実は常にデータベース側にあります。
ブロードキャストは 2 段階で絞ります。キーのプレフィックスには部門とタスクの識別子が入っているので、前方一致で自然とその範囲を見ている人にしか届きません。個人ビュー向けのイベントは、さらに従業員の識別子で宛先を絞ります。接続に紐づく従業員のラベルはサーバー側のゲートウェイが上書きするため、クライアントからは詐称できません。
この割り切りと引き換えに、楽観的並行制御も並び順のロジックも 1 文字も変えずに済み、Durable Object の中に業務データを一切置かずに済んでいます。接続はハイバネーション API を使い、ハートビートはランタイムが自動で返すのでインスタンスは起きません。切断時はポーリングに退避します。
フレームワークに上書きされるエントリーへ Durable Object を差し込む
Cloudflare のアダプターはビルドのたびに Worker のエントリーファイルを書き換えるので、手書きの Durable Object のエクスポートはそこに置いておけません。そこでビルドスクリプトが、コンパイル後に成果物を横へどかし、同じ位置にラッパーのエントリーを生成します。通常のリクエストはフレームワークへ転送し、接続のアップグレード要求だけを受け止め、あわせて Durable Object のクラスをエクスポートします。
ラッパーに書いてあるのは配線だけで、ロジックは型チェックとテストが効く元のソースに残してあります。スクリプトには再入の判定があるので、繰り返し実行しても、どかした成果物をもう一度上書きしてしまうことはありません。
楽観的並行制御とフラクショナルインデックス
編集できる行にはバージョン番号を持たせ、更新文の条件にそのバージョンを入れます。影響した行が 0 件なら競合として返します。ドラッグでの並べ替えはフラクショナルインデックスで、落とした位置の両隣から新しい並び順の値をサーバーが生成します。クライアントに書かせることはありません。
この 2 つの組み合わせで、同時にドラッグしても列全体が並び直ることはありません。同じ位置を取り合ったときは挿入がユニークインデックスに当たるので、回数を区切ってリトライします。採番のような取り消せない操作は、バージョンで守られた書き込みより前に置いてあり、競争に負けても最悪で番号が 1 つ空くだけです。
親タスクの状態を再帰で導出する
親タスクの状態は子タスクから計算するもので、別に保存はしていません。子が変わったらその親から上へ 1 階層ずつ計算し直し、ある階層で結果が変わらなければそこで止めます。それより上の導出は、この変わらなかった値にしか依存していないからです。あわせて訪問済みの集合を持ち、循環を防いでいます。
日本語では全文検索インデックスを使わない
検索は部分一致とアプリ側のスコアリングで、SQLite の全文検索インデックスは使っていません。既定のトークナイザーは日本語を切れませんし、トライグラムのインデックスにすると予約や会議のような 2 文字語を取りこぼします。検索範囲は自部門に限られるので、走査のコストは許容できる範囲です。
AI ネイティブな進め方
このプロジェクトは最初のコミットから本番公開まで 17 日でした。400 のコミットのうち 223 件に AI の共著者署名が入っています。進め方としては AI エージェントを設計の相棒として扱い、問題設定と取捨は私が決め、実装は相手が出し、それを私が確認して残すかどうかを決めます。
この速度を続けられるようにしているのは、判断を書き残すことです。リポジトリにはエージェント向けのドキュメントが 4 つ、合わせて 2,000 行あまりあります。書いてあるのはコードの構造ではなく、それぞれの決定をなぜそう決めたのか、そしてどの道をすでに試して却下したのか、です。
なぜカレンダーのテーブルを消したのか、なぜ依頼を別テーブルにしないのか、なぜ検索に全文インデックスを使わないのか。どれも代償とセットで、ドキュメントとマイグレーションファイルの冒頭に書いてあります。次にここへ手を入れる人が読むのは判断の根拠で、推測し直す必要はありません。
ドキュメントは同時に、ずれやすい場所をいくつか固定しています。列挙型の定義は 1 ファイルだけに置く、画面の表記を変えてもコード中のクエリキーは動かさない、意味の違う日付フィールドは統合しない。どれにも対応するテストを付けて守っています。