关于 persistence-drift
面向 Flutter 开发者的持久化实践技能,规范 Drift/SQLite 与 JSON 文件存储。重点是数据库分层、事务、数据约束及可验证备份。
主要功能
- 01将 Drift 限定在 lib/data/
- 02用架构约束数据不变量
- 03以事务提交每次变更
- 04采用规范化存储值
- 05按 WAL 规则备份并验证
- 06提供边界检查脚本
适用场景
- 01设计 Drift 表与 DAO
- 02实现跨表原子写入
- 03规划 SQLite 备份与恢复
- 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、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。
不可妥协的规则
- 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 代码检查。 - 每次打开时,都要在
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)中进行初始化数据填充。 - 每次修改使用一个
db.transaction;事务内的每个查询都必须await;先持久化,再发布。 涉及多行写入的修改(父项及其依赖项,或一笔借记及一笔贷记)必须要么全部成功,要么全部失败。在transaction(() async {中漏掉await,会导致查询在事务关闭后运行——Drift 将其视为数据丢失;这是阻止发布的问题。DAO 的Future只有在持久化提交后才会完成;提交的写入随后会自行使被监听的.watch()流重新发出新状态——绝不进行提交前的乐观更新,绝不手动重新发布state = …,绝不“稍后保存”。(写入→UI 更新规则由state-management-riverpod负责。) - 只存储规范值;在展示边界进行转换。 金额以整数最小货币单位存储,并依据真实的 ISO-4217 指数确定(绝不使用
REAL/double);其他数量使用 SI 整数;真实时刻使用 UTC epoch 毫秒。本地日历日(任何影响日界的值)应使用序列日整数存储,绝不使用DateTime时刻——时刻会重新引入 DST/时区导致的日期偏差。任何列中都不得存储展示字符串、本地化数字或格式化值;切换区域设置/单位后,存储行必须逐字节保持不变。 - 派生状态应在读取时重新计算,绝不作为第二权威来源存储。 计数、连续天数、累计值、直方图都应是对源行进行纯折叠的结果,并在数据附近通过一个
watch…/查询折叠计算。存储副本会成为第二个事实来源并产生偏差,未来任何同步也都必须协调这两份数据。 - 仓库是唯一写入路径,也是唯一事实来源。 DAO 只负责单表查询;仓库负责跨表事务和行→值对象映射,并为可能失败的操作返回类型化的
Result<T, Failure>。功能代码依赖仓库抽象,绝不依赖 DAO 或 Drift 行。 - 读取使用范围明确的
.watch()流;分页使用键集,不使用OFFSET。 绝不订阅无范围限制的应用级流(每次写入都会触发重新计算);按所属方/实体及时间窗口限定范围。历史记录使用WHERE ts < :cursor ORDER BY ts DESC LIMIT n;在大表上,OFFSET的性能会严重下降。 - 绝不将 BLOB 字节存入 SQLite。 文件存储在磁盘上(应用私有目录),只保留一行元数据;持久化相对于基目录的路径,并在读取时解析为绝对路径——iOS 重新安装/恢复后,容器 UUID 会变化,绝对路径会失效,而行仍会保留,文件却会显示空白且没有错误。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 限制在一层
使用一个 @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——应使用NativeDatabaseFFI;该标志会清除真实数据。
完成标准
-
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 导出。
参考资料
- 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