persistence-drift について
Drift/SQLiteやJSONファイルを使うFlutter開発者向けの永続化ガイドです。データ層の境界、トランザクション、スキーマ制約、検証付きバックアップを重視します。
主な機能
- 01Driftをlib/data/に限定
- 02スキーマで不変条件を適用
- 03変更をトランザクションで確定
- 04正規化した値を保存
- 05WALデータベースをバックアップ・検証
- 06境界チェック用スクリプト
活用シーン
- 01DriftのテーブルとDAOの設計
- 02複数テーブルの原子的な書き込み
- 03SQLiteのバックアップと復元の計画
- 04小規模アプリでのJSON永続化
インストール
コマンドをコピーしてターミナルで実行します。
npx skills add https:/ / github.com/ zakariaf/ flutter-skills --skill persistence-driftインストール後、Claude Code で/persistence-driftを入力して読み込まれたか確認します。
同梱ファイル
SKILL.md と一緒にインストールされる参考資料やスクリプト。
examples/in_memory_dao_test.dartscoped_watch_repository.darttransactional_write.dartreferences/backup-and-wal.mdpersistence-without-drift.mdschema-and-daos.mdscripts/check-drift-confinement.shcheck-persistence-bans.sh詳細
永続化 — Drift / SQLite
デバイス内ストアが唯一の信頼できる情報源です。サーバーがないため、プロセスの終了、再起動、または復元後には、このデータベースだけからすべてを再構築できなければなりません。不変条件はスキーマに組み込み、Drift は単一のレイヤーに限定し、すべての変更を永続化される単一のトランザクションにまとめ、正規化された値のみを保存してください。lib/data/ 内の Drift のテーブル、DAO、リポジトリ、接続設定、バックアップすべてに適用されます。
作業内容に応じて、以下のリファレンスを参照してください。
references/schema-and-daos.md— 監査列の Mixin、STRICT/CHECK/FK の不変条件、正規化された列型、インデックスとEXPLAIN QUERY PLANのゲート、DAO とリポジトリの分離、相対パスを使用した BLOB のディスク上のファイル保存。references/backup-and-wal.md— チェックポイント→バキューム→再オープンによる検証の基本手順、WAL のルール、何よりも先にバックアップすることの重要性、オプションの SQLCipher(キーを最初に設定し、cipher をアサートし、ヘッダーを確認)。references/persistence-without-drift.md— プレーンな JSON ファイルを使う小規模なアプリでの同じ原則(ベースディレクトリの注入、デバウンスとライフサイクル時のフラッシュ、アトミックな書き込み、柔軟なデコード)。
PR の前に scripts/check-drift-confinement.sh と scripts/check-persistence-bans.sh を実行してください。マイグレーションとそのテストについては、run-migration を参照してください。
絶対に守るべきルール
- Drift は
lib/data/内だけで使用します。それ以外の場所では値型のみを扱います。package:driftとpackage:sqlite3はほかの場所ではインポートされません。禁止インポートを検出する lint/grep でこれを強制します。DAO は行を不変の値オブジェクトにマッピングします。Table、Companion、TableInfo、生成された行クラスが境界を越えることはありません。機能コードが生のクエリを使うのはレイヤー違反であり、その上位すべての純粋なテストも妨げます。 - 不変条件は呼び出し元ではなく、スキーマに設定します。 テーブルは
STRICT(暗黙の型変換なし)にします。列挙可能な列にはCHECK (col IN (...))、範囲にはCHECK (qty BETWEEN 0 AND n)/CHECK (amount_minor >= 0)を設定します。リレーションはonDeleteを明示した外部キーにし、一意性は(部分)UNIQUE INDEXで保証します。破損した行は Dart で検査するだけでなく、ストレージ層で表現できないようにする必要があります。 foreign_keysとsynchronousは、すべてのオープン時にbeforeOpen/setupで設定します。journal_mode = WALも同じ場所で冪等に設定します。foreign_keysとsynchronousは接続ごとの設定で、ファイルには保存されないため、オープンのたびに再設定する必要があります。一方、journal_mode = WALはデータベースファイルのヘッダーに保存され、接続をまたいで保持されますが、新規作成または復元された DB にも適用されるよう、セットアップで冪等に設定します。PRAGMA foreign_keys = ONは無条件に設定します(SQLite ではデフォルトで OFF であり、OFF の場合、外部キーの処理は警告なしに何も行いません)。並行した耐久性のある読み取りにはjournal_mode = WALを使います。再生成できないユーザーデータを保持するストアではsynchronous = FULLを使います(WAL+NORMAL では「電源障害の後にロールバックされる可能性があります」)。シード処理はif (details.wasCreated)の中でのみ行います。- 変更ごとに
db.transactionを1つ使い、その中のすべてのクエリを await し、公開前に永続化します。 複数の行(親と従属データ、または借方と貸方)を書き込む変更は、すべて成功するか、すべて失敗するかのどちらかでなければなりません。transaction(() async {の中でawaitを忘れると、トランザクション終了後にクエリが実行されることがあります。Drift はこれをデータ損失とみなしており、リリースを阻止する問題です。DAO のFutureは永続的なコミットが完了してから解決します。コミット済みの書き込みにより、監視中の.watch()ストリームが自動的に新しい状態を再通知します。コミット前の楽観的更新も、手動でのstate = …再公開も、「後で保存」も行いません。(書き込みから UI 更新までのルールはstate-management-riverpodが担います。) - 正規化された値のみを保存し、表示時に変換します。 金額は実際の ISO-4217 の指数に基づく整数の最小通貨単位で保存します(
REAL/doubleは使いません)。それ以外の数量は SI 単位の整数、実際の時刻は UTC のエポックミリ秒で保存します。ローカルカレンダー上の日付(日付の境界を決めるもの)はシリアル日付の整数とし、DateTimeの時刻値にはしません。時刻値では DST/タイムゾーンによる日付のずれが再び発生します。表示文字列、ローカライズされた数字、書式設定済みの値を列に保存してはいけません。ロケールや単位を切り替えても、保存済みの行はバイト単位で同一でなければなりません。 - 派生状態は読み取り時に再計算し、第二の信頼できる情報源として保存しません。 件数、連続記録、累計、ヒストグラムはソース行を純粋に集計し、データの近くにある単一の
watch…/クエリ集計で計算します。コピーを保存すると第二の情報源となって値がずれ、将来同期を導入する際に両方の整合性を取る必要が生じます。 - リポジトリを唯一の書き込み経路かつ唯一の信頼できる情報源にします。 DAO は単一テーブルのクエリを担当し、リポジトリは複数テーブルにまたがるトランザクションと行から値オブジェクトへのマッピングを担い、失敗する可能性がある処理では型付きの
Result<T, Failure>を返します。機能コードは DAO や Drift の行ではなく、リポジトリの抽象化に依存します。 - 読み取りには範囲を限定した
.watch()ストリームを使い、ページネーションにはOFFSETではなくキーセット方式を使います。 アプリ全体を対象とした範囲のないストリームは、書き込みのたびに再計算されるため、購読してはいけません。所有者/エンティティと期間で範囲を限定します。履歴にはWHERE ts < :cursor ORDER BY ts DESC LIMIT nを使います。大きなテーブルではOFFSETの性能が大幅に低下します。 - BLOB のバイト列を SQLite に保存してはいけません。 ファイルはディスク(アプリ専用領域)に保存し、メタデータの行のみを保持します。パスはベースディレクトリからの相対パスで保存し、読み取り時に絶対パスへ解決します。絶対パスは、コンテナ UUID が変わる iOS の再インストール/復元後には無効になります。その一方で行は残るため、ファイルが表示されないのにエラーも発生しない状態になります。BLOB は DB を肥大化させ、チェックポイントとバックアップのたびに処理を遅くします。
- バックアップには
wal_checkpoint(TRUNCATE)とVACUUM INTOを使い、再オープンして検証します。WAL を使用中の DB をFile.copyでコピーしてはいけません。 WAL モードの DB をそのままコピーすると、-wal/-shmサイドカーファイルをまたぐ不整合な状態が含まれ、破損して復元できなくなります。再オープンして整合性を確認していないバックアップは、成功したとはいえません。詳細はreferences/backup-and-wal.mdを参照してください。 - スキーマの進化は前進のみ・追加のみとし、スナップショットで保護します。これは独立した手順です。
schemaVersionを上げ、新しいstepByStepステップを追加し(リリース済みのステップは編集しません)、スキーマスナップショットをコミットします。コンテンツレベルのテストなしにマイグレーションをリリースしてはいけません。この一連のワークフローはrun-migrationに記載されています。このスキルでは、マイグレーション対象のテーブルを定義します。
Drift を 1 つのレイヤーに閉じ込める
@DriftDatabase は 1 つ、機能ごとの @DriftAccessor DAO を用意し、その上にリポジトリを配置します。NativeDatabase FFI バックエンドを使用し、sqflite は決して使用しません。
lib/data/
db/ app_database.dart (@DriftDatabase, schemaVersion, MigrationStrategy)
connection.dart (open + beforeOpen pragmas; optional SQLCipher key)
tables/ one Table subclass per entity + audit mixin
daos/ one @DriftAccessor per feature (single-table queries)
repositories/ cross-table transactions; map rows -> value objects; expose .watch
マルチパッケージ(ワークスペース)の場合: 同じ境界はパッケージ(例:
packages/data)になり、drift/sqlite3に依存する pubspec はそのパッケージだけになります。これはインポート禁止のチェックで強制します。単一パッケージのアプリでは、境界はlib/data/ディレクトリとscripts/check-drift-confinement.sh内の grep です。この境界を設けるためにワークスペースを導入する必要はありません。ディレクトリと lint で十分です。
スキーマ:テーブルに不変条件を持たせる
// lib/data/db/tables/orders.dart — the ONLY layer importing package:drift.
import 'package:drift/drift.dart';
@DataClassName('OrderRow') // generated row type stays inside lib/data/
class Orders extends Table with AuditColumns {
TextColumn get status =>
text().check(status.isIn(const ['draft', 'placed', 'shipped', 'cancelled']))();
IntColumn get amountMinor => integer()(); // integer minor units, never REAL
TextColumn get currencyCode => text().withLength(min: 3, max: 3)();
IntColumn get placedOnDay => integer().nullable()(); // LOCAL calendar day as serial int
IntColumn get placedAtUtcMs => integer().nullable()(); // true instant, UTC epoch millis
@override
List<String> get customConstraints => const [
'CHECK (amount_minor >= 0)',
// a placed order must carry the day that drives its boundary
"CHECK (status = 'draft' OR placed_on_day IS NOT NULL)",
];
@override
bool get isStrict => true; // no silent type coercion
}
AuditColumns(Table を対象とする mixin)を使うと、すべてのテーブルに安定したテキスト形式の PK(UUID。衝突せず、マージに安全で、エクスポート後も安定します)、UTC ミリ秒単位の createdAt/updatedAt、rowRevision、およびソフトデリート用の isDeleted/deletedAt が追加されます。共有の書き込みラッパーを通して updatedAt/rowRevision を設定し、分析処理も含め、共有のベースクエリヘルパーで is_deleted = 0 を条件にします。references/schema-and-daos.md を参照してください。
接続:接続を開くたびにプラグマを設定する
// lib/data/db/connection.dart
LazyDatabase openConnection() => LazyDatabase(() async {
final dir = await getApplicationSupportDirectory(); // internal DB, not Documents
final file = File(p.join(dir.path, 'app.sqlite'));
return NativeDatabase.createInBackground(
file,
setup: (raw) {
// foreign_keys & synchronous are PER-CONNECTION (not persisted) — set on every open.
// WAL persists in the file header, but set it idempotently so a fresh/restored DB adopts it.
raw.execute('PRAGMA journal_mode = WAL;');
raw.execute('PRAGMA synchronous = FULL;');
raw.execute('PRAGMA foreign_keys = ON;');
raw.execute('PRAGMA busy_timeout = 5000;');
},
);
});
MigrationStrategy.beforeOpen で foreign_keys = ON を再設定し、if (details.wasCreated) の場合にのみ初期データを登録します。マイグレーション中に foreign_keys を切り替える場合は、トランザクションの外で行ってください(トランザクション内では何も起こりません)。その後、PRAGMA foreign_key_check を実行します。
DAO + リポジトリ:1 つのトランザクションを値オブジェクトにマッピングする
DAO には、1 つのテーブルに対する単一テーブルクエリを保持します(window は UTC ミリ秒の境界値を持つプレーンな Dart の TimeWindow VO です。データレイヤーで Flutter の DateTimeRange を使うことは決してありません)。
// lib/data/daos/orders_dao.dart — the only door to SQL for the Orders table.
@DriftAccessor(tables: [Orders])
class OrdersDao extends DatabaseAccessor<AppDatabase> with _$OrdersDaoMixin {
OrdersDao(super.db);
/// Reactive, SCOPED, SINGLE-TABLE read. Rows map to value objects — callers see no Drift symbol.
Stream<List<Order>> watchForAccount(String accountId, TimeWindow window) =>
(select(orders)
..where((o) =>
o.accountId.equals(accountId) &
o.isDeleted.equals(false) & // always filter soft-deletes
o.placedAtUtcMs.isBetweenValues(window.startMs, window.endMs))
..orderBy([(o) => OrderingTerm.desc(o.placedAtUtcMs)]))
.watch()
.map((rows) => rows.map(_toModel).toList());
}
リポジトリはテーブル間のトランザクションを担います([Orders] にスコープされた DAO から lineItems を参照することはできません。これはルール 7 です)。また、行を値オブジェクトにマッピングします。
// lib/data/repositories/order_repository.dart — the single write path across tables.
class OrderRepository {
OrderRepository(this._db);
final AppDatabase _db;
/// ONE transaction, every query awaited, persist-before-publish.
Future<void> place(Order order, LineItem item) {
return _db.transaction(() async {
await _db.into(_db.orders).insertOnConflictUpdate(order.toRow()); // await — required
await _db.into(_db.lineItems).insert(item.toCompanion()); // await — required
}); // Future resolves only after the durable commit; the watched stream re-emits — no manual republish.
}
}
リポジトリは OrderRow を不変の Order 値オブジェクトにマッピングします(整数の最小通貨単位を Money 値オブジェクトに、placedOnDay シリアルを暦日型に変換します)。これにより、どの機能も正規の int や Drift クラスを直接参照しません。キーセットページネーション:..where((o) => o.placedAtUtcMs.isSmallerThanValue(cursor))..limit(n)。
Riverpod の配線(状態管理 + DI)
データベースと各リポジトリを提供し、スコープ付きの .watch() ストリームをストリームプロバイダーに対応付け、派生ビューを計算プロバイダーとして実装します。コア部分は永続化方式に依存しないようにします。書き込み経路が 1 つのリポジトリインターフェースを用意し、値は保存せずに導出します。そして Riverpod を注入と利用の仕組みとして使います。
final appDatabaseProvider = Provider<AppDatabase>((ref) {
final db = AppDatabase(openConnection());
ref.onDispose(db.close); // close the connection with the provider
return db;
});
final orderRepositoryProvider = Provider<OrderRepository>(
(ref) => OrderRepository(ref.watch(appDatabaseProvider)),
);
// Scoped stream — family carries the scope; autoDispose tears the watch down.
final ordersProvider = StreamProvider.autoDispose
.family<List<Order>, OrderScope>((ref, scope) =>
ref.watch(orderRepositoryProvider).watchForAccount(scope.accountId, scope.window));
ウィジェット内で DB を開かないでください。DAO をプロバイダー経由で公開しないでください。これらのリポジトリで Notifier/AsyncNotifier を扱う際の規律については、state-management-riverpod を参照してください。
アンチパターン
package:driftのシンボルがlib/data/の外へ漏れる、またはウィジェット/Notifier が生のクエリを発行する — 行の形式はデータレイヤーの内部情報です。境界で値オブジェクトにマッピングしてください。STRICTでないテーブルを使う、またはスキーマのCHECKではなく各呼び出し元で列挙値や範囲を検証する — 不正な行を書き込めないようにしてください。- プラグマが保持されると想定する —
foreign_keys/synchronous/暗号化キーは接続ごとに設定が必要です。開くたびにsetup/beforeOpenで設定してください。トランザクション内のPRAGMA foreign_keysは何もせずに無視されます。 - 再生成できないデータを保存するストアで
synchronous = NORMALを使う — SQLite によると、WAL+NORMAL では停電後にトランザクションがロールバックされる可能性があります。 DateTimeの時刻をローカル日付として保存する — DST/タイムゾーンによる日付の繰り上がりバグが再発します。ローカル日はシリアル整数、時刻は UTC ミリ秒で保存してください。- 保存済みの
count/total/streakを正しい値として扱う — 元の行から再計算してください。保存した複製値はずれていきます。 - 変更を複数のトランザクションに分割する、
transaction(() async {内でawaitを省略する、またはコミット前に楽観的更新を行う — いずれもデータ損失につながりかねません。先にコミットし、監視中のストリームから再通知させてください。 OFFSETによるページネーション、またはスコープを限定しない.watch()— どちらも性能が低下し、再計算が過剰になります。キーセットページネーションとスコープを限定したストリームを使ってください。- SQLite に BLOB バイト列を保存する、または絶対メディアパスを使う — ファイルはディスク上に保存し、ベースディレクトリからの相対パスを使ってください。絶対パスは再インストール/復元後に無効になります。
- 稼働中の WAL DB をバックアップするために
File.copyを使う — バックアップが不完全になり、復元できません。チェックポイント処理 +VACUUM INTO+ 再オープンによる検証を行ってください。 sqfliteバックエンドを使う、またはリリースビルドからeraseDatabaseOnSchemaChangeに到達できる —NativeDatabaseFFI を使ってください。このフラグは実データを消去します。
完了の定義
-
package:drift/package:sqlite3がlib/data/内にのみ存在する(grep が成功する)。DAO は値オブジェクトを返し、Drift の型は境界を越えません。バックエンドはsqfliteではなくNativeDatabase(FFI)です。 - すべてのテーブルが
STRICTです。列挙値はCHECK (... IN (...))、範囲はCHECKで検証され、リレーションは明示的なonDeleteを指定した外部キーで定義されます。一意性は(部分)UNIQUE INDEXで定義されます。負荷の高いクエリには対応するインデックスがあり、テストでEXPLAIN QUERY PLANによって検証されています。 -
journal_mode = WAL、synchronous = FULL、foreign_keys = ON、busy_timeoutが、開くたびにsetup/beforeOpenで設定されます。シード処理はif (details.wasCreated)の中でのみ行われます。 - 各変更は必ず 1 つの
db.transactionで実行されます。内部のすべてのクエリはawaitされます。UI の更新前に書き込みのFutureが完了し、監視中のストリームから再通知されます(公開前に永続化し、楽観的更新は行わず、手動で再通知しません)。 - 正規化された形式でのみ保存します。整数の最小通貨単位 + ISO-4217 コード、SI 単位の整数、UTC エポックミリ秒を使います。ローカル日はシリアル整数であり、
DateTimeは使いません。どのカラムにも書式設定済み/ローカライズ済みの値を保存しません。 - 導出状態は読み取り時に再計算します(データの近くで 1 回の集約処理を行います)。正しい値として保存することはありません。
- 読み取りは、所有者/期間でスコープを限定した
.watch()ストリームを値オブジェクトにマッピングします。ページネーションにはキーセットを使い、OFFSETは使いません。 - BLOB バイト列はディスク上のファイルとして保存し、ベースディレクトリからの相対パスを使います。BLOB カラムはありません。
- バックアップでは
wal_checkpoint(TRUNCATE)+VACUUM INTO+ 再オープンによる検証を行います。稼働中の DB にFile.copyは使いません。 - スキーマ変更には
run-migrationの手順を組み合わせます(schemaVersionの更新、追記のみのステップ、スナップショットのコミット、内容のテスト)。
関連スキル
run-migration— 前方のみのstepByStep移行の手順、コミット済みスナップショット、およびこのスキルで定義するテーブルのコンテンツテストです。error-handling-typed-results— リポジトリが返すResult/Failureの基盤と、データを決して失わないためのレイヤー(トランザクション、下書き、ソフト削除/Undo)です。value-objects-money-and-units— リポジトリが正規のカラムにマッピングするMoney/数量/Clockの値型です。state-management-riverpod— これらのリポジトリ上に構築する Notifier/AsyncNotifier ViewModel と、DI としてのプロバイダーです。flutter-architecture— このデータレイヤーが最下層に位置する、下方向のみの DAG と単一書き込み経路のレイヤー構造です。codegen-and-toolchain/run-codegen— Drift の生成コードをbuild_runnerで実行する方法です。service-boundary-and-native— データベース/リポジトリを、コンポジションルートでオーバーライドする注入可能なプロバイダーとして接続する方法です。data-export-and-restore— このストアのユーザー向けのポータブルな機能です。バージョン管理されたバックアップエンベロープ、ステージング後のスワップによる復元、CSV/PDF エクスポートを扱います。
参考資料
- Drift のドキュメント:https://drift.simonbinder.eu/
- Drift のトランザクション:https://drift.simonbinder.eu/dart_api/transactions/
- Drift のマイグレーション:https://drift.simonbinder.eu/Migrations/
- SQLite の STRICT テーブル:https://sqlite.org/stricttables.html
- SQLite の WAL:https://sqlite.org/wal.html
- SQLite の
VACUUM INTO:https://sqlite.org/lang_vacuum.html#vacuuminto - SQLite の外部キー(接続ごとの pragma):https://sqlite.org/foreignkeys.html
path_provider:https://pub.dev/packages/path_provider