解決しようとした問題
デザインの仕事は final_fix_revisi3_ok.psd のような名前のファイルとして積み重なり、本当の履歴はその名前の間に埋もれてしまいます。クライアントの要望はチャットに散らばり、誰がどのバージョンを待っているのか誰にもわかりません。
Git は別の問題のために作られました。1行ずつ読めるテキストのために設計されていて、800 MB の PSD は diff もマージもできないバイナリの塊にすぎません。しかもコミットメッセージを書くために手を止めることを求めます。CIT はその向きを逆にします。使う人はいつもどおり Ctrl+S を押すだけで、システムのほうが合わせます。
対象はデザイン科(DKV)の学生、フリーランスのデザイナー、小さなスタジオです。すべて自分のパソコンで動きます。
なぜ中止したか
保存期間の管理に進む前に、対象ユーザーであるデザイン科の学生に、デザインファイルをどう保存し、どう管理しているかを聞きました。彼らは上の問題を感じていませんでした。使う人が感じていない問題のためにさらに作り込むのは正しい判断ではないので、開発は M6 で止めました。
黄金律
システムは観察し、提案する。決めるのは人間。
この一文が、仕様に書かれていない設計の判断に答えます。
- 取り消せることは、見えていて簡単に取り消せる限り、確認せずにすぐ行う。
- 取り消せないことは必ず確認を求め、確認を切るオプションは置かない。
- ポップアップは決して出さない。 アプリは割り込みません。確認が必要なものはすべて一つの「確認ボックス」にたまり、使う人が好きなときに開きます。
結果は具体的です。自動のグループ分けは取り消せるのでそのまま適用します。ただし使う人が二つのファイルを手で分けたら、その判断は永久で、どんな計算も覆してはいけません。スキャンのたびに自分を主張し直す推測は提案ではなく、使う人が勝てない議論だからです。
確認待ちのチケットがある作品に新しいバージョンができると、そのチケットに「完了かもしれない」と印を付けます。そして、それ以上は何もしません。
守っている不変条件と、その理由
- ファイルの復元はバイト単位で同一でなければならない。 これは絶対条件です。
Restore(hash)がStore()に入れたものとまったく同じファイルを返さなければ、その上のすべての層は意味を失います。各チャンクは渡す前にハッシュと照合し、それを切るスイッチはありません。照合に失敗したら、ファイルは一切書き出しません。完全に見える半端なファイルを渡すより、はっきり失敗するほうがましです。 - バージョンのメタデータは決して消さない。 間引きで捨てるのはファイルの中身だけです。タイムラインに穴が開いてはならず、中身を捨てたバージョンも印付きで表示し続けます。
- チャンクの削除は参照数がゼロのときだけ。 チャンクはバージョン間で共有されるので、一つのバージョンの古さで削除すると、まだ新しい別のバージョンを壊してしまいます。
- 例外なく間引きの対象外: 各作品の最新バージョン、手動で印を付けたバージョン、確認待ちのチケットがあるバージョン。
- チケットは単独では存在できない。 必ずどれかの作品に結びつきます。この制約が製品を定義しています。宙に浮くチケットを許すと、CIT はただの ToDo アプリになってしまいます。
- チケットはファイル名やパスではなく、バージョンに付く。 よりどころは中身のハッシュではなく
version_idです。バイト単位で同一の二つの保存は、中身としては一つでも、別々の作品に属しうる二つのバージョンです。ハッシュをキーにすると両方に現れてしまい、どの作品か特定できません。 - 拒否するファイルはない。 知らない形式も保存し、バージョン管理します。プレビューだけが汎用アイコンになります。
規律ではなく、データベースが守る
「バージョンは決して消さない」「チケットは単独で存在できない」といったルールは、トリガーと外部キーで守っています。あとでコードに触れる人全員が覚えておかなければならない約束事にはしていません。宙に浮くチケットを拒むスキーマこそが、この製品を定義しています。
最初から守っているもう一つの厳しい制約:/internal 全体は CGO_ENABLED=0 でビルドとテストができなければなりません。そのおかげで、アプリの中核を C のツールチェーンなしでテストできます。SQLite は常に純 Go の modernc.org/sqlite を通します。外部への依存は ffmpeg だけで、それも任意です。なければ動画のプレビューが汎用アイコンになるだけで、アプリは動き続けます。
AI モデルはどこにも使っていません。グループ分けの「賢さ」はすべて知覚ハッシュ、正規表現、時間の比較から来ています。古くなったり、ダウンロードが必要になったりするものはありません。
テスト
パッケージごとのテスト関数の数(ソースツリーから数えたもの):
| パッケージ | 内容 | テスト |
|---|---|---|
internal/grouping | pHash、名前の類似度、時間のクラスター | 70 |
internal/store | SQLite、スキーマ、マイグレーション | 43 |
internal/preview | 段階的なサムネイル | 43 |
internal/vault | チャンク分割、ブロブストア、参照数 | 38 |
internal/ingest | フォルダのスキャンと監視 | 30 |
cmd | Wails のアプリ層 | 18 |
internal/ticket | チケットと確認ボックス | 7 |
internal/retention | 未実装 | 0 |
internal/sync | 未実装 | 0 |
| 合計 | 249 |
internal/vault では、実装の前にテストを書くことをルールにしました。このパッケージは使う人のデータを壊しかねないからです。同じルールを internal/retention にも定めましたが、こちらは結局作りませんでした。
数より大事なのは、バグがどう見つかったかです。マイルストーンごとに決まった項目の開発日誌があります。何を作ったか、判断とその理由、見つかったバグとそれを見つけたもの、意図して見送ったもの。記録に残っているものをいくつか:
- M4 のテストスイートが一発で通った。それこそが怪しかった。 そのまま受け入れず、ハミング距離と正規化の出力を一つずつ確かめました。それが次の三つのバグを掘り起こしました。
- 何もテストしていないテスト。 ペアのハミング距離が24で、拒否のしきい値26を下回っていたため、テストするはずの拒否が一度も発動していませんでした。距離44のペアに替え、距離が再びしきい値を下回ったら失敗する前提条件のアサーションを加えて直しました。
logo_v2.pngが二つのクライアントの履歴を一つの作品にまとめてしまった。 名前の正規化にfilepath.Baseを使っていたので、無関係なフォルダにある同じ名前が pHash の拒否を無効にしていたのです。まず失敗するテストで証明してから直しました。同じ名前が拒否を無効にするのは、両方が同じフォルダツリーにあるときだけです。- チケットのテストを、わざと壊して確かめた。
UPDATE versionsの1行を一時的に消すと、二つのテストがすぐに赤くなりました。つまり、たまたま通っているのではなく、主張どおりの仕組みを本当にテストしています。 - マイグレーションを、すでに行が入っているデータベースで試したことがなかった。
grepで発覚しました。マイグレーションのテストファイルに新しい列への参照がゼロだったのです。それ以来、マイグレーションのテストは空のデータベースからだけでなく、すべてのスキーマバージョンから実行します。
現状、ありのまま
M6 で中止し、リポジトリはアーカイブしました。最後のタグは m6(チケットと確認ボックス)。データベースのスキーマはバージョン9。make test はすべて CGO_ENABLED=0 で緑です。
完成して動作が確かめられているもの:FastCDC と参照数による内容アドレス型の保管庫、9つのマイグレーションを持つ SQLite のカタログ、デバウンス付きのフォルダのスキャンと監視、段階的なプレビュー、手動の判断が永久に残る三つの信号によるグループ分け、確認ボックス付きの双方向のチケット、そしてそれらすべてをまとめる Wails アプリ。
作らなかったもの:
internal/retention(履歴の間引きとガベージコレクション)とinternal/sync(フォルダや外付けディスクの間のコピー)。どちらも設計の文書だけで、テストはゼロです。画面は中身を捨てたバージョンを表示できますが、実際に捨てるものはありません。- 「間引きの対象外」という不変条件の強制。 保存期間の管理を作らなかったためです。入力となる三つ(最新バージョン、手動で印を付けたバージョン、確認待ちのチケットがあるバージョン)は存在し、テストもされています。
- フロントエンドの自動テスト。 自動チェックは
make testの外にあるnpm run checkだけです。 - Windows 以外での
./cmdのテスト。 Wails は Linux と macOS で CGO を必須にするので、この18件のテストは Windows でしか動かしたことがありません。 - レーベンシュタイン距離の比較を減らすトークン索引。 そのためグループ分けは O(n²) のままです。1ペアあたり約 860 ns と測定しました。1,000ファイルで0.5秒、10,000ファイルで約44秒。これは推定ではなく測定した数字です。
あえて作らなかったもの
何度も提案されないように、先に断ったもの:Git 風のバイナリファイルのマージ、あらゆる AI モデル、アカウントと認証、サーバー、サブスクリプション、テレメトリ、インターネット越しの P2P、画像の一点に貼り付けるコメント。チケットの削除、締め切り、リマインダーもありません。リマインダーは割り込むことであり、CIT は割り込まないからです。