persistence-drift
Compare original and translation side by side
🇺🇸
Original
English🇨🇳
Translation
ChinesePersistence — Drift / SQLite
持久化 — Drift / SQLite
The on-device store is the single source of truth: with no server, everything must rebuild from this DB alone after
process death, reboot, or restore. Push invariants into the schema, confine Drift to one layer, make every mutation
one durable transaction, and store canonical values only. Applies to any Drift table, DAO, repository,
connection setup, or backup.
lib/data/Read the reference for the task at hand:
- — audit-column mixin, STRICT/CHECK/FK invariants, canonical column types, the index +
references/schema-and-daos.mdgate, DAO↔repository split, files-on-disk-with-relative-paths for blobs.EXPLAIN QUERY PLAN - — the checkpoint→vacuum→verify-by-reopen primitive, WAL rules, the backup-before-anything lesson, optional SQLCipher (key-first, assert-cipher, header-check).
references/backup-and-wal.md - — the same discipline for a small app on plain JSON files (injected base dir, debounce + lifecycle flush, atomic writes, lenient decode).
references/persistence-without-drift.md
Run and before a PR. Migrations and their tests: see .
scripts/check-drift-confinement.shscripts/check-persistence-bans.shrun-migration设备端 store 是唯一的事实来源:没有 server,因此进程死亡、设备重启或恢复后,所有内容都必须仅依靠此 DB 重建。将不变量下沉到 schema,将 Drift 限制在单一层内,使每次 mutation 都成为一次持久化 transaction,并且只存储规范值。适用于 lib/data/ 中的任何 Drift table、DAO、repository、connection 配置或备份。
根据当前任务阅读对应的 reference:
- — audit-column mixin、STRICT/CHECK/FK 不变量、规范 column 类型、index +
references/schema-and-daos.md准入检查、DAO↔repository 分层,以及将 blob 以相对路径存放到磁盘上的规则。EXPLAIN QUERY PLAN - — checkpoint→vacuum→重新打开验证的基础流程、WAL 规则、任何操作前先备份的经验法则,以及可选的 SQLCipher(先设置 key、断言 cipher、检查 header)。
references/backup-and-wal.md - — 小型 app 使用普通 JSON 文件时仍应遵循同样的纪律(注入 base dir、debounce + lifecycle flush、原子写入、宽松 decode)。
references/persistence-without-drift.md
提交 PR 前运行 和 。迁移及其测试:参见 。
scripts/check-drift-confinement.shscripts/check-persistence-bans.shrun-migrationNon-negotiable rules
不可妥协的规则
- Drift lives only in ; everything else sees value types.
lib/data/andpackage:driftare imported nowhere else — a banned-import lint/grep enforces it. DAOs map rows to immutable value objects; nopackage:sqlite3,Table,Companion, or generated row class crosses the boundary. A feature reaching for a raw query is a layering break that also blocks pure testing of everything above it.TableInfo - Put invariants in the schema, not at call sites. Tables are (no silent coercion); enumerable columns get
STRICT, ranges getCHECK (col IN (...))/CHECK (qty BETWEEN 0 AND n), relations are foreign keys with an explicitCHECK (amount_minor >= 0), uniqueness is a (partial)onDelete. A corrupt row must be unrepresentable at the storage layer, not merely policed in Dart.UNIQUE INDEX - and
foreign_keysare set insynchronous/beforeOpenon EVERY open;setupis set idempotently there too.journal_mode = WALandforeign_keysare per-connection and are not persisted in the file, so they must be re-asserted on every open.synchronous, by contrast, is persisted in the database file header and survives across connections — but it is still set idempotently in setup so a freshly created or restored DB adopts it.journal_mode = WALunconditionally (SQLite defaults it OFF and silently no-ops FK actions when off);PRAGMA foreign_keys = ONfor concurrent durable reads;journal_mode = WALon any store holding non-regenerable user data (WAL+NORMAL "might rollback following a power failure"). Seeding, and only seeding, goes insidesynchronous = FULL.if (details.wasCreated) - One per mutation; every query inside awaited; persist before publish. A mutation that writes several rows (parent + dependents, or a debit + a credit) is all-or-nothing. A missing
db.transactioninsideawaitlets a query run after the transaction closes — Drift calls this data loss; it is a release blocker. The DAOtransaction(() async {resolves only after the durable commit; the committed write then makes the watchedFuturestream re-emit the new state on its own — never an optimistic pre-commit update, never a manual.watch()republish, never "save later". (The write→UI-update rule is owned bystate = ….)state-management-riverpod - Store canonical values only; convert at the presentation edge. Money as integer minor units keyed to the real ISO-4217 exponent (never a /
REAL), other quantities as SI integers, true instants as UTC epoch millis. The local calendar day (anything that drives a day boundary) is a serial-day integer, never adoubleinstant — an instant reintroduces the DST/timezone off-by-one. No display strings, localized numerals, or formatted values in any column; switching locale/unit must leave stored rows byte-identical.DateTime - Derived state is recomputed on read, never stored as a second authority. Counts, streaks, running totals, histograms are pure folds over their source rows, computed by one /query fold next to the data. A stored copy is a second source of truth that drifts and that any future sync must reconcile twice.
watch… - The repository is the single write path and the single source of truth. DAOs hold single-table queries; repositories own cross-table transactions and row→value-object mapping, and return a typed for fallible work. Feature code depends on the repository abstraction, never on a DAO or Drift row.
Result<T, Failure> - Reads are scoped streams; pagination is keyset, not
.watch(). Never subscribe to an unscoped app-wide stream (it recomputes on every write); scope by owner/entity + time window. History usesOFFSET;WHERE ts < :cursor ORDER BY ts DESC LIMIT ndegrades badly on large tables.OFFSET - Blob bytes never live in SQLite. Store files on disk (app-private) with only a metadata row; persist the path relative to a base directory and resolve to absolute at read time — an absolute path dies on iOS reinstall/restore when the container UUID changes, and the row survives while the file renders blank with no error. BLOBs bloat the DB and slow every checkpoint and backup.
- Back up via +
wal_checkpoint(TRUNCATE), then verify by reopen — neverVACUUM INTOa live WAL DB. A raw copy of a WAL-mode DB captures a torn state across theFile.copy/-walsidecars and is corrupt and unrestorable. A backup that was not re-opened and integrity-checked did not succeed. Detail in-shm.references/backup-and-wal.md - Schema evolution is forward-only, append-only, and snapshot-guarded — and it is a separate ritual. Bump , add a new
schemaVersionstep (never edit a shipped one), commit the schema snapshot, and ship no migration without a content-level test. That whole workflow lives instepByStep; this skill defines the tables it migrates.run-migration
- Drift 只能存在于 ;其他所有层只能看到值类型。
lib/data/和package:drift不得在其他任何位置导入——由 banned-import lint/grep 强制执行。DAO 将数据行映射为不可变值对象;任何package:sqlite3、Table、Companion或生成的 row class 都不得越过边界。feature 直接使用 raw query 属于分层破坏,也会使其上层所有代码无法进行纯测试。TableInfo - 将不变量放在 schema 中,而不是调用点。 表必须是 (不允许静默类型强制转换);可枚举列使用
STRICT,范围使用CHECK (col IN (...))/CHECK (qty BETWEEN 0 AND n),关系使用带明确CHECK (amount_minor >= 0)的 foreign key,唯一性使用(partial)onDelete。损坏的行必须在 storage 层就无法表示,而不只是由 Dart 代码进行约束。UNIQUE INDEX - 每次打开时,都必须在 /
beforeOpen中设置setup和foreign_keys;synchronous也要在那里以幂等方式设置。journal_mode = WAL和foreign_keys是 per-connection 属性,不会持久化到文件中,因此每次打开时都必须重新声明。相比之下,synchronous会持久化在数据库文件头中,并可跨 connection 保留——但仍应在 setup 中以幂等方式设置,以确保新建或恢复的 DB 采用该模式。无条件执行journal_mode = WAL(SQLite 默认关闭,关闭时 FK 操作会静默失效);使用PRAGMA foreign_keys = ON支持并发的持久化读取;对于存储不可再生成用户数据的 store,使用journal_mode = WAL(WAL+NORMAL“在断电后可能会回滚”)。只有 seeding 操作可以放入synchronous = FULL中。if (details.wasCreated) - 每次 mutation 使用一个 ;transaction 内的每个 query 都必须 await;先持久化,再发布。 写入多行的 mutation(父记录及依赖记录,或一笔借方与一笔贷方)必须做到全有或全无。在
db.transaction内遗漏transaction(() async {,会让 query 在 transaction 关闭后继续运行——Drift 将其视为数据丢失;这是 release blocker。DAO 的await仅在持久化 commit 完成后才 resolve;commit 的写入随后会由被监听的Futurestream 自动重新发出新状态——绝不能进行乐观的 pre-commit 更新,绝不能手动重新发布.watch(),也绝不能“稍后保存”。(write→UI 更新规则由state = …负责。)state-management-riverpod - 只存储规范值;在展示边界进行转换。 金额使用与实际 ISO-4217 exponent 对应的整数最小单位(绝不使用 /
REAL),其他数量使用 SI 整数,真实时间点使用 UTC epoch millis。本地日历日(任何决定日边界的内容)是 serial-day 整数,绝不能是double时间点——时间点会重新引入 DST/timezone 差一错误。列中不得存储显示字符串、本地化数字或格式化值;切换 locale/unit 后,存储的行必须保持逐字节一致。DateTime - 派生状态在读取时重新计算,绝不作为第二事实来源存储。 计数、连续记录数、累计总计和直方图都是源行上的纯 fold,由数据旁一个 /query fold 计算。存储副本会成为第二个事实来源并发生漂移,而且未来的任何 sync 都必须对其协调两次。
watch… - repository 是唯一写入路径和唯一事实来源。 DAO 负责单表 query;repository 负责跨表 transaction 和 row→value-object 映射,并为可能失败的工作返回有类型的 。feature 代码依赖 repository 抽象,绝不依赖 DAO 或 Drift row。
Result<T, Failure> - 读取使用带作用域的 stream;分页使用 keyset,而非
.watch()。 绝不订阅无作用域的应用级 stream(每次写入都会触发重新计算);应按 owner/entity + time window 限定范围。历史记录使用OFFSET;WHERE ts < :cursor ORDER BY ts DESC LIMIT n在大型表上性能会严重下降。OFFSET - BLOB 字节绝不存放在 SQLite 中。 将文件存放到磁盘(app-private),数据库中只保留 metadata row;将 path 持久化为相对于 base directory 的路径,并在读取时解析为绝对路径——当 container UUID 变化时,绝对路径会在 iOS 重新安装或恢复后失效,届时 row 仍然存在,但文件会无报错地显示为空白。BLOB 会让 DB 膨胀,并拖慢每次 checkpoint 和 backup。
- 通过 +
wal_checkpoint(TRUNCATE)备份,然后通过重新打开进行验证——绝不通过VACUUM INTO复制活动 WAL DB。 直接复制 WAL-mode DB 会跨File.copy/-walsidecar 捕获到撕裂状态,导致文件损坏且无法恢复。未重新打开并执行完整性检查的备份不算成功。详情见-shm。references/backup-and-wal.md - Schema 演进只能向前、只能追加,并受 snapshot 保护——而且它是独立流程。 提高 ,添加新的
schemaVersionstep(绝不修改已经发布的 step),提交 schema snapshot,并且任何 migration 都必须配套内容级测试。整个流程由stepByStep负责;本 skill 定义由其迁移的 table。run-migration
Confining Drift to one layer
将 Drift 限制在单一层
One , per-feature DAOs, repositories on top. Use the FFI backend, never .
@DriftDatabase@DriftAccessorNativeDatabasesqflitetext
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 .watchWhen multi-package (workspace): the same boundary becomes a package (e.g.) that is the only pubspec depending onpackages/data/drift, enforced by a banned-import gate. For a single-package app the boundary is thesqlite3directory and the grep inlib/data/. Do not reach for a workspace to get this boundary — a directory + lint is enough.scripts/check-drift-confinement.sh
使用一个 ,每个 feature 使用一个 DAO,并在其上构建 repository。使用 FFI backend,绝不使用 。
@DriftDatabase@DriftAccessorNativeDatabasesqflitetext
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多 package(workspace)时: 同一边界会成为一个 package(例如),它是唯一依赖packages/data/drift的 pubspec,并由 banned-import gate 强制执行。对于单 package app,边界是sqlite3目录以及lib/data/中的 grep 检查。不要为了实现此边界而引入 workspace;目录加 lint 就足够了。scripts/check-drift-confinement.sh
Schema: invariants in the table
Schema:将不变量放入 table
dart
// lib/data/db/tables/orders.dart — the ONLY layer importing package:drift.
import 'package:drift/drift.dart';
('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
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)",
];
bool get isStrict => true; // no silent type coercion
}AuditColumnsmixin on TablecreatedAtupdatedAtrowRevisionisDeleteddeletedAtupdatedAtrowRevisionis_deleted = 0references/schema-and-daos.mddart
// lib/data/db/tables/orders.dart — the ONLY layer importing package:drift.
import 'package:drift/drift.dart';
('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
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)",
];
bool get isStrict => true; // no silent type coercion
}AuditColumnsmixin on TablecreatedAtupdatedAtrowRevisionisDeleteddeletedAtupdatedAtrowRevisionis_deleted = 0references/schema-and-daos.mdThe connection: pragmas per open
Connection:每次打开都设置 pragma
dart
// 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;');
},
);
});In , re-assert and seed only under . During a migration, toggle outside any transaction (it is a no-op inside one) and run afterwards.
MigrationStrategy.beforeOpenforeign_keys = ONif (details.wasCreated)foreign_keysPRAGMA foreign_key_checkdart
// 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;');
},
);
});在 中,重新声明 ,并且仅在 条件下执行 seed。迁移期间,必须在 transaction 外切换 (在 transaction 内设置为静默无操作),并在之后运行 。
MigrationStrategy.beforeOpenforeign_keys = ONif (details.wasCreated)foreign_keysPRAGMA foreign_key_checkDAO + repository: one transaction, mapped to value objects
DAO + repository:一个 transaction,映射为值对象
A DAO holds single-table queries for one table ( is a plain-Dart
VO of UTC-ms bounds — never a Flutter in the data layer):
windowTimeWindowDateTimeRangedart
// lib/data/daos/orders_dao.dart — the only door to SQL for the Orders table.
(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());
}The repository owns the cross-table transaction (a DAO scoped to
cannot reference — that is rule 7) and maps rows to value objects:
[Orders]lineItemsdart
// 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.
}
}The repository maps → the immutable value object (integer minor units → a value object, serial → a calendar-day type) so no feature ever sees a canonical or a Drift class. Keyset pagination: .
OrderRowOrderMoneyplacedOnDayint..where((o) => o.placedAtUtcMs.isSmallerThanValue(cursor))..limit(n)DAO 持有一个 table 的 single-table query( 是由 UTC-ms 边界组成的普通 Dart VO——数据层绝不能使用 Flutter ):
windowTimeWindowDateTimeRangedart
// lib/data/daos/orders_dao.dart — the only door to SQL for the Orders table.
(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());
}repository 负责跨表 transaction(限定为 的 DAO 无法引用 ——这就是规则 7),并将数据行映射为值对象:
[Orders]lineItemsdart
// 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.
}
}repository 将 → 不可变 值对象(整数最小单位 → 值对象, serial → calendar-day type)进行映射,因此任何 feature 都不会看到规范 或 Drift class。Keyset pagination:。
OrderRowOrderMoneyplacedOnDayint..where((o) => o.placedAtUtcMs.isSmallerThanValue(cursor))..limit(n)Riverpod wiring (state + DI)
Riverpod wiring(状态 + DI)
The database and each repository are provided; scoped streams map onto stream providers; derived views are computed providers. Keep the core persistence-agnostic — a repository interface with a single write path and derive-don't-store — and let Riverpod be the injection and consumption mechanism.
.watch()dart
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));Never open the DB in a widget; never expose a DAO through a provider. See for Notifier/AsyncNotifier discipline over these repositories.
state-management-riverpod数据库和每个 repository 都通过 provider 提供;带作用域的 stream 映射为 stream provider;派生视图由 computed provider 计算。保持核心 persistence-agnostic——使用具有单一写入路径且采用 derive-don't-store 原则的 repository interface——并让 Riverpod 充当注入和消费机制。
.watch()dart
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 中打开 DB;绝不要通过 provider 暴露 DAO。有关这些 repository 上的 Notifier/AsyncNotifier 规范,请参见 。
state-management-riverpodAnti-patterns
反模式
- A symbol leaking past
package:drift, or a widget/Notifier issuing a raw query — the row shape is a data-layer secret; map to value objects at the boundary.lib/data/ - A non-table, or validating enums/ranges at every call site instead of
STRICTin the schema — a corrupt row must be impossible to write.CHECK - Assuming a pragma persists — /
foreign_keys/a cipher key are per-connection; set them insynchronous/setupon every open.beforeOpeninside a transaction is a silent no-op.PRAGMA foreign_keys - on a store of non-regenerable data — SQLite says WAL+NORMAL transactions may roll back after power loss.
synchronous = NORMAL - A instant stored as the local day — reintroduces the DST/timezone rollover bug. Local day = serial int; instants = UTC ms.
DateTime - A stored /
count/totaltreated as authority — recompute from source rows; a stored copy drifts.streak - Splitting a mutation across transactions, dropping an inside
await, or an optimistic pre-commit update — each is a data-loss footgun; commit first and let the watched stream re-emit.transaction(() async { - pagination, or an unscoped
OFFSET— both degrade and over-recompute; keyset + scoped streams..watch() - BLOB bytes in SQLite, or an absolute media path — files on disk with a path relative to a base dir; absolute paths die on reinstall/restore.
- of a live WAL DB for a backup — torn, unrestorable; checkpoint +
File.copy+ verify-by-reopen.VACUUM INTO - backend, or
sqflitereachable in release — useeraseDatabaseOnSchemaChangeFFI; that flag wipes real data.NativeDatabase
- 符号越过
package:drift,或 widget/Notifier 直接发起 raw query——row 结构是 data layer 的机密;必须在边界处映射为值对象。lib/data/ - table 未使用 ,或未在 schema 中使用
STRICT,而是在每个调用点验证 enum/范围——损坏的行必须无法写入。CHECK - 假设 pragma 会持久化——/
foreign_keys/cipher key 是 per-connection 的;每次打开时都必须设置。在 transaction 内执行synchronous会静默无操作。PRAGMA foreign_keys - 对存储不可再生成数据的 store 使用 ——SQLite 指出 WAL+NORMAL transaction 在断电后可能会回滚。
synchronous = NORMAL - 将 时间点存储为本地日——会重新引入 DST/timezone rollover bug。本地日 = serial int;时间点 = UTC ms。
DateTime - 将存储的 /
count/total视为事实来源——应从源行重新计算;存储副本会漂移。streak - 将 mutation 拆分到多个 transaction、遗漏 内的
transaction(() async {,或进行乐观的 pre-commit 更新——每一种都是数据流失陷阱;先 commit,再让被监听的 stream 重新发出。await - 使用 分页,或使用无作用域的
OFFSET——两者都会导致性能下降和过度重新计算;应使用 keyset + scoped stream。.watch() - 将 BLOB 字节存放在 SQLite 中,或使用绝对媒体路径——文件存放到磁盘,并使用相对于 base dir 的路径;绝对路径会在重新安装或恢复后失效。
- 通过 复制活动 WAL DB 作为备份——会得到撕裂且无法恢复的数据;应使用 checkpoint +
File.copy+ verify-by-reopen。VACUUM INTO - 使用 backend,或让
sqflite在 release 版本中可达——应使用eraseDatabaseOnSchemaChangeFFI;该 flag 会清除真实数据。NativeDatabase
Definition of done
完成标准
- /
package:driftappear only underpackage:sqlite3(grep green); DAOs return value objects; no Drift type crosses the boundary. Backend islib/data/(FFI), notNativeDatabase.sqflite - Every table is ; enums are
STRICT; ranges areCHECK (... IN (...))ed; relations are foreign keys with an explicitCHECK; uniqueness is a (partial)onDelete; hot queries have a matching index proven byUNIQUE INDEXin a test.EXPLAIN QUERY PLAN - ,
journal_mode = WAL,synchronous = FULL,foreign_keys = ONare set inbusy_timeout/setupon every open; seeding is only underbeforeOpen.if (details.wasCreated) - Each mutation is exactly one ; every query inside is awaited; the write
db.transactionresolves before any UI update, and the watched stream re-emits (persist-before-publish, never optimistic, no manual republish).Future - Canonical storage only: integer minor units + ISO-4217 code, SI ints, UTC epoch ms; the local day is a serial int, never a ; no formatted/localized values in any column.
DateTime - Derived state is recomputed on read (one fold next to the data), never stored as authority.
- Reads are owner/window-scoped streams mapped to value objects; pagination is keyset, never
.watch().OFFSET - Blob bytes are files on disk with a path relative to a base dir; no BLOB columns.
- Backups use +
wal_checkpoint(TRUNCATE)+ verify-by-reopen; noVACUUM INTOof a live DB.File.copy - Schema changes are paired with the ritual (bumped
run-migration, append-only step, committed snapshot, content test).schemaVersion
- /
package:drift仅出现在package:sqlite3下(grep 通过);DAO 返回值对象;任何 Drift type 都不得越过边界。Backend 为lib/data/(FFI),而不是NativeDatabase。sqflite - 每张表都是 ;enum 使用
STRICT;范围使用CHECK (... IN (...));关系使用带明确CHECK的 foreign key;唯一性使用(partial)onDelete;高频 query 有匹配的 index,并通过测试中的UNIQUE INDEX验证。EXPLAIN QUERY PLAN - 每次打开时都在 /
setup中设置beforeOpen、journal_mode = WAL、synchronous = FULL、foreign_keys = ON;仅在busy_timeout条件下执行 seeding。if (details.wasCreated) - 每次 mutation 恰好使用一个 ;其中每个 query 都经过 await;write 的
db.transaction在任何 UI 更新前 resolve,并由被监听的 stream 重新发出(persist-before-publish,绝不乐观更新,也不手动重新发布)。Future - 仅存储规范值:整数最小单位 + ISO-4217 code、SI int、UTC epoch ms;本地日是 serial int,绝不是 ;任何 column 中都不得存储格式化或本地化的值。
DateTime - 派生状态在读取时重新计算(数据旁只有一个 fold),绝不作为事实来源存储。
- 读取使用按 owner/window 限定作用域并映射为值对象的 stream;分页使用 keyset,绝不使用
.watch()。OFFSET - Blob 字节以文件形式存放到磁盘,并使用相对于 base dir 的路径;不得存在 BLOB column。
- 备份使用 +
wal_checkpoint(TRUNCATE)+ verify-by-reopen;不得通过VACUUM INTO复制活动 DB。File.copy - Schema 变更与 流程配套(提高
run-migration、仅追加 step、提交 snapshot、内容测试)。schemaVersion
Related skills
相关 skill
- — the forward-only
run-migrationmigration ritual, committed snapshots, and content tests for the tables this skill defines.stepByStep - — the
error-handling-typed-results/Resultspine repositories return, and the never-lose-data layer (transactions, drafts, soft-delete/Undo).Failure - — the
value-objects-money-and-units/quantity/Moneyvalue types a repository maps canonical columns into.Clock - — Notifier/AsyncNotifier ViewModels and providers-as-DI over these repositories.
state-management-riverpod - — the downward-only DAG and single-write-path layering this data layer sits at the bottom of.
flutter-architecture - /
codegen-and-toolchain— runningrun-codegenfor Drift's generated code.build_runner - — wiring the database/repository as injectable providers overridden at the composition root.
service-boundary-and-native - — the portable, user-facing end of this store: the versioned backup envelope, staging-then-swap restore, and CSV/PDF export.
data-export-and-restore
- — 仅向前的
run-migrationmigration 流程、已提交的 snapshot,以及本 skill 所定义 table 的内容测试。stepByStep - — repository 返回的
error-handling-typed-results/Result主干,以及绝不丢失数据的层(transaction、draft、soft-delete/Undo)。Failure - — repository 将规范 column 映射到的
value-objects-money-and-units/quantity/Moneyvalue type。Clock - — 基于这些 repository 的 Notifier/AsyncNotifier ViewModel 和作为 DI 的 provider。
state-management-riverpod - — 单向 DAG 和单一写入路径的分层,本 data layer 位于其底部。
flutter-architecture - /
codegen-and-toolchain— 运行run-codegen生成 Drift 代码。build_runner - — 将 database/repository 配置为可注入的 provider,并在 composition root 中覆盖。
service-boundary-and-native - — 此 store 的可移植、面向用户的最终形态:带版本的备份 envelope、先 staging 后 swap 的 restore,以及 CSV/PDF export。
data-export-and-restore
References
Reference
- Drift docs: https://drift.simonbinder.eu/
- Drift transactions: https://drift.simonbinder.eu/dart_api/transactions/
- Drift migrations: https://drift.simonbinder.eu/Migrations/
- SQLite STRICT tables: https://sqlite.org/stricttables.html
- SQLite WAL: https://sqlite.org/wal.html
- SQLite : https://sqlite.org/lang_vacuum.html#vacuuminto
VACUUM INTO - SQLite foreign keys (per-connection pragma): https://sqlite.org/foreignkeys.html
- : https://pub.dev/packages/path_provider
path_provider
- Drift 文档:https://drift.simonbinder.eu/
- Drift transaction:https://drift.simonbinder.eu/dart_api/transactions/
- Drift migration:https://drift.simonbinder.eu/Migrations/
- SQLite STRICT table:https://sqlite.org/stricttables.html
- SQLite WAL:https://sqlite.org/wal.html
- SQLite :https://sqlite.org/lang_vacuum.html#vacuuminto
VACUUM INTO - SQLite foreign key(per-connection pragma):https://sqlite.org/foreignkeys.html
- :https://pub.dev/packages/path_provider
path_provider