mcpField
P
persistence-drift

persistence-drift

github.com/zakariaf数据与数据库63154 次安装5 周前更新
代码仓库

关于 persistence-drift

面向 Flutter 开发者的持久化实践技能,规范 Drift/SQLite 与 JSON 文件存储。重点是数据库分层、事务、数据约束及可验证备份。

主要功能

  1. 01将 Drift 限定在 lib/data/
  2. 02用架构约束数据不变量
  3. 03以事务提交每次变更
  4. 04采用规范化存储值
  5. 05按 WAL 规则备份并验证
  6. 06提供边界检查脚本

适用场景

  1. 01设计 Drift 表与 DAO
  2. 02实现跨表原子写入
  3. 03规划 SQLite 备份与恢复
  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、repository、连接设置或备份。

根据当前任务阅读参考资料:

  • references/schema-and-daos.md — 审计列混入项、STRICT/CHECK/FK 不变量、规范列类型、索引与 EXPLAIN QUERY PLAN 门槛、DAO 与 repository 的职责划分、使用相对路径将 Blob 存储在磁盘文件中。
  • references/backup-and-wal.md — checkpoint→vacuum→重新打开验证这一基本操作、WAL 规则、先备份再执行任何操作的经验教训、可选 SQLCipher(先设置密钥,再断言 cipher,检查文件头)。
  • references/persistence-without-drift.md — 小型应用使用纯 JSON 文件时遵循相同规范(注入基础目录、debounce 与生命周期 flush、原子写入、宽松解码)。

提交 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. 每次打开时,都要在 beforeOpen/setup 中设置 foreign_keys 和 synchronous;也要在此处幂等地设置 journal_mode = WAL。 foreign_keys 和 synchronous 是每个连接独有的,不会持久化到文件中,因此每次打开时都必须重新设置。相比之下,journal_mode = WAL 会持久化到数据库文件头中,并在连接之间保留;但仍要在 setup 中幂等地设置,以便新建或还原的 DB 采用此模式。无条件执行 PRAGMA foreign_keys = ON(SQLite 默认将其关闭,关闭时会静默忽略外键操作);并将 journal_mode = WAL 用于支持并发持久化读取;对于保存不可再生成的用户数据的存储,设置 synchronous = FULL(WAL+NORMAL“在断电后可能回滚”)。只有在 if (details.wasCreated) 中进行初始化数据填充。
  4. 每次修改使用一个 db.transaction;事务内的每个查询都必须 await;先持久化,再发布。 涉及多行写入的修改(父项及其依赖项,或一笔借记及一笔贷记)必须要么全部成功,要么全部失败。在 transaction(() async { 中漏掉 await,会导致查询在事务关闭后运行——Drift 将其视为数据丢失;这是阻止发布的问题。DAO 的 Future 只有在持久化提交后才会完成;提交的写入随后会自行使被监听的 .watch() 流重新发出新状态——绝不进行提交前的乐观更新,绝不手动重新发布 state = …,绝不“稍后保存”。(写入→UI 更新规则由 state-management-riverpod 负责。)
  5. 只存储规范值;在展示边界进行转换。 金额以整数最小货币单位存储,并依据真实的 ISO-4217 指数确定(绝不使用 REAL/double);其他数量使用 SI 整数;真实时刻使用 UTC epoch 毫秒。本地日历日(任何影响日界的值)应使用序列日整数存储,绝不使用 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。 文件存储在磁盘上(应用私有目录),只保留一行元数据;持久化相对于基目录的路径,并在读取时解析为绝对路径——iOS 重新安装/恢复后,容器 UUID 会变化,绝对路径会失效,而行仍会保留,文件却会显示空白且没有错误。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 限制在一层

使用一个 @DriftDatabase,按功能划分的 @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),且只有该包的 pubspec 依赖 drift/sqlite3,并通过禁止导入的检查来强制执行。对于单包应用,边界是 lib/data/ 目录和 scripts/check-drift-confinement.sh 中的 grep。不要为了实现此边界而采用工作区——目录加 lint 就足够了。

Schema:在表中定义不变量

// 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(一个 mixin on Table)为每张表提供稳定的文本主键(UUID,无冲突、适合合并、导出后保持稳定)、UTC 毫秒时间戳 createdAt/updatedAt、rowRevision,以及用于软删除的 isDeleted/deletedAt。通过一个共享写入封装更新 updatedAt/rowRevision;通过一个共享的基础查询辅助函数筛选 is_deleted = 0,包括分析查询。参见 references/schema-and-daos.md。

连接:每次打开时设置 pragma

// 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 + 仓储:一个事务,映射为值对象

DAO 为一张表保存单表查询(window 是一个纯 Dart TimeWindow 值对象,表示 UTC 毫秒边界——数据层中绝不使用 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)

数据库和每个仓储都通过 provider 提供;作用域内的 .watch() 流映射为流 provider;派生视图通过计算型 provider 得出。保持核心层与持久化机制无关——使用只有一个写入路径的仓储接口,并遵循派生而非存储的原则——让 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));

绝不在 widget 中打开数据库;绝不通过 provider 暴露 DAO。有关如何在这些仓储之上遵循 Notifier/AsyncNotifier 的规范,请参见 state-management-riverpod。

反模式

  • package:drift 符号泄漏到 lib/data/ 之外,或 Widget/Notifier 发起原始查询——行结构属于数据层的内部细节;应在边界处映射为值对象。
  • 表不是 STRICT,或在每个调用点验证枚举/范围,而不是在架构中使用 CHECK——必须确保无法写入损坏的行。
  • 假设 pragma 会持久生效——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 字节,或使用绝对媒体路径——文件应存储在磁盘上,路径相对于基础目录;绝对路径在重新安装/恢复后会失效。
  • 使用 File.copy 备份正在运行的 WAL 数据库——备份可能不完整且无法恢复;应使用 checkpoint + VACUUM INTO + 重新打开验证。
  • 使用 sqflite 后端,或让发布版本能够触及 eraseDatabaseOnSchemaChange——应使用 NativeDatabase FFI;该标志会清除真实数据。

完成标准

  • package:drift/package:sqlite3 只出现在 lib/data/ 下(grep 检查通过);DAO 返回值对象;Drift 类型不会越过边界。后端为 NativeDatabase(FFI),而非 sqflite。
  • 每张表都是 STRICT;枚举使用 CHECK (... IN (...));范围使用 CHECK;关系使用带有显式 onDelete 的外键;唯一性使用(部分)UNIQUE INDEX;高频查询都有匹配的索引,并通过测试中的 EXPLAIN QUERY PLAN 验证。
  • 每次打开时都在 setup/beforeOpen 中设置 journal_mode = WAL、synchronous = FULL、foreign_keys = ON 和 busy_timeout;仅在 if (details.wasCreated) 下进行数据填充。
  • 每次修改都恰好使用一个 db.transaction;其中的每个查询都必须等待完成;写入 Future 在任何 UI 更新之前完成,且被监听的流会重新发出数据(先持久化再发布,绝不乐观更新,不手动重新发布)。
  • 仅使用规范化存储:整数最小货币单位 + ISO-4217 代码、SI 整数、UTC 纪元毫秒;本地日期使用序列整数,绝不使用 DateTime;任何列中都不存储格式化/本地化值。
  • 读取时重新计算派生状态(在数据旁进行一次折叠计算),绝不将其存储为权威值。
  • 读取使用按所有者/时间窗口限定范围的 .watch() 流,并映射为值对象;分页使用键集,绝不使用 OFFSET。
  • BLOB 字节存储为磁盘文件,路径相对于基础目录;不设 BLOB 列。
  • 备份使用 wal_checkpoint(TRUNCATE) + VACUUM INTO + 重新打开验证;不对正在运行的数据库使用 File.copy。
  • 架构变更必须配套执行 run-migration 流程(递增 schemaVersion、追加迁移步骤、提交快照、内容测试)。

相关技能

  • run-migration — 仅向前的 stepByStep 迁移流程、已提交的快照,以及针对此技能定义的表的内容测试。
  • error-handling-typed-results — 仓库返回的 Result/Failure 核心机制,以及永不丢失数据的层(事务、草稿、软删除/撤销)。
  • value-objects-money-and-units — 仓库将规范列映射到的 Money/数量/Clock 值类型。
  • state-management-riverpod — 基于这些仓库的 Notifier/AsyncNotifier 视图模型,以及作为依赖注入的 providers。
  • flutter-architecture — 此数据层所处的仅向下 DAG 和单写入路径分层。
  • codegen-and-toolchain / run-codegen — 运行 build_runner 生成 Drift 代码。
  • service-boundary-and-native — 在组合根中将数据库/仓库接线为可注入的 providers,并在此处进行覆盖。
  • data-export-and-restore — 此存储面向用户的便携功能:带版本的备份封装、先暂存再交换的恢复流程,以及 CSV/PDF 导出。

参考资料

评论

支持 Markdown