From 8ca9205cc4004c4c62dc7c6ffebb0915dd09c9fa Mon Sep 17 00:00:00 2001 From: zarzet <42882290+zarzet@users.noreply.github.com> Date: Wed, 7 Oct 2026 04:19:14 +0700 Subject: [PATCH] fix(library): stage native scans before owner SQLite publication --- .../native_library_staging_test.dart | 451 ++++++++++++ lib/services/library_database.dart | 76 +- lib/services/library_native_adapter.dart | 435 +++++++++++ .../crates/mobile/src/native_library.rs | 18 +- .../crates/mobile/src/native_library/tests.rs | 70 ++ test/library_native_adapter_test.dart | 684 ++++++++++++++++++ test/support/library_native_fixture.dart | 103 +++ 7 files changed, 1784 insertions(+), 53 deletions(-) create mode 100644 integration_test/native_library_staging_test.dart create mode 100644 lib/services/library_native_adapter.dart create mode 100644 test/library_native_adapter_test.dart create mode 100644 test/support/library_native_fixture.dart diff --git a/integration_test/native_library_staging_test.dart b/integration_test/native_library_staging_test.dart new file mode 100644 index 00000000..620d7127 --- /dev/null +++ b/integration_test/native_library_staging_test.dart @@ -0,0 +1,451 @@ +import 'dart:convert'; +import 'dart:io'; +import 'dart:typed_data'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; +import 'package:sqflite/sqflite.dart'; +import 'package:spotiflac_android/services/library_native_adapter.dart'; +import 'package:spotiflac_android/services/library_row_mapper.dart'; +import 'package:spotiflac_android/services/platform_bridge.dart'; + +import '../test/support/library_native_fixture.dart'; + +Future _wav(String path) async { + final bytes = Uint8List(44 + 320); + final data = ByteData.sublistView(bytes); + void text(int offset, String value) => + bytes.setRange(offset, offset + value.length, value.codeUnits); + text(0, 'RIFF'); + data.setUint32(4, bytes.length - 8, Endian.little); + text(8, 'WAVE'); + text(12, 'fmt '); + data.setUint32(16, 16, Endian.little); + data.setUint16(20, 1, Endian.little); + data.setUint16(22, 1, Endian.little); + data.setUint32(24, 8000, Endian.little); + data.setUint32(28, 16000, Endian.little); + data.setUint16(32, 2, Endian.little); + data.setUint16(34, 16, Endian.little); + text(36, 'data'); + data.setUint32(40, bytes.length - 44, Endian.little); + return File(path).writeAsBytes(bytes, flush: true); +} + +void main() { + final binding = IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + late Directory root; + late Database database; + late LibraryNativeAdapter adapter; + var initialized = false; + final privatePaths = []; + Future Function(Map, Map)? afterStage; + setUp(() async { + root = await Directory.systemTemp.createTemp('native-library-private-'); + database = await openDatabase( + '${root.path}/live.db', + onConfigure: (db) async { + await db.rawQuery('PRAGMA journal_mode=WAL'); + await db.execute('PRAGMA recursive_triggers=ON'); + }, + ); + await createNativeLibraryFixture( + database, + historyPath: '${root.path}/history.db', + ); + privatePaths.clear(); + afterStage = null; + adapter = LibraryNativeAdapter( + database, + temporaryDirectory: root, + nativeJobRunner: (request, {requestId}) async { + final private = request['library_path'] as String; + expect(private, isNot(database.path)); + final attachments = await database.rawQuery('PRAGMA database_list'); + expect( + attachments.where( + (row) => + (row['name'] as String).startsWith('library_private_') || + (row['name'] as String).startsWith('native_snapshot_'), + ), + isEmpty, + ); + expect(request['history_path'], isNot('${root.path}/history.db')); + privatePaths.add(private); + final result = await PlatformBridge.runNativeDataJob( + request, + requestId: requestId, + ); + await afterStage?.call(request, result); + return result; + }, + ); + await addNativeLibraryTrack(database, nativeLibraryTrack('old')); + await addNativeLibraryTrack( + database, + nativeLibraryTrack('other', path: '/other/other.flac'), + source: 'other', + ); + }); + tearDown(() async { + if (initialized) { + await PlatformBridge.cleanupExtensions(); + initialized = false; + } + await database.close(); + await root.delete(recursive: true); + }); + Future scan( + List> rows, { + int errors = 0, + }) async { + final file = File('${root.path}/scan.ndjson'); + await file.writeAsString(rows.map(jsonEncode).join('\n')); + return LibraryScanNDJSONFile( + file: file, + expectedCount: rows.length, + errorCount: errors, + ); + } + + Future cleanAndHealthy() async { + for (final path in privatePaths) { + expect(await File(path).parent.exists(), isFalse); + } + expect(await database.rawQuery('PRAGMA integrity_check'), [ + {'integrity_check': 'ok'}, + ]); + expect( + await database.rawQuery( + 'SELECT item_id FROM library_path_keys WHERE item_id NOT IN(SELECT id FROM library)', + ), + isEmpty, + ); + final lookups = await database.rawQuery( + 'SELECT source_id, kind, value, ref_count FROM library_lookup_summary ORDER BY source_id,kind,value', + ); + final expected = await database.rawQuery( + "SELECT source_id, 'match' AS kind, match_key AS value, COUNT(*) AS ref_count FROM library WHERE match_key IS NOT NULL AND match_key != '' GROUP BY source_id, match_key ORDER BY source_id,kind,value", + ); + expect(lookups, expected); + if ((await database.rawQuery( + "SELECT name FROM sqlite_master WHERE name='library_search_fts'", + )).isNotEmpty) { + expect( + await database.rawQuery( + "SELECT rowid FROM library_search_fts WHERE library_search_fts MATCH 'track' ORDER BY rowid", + ), + await database.rawQuery( + "SELECT rowid FROM library WHERE search_text LIKE '%track%' ORDER BY rowid", + ), + ); + } + } + + testWidgets( + 'production full scan repeatedly publishes exact rows, empty and partial states through owner SQLite', + (tester) async { + final timings = []; + for (final count in [2, 0, 3, 0]) { + final rows = [ + for (var i = 0; i < count; i++) nativeLibraryTrack('scan-$i'), + ]; + afterStage = count == 3 + ? (_, _) => addNativeLibraryDownload(database, '/music/scan-2.flac') + : null; + final input = await scan(rows); + final stopwatch = Stopwatch()..start(); + final result = await adapter.importScanFile( + 'source', + input, + requestId: 'full-$count-${timings.length}', + isCancelled: () => false, + ); + timings.add(stopwatch.elapsedMicroseconds); + final accepted = count == 3 ? rows.take(2).toList() : rows; + expect(result['committed'], isTrue); + expect(result['durable'], isTrue); + expect(result['inserted'], accepted.length); + expect(result['skipped'], count - accepted.length); + expect( + await database.query( + 'library', + where: 'source_id=?', + whereArgs: ['source'], + orderBy: 'id', + ), + accepted + .map((row) => LibraryRowMapper.encode(row, sourceId: 'source')) + .toList(), + ); + await cleanAndHealthy(); + } + await addNativeLibraryTrack(database, nativeLibraryTrack('unreadable')); + await adapter.importScanFile( + 'source', + await scan([nativeLibraryTrack('new')], errors: 1), + requestId: 'partial', + isCancelled: () => false, + ); + expect( + (await database.query( + 'library', + orderBy: 'id', + )).map((row) => row['id']), + ['new', 'other', 'unreadable'], + ); + await cleanAndHealthy(); + binding.reportData ??= {}; + binding.reportData!['library_private_full'] = { + 'samples_us': timings, + 'parity': true, + }; + }, + ); + + testWidgets( + 'source edit, late SQL failure and cancellation after native staging leave live rows unchanged', + (tester) async { + final before = await nativeLibraryDigest(database); + var cancelled = false; + afterStage = (_, _) async { + cancelled = true; + }; + await expectLater( + adapter.importScanFile( + 'source', + await scan([nativeLibraryTrack('new')]), + requestId: 'cancel-staged', + isCancelled: () => cancelled, + ), + throwsStateError, + ); + expect(await nativeLibraryDigest(database), before); + afterStage = (_, _) => database + .update( + 'library_sources', + {'enabled': 0}, + where: 'id=?', + whereArgs: ['source'], + ) + .then((_) {}); + await expectLater( + adapter.importScanFile( + 'source', + await scan([nativeLibraryTrack('new')]), + requestId: 'source-changed', + isCancelled: () => false, + ), + throwsStateError, + ); + expect(await nativeLibraryDigest(database), before); + await database.update( + 'library_sources', + {'enabled': 1}, + where: 'id=?', + whereArgs: ['source'], + ); + afterStage = null; + await database.execute( + "CREATE TRIGGER reject_new BEFORE INSERT ON library WHEN new.id='new' BEGIN SELECT RAISE(ABORT,'fixture late failure'); END", + ); + await expectLater( + adapter.importScanFile( + 'source', + await scan([nativeLibraryTrack('new')]), + requestId: 'owner-rollback', + isCancelled: () => false, + ), + throwsA(anything), + ); + expect(await nativeLibraryDigest(database), before); + await cleanAndHealthy(); + }, + ); + + testWidgets( + 'production timestamp export uses detached projection and conditionally backfills current owner rows', + (tester) async { + final zero = await _wav('${root.path}/zero.wav'); + final changed = await _wav('${root.path}/changed.wav'); + final legacy = await _wav('${root.path}/legacy.wav'); + for (final file in [zero, changed, legacy]) { + await addNativeLibraryTrack( + database, + nativeLibraryTrack( + file.uri.pathSegments.last, + path: file.path, + timestamp: 0, + version: file == legacy ? 3 : 4, + ), + ); + } + afterStage = (_, _) async { + await database.update( + 'library', + {'file_mod_time': 777, 'sort_added': 777}, + where: 'file_path=?', + whereArgs: [changed.path], + ); + await database.update( + 'library', + {'file_mod_time': 0}, + where: 'id=?', + whereArgs: ['old'], + ); + }; + final path = await adapter.writeFileModTimesSnapshot( + 'source', + requestId: 'snapshot', + isCancelled: () => false, + ); + final lines = await File(path).readAsLines(); + expect(lines, contains('-1\t${legacy.path}')); + expect(lines, contains('1770000000000\t/music/old.flac')); + final zeroTime = int.parse( + lines + .singleWhere((line) => line.endsWith('\t${zero.path}')) + .split('\t') + .first, + ); + expect(zeroTime, greaterThan(0)); + final liveRows = await database.query( + 'library', + columns: ['file_path', 'file_mod_time'], + ); + expect( + liveRows.singleWhere( + (row) => row['file_path'] == zero.path, + )['file_mod_time'], + zeroTime, + ); + expect( + liveRows.singleWhere( + (row) => row['file_path'] == changed.path, + )['file_mod_time'], + 777, + ); + expect( + liveRows.singleWhere( + (row) => row['file_path'] == legacy.path, + )['file_mod_time'], + 0, + ); + expect( + liveRows.singleWhere( + (row) => row['file_path'] == '/music/old.flac', + )['file_mod_time'], + 0, + ); + await cleanAndHealthy(); + await File(path).delete(); + }, + ); + + testWidgets( + 'production folder incremental scan retains removals and publishes current History filtering', + (tester) async { + final folder = await Directory('${root.path}/audio').create(); + final extensions = await Directory('${root.path}/extensions').create(); + final data = await Directory('${root.path}/extension-data').create(); + await PlatformBridge.initExtensionSystem( + extensions.path, + data.path, + masterKey: base64Encode(List.filled(32, 1)), + lyricsProviders: const [], + lyricsFetchOptions: const {}, + allowedDirectories: [folder.path], + ); + initialized = true; + final unchanged = await _wav('${folder.path}/unchanged.wav'); + final added = await _wav('${folder.path}/added.wav'); + final missing = '${folder.path}/missing.wav'; + await database.update( + 'library_sources', + {'path': folder.path}, + where: 'id=?', + whereArgs: ['source'], + ); + await database.delete( + 'library_path_keys', + where: 'item_id=?', + whereArgs: ['old'], + ); + await database.delete('library', where: 'id=?', whereArgs: ['old']); + await addNativeLibraryTrack( + database, + nativeLibraryTrack( + 'unchanged', + path: unchanged.path, + timestamp: (await unchanged.stat()).modified.millisecondsSinceEpoch, + ), + ); + await addNativeLibraryTrack( + database, + nativeLibraryTrack('missing', path: missing), + ); + final snapshot = await adapter.writeFileModTimesSnapshot( + 'source', + requestId: 'incremental-snapshot', + isCancelled: () => false, + ); + List>? staged; + afterStage = (request, _) async { + final private = await openDatabase( + request['library_path'] as String, + singleInstance: false, + ); + try { + staged = await private.query('library', orderBy: 'id'); + expect( + await private.query( + 'native_library_removed_paths', + columns: ['path'], + orderBy: 'ordinal', + ), + [ + {'path': missing}, + ], + ); + } finally { + await private.close(); + } + await addNativeLibraryDownload(database, unchanged.path); + }; + final watch = Stopwatch()..start(); + final result = await adapter.scanIncremental( + 'source', + folder.path, + snapshot, + isSaf: false, + requestId: 'incremental', + isCancelled: () => false, + ); + expect(result['committed'], isTrue); + expect(result['durable'], isTrue); + expect(result['inserted'], 1); + expect(result['scanned_count'], 1); + expect(result['skipped'], 1); + expect(result['deleted_count'], 1); + expect(result['skippedCount'], 1); + expect(result['totalFiles'], 2); + expect( + await database.query( + 'library', + where: 'source_id=?', + whereArgs: ['source'], + orderBy: 'id', + ), + staged, + ); + expect(staged!.single['file_path'], added.path); + await cleanAndHealthy(); + await File(snapshot).delete(); + binding.reportData ??= {}; + binding.reportData!['library_private_incremental'] = { + 'total_us': watch.elapsedMicroseconds, + 'parity': true, + }; + }, + ); +} diff --git a/lib/services/library_database.dart b/lib/services/library_database.dart index 16e5830a..37f4d8f2 100644 --- a/lib/services/library_database.dart +++ b/lib/services/library_database.dart @@ -11,6 +11,7 @@ import 'package:spotiflac_android/services/library_queue_store.dart'; import 'package:spotiflac_android/services/library_cleanup.dart'; import 'package:spotiflac_android/services/library_search.dart'; import 'package:spotiflac_android/services/library_database_models.dart'; +import 'package:spotiflac_android/services/library_native_adapter.dart'; import 'package:spotiflac_android/services/library_row_mapper.dart'; import 'package:spotiflac_android/services/library_schema.dart'; import 'package:spotiflac_android/services/platform_bridge.dart'; @@ -77,28 +78,6 @@ class LibraryDatabase { bool get searchFtsAvailable => _searchFtsAvailable ?? false; - Future> _nativeScanRequest(String sourceId) async { - final db = await database; - final history = await HistoryDatabase.instance.database; - final sources = await db.query( - 'library_sources', - columns: ['path', 'enabled'], - where: 'id = ?', - whereArgs: [sourceId], - limit: 1, - ); - if (sources.isEmpty || sources.single['enabled'] != 1) { - throw StateError('Library source changed or was removed during scan'); - } - return { - 'library_path': db.path, - 'history_path': history.path, - 'source_id': sourceId, - 'expected_source_path': sources.single['path'], - 'platform': Platform.isAndroid ? 'android' : 'ios', - }; - } - /// Keeps native scan output native through parsing, indexing and the atomic /// source swap. The stream implementation remains the desktop/test adapter. Future<({int inserted, int skipped})> replaceSourceScanFile( @@ -109,15 +88,14 @@ class LibraryDatabase { }) async { if (isCancelled()) throw StateError('Library scan cancelled'); if (PlatformBridge.supportsCoreBackend) { - final request = await _nativeScanRequest(sourceId); - if (isCancelled()) throw StateError('Library scan cancelled'); - final result = await PlatformBridge.runNativeDataJob({ - ...request, - 'operation': 'library_full_import', - 'ndjson_path': scan.file.path, - 'expected_count': scan.expectedCount, - 'error_count': scan.errorCount, - }, requestId: requestId); + final db = await database; + await _ensureHistoryAttached(db); + final result = await LibraryNativeAdapter(db).importScanFile( + sourceId, + scan, + requestId: requestId, + isCancelled: isCancelled, + ); return ( inserted: (result['inserted'] as num).toInt(), skipped: (result['skipped'] as num).toInt(), @@ -154,19 +132,11 @@ class LibraryDatabase { required String requestId, required bool Function() isCancelled, }) async { - final request = await _nativeScanRequest(sourceId); - if (isCancelled()) throw StateError('Library scan cancelled'); - final directory = await getTemporaryDirectory(); - if (isCancelled()) throw StateError('Library scan cancelled'); - final result = await PlatformBridge.runNativeDataJob({ - ...request, - 'operation': 'library_snapshot', - 'snapshot_path': join( - directory.path, - 'library_file_mod_times_${DateTime.now().microsecondsSinceEpoch}.tsv', - ), - }, requestId: requestId); - return result['path'] as String; + return LibraryNativeAdapter(await database).writeFileModTimesSnapshot( + sourceId, + requestId: requestId, + isCancelled: isCancelled, + ); } /// Native folder scanning and delta ingestion share one operation, so delta @@ -179,14 +149,16 @@ class LibraryDatabase { required String requestId, required bool Function() isCancelled, }) async { - final request = await _nativeScanRequest(sourceId); - if (isCancelled()) throw StateError('Library scan cancelled'); - return PlatformBridge.runNativeDataJob({ - ...request, - 'operation': 'library_scan_incremental', - if (isSaf) 'tree_uri': folderPath else 'folder_path': folderPath, - 'snapshot_path': snapshotPath, - }, requestId: requestId); + final db = await database; + await _ensureHistoryAttached(db); + return LibraryNativeAdapter(db).scanIncremental( + sourceId, + folderPath, + snapshotPath, + isSaf: isSaf, + requestId: requestId, + isCancelled: isCancelled, + ); } Future> searchLibrary({ diff --git a/lib/services/library_native_adapter.dart b/lib/services/library_native_adapter.dart new file mode 100644 index 00000000..f8c5e340 --- /dev/null +++ b/lib/services/library_native_adapter.dart @@ -0,0 +1,435 @@ +import 'dart:io'; + +import 'package:path/path.dart' as p; +import 'package:path_provider/path_provider.dart'; +import 'package:sqflite/sqflite.dart'; +import 'package:spotiflac_android/services/platform_bridge.dart'; +import 'package:spotiflac_android/services/sqlite_native_snapshot.dart'; +import 'package:spotiflac_android/utils/logger.dart'; + +typedef NativeLibraryJobRunner = + Future> Function( + Map request, { + String? requestId, + }); + +final _log = AppLogger('LibraryNativeAdapter'); +var _attachmentSequence = 0; + +/// Native SQLite only opens detached private files. Publication uses the live +/// database's owning connection, its current History keys and existing indexes. +class LibraryNativeAdapter { + LibraryNativeAdapter( + this.database, { + NativeLibraryJobRunner? nativeJobRunner, + this.temporaryDirectory, + String? platform, + }) : _run = nativeJobRunner ?? PlatformBridge.runNativeDataJob, + _platform = platform ?? (Platform.isAndroid ? 'android' : 'ios'); + + final Database database; + final Directory? temporaryDirectory; + final NativeLibraryJobRunner _run; + final String _platform; + final _retainedPaths = {}; + + static void _check(bool Function() isCancelled) { + if (isCancelled()) throw StateError('Library scan cancelled'); + } + + Future _sourcePath(DatabaseExecutor db, String sourceId) async { + final rows = await db.query( + 'library_sources', + columns: ['path', 'enabled'], + where: 'id = ?', + whereArgs: [sourceId], + limit: 1, + ); + if (rows.isEmpty || rows.single['enabled'] != 1) { + throw StateError('Library source changed or was removed during scan'); + } + return rows.single['path'] as String; + } + + Future _checkSource( + DatabaseExecutor db, + String sourceId, + String path, + ) async { + if (await _sourcePath(db, sourceId) != path) { + throw StateError('Library source changed or was removed during scan'); + } + } + + Future _attached( + String path, + Future Function(String alias) action, { + bool publication = false, + }) async { + final alias = 'library_private_${_attachmentSequence++}'; + await database.execute('ATTACH DATABASE ? AS $alias', [path]); + var completed = false; + try { + final result = await action(alias); + completed = true; + return result; + } finally { + // A successful live commit must never be reported as a cancelled or + // failed scan merely because temporary-file cleanup failed afterward. + try { + await database.execute('DETACH DATABASE $alias'); + } catch (error) { + _retainedPaths.add(path); + _log.w('Could not detach private Library database: $error'); + if (!publication || !completed) { + throw NativeSqliteSnapshotDetachException(path, error); + } + } + } + } + + Future _workingDirectory() async => + (temporaryDirectory ?? await getTemporaryDirectory()).createTemp( + 'library-native-', + ); + + Future _cleanup(Directory directory) async { + if (_retainedPaths.any((path) => p.isWithin(directory.path, path))) return; + // Also covers DETACH errors from the shared schema-cloning helper. + try { + final attached = await database.rawQuery('PRAGMA database_list'); + if (attached.any((row) => p.isWithin(directory.path, '${row['file']}'))) { + _log.w('Retaining private Library file that is still attached'); + return; + } + } catch (error) { + _log.w('Could not verify private Library detachment: $error'); + return; + } + try { + await directory.delete(recursive: true); + } catch (error) { + _log.w('Could not remove private Library staging: $error'); + } + } + + Map _request( + String sourceId, + String sourcePath, + Directory directory, + ) => { + 'library_path': p.join(directory.path, 'local_library.db'), + 'history_path': p.join(directory.path, 'history.db'), + 'source_id': sourceId, + 'expected_source_path': sourcePath, + 'platform': _platform, + }; + + Future _prepareImport( + String sourceId, + String sourcePath, + Directory directory, + bool Function() isCancelled, + ) async { + await createNativeSqliteSnapshot( + database, + p.join(directory.path, 'local_library.db'), + tables: ['library', 'library_path_keys', 'library_sources'], + version: 17, + copyRows: false, + ); + _check(isCancelled); + await _attached(p.join(directory.path, 'local_library.db'), (alias) async { + await database.transaction((txn) async { + await _checkSource(txn, sourceId, sourcePath); + _check(isCancelled); + await txn.execute( + 'INSERT INTO $alias.library_sources SELECT * FROM main.library_sources ' + 'WHERE id = ?', + [sourceId], + ); + }); + }); + // Retain every candidate during staging. Downloads can change while native + // work runs; the authoritative anti-join belongs to live publication. + await _attached(p.join(directory.path, 'history.db'), (alias) async { + await database.execute( + 'CREATE TABLE $alias.history_path_keys(' + 'item_id TEXT NOT NULL, path_key TEXT NOT NULL, ' + 'PRIMARY KEY(item_id, path_key))', + ); + await database.execute('PRAGMA $alias.user_version = 15'); + }); + } + + Future> importScanFile( + String sourceId, + LibraryScanNDJSONFile scan, { + required String requestId, + required bool Function() isCancelled, + }) => _import( + sourceId, + { + 'operation': 'library_full_import', + 'ndjson_path': scan.file.path, + 'expected_count': scan.expectedCount, + 'error_count': scan.errorCount, + }, + incremental: false, + preserveMissing: scan.errorCount > 0, + requestId: requestId, + isCancelled: isCancelled, + ); + + Future> scanIncremental( + String sourceId, + String folderPath, + String snapshotPath, { + required bool isSaf, + required String requestId, + required bool Function() isCancelled, + }) => _import( + sourceId, + { + 'operation': 'library_scan_incremental', + if (isSaf) 'tree_uri': folderPath else 'folder_path': folderPath, + 'snapshot_path': snapshotPath, + 'retain_removed_paths': true, + }, + incremental: true, + preserveMissing: true, + requestId: requestId, + isCancelled: isCancelled, + ); + + Future> _import( + String sourceId, + Map operation, { + required bool incremental, + required bool preserveMissing, + required String requestId, + required bool Function() isCancelled, + }) async { + _check(isCancelled); + final sourcePath = await _sourcePath(database, sourceId); + final directory = await _workingDirectory(); + try { + await _prepareImport(sourceId, sourcePath, directory, isCancelled); + _check(isCancelled); + final result = await _run({ + ..._request(sourceId, sourcePath, directory), + ...operation, + }, requestId: requestId); + if (result['committed'] != true) { + throw StateError('Native Library staging did not complete'); + } + // Native committed:true only describes the private artifact. Cancellation + // still prevents publication until the owner's transaction commits. + _check(isCancelled); + return await _publish( + sourceId, + sourcePath, + p.join(directory.path, 'local_library.db'), + result, + incremental: incremental, + preserveMissing: preserveMissing, + isCancelled: isCancelled, + ); + } on NativeSqliteSnapshotDetachException catch (error) { + _retainedPaths.add(error.path); + rethrow; + } finally { + await _cleanup(directory); + } + } + + Future> _publish( + String sourceId, + String sourcePath, + String privatePath, + Map nativeResult, { + required bool incremental, + required bool preserveMissing, + required bool Function() isCancelled, + }) => _attached(privatePath, (alias) async { + return database.transaction((txn) async { + _check(isCancelled); + await _checkSource(txn, sourceId, sourcePath); + final columns = (await txn.rawQuery( + 'PRAGMA main.table_info(library)', + )).map((column) => '"${column['name']}"').join(', '); + final historyMatch = + 'EXISTS(SELECT 1 FROM $alias.library_path_keys sk ' + 'JOIN history_db.history_path_keys hk ON hk.path_key = sk.path_key ' + 'WHERE sk.item_id = s.id)'; + Future count(String sql, [List? args]) async => + ((await txn.rawQuery(sql, args)).single.values.single as num).toInt(); + final staged = await count('SELECT COUNT(*) FROM $alias.library'); + final skipped = await count( + 'SELECT COUNT(*) FROM $alias.library s WHERE $historyMatch', + ); + var excludedExisting = 0; + var deleted = 0; + final selectedIds = '${alias}_selected'; + await txn.execute('CREATE TEMP TABLE $selectedIds(id TEXT PRIMARY KEY)'); + Future deleteWhere(String where, List args) async { + // Some predicates read the path-key table being deleted. Freeze the + // selected IDs first so both deletes apply to the same set. + await txn.execute('DELETE FROM $selectedIds'); + await txn.execute( + 'INSERT INTO $selectedIds SELECT id FROM main.library WHERE $where', + args, + ); + await txn.rawDelete( + 'DELETE FROM main.library_path_keys WHERE item_id IN(' + 'SELECT id FROM $selectedIds)', + ); + return txn.rawDelete( + 'DELETE FROM main.library WHERE id IN(SELECT id FROM $selectedIds)', + ); + } + + if (incremental) { + excludedExisting += await deleteWhere( + 'source_id = ? AND EXISTS(SELECT 1 FROM main.library_path_keys lk ' + 'JOIN history_db.history_path_keys hk ON hk.path_key = lk.path_key ' + 'WHERE lk.item_id = library.id)', + [sourceId], + ); + _check(isCancelled); + deleted = await deleteWhere( + 'source_id = ? AND file_path IN(' + 'SELECT path FROM $alias.native_library_removed_paths)', + [sourceId], + ); + excludedExisting += await deleteWhere( + 'source_id = ? AND file_path IN(SELECT s.file_path ' + 'FROM $alias.library s WHERE $historyMatch)', + [sourceId], + ); + } else { + await deleteWhere( + preserveMissing + ? 'source_id = ? AND (id IN(SELECT id FROM $alias.library) ' + 'OR file_path IN(SELECT file_path FROM $alias.library))' + : 'source_id = ?', + [sourceId], + ); + } + _check(isCancelled); + await txn.rawDelete( + 'DELETE FROM main.library_path_keys WHERE item_id IN(' + 'SELECT s.id FROM $alias.library s WHERE NOT $historyMatch)', + ); + await txn.execute( + 'INSERT OR REPLACE INTO main.library($columns) ' + 'SELECT $columns FROM $alias.library s WHERE NOT $historyMatch ' + 'ORDER BY s.rowid', + ); + await txn.execute( + 'INSERT OR IGNORE INTO main.library_path_keys ' + 'SELECT sk.item_id, sk.path_key FROM $alias.library_path_keys sk ' + 'JOIN $alias.library s ON s.id = sk.item_id WHERE NOT $historyMatch', + ); + await txn.execute('DROP TABLE $selectedIds'); + _check(isCancelled); + return { + ...nativeResult, + 'committed': true, + 'durable': true, + 'inserted': staged - skipped, + 'upserted': staged - skipped, + 'skipped': skipped + excludedExisting, + 'deleted_count': deleted, + }; + }, exclusive: true); + }, publication: true); + + Future writeFileModTimesSnapshot( + String sourceId, { + required String requestId, + required bool Function() isCancelled, + }) async { + _check(isCancelled); + final sourcePath = await _sourcePath(database, sourceId); + final directory = await _workingDirectory(); + final output = File('${directory.path}.tsv'); + var published = false; + try { + final privatePath = p.join(directory.path, 'local_library.db'); + await _attached(privatePath, (alias) async { + await database.transaction((txn) async { + await _checkSource(txn, sourceId, sourcePath); + _check(isCancelled); + await txn.execute( + 'CREATE TABLE $alias.library AS SELECT id, source_id, file_path, ' + 'file_mod_time, audio_metadata_scan_version, sort_added ' + 'FROM main.library WHERE source_id = ?', + [sourceId], + ); + await txn.execute( + 'CREATE TABLE $alias.library_sources AS SELECT id, path, enabled ' + 'FROM main.library_sources WHERE id = ?', + [sourceId], + ); + await txn.execute( + 'CREATE TABLE $alias.native_library_timestamp_candidates AS ' + 'SELECT file_path FROM $alias.library ' + 'WHERE COALESCE(file_mod_time, 0) = 0 ' + 'AND audio_metadata_scan_version >= 4', + ); + await txn.execute( + 'CREATE INDEX $alias.native_library_timestamp_path ' + 'ON native_library_timestamp_candidates(file_path)', + ); + await txn.execute( + 'CREATE INDEX $alias.native_library_snapshot_path ' + 'ON library(file_path)', + ); + await txn.execute( + 'CREATE INDEX $alias.native_library_snapshot_id ' + 'ON library(source_id, id)', + ); + await txn.execute('PRAGMA $alias.user_version = 17'); + }); + }); + _check(isCancelled); + final result = await _run({ + ..._request(sourceId, sourcePath, directory), + 'operation': 'library_snapshot', + 'snapshot_path': output.path, + }, requestId: requestId); + if (result['committed'] != true || result['path'] != output.path) { + throw StateError('Native Library snapshot did not complete'); + } + _check(isCancelled); + await _attached(privatePath, (alias) async { + await database.transaction((txn) async { + _check(isCancelled); + await _checkSource(txn, sourceId, sourcePath); + final timestamp = + '(SELECT s.file_mod_time FROM $alias.library s ' + 'JOIN $alias.native_library_timestamp_candidates c ' + 'ON c.file_path = s.file_path ' + 'WHERE s.file_path = library.file_path AND s.file_mod_time > 0)'; + await txn.rawUpdate( + 'UPDATE main.library SET file_mod_time = $timestamp, ' + 'sort_added = $timestamp WHERE source_id = ? ' + 'AND COALESCE(file_mod_time, 0) = 0 ' + 'AND audio_metadata_scan_version >= 4 AND $timestamp IS NOT NULL', + [sourceId], + ); + _check(isCancelled); + }, exclusive: true); + }, publication: true); + published = true; + return output.path; + } on NativeSqliteSnapshotDetachException catch (error) { + _retainedPaths.add(error.path); + rethrow; + } finally { + await _cleanup(directory); + if (!published && await output.exists()) await output.delete(); + } + } +} diff --git a/rust_backend/crates/mobile/src/native_library.rs b/rust_backend/crates/mobile/src/native_library.rs index 7753a662..ec2b76c7 100644 --- a/rust_backend/crates/mobile/src/native_library.rs +++ b/rust_backend/crates/mobile/src/native_library.rs @@ -509,12 +509,28 @@ fn import(request: &Value, delta: Option<&Value>, check: Check<'_>) -> Result Value { json!({"id":id,"trackName":format!("Track {id}"),"artistName":"Artist","albumName":"Album","albumArtist":null,"filePath":path,"scannedAt":"2026-10-06T12:00:00.000Z","fileModTime":1770000000000_i64,"isrc":"USAAA2600001","hasReplayGain":false,"replaygain_track_gain":"-4.5 dB"}) } +#[test] +fn private_incremental_manifest_retains_selected_paths_and_order() { + let fixture = Fixture::new(); + let mut request = fixture.request.clone(); + request["operation"] = "library_incremental_import".into(); + request["retain_removed_paths"] = true.into(); + request["delta"] = + json!({"files":[],"removedUris":["/z","/a","/z"],"deletedPaths":["/ignored"]}); + execute(&request, &|| Ok(())).unwrap(); + let paths = fixture + .db + .prepare("SELECT path FROM native_library_removed_paths ORDER BY ordinal") + .unwrap() + .query_map([], |row| row.get::<_, String>(0)) + .unwrap() + .collect::, _>>() + .unwrap(); + assert_eq!(paths, ["/z", "/a", "/z"]); + request["delta"] = json!({"scanned":[],"deletedPaths":["/fallback"]}); + execute(&request, &|| Ok(())).unwrap(); + let path: String = fixture + .db + .query_row("SELECT path FROM native_library_removed_paths", [], |row| { + row.get(0) + }) + .unwrap(); + assert_eq!(path, "/fallback"); + request["delta"] = json!({"files":[],"removedUris":[]}); + execute(&request, &|| Ok(())).unwrap(); + assert_eq!( + fixture + .db + .query_row( + "SELECT COUNT(*) FROM native_library_removed_paths", + [], + |row| row.get::<_, i64>(0) + ) + .unwrap(), + 0 + ); +} + +#[test] +fn invalid_private_removal_manifest_rolls_back_rows_and_manifest() { + let fixture = Fixture::new(); + fixture.add("old", "/music/old.flac", "source"); + let mut request = fixture.request.clone(); + request["operation"] = "library_incremental_import".into(); + request["retain_removed_paths"] = true.into(); + request["delta"] = + json!({"files":[track("new","/music/new.flac")],"removedUris":["/music/old.flac",42]}); + assert!( + execute(&request, &|| Ok(())) + .unwrap_err() + .contains("must be a string") + ); + assert_eq!(fixture.ids(), ["old"]); + assert_eq!( + fixture + .db + .query_row( + "SELECT COUNT(*) FROM sqlite_master WHERE name='native_library_removed_paths'", + [], + |row| row.get::<_, i64>(0) + ) + .unwrap(), + 0 + ); +} + #[test] fn production_dart_row_and_path_codec_parity() { let fixture: Value = serde_json::from_str(include_str!( diff --git a/test/library_native_adapter_test.dart b/test/library_native_adapter_test.dart new file mode 100644 index 00000000..bddbcd49 --- /dev/null +++ b/test/library_native_adapter_test.dart @@ -0,0 +1,684 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:sqflite/sqflite.dart'; +import 'package:spotiflac_android/services/library_native_adapter.dart'; +import 'package:spotiflac_android/services/library_row_mapper.dart'; +import 'package:spotiflac_android/services/platform_bridge.dart'; +import 'package:spotiflac_android/services/sqlite_native_snapshot.dart'; + +import 'support/library_native_fixture.dart'; +import 'support/sqlite_process_database.dart'; + +class _NativeHarness { + _NativeHarness(this.live); + final Database live; + List> incremental = []; + List removed = []; + Future Function()? afterStage; + final stagedPaths = []; + + Future> run( + Map request, { + String? requestId, + }) async { + final path = request['library_path'] as String; + expect(path, isNot(live.path)); + expect( + (await live.rawQuery('PRAGMA database_list')).map((r) => r['file']), + isNot(contains(path)), + ); + stagedPaths.add(path); + final private = await SqliteProcessDatabase.open(path: path); + final snapshot = request['operation'] == 'library_snapshot'; + var count = 0; + try { + if (snapshot) { + final indexes = await private.rawQuery('PRAGMA index_list(library)'); + expect( + indexes.any((row) => row['name'] == 'native_library_snapshot_id'), + isTrue, + ); + final rows = await private.query('library', orderBy: 'id'); + final lines = []; + for (final row in rows) { + var timestamp = + (row['audio_metadata_scan_version'] as num).toInt() < 4 + ? -1 + : (row['file_mod_time'] as num?)?.toInt() ?? 0; + if (timestamp == 0) { + timestamp = 999; + await private.update( + 'library', + {'file_mod_time': timestamp, 'sort_added': timestamp}, + where: 'id = ?', + whereArgs: [row['id']], + ); + } + lines.add('$timestamp\t${row['file_path']}'); + } + await File( + request['snapshot_path'] as String, + ).writeAsString('${lines.join('\n')}\n'); + count = rows.length; + } else { + expect(await private.query('library'), isEmpty); + final history = await SqliteProcessDatabase.open( + path: request['history_path'] as String, + ); + try { + expect(await history.query('history_path_keys'), isEmpty); + } finally { + await history.close(); + } + final full = request['operation'] == 'library_full_import'; + final rows = full + ? (await File(request['ndjson_path'] as String).readAsLines()) + .where((line) => line.isNotEmpty) + .map( + (line) => + Map.from(jsonDecode(line) as Map), + ) + .toList() + : incremental; + for (final row in rows) { + await addNativeLibraryTrack(private, row); + } + count = rows.length; + if (!full) { + expect(request['retain_removed_paths'], isTrue); + await private.execute( + 'CREATE TABLE native_library_removed_paths(ordinal INTEGER PRIMARY KEY,path TEXT NOT NULL)', + ); + for (var i = 0; i < removed.length; i++) { + await private.execute( + 'INSERT INTO native_library_removed_paths VALUES(?,?)', + [i, removed[i]], + ); + } + } + } + } finally { + await private.close(); + } + await afterStage?.call(); + return { + 'committed': true, + 'inserted': count, + 'skipped': 0, + 'scanned_count': count, + 'totalFiles': count + 3, + 'skippedCount': 3, + if (snapshot) 'path': request['snapshot_path'], + }; + } +} + +class _SqlProbe implements Database, Transaction { + _SqlProbe(this.inner, this.path, this.onSql); + final DatabaseExecutor inner; + @override + final String path; + final void Function(String) onSql; + @override + Future execute(String sql, [List? args]) async { + if (sql.startsWith('DETACH')) onSql(sql); + await inner.execute(sql, args); + if (!sql.startsWith('DETACH')) onSql(sql); + } + + @override + Future>> rawQuery( + String sql, [ + List? args, + ]) => inner.rawQuery(sql, args); + @override + Future rawDelete(String sql, [List? args]) async { + final result = await inner.rawDelete(sql, args); + onSql(sql); + return result; + } + + @override + Future rawUpdate(String sql, [List? args]) async { + final result = await inner.rawUpdate(sql, args); + onSql(sql); + return result; + } + + @override + Future>> query( + String table, { + bool? distinct, + List? columns, + String? where, + List? whereArgs, + String? groupBy, + String? having, + String? orderBy, + int? limit, + int? offset, + }) => inner.query( + table, + distinct: distinct, + columns: columns, + where: where, + whereArgs: whereArgs, + groupBy: groupBy, + having: having, + orderBy: orderBy, + limit: limit, + offset: offset, + ); + @override + Future transaction( + Future Function(Transaction) action, { + bool? exclusive, + }) => (inner as Database).transaction( + (txn) => action(_SqlProbe(txn, path, onSql)), + exclusive: exclusive, + ); + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +void main() { + late Directory root; + late SqliteProcessDatabase live; + late _NativeHarness native; + late LibraryNativeAdapter adapter; + setUp(() async { + root = await Directory.systemTemp.createTemp('library-private-adapter-'); + live = await SqliteProcessDatabase.open(path: '${root.path}/live.db'); + await createNativeLibraryFixture(live); + native = _NativeHarness(live); + adapter = LibraryNativeAdapter( + live, + temporaryDirectory: root, + nativeJobRunner: native.run, + platform: 'ios', + ); + await addNativeLibraryTrack(live, nativeLibraryTrack('old')); + await addNativeLibraryTrack( + live, + nativeLibraryTrack('other', path: '/other/other.flac'), + source: 'other', + ); + }); + tearDown(() async { + await live.close(); + await root.delete(recursive: true); + }); + Future scan( + List> rows, { + int errors = 0, + }) async { + final file = File('${root.path}/scan.ndjson'); + await file.writeAsString(rows.map(jsonEncode).join('\n')); + return LibraryScanNDJSONFile( + file: file, + expectedCount: rows.length, + errorCount: errors, + ); + } + + Future cleaned() async { + for (final path in native.stagedPaths) { + expect(await Directory(File(path).parent.path).exists(), isFalse); + } + expect( + (await live.rawQuery( + 'PRAGMA database_list', + )).map((r) => r['name']).where((name) => name != 'temp'), + ['main', 'history_db'], + ); + expect(await live.rawQuery('PRAGMA integrity_check'), [ + {'integrity_check': 'ok'}, + ]); + } + + test( + 'full private staging publishes exact rows and filters current History through live triggers', + () async { + final rows = [nativeLibraryTrack('new'), nativeLibraryTrack('download')]; + native.afterStage = () => + addNativeLibraryDownload(live, '/music/download.flac'); + final result = await adapter.importScanFile( + 'source', + await scan(rows), + requestId: 'full', + isCancelled: () => false, + ); + expect(result, containsPair('durable', true)); + expect(result['inserted'], 1); + expect(result['skipped'], 1); + expect( + await live.query( + 'library', + where: 'source_id = ?', + whereArgs: ['source'], + ), + [LibraryRowMapper.encode(rows.first, sourceId: 'source')], + ); + expect( + (await live.query( + 'library_path_keys', + )).map((r) => r['item_id']).toSet(), + {'new', 'other'}, + ); + final lookup = await live.rawQuery( + "SELECT ref_count FROM library_lookup_summary WHERE source_id='source' AND kind='match'", + ); + expect(lookup, [ + {'ref_count': 1}, + ]); + await cleaned(); + }, + ); + + test( + 'partial and empty scans preserve missing rows only for partial scans', + () async { + await adapter.importScanFile( + 'source', + await scan([nativeLibraryTrack('new')], errors: 1), + requestId: 'partial', + isCancelled: () => false, + ); + expect((await live.query('library', orderBy: 'id')).map((r) => r['id']), [ + 'new', + 'old', + 'other', + ]); + await adapter.importScanFile( + 'source', + await scan([], errors: 1), + requestId: 'empty-partial', + isCancelled: () => false, + ); + expect((await live.query('library', orderBy: 'id')).map((r) => r['id']), [ + 'new', + 'old', + 'other', + ]); + final result = await adapter.importScanFile( + 'source', + await scan([]), + requestId: 'empty', + isCancelled: () => false, + ); + expect(result['inserted'], 0); + expect((await live.query('library')).map((r) => r['id']), ['other']); + await cleaned(); + }, + ); + + test( + 'incremental removes current downloads and exact source-scoped deleted paths', + () async { + await addNativeLibraryTrack(live, nativeLibraryTrack('unchanged')); + await addNativeLibraryTrack(live, nativeLibraryTrack('unreadable')); + native.incremental = [ + nativeLibraryTrack('new'), + nativeLibraryTrack('download'), + ]; + native.removed = [ + '/music/old.flac', + '/music/old.flac', + '/other/other.flac', + ]; + native.afterStage = () async { + await addNativeLibraryDownload(live, '/music/unchanged.flac'); + await addNativeLibraryDownload(live, '/music/download.flac'); + }; + final result = await adapter.scanIncremental( + 'source', + '/music', + '${root.path}/snapshot.tsv', + isSaf: false, + requestId: 'incremental', + isCancelled: () => false, + ); + expect(result['inserted'], 1); + expect(result['skipped'], 2); + expect(result['deleted_count'], 1); + expect(result['skippedCount'], 3); + expect(result['scanned_count'], 2); + expect(result['totalFiles'], 5); + expect((await live.query('library', orderBy: 'id')).map((r) => r['id']), [ + 'new', + 'other', + 'unreadable', + ]); + expect( + (await live.query( + 'library_path_keys', + )).map((r) => r['item_id']).toSet(), + {'new', 'other', 'unreadable'}, + ); + await cleaned(); + }, + ); + + test( + 'source edit and cancellation after private commit never publish', + () async { + final before = await nativeLibraryDigest(live); + var cancelled = false; + native.afterStage = () async { + cancelled = true; + }; + await expectLater( + adapter.importScanFile( + 'source', + await scan([nativeLibraryTrack('new')]), + requestId: 'cancel', + isCancelled: () => cancelled, + ), + throwsStateError, + ); + expect(await nativeLibraryDigest(live), before); + native.afterStage = () => live + .update( + 'library_sources', + {'path': '/changed'}, + where: 'id=?', + whereArgs: ['source'], + ) + .then((_) {}); + await expectLater( + adapter.importScanFile( + 'source', + await scan([nativeLibraryTrack('new')]), + requestId: 'source-edit', + isCancelled: () => false, + ), + throwsStateError, + ); + expect(await nativeLibraryDigest(live), before); + await cleaned(); + }, + ); + + test( + 'late owner SQL failure rolls back rows, path keys and derived lookups', + () async { + final before = await nativeLibraryDigest(live); + await live.execute( + "CREATE TRIGGER reject_new BEFORE INSERT ON library WHEN new.id='new' BEGIN SELECT RAISE(ABORT,'fixture late failure'); END", + ); + await expectLater( + adapter.importScanFile( + 'source', + await scan([nativeLibraryTrack('new')]), + requestId: 'rollback', + isCancelled: () => false, + ), + throwsStateError, + ); + expect(await nativeLibraryDigest(live), before); + expect( + await live.rawQuery( + "SELECT name FROM sqlite_temp_master WHERE name LIKE 'library_private_%_selected'", + ), + isEmpty, + ); + await cleaned(); + }, + ); + + test( + 'cancellation during owner publication rolls back accepted rows and keys', + () async { + var cancelled = false; + final before = await nativeLibraryDigest(live); + final probed = _SqlProbe(live, live.path, (sql) { + if (sql.startsWith('INSERT OR REPLACE INTO main.library(')) { + cancelled = true; + } + }); + final candidate = LibraryNativeAdapter( + probed, + temporaryDirectory: root, + nativeJobRunner: native.run, + ); + await expectLater( + candidate.importScanFile( + 'source', + await scan([nativeLibraryTrack('new')]), + requestId: 'cancel-publication', + isCancelled: () => cancelled, + ), + throwsStateError, + ); + expect(await nativeLibraryDigest(live), before); + await cleaned(); + }, + ); + + test( + 'failed preparation DETACH retains file and never invokes native', + () async { + final probed = _SqlProbe(live, live.path, (sql) { + if (sql.startsWith('DETACH')) { + throw StateError('fixture detach failure'); + } + }); + final candidate = LibraryNativeAdapter( + probed, + temporaryDirectory: root, + nativeJobRunner: native.run, + ); + await expectLater( + candidate.importScanFile( + 'source', + await scan([nativeLibraryTrack('new')]), + requestId: 'detach-prepare', + isCancelled: () => false, + ), + throwsA(isA()), + ); + expect(native.stagedPaths, isEmpty); + final attached = await live.rawQuery('PRAGMA database_list'); + final stage = attached.singleWhere( + (row) => (row['name'] as String).startsWith('native_snapshot_'), + ); + expect(await File(stage['file'] as String).exists(), isTrue); + await live.execute('DETACH DATABASE "${stage['name']}"'); + }, + ); + + test( + 'cancellation after durable commit still returns the committed result', + () async { + var publishing = false; + var cancelled = false; + final probed = _SqlProbe(live, live.path, (sql) { + if (sql.startsWith('INSERT OR REPLACE INTO main.library(')) { + publishing = true; + } + if (publishing && sql.startsWith('DETACH')) { + cancelled = true; + } + }); + final candidate = LibraryNativeAdapter( + probed, + temporaryDirectory: root, + nativeJobRunner: native.run, + ); + final result = await candidate.importScanFile( + 'source', + await scan([nativeLibraryTrack('new')]), + requestId: 'cancel-after-commit', + isCancelled: () => cancelled, + ); + expect(cancelled, isTrue); + expect(result['durable'], isTrue); + expect( + (await live.query('library', orderBy: 'id')).map((row) => row['id']), + ['new', 'other'], + ); + await cleaned(); + }, + ); + + test( + 'failed DETACH after durable publication retains stage and reports success', + () async { + var published = false; + final probed = _SqlProbe(live, live.path, (sql) { + if (sql.startsWith('INSERT OR REPLACE INTO main.library(')) { + published = true; + } + if (published && sql.startsWith('DETACH')) { + throw StateError('fixture postcommit detach failure'); + } + }); + final candidate = LibraryNativeAdapter( + probed, + temporaryDirectory: root, + nativeJobRunner: native.run, + ); + final result = await candidate.importScanFile( + 'source', + await scan([nativeLibraryTrack('new')]), + requestId: 'detach-publish', + isCancelled: () => false, + ); + expect(result['durable'], isTrue); + expect((await live.query('library', orderBy: 'id')).map((r) => r['id']), [ + 'new', + 'other', + ]); + expect(await File(native.stagedPaths.single).exists(), isTrue); + final stage = (await live.rawQuery('PRAGMA database_list')).singleWhere( + (row) => (row['name'] as String).startsWith('library_private_'), + ); + await live.execute('DETACH DATABASE "${stage['name']}"'); + }, + ); + + test( + 'timestamp snapshot retains sentinels and only backfills still missing eligible live rows', + () async { + for (final id in ['zero', 'changed', 'legacy']) { + await addNativeLibraryTrack( + live, + nativeLibraryTrack(id, timestamp: 0, version: id == 'legacy' ? 3 : 4), + ); + } + native.afterStage = () async { + await live.update( + 'library', + {'file_mod_time': 777, 'sort_added': 777}, + where: 'id=?', + whereArgs: ['changed'], + ); + await live.update( + 'library', + {'file_mod_time': 0}, + where: 'id=?', + whereArgs: ['old'], + ); + }; + final path = await adapter.writeFileModTimesSnapshot( + 'source', + requestId: 'snapshot', + isCancelled: () => false, + ); + expect(await File(path).readAsLines(), [ + '999\t/music/changed.flac', + '-1\t/music/legacy.flac', + '1770000000000\t/music/old.flac', + '999\t/music/zero.flac', + ]); + final rows = await live.query( + 'library', + columns: ['id', 'file_mod_time'], + orderBy: 'id', + ); + expect(rows, [ + {'id': 'changed', 'file_mod_time': 777}, + {'id': 'legacy', 'file_mod_time': 0}, + {'id': 'old', 'file_mod_time': 0}, + {'id': 'other', 'file_mod_time': 1770000000000}, + {'id': 'zero', 'file_mod_time': 999}, + ]); + await cleaned(); + }, + ); + + test( + 'timestamp preparation DETACH failure retains projection and never invokes native', + () async { + final probed = _SqlProbe(live, live.path, (sql) { + if (sql.startsWith('DETACH')) { + throw StateError('fixture timestamp preparation detach failure'); + } + }); + final candidate = LibraryNativeAdapter( + probed, + temporaryDirectory: root, + nativeJobRunner: native.run, + ); + await expectLater( + candidate.writeFileModTimesSnapshot( + 'source', + requestId: 'timestamp-detach-prepare', + isCancelled: () => false, + ), + throwsA(isA()), + ); + expect(native.stagedPaths, isEmpty); + final stage = (await live.rawQuery('PRAGMA database_list')).singleWhere( + (row) => (row['name'] as String).startsWith('library_private_'), + ); + expect(await File(stage['file'] as String).exists(), isTrue); + await live.execute('DETACH DATABASE "${stage['name']}"'); + }, + ); + + test( + 'timestamp publication DETACH failure retains projection and returns durable snapshot', + () async { + await addNativeLibraryTrack( + live, + nativeLibraryTrack('zero', timestamp: 0), + ); + var published = false; + final probed = _SqlProbe(live, live.path, (sql) { + if (sql.startsWith('UPDATE main.library SET file_mod_time')) { + published = true; + } + if (published && sql.startsWith('DETACH')) { + throw StateError('fixture timestamp postcommit detach failure'); + } + }); + final candidate = LibraryNativeAdapter( + probed, + temporaryDirectory: root, + nativeJobRunner: native.run, + ); + final output = await candidate.writeFileModTimesSnapshot( + 'source', + requestId: 'timestamp-detach-publish', + isCancelled: () => false, + ); + expect(await File(output).exists(), isTrue); + expect( + (await live.query( + 'library', + where: 'id=?', + whereArgs: ['zero'], + )).single['file_mod_time'], + 999, + ); + expect(await File(native.stagedPaths.single).exists(), isTrue); + final stage = (await live.rawQuery('PRAGMA database_list')).singleWhere( + (row) => (row['name'] as String).startsWith('library_private_'), + ); + await live.execute('DETACH DATABASE "${stage['name']}"'); + }, + ); +} diff --git a/test/support/library_native_fixture.dart b/test/support/library_native_fixture.dart new file mode 100644 index 00000000..0fa94f0c --- /dev/null +++ b/test/support/library_native_fixture.dart @@ -0,0 +1,103 @@ +import 'package:sqflite/sqflite.dart'; +import 'package:spotiflac_android/services/library_row_mapper.dart'; +import 'package:spotiflac_android/services/library_schema.dart'; +import 'package:spotiflac_android/services/sqlite_helpers.dart' as sqlite; + +Map nativeLibraryTrack( + String id, { + String? path, + int? timestamp = 1770000000000, + int version = 4, +}) => { + 'id': id, + 'trackName': 'Track $id', + 'artistName': ' CAFÉ İ 音楽 ', + 'albumName': 'Album', + 'albumArtist': null, + 'filePath': path ?? '/music/$id.flac', + 'scannedAt': '2026-10-07T12:00:00.000Z', + 'fileModTime': timestamp, + 'audioMetadataScanVersion': version, + 'explicit': true, + 'hasLyrics': true, + 'hasReplayGain': true, + 'bitDepth': 24, + 'sampleRate': 96000, +}; + +Future createNativeLibraryFixture( + Database db, { + String historyPath = ':memory:', +}) async { + await LibrarySchema.create(db, LibrarySchema.version); + await db.execute('PRAGMA user_version = 17'); + await db.execute('PRAGMA recursive_triggers = ON'); + await db.execute('ATTACH DATABASE ? AS history_db', [historyPath]); + await db.execute( + 'CREATE TABLE history_db.history_path_keys(' + 'item_id TEXT NOT NULL,path_key TEXT NOT NULL,PRIMARY KEY(item_id,path_key))', + ); + await db.execute( + 'CREATE INDEX history_db.history_key_index ' + 'ON history_path_keys(path_key)', + ); + for (final source in ['source', 'other']) { + await db.insert('library_sources', { + 'id': source, + 'path': source == 'source' ? '/music' : '/other', + 'display_name': source, + }); + } +} + +Future addNativeLibraryTrack( + DatabaseExecutor db, + Map track, { + String source = 'source', +}) async { + await db.insert( + 'library', + LibraryRowMapper.encode(track, sourceId: source), + conflictAlgorithm: ConflictAlgorithm.replace, + ); + await db.delete( + 'library_path_keys', + where: 'item_id = ?', + whereArgs: [track['id']], + ); + final batch = db.batch(); + sqlite.putPathKeysInBatch( + batch, + 'library_path_keys', + track['id'] as String, + track['filePath'] as String, + ); + await batch.commit(noResult: true); +} + +Future addNativeLibraryDownload(DatabaseExecutor db, String path) async { + final batch = db.batch(); + sqlite.putPathKeysInBatch( + batch, + 'history_db.history_path_keys', + 'download-$path', + path, + ); + await batch.commit(noResult: true); +} + +Future> nativeLibraryDigest(DatabaseExecutor db) async => { + 'rows': await db.rawQuery('SELECT * FROM library ORDER BY id'), + 'keys': await db.rawQuery( + 'SELECT * FROM library_path_keys ORDER BY item_id,path_key', + ), + 'lookup': await db.rawQuery( + 'SELECT * FROM library_lookup_summary ORDER BY source_id,kind,value', + ), + if ((await db.rawQuery( + "SELECT name FROM sqlite_master WHERE name='library_search_fts'", + )).isNotEmpty) + 'fts': await db.rawQuery( + "SELECT rowid FROM library_search_fts WHERE library_search_fts MATCH 'track' ORDER BY rowid", + ), +};