Loading...
Loading...
Governs the on-device Drift/SQLite data layer: package:drift and package:sqlite3 confined to lib/data/ behind DAOs that map rows to immutable value objects (no Drift symbol leaks past the repository), invariants pushed into the schema (STRICT tables, CHECK/FK/partial-UNIQUE indexes), foreign_keys/WAL pragmas re-asserted idempotently in beforeOpen, one db.transaction per mutation, persist-before-publish, every query awaited, canonical integer storage, derived state recomputed-on-read never stored, scoped .watch streams, keyset (seek) pagination not OFFSET, and WAL-safe backups (checkpoint + VACUUM INTO, verify-by-reopen, never File.copy a live WAL DB). Use when defining or altering a Drift Table, Companion, DAO, index, or CHECK; writing a repository transaction or scoped watch provider; wiring the connection/beforeOpen pragmas; adding SQLCipher; building or verifying a backup; or reviewing a data-layer diff. Migrations and their tests live in run-migration.
npx skill4agent add zakariaf/flutter-skills persistence-driftlib/data/references/schema-and-daos.mdEXPLAIN QUERY PLANreferences/backup-and-wal.mdreferences/persistence-without-drift.mdscripts/check-drift-confinement.shscripts/check-persistence-bans.shrun-migrationlib/data/package:driftpackage:sqlite3TableCompanionTableInfoSTRICTCHECK (col IN (...))CHECK (qty BETWEEN 0 AND n)CHECK (amount_minor >= 0)onDeleteUNIQUE INDEXforeign_keyssynchronousbeforeOpensetupjournal_mode = WALforeign_keyssynchronousjournal_mode = WALPRAGMA foreign_keys = ONjournal_mode = WALsynchronous = FULLif (details.wasCreated)db.transactionawaittransaction(() async {Future.watch()state = …state-management-riverpodREALdoubleDateTimewatch…Result<T, Failure>.watch()OFFSETWHERE ts < :cursor ORDER BY ts DESC LIMIT nOFFSETwal_checkpoint(TRUNCATE)VACUUM INTOFile.copy-wal-shmreferences/backup-and-wal.mdschemaVersionstepBySteprun-migration@DriftDatabase@DriftAccessorNativeDatabasesqflitelib/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
// 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.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.beforeOpenforeign_keys = ONif (details.wasCreated)foreign_keysPRAGMA foreign_key_checkwindowTimeWindowDateTimeRange// 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());
}[Orders]lineItems// 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.
}
}OrderRowOrderMoneyplacedOnDayint..where((o) => o.placedAtUtcMs.isSmallerThanValue(cursor))..limit(n).watch()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));state-management-riverpodpackage:driftlib/data/STRICTCHECKforeign_keyssynchronoussetupbeforeOpenPRAGMA foreign_keyssynchronous = NORMALDateTimecounttotalstreakawaittransaction(() async {OFFSET.watch()File.copyVACUUM INTOsqfliteeraseDatabaseOnSchemaChangeNativeDatabasepackage:driftpackage:sqlite3lib/data/NativeDatabasesqfliteSTRICTCHECK (... IN (...))CHECKonDeleteUNIQUE INDEXEXPLAIN QUERY PLANjournal_mode = WALsynchronous = FULLforeign_keys = ONbusy_timeoutsetupbeforeOpenif (details.wasCreated)db.transactionFutureDateTime.watch()OFFSETwal_checkpoint(TRUNCATE)VACUUM INTOFile.copyrun-migrationschemaVersionrun-migrationstepBySteperror-handling-typed-resultsResultFailurevalue-objects-money-and-unitsMoneyClockstate-management-riverpodflutter-architecturecodegen-and-toolchainrun-codegenbuild_runnerservice-boundary-and-nativedata-export-and-restoreVACUUM INTOpath_provider