perf: harden download and persistence lifecycle

This commit is contained in:
zarzet
2026-08-28 12:51:03 +07:00
parent 51f61c9c95
commit 45d9bbd7b2
22 changed files with 1656 additions and 231 deletions
+110 -14
View File
@@ -8,7 +8,7 @@ import 'package:spotiflac_android/utils/logger.dart';
final _log = AppLogger('AppStateDb');
const _dbFileName = 'app_state.db';
const _dbVersion = 3;
const _dbVersion = 4;
const _queueTable = 'download_queue_items';
const _recentTable = 'recent_access_items';
@@ -87,12 +87,12 @@ class AppStateDatabase {
Future<void> _upgradeDb(Database db, int oldVersion, int newVersion) async {
_log.i('Upgrading app state database from v$oldVersion to v$newVersion');
if (oldVersion < 2) {
await _createPlaybackSessionTable(db);
}
if (oldVersion < 3) {
await _createRecentStateTable(db);
}
if (oldVersion < 4) {
await _migratePlaybackSessionToV4(db);
}
}
static Future<void> _createRecentStateTable(Database db) {
@@ -105,18 +105,88 @@ class AppStateDatabase {
}
static Future<void> _createPlaybackSessionTable(Database db) {
// Keep this idempotent so an interrupted migration or a database restored
// from an intermediate build can resume v1 -> v2 without losing queue
// state merely because the table was already created.
return db.execute('''
CREATE TABLE IF NOT EXISTS $_playbackSessionTable (
id INTEGER PRIMARY KEY CHECK (id = 1),
session_json TEXT NOT NULL,
media_json TEXT NOT NULL,
current_index INTEGER NOT NULL DEFAULT 0,
position_ms INTEGER NOT NULL DEFAULT 0,
shuffle INTEGER NOT NULL DEFAULT 0,
repeat_mode TEXT NOT NULL DEFAULT 'none',
updated_at TEXT NOT NULL
)
''');
}
static Future<void> _migratePlaybackSessionToV4(Database db) async {
final table = await db.rawQuery(
"SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?",
[_playbackSessionTable],
);
if (table.isEmpty) {
await _createPlaybackSessionTable(db);
return;
}
final columns = await db.rawQuery(
'PRAGMA table_info($_playbackSessionTable)',
);
if (columns.any((column) => column['name'] == 'media_json')) return;
Map<String, dynamic>? legacySession;
final rows = await db.query(_playbackSessionTable, limit: 1);
final raw = rows.isEmpty ? null : rows.first['session_json'] as String?;
if (raw != null && raw.isNotEmpty) {
try {
final decoded = jsonDecode(raw);
if (decoded is Map) {
legacySession = Map<String, dynamic>.from(decoded);
}
} catch (e) {
_log.w('Discarding unreadable legacy playback session: $e');
}
}
const migratedTable = '${_playbackSessionTable}_v4';
await db.execute('DROP TABLE IF EXISTS $migratedTable');
await db.execute('''
CREATE TABLE $migratedTable (
id INTEGER PRIMARY KEY CHECK (id = 1),
media_json TEXT NOT NULL,
current_index INTEGER NOT NULL DEFAULT 0,
position_ms INTEGER NOT NULL DEFAULT 0,
shuffle INTEGER NOT NULL DEFAULT 0,
repeat_mode TEXT NOT NULL DEFAULT 'none',
updated_at TEXT NOT NULL
)
''');
if (legacySession != null) {
await db.insert(migratedTable, _playbackSessionRow(legacySession));
}
await db.execute('DROP TABLE $_playbackSessionTable');
await db.execute(
'ALTER TABLE $migratedTable RENAME TO $_playbackSessionTable',
);
}
static Map<String, Object?> _playbackSessionRow(
Map<String, dynamic> session,
) {
final media = session['media'];
final currentIndex = session['index'];
final positionMs = session['positionMs'];
final repeatMode = session['repeat'];
return {
'id': 1,
'media_json': jsonEncode(media is List ? media : const []),
'current_index': currentIndex is num ? currentIndex.toInt() : 0,
'position_ms': positionMs is num ? positionMs.toInt() : 0,
'shuffle': session['shuffle'] == true ? 1 : 0,
'repeat_mode': repeatMode is String ? repeatMode : 'none',
'updated_at': DateTime.now().toIso8601String(),
};
}
Future<bool> migrateQueueFromSharedPreferences() async {
final prefs = await _prefs;
if (prefs.getBool(_queueMigrationKey) == true) {
@@ -288,11 +358,20 @@ class AppStateDatabase {
final db = await database;
final rows = await db.query(_playbackSessionTable, limit: 1);
if (rows.isEmpty) return null;
final raw = rows.first['session_json'] as String?;
final row = rows.first;
final raw = row['media_json'] as String?;
if (raw == null || raw.isEmpty) return null;
try {
final decoded = jsonDecode(raw);
if (decoded is Map) return Map<String, dynamic>.from(decoded);
if (decoded is! List) return null;
return {
'version': 2,
'media': decoded,
'index': (row['current_index'] as num?)?.toInt() ?? 0,
'positionMs': (row['position_ms'] as num?)?.toInt() ?? 0,
'shuffle': (row['shuffle'] as num?)?.toInt() == 1,
'repeat': row['repeat_mode'] as String? ?? 'none',
};
} catch (e) {
_log.w('Discarding unreadable playback session: $e');
}
@@ -301,11 +380,28 @@ class AppStateDatabase {
Future<void> savePlaybackSession(Map<String, dynamic> session) async {
final db = await database;
await db.insert(_playbackSessionTable, {
'id': 1,
'session_json': jsonEncode(session),
await db.insert(
_playbackSessionTable,
_playbackSessionRow(session),
conflictAlgorithm: ConflictAlgorithm.replace,
);
}
Future<bool> updatePlaybackSessionState({
required int index,
required int positionMs,
required bool shuffle,
required String repeatMode,
}) async {
final db = await database;
final changed = await db.update(_playbackSessionTable, {
'current_index': index,
'position_ms': positionMs,
'shuffle': shuffle ? 1 : 0,
'repeat_mode': repeatMode,
'updated_at': DateTime.now().toIso8601String(),
}, conflictAlgorithm: ConflictAlgorithm.replace);
}, where: 'id = 1');
return changed > 0;
}
Future<void> clearPlaybackSession() async {
+29 -4
View File
@@ -65,24 +65,39 @@ class HistoryBatchLookupRequest {
}
class HistoryDatabase {
// The FTS table is a derived, optional index and is initialized lazily after
// the existing schema migration. Keep this contract at v13 because the
// background native writer shares history.db and must accept the same
// user_version without depending on FTS5.
static const int schemaVersion = 13;
static const String searchFtsTable = 'history_search_fts';
static final HistoryDatabase instance = HistoryDatabase._init();
static final sqlite.SingleFlightInitializer<Database> _database =
sqlite.SingleFlightInitializer<Database>();
bool _searchFtsAvailable = false;
HistoryDatabase._init();
Future<Database> get database {
return _database.getOrCreate(
() => sqlite.openAppDatabase(
return _database.getOrCreate(() async {
final db = await sqlite.openAppDatabase(
'history.db',
version: schemaVersion,
onCreate: _createDB,
onUpgrade: _upgradeDB,
),
);
);
// onCreate normally initializes this derived index. Retry once after
// opening an existing database in case an earlier setup was
// interrupted; unsupported SQLite builds remain on the LIKE fallback.
if (!_searchFtsAvailable) {
_searchFtsAvailable = await _createSearchFts(db);
}
return db;
});
}
bool get searchFtsAvailable => _searchFtsAvailable;
Future<void> _createDB(Database db, int version) async {
_log.i('Creating database schema v$version');
@@ -151,6 +166,7 @@ class HistoryDatabase {
await _createNormalizedIndexes(db);
await _createQueueIndexes(db);
await _createPathKeyTable(db);
_searchFtsAvailable = await _createSearchFts(db);
_log.i('Database schema created with indexes');
}
@@ -258,6 +274,15 @@ class HistoryDatabase {
}
}
Future<bool> _createSearchFts(DatabaseExecutor db) {
return sqlite.createTrigramFtsIndex(
db,
ftsTable: searchFtsTable,
contentTable: 'history',
triggerPrefix: 'history_search_fts',
);
}
static String normalizeLookupText(String? value) =>
sqlite.normalizeLookupText(value);
+27 -4
View File
@@ -16,27 +16,40 @@ final _log = AppLogger('LibraryDatabase');
class LibraryDatabase {
static final LibraryDatabase instance = LibraryDatabase._init();
// The FTS table is a derived, optional index and is initialized lazily after
// the existing schema migration, so it does not require a user_version bump.
static const int schemaVersion = 13;
static const String legacySourceId = LocalLibraryItem.legacySourceId;
static const String visibleLibraryView = 'library_visible';
static const String searchFtsTable = 'library_search_fts';
static const int audioMetadataScanVersion = 3;
static final sqlite.SingleFlightInitializer<Database> _database =
sqlite.SingleFlightInitializer<Database>();
bool _historyAttached = false;
bool _searchFtsAvailable = false;
LibraryDatabase._init();
Future<Database> get database {
return _database.getOrCreate(
() => sqlite.openAppDatabase(
return _database.getOrCreate(() async {
final db = await sqlite.openAppDatabase(
'local_library.db',
version: schemaVersion,
onCreate: _createDB,
onUpgrade: _upgradeDB,
),
);
);
// onCreate normally initializes this derived index. Retry once after
// opening an existing database in case an earlier setup was
// interrupted; unsupported SQLite builds remain on the LIKE fallback.
if (!_searchFtsAvailable) {
_searchFtsAvailable = await _createSearchFts(db);
}
return db;
});
}
bool get searchFtsAvailable => _searchFtsAvailable;
Future<void> _ensureHistoryAttached(Database db) async {
if (_historyAttached) return;
await HistoryDatabase.instance.database;
@@ -114,6 +127,7 @@ class LibraryDatabase {
await _createQueueIndexes(db);
await _createPathKeyTable(db);
await _createLibrarySources(db);
_searchFtsAvailable = await _createSearchFts(db);
_log.i('Library database schema created with indexes');
}
@@ -223,6 +237,15 @@ class LibraryDatabase {
}
}
Future<bool> _createSearchFts(DatabaseExecutor db) {
return sqlite.createTrigramFtsIndex(
db,
ftsTable: searchFtsTable,
contentTable: 'library',
triggerPrefix: 'library_search_fts',
);
}
Future<void> _createLibrarySources(DatabaseExecutor db) async {
await db.execute('''
CREATE TABLE IF NOT EXISTS library_sources (
+30 -6
View File
@@ -365,9 +365,21 @@ extension _LibraryDbQueueSql on LibraryDatabase {
) {
final query = LibraryDatabase.normalizeLookupText(request.searchQuery);
if (query.isNotEmpty) {
final like = '%${_escapeLikePattern(query)}%';
where.add("h.search_text LIKE ? ESCAPE '\\'");
args.add(like);
final ftsQuery = sqlite.ftsPhraseSearchQuery(query);
if (HistoryDatabase.instance.searchFtsAvailable && ftsQuery != null) {
where.add('''
h.rowid IN (
SELECT rowid
FROM history_db.history_search_fts
WHERE history_search_fts MATCH ?
)
''');
args.add(ftsQuery);
} else {
final like = '%${_escapeLikePattern(query)}%';
where.add("h.search_text LIKE ? ESCAPE '\\'");
args.add(like);
}
}
_appendQueueCommonFilters(
where,
@@ -396,9 +408,21 @@ extension _LibraryDbQueueSql on LibraryDatabase {
) {
final query = LibraryDatabase.normalizeLookupText(request.searchQuery);
if (query.isNotEmpty) {
final like = '%${_escapeLikePattern(query)}%';
where.add("l.search_text LIKE ? ESCAPE '\\'");
args.add(like);
final ftsQuery = sqlite.ftsPhraseSearchQuery(query);
if (searchFtsAvailable && ftsQuery != null) {
where.add('''
l.rowid IN (
SELECT rowid
FROM library_search_fts
WHERE library_search_fts MATCH ?
)
''');
args.add(ftsQuery);
} else {
final like = '%${_escapeLikePattern(query)}%';
where.add("l.search_text LIKE ? ESCAPE '\\'");
args.add(like);
}
}
_appendQueueCommonFilters(
where,
+87 -14
View File
@@ -296,6 +296,9 @@ class MusicPlayerHandler extends BaseAudioHandler
DateTime? _lastPositionBroadcastAt;
DateTime? _lastPeriodicPersistAt;
Future<void> _sessionWriteTail = Future<void>.value();
int _sessionQueueRevision = 0;
int _scheduledSessionQueueRevision = -1;
int _persistedSessionQueueRevision = -1;
static const Duration _positionBroadcastInterval = Duration(
milliseconds: 500,
);
@@ -653,27 +656,77 @@ class MusicPlayerHandler extends BaseAudioHandler
return queued;
}
/// Persists queue, current index, and position so a killed process can
/// restore the session paused on next launch. Position updates are throttled
/// by [_handlePositionChanged], while lifecycle/pause writes flush exactly.
void _markSessionQueueChanged() {
_sessionQueueRevision++;
}
/// Persists the queue only when it changed. Periodic position updates write
/// fixed-size scalar columns, avoiding full queue JSON serialization every
/// ten seconds for large playback sessions.
Future<void> _persistSession({Duration? position}) {
if (_restoringSession) return Future<void>.value();
if (_media.isEmpty || _index < 0 || _index >= _media.length) {
_scheduledSessionQueueRevision = -1;
_persistedSessionQueueRevision = -1;
return _enqueueSessionWrite(
AppStateDatabase.instance.clearPlaybackSession,
);
}
final session = <String, dynamic>{
'version': 1,
'media': _media.map((m) => m.toJson()).toList(growable: false),
'index': _index,
'positionMs': (position ?? Duration.zero).inMilliseconds,
'shuffle': _shuffle,
'repeat': _repeatMode.name,
};
return _enqueueSessionWrite(
() => AppStateDatabase.instance.savePlaybackSession(session),
);
final queueRevision = _sessionQueueRevision;
final index = _index;
final positionMs = (position ?? Duration.zero).inMilliseconds;
final shuffle = _shuffle;
final repeatMode = _repeatMode.name;
if (_scheduledSessionQueueRevision != queueRevision) {
final media = _media.map((item) => item.toJson()).toList(growable: false);
_scheduledSessionQueueRevision = queueRevision;
return _enqueueSessionWrite(() async {
try {
await AppStateDatabase.instance.savePlaybackSession({
'version': 2,
'media': media,
'index': index,
'positionMs': positionMs,
'shuffle': shuffle,
'repeat': repeatMode,
});
_persistedSessionQueueRevision = queueRevision;
} catch (_) {
if (_scheduledSessionQueueRevision == queueRevision) {
_scheduledSessionQueueRevision = _persistedSessionQueueRevision;
}
rethrow;
}
});
}
return _enqueueSessionWrite(() async {
final updated = await AppStateDatabase.instance
.updatePlaybackSessionState(
index: index,
positionMs: positionMs,
shuffle: shuffle,
repeatMode: repeatMode,
);
if (updated) return;
// A newer queue snapshot is already (or is about to be) scheduled. Do
// not recreate a missing row from this older scalar snapshot with a
// mismatched index; the newer full write will restore it consistently.
if (_sessionQueueRevision != queueRevision) return;
// Defensive recovery for an externally cleared/corrupted row. This is
// intentionally the only state-only path that serializes the queue.
await AppStateDatabase.instance.savePlaybackSession({
'version': 2,
'media': _media.map((item) => item.toJson()).toList(growable: false),
'index': index,
'positionMs': positionMs,
'shuffle': shuffle,
'repeat': repeatMode,
});
_persistedSessionQueueRevision = queueRevision;
});
}
Future<Duration> _currentPositionForPersist() async {
@@ -706,6 +759,7 @@ class MusicPlayerHandler extends BaseAudioHandler
required int index,
required Duration position,
required bool shuffle,
bool queueNeedsRewrite = false,
AudioServiceRepeatMode repeatMode = AudioServiceRepeatMode.none,
}) async {
if (items.isEmpty) return;
@@ -725,6 +779,14 @@ class MusicPlayerHandler extends BaseAudioHandler
_pendingRestorePosition = position > Duration.zero ? position : null;
_sourceReady = false;
_lastPeriodicPersistAt = null;
_sessionQueueRevision++;
if (queueNeedsRewrite) {
_scheduledSessionQueueRevision = -1;
_persistedSessionQueueRevision = -1;
} else {
_scheduledSessionQueueRevision = _sessionQueueRevision;
_persistedSessionQueueRevision = _sessionQueueRevision;
}
queue.add(List<MediaItem>.unmodifiable(_queueItems));
mediaItem.add(_media[_index].toMediaItem());
if (position > Duration.zero) {
@@ -736,6 +798,9 @@ class MusicPlayerHandler extends BaseAudioHandler
} finally {
_restoringSession = false;
}
if (queueNeedsRewrite) {
await _persistSession(position: position);
}
}
bool _isCurrentPlayRequest(int generation, PlayableMedia media) {
@@ -758,6 +823,7 @@ class MusicPlayerHandler extends BaseAudioHandler
_queueItems
..clear()
..addAll(items.map((m) => m.toMediaItem()));
_markSessionQueueChanged();
_recent.clear();
_playHistory.clear();
queue.add(List<MediaItem>.unmodifiable(_queueItems));
@@ -774,6 +840,7 @@ class MusicPlayerHandler extends BaseAudioHandler
: _media.length;
_media.insert(insertAt, item);
_queueItems.insert(insertAt, item.toMediaItem());
_markSessionQueueChanged();
for (var i = 0; i < _recent.length; i++) {
if (_recent[i] >= insertAt) _recent[i]++;
@@ -808,6 +875,7 @@ class MusicPlayerHandler extends BaseAudioHandler
}
at++;
}
_markSessionQueueChanged();
queue.add(List<MediaItem>.unmodifiable(_queueItems));
_broadcastState();
unawaited(_persistSession(position: playbackState.value.position));
@@ -825,6 +893,7 @@ class MusicPlayerHandler extends BaseAudioHandler
final qi = _queueItems.removeAt(oldIndex);
_media.insert(newIndex, media);
_queueItems.insert(newIndex, qi);
_markSessionQueueChanged();
if (_index == oldIndex) {
_index = newIndex;
@@ -1127,6 +1196,8 @@ class MusicPlayerHandler extends BaseAudioHandler
_recent.clear();
_playHistory.clear();
_pendingRestorePosition = null;
_scheduledSessionQueueRevision = -1;
_persistedSessionQueueRevision = -1;
// An explicit stop ends the session for good; nothing to restore later.
await _enqueueSessionWrite(AppStateDatabase.instance.clearPlaybackSession);
// A stopped session has no current item; this also hides the mini player.
@@ -1236,6 +1307,7 @@ class MusicPlayerHandler extends BaseAudioHandler
_queueItems
..clear()
..addAll(kept.map((m) => m.toMediaItem()));
_markSessionQueueChanged();
_recent.clear();
_playHistory.clear();
queue.add(List<MediaItem>.unmodifiable(_queueItems));
@@ -1376,6 +1448,7 @@ Future<void> restorePersistedPlaybackSession() async {
index: index,
position: position,
shuffle: session['shuffle'] == true,
queueNeedsRewrite: items.length != rawMedia.length,
repeatMode: AudioServiceRepeatMode.values.firstWhere(
(mode) => mode.name == session['repeat'],
orElse: () => AudioServiceRepeatMode.none,
+26
View File
@@ -181,6 +181,9 @@ class PlatformBridge {
StreamController<ExtensionSessionGrantEvent>.broadcast();
static final StreamController<void> _libraryStorageEvents =
StreamController<void>.broadcast();
static final StreamController<List<String>>
_iosBackgroundDownloadExpirationEvents =
StreamController<List<String>>.broadcast();
static bool _backendEventHandlerInstalled = false;
static bool get supportsCoreBackend => Platform.isAndroid || Platform.isIOS;
@@ -198,6 +201,11 @@ class PlatformBridge {
return _libraryStorageEvents.stream;
}
static Stream<List<String>> iosBackgroundDownloadExpirationEvents() {
_ensureBackendEventHandler();
return _iosBackgroundDownloadExpirationEvents.stream;
}
static void _ensureBackendEventHandler() {
if (_backendEventHandlerInstalled) return;
_backendEventHandlerInstalled = true;
@@ -220,6 +228,24 @@ class PlatformBridge {
case 'libraryStorageChanged':
_libraryStorageEvents.add(null);
return null;
case 'iosBackgroundDownloadExpired':
final raw = call.arguments;
var itemIds = const <String>[];
try {
final decoded = raw is String ? jsonDecode(raw) : raw;
if (decoded is List) {
itemIds = decoded
.map((value) => value.toString().trim())
.where((value) => value.isNotEmpty)
.toSet()
.toList(growable: false);
}
} catch (_) {
// Older/native-mismatched builds may send no payload. The queue
// still pauses its currently visible active items below.
}
_iosBackgroundDownloadExpirationEvents.add(itemIds);
return null;
default:
return null;
}
+100
View File
@@ -65,6 +65,11 @@ Future<Database> openAppDatabase(
if (foreignKeys) {
await db.execute('PRAGMA foreign_keys = ON');
}
// History/library use INSERT OR REPLACE extensively. SQLite only fires
// delete triggers for REPLACE when recursive_triggers is enabled; the
// FTS external-content delete trigger needs that event to remove the old
// rowid instead of accumulating unreachable index entries.
await db.execute('PRAGMA recursive_triggers = ON');
if (incrementalAutoVacuum) {
final tables = await db.rawQuery('''
SELECT 1
@@ -93,6 +98,101 @@ String normalizeLookupText(String? value) {
return (value ?? '').trim().toLowerCase();
}
/// Returns a literal phrase suitable for the trigram FTS5 MATCH operator.
///
/// The trigram tokenizer cannot answer one- or two-character searches, so
/// callers should use their compatibility fallback when this returns null.
/// Quoting and escaping the value keeps user-entered FTS operators literal.
String? ftsPhraseSearchQuery(String value) {
if (value.runes.length < 3 || value.contains('\u0000')) return null;
return '"${value.replaceAll('"', '""')}"';
}
/// Creates an external-content FTS5 index that preserves substring search
/// semantics through SQLite's trigram tokenizer.
///
/// FTS5 is an optional SQLite extension on some platform/database builds, so
/// callers must retain their existing query fallback when this returns false.
/// The index is external-content: the source table remains authoritative and
/// these triggers keep the index synchronized for every insert/update/delete,
/// including writes that happen outside the Dart repository methods.
Future<bool> createTrigramFtsIndex(
DatabaseExecutor db, {
required String ftsTable,
required String contentTable,
required String triggerPrefix,
}) async {
try {
final existingIndex = await db.rawQuery(
"SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ? LIMIT 1",
[ftsTable],
);
final existingTriggers = await db.rawQuery(
"SELECT name FROM sqlite_master WHERE type = 'trigger' AND name IN (?, ?, ?)",
['${triggerPrefix}_ai', '${triggerPrefix}_ad', '${triggerPrefix}_au'],
);
final needsRebuild = existingIndex.isEmpty || existingTriggers.length != 3;
await db.execute('''
CREATE VIRTUAL TABLE IF NOT EXISTS $ftsTable USING fts5(
search_text,
content='$contentTable',
content_rowid='rowid',
tokenize='trigram'
)
''');
await db.execute('''
CREATE TRIGGER IF NOT EXISTS ${triggerPrefix}_ai
AFTER INSERT ON $contentTable
BEGIN
INSERT INTO $ftsTable(rowid, search_text)
VALUES (new.rowid, COALESCE(new.search_text, ''));
END
''');
await db.execute('''
CREATE TRIGGER IF NOT EXISTS ${triggerPrefix}_ad
AFTER DELETE ON $contentTable
BEGIN
INSERT INTO $ftsTable($ftsTable, rowid, search_text)
VALUES ('delete', old.rowid, COALESCE(old.search_text, ''));
END
''');
await db.execute('''
CREATE TRIGGER IF NOT EXISTS ${triggerPrefix}_au
AFTER UPDATE OF search_text ON $contentTable
BEGIN
INSERT INTO $ftsTable($ftsTable, rowid, search_text)
VALUES ('delete', old.rowid, COALESCE(old.search_text, ''));
INSERT INTO $ftsTable(rowid, search_text)
VALUES (new.rowid, COALESCE(new.search_text, ''));
END
''');
// Rebuild a new or partially-created index. Avoid doing this on every app
// start: the external-content table is already kept current by triggers.
if (needsRebuild) {
await db.rawInsert("INSERT INTO $ftsTable($ftsTable) VALUES (?)", [
'rebuild',
]);
}
return true;
} catch (error) {
_log.w(
'FTS5 index unavailable for $contentTable; using LIKE fallback: $error',
);
// Do not leave a half-created index/triggers behind. This makes a later
// retry deterministic and never compromises the authoritative table.
try {
await db.execute('DROP TRIGGER IF EXISTS ${triggerPrefix}_ai');
await db.execute('DROP TRIGGER IF EXISTS ${triggerPrefix}_ad');
await db.execute('DROP TRIGGER IF EXISTS ${triggerPrefix}_au');
await db.execute('DROP TABLE IF EXISTS $ftsTable');
} catch (cleanupError) {
_log.w('Failed to clean up partial FTS5 index $ftsTable: $cleanupError');
}
return false;
}
}
Future<void> addColumnIfMissing(
Database db,
String table,