fix(library): stage native scans before owner SQLite publication

This commit is contained in:
zarzet committed 2026-10-07 04:19:14 +07:00
1 parent d285f26f9a
commit 8ca9205cc4
7 files changed
+1784 -53

No files matched your search

@@ -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<File> _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 = <String>[];
Future<void> Function(Map<String, dynamic>, Map<String, dynamic>)? 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<LibraryScanNDJSONFile> scan(
List<Map<String, dynamic>> 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<void> 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 = <int>[];
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<Map<String, Object?>>? 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,
};
},
);
}
+24 -52
View File
@@ -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<Map<String, dynamic>> _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<List<LibrarySearchHit>> searchLibrary({
+435
View File
@@ -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<Map<String, dynamic>> Function(
Map<String, dynamic> 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 = <String>{};
static void _check(bool Function() isCancelled) {
if (isCancelled()) throw StateError('Library scan cancelled');
}
Future<String> _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<void> _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<T> _attached<T>(
String path,
Future<T> 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<Directory> _workingDirectory() async =>
(temporaryDirectory ?? await getTemporaryDirectory()).createTemp(
'library-native-',
);
Future<void> _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<String, dynamic> _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<void> _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<Map<String, dynamic>> 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<Map<String, dynamic>> 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<Map<String, dynamic>> _import(
String sourceId,
Map<String, dynamic> 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<Map<String, dynamic>> _publish(
String sourceId,
String sourcePath,
String privatePath,
Map<String, dynamic> 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<int> count(String sql, [List<Object?>? 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<int> deleteWhere(String where, List<Object?> 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<String> 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();
}
}
}
@@ -509,12 +509,28 @@ fn import(request: &Value, delta: Option<&Value>, check: Check<'_>) -> Result<Va
let paths = delta["removedUris"]
.as_array()
.or_else(|| delta["deletedPaths"].as_array());
// The platform adapter can delete its spilled delta immediately after
// this operation. Retain its exact removal selection in the private
// staging database for publication by the live database's owner.
let retain_removed = request["retain_removed_paths"] == true;
if retain_removed {
transaction.execute_batch("CREATE TABLE IF NOT EXISTS main.native_library_removed_paths(ordinal INTEGER PRIMARY KEY,path TEXT NOT NULL); DELETE FROM main.native_library_removed_paths;")
.map_err(|e| error("stage removed Library paths", e))?;
}
if let Some(paths) = paths {
for path in paths {
for (ordinal, path) in paths.iter().enumerate() {
check()?;
let path = path
.as_str()
.ok_or("Deleted Library path must be a string")?;
if retain_removed {
transaction
.execute(
"INSERT INTO main.native_library_removed_paths VALUES(?,?)",
params![ordinal as i64, path],
)
.map_err(|e| error("retain removed Library path", e))?;
}
transaction.execute("DELETE FROM library_path_keys WHERE item_id IN(SELECT id FROM library WHERE source_id = ? AND file_path = ?)", params![source,path]).map_err(|e| error("delete removed path keys", e))?;
deleted += transaction
.execute(
@@ -109,6 +109,76 @@ fn track(id: &str, path: &str) -> 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::<Result<Vec<_>, _>>()
.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!(
+684
View File
@@ -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<Map<String, dynamic>> incremental = [];
List<String> removed = [];
Future<void> Function()? afterStage;
final stagedPaths = <String>[];
Future<Map<String, dynamic>> run(
Map<String, dynamic> 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 = <String>[];
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<String, dynamic>.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<void> execute(String sql, [List<Object?>? args]) async {
if (sql.startsWith('DETACH')) onSql(sql);
await inner.execute(sql, args);
if (!sql.startsWith('DETACH')) onSql(sql);
}
@override
Future<List<Map<String, Object?>>> rawQuery(
String sql, [
List<Object?>? args,
]) => inner.rawQuery(sql, args);
@override
Future<int> rawDelete(String sql, [List<Object?>? args]) async {
final result = await inner.rawDelete(sql, args);
onSql(sql);
return result;
}
@override
Future<int> rawUpdate(String sql, [List<Object?>? args]) async {
final result = await inner.rawUpdate(sql, args);
onSql(sql);
return result;
}
@override
Future<List<Map<String, Object?>>> query(
String table, {
bool? distinct,
List<String>? columns,
String? where,
List<Object?>? 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<T> transaction<T>(
Future<T> 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<LibraryScanNDJSONFile> scan(
List<Map<String, dynamic>> 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<void> 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<NativeSqliteSnapshotDetachException>()),
);
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<NativeSqliteSnapshotDetachException>()),
);
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']}"');
},
);
}
+103
View File
@@ -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<String, dynamic> 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<void> 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<void> addNativeLibraryTrack(
DatabaseExecutor db,
Map<String, dynamic> 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<void> 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<Map<String, Object?>> 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",
),
};