起きたこと
スマホで撮った写真に言葉を綴って同期した。アプリは「アップロード成功」と返している。それを web の /lab/shelf(振り返りの本棚)で開いた。テキストは出るのに、写真が割れアイコンで出る。
最初は「画像配信のバグだ」と思った。前日まで「画像経路 完全開通」と進捗メモに書いたばかりだったので、なおさら回帰を疑った。
1層ずつ剥がす
ここから AI に手を貸してもらいながら、症状から原因の層を1つずつ剥がしていった。
(以下、docker ps=サーバのプロセスが動いているか確認する、curl=ブラウザを介さずサーバに直接問い合わせる、といった道具を使う。)
① backend は生きているか
docker ps でコンテナは Up。/api/sync/memories/ を curl で叩くと HTTP 401(認証要求)が返る。コンテナ内のサーバプロセスは生きている=層①は正常。
② DB に届いているか
Django shell から DB をダンプ。
=== PHOTOS 25 ===
{'id': 34, 'image': 'photos/c7844987-....jpg', 'sync_state': 'synced', ...}
{'id': 35, 'image': 'photos/6f1d3357-....jpg', 'sync_state': 'synced', ...}
...
=== MEANINGS 27 ===
{'id': 25, 'photo_id': 34, 'text': 'テスト\n(本文は省略)', ...}
...
今日アップした9枚、全部画像つきで届いている。sync_state='synced'。さらに media/photos/<uuid>.jpg を直接 HTTP で取ったら 410KB の image/jpeg が 200 で返る。
→ 「届いてない」ではない。同期も画像アップロードも成功している。
③ じゃあ web は何を見ているのか
/api/favorites/ を叩く。返ってきたのは 3件だけ。前日までに☆を付けた古いデータで、今日の9枚は1件も入っていない。
favorites という名前の通り、これは「お気に入りに登録した写真」だけを返すAPI。web の create プールも本棚も、このAPIだけを見ていた。
④ じゃあ「☆お気に入りに登録する」のはどこでやる
backend の同期処理を読んだ。
is_favorite = item.get("is_favorite", False)
if is_favorite:
FavoriteMemory.objects.get_or_create(...)backend には「同期時に is_favorite: true を受けたら☆登録する」橋がある。ある。じゃあ mobile はどう送っているか。
// `is_favorite` は常に false(モバイルにお気に入り概念なし)
'is_favorite': false,ここで全部つながった。
正体は、モックからの移し替えが途中だったこと
web の /lab は、もともとクリッカブルモックとして作ったものだ。全画面の導線を先に組んで、中身は sampleData という固定のサンプルで動かしていた。同期の管がまだ通っていなかったので、それでよかった。
そのあと管が通り、web を実データに繋ぎ直した。ただしその移し替えが途中で止まっていた。
☆が付く経路はどこにも無い。だから同期した9枚は、web のどの画面にも出てこない。バグを踏んだのではなく、モックから実データへ移すときに、繋ぎ先が「全部」ではなく「選ばれたものだけ」を返すAPIのままだった、という話だった。
進捗メモには前から「器の粒度ズレ(web curated ↔ mobile raw)の橋渡し=未対応」と書いてあった。文字としては知っていたが、それがどの画面のどのAPIに穴として出るかまでは繋がっていなかった。
☆をやめた
繋ぎ直すには2つ道があります。☆を付ける経路を作るか、☆そのものをやめるか。やめる方を選んだ。
理由は、☆を残すと選ぶ場所が2つになるからだ。当初は web を「作る/選ぶ/見せる」の3つで考えていて、「選ぶ」が本棚、「作る」が CreateWizard だった。でも実際に作品を作ろうとすると作るときにも結局選ぶ。それなら本棚側の☆は要らない。
書き直したモデルはこう。
backend は「もう書いてあった」
設計を spec に落として実装プランを書き始めた時、もう1つ発見があった。
「全同期写真を返す API」を新規に作るつもりで urls.py を確認したら、既に GET /api/memories/ が登録されている。エンドポイントとは「URLを叩くと決まった形のデータが返ってくる窓口」のこと。MeaningViewSet が Meaning.objects.filter(owner=...).select_related("photo").order_by("-created_at") を返し、serializer は { id, photo, photo_image(null許容・絶対URL), text, mood, photo_taken_at, created_at } を出します。新しく要るものが全部既にあった。
$ curl -H "Authorization: Bearer ..." http://localhost:8000/api/memories/
count= 27
33 photo= 42 img= OK mood= 'happy' text= 'テスト'
32 photo= 41 img= OK mood= 'like' text= 'テスト'
...
ライブで叩いて27件返ることまで確認した。新規エンドポイントを作らず、契約をテストでピン留めするだけで済みます。実装プランから backend 1タスクが丸ごと「テスト追加だけ」に縮んだ。
実装の進め方
ここからは AI(Sonnet サブエージェント)に実装してもらい、Opus にレビューしてもらう運用で進めた(Sonnet は速度重視で実装タスクをこなす実行役、Opus は判断の重い局面を担うレビュー役)。8タスクに分けて、1タスクずつ(useRemoteMemories・shelfAxes.ts 等は綴Photo 内部の画面・部品名。1つずつ追わなくてよい付録として置く):
- backend
/api/memories/の契約テストをピン留め - frontend
useRemoteMemories新設(画面に渡す構造は変えず、読みに行く元だけ差し替え=画面側は無改修) - CreateWizard のプール源を memories へ+画像 null プレースホルダ
shelfAxes.tsを全同期メモリの月/気分グルーピングへ書き換え(残っていたsampleDataをここで撤去)- 本棚を振り返り専用化(☆系コピー撤去・Axis 縮小)
/lab/favoritesページと FavoritesDock を撤去(970行削除)- CreateWizard に作業トレイ
SelectionTrayを新設(撤去したドック概念の転用) - 全体ゲート+進捗メモ更新
タスクごとに「失敗するテスト → 実装 → 緑 → コミット → レビュー → 次へ」を回した。frontend 247テスト・backend 60テスト・tsc 0・eslint 0 で全緑。
判断はいくつか AI と対話しながら自分で切った: ☆面を今いっしょに整理するか後回しか/本棚を畳むか作り直すか(→作り直す)/ドックを捨てるか(→「作業トレイ」概念に転用する)/FavoriteMemory テーブルを drop するか(→休眠で残す)。
撤去したドックを「概念だけ残して別の仕事に転用する」のは、当初思いつかなかった。「ドックが見づらかった」と話しているうちに、ドックという形は『今選んでいるものを見せる』に向いていると気づいた。☆という意味から外して、選択中の作業トレイにする。同じ "下部常駐ストリップ" でも、目的を1つにすれば意味が立ちます。
学んだこと
- 「アップロード成功」と「web に出る」は別の層。症状が出た層から1つずつ剥がしていくと、原因の階層が見えます。今回は backend→DB→media→favorites契約→mobile送信→web UI と、6回くらい剥がした。
- モックを実データに繋ぎ直すときは、繋ぎ先が本当に全件を返すAPIかを見ます。 今回は「選ばれたものだけ」を返すAPIに繋いでいて、しかも選ぶ経路が存在しなかった。モック側の残骸(
sampleData)も同時に消しきる必要があります。 - 設計を spec に書き出す前に、実装の現状を1回見ます。新規に作ろうとしているものが、もう存在することがあります。
- 「捨てる」と「概念だけ転用する」は別の選択肢。撤去しながら、その形が次の仕事に効く面を残せることがあります。
この記事は2026年6月時点の自分の理解で書いています。仕様や認識が変わっている可能性があります。