diff --git a/experimental/web_file_system/README.md b/experimental/web_file_system/README.md new file mode 100644 index 0000000..15bb638 --- /dev/null +++ b/experimental/web_file_system/README.md @@ -0,0 +1,116 @@ +# web_file_system + +A high-performance, fully asynchronous VFS (Virtual File System) for the web implementing Dart's standard `package:file` interfaces. + +It uses a hybrid backend that separates metadata management from actual block storage for optimal latency and performance in the browser. + +--- + +## Key Features + +* **`package:file` Compliance**: Drop-in replacement for standard file system operations. Fully compatible with libraries expecting a `FileSystem` interface. +* **O(1) Directory Renames**: By utilizing an inode-based database structure, directory renames and moves are fast constant-time operations. Children don't require path prefix updates. +* **IndexedDB Metadata Store**: Keeps directory hierarchies, timestamps, file sizes, and symbolic links indexed using fast IndexedDB tables. +* **OPFS (Origin Private File System) Block Store**: Stores raw file bytes and stream chunks directly in the high-performance private browser storage space. +* **Symbolic Links (Symlinks)**: Full support for relative and absolute symbolic links, target updating, recursive listing, and canonical path resolution. + +--- + +## Architectural Flow + +```mermaid +graph TD + subgraph WebFileSystem ["WebFileSystem (Dart API)"] + FS["WebFileSystem"] + WD["WebDirectory"] + WF["WebFile"] + WL["WebLink"] + end + + subgraph Metadata ["IndexedDB (IdbInodeService)"] + DB[(WebFileSystemDB)] + Inodes[Inode Table: parentId index] + end + + subgraph DataBlocks ["OPFS (OpfsBlockStore)"] + BlocksDir[/.blocks/ Directory] + RawData[[UUID Data Blobs]] + end + + FS -->|Lookup / Resolve| Metadata + WD -->|Read / Write Children| Metadata + WF -->|Read / Write Metadata| Metadata + WF -->|Read / Write Data Blocks| DataBlocks + WL -->|Read / Write Link Target| DataBlocks +``` + +--- + +## Getting Started + +### Installation + +Add `web_file_system` to your `pubspec.yaml` (or reference the path if using as a path dependency): + +```yaml +dependencies: + web_file_system: + path: path/to/web_file_system +``` + +--- + +## Usage Examples + +### 1. Initializing the File System + +```dart +import 'package:web_file_system/web_file_system.dart'; + +final fs = WebFileSystem(); +``` + +### 2. Creating and Reading Files + +```dart +final file = fs.file('/documents/report.txt'); + +// Standard write & read (creates parent directories if needed when recursive is true) +await file.create(recursive: true); +await file.writeAsString('Hello, Web private storage!'); + +final contents = await file.readAsString(); +print(contents); // "Hello, Web private storage!" +``` + +### 3. Directory Listing & Traversal + +```dart +final dir = fs.directory('/documents'); +await for (final entity in dir.list(recursive: true, followLinks: false)) { + print('${entity.path} (${entity.runtimeType})'); +} +``` + +### 4. Symbolic Links + +```dart +// Create a symbolic link +final link = fs.link('/shortcut_to_report'); +await link.create('/documents/report.txt'); + +// Reading content through the link +final data = await fs.file('/shortcut_to_report').readAsString(); + +// Resolve canonical absolute path +final canonicalPath = await link.resolveSymbolicLinks(); +print(canonicalPath); // "/documents/report.txt" +``` + +--- + +## Performance Notes: Inode vs Path-based Renames + +Most browser file systems index files by their full path strings (e.g., keying a database table by `/documents/photos/holiday.png`). Renaming the directory `/documents` to `/archive` requires iterating over every nested path and rewriting their keys, which is an $O(N)$ operation where $N$ is the number of recursive items. + +`web_file_system` implements an **inode index** model. Inodes reference parents by their database IDs rather than paths. Renaming a directory only updates the single directory node's `name` property. Its child files and directories remain untouched because they continue to reference the same unchanged parent inode ID. This makes directory renaming an **$O(1)$** operation. diff --git a/experimental/web_file_system/lib/src/backend/idb_inode_service.dart b/experimental/web_file_system/lib/src/backend/idb_inode_service.dart index 758e007..7770020 100644 --- a/experimental/web_file_system/lib/src/backend/idb_inode_service.dart +++ b/experimental/web_file_system/lib/src/backend/idb_inode_service.dart @@ -95,13 +95,16 @@ class IdbInodeService { static const String _storeName = 'inodes'; web.IDBDatabase? _db; - final Completer _initCompleter = Completer(); + Future? _initFuture; static const String rootId = '00000000-0000-0000-0000-000000000000'; - Future _ensureReady() async { - if (_db != null) return; - if (_initCompleter.isCompleted) return _initCompleter.future; + Future _ensureReady() { + if (_db != null) return Future.value(); + return _initFuture ??= _init(); + } + Future _init() async { + final completer = Completer(); final request = web.window.indexedDB.open(_dbName, _version); request.onupgradeneeded = (web.IDBVersionChangeEvent event) { @@ -119,20 +122,17 @@ class IdbInodeService { } }.toJS; - final completer = Completer(); - request.onsuccess = (web.Event event) { _db = (event.target as web.IDBOpenDBRequest).result as web.IDBDatabase; _ensureRootExists().then((_) { - if (!_initCompleter.isCompleted) completer.complete(); + completer.complete(); }).catchError((e) { - if (!_initCompleter.isCompleted) completer.completeError(e); + completer.completeError(e); }); }.toJS; request.onerror = (web.Event event) { - if (!_initCompleter.isCompleted) - completer.completeError(Exception('Failed to open IDB')); + completer.completeError(Exception('Failed to open IDB')); }.toJS; return completer.future; diff --git a/experimental/web_file_system/lib/src/backend/opfs_block_store.dart b/experimental/web_file_system/lib/src/backend/opfs_block_store.dart index ab262c8..a6e7209 100644 --- a/experimental/web_file_system/lib/src/backend/opfs_block_store.dart +++ b/experimental/web_file_system/lib/src/backend/opfs_block_store.dart @@ -26,7 +26,7 @@ class OpfsBlockStore { .toDart; } - Future writeBlob(Stream> stream) async { + Future<(String, int)> writeBlob(Stream> stream) async { await _ensureReady(); final blockId = _uuid.v4(); @@ -38,10 +38,12 @@ class OpfsBlockStore { .toDart; final writable = await fileHandle.createWritable().toDart; + int totalBytes = 0; try { await for (final chunk in stream) { final uint8 = Uint8List.fromList(chunk); + totalBytes += uint8.length; await writable.write(uint8.toJS).toDart; } await writable.close().toDart; @@ -53,7 +55,7 @@ class OpfsBlockStore { rethrow; } - return blockId; + return (blockId, totalBytes); } Stream> readBlob(String blockId) async* { diff --git a/experimental/web_file_system/lib/src/entities/web_directory.dart b/experimental/web_file_system/lib/src/entities/web_directory.dart index b4ddcc0..5ab0364 100644 --- a/experimental/web_file_system/lib/src/entities/web_directory.dart +++ b/experimental/web_file_system/lib/src/entities/web_directory.dart @@ -3,6 +3,7 @@ import 'package:file/file.dart'; import 'package:web_file_system/src/backend/idb_inode_service.dart'; import '../web_file_system.dart'; import 'web_file.dart'; +import 'web_link.dart'; class WebDirectory extends FileSystemEntity implements Directory { final WebFileSystem _fs; @@ -143,6 +144,26 @@ class WebDirectory extends FileSystemEntity implements Directory { if (recursive) { yield* dir.list(recursive: true, followLinks: followLinks); } + } else if (child.nodeType == 2) { + if (!followLinks) { + yield WebLink(_fs, childPath); + } else { + try { + final resolved = + await _fs.resolvepath(childPath, followLinks: true); + if (resolved.nodeType == 1) { + final dir = WebDirectory(_fs, childPath); + yield dir; + if (recursive) { + yield* dir.list(recursive: true, followLinks: true); + } + } else { + yield WebFile(_fs, childPath); + } + } catch (_) { + yield WebLink(_fs, childPath); + } + } } else { yield WebFile(_fs, childPath); } @@ -202,7 +223,7 @@ class WebDirectory extends FileSystemEntity implements Directory { FileStat statSync() => throw UnsupportedError('Sync not supported'); @override - Future resolveSymbolicLinks() async => path; + Future resolveSymbolicLinks() => _fs.resolveSymbolicLinks(path); @override String resolveSymbolicLinksSync() => diff --git a/experimental/web_file_system/lib/src/entities/web_file.dart b/experimental/web_file_system/lib/src/entities/web_file.dart index 0e0d2ed..13a0122 100644 --- a/experimental/web_file_system/lib/src/entities/web_file.dart +++ b/experimental/web_file_system/lib/src/entities/web_file.dart @@ -149,7 +149,7 @@ class WebFile extends FileSystemEntity implements File { bool flush = false, }) async { final stream = Stream.value(bytes); - final newBlobId = await _fs.opfs.writeBlob(stream); + final (newBlobId, _) = await _fs.opfs.writeBlob(stream); Inode inode; try { @@ -157,8 +157,15 @@ class WebFile extends FileSystemEntity implements File { if (mode == FileMode.append) { throw UnsupportedError('Append not yet optimized'); } - } catch (_) { - await create(recursive: true); + } on FileSystemException catch (_) { + final parentPath = _fs.path.dirname(path); + if (await _fs.type(parentPath) != FileSystemEntityType.directory) { + throw FileSystemException( + 'Cannot open file, path = \'$path\' (OS Error: No such file or directory, errno = 2)', + path, + ); + } + await create(recursive: false); inode = await _fs.resolvepath(path); } @@ -220,16 +227,9 @@ class WebFile extends FileSystemEntity implements File { // Start background write but keep future to await in close() final writeFuture = _handleWrite(controller.stream, encoding, mode); + writeFuture.catchError((Object _) {}); - final sink = _WebIOSink( - controller, - encoding, - onDone: () async { - await writeFuture; - }, - ); - - return sink; + return _WebIOSink(controller, writeFuture, encoding); } Future _handleWrite( @@ -238,14 +238,22 @@ class WebFile extends FileSystemEntity implements File { FileMode mode, ) async { try { - final newId = await _fs.opfs.writeBlob(stream); + final parentPath = _fs.path.dirname(path); + if (await _fs.type(parentPath) != FileSystemEntityType.directory) { + throw FileSystemException( + 'Cannot open file, path = \'$path\' (OS Error: No such file or directory, errno = 2)', + path, + ); + } + + final (newId, size) = await _fs.opfs.writeBlob(stream); Inode inode; try { inode = await _fs.resolvepath(path); } catch (_) { // Create if missing - await create(recursive: true); + await create(recursive: false); inode = await _fs.resolvepath(path); } @@ -256,8 +264,7 @@ class WebFile extends FileSystemEntity implements File { name: inode.name, nodeType: 0, blobId: newId, - size: - 0, // TODO: Size not returned by OPFS yet, so 0 for streamed content + size: size, modified: DateTime.now().millisecondsSinceEpoch, ), ); @@ -364,7 +371,7 @@ class WebFile extends FileSystemEntity implements File { File get absolute => WebFile(_fs, _fs.path.absolute(path)); @override - Future resolveSymbolicLinks() async => path; + Future resolveSymbolicLinks() => _fs.resolveSymbolicLinks(path); @override String resolveSymbolicLinksSync() => @@ -381,10 +388,10 @@ class WebFile extends FileSystemEntity implements File { class _WebIOSink implements IOSink { final StreamController> _controller; - final Future Function()? onDone; + final Future _writeFuture; Encoding _encoding; - _WebIOSink(this._controller, this._encoding, {this.onDone}); + _WebIOSink(this._controller, this._writeFuture, this._encoding); @override Encoding get encoding => _encoding; @@ -394,27 +401,35 @@ class _WebIOSink implements IOSink { @override void add(List data) { + if (_controller.isClosed) return; _controller.add(data); } @override void addError(Object error, [StackTrace? stackTrace]) { + if (_controller.isClosed) return; _controller.addError(error, stackTrace); } @override Future addStream(Stream> stream) { - return _controller.addStream(stream); + return Future.any([ + _controller.addStream(stream), + _writeFuture, + ]); } @override Future close() async { await _controller.close(); - if (onDone != null) await onDone!(); + await _writeFuture; } @override - Future get done => _controller.done; + Future get done => Future.any([ + _controller.done, + _writeFuture, + ]); @override Future flush() async {} diff --git a/experimental/web_file_system/lib/src/entities/web_link.dart b/experimental/web_file_system/lib/src/entities/web_link.dart index a5f3df4..1de3393 100644 --- a/experimental/web_file_system/lib/src/entities/web_link.dart +++ b/experimental/web_file_system/lib/src/entities/web_link.dart @@ -35,7 +35,7 @@ class WebLink extends FileSystemEntity implements Link { // Write target path string to OPFS blob final stream = Stream.value(utf8.encode(target)); - final blobId = await _fs.opfs.writeBlob(stream); + final (blobId, _) = await _fs.opfs.writeBlob(stream); final parentPath = _fs.path.dirname(path); final parentInode = await _fs.resolvepath(parentPath); @@ -66,7 +66,7 @@ class WebLink extends FileSystemEntity implements Link { // Write new blob final stream = Stream.value(utf8.encode(target)); - final blobId = await _fs.opfs.writeBlob(stream); + final (blobId, _) = await _fs.opfs.writeBlob(stream); await _fs.idb.updateInode( Inode( @@ -184,17 +184,7 @@ class WebLink extends FileSystemEntity implements Link { Link get absolute => WebLink(_fs, _fs.path.absolute(path)); @override - Future resolveSymbolicLinks() async { - // If we are a link, return target? No, resolveSymbolicLinks follows all the way to canonical path. - // For now, simpler: resolve path logic. - final targetPath = await target(); - // If target is relative, resolve against directory. This gets complex. - // MVP: Return target path raw? No, contract says "path with all symbolic links resolved". - // This requires full traversal logic. - // For MVP just return the path as we stored it if it's absolute, or join if relative. - if (_fs.path.isAbsolute(targetPath)) return targetPath; - return _fs.path.normalize(_fs.path.join(dirname, targetPath)); - } + Future resolveSymbolicLinks() => _fs.resolveSymbolicLinks(path); @override String resolveSymbolicLinksSync() => diff --git a/experimental/web_file_system/lib/src/web_file_system.dart b/experimental/web_file_system/lib/src/web_file_system.dart index b54b441..fd934ea 100644 --- a/experimental/web_file_system/lib/src/web_file_system.dart +++ b/experimental/web_file_system/lib/src/web_file_system.dart @@ -228,6 +228,18 @@ class WebFileSystem extends FileSystem { @override bool identicalSync(String path1, String path2) => throw UnsupportedError('Sync not supported'); + + Future resolveSymbolicLinks(String pathStr) async { + final inode = await resolvepath(pathStr, followLinks: true); + final List segments = []; + Inode current = inode; + while (current.id != IdbInodeService.rootId) { + segments.add(current.name); + current = await _idb.getInode(current.parentId); + } + if (segments.isEmpty) return '/'; + return '/' + segments.reversed.join('/'); + } } class FileStatImpl implements FileStat { diff --git a/experimental/web_file_system/pubspec.yaml b/experimental/web_file_system/pubspec.yaml index f08b4e0..1961a9c 100644 --- a/experimental/web_file_system/pubspec.yaml +++ b/experimental/web_file_system/pubspec.yaml @@ -6,7 +6,7 @@ homepage: https://github.com/fluttercommunity/flutter_whatsnew maintainer: Rody Davis (@rodydavis) environment: - sdk: ^3.5.0 + sdk: ^3.10.0 flutter: ^3.38.5 dependencies: @@ -14,7 +14,6 @@ dependencies: path: ^1.9.0 web: ^1.1.1 uuid: ^4.0.0 - mime: ^1.0.0 dev_dependencies: lints: ^6.0.0 diff --git a/experimental/web_file_system/test/web_file_system_test.dart b/experimental/web_file_system/test/web_file_system_test.dart index 3862be6..68909a3 100644 --- a/experimental/web_file_system/test/web_file_system_test.dart +++ b/experimental/web_file_system/test/web_file_system_test.dart @@ -1,8 +1,12 @@ @TestOn('browser') import 'dart:async'; import 'dart:typed_data'; +import 'dart:js_interop'; import 'package:test/test.dart'; import 'package:web_file_system/web_file_system.dart'; +import 'package:web_file_system/src/backend/idb_inode_service.dart'; +import 'package:web_file_system/src/backend/opfs_block_store.dart'; + void main() { late WebFileSystem fs; @@ -104,6 +108,235 @@ void main() { equals('child-content'), ); }); + + test('Streamed write records correct file size in inode', () async { + final uniqueId = DateTime.now().millisecondsSinceEpoch; + final file = fs.file('/stream_size_$uniqueId.txt'); + final sink = file.openWrite(); + sink.add([1, 2, 3, 4, 5]); + await sink.close(); + + expect(await file.length(), equals(5)); + }); + + test('Writing to file with nonexistent parent throws FileSystemException', () async { + final uniqueId = DateTime.now().millisecondsSinceEpoch; + final file = fs.file('/nonexistent_$uniqueId/file.txt'); + + expect( + () => file.writeAsBytes([1, 2, 3]), + throwsA(isA()), + ); + + final sink = file.openWrite(); + expect( + () => sink.addStream(Stream.value([1, 2, 3])).then((_) => sink.close()), + throwsA(isA()), + ); + }); + + test('Directory list yields correct entity types based on followLinks', () async { + final uniqueId = DateTime.now().millisecondsSinceEpoch; + final dir = '/list_test_$uniqueId'; + final file = '$dir/file.txt'; + final link = '$dir/link_to_file'; + + await fs.directory(dir).create(); + await fs.file(file).writeAsString('data'); + await fs.link(link).create(file); + + // Listing with followLinks = false yields Link + final listNoFollow = await fs.directory(dir).list(followLinks: false).toList(); + final linkEntity = listNoFollow.firstWhere((e) => e.path == link); + expect(linkEntity, isA()); + + // Listing with followLinks = true yields File + final listFollow = await fs.directory(dir).list(followLinks: true).toList(); + final fileEntity = listFollow.firstWhere((e) => e.path == link); + expect(fileEntity, isA()); + }); + + test('Listing a non-existent directory path throws FileSystemException', () async { + final uniqueId = DateTime.now().millisecondsSinceEpoch; + final dir = fs.directory('/nonexistent_dir_$uniqueId'); + expect( + () => dir.list().toList(), + throwsA(isA().having((e) => e.message, 'message', contains('Directory not found'))), + ); + }); + + test('Recursive listing of a directory containing a symlink pointing to another directory with followLinks: true', () async { + final uniqueId = DateTime.now().millisecondsSinceEpoch; + final root = '/root_$uniqueId'; + final targetDir = '/target_dir_$uniqueId'; + final file = '$targetDir/file.txt'; + final link = '$root/link_to_dir'; + + await fs.directory(root).create(); + await fs.directory(targetDir).create(); + await fs.file(file).writeAsString('some data'); + await fs.link(link).create(targetDir); + + final list = await fs.directory(root).list(recursive: true, followLinks: true).toList(); + final paths = list.map((e) => e.path).toList(); + + expect(paths, contains(link)); + expect(paths, contains('$link/file.txt')); + + final linkDirEntity = list.firstWhere((e) => e.path == link); + expect(linkDirEntity, isA()); + }); + + test('Listing directory containing a broken symlink with followLinks: true yields WebLink', () async { + final uniqueId = DateTime.now().millisecondsSinceEpoch; + final dir = '/broken_link_test_$uniqueId'; + final link = '$dir/broken_link'; + final nonexistentTarget = '/nonexistent_target_$uniqueId'; + + await fs.directory(dir).create(); + await fs.link(link).create(nonexistentTarget); + + final list = await fs.directory(dir).list(followLinks: true).toList(); + expect(list.length, equals(1)); + expect(list.first.path, equals(link)); + expect(list.first, isA()); + }); + + test('resolveSymbolicLinks resolves canonical path', () async { + final uniqueId = DateTime.now().millisecondsSinceEpoch; + final dir = '/resolve_test_$uniqueId'; + final subDir = '$dir/subdir'; + final target = '$subDir/file.txt'; + final link = '$dir/link_to_file'; + + await fs.directory(subDir).create(recursive: true); + await fs.file(target).writeAsString('target'); + await fs.link(link).create(target); + + // Verify canonical link resolution + expect(await fs.link(link).resolveSymbolicLinks(), equals(target)); + expect(await fs.file(link).resolveSymbolicLinks(), equals(target)); + + // Verify canonical directory resolution + final dirLink = '$dir/link_to_subdir'; + await fs.link(dirLink).create(subDir); + expect(await fs.directory(dirLink).resolveSymbolicLinks(), equals(subDir)); + }); + + test('IndexedDB concurrent database initialization does not fail or race', () async { + final futures = >[]; + for (int i = 0; i < 15; i++) { + futures.add(WebFileSystem().file('/concurrent_$i.txt').exists()); + } + final results = await Future.wait(futures); + expect(results, hasLength(15)); + for (final result in results) { + expect(result, isFalse); + } + }); + + test('WebFile.create on existing file returns the file', () async { + final uniqueId = DateTime.now().millisecondsSinceEpoch; + final file = fs.file('/create_existing_$uniqueId.txt'); + await file.create(); + expect(await file.exists(), isTrue); + final sameFile = await file.create(); + expect(sameFile.path, equals(file.path)); + }); + + test('WebFile.create recursive in nested directory structure', () async { + final uniqueId = DateTime.now().millisecondsSinceEpoch; + final file = fs.file('/nested_$uniqueId/sub_$uniqueId/file.txt'); + await file.create(recursive: true); + expect(await file.exists(), isTrue); + }); + + test('WebFile.writeAsBytes with FileMode.append throws UnsupportedError', () async { + final uniqueId = DateTime.now().millisecondsSinceEpoch; + final file = fs.file('/append_test_$uniqueId.txt'); + await file.create(); + expect( + () => file.writeAsBytes([1, 2], mode: FileMode.append), + throwsA(isA()), + ); + }); + + test('WebLink.create on existing link throws FileSystemException', () async { + final uniqueId = DateTime.now().millisecondsSinceEpoch; + final targetPath = '/target_$uniqueId.txt'; + final linkPath = '/link_$uniqueId'; + + await fs.file(targetPath).writeAsString('target-content'); + final link = fs.link(linkPath); + await link.create(targetPath); + + expect( + () => link.create(targetPath), + throwsA(isA().having((e) => e.message, 'message', contains('Link already exists'))), + ); + }); + + test('WebLink.target on a non-link entity throws FileSystemException', () async { + final uniqueId = DateTime.now().millisecondsSinceEpoch; + final filePath = '/file_$uniqueId.txt'; + await fs.file(filePath).writeAsString('some content'); + + final link = fs.link(filePath); + expect( + () => link.target(), + throwsA(isA().having((e) => e.message, 'message', contains('Not a link'))), + ); + }); + + test('Deep symbolic link chain throws ELOOP', () async { + final uniqueId = DateTime.now().millisecondsSinceEpoch; + await fs.directory('/dir0_$uniqueId').create(); + for (int i = 1; i <= 25; i++) { + await fs.directory('/dir${i}_$uniqueId').create(); + await fs.link('/dir${i-1}_$uniqueId/link$i').create('/dir${i}_$uniqueId'); + } + + final pathParts = ['/dir0_$uniqueId']; + for (int i = 1; i <= 25; i++) { + pathParts.add('link$i'); + } + final longPath = pathParts.join('/'); + + expect( + () => fs.file(longPath).readAsBytes(), + throwsA(isA().having((e) => e.osError?.errorCode, 'errorCode', 40)), + ); + }); + + test('Relative symbolic link target resolution', () async { + final uniqueId = DateTime.now().millisecondsSinceEpoch; + await fs.directory('/dir_$uniqueId').create(); + await fs.file('/target_$uniqueId.txt').writeAsString('relative-target'); + await fs.link('/dir_$uniqueId/link_$uniqueId').create('../target_$uniqueId.txt'); + + expect( + await fs.file('/dir_$uniqueId/link_$uniqueId').readAsString(), + equals('relative-target'), + ); + }); + + test('Treating file symlink as directory throws ENOTDIR', () async { + final uniqueId = DateTime.now().millisecondsSinceEpoch; + final filePath = '/file_$uniqueId.txt'; + final linkPath = '/link_$uniqueId'; + + await fs.file(filePath).writeAsString('file-content'); + await fs.link(linkPath).create(filePath); + + expect( + () => fs.file('$linkPath/child.txt').readAsBytes(), + throwsA(isA().having((e) => e.osError?.errorCode, 'errorCode', 20)), + ); + }); + + test('resolveSymbolicLinks on root directory', () async { + expect(await fs.resolveSymbolicLinks('/'), equals('/')); + }); }); group('Benchmarks', () { @@ -162,4 +395,199 @@ void main() { expect(totalBytes, equals(10 * chunkSize)); }); }); + + group('IdbInodeService Coverage', () { + setUp(() { + injectMockJS(); + }); + + tearDown(() { + setMockIDBOpenShouldFail(false); + setMockIDBGetShouldFail(false); + setMockIDBPutShouldFail(false); + setMockIDBIndexGetShouldFail(false); + }); + + test('unique index violation triggers request.onerror', () async { + final idb = fs.idb; + // Ensure DB is ready + await idb.getInode(IdbInodeService.rootId); + + // Create two different inodes with same parentId and name + final inode1 = Inode( + id: 'inode-1', + parentId: IdbInodeService.rootId, + name: 'duplicate-name', + nodeType: 0, + modified: DateTime.now().millisecondsSinceEpoch, + ); + final inode2 = Inode( + id: 'inode-2', + parentId: IdbInodeService.rootId, + name: 'duplicate-name', + nodeType: 0, + modified: DateTime.now().millisecondsSinceEpoch, + ); + + await idb.createInode(inode1); + + // The second one must throw Exception('IDB Error') due to unique index on (parentId, name) + expect( + () => idb.createInode(inode2), + throwsA(isA().having((e) => e.toString(), 'message', contains('IDB Error'))), + ); + }); + + test('database open error triggers request.onerror', () async { + setMockIDBOpenShouldFail(true); + // Instantiate a new service so we call _init() again. + final newService = IdbInodeService(); + expect( + () => newService.getInode('some-id'), + throwsA(isA().having((e) => e.toString(), 'message', contains('Failed to open IDB'))), + ); + }); + + test('_ensureRootExists failure triggers open request catchError', () async { + setMockIDBGetShouldFail(true); + setMockIDBPutShouldFail(true); + final newService = IdbInodeService(); + expect( + () => newService.getInode('some-id'), + throwsA(isA().having((e) => e.toString(), 'message', contains('IDB Error'))), + ); + }); + + test('getChild catches request.onerror and returns null', () async { + final idb = fs.idb; + // Ensure DB ready + await idb.getInode(IdbInodeService.rootId); + + setMockIDBIndexGetShouldFail(true); + final child = await idb.getChild(IdbInodeService.rootId, 'any-name'); + expect(child, isNull); + }); + }); + + group('OpfsBlockStore Coverage', () { + test('writeBlob handles stream error and cleanup', () async { + final store = OpfsBlockStore(); + final stream = Stream>.error(Exception('Stream error')); + expect( + () => store.writeBlob(stream), + throwsA(isA().having((e) => e.toString(), 'message', contains('Stream error'))), + ); + }); + + test('readBlob rethrows exception on non-existent block', () async { + final store = OpfsBlockStore(); + final nonExistentBlockId = '00000000-0000-0000-0000-000000000000'; + expect( + () => store.readBlob(nonExistentBlockId).drain(), + throwsA(anything), + ); + }); + }); +} + +@JS('eval') +external JSAny? jsEval(String code); + +void injectMockJS() { + jsEval(''' + window.mockIDBOpenShouldFail = false; + window.mockIDBGetShouldFail = false; + window.mockIDBPutShouldFail = false; + window.mockIDBIndexGetShouldFail = false; + + if (!window.hasInjectedIDBMocks) { + window.hasInjectedIDBMocks = true; + + const originalOpen = IDBFactory.prototype.open; + IDBFactory.prototype.open = function(name, version) { + if (window.mockIDBOpenShouldFail) { + const mockRequest = { + set onsuccess(fn) { this._onsuccess = fn; }, + set onerror(fn) { this._onerror = fn; }, + set onupgradeneeded(fn) { this._onupgradeneeded = fn; }, + get target() { return this; } + }; + setTimeout(() => { + if (mockRequest._onerror) { + mockRequest._onerror({ target: mockRequest }); + } + }, 0); + return mockRequest; + } + return originalOpen.apply(this, arguments); + }; + + const originalGet = IDBObjectStore.prototype.get; + IDBObjectStore.prototype.get = function(key) { + if (window.mockIDBGetShouldFail) { + const mockRequest = { + set onsuccess(fn) { this._onsuccess = fn; }, + set onerror(fn) { this._onerror = fn; }, + get target() { return this; } + }; + setTimeout(() => { + if (mockRequest._onerror) { + mockRequest._onerror({ target: mockRequest }); + } + }, 0); + return mockRequest; + } + return originalGet.apply(this, arguments); + }; + + const originalPut = IDBObjectStore.prototype.put; + IDBObjectStore.prototype.put = function(value) { + if (window.mockIDBPutShouldFail) { + const mockRequest = { + set onsuccess(fn) { this._onsuccess = fn; }, + set onerror(fn) { this._onerror = fn; }, + get target() { return this; } + }; + setTimeout(() => { + if (mockRequest._onerror) { + mockRequest._onerror({ target: mockRequest }); + } + }, 0); + return mockRequest; + } + return originalPut.apply(this, arguments); + }; + + const originalIndexGet = IDBIndex.prototype.get; + IDBIndex.prototype.get = function(key) { + if (window.mockIDBIndexGetShouldFail) { + const mockRequest = { + set onsuccess(fn) { this._onsuccess = fn; }, + set onerror(fn) { this._onerror = fn; }, + get target() { return this; } + }; + setTimeout(() => { + if (mockRequest._onerror) { + mockRequest._onerror({ target: mockRequest }); + } + }, 0); + return mockRequest; + } + return originalIndexGet.apply(this, arguments); + }; + } + '''); +} + +void setMockIDBOpenShouldFail(bool value) { + jsEval('window.mockIDBOpenShouldFail = $value;'); +} +void setMockIDBGetShouldFail(bool value) { + jsEval('window.mockIDBGetShouldFail = $value;'); +} +void setMockIDBPutShouldFail(bool value) { + jsEval('window.mockIDBPutShouldFail = $value;'); +} +void setMockIDBIndexGetShouldFail(bool value) { + jsEval('window.mockIDBIndexGetShouldFail = $value;'); }