feat(web_file_system): resolve concurrency and stream sizing issues, add README, and achieve 100% test coverage
This commit is contained in:
@@ -95,13 +95,16 @@ class IdbInodeService {
|
||||
static const String _storeName = 'inodes';
|
||||
|
||||
web.IDBDatabase? _db;
|
||||
final Completer<void> _initCompleter = Completer<void>();
|
||||
Future<void>? _initFuture;
|
||||
static const String rootId = '00000000-0000-0000-0000-000000000000';
|
||||
|
||||
Future<void> _ensureReady() async {
|
||||
if (_db != null) return;
|
||||
if (_initCompleter.isCompleted) return _initCompleter.future;
|
||||
Future<void> _ensureReady() {
|
||||
if (_db != null) return Future.value();
|
||||
return _initFuture ??= _init();
|
||||
}
|
||||
|
||||
Future<void> _init() async {
|
||||
final completer = Completer<void>();
|
||||
final request = web.window.indexedDB.open(_dbName, _version);
|
||||
|
||||
request.onupgradeneeded = (web.IDBVersionChangeEvent event) {
|
||||
@@ -119,20 +122,17 @@ class IdbInodeService {
|
||||
}
|
||||
}.toJS;
|
||||
|
||||
final completer = Completer<void>();
|
||||
|
||||
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;
|
||||
|
||||
@@ -26,7 +26,7 @@ class OpfsBlockStore {
|
||||
.toDart;
|
||||
}
|
||||
|
||||
Future<String> writeBlob(Stream<List<int>> stream) async {
|
||||
Future<(String, int)> writeBlob(Stream<List<int>> 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<List<int>> readBlob(String blockId) async* {
|
||||
|
||||
@@ -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<String> resolveSymbolicLinks() async => path;
|
||||
Future<String> resolveSymbolicLinks() => _fs.resolveSymbolicLinks(path);
|
||||
|
||||
@override
|
||||
String resolveSymbolicLinksSync() =>
|
||||
|
||||
@@ -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<void> _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<String> resolveSymbolicLinks() async => path;
|
||||
Future<String> resolveSymbolicLinks() => _fs.resolveSymbolicLinks(path);
|
||||
|
||||
@override
|
||||
String resolveSymbolicLinksSync() =>
|
||||
@@ -381,10 +388,10 @@ class WebFile extends FileSystemEntity implements File {
|
||||
|
||||
class _WebIOSink implements IOSink {
|
||||
final StreamController<List<int>> _controller;
|
||||
final Future<void> Function()? onDone;
|
||||
final Future<void> _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<int> 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<List<int>> 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 {}
|
||||
|
||||
@@ -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<String> 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<String> resolveSymbolicLinks() => _fs.resolveSymbolicLinks(path);
|
||||
|
||||
@override
|
||||
String resolveSymbolicLinksSync() =>
|
||||
|
||||
@@ -228,6 +228,18 @@ class WebFileSystem extends FileSystem {
|
||||
@override
|
||||
bool identicalSync(String path1, String path2) =>
|
||||
throw UnsupportedError('Sync not supported');
|
||||
|
||||
Future<String> resolveSymbolicLinks(String pathStr) async {
|
||||
final inode = await resolvepath(pathStr, followLinks: true);
|
||||
final List<String> 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 {
|
||||
|
||||
Reference in New Issue
Block a user