kikuta@dev:~$ls -lt ./notes
kikuta@dev:~$cat ./notes/local-first-tools.md
·10 min·kikuta

iOSでユーザー所有ファイルと内部データを分ける:DocumentsとCleanup Journalの設計

iOS clipboard appで、ユーザーが保持する画像/fileをFilesから見えるDocumentsへ置き、内部metadataとcleanup責務を分離した実装。

seriesiOS Clipboard Architecture3/3
使える場面

iOSアプリでユーザーが保持した画像/fileと、thumbnail・metadataなど内部cacheの保存場所や削除責務が混ざっているとき。

持ち帰れるもの

DocumentsとApplication Supportを役割分離し、独立snapshotとretry可能なcleanup journalでfile ownershipを守る設計。

保存場所をfile typeではなくownershipで決める

この実装では、clipboardから保持した画像と一般fileを単なるcacheとして扱いません。HistoryのpayloadはDocuments/履歴、Stockへ保存したsnapshotはDocuments/ストックへ置き、iOSのFilesから実体を確認できるuser-owned contentとして扱います。

一方、SQLiteのHistory metadata、saved-sections.json、URL preview artwork、favicon、rich-textの補助payloadはApplication Supportへ残します。『画像だからDocuments』ではなく、ユーザーが直接所有・再利用するbytesか、アプリ内部の再構築・補助データかで境界を決めています。

StockはHistory fileへの参照を持たない

HistoryとStockはlife cycleが違います。Stockへ画像/fileを保存するときはHistory側のpathをそのまま参照せず、Documents/ストックへ独立copyを作ります。Historyがtrim・deleteされてもStockが壊れないためです。

同じ理由で、Stock itemの編集・削除はStock側のasset ownershipだけを変更します。metadata上の独立aggregateというだけでなく、filesystem上でも独立したsnapshotになるよう揃えています。

metadata削除とfile削除を一つのtransactionだと思わない

SQLiteやJSON metadataとfilesystem deleteを原子的な一transactionにはできません。metadataを先に消してfile削除に失敗するとorphan fileが残り、fileを先に消してmetadata保存に失敗するとlive recordがmissing fileを指します。

そこでapp-owned assetを削除するときはpending-asset-cleanup.jsonへcleanup intentをdurably enqueueし、その後metadataから最後の参照を外し、最後にfile deleteを試します。deleteに失敗してもintentを残すため、次回loadやmutationでretryできます。

cleanup replayは現在のmetadataを再確認する

journalへintentを書いた直後にcrashし、metadata commit前で止まる可能性があります。そのためreplay時は『journalにあるから消す』とはせず、現在のauthoritative metadataがまだassetを参照していないか確認します。参照中ならintentをcancelし、bytesを保持します。

metadataから参照が消えていればdeleteをretryします。すでにfileがない場合はidempotent success、その他のI/O errorならpendingのまま残します。この順序で、各crash pointから復旧可能な状態を作っています。

user-visible directoryをorphan scanしない

Documentsはユーザーから見えるため、アプリが知らないfileをユーザー自身が置く可能性があります。アプリはdirectory全体をscanして『metadataにないfileは不要』と推測して削除しません。

cleanup journalには既知のstorage domainと検証済みleaf filenameだけを記録し、そのentry単位でcleanupします。local-firstを『全部端末内に置く』という話で終わらせず、誰がそのbytesを所有し、誰が削除してよいかまでdata modelへ含めています。

このシリーズ

clipboard監視、History/Stock、file ownership、cleanupまでをlocal-firstなiOS実装として扱う設計記録。

関連技術記事