mcpField
P
persistence-drift

persistence-drift

github.com/zakariafデータ・データベース63154 回インストール5 週間前に更新
リポジトリ

persistence-drift について

Drift/SQLiteやJSONファイルを使うFlutter開発者向けの永続化ガイドです。データ層の境界、トランザクション、スキーマ制約、検証付きバックアップを重視します。

主な機能

  1. 01Driftをlib/data/に限定
  2. 02スキーマで不変条件を適用
  3. 03変更をトランザクションで確定
  4. 04正規化した値を保存
  5. 05WALデータベースをバックアップ・検証
  6. 06境界チェック用スクリプト

活用シーン

  1. 01DriftのテーブルとDAOの設計
  2. 02複数テーブルの原子的な書き込み
  3. 03SQLiteのバックアップと復元の計画
  4. 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.dart
scoped_watch_repository.dart
transactional_write.dart
references/
backup-and-wal.md
persistence-without-drift.md
schema-and-daos.md
scripts/
check-drift-confinement.sh
check-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 を参照してください。

絶対に守るべきルール

  1. Drift は lib/data/ 内だけで使用します。それ以外の場所では値型のみを扱います。 package:drift と package:sqlite3 はほかの場所ではインポートされません。禁止インポートを検出する lint/grep でこれを強制します。DAO は行を不変の値オブジェクトにマッピングします。Table、Companion、TableInfo、生成された行クラスが境界を越えることはありません。機能コードが生のクエリを使うのはレイヤー違反であり、その上位すべての純粋なテストも妨げます。
  2. 不変条件は呼び出し元ではなく、スキーマに設定します。 テーブルは STRICT(暗黙の型変換なし)にします。列挙可能な列には CHECK (col IN (...))、範囲には CHECK (qty BETWEEN 0 AND n) / CHECK (amount_minor >= 0) を設定します。リレーションは onDelete を明示した外部キーにし、一意性は(部分)UNIQUE INDEX で保証します。破損した行は Dart で検査するだけでなく、ストレージ層で表現できないようにする必要があります。
  3. 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) の中でのみ行います。
  4. 変更ごとに db.transaction を1つ使い、その中のすべてのクエリを await し、公開前に永続化します。 複数の行(親と従属データ、または借方と貸方)を書き込む変更は、すべて成功するか、すべて失敗するかのどちらかでなければなりません。transaction(() async { の中で await を忘れると、トランザクション終了後にクエリが実行されることがあります。Drift はこれをデータ損失とみなしており、リリースを阻止する問題です。DAO の Future は永続的なコミットが完了してから解決します。コミット済みの書き込みにより、監視中の .watch() ストリームが自動的に新しい状態を再通知します。コミット前の楽観的更新も、手動での state = … 再公開も、「後で保存」も行いません。(書き込みから UI 更新までのルールは state-management-riverpod が担います。)
  5. 正規化された値のみを保存し、表示時に変換します。 金額は実際の ISO-4217 の指数に基づく整数の最小通貨単位で保存します(REAL/double は使いません)。それ以外の数量は SI 単位の整数、実際の時刻は UTC のエポックミリ秒で保存します。ローカルカレンダー上の日付(日付の境界を決めるもの)はシリアル日付の整数とし、DateTime の時刻値にはしません。時刻値では DST/タイムゾーンによる日付のずれが再び発生します。表示文字列、ローカライズされた数字、書式設定済みの値を列に保存してはいけません。ロケールや単位を切り替えても、保存済みの行はバイト単位で同一でなければなりません。
  6. 派生状態は読み取り時に再計算し、第二の信頼できる情報源として保存しません。 件数、連続記録、累計、ヒストグラムはソース行を純粋に集計し、データの近くにある単一の watch…/クエリ集計で計算します。コピーを保存すると第二の情報源となって値がずれ、将来同期を導入する際に両方の整合性を取る必要が生じます。
  7. リポジトリを唯一の書き込み経路かつ唯一の信頼できる情報源にします。 DAO は単一テーブルのクエリを担当し、リポジトリは複数テーブルにまたがるトランザクションと行から値オブジェクトへのマッピングを担い、失敗する可能性がある処理では型付きの Result<T, Failure> を返します。機能コードは DAO や Drift の行ではなく、リポジトリの抽象化に依存します。
  8. 読み取りには範囲を限定した .watch() ストリームを使い、ページネーションには OFFSET ではなくキーセット方式を使います。 アプリ全体を対象とした範囲のないストリームは、書き込みのたびに再計算されるため、購読してはいけません。所有者/エンティティと期間で範囲を限定します。履歴には WHERE ts < :cursor ORDER BY ts DESC LIMIT n を使います。大きなテーブルでは OFFSET の性能が大幅に低下します。
  9. BLOB のバイト列を SQLite に保存してはいけません。 ファイルはディスク(アプリ専用領域)に保存し、メタデータの行のみを保持します。パスはベースディレクトリからの相対パスで保存し、読み取り時に絶対パスへ解決します。絶対パスは、コンテナ UUID が変わる iOS の再インストール/復元後には無効になります。その一方で行は残るため、ファイルが表示されないのにエラーも発生しない状態になります。BLOB は DB を肥大化させ、チェックポイントとバックアップのたびに処理を遅くします。
  10. バックアップには wal_checkpoint(TRUNCATE) と VACUUM INTO を使い、再オープンして検証します。WAL を使用中の DB を File.copy でコピーしてはいけません。 WAL モードの DB をそのままコピーすると、-wal/-shm サイドカーファイルをまたぐ不整合な状態が含まれ、破損して復元できなくなります。再オープンして整合性を確認していないバックアップは、成功したとはいえません。詳細は references/backup-and-wal.md を参照してください。
  11. スキーマの進化は前進のみ・追加のみとし、スナップショットで保護します。これは独立した手順です。 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 に到達できる — NativeDatabase FFI を使ってください。このフラグは実データを消去します。

完了の定義

  • 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 エクスポートを扱います。

参考資料

コメント