data-export-and-restore について
サーバーを持たないアプリで、持ち運び可能なデータのエクスポートと復元を実装するための開発スキルです。完全に復元できるバックアップと、閲覧用の CSV・PDF を区別し、検証、ステージング、ロールバック、安全なファイル公開の手順を示します。
主な機能
- 01バックアップと閲覧用エクスポートの分離
- 02バージョン付きエンベロープとチェックサム検証
- 03ステージングによる一括復元
- 04正規値と安定 ID
- 05ストリーム書き込みとリネーム公開
- 06CSV の数式インジェクション対策
活用シーン
- 01復元可能なアプリ用バックアップ形式の設計
- 02稼働中のデータベースを変えずに復元
- 03表計算向けの安全な CSV 出力
- 04破損・旧版ファイルのインポート検証
インストール
コマンドをコピーしてターミナルで実行します。
npx skills add https:/ / github.com/ zakariaf/ flutter-skills --skill data-export-and-restoreインストール後、Claude Code で/data-export-and-restoreを入力して読み込まれたか確認します。
同梱ファイル
SKILL.md と一緒にインストールされる参考資料やスクリプト。
examples/backup_envelope.dartcsv_writer.dartreferences/formats-and-encoding.mdrestore-and-merge-policy.md詳細
データのエクスポートと復元
サーバーのないアプリでは、エクスポートファイルは、ユーザーのデータをスマートフォンより長く保持できる唯一のコピーであり、復元はユーザー自身が実行できる唯一の復旧手段です。両者は逆の方向で危険を伴います。不完全なエクスポートは、気付かないうちに誰かのバックアップになり、不完全な復元は、守るはずだったデータベースそのものを破損させます。このスキルでは、ポータブルなアーティファクトを扱います。デバイス上の VACUUM INTO スナップショットと WAL のルールは persistence-drift の管轄です。
詳細は references/formats-and-encoding.md(CSV/JSON/PDF の仕組み)と
references/restore-and-merge-policy.md(検証の段階、マージ戦略)を参照してください。
譲れないルール
- バックアップとエクスポートは別の機能です。決して混同しないでください。 バックアップは厳密で、機械による読み取りと往復変換が可能であり、復元で受け付ける唯一の形式です。エクスポート(スプレッドシート用のCSV、人が読むためのPDF)は相互運用のための成果物であり、設計上、情報が失われます。復元元として受け付けてはいけません。理由:CSVから復元できるようにした瞬間、読みやすさのために作られた形式が、誰かの記録を左右するファイルになってしまいます。
- すべてのバックアップにエンベロープを含め、復元では最初にそれを読み取ります。
formatVersion、schemaVersion、appVersion、exportedAtUtc、ペイロードのチェックサム、続いてペイロードを含めます。復元ではチェックサムを検証し、schemaVersionがアプリのバージョンより大きい場合は、分かりやすいメッセージを表示して拒否します。古いペイロードはデータベースと同じ前方向のみの経路(run-migration)で移行します。理由:バージョン情報のないファイルは拒否できず、誤って読み取るしかありません。 - 復元は全か無かで行い、稼働中のデータベースをその場で編集してはいけません。 新しいステージングデータベースにインポートしてすべてを検証し、すべてのハンドルを閉じた後、アトミックな名前変更で公開します。新しいデータベースが問題なく開くまでは、以前のファイルをロールバック用に保持します。理由:復元の失敗よりも部分的な復元のほうが深刻です。ユーザーにも説明できず、再現もできない状態が残るからです。
- 機械可読形式には正規化された値のみを格納します。 整数の最小単位、SI単位の整数、ISO-8601形式のUTC時刻、安定したID、翻訳されていない列挙型のコードを使います。解析されるファイルに、ローカライズされた数字、書式設定済みの日付、通貨記号、翻訳済みのラベルを含めてはいけません。ローカライズするのは、人が読むためのエクスポートのみとし、これらを再インポートすることはありません。理由:
١٢٫٥、12.5、12,5は同じ量を表しますが、解析結果はそれぞれ異なります。 - 識別子は行IDではなく、コンテンツに割り当てます。 エクスポート可能なすべての行に、作成時に生成した安定したID(UUID/ULID)を持たせます。理由:同じファイルを再インポートしたときに冪等でなければなりません。自動インクリメントIDを使うと、代わりにすべてのレコードが重複します。
- マージポリシーを明示し、実行前に表示します。 全置換(消去してからインポート)とIDによるマージ(保存された
updatedAtUtcを基準とする最終書き込み優先)は、異なる約束です。確認画面では、どちらを実行するのか、何が失われるのかを明示します。理由:「復元」は、1週間分のデータを失おうとしているユーザーには「データを元に戻す」という意味に受け取られます。 - 一時ファイルにストリーミングし、名前変更で公開します。 アプリ専用の一時ディレクトリにある
IOSinkへ成果物を書き込み、最後のバイトがフラッシュされたときにのみ配置先へ名前変更します。理由:10万行のエクスポートをStringに組み立てると、安価なスマートフォンではメモリ不足になります。また、書き込み途中のファイルを共有可能な状態にしてはいけません。 - 注入されたゲートウェイを通じたユーザーの明示的な操作がない限り、サンドボックスの外へデータを出してはいけません。 自動アップロード、バックグラウンド同期、「便利な」クラウドコピーは禁止です。共有シートとファイル保存ピッカーは
ShareGateway/FileExportGateway(service-boundary-and-native)の背後に置き、テストではフェイクを使います。 - CSVはRFC 4180と数式インジェクション対策の両方でエスケープします。 区切り文字、引用符、CR、LFを含むフィールドを引用符で囲み、埋め込まれた引用符を二重にします。さらに、先頭の
=、+、-、@、タブ、CRを無害化し、スプレッドシートで実行されずにテキストとして表示されるようにします。理由:エスケープされていないセルは、受信者のスプレッドシートでコードを実行する手段になり、自分のファイルでは行が失われる原因になります。 - エクスポートと復元は型付きの結果を返し、UIに例外を投げません。
Result<ExportArtifact, ExportFailure>/Result<RestoreReport, RestoreFailure>を使い、拒否理由(チェックサム、未対応バージョン、不正なペイロード、空き容量不足、権限拒否)ごとに異なる失敗を返します。理由:「復元に失敗しました」という理由のないメッセージは、読む人にとってデータの消失と区別がつきません。 - エクスポートに失敗した場合、成果物を残してはいけません。 すべての失敗経路で一時ファイルを削除し、途中まで書き込まれたファイルを公開してはいけません。理由:バックアップに見える不完全なファイルは、数か月後に問題として発覚する失敗パターンです。
- エクスポートの内容を明示します。 暗号化を意図的に実装していない限り、エクスポートは平文で暗号化されていません。共有時にUIでそのことを伝え、表示内容をコードと一致させてください(表示文言については
release-and-store-shippingを参照してください)。
エンベロープ
/// The only shape restore accepts. Version fields come FIRST so a truncated or
/// foreign file is rejected by the header rather than by a mid-parse exception.
({
int formatVersion, // this envelope's own shape — bump independently of the DB
int schemaVersion, // the DB schema the payload was written from
String appVersion, // provenance for support, never a compatibility check
DateTime exportedAtUtc, // UTC, from the injected Clock
String payloadSha256, // checksum over the payload bytes, verified before parsing
Map<String, Object?> payload,
});
formatVersion と schemaVersion は意図的に分けています。エンベロープにはデータベースのマイグレーションなしでフィールドを追加でき、データベースはファイルの形状を変えずにマイグレーションできます。単一の「バージョン」番号では両者が混同され、次の変更で誤った選択を迫られることになります。実行可能な完全な形については、examples/backup_envelope.dart を参照してください。
復元:検証、ステージング、入れ替え
Future<Result<RestoreReport, RestoreFailure>> restore(File picked) async {
// 1. Header and integrity BEFORE anything is parsed or opened.
final envelope = readEnvelope(picked);
switch (envelope) {
case Err(:final failure): return Err(failure); // malformed / bad checksum
case Ok(:final value) when value.schemaVersion > kSchemaVersion:
return const Err(RestoreFailure.newerThanApp()); // refuse, never guess
case Ok():
break;
}
// 2. Import into a STAGING database — the live one is never touched yet.
final staging = await openStagingDatabase();
final imported = await staging.transaction(() => _importAll(envelope.value.payload));
if (imported case Err(:final failure)) {
await staging.close();
await deleteStagingFiles(); // user's data still intact
return Err(failure);
}
// 3. Migrate the staged copy forward through the SAME path as the app database.
// 4. Close every handle, then publish by rename, keeping the old file until the
// new one opens cleanly. See references/restore-and-merge-policy.md.
return publishStagedDatabase(staging);
}
ステージングファイル、マイグレーションの経路、名前変更の順序が、正しさを担保するすべてです。開いているアプリのデータベースに直接書き込む復元では、ユーザーを元の状態に戻せる失敗モードがありません。
形式の選択
| 必要なもの | 形式 | 復元可能か |
|---|---|---|
| アプリが読み戻す、完全な忠実度のバックアップ | エンベロープ + JSON ペイロード(またはコピーした DB ファイル) | はい — 唯一の形式です |
| スプレッドシートや別のツールで使う行データ | CSV、RFC 4180、正規化された値 | いいえ |
| 人が読んだり、印刷したり、保管したりするもの | PDF、完全にローカライズされ、書式設定されたもの | いいえ |
人間向けのエクスポートは、ほかのレンダリングと同様に境界でローカライズします
(value-objects-money-and-units、i18n-rtl-l10n)。機械可読形式ではローカライズしません。稼働中のデータベースファイルを直接コピーする方法が有効なバックアップとなるのは、persistence-drift の WAL 対応プリミティブを使う場合に限られます。開いているデータベースに File.copy を使ってはいけません。
必ず用意するテスト
- 意地悪なフィクスチャでの往復テスト。 エクスポート → 空のデータベースにインポート → 再度エクスポートし、バイト単位で完全一致すること。フィクスチャには、アポストロフィ、引用符、カンマ、改行を含む文字列、絵文字、双方向テキスト制御文字を含む RTL テキスト、空白のみの文字列、サポートされている最大の数値、DST により曖昧になる現地時刻、null と空値のペアを含めます。
'test1'がすべてを通過しても、何の証明にもなりません。 - 再インポートの冪等性。 同じファイルを 2 回インポートしても、ID によるマージで同じ状態になり、重複が生じないこと。
- 拒否ケースはすべてテストする: ファイルの切り詰め、チェックサムの 1 バイトの反転、アプリより 1 つ新しい
schemaVersion、マイグレーションが必要な古いschemaVersion、空のペイロード、この形式ではないファイル。それぞれについて、型付きのエラーと、稼働中のデータベースがバイト単位で変更されていないことを検証します。 - 失敗時に成果物を残さない。 書き込み途中で失敗を発生させ、エクスポートディレクトリに公開可能なファイルが存在しないことを検証します。
- CSV のエスケープ処理。
=cmd()を含むセル、a,b"c\ndを含むセル、RTL のセルが、それぞれ実際のパーサーを使った書き込み→読み込みの往復で保持されること。
アンチパターン
- CSV をバックアップ形式にする — エンベロープも型もバージョン管理もありません。誰かにとって唯一のコピーになってしまいます。
- バージョン管理されていないエクスポートファイル — 最初のスキーマ変更で、既存のファイルがすべて読み取れなくなるか、さらに悪い場合は誤って読み込まれます。
- 稼働中のデータベースへ直接復元する — ロールバックできず、インポート途中の失敗で、誰にも説明できない状態が残ります。
- エクスポートの識別子に自動採番の rowid を使う — 再インポートであらゆるデータが重複します。
- 機械可読形式で数値や日付をフォーマットする — ロケールに依存した出力になり、書き出したマシンでしか往復できません。
- エクスポートを
String/StringBufferで構築する — 100 行なら問題ありませんが、サポート対象の最も低価格なデバイスでは 10 万行で OOM クラッシュが発生します。 - 共有可能なパスに直接書き込む — 書き込み途中のクラッシュで、バックアップに見える切り詰められたファイルが公開されます。
- 「安全のため」と自動アップロードや自動バックアップを行う — オフラインアプリをデータ処理サービスに変え、ストアのプライバシーに関する申告を変更し、掲げている説明にも反することになります。
- 選択されたファイルの拡張子や名前を信用する — パスではなく、エンベロープを検証します。
- 「インポートに失敗しました」という汎用メッセージ — ユーザーはファイルが間違っているのか、データが失われたのかを判断できません。型付きエラーに基づいて理由を明示します。
- ユーザーが置き換えを期待しているのに暗黙にマージする(またはその逆) — 確認時に、コードと同じ言葉で方針を明示します。
完了の定義
- バックアップとエクスポートは別の経路であり、エクスポート成果物は復元時に拒否されます。
- すべてのバックアップには、
formatVersion、schemaVersion、appVersion、exportedAtUtc、ペイロードのチェックサムが、このヘッダー優先の順序で含まれています。 - 復元時にチェックサムを検証し、新しい
schemaVersionは拒否します。また、古いペイロードはデータベースと同じ前方のみの経路で移行します。 - 復元ではステージングデータベースにインポートし、名前の変更によって公開します。失敗してもライブデータベースのバイト列は変更されません。
- 機械可読形式に含めるのは、正規値と安定したコンテンツ ID のみです。
- マージと置換のどちらを行うかはコード内で明示し、確認 UI にも名前を明記します。
- 書き込みは一時ファイルにストリームし、アトミックな名前変更で公開します。失敗した場合は一時ファイルを削除し、何も公開しません。
- 共有/保存は、明示的なユーザー操作で注入された Gateway を通じて行います。自動アップロードはありません。
- CSV 出力は RFC 4180 に準拠して引用符で囲み、数式インジェクション対策を施します。
- エクスポート/復元では、拒否理由ごとに 1 つの失敗を持つ型付き
Resultを返します。 - 敵対的なフィクスチャを使った往復、冪等な再インポート、すべての拒否ケース、部分成果物が残らないこと、CSV エスケープのテストに合格します。
関連スキル
persistence-drift— WAL セーフなオンデバイススナップショットのプリミティブと、インポーターが書き込みに使う DAO レイヤーです。ライブデータベースにFile.copyを使用しないでください。run-migration— 古いペイロードを移行する前方のみの経路です。error-handling-typed-results—Result/Failureの基盤と、この機能がユーザー向けの入口となる「データを決して失わない」保証です。value-objects-money-and-units— 機械可読形式の往復を可能にする正規ストレージ(最小通貨単位、SI、UTC)です。i18n-rtl-l10n— 表示時にローカライズする方法と、ローカライズされた数字をファイルに含めない理由です。service-boundary-and-native—ShareGateway/ファイルピッカーの境界と、そのフェイクです。release-and-store-shipping— エクスポート機能が損なってはならないプライバシーに関する主張です。testing-strategy— 敵対的なフィクスチャ、プロパティ/往復テスト、注入されたClockです。
参考資料
- RFC 4180 — CSV ファイルの共通形式と MIME タイプ:https://www.rfc-editor.org/rfc/rfc4180
- Dart —
dart:ioのIOSink、File.rename:https://api.dart.dev/stable/dart-io/File-class.html path_provider— アプリのディレクトリ:https://pub.dev/packages/path_providershare_plus— 共有シート(sharePositionOriginを含む):https://pub.dev/packages/share_plusfile_selector— 保存/開くダイアログ:https://pub.dev/packages/file_selectorcrypto— ペイロードのチェックサム用 SHA-256:https://pub.dev/packages/crypto- OWASP — CSV インジェクション:https://owasp.org/www-community/attacks/CSV_Injection