// Version: 1.2.0 | Created: 2026-04-01 | Updated: 2026-04-02 // Drift database — rooms and messages tables. // Uses driftDatabase() from drift_flutter for cross-platform support: // - Mobile/desktop: SQLite file via sqlite3_flutter_libs // - Web: OPFS (Origin Private File System) via IndexedDB fallback import 'package:drift/drift.dart'; import 'package:drift_flutter/drift_flutter.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; part 'database.g.dart'; // --------------------------------------------------------------------------- // Table definitions // --------------------------------------------------------------------------- /// Persisted room state. Upserted on every sync. class RoomsTable extends Table { @override String get tableName => 'rooms'; TextColumn get id => text()(); TextColumn get name => text()(); TextColumn get avatarUrl => text().nullable()(); TextColumn get lastMessage => text().nullable()(); IntColumn get lastActivityAt => integer().nullable()(); // milliseconds since epoch IntColumn get unreadCount => integer().withDefault(const Constant(0))(); BoolColumn get isDm => boolean().withDefault(const Constant(false))(); @override Set> get primaryKey => {id}; } /// Persisted message events. Upserted when received from sync. @TableIndex(name: 'idx_messages_room_id', columns: {#roomId}) class MessagesTable extends Table { @override String get tableName => 'messages'; TextColumn get id => text()(); // eventId TextColumn get roomId => text()(); TextColumn get senderId => text()(); TextColumn get body => text().nullable()(); TextColumn get type => text()(); // MessageType name IntColumn get timestamp => integer()(); // milliseconds since epoch @override Set> get primaryKey => {id}; } // --------------------------------------------------------------------------- // Database class // --------------------------------------------------------------------------- @DriftDatabase(tables: [RoomsTable, MessagesTable]) class AppDatabase extends _$AppDatabase { AppDatabase() : super(driftDatabase(name: 'm8chat')); // Separate constructor for testing — accepts a custom executor. AppDatabase.forTesting(super.executor); @override int get schemaVersion => 2; @override MigrationStrategy get migration => MigrationStrategy( onUpgrade: (migrator, from, to) async { if (from < 2) { // v2: drop rawJson column, add index on roomId. // Drift cannot drop columns — recreate the table. await customStatement( 'CREATE TABLE IF NOT EXISTS messages_new (' ' id TEXT NOT NULL PRIMARY KEY, ' ' room_id TEXT NOT NULL, ' ' sender_id TEXT NOT NULL, ' ' body TEXT, ' ' type TEXT NOT NULL, ' ' timestamp INTEGER NOT NULL' ')', ); await customStatement( 'INSERT OR IGNORE INTO messages_new ' '(id, room_id, sender_id, body, type, timestamp) ' 'SELECT id, room_id, sender_id, body, type, timestamp FROM messages', ); await customStatement('DROP TABLE IF EXISTS messages'); await customStatement('ALTER TABLE messages_new RENAME TO messages'); await customStatement( 'CREATE INDEX IF NOT EXISTS idx_messages_room_id ' 'ON messages (room_id)', ); } }, onCreate: (migrator) async { await migrator.createAll(); }, ); } // --------------------------------------------------------------------------- // DAOs // --------------------------------------------------------------------------- /// Data access for rooms. extension RoomsDao on AppDatabase { /// Insert or replace a room row. Future upsertRoom(RoomsTableCompanion room) => into(roomsTable).insertOnConflictUpdate(room); /// Watch all rooms ordered by unread count desc then last activity desc. Stream> watchAllRooms() { return (select(roomsTable)..orderBy([ (t) => OrderingTerm(expression: t.unreadCount, mode: OrderingMode.desc), (t) => OrderingTerm( expression: t.lastActivityAt, mode: OrderingMode.desc, nulls: NullsOrder.last, ), ])) .watch(); } /// Get a single room by id, or null if not found. Future getRoomById(String id) => (select(roomsTable)..where((t) => t.id.equals(id))).getSingleOrNull(); } /// Data access for messages. extension MessagesDao on AppDatabase { /// Insert or replace a message row. Future upsertMessage(MessagesTableCompanion message) => into(messagesTable).insertOnConflictUpdate(message); /// Insert or replace multiple message rows in a single batch transaction. Future upsertMessages(List messages) async { await batch((b) { for (final msg in messages) { b.insert(messagesTable, msg, onConflict: DoUpdate((_) => msg)); } }); } /// Watch all messages for [roomId] ordered oldest-first. Stream> watchByRoom(String roomId) { return (select(messagesTable) ..where((t) => t.roomId.equals(roomId)) ..orderBy([ (t) => OrderingTerm(expression: t.timestamp, mode: OrderingMode.asc), ])) .watch(); } /// Load one page of messages for [roomId], most recent first. Future> getPage( String roomId, { int limit = 50, int offset = 0, }) { return (select(messagesTable) ..where((t) => t.roomId.equals(roomId)) ..orderBy([ (t) => OrderingTerm(expression: t.timestamp, mode: OrderingMode.desc), ]) ..limit(limit, offset: offset)) .get(); } } // --------------------------------------------------------------------------- // Riverpod provider // --------------------------------------------------------------------------- /// Provides the singleton [AppDatabase]. keepAlive: true — opened once for /// the app lifetime. @Riverpod(keepAlive: true) AppDatabase appDatabase(Ref ref) { final db = AppDatabase(); // Close the database when the provider is finally disposed (app shutdown). ref.onDispose(db.close); return db; }