From 36c426bf084e8646ba10afb155dc6f13121ee1a7 Mon Sep 17 00:00:00 2001 From: Okiki Ojo Date: Thu, 13 Aug 2026 02:53:45 -0400 Subject: [PATCH] feat: implement unpartitioned OPFS support with Storage Access API - Add iframe.ts to handle requests for unpartitioned OPFS root. - Introduce lock.ts for managing file operation locks using Web Locks or local FIFO locks. - Create path.ts for path normalization and validation in the virtual filesystem. - Implement probe.ts to diagnose OPFS capabilities and storage availability. - Define schema.ts for type validation of paths, entries, and error codes. - Add stream.ts for handling byte streams and data writing. - Introduce sync.ts for synchronous file operations and management. Signed-off-by: Okiki Ojo --- src/context.ts | 52 +++ src/error.ts | 102 +++++ src/filesystem.ts | 1056 +++++++++++++++++++++++++++++++++++++++++++++ src/handle.ts | 381 ++++++++++++++++ src/iframe.ts | 76 ++++ src/lock.ts | 287 ++++++++++++ src/path.ts | 93 ++++ src/probe.ts | 220 ++++++++++ src/schema.ts | 193 +++++++++ src/stream.ts | 194 +++++++++ src/sync.ts | 153 +++++++ 11 files changed, 2807 insertions(+) create mode 100644 src/context.ts create mode 100644 src/error.ts create mode 100644 src/filesystem.ts create mode 100644 src/handle.ts create mode 100644 src/iframe.ts create mode 100644 src/lock.ts create mode 100644 src/path.ts create mode 100644 src/probe.ts create mode 100644 src/schema.ts create mode 100644 src/stream.ts create mode 100644 src/sync.ts diff --git a/src/context.ts b/src/context.ts new file mode 100644 index 0000000..a8daf41 --- /dev/null +++ b/src/context.ts @@ -0,0 +1,52 @@ +import type { OpfsContextType } from "./schema.ts"; + +/** + * Small structural view of browser globals used for context classification. + * + * The library intentionally avoids browser-name checks. Runtime placement is + * inferred from the globals that define Window and Worker execution models. + */ +interface BrowserGlobalType { + /** Window document marker. */ + readonly document?: object; + /** ServiceWorker registration marker. */ + readonly registration?: unknown; + /** ServiceWorker clients marker. */ + readonly clients?: unknown; + /** SharedWorker connection-handler marker. */ + readonly onconnect?: unknown; + /** Classic Worker script-loader marker. */ + readonly importScripts?: unknown; + /** Runtime constructor name used when the concrete worker global exists. */ + readonly constructor?: { readonly name?: string }; +} + +/** + * Returns the browser execution context that owns the current call. + * + * The result describes runtime placement. It does not imply that OPFS is + * available. Call `probeOpfs()` when availability, storage partitioning, or + * synchronous-access support matters. + * + * @example Detect whether synchronous OPFS can be attempted. + * ```ts + * import { getOpfsContext } from "@okikio/opfs"; + * + * if (getOpfsContext() === "dedicated-worker") { + * // A DedicatedWorker can expose createSyncAccessHandle(). + * } + * ``` + */ +export function getOpfsContext(value: BrowserGlobalType = globalThis as BrowserGlobalType): OpfsContextType { + if (typeof value.document === "object") return "window"; + + const constructorName = value.constructor?.name; + if (constructorName === "DedicatedWorkerGlobalScope") return "dedicated-worker"; + if (constructorName === "SharedWorkerGlobalScope") return "shared-worker"; + if (constructorName === "ServiceWorkerGlobalScope") return "service-worker"; + + if ("registration" in value && "clients" in value) return "service-worker"; + if ("onconnect" in value && typeof value.importScripts === "function") return "shared-worker"; + if (typeof value.importScripts === "function") return "worker"; + return "unknown"; +} diff --git a/src/error.ts b/src/error.ts new file mode 100644 index 0000000..cd07532 --- /dev/null +++ b/src/error.ts @@ -0,0 +1,102 @@ +import type { ErrorCodeType } from "./schema.ts"; + +/** + * Error returned by the high-level filesystem and first-party adapters. + * + * `code` is stable package vocabulary. `operation` identifies the public or + * adapter operation. `path` identifies the affected virtual path when one + * exists. The original runtime failure remains available through `cause`. + */ +export class FileSystemError extends Error { + /** Stable category for programmatic branching. */ + readonly code: ErrorCodeType; + /** Operation that failed, such as `read`, `write`, or `move`. */ + readonly operation: string; + /** Canonical virtual path associated with the failure. */ + readonly path?: string; + /** Original runtime or adapter failure. */ + override readonly cause?: unknown; + + /** Creates one normalized filesystem failure. */ + constructor(code: ErrorCodeType, operation: string, path: string | undefined, message: string, cause?: unknown) { + super(message); + this.name = "FileSystemError"; + this.code = code; + this.operation = operation; + if (path !== undefined) this.path = path; + if (cause !== undefined) this.cause = cause; + } +} + +/** Returns an Error-like name without relying on same-realm `instanceof`. */ +export function getErrorName(error: unknown): string { + if (typeof error === "object" && error !== null && "name" in error) { + const name = Reflect.get(error, "name"); + if (typeof name === "string") return name; + } + return "Error"; +} + + +/** Returns a runtime error code such as `ENOENT` when one is exposed. */ +function getRuntimeErrorCode(error: unknown): string | undefined { + if (typeof error !== "object" || error === null || !("code" in error)) return undefined; + const code = Reflect.get(error, "code"); + return typeof code === "string" ? code : undefined; +} + +/** Returns an Error-like message without relying on same-realm `instanceof`. */ +export function getErrorMessage(error: unknown): string { + if (typeof error === "object" && error !== null && "message" in error) { + const message = Reflect.get(error, "message"); + if (typeof message === "string") return message; + } + return String(error); +} + +/** + * Maps common browser and server filesystem failures into {@link FileSystemError}. + * + * Adapters can call this function for native errors. Database adapters should + * wrap provider-specific failures with the most precise category they can prove. + */ +export function toFileSystemError(error: unknown, operation: string, path?: string): FileSystemError { + if (error instanceof FileSystemError) return error; + + const name = getErrorName(error); + const runtimeCode = getRuntimeErrorCode(error); + let code: FileSystemError["code"] = "unknown"; + switch (runtimeCode ?? name) { + case "AbortError": code = "aborted"; break; + case "NotFoundError": + case "ENOENT": code = "not-found"; break; + case "AlreadyExists": + case "EEXIST": code = "already-exists"; break; + case "TypeMismatchError": + case "ENOTDIR": + case "EISDIR": code = "type-mismatch"; break; + case "NoModificationAllowedError": + case "EBUSY": code = "locked"; break; + case "QuotaExceededError": + case "ENOSPC": code = "quota-exceeded"; break; + case "NotAllowedError": + case "SecurityError": + case "EACCES": + case "EPERM": code = "permission-denied"; break; + case "NotSupportedError": + case "ENOTSUP": code = "not-supported"; break; + case "InvalidModificationError": + case "InvalidStateError": code = "invalid-operation"; break; + case "UnknownError": code = operation === "open" ? "unavailable" : "unknown"; break; + } + + const location = path === undefined ? "" : ` '${path}'`; + return new FileSystemError(code, operation, path, `${operation} failed${location}: ${getErrorMessage(error)}`, error); +} + +/** Throws a stable cancellation failure when the supplied signal is aborted. */ +export function throwIfAborted(signal: AbortSignal | undefined, operation: string, path?: string): void { + if (!signal?.aborted) return; + const suffix = path === undefined ? "" : ` for '${path}'`; + throw new FileSystemError("aborted", operation, path, `${operation} was aborted${suffix}.`, signal.reason); +} diff --git a/src/filesystem.ts b/src/filesystem.ts new file mode 100644 index 0000000..7d4ad49 --- /dev/null +++ b/src/filesystem.ts @@ -0,0 +1,1056 @@ +import type { + AdapterDirectoryEntryType, + AdapterSignalOptionsType, + AdapterType, + FileSystemOptionsType, +} from "./adapter/definition.ts"; +import { FileSystemError, throwIfAborted, toFileSystemError } from "./error.ts"; +import { MutationLocks } from "./lock.ts"; +import { basename, dirname, isAncestorPath, joinPath, normalizePath, ROOT_PATH, splitPath } from "./path.ts"; +import { CoordinationModeSchema, EntryKindSchema, WriteModeSchema } from "./schema.ts"; +import type { EntryKindType, WriteModeType } from "./schema.ts"; +import { + collectBytes, + isAsyncIterable, + isReadableStream, + toBytes, + toByteStream, + type WriteDataType, + withAbortSignal, +} from "./stream.ts"; +import { ManagedSyncFile, type SyncFileType } from "./sync.ts"; +import { DirectoryHandle, FileHandle, type DirectoryHandleType, type FileHandleType } from "./handle.ts"; + +/** Default lock namespace used when the caller does not provide one. */ +const DEFAULT_LOCK_PREFIX = "@okikio/opfs"; +/** Default maximum materialization for a stream sent to a value-oriented adapter. */ +const DEFAULT_BUFFER_LIMIT = 64 * 1024 * 1024; +/** Default concurrent file-copy count for recursive copy. */ +const DEFAULT_COPY_CONCURRENCY = 4; + +/** Options for operations that support cancellation. */ +export interface SignalOptionsType { + /** Stops work that has not committed yet. */ + readonly signal?: AbortSignal; +} + +/** Options for opening or creating a directory through the facade. */ +export interface DirectoryOptionsType extends SignalOptionsType { + /** Creates the final directory when it does not exist. */ + readonly create?: boolean; + /** Creates missing parent directories as well. */ + readonly recursive?: boolean; +} + +/** Options for opening or creating a file through the facade. */ +export interface FileOptionsType extends SignalOptionsType { + /** Creates the file when it does not exist. */ + readonly create?: boolean; + /** Creates missing parent directories before the file. */ + readonly parents?: boolean; +} + +/** Options for reading a complete file or byte range. */ +export interface ReadOptionsType extends SignalOptionsType { + /** Zero-based byte offset. */ + readonly at?: number; + /** Maximum bytes to return. */ + readonly length?: number; +} + +/** Options for decoding text after a byte read. */ +export interface ReadTextOptionsType extends ReadOptionsType { + /** Encoding passed to TextDecoder. Defaults to UTF-8. */ + readonly encoding?: string; +} + +/** Options for writing a file. */ +export interface WriteOptionsType extends SignalOptionsType { + /** Relationship between new and existing bytes. Defaults to `replace`. */ + readonly mode?: WriteModeType; + /** Zero-based offset used by `update`. */ + readonly at?: number; + /** Truncates at the final write cursor. */ + readonly truncate?: boolean; + /** Creates missing parent directories. */ + readonly parents?: boolean; + /** Media type retained by adapters that persist metadata. */ + readonly mediaType?: string; +} + +/** Options for advisory entry existence checks. */ +export interface ExistsOptionsType extends SignalOptionsType { + /** Requires a specific observed entry kind. */ + readonly kind?: EntryKindType; +} + +/** Options for creating directories. */ +export interface MakeDirectoryOptionsType extends SignalOptionsType { + /** Creates all missing parent directories. */ + readonly recursive?: boolean; +} + +/** Options for lazy recursive traversal. */ +export interface WalkOptionsType extends SignalOptionsType { + /** Maximum depth below the requested root. `0` yields only the root when included. */ + readonly maxDepth?: number; + /** Includes the requested root. Defaults to false. */ + readonly includeRoot?: boolean; + /** Includes file entries. Defaults to true. */ + readonly includeFiles?: boolean; + /** Includes directory entries. Defaults to true. */ + readonly includeDirectories?: boolean; +} + +/** Options for recursive copy and move. */ +export interface CopyOptionsType extends SignalOptionsType { + /** Replaces an existing destination. Defaults to false. */ + readonly overwrite?: boolean; + /** Maximum file bodies copied concurrently. Defaults to four. */ + readonly concurrency?: number; +} + +/** Move has the same policy inputs as recursive copy. */ +export type MoveOptionsType = CopyOptionsType; + +/** Options for removing an entry. */ +export interface RemoveOptionsType extends SignalOptionsType { + /** Removes directory descendants before the directory. */ + readonly recursive?: boolean; +} + +/** Options for removing every child of one directory. */ +export interface EmptyDirectoryOptionsType extends SignalOptionsType { + /** Maximum direct-child removals started concurrently. Defaults to four. */ + readonly concurrency?: number; +} + +/** Options for opening synchronous random access. */ +export interface OpenSyncFileOptionsType extends SignalOptionsType { + /** Creates the file when it does not exist. */ + readonly create?: boolean; + /** Creates missing parent directories when creating the file. */ + readonly parents?: boolean; +} + +/** One direct child returned by {@link FileSystemType.readDir}. */ +export interface DirectoryEntryType { + /** Canonical virtual path. */ + readonly path: string; + /** Final entry name. */ + readonly name: string; + /** File or directory discriminator. */ + readonly kind: EntryKindType; + /** OPFS-compatible facade object for code that prefers handle APIs. */ + readonly handle: FileHandleType | DirectoryHandleType; +} + +/** One recursive entry returned by {@link FileSystemType.walk}. */ +export interface WalkEntryType extends DirectoryEntryType { + /** Depth below the requested walk root. */ + readonly depth: number; +} + +/** Portable file metadata returned by {@link FileSystemType.stat}. */ +export interface FileStatType { + /** Discriminator for file metadata. */ + readonly kind: "file"; + /** Canonical virtual path. */ + readonly path: string; + /** Final file name. */ + readonly name: string; + /** File byte length. */ + readonly size: number; + /** Last-modified Unix epoch milliseconds. */ + readonly lastModified: number; + /** Media type, or an empty string when unknown. */ + readonly mediaType: string; +} + +/** Portable directory metadata returned by {@link FileSystemType.stat}. */ +export interface DirectoryStatType { + /** Discriminator for directory metadata. */ + readonly kind: "directory"; + /** Canonical virtual path. */ + readonly path: string; + /** Final directory name. Root uses an empty name. */ + readonly name: string; + /** Last-modified Unix epoch milliseconds when observable. */ + readonly lastModified?: number; +} + +/** Portable file or directory metadata. */ +export type StatType = FileStatType | DirectoryStatType; + +/** + * Adapter-independent filesystem facade. + * + * High-level calls use canonical virtual paths. `root`, `getFileHandle()`, and + * `getDirectoryHandle()` provide File System API-shaped objects on top of the + * same adapter. This lets browser-oriented code run against OPFS, Deno, Bun, + * Node, RxDB, unstorage, db0, Drizzle, or a custom adapter. + * + * The facade owns mutation locks. It does not own the adapter unless + * `disposeAdapter` was enabled at creation time. + */ +export interface FileSystemType extends AsyncDisposable { + /** Adapter selected for this filesystem. */ + /** Persistence adapter that implements this facade's backend operations. */ + readonly adapter: AdapterType; + /** OPFS-compatible root directory facade. */ + /** Stable OPFS-shaped handle for the virtual root directory. */ + readonly root: DirectoryHandleType; + /** Maximum stream bytes materialized for adapters without native streaming writes. */ + /** Hard limit used before a value-oriented adapter may materialize streamed input. */ + readonly maxBufferedWriteBytes: number; + + /** Opens or optionally creates a directory. */ + getDirectoryHandle(path: string, options?: DirectoryOptionsType): Promise; + /** Opens or optionally creates a file. */ + getFileHandle(path: string, options?: FileOptionsType): Promise; + /** Returns a Web File snapshot for one file. */ + getFile(path: string, options?: SignalOptionsType): Promise; + /** Returns portable entry metadata. */ + stat(path: string, options?: SignalOptionsType): Promise; + /** Performs an advisory existence check that can race later operations. */ + exists(path: string, options?: ExistsOptionsType): Promise; + /** Creates one directory, or its missing parents when recursive is true. */ + mkdir(path: string, options?: MakeDirectoryOptionsType): Promise; + /** Ensures a complete directory path exists. */ + ensureDir(path: string, options?: SignalOptionsType): Promise; + /** Ensures a file exists without truncating an existing file. */ + ensureFile(path: string, options?: SignalOptionsType): Promise; + /** Lazily iterates direct children. */ + readDir(path?: string, options?: SignalOptionsType): AsyncIterableIterator; + /** Lazily traverses a directory tree. */ + walk(path?: string, options?: WalkOptionsType): AsyncIterableIterator; + /** Materializes a complete file or requested byte range. */ + readFile(path: string, options?: ReadOptionsType): Promise; + /** Reads and decodes text. */ + readText(path: string, options?: ReadTextOptionsType): Promise; + /** Opens an abortable byte stream. Non-streaming adapters return one buffered chunk. */ + openReadStream(path: string, options?: ReadOptionsType): Promise>; + /** Writes materialized, streamed, or async-iterable data. */ + writeFile(path: string, data: WriteDataType, options?: WriteOptionsType): Promise; + /** Copies a file or directory tree. */ + copy(source: string, destination: string, options?: CopyOptionsType): Promise; + /** Uses a native adapter move when available, otherwise copy-then-remove. */ + move(source: string, destination: string, options?: MoveOptionsType): Promise; + /** Removes one entry and optionally its descendants. Missing paths are success. */ + remove(path: string, options?: RemoveOptionsType): Promise; + /** Removes every direct or nested child while preserving the requested directory. */ + emptyDir(path?: string, options?: EmptyDirectoryOptionsType): Promise; + /** Opens synchronous random access when the selected adapter supports it. */ + openSyncFile(path: string, options?: OpenSyncFileOptionsType): Promise; + /** Releases an adapter only when ownership was transferred at creation. */ + close(): Promise; +} + +/** Validates byte offsets, lengths, and finite walk depths before an adapter sees them. */ +function assertNonNegativeInteger(value: number, name: string): void { + if (!Number.isSafeInteger(value) || value < 0) throw new RangeError(`${name} must be a non-negative safe integer.`); +} + +/** Resolves and validates bounded copy/removal concurrency. */ +function getConcurrency(value: number | undefined): number { + const concurrency = value ?? DEFAULT_COPY_CONCURRENCY; + if (!Number.isSafeInteger(concurrency) || concurrency < 1) { + throw new RangeError("concurrency must be a positive safe integer."); + } + return concurrency; +} + +/** Resolves the maximum safe materialization for value-oriented storage adapters. */ +function getBufferLimit(value: number | undefined): number { + const limit = value ?? DEFAULT_BUFFER_LIMIT; + if (!Number.isSafeInteger(limit) || limit < 1) { + throw new RangeError("maxBufferedWriteBytes must be a positive safe integer."); + } + return limit; +} + +/** Projects a facade cancellation signal into the adapter operation contract. */ +function getAdapterSignalOptions(signal: AbortSignal | undefined): AdapterSignalOptionsType { + return signal === undefined ? {} : { signal }; +} + +/** + * Waits for every already-started mutation before propagating a failure. + * + * Recursive copy and clear must not release their tree lock while sibling writes + * are still running, even when one sibling has already failed. + */ +async function settleConcurrent(active: Set>, failures: unknown[], prior?: unknown): Promise { + await Promise.allSettled([...active]); + if (prior !== undefined) throw prior; + if (failures.length > 0) throw failures[0]; +} + +/** Tracks one bounded child mutation and records its first failure without an unhandled rejection. */ +function trackConcurrent(active: Set>, failures: unknown[], operation: Promise): void { + let tracked!: Promise; + tracked = operation.catch((error) => { + failures.push(error); + throw error; + }).finally(() => active.delete(tracked)); + active.add(tracked); + void tracked.catch(() => undefined); +} + +/** + * Creates missing directory components from root to leaf. + * + * The walk checks each component before creation so a file in the middle of the + * path produces a precise type mismatch instead of a backend-specific failure. + */ +async function ensureParents(adapter: AdapterType, path: string, signal?: AbortSignal): Promise { + let current = ROOT_PATH; + for (const part of splitPath(path)) { + current = joinPath(current, part); + throwIfAborted(signal, "mkdir", current); + const stat = await adapter.stat(current, getAdapterSignalOptions(signal)); + if (stat?.kind === "file") { + throw new FileSystemError( + "type-mismatch", + "mkdir", + current, + `Cannot create directory '${current}' because a file exists at that path.`, + ); + } + if (stat === null) await adapter.createDir(current, getAdapterSignalOptions(signal)); + } +} + +/** Projects one adapter child into path metadata plus an OPFS-shaped facade handle. */ +function makeDirectoryEntry( + fileSystem: FileSystemType, + parent: string, + entry: AdapterDirectoryEntryType, +): DirectoryEntryType { + const path = joinPath(parent, entry.name); + return { + path, + name: entry.name, + kind: entry.kind, + handle: entry.kind === "file" ? new FileHandle(fileSystem, path) : new DirectoryHandle(fileSystem, path), + }; +} + +/** + * Concrete facade that owns coordination and delegates persistence to one adapter. + * + * It is intentionally not exported as a class. Consumers depend on + * {@link FileSystemType} and create instances through {@link createFileSystem}, + * which keeps adapter selection and lifecycle policy explicit. + */ +class FileSystemFacade implements FileSystemType { + /** Persistence adapter that implements this facade's backend operations. */ + readonly adapter: AdapterType; + /** Stable OPFS-shaped handle for the virtual root directory. */ + readonly root: DirectoryHandleType; + /** Hard limit used before a value-oriented adapter may materialize streamed input. */ + readonly maxBufferedWriteBytes: number; + /** Coordinates file mutations and structural tree changes for this facade. */ + readonly #locks: MutationLocks; + /** Records whether facade disposal also transfers disposal to the adapter. */ + readonly #disposeAdapter: boolean; + /** Terminal facade state. A closed facade never reopens. */ + #closed = false; + + constructor(adapter: AdapterType, options: FileSystemOptionsType) { + this.adapter = adapter; + this.maxBufferedWriteBytes = getBufferLimit(options.maxBufferedWriteBytes); + this.#locks = new MutationLocks( + CoordinationModeSchema.parse(options.coordination ?? "auto"), + options.lockPrefix ?? DEFAULT_LOCK_PREFIX, + ); + this.#disposeAdapter = options.disposeAdapter ?? false; + this.root = new DirectoryHandle(this, ROOT_PATH); + } + + /** Rejects all operations after the caller closes this facade. */ + #assertOpen(): void { + if (this.#closed) { + throw new FileSystemError( + "invalid-operation", + "filesystem", + undefined, + "Filesystem is already closed.", + ); + } + } + + /** + * Opens or creates a directory after validating parent and entry-kind invariants. + * + * Creation holds the structural tree lock so another facade mutation cannot + * replace an ancestor while this method creates the requested directory. + */ + async getDirectoryHandle(path: string, options: DirectoryOptionsType = {}): Promise { + this.#assertOpen(); + const normalized = normalizePath(path); + if (normalized === ROOT_PATH) return this.root; + throwIfAborted(options.signal, "get-directory", normalized); + + if (options.create || options.recursive) { + const lock = await this.#locks.acquireTree(options.signal); + try { + if (options.recursive) await ensureParents(this.adapter, normalized, options.signal); + else { + const parent = await this.adapter.stat(dirname(normalized), getAdapterSignalOptions(options.signal)); + if (parent?.kind !== "directory") { + throw new FileSystemError( + "not-found", + "get-directory", + normalized, + `Parent directory '${dirname(normalized)}' does not exist.`, + ); + } + const existing = await this.adapter.stat(normalized, getAdapterSignalOptions(options.signal)); + if (existing?.kind === "file") { + throw new FileSystemError( + "type-mismatch", + "get-directory", + normalized, + `'${normalized}' is a file.`, + ); + } + if (existing === null) await this.adapter.createDir(normalized, getAdapterSignalOptions(options.signal)); + } + } finally { + lock.release(); + } + return new DirectoryHandle(this, normalized); + } + + const stat = await this.adapter.stat(normalized, getAdapterSignalOptions(options.signal)); + if (stat === null) { + throw new FileSystemError( + "not-found", + "get-directory", + normalized, + `Directory '${normalized}' does not exist.`, + ); + } + if (stat.kind !== "directory") { + throw new FileSystemError("type-mismatch", "get-directory", normalized, `'${normalized}' is a file.`); + } + return new DirectoryHandle(this, normalized); + } + + /** + * Opens or creates a file and returns an OPFS-shaped facade handle. + * + * File creation takes the file mutation lock and rechecks storage after the + * lock is acquired so two creators cannot both assume the path is missing. + */ + async getFileHandle(path: string, options: FileOptionsType = {}): Promise { + this.#assertOpen(); + const normalized = normalizePath(path); + if (normalized === ROOT_PATH) { + throw new FileSystemError("type-mismatch", "get-file", normalized, "The virtual root is a directory."); + } + throwIfAborted(options.signal, "get-file", normalized); + + let stat = await this.adapter.stat(normalized, getAdapterSignalOptions(options.signal)); + if (stat?.kind === "directory") { + throw new FileSystemError("type-mismatch", "get-file", normalized, `'${normalized}' is a directory.`); + } + if (stat === null && options.create) { + const lock = await this.#locks.acquireFile(normalized, options.signal); + try { + if (options.parents) await ensureParents(this.adapter, dirname(normalized), options.signal); + const parent = await this.adapter.stat(dirname(normalized), getAdapterSignalOptions(options.signal)); + if (parent?.kind !== "directory") { + throw new FileSystemError( + "not-found", + "get-file", + normalized, + `Parent directory '${dirname(normalized)}' does not exist.`, + ); + } + stat = await this.adapter.stat(normalized, getAdapterSignalOptions(options.signal)); + if (stat === null) { + await this.adapter.writeFile(normalized, new Uint8Array(), { + mode: "replace", + ...getAdapterSignalOptions(options.signal), + }); + } + } finally { + lock.release(); + } + } else if (stat === null) { + throw new FileSystemError("not-found", "get-file", normalized, `File '${normalized}' does not exist.`); + } + return new FileHandle(this, normalized); + } + + /** Returns a fresh Web `File` snapshot built from the adapter's current bytes and metadata. */ + async getFile(path: string, options: SignalOptionsType = {}): Promise { + const normalized = normalizePath(path); + const stat = await this.stat(normalized, options); + if (stat.kind !== "file") { + throw new FileSystemError("type-mismatch", "get-file", normalized, `'${normalized}' is a directory.`); + } + const bytes = await this.readFile(normalized, options); + return new File([new Uint8Array(bytes)], stat.name, { lastModified: stat.lastModified, type: stat.mediaType }); + } + + /** Returns normalized file or directory metadata and rejects a missing path. */ + async stat(path: string, options: SignalOptionsType = {}): Promise { + this.#assertOpen(); + const normalized = normalizePath(path); + throwIfAborted(options.signal, "stat", normalized); + if (normalized === ROOT_PATH) return { kind: "directory", path: ROOT_PATH, name: "" }; + + try { + const stat = await this.adapter.stat(normalized, getAdapterSignalOptions(options.signal)); + if (stat === null) { + throw new FileSystemError("not-found", "stat", normalized, `Entry '${normalized}' does not exist.`); + } + if (stat.kind === "file") { + return { + kind: "file", + path: normalized, + name: basename(normalized), + size: stat.size, + lastModified: stat.lastModified, + mediaType: stat.mediaType, + }; + } + const output: DirectoryStatType = { kind: "directory", path: normalized, name: basename(normalized) }; + if (stat.lastModified !== undefined) return { ...output, lastModified: stat.lastModified }; + return output; + } catch (error) { + throw toFileSystemError(error, "stat", normalized); + } + } + + /** + * Performs an advisory existence check. + * + * Callers must not use this result as a write precondition because another + * context can mutate the path before the next operation starts. + */ + async exists(path: string, options: ExistsOptionsType = {}): Promise { + this.#assertOpen(); + const normalized = normalizePath(path); + throwIfAborted(options.signal, "exists", normalized); + const kind = options.kind === undefined ? undefined : EntryKindSchema.parse(options.kind); + if (normalized === ROOT_PATH) return kind === undefined || kind === "directory"; + try { + const stat = await this.adapter.stat(normalized, getAdapterSignalOptions(options.signal)); + return stat !== null && (kind === undefined || stat.kind === kind); + } catch (error) { + const normalizedError = toFileSystemError(error, "exists", normalized); + if (normalizedError.code === "not-found") return false; + throw normalizedError; + } + } + + /** Creates one directory, optionally creating missing ancestors under the tree lock. */ + async mkdir(path: string, options: MakeDirectoryOptionsType = {}): Promise { + this.#assertOpen(); + const normalized = normalizePath(path); + if (normalized === ROOT_PATH) return; + const lock = await this.#locks.acquireTree(options.signal); + try { + if (options.recursive) { + await ensureParents(this.adapter, normalized, options.signal); + return; + } + const existing = await this.adapter.stat(normalized, getAdapterSignalOptions(options.signal)); + if (existing !== null) { + throw new FileSystemError("already-exists", "mkdir", normalized, `Entry '${normalized}' already exists.`); + } + const parent = await this.adapter.stat(dirname(normalized), getAdapterSignalOptions(options.signal)); + if (parent?.kind !== "directory") { + throw new FileSystemError( + "not-found", + "mkdir", + normalized, + `Parent directory '${dirname(normalized)}' does not exist.`, + ); + } + await this.adapter.createDir(normalized, getAdapterSignalOptions(options.signal)); + } finally { + lock.release(); + } + } + + /** Ensures every directory segment exists without replacing a file at any segment. */ + async ensureDir(path: string, options: SignalOptionsType = {}): Promise { + this.#assertOpen(); + const lock = await this.#locks.acquireTree(options.signal); + try { + await ensureParents(this.adapter, normalizePath(path), options.signal); + } finally { + lock.release(); + } + } + + /** Creates an empty file only when the path is missing and never truncates an existing file. */ + async ensureFile(path: string, options: SignalOptionsType = {}): Promise { + await this.getFileHandle(path, { create: true, parents: true, ...getAdapterSignalOptions(options.signal) }); + } + + /** + * Lazily yields direct children from the adapter. + * + * The iterator does not collect the full directory, which keeps memory use + * proportional to the adapter's own iteration strategy. + */ + async *readDir(path = ROOT_PATH, options: SignalOptionsType = {}): AsyncIterableIterator { + this.#assertOpen(); + const normalized = normalizePath(path); + const stat = await this.stat(normalized, options); + if (stat.kind !== "directory") { + throw new FileSystemError("type-mismatch", "read-dir", normalized, `'${normalized}' is a file.`); + } + + try { + for await (const entry of this.adapter.readDir(normalized, getAdapterSignalOptions(options.signal))) { + throwIfAborted(options.signal, "read-dir", normalized); + yield makeDirectoryEntry(this, normalized, entry); + } + } catch (error) { + throw toFileSystemError(error, "read-dir", normalized); + } + } + + /** Lazily traverses the tree while applying depth and entry-kind filters during traversal. */ + async *walk(path = ROOT_PATH, options: WalkOptionsType = {}): AsyncIterableIterator { + this.#assertOpen(); + const root = normalizePath(path); + const maxDepth = options.maxDepth ?? Number.POSITIVE_INFINITY; + if (maxDepth < 0) return; + if (Number.isFinite(maxDepth)) assertNonNegativeInteger(maxDepth, "maxDepth"); + const includeFiles = options.includeFiles ?? true; + const includeDirectories = options.includeDirectories ?? true; + + const rootStat = await this.stat(root, options); + if (options.includeRoot) { + const include = rootStat.kind === "file" ? includeFiles : includeDirectories; + if (include) { + yield { + path: root, + name: basename(root), + kind: rootStat.kind, + handle: rootStat.kind === "file" ? new FileHandle(this, root) : new DirectoryHandle(this, root), + depth: 0, + }; + } + } + if (rootStat.kind === "file" || maxDepth === 0) return; + + const visit = async function* ( + fileSystem: FileSystemType, + directory: string, + depth: number, + ): AsyncIterableIterator { + for await (const entry of fileSystem.readDir(directory, options)) { + const nextDepth = depth + 1; + const include = entry.kind === "file" ? includeFiles : includeDirectories; + if (include) yield { ...entry, depth: nextDepth }; + if (entry.kind === "directory" && nextDepth < maxDepth) yield* visit(fileSystem, entry.path, nextDepth); + } + }; + yield* visit(this, root, 0); + } + + /** Materializes a file or requested byte range after validating offsets and cancellation. */ + async readFile(path: string, options: ReadOptionsType = {}): Promise { + this.#assertOpen(); + const normalized = normalizePath(path); + if (options.at !== undefined) assertNonNegativeInteger(options.at, "at"); + if (options.length !== undefined) assertNonNegativeInteger(options.length, "length"); + throwIfAborted(options.signal, "read", normalized); + try { + return await this.adapter.readFile(normalized, { + ...(options.at === undefined ? {} : { at: options.at }), + ...(options.length === undefined ? {} : { length: options.length }), + ...getAdapterSignalOptions(options.signal), + }); + } catch (error) { + throw toFileSystemError(error, "read", normalized); + } + } + + /** Reads bytes and decodes them with the requested `TextDecoder` encoding. */ + async readText(path: string, options: ReadTextOptionsType = {}): Promise { + const bytes = await this.readFile(path, options); + return new TextDecoder(options.encoding).decode(bytes); + } + + /** + * Opens an abortable byte stream. + * + * Streaming adapters stay incremental. Value-oriented adapters expose one + * materialized chunk because they cannot supply a native byte stream. + */ + async openReadStream(path: string, options: ReadOptionsType = {}): Promise> { + this.#assertOpen(); + const normalized = normalizePath(path); + if (options.at !== undefined) assertNonNegativeInteger(options.at, "at"); + if (options.length !== undefined) assertNonNegativeInteger(options.length, "length"); + const adapterOptions = { + ...(options.at === undefined ? {} : { at: options.at }), + ...(options.length === undefined ? {} : { length: options.length }), + ...getAdapterSignalOptions(options.signal), + }; + try { + const source = this.adapter.capabilities.streamRead && this.adapter.openReadStream !== undefined + ? await this.adapter.openReadStream(normalized, adapterOptions) + : new ReadableStream({ + start: async (controller) => { + controller.enqueue(await this.adapter.readFile(normalized, adapterOptions)); + controller.close(); + }, + }); + return withAbortSignal(source, options.signal, normalized); + } catch (error) { + throw toFileSystemError(error, "read", normalized); + } + } + + /** + * Writes bytes under the file mutation lock. + * + * Native streaming adapters receive streams directly. Other adapters must + * materialize them below `maxBufferedWriteBytes` or the operation fails. + */ + async writeFile(path: string, data: WriteDataType, options: WriteOptionsType = {}): Promise { + this.#assertOpen(); + const normalized = normalizePath(path); + if (normalized === ROOT_PATH) { + throw new FileSystemError("type-mismatch", "write", normalized, "The virtual root is a directory."); + } + const mode = WriteModeSchema.parse(options.mode ?? "replace"); + if (options.at !== undefined) assertNonNegativeInteger(options.at, "at"); + const lock = await this.#locks.acquireFile(normalized, options.signal); + + try { + if (options.parents) await ensureParents(this.adapter, dirname(normalized), options.signal); + const parent = await this.adapter.stat(dirname(normalized), getAdapterSignalOptions(options.signal)); + if (parent?.kind !== "directory") { + throw new FileSystemError( + "not-found", + "write", + normalized, + `Parent directory '${dirname(normalized)}' does not exist.`, + ); + } + const existing = await this.adapter.stat(normalized, getAdapterSignalOptions(options.signal)); + if (existing?.kind === "directory") { + throw new FileSystemError("type-mismatch", "write", normalized, `'${normalized}' is a directory.`); + } + + const adapterOptions = { + mode, + ...(options.at === undefined ? {} : { at: options.at }), + ...(options.truncate === undefined ? {} : { truncate: options.truncate }), + ...(options.mediaType === undefined ? {} : { mediaType: options.mediaType }), + ...getAdapterSignalOptions(options.signal), + }; + + const isStream = isReadableStream(data) || isAsyncIterable(data); + if (isStream && this.adapter.capabilities.streamWrite && this.adapter.writeStream !== undefined) { + await this.adapter.writeStream(normalized, toByteStream(data), adapterOptions); + } else if (isReadableStream(data) || isAsyncIterable(data)) { + const bytes = await collectBytes( + toByteStream(data), + this.maxBufferedWriteBytes, + options.signal, + "write", + normalized, + ); + await this.adapter.writeFile(normalized, bytes, adapterOptions); + } else { + await this.adapter.writeFile(normalized, await toBytes(data), adapterOptions); + } + } catch (error) { + throw toFileSystemError(error, "write", normalized); + } finally { + lock.release(); + } + } + + /** + * Copies a file or directory tree while holding the structural tree lock. + * + * Recursive file-body work is concurrency-limited. Source/destination overlap + * is rejected before overwrite removal can destroy source data. + */ + async copy(source: string, destination: string, options: CopyOptionsType = {}): Promise { + this.#assertOpen(); + const from = normalizePath(source); + const to = normalizePath(destination); + if (from === to || isAncestorPath(from, to) || isAncestorPath(to, from)) { + throw new FileSystemError( + "invalid-operation", + "copy", + from, + `Copy source '${from}' and destination '${to}' must not overlap.`, + ); + } + const concurrency = getConcurrency(options.concurrency); + const lock = await this.#locks.acquireTree(options.signal); + try { + const sourceStat = await this.stat(from, options); + const destinationStat = await this.adapter.stat(to, getAdapterSignalOptions(options.signal)); + if (destinationStat !== null) { + if (!options.overwrite) { + throw new FileSystemError("already-exists", "copy", to, `Destination '${to}' already exists.`); + } + await this.#removeUnlocked(to, true, options.signal); + } + await ensureParents(this.adapter, dirname(to), options.signal); + + if (sourceStat.kind === "file") { + await this.#copyFileUnlocked(from, to, options.signal); + return; + } + await this.adapter.createDir(to, getAdapterSignalOptions(options.signal)); + const active = new Set>(); + const failures: unknown[] = []; + + try { + for await (const entry of this.#walkAdapter(from, options.signal)) { + if (failures.length > 0) break; + const relative = entry.path.slice(from.length).replace(/^\//, ""); + const target = joinPath(to, relative); + if (entry.kind === "directory") { + await this.adapter.createDir(target, getAdapterSignalOptions(options.signal)); + } else { + while (active.size >= concurrency) await Promise.race(active); + trackConcurrent(active, failures, this.#copyFileUnlocked(entry.path, target, options.signal)); + } + } + await settleConcurrent(active, failures); + } catch (error) { + await settleConcurrent(active, failures, error); + } + } finally { + lock.release(); + } + } + + /** Copies one file after the caller has acquired the structural tree lock. */ + async #copyFileUnlocked(source: string, destination: string, signal?: AbortSignal): Promise { + const stream = this.adapter.capabilities.streamRead && this.adapter.openReadStream !== undefined + ? await this.adapter.openReadStream(source, getAdapterSignalOptions(signal)) + : new ReadableStream({ + start: async (controller) => { + controller.enqueue(await this.adapter.readFile(source, getAdapterSignalOptions(signal))); + controller.close(); + }, + }); + if (this.adapter.capabilities.streamWrite && this.adapter.writeStream !== undefined) { + await this.adapter.writeStream(destination, stream, { mode: "replace", ...getAdapterSignalOptions(signal) }); + } else { + const bytes = await collectBytes(stream, this.maxBufferedWriteBytes, signal, "copy", source); + await this.adapter.writeFile(destination, bytes, { mode: "replace", ...getAdapterSignalOptions(signal) }); + } + } + + /** Traverses raw adapter entries without creating public facade handles. */ + async *#walkAdapter( + path: string, + signal?: AbortSignal, + ): AsyncIterableIterator<{ path: string; kind: EntryKindType }> { + for await (const entry of this.adapter.readDir(path, getAdapterSignalOptions(signal))) { + throwIfAborted(signal, "walk", path); + const child = joinPath(path, entry.name); + yield { path: child, kind: entry.kind }; + if (entry.kind === "directory") yield* this.#walkAdapter(child, signal); + } + } + + /** + * Moves an entry with the adapter's native move when available. + * + * Adapters without native move use copy-then-remove. That fallback is + * deliberately non-atomic and is documented as such for callers. + */ + async move(source: string, destination: string, options: MoveOptionsType = {}): Promise { + this.#assertOpen(); + const from = normalizePath(source); + const to = normalizePath(destination); + if (from === to) return; + if (isAncestorPath(from, to) || isAncestorPath(to, from)) { + throw new FileSystemError( + "invalid-operation", + "move", + from, + `Move source '${from}' and destination '${to}' must not overlap.`, + ); + } + + if (this.adapter.capabilities.nativeMove && this.adapter.move !== undefined) { + const lock = await this.#locks.acquireTree(options.signal); + try { + if (options.overwrite) await this.#removeUnlocked(to, true, options.signal); + else if (await this.adapter.stat(to, getAdapterSignalOptions(options.signal)) !== null) { + throw new FileSystemError("already-exists", "move", to, `Destination '${to}' already exists.`); + } + await ensureParents(this.adapter, dirname(to), options.signal); + await this.adapter.move(from, to, { + overwrite: options.overwrite ?? false, + ...getAdapterSignalOptions(options.signal), + }); + } catch (error) { + throw toFileSystemError(error, "move", from); + } finally { + lock.release(); + } + return; + } + + // Copy and remove are intentionally separate commits on adapters without a native rename. + await this.copy(from, to, options); + await this.remove(from, { recursive: true, ...getAdapterSignalOptions(options.signal) }); + } + + /** Removes a path idempotently and holds the tree lock for recursive structure changes. */ + async remove(path: string, options: RemoveOptionsType = {}): Promise { + this.#assertOpen(); + const normalized = normalizePath(path); + if (normalized === ROOT_PATH) { + throw new FileSystemError( + "invalid-operation", + "remove", + normalized, + "The virtual root cannot be removed. Use emptyDir('/') instead.", + ); + } + const lock = await this.#locks.acquireTree(options.signal); + try { + await this.#removeUnlocked(normalized, options.recursive ?? false, options.signal); + } finally { + lock.release(); + } + } + + /** Removes one entry after the caller has acquired the structural tree lock. */ + async #removeUnlocked(path: string, recursive: boolean, signal?: AbortSignal): Promise { + const stat = await this.adapter.stat(path, getAdapterSignalOptions(signal)); + if (stat === null) return; + if (stat.kind === "directory") { + const children: string[] = []; + for await (const entry of this.adapter.readDir(path, getAdapterSignalOptions(signal))) { + children.push(joinPath(path, entry.name)); + } + if (children.length > 0 && !recursive) { + throw new FileSystemError( + "invalid-operation", + "remove", + path, + `Directory '${path}' is not empty. Set recursive to true.`, + ); + } + for (const child of children) await this.#removeUnlocked(child, true, signal); + } + await this.adapter.remove(path, getAdapterSignalOptions(signal)); + } + + /** + * Removes every child while preserving the requested directory. + * + * Direct-child removals are concurrency-limited, and already-started + * removals settle before this method releases the structural tree lock. + */ + async emptyDir(path = ROOT_PATH, options: EmptyDirectoryOptionsType = {}): Promise { + this.#assertOpen(); + const normalized = normalizePath(path); + const stat = await this.stat(normalized, options); + if (stat.kind !== "directory") { + throw new FileSystemError("type-mismatch", "empty-dir", normalized, `'${normalized}' is a file.`); + } + const concurrency = getConcurrency(options.concurrency); + const lock = await this.#locks.acquireTree(options.signal); + const active = new Set>(); + const failures: unknown[] = []; + try { + for await (const entry of this.adapter.readDir(normalized, getAdapterSignalOptions(options.signal))) { + while (active.size >= concurrency) await Promise.race(active); + trackConcurrent(active, failures, this.#removeUnlocked(joinPath(normalized, entry.name), true, options.signal)); + } + await settleConcurrent(active, failures); + } catch (error) { + await settleConcurrent(active, failures, error); + } finally { + lock.release(); + } + } + + /** + * Opens synchronous random access and transfers the file mutation lock to the returned resource. + * + * The caller must close the returned file. Closing it releases both the + * adapter-native resource and the facade lock. + */ + async openSyncFile(path: string, options: OpenSyncFileOptionsType = {}): Promise { + this.#assertOpen(); + const normalized = normalizePath(path); + if (!this.adapter.capabilities.syncAccess || this.adapter.openSyncFile === undefined) { + throw new FileSystemError( + "not-supported", + "open-sync-file", + normalized, + `Adapter '${this.adapter.name}' does not provide synchronous file access.`, + ); + } + if (options.create) { + await this.getFileHandle(normalized, { + create: true, + parents: options.parents ?? false, + ...getAdapterSignalOptions(options.signal), + }); + } + else await this.getFileHandle(normalized, getAdapterSignalOptions(options.signal)); + const lock = await this.#locks.acquireFile(normalized, options.signal); + try { + const file = await this.adapter.openSyncFile(normalized); + return new ManagedSyncFile(normalized, file, lock); + } catch (error) { + lock.release(); + throw toFileSystemError(error, "open-sync-file", normalized); + } + } + + /** + * Closes this facade once and optionally disposes the injected adapter. + * + * `disposeAdapter` controls ownership transfer. Borrowed adapters remain live + * after the facade closes. + */ + async close(): Promise { + if (this.#closed) return; + this.#closed = true; + if (this.#disposeAdapter) await this.adapter.dispose?.(); + } + + /** Enables `await using` to apply the same ownership rules as {@link close}. */ + async [Symbol.asyncDispose](): Promise { + await this.close(); + } +} + +/** + * Creates the OPFS-shaped facade over any filesystem adapter. + * + * @example Deno-backed frontend + * ```ts + * import { createFileSystem } from "@okikio/opfs"; + * import { createDenoAdapter } from "@okikio/opfs/adapter/deno"; + * + * const fs = createFileSystem(createDenoAdapter({ root: "./data" })); + * const file = await fs.root.getFileHandle("hello.txt", { create: true }); + * const writable = await file.createWritable(); + * await writable.write("hello"); + * await writable.close(); + * ``` + */ +export function createFileSystem(adapter: AdapterType, options: FileSystemOptionsType = {}): FileSystemType { + return new FileSystemFacade(adapter, options); +} diff --git a/src/handle.ts b/src/handle.ts new file mode 100644 index 0000000..cf76c85 --- /dev/null +++ b/src/handle.ts @@ -0,0 +1,381 @@ +import { FileSystemError } from "./error.ts"; +import type { FileSystemType } from "./filesystem.ts"; +import { basename, isAncestorPath, joinPath, normalizePath, validateName } from "./path.ts"; +import type { EntryKindType } from "./schema.ts"; +import type { SyncFileType } from "./sync.ts"; +import { toBytes, type WriteDataType } from "./stream.ts"; + +/** Options matching the File System API create flag. */ +export interface HandleCreateOptionsType { + /** Creates the requested entry when it does not exist. */ + readonly create?: boolean; +} + +/** Options matching directory `removeEntry()`. */ +export interface HandleRemoveOptionsType { + /** Removes descendants before a directory. */ + readonly recursive?: boolean; +} + +/** Options matching file `createWritable()`. */ +export interface CreateWritableOptionsType { + /** Starts the temporary write image with current file bytes. */ + readonly keepExistingData?: boolean; +} + +/** Command accepted by {@link WritableFileStreamType.write}. */ +export type WriteCommandType = + | { + readonly type: "write"; + readonly position?: number; + readonly data: Exclude | AsyncIterable>; + } + | { readonly type: "seek"; readonly position: number } + | { readonly type: "truncate"; readonly size: number }; + +/** Input accepted by OPFS-compatible writable handles. */ +export type WritableChunkType = + | Exclude | AsyncIterable> + | WriteCommandType; + +/** Base contract shared by file and directory handle facades. */ +export interface HandleType { + /** File System API discriminator. */ + readonly kind: EntryKindType; + /** Final entry name. Root uses an empty string. */ + readonly name: string; + /** Canonical path used by this library. Native FileSystemHandle does not expose this property. */ + /** Canonical virtual path that identifies this facade entry. */ + readonly path: string; + /** Returns true when both facades represent the same path in the same filesystem. */ + isSameEntry(other: HandleType): Promise; +} + +/** File System API-shaped file handle backed by the selected adapter. */ +export interface FileHandleType extends HandleType { + /** File discriminator compatible with `FileSystemFileHandle.kind`. */ + readonly kind: "file"; + /** Returns a snapshot File object. */ + getFile(): Promise; + /** Opens a staged writable object. Bytes commit on close and discard on abort. */ + createWritable(options?: CreateWritableOptionsType): Promise; + /** Opens synchronous random access when the adapter supports it. */ + createSyncAccessHandle(): Promise; +} + +/** File System API-shaped directory handle backed by the selected adapter. */ +export interface DirectoryHandleType extends HandleType, AsyncIterable<[string, FileHandleType | DirectoryHandleType]> { + /** Directory discriminator compatible with `FileSystemDirectoryHandle.kind`. */ + readonly kind: "directory"; + /** Opens or creates one direct child directory. */ + getDirectoryHandle(name: string, options?: HandleCreateOptionsType): Promise; + /** Opens or creates one direct child file. */ + getFileHandle(name: string, options?: HandleCreateOptionsType): Promise; + /** Removes one direct child. */ + removeEntry(name: string, options?: HandleRemoveOptionsType): Promise; + /** Resolves a descendant handle to names relative to this directory, or null when unrelated. */ + resolve(possibleDescendant: HandleType): Promise; + /** Lazily iterates `[name, handle]` pairs. */ + entries(): AsyncIterableIterator<[string, FileHandleType | DirectoryHandleType]>; + /** Lazily iterates child names. */ + keys(): AsyncIterableIterator; + /** Lazily iterates child handles. */ + values(): AsyncIterableIterator; +} + +/** Validates writable-stream cursor and truncate positions. */ +function assertOffset(value: number, name: string): void { + if (!Number.isSafeInteger(value) || value < 0) throw new RangeError(`${name} must be a non-negative safe integer.`); +} + +/** + * Distinguishes File System API write commands from ordinary Blob data. + * + * Blob also exposes a string `type` property, so checking only for the property + * would incorrectly classify Blob payloads as write commands. + */ +function isWriteCommand(value: WritableChunkType): value is WriteCommandType { + if (typeof value !== "object" || value === null) return false; + const type = Reflect.get(value, "type"); + return type === "write" || type === "seek" || type === "truncate"; +} + +/** Creates the next staged writable image while preserving bytes outside the write range. */ +function writeAt(existing: Uint8Array, position: number, data: Uint8Array): Uint8Array { + const size = Math.max(existing.byteLength, position + data.byteLength); + const next = new Uint8Array(size); + next.set(existing); + next.set(data, position); + return next; +} + +/** Mutable staged image used behind FileSystemWritableFileStream-like methods. */ +class WriteSession { + /** Filesystem that receives the staged image only after close commits. */ + /** Filesystem instance that owns path resolution and persistence for this handle. */ + readonly #fileSystem: FileSystemType; + /** Canonical file path whose current bytes seeded this write session. */ + readonly #path: string; + /** Mutable staged file image. Abort discards this image without persistence. */ + #bytes: Uint8Array; + /** Cursor used by write and seek commands inside the staged image. */ + #position = 0; + /** Prevents a second commit and rejects writes after close or abort. */ + #done = false; + + constructor(fileSystem: FileSystemType, path: string, bytes: Uint8Array) { + this.#fileSystem = fileSystem; + this.#path = path; + this.#bytes = bytes; + } + + /** Rejects operations after the session has reached its terminal state. */ + #assertOpen(): void { + if (this.#done) { + throw new FileSystemError( + "invalid-operation", + "writable", + this.#path, + `Writable file '${this.#path}' is already closed or aborted.`, + ); + } + } + + /** Applies one File System API write command or byte payload to the staged image. */ + async write(chunk: WritableChunkType): Promise { + this.#assertOpen(); + if (isWriteCommand(chunk)) { + if (chunk.type === "seek") return this.seek(chunk.position); + if (chunk.type === "truncate") return this.truncate(chunk.size); + if (chunk.position !== undefined) this.seek(chunk.position); + return await this.write(chunk.data); + } + + const data = await toBytes(chunk); + this.#bytes = writeAt(this.#bytes, this.#position, data); + this.#position += data.byteLength; + } + + /** Moves the staged cursor without changing committed storage. */ + seek(position: number): void { + this.#assertOpen(); + assertOffset(position, "position"); + this.#position = position; + } + + /** Resizes the staged image and clamps the cursor when it falls past the new end. */ + truncate(size: number): void { + this.#assertOpen(); + assertOffset(size, "size"); + const next = new Uint8Array(size); + next.set(this.#bytes.subarray(0, size)); + this.#bytes = next; + if (this.#position > size) this.#position = size; + } + + /** Commits the complete staged image exactly once through the owning filesystem facade. */ + async close(): Promise { + this.#assertOpen(); + this.#done = true; + await this.#fileSystem.writeFile(this.#path, this.#bytes, { mode: "replace" }); + } + + /** Marks the staged image terminal without persisting any of its bytes. */ + abort(): void { + if (this.#done) return; + this.#done = true; + } +} + +/** + * WritableStream-compatible object returned by {@link FileHandleType.createWritable}. + * + * The standard OPFS writable stream stages data and commits on close. This + * adapter-independent implementation does the same at the facade level. It + * keeps the staged file in memory, so large sequential writes should use + * `FileSystemType.writeFile()` with a streaming-capable adapter instead. + */ +export class WritableFileStream extends WritableStream { + /** In-memory staged write state committed only when the writable stream closes. */ + readonly #session: WriteSession; + + constructor(session: WriteSession) { + super({ + write: async (chunk) => await session.write(chunk), + close: async () => await session.close(), + abort: () => session.abort(), + }); + this.#session = session; + } + + /** Writes bytes or a standard write/seek/truncate command. */ + async write(data: WritableChunkType): Promise { + if (this.locked) throw new TypeError("Writable file stream is locked by another writer."); + const writer = this.getWriter(); + try { + await writer.write(data); + } finally { + writer.releaseLock(); + } + } + + /** Changes the staged cursor without committing. */ + async seek(position: number): Promise { + if (this.locked) throw new TypeError("Writable file stream is locked by another writer."); + this.#session.seek(position); + } + + /** Changes staged file length without committing. */ + async truncate(size: number): Promise { + if (this.locked) throw new TypeError("Writable file stream is locked by another writer."); + this.#session.truncate(size); + } + + /** Commits staged bytes and closes the stream. */ + async close(): Promise { + if (this.locked) throw new TypeError("Writable file stream is locked by another writer."); + const writer = this.getWriter(); + try { + await writer.close(); + } finally { + writer.releaseLock(); + } + } +} + +/** Public type for the OPFS-compatible writable stream. */ +export type WritableFileStreamType = WritableFileStream; + +/** + * Shared identity implementation for OPFS-shaped facade handles. + * + * Filesystem identity stays private so callers cannot mutate adapter ownership, + * but sibling facade objects can still prove same-entry and descendant relations. + */ +abstract class BaseHandle implements HandleType { + /** Concrete entry discriminator supplied by the file or directory subclass. */ + abstract readonly kind: EntryKindType; + /** Canonical virtual path that identifies this facade entry. */ + readonly path: string; + /** Filesystem instance that owns path resolution and persistence for this handle. */ + readonly #fileSystem: FileSystemType; + + constructor(fileSystem: FileSystemType, path: string) { + this.#fileSystem = fileSystem; + this.path = normalizePath(path); + } + + /** Returns the final path segment, or an empty string for the virtual root. */ + get name(): string { + return basename(this.path); + } + + /** Gives subclasses controlled access to the owning filesystem facade. */ + protected get fileSystem(): FileSystemType { + return this.#fileSystem; + } + + /** Returns whether another facade belongs to this exact filesystem instance. */ + protected belongsToSameFileSystem(other: HandleType): boolean { + return other instanceof BaseHandle && other.#fileSystem === this.#fileSystem; + } + + /** Compares both filesystem identity and canonical path, not only the visible name. */ + async isSameEntry(other: HandleType): Promise { + return this.belongsToSameFileSystem(other) && other.path === this.path; + } +} + +/** + * Concrete File System API-shaped file handle facade. + * + * The object stores only filesystem identity and a canonical virtual path. It + * does not pin an adapter-native file descriptor or browser handle. `getFile()` + * therefore returns a fresh snapshot, while `createSyncAccessHandle()` acquires + * the real backend resource only for the returned sync-file lifetime. + */ +export class FileHandle extends BaseHandle implements FileHandleType { + /** File discriminator exposed to OPFS-oriented consumers. */ + readonly kind = "file" as const; + + /** Returns a fresh immutable `File` snapshot from the current backend state. */ + async getFile(): Promise { + return await this.fileSystem.getFile(this.path); + } + + /** + * Creates an OPFS-compatible staged writable stream. + * + * This handle-level API stages bytes in memory. Use `FileSystemType.writeFile` + * for large streaming writes that should reach a streaming adapter directly. + */ + async createWritable(options: CreateWritableOptionsType = {}): Promise { + const bytes = options.keepExistingData ? await this.fileSystem.readFile(this.path) : new Uint8Array(); + return new WritableFileStream(new WriteSession(this.fileSystem, this.path, bytes)); + } + + /** Opens adapter synchronous random access and transfers lock ownership to the returned resource. */ + async createSyncAccessHandle(): Promise { + return await this.fileSystem.openSyncFile(this.path); + } +} + +/** + * Concrete File System API-shaped directory handle facade. + * + * Child lookups stay direct-child operations and validate names before they are + * joined to the directory path. Iteration delegates to the filesystem's lazy + * `readDir()` implementation, so large directories are not eagerly collected by + * this facade. + */ +export class DirectoryHandle extends BaseHandle implements DirectoryHandleType { + /** Directory discriminator exposed to OPFS-oriented consumers. */ + readonly kind = "directory" as const; + + /** Opens or creates one direct child directory after validating the child name. */ + async getDirectoryHandle(name: string, options: HandleCreateOptionsType = {}): Promise { + validateName(name); + return await this.fileSystem.getDirectoryHandle(joinPath(this.path, name), { create: options.create ?? false }); + } + + /** Opens or creates one direct child file after validating the child name. */ + async getFileHandle(name: string, options: HandleCreateOptionsType = {}): Promise { + validateName(name); + return await this.fileSystem.getFileHandle(joinPath(this.path, name), { create: options.create ?? false }); + } + + /** Removes one direct child through the filesystem's coordinated removal path. */ + async removeEntry(name: string, options: HandleRemoveOptionsType = {}): Promise { + validateName(name); + await this.fileSystem.remove(joinPath(this.path, name), { recursive: options.recursive ?? false }); + } + + /** Resolves a descendant only when both handles belong to this same filesystem instance. */ + async resolve(possibleDescendant: HandleType): Promise { + if (!(possibleDescendant instanceof BaseHandle) || !this.belongsToSameFileSystem(possibleDescendant)) return null; + if (possibleDescendant.path === this.path) return []; + if (!isAncestorPath(this.path, possibleDescendant.path)) return null; + const relative = possibleDescendant.path.slice(this.path === "/" ? 1 : this.path.length + 1); + return relative.length === 0 ? [] : relative.split("/"); + } + + /** Lazily yields direct-child name and handle pairs. */ + async *entries(): AsyncIterableIterator<[string, FileHandleType | DirectoryHandleType]> { + for await (const entry of this.fileSystem.readDir(this.path)) yield [entry.name, entry.handle]; + } + + /** Lazily yields direct-child names without collecting the directory. */ + async *keys(): AsyncIterableIterator { + for await (const [name] of this.entries()) yield name; + } + + /** Lazily yields direct-child file or directory handles. */ + async *values(): AsyncIterableIterator { + for await (const [, handle] of this.entries()) yield handle; + } + + /** Makes the directory itself iterable with the same semantics as {@link entries}. */ + [Symbol.asyncIterator](): AsyncIterableIterator<[string, FileHandleType | DirectoryHandleType]> { + return this.entries(); + } +} diff --git a/src/iframe.ts b/src/iframe.ts new file mode 100644 index 0000000..6cd6474 --- /dev/null +++ b/src/iframe.ts @@ -0,0 +1,76 @@ +import { createOpfsAdapter } from "./adapter/opfs.ts"; +import type { FileSystemOptionsType } from "./adapter/definition.ts"; +import { FileSystemError, toFileSystemError } from "./error.ts"; +import { createFileSystem, type FileSystemType } from "./filesystem.ts"; + +/** Storage Access API result shape used without depending on experimental DOM declarations. */ +interface StorageAccessHandleType { + /** Returns the unpartitioned OPFS root when the browser granted that capability. */ + getDirectory?: () => Promise; +} + +/** Document shape for browsers that implement unpartitioned OPFS Storage Access. */ +interface StorageAccessDocumentType { + /** Requests selected unpartitioned storage capabilities from an embedded document. */ + requestStorageAccess?: (types?: { readonly getDirectory?: boolean }) => Promise; +} + +/** + * Returns true when the current document exposes the Storage Access API entrypoint. + * + * A true result does not mean that a request will be granted. Browsers can require + * user activation, iframe permissions policy, prior site interaction, or a user + * decision before they return an unpartitioned OPFS directory. + */ +export function supportsUnpartitionedOpfsRequest(): boolean { + const documentValue = Reflect.get(globalThis, "document") as unknown as StorageAccessDocumentType | undefined; + return typeof documentValue?.requestStorageAccess === "function"; +} + +/** + * Requests an unpartitioned OPFS root from an embedded document. + * + * Normal `openFileSystem()` uses the current storage key. In a third-party + * iframe that storage can be partitioned by the top-level site. This opt-in API + * requests the browser's unpartitioned `getDirectory` capability instead. + * + * The caller must invoke this function from a context that satisfies the + * browser's Storage Access requirements. The returned filesystem borrows the + * browser root and therefore has no root resource to dispose. + * + * @example Request unpartitioned storage after a user action. + * ```ts + * import { requestUnpartitionedFileSystem } from "@okikio/opfs/iframe"; + * + * button.addEventListener("click", async () => { + * const fileSystem = await requestUnpartitionedFileSystem(); + * await fileSystem.writeFile("/state.json", "{}", { parents: true }); + * }); + * ``` + */ +export async function requestUnpartitionedFileSystem(options: FileSystemOptionsType = {}): Promise { + const documentValue = Reflect.get(globalThis, "document") as unknown as StorageAccessDocumentType | undefined; + if (typeof documentValue?.requestStorageAccess !== "function") { + throw new FileSystemError( + "unavailable", + "request-unpartitioned-opfs", + undefined, + "The Storage Access API is unavailable in this document.", + ); + } + + try { + const access = await documentValue.requestStorageAccess({ getDirectory: true }); + if (typeof access.getDirectory !== "function") { + throw new FileSystemError( + "unavailable", + "request-unpartitioned-opfs", + undefined, + "The browser did not grant the OPFS directory capability.", + ); + } + return createFileSystem(createOpfsAdapter(await access.getDirectory()), options); + } catch (error) { + throw toFileSystemError(error, "request-unpartitioned-opfs"); + } +} diff --git a/src/lock.ts b/src/lock.ts new file mode 100644 index 0000000..9bd5c8f --- /dev/null +++ b/src/lock.ts @@ -0,0 +1,287 @@ +import { FileSystemError, throwIfAborted } from "./error.ts"; +import type { CoordinationModeType } from "./schema.ts"; + +/** Lock access required for one operation. */ +type LockModeType = "shared" | "exclusive"; + +/** Explicit release contract used by file and tree operations. */ +export interface HeldLockType { + /** Releases the lock once. Repeated calls have no effect. */ + release(): void; +} + +/** Internal lock provider implemented by Web Locks, local FIFO locks, or no-op mode. */ +interface LockCoordinatorType { + /** Acquires a named shared or exclusive lock and returns explicit release ownership. */ + acquire(name: string, mode: LockModeType, signal?: AbortSignal): Promise; +} + +/** One local lock request waiting for grant or AbortSignal cancellation. */ +interface PendingLockType { + /** Requested reader or writer mode. */ + mode: LockModeType; + /** Completes the waiting acquire call after the request is granted. */ + resolve: (lock: HeldLockType) => void; + /** Rejects the waiting acquire call when cancellation removes it from the queue. */ + reject: (reason: FileSystemError) => void; + /** Optional cancellation signal retained only while this request is queued. */ + signal?: AbortSignal; + /** Listener removed at grant time so completed locks do not retain queued cancellation state. */ + onAbort?: () => void; +} + +/** Reader/writer state for one in-realm lock name. */ +interface LocalLockStateType { + /** Number of currently granted shared readers. */ + readers: number; + /** True while one exclusive writer owns this lock name. */ + writer: boolean; + /** FIFO requests waiting behind current owners or an earlier exclusive waiter. */ + queue: PendingLockType[]; +} + +/** + * Process-realm lock registry shared by every local coordinator instance. + * + * Sharing by lock name makes separately created filesystem facades coordinate + * when they deliberately use the same `lockPrefix`. Empty states are removed. + */ +const localStates = new Map(); + +/** Returns the existing local state or creates its empty reader/writer queue. */ +function getState(name: string): LocalLockStateType { + let state = localStates.get(name); + if (state === undefined) { + state = { readers: 0, writer: false, queue: [] }; + localStates.set(name, state); + } + return state; +} + +/** Converts lock cancellation into the package's stable aborted error. */ +function getAbortError(signal: AbortSignal): FileSystemError { + return new FileSystemError("aborted", "lock", undefined, "Lock acquisition was aborted.", signal.reason); +} + +/** Tests current reader/writer occupancy without considering queued fairness. */ +function canGrant(state: LocalLockStateType, mode: LockModeType): boolean { + return mode === "exclusive" ? !state.writer && state.readers === 0 : !state.writer; +} + +/** Deletes unused lock state so dynamic file paths do not grow the registry forever. */ +function removeEmptyState(name: string, state: LocalLockStateType): void { + if (!state.writer && state.readers === 0 && state.queue.length === 0) localStates.delete(name); +} + +/** Grants one queued request and binds idempotent release to the same state. */ +function grant(name: string, state: LocalLockStateType, pending: PendingLockType): void { + if (pending.signal !== undefined && pending.onAbort !== undefined) { + pending.signal.removeEventListener("abort", pending.onAbort); + } + if (pending.mode === "exclusive") state.writer = true; + else state.readers += 1; + + let released = false; + pending.resolve({ + release() { + if (released) return; + released = true; + if (pending.mode === "exclusive") state.writer = false; + else state.readers -= 1; + drain(name, state); + removeEmptyState(name, state); + }, + }); +} + +/** + * Grants queued readers until an exclusive request reaches the head. + * + * This FIFO rule prevents a steady stream of new readers from starving a queued + * writer. It also makes abort removal deterministic because queue order remains + * the only authority for pending requests. + */ +function drain(name: string, state: LocalLockStateType): void { + if (state.writer || state.queue.length === 0) return; + const first = state.queue[0]; + if (first === undefined) return; + + if (first.mode === "exclusive") { + if (state.readers !== 0) return; + state.queue.shift(); + grant(name, state, first); + return; + } + + while (!state.writer) { + const pending = state.queue[0]; + if (pending === undefined || pending.mode !== "shared") break; + state.queue.shift(); + grant(name, state, pending); + } +} + +/** In-realm FIFO reader/writer coordinator used when Web Locks are unavailable. */ +class LocalLockCoordinator implements LockCoordinatorType { + /** + * Acquires one process-realm FIFO reader/writer lock. + * + * New readers do not bypass an already queued writer, which prevents writer + * starvation. Aborted queued requests are removed before the queue drains. + */ + async acquire(name: string, mode: LockModeType, signal?: AbortSignal): Promise { + if (signal?.aborted) throw getAbortError(signal); + const state = getState(name); + + return await new Promise((resolve, reject) => { + const pending: PendingLockType = { mode, resolve, reject }; + if (signal !== undefined) { + pending.signal = signal; + pending.onAbort = () => { + const index = state.queue.indexOf(pending); + if (index < 0) return; + state.queue.splice(index, 1); + reject(getAbortError(signal)); + drain(name, state); + removeEmptyState(name, state); + }; + signal.addEventListener("abort", pending.onAbort, { once: true }); + } + + // New readers queue behind an exclusive waiter so a writer cannot starve. + if (state.queue.length === 0 && canGrant(state, mode)) grant(name, state, pending); + else state.queue.push(pending); + }); + } +} + +/** Structural Web Locks subset kept independent of browser-specific declaration versions. */ +interface WebLocksType { + /** Holds a browser Web Lock until the callback promise settles. */ + request( + name: string, + options: { mode: LockModeType; signal?: AbortSignal }, + callback: () => Promise, + ): Promise; +} + +/** Resolves navigator.locks lazily so server imports stay side-effect free. */ +function getWebLocks(): WebLocksType | undefined { + const navigatorValue = Reflect.get(globalThis, "navigator") as { locks?: WebLocksType } | undefined; + return navigatorValue?.locks; +} + +/** Cross-tab/worker coordinator backed by the browser Web Locks API. */ +class WebLockCoordinator implements LockCoordinatorType { + /** Web Locks manager supplied by the current browser realm. */ + readonly #locks: WebLocksType; + + constructor(locks: WebLocksType) { + this.#locks = locks; + } + + /** + * Acquires one process-realm FIFO reader/writer lock. + * + * New readers do not bypass an already queued writer, which prevents writer + * starvation. Aborted queued requests are removed before the queue drains. + */ + async acquire(name: string, mode: LockModeType, signal?: AbortSignal): Promise { + throwIfAborted(signal, "lock"); + + let markAcquired: (() => void) | undefined; + const acquired = new Promise((resolve) => { markAcquired = resolve; }); + let releaseRequest: (() => void) | undefined; + const hold = new Promise((resolve) => { releaseRequest = resolve; }); + const options: { mode: LockModeType; signal?: AbortSignal } = { mode }; + if (signal !== undefined) options.signal = signal; + + const request = this.#locks.request(name, options, async () => { + markAcquired?.(); + await hold; + }); + await Promise.race([acquired, request]); + + let released = false; + return { + release() { + if (released) return; + released = true; + releaseRequest?.(); + void request.catch(() => undefined); + }, + }; + } +} + +/** Coordination mode that preserves cancellation checks but acquires no lock. */ +class NoopLockCoordinator implements LockCoordinatorType { + /** Returns an immediately released ownership token after preserving cancellation checks. */ + async acquire(_name: string, _mode: LockModeType, signal?: AbortSignal): Promise { + throwIfAborted(signal, "lock"); + return { release() {} }; + } +} + +/** + * Coordinates facade mutations without making an adapter own application state. + * + * File operations take a shared tree gate plus an exclusive path lock. Tree + * mutations take the tree gate exclusively. This permits independent file + * writes while preventing recursive remove/copy/move from racing those writes. + */ +export class MutationLocks { + /** Selected coordination backend for every lock name created by this facade. */ + readonly #coordinator: LockCoordinatorType; + /** Shared lock name that coordinates recursive structure changes with file mutations. */ + readonly #treeName: string; + /** Namespace used to derive stable file-lock names across cooperating facade instances. */ + readonly #prefix: string; + + constructor(mode: CoordinationModeType, prefix: string) { + this.#prefix = prefix; + this.#treeName = `${prefix}:tree`; + const webLocks = getWebLocks(); + + if (mode === "none") this.#coordinator = new NoopLockCoordinator(); + else if (mode === "local") this.#coordinator = new LocalLockCoordinator(); + else if (mode === "web-locks") { + if (webLocks === undefined) { + throw new FileSystemError( + "unavailable", + "configure-locks", + undefined, + "Web Locks were requested but are unavailable in this context.", + ); + } + this.#coordinator = new WebLockCoordinator(webLocks); + } else { + this.#coordinator = webLocks === undefined ? new LocalLockCoordinator() : new WebLockCoordinator(webLocks); + } + } + + /** Acquires the lock set used by one file mutation. */ + async acquireFile(path: string, signal?: AbortSignal): Promise { + const tree = await this.#coordinator.acquire(this.#treeName, "shared", signal); + try { + const file = await this.#coordinator.acquire(`${this.#prefix}:file:${path}`, "exclusive", signal); + let released = false; + return { + release() { + if (released) return; + released = true; + file.release(); + tree.release(); + }, + }; + } catch (error) { + tree.release(); + throw error; + } + } + + /** Acquires exclusive access to recursive tree structure. */ + async acquireTree(signal?: AbortSignal): Promise { + return await this.#coordinator.acquire(this.#treeName, "exclusive", signal); + } +} diff --git a/src/path.ts b/src/path.ts new file mode 100644 index 0000000..3203fbf --- /dev/null +++ b/src/path.ts @@ -0,0 +1,93 @@ +import { FileSystemError } from "./error.ts"; +import type { PathType } from "./schema.ts"; + +export type { PathType } from "./schema.ts"; + +/** Canonical virtual filesystem root. */ +export const ROOT_PATH = "/"; + +/** Creates the package error used when virtual-path normalization cannot continue safely. */ +function failPath(path: string, message: string): never { + throw new FileSystemError("invalid-path", "path", path, message); +} + +/** + * Normalizes one application path into the adapter-independent path format. + * + * Both `a/b` and `/a/b` become `/a/b`. The function resolves `.` and `..`, but + * rejects traversal above `/`. Backslashes are rejected instead of being + * interpreted as separators. This keeps the same virtual path on Windows, + * Unix, OPFS, databases, and key-value stores. + * + * @example + * ```ts + * normalizePath("reports/../cache/data.bin"); // "/cache/data.bin" + * ``` + */ +export function normalizePath(path: string): PathType { + if (typeof path !== "string") throw new TypeError("Filesystem paths must be strings."); + if (path.includes("\0")) failPath(path, "Filesystem paths cannot contain NUL characters."); + if (path.includes("\\")) failPath(path, "Filesystem paths cannot contain backslashes."); + + const parts: string[] = []; + for (const part of path.split("/")) { + if (part === "" || part === ".") continue; + if (part === "..") { + if (parts.length === 0) failPath(path, "Filesystem paths cannot escape above the virtual root."); + parts.pop(); + continue; + } + parts.push(part); + } + return parts.length === 0 ? ROOT_PATH : `/${parts.join("/")}`; +} + +/** Splits a validated path into entry names. */ +export function splitPath(path: string): string[] { + const normalized = normalizePath(path); + return normalized === ROOT_PATH ? [] : normalized.slice(1).split("/"); +} + +/** Joins path fragments and normalizes the result. */ +export function joinPath(...parts: string[]): PathType { + return parts.length === 0 ? ROOT_PATH : normalizePath(parts.join("/")); +} + +/** Returns the canonical parent path. The root is its own parent. */ +export function dirname(path: string): PathType { + const parts = splitPath(path); + return parts.length <= 1 ? ROOT_PATH : `/${parts.slice(0, -1).join("/")}`; +} + +/** Returns the final entry name, or an empty string for the root. */ +export function basename(path: string): string { + return splitPath(path).at(-1) ?? ""; +} + +/** Returns true when `ancestor` strictly contains `path`. */ +export function isAncestorPath(ancestor: string, path: string): boolean { + const parent = normalizePath(ancestor); + const child = normalizePath(path); + if (parent === child) return false; + return parent === ROOT_PATH || child.startsWith(`${parent}/`); +} + +/** + * Validates one File System API entry name. + * + * Directory and file handle methods accept one name, not a path. `/`, `.` and + * `..` therefore fail before an adapter receives them. + */ +export function validateName(name: string): string { + if ( + name.length === 0 || + name === "." || + name === ".." || + name.includes("/") || + name.includes("\\") || + name.includes("\0") + ) { + throw new TypeError(`'${name}' is not a valid filesystem entry name.`); + } + return name; +} diff --git a/src/probe.ts b/src/probe.ts new file mode 100644 index 0000000..79930b8 --- /dev/null +++ b/src/probe.ts @@ -0,0 +1,220 @@ +import { getOpfsContext } from "./context.ts"; +import { getErrorMessage, getErrorName } from "./error.ts"; +import type { OpfsContextType } from "./schema.ts"; + +/** A platform error captured while probing OPFS without throwing. */ +export interface OpfsProbeErrorType { + /** DOMException or Error name reported by the runtime. */ + readonly name: string; + /** Runtime-provided diagnostic text. */ + readonly message: string; +} + +/** Storage quota information returned by `navigator.storage.estimate()`. */ +export interface OpfsStorageEstimateType { + /** Approximate bytes currently used by the storage key. */ + readonly usage?: number; + /** Approximate byte quota assigned to the storage key. */ + readonly quota?: number; +} + +/** + * Concrete browser capabilities observed by {@link probeOpfs}. + * + * The probe reports observable capabilities instead of trying to identify + * private/incognito browsing. Browsers can change private-storage behavior, + * iframe partitioning, and quota policy independently of this package. + */ +export interface OpfsCapabilitiesType { + /** Browser execution context that owns the probe. */ + readonly context: OpfsContextType; + /** Whether the runtime reports a secure context, or null when unavailable. */ + readonly secureContext: boolean | null; + /** Current origin string when exposed by the runtime. */ + readonly origin: string | null; + /** Whether the current document is embedded, or null outside Window. */ + readonly embedded: boolean | null; + /** Whether an embedded document can prove the top document has the same origin. */ + readonly sameOriginTop: boolean | null; + /** Whether `navigator.storage.getDirectory()` completed successfully. */ + readonly rootAvailable: boolean; + /** Failure returned by root acquisition, when rootAvailable is false. */ + readonly rootError?: OpfsProbeErrorType; + /** Whether the Web Locks API is exposed in the current realm. */ + readonly webLocksAvailable: boolean; + /** Whether `createSyncAccessHandle()` is visible on the runtime prototype. */ + readonly syncAccessHandleExposed: boolean; + /** Whether the execution context is allowed to use sync access handles. */ + readonly syncAccessHandleAllowedByContext: boolean; + /** Whether the document exposes `requestStorageAccess()`. */ + readonly storageAccessApiAvailable: boolean; + /** Optional quota estimate. */ + readonly storageEstimate?: OpfsStorageEstimateType; + /** Whether storage is already persisted, when the browser exposes that diagnostic. */ + readonly persistentStorage?: boolean; +} + +/** StorageManager methods used by diagnostics without requiring a specific lib.dom revision. */ +interface StorageManagerType { + /** Browser OPFS root acquisition entrypoint. */ + getDirectory?: () => Promise; + /** Optional storage quota diagnostic. */ + estimate?: () => Promise<{ readonly quota?: number; readonly usage?: number }>; + /** Optional diagnostic that reports whether browser storage is already persisted. */ + persisted?: () => Promise; +} + +/** Navigator fields relevant to OPFS and cross-context mutation coordination. */ +interface NavigatorType { + /** Storage manager exposed by the current navigator. */ + readonly storage?: StorageManagerType; + /** Web Locks manager presence used only as a capability signal. */ + readonly locks?: unknown; +} + +/** Window document fields used to diagnose iframe placement and Storage Access support. */ +interface DocumentType { + /** Storage Access API presence used for unpartitioned iframe diagnostics. */ + readonly requestStorageAccess?: unknown; + /** Window reference used to compare embedded and top-level origins when readable. */ + readonly defaultView?: { + readonly top?: unknown; + readonly location?: { readonly origin?: string }; + }; +} + +/** Reads navigator without creating a hard Window dependency for server/worker imports. */ +function getNavigator(): NavigatorType | undefined { + return Reflect.get(globalThis, "navigator") as NavigatorType | undefined; +} + +/** Reads document structurally so worker and server imports remain valid. */ +function getDocument(): DocumentType | undefined { + return Reflect.get(globalThis, "document") as DocumentType | undefined; +} + +/** Returns the current serialized origin when the runtime exposes location. */ +function getOrigin(): string | null { + const location = Reflect.get(globalThis, "location") as { readonly origin?: string } | undefined; + return typeof location?.origin === "string" ? location.origin : null; +} + +/** + * Diagnoses iframe placement without treating cross-origin access failure as an OPFS result. + * + * A SecurityError while reading top.location only proves that the top document + * has a different origin. Storage partitioning still has to be observed by the + * actual `getDirectory()` probe. + */ +function getEmbedding(): { readonly embedded: boolean | null; readonly sameOriginTop: boolean | null } { + const view = getDocument()?.defaultView; + if (view === undefined) return { embedded: null, sameOriginTop: null }; + + try { + const top = view.top; + const embedded = top !== view; + if (!embedded) return { embedded: false, sameOriginTop: true }; + const topOrigin = (top as { readonly location?: { readonly origin?: string } } | null)?.location?.origin; + const currentOrigin = view.location?.origin; + return { + embedded: true, + sameOriginTop: typeof topOrigin === "string" && typeof currentOrigin === "string" + ? topOrigin === currentOrigin + : null, + }; + } catch { + // Reading top.location across origins throws. That proves a cross-origin top + // document but does not prove whether storage is partitioned or granted. + return { embedded: true, sameOriginTop: false }; + } +} + +/** Checks API exposure separately from the DedicatedWorker placement requirement. */ +function hasSyncAccessHandle(): boolean { + const constructor = Reflect.get(globalThis, "FileSystemFileHandle"); + if (typeof constructor !== "function") return false; + const prototype = Reflect.get(constructor, "prototype"); + return typeof prototype === "object" && + prototype !== null && + typeof Reflect.get(prototype, "createSyncAccessHandle") === "function"; +} + +/** + * Probes OPFS and related coordination/storage capabilities without throwing. + * + * Use this function for diagnostics and feature selection. Do not use it as a + * permanent permission check. A later filesystem operation can still fail due + * to quota, storage eviction, iframe policy, native locking, or browser state. + * + * @example Show a useful message before opening storage. + * ```ts + * import { probeOpfs } from "@okikio/opfs"; + * + * const capabilities = await probeOpfs(); + * if (!capabilities.rootAvailable) { + * console.warn(capabilities.rootError); + * } + * ``` + */ +export async function probeOpfs(): Promise { + const navigatorValue = getNavigator(); + const storage = navigatorValue?.storage; + const context = getOpfsContext(); + const embedding = getEmbedding(); + + let rootAvailable = false; + let rootError: OpfsProbeErrorType | undefined; + if (typeof storage?.getDirectory === "function") { + try { + await storage.getDirectory(); + rootAvailable = true; + } catch (error) { + rootError = { name: getErrorName(error), message: getErrorMessage(error) }; + } + } else { + rootError = { + name: "NotSupportedError", + message: "navigator.storage.getDirectory() is unavailable in this context.", + }; + } + + let storageEstimate: OpfsStorageEstimateType | undefined; + if (typeof storage?.estimate === "function") { + try { + const estimate = await storage.estimate(); + const result: { usage?: number; quota?: number } = {}; + if (typeof estimate.usage === "number") result.usage = estimate.usage; + if (typeof estimate.quota === "number") result.quota = estimate.quota; + storageEstimate = result; + } catch { + // Quota diagnostics do not control whether root acquisition succeeded. + } + } + + let persistentStorage: boolean | undefined; + if (typeof storage?.persisted === "function") { + try { + persistentStorage = await storage.persisted(); + } catch { + // Persistence is an optional diagnostic and can fail independently. + } + } + + return { + context, + secureContext: typeof Reflect.get(globalThis, "isSecureContext") === "boolean" + ? Reflect.get(globalThis, "isSecureContext") as boolean + : null, + origin: getOrigin(), + embedded: embedding.embedded, + sameOriginTop: embedding.sameOriginTop, + rootAvailable, + ...(rootError === undefined ? {} : { rootError }), + webLocksAvailable: navigatorValue?.locks !== undefined, + syncAccessHandleExposed: hasSyncAccessHandle(), + syncAccessHandleAllowedByContext: context === "dedicated-worker", + storageAccessApiAvailable: typeof getDocument()?.requestStorageAccess === "function", + ...(storageEstimate === undefined ? {} : { storageEstimate }), + ...(persistentStorage === undefined ? {} : { persistentStorage }), + }; +} diff --git a/src/schema.ts b/src/schema.ts new file mode 100644 index 0000000..d612a1d --- /dev/null +++ b/src/schema.ts @@ -0,0 +1,193 @@ +import { z } from "zod"; + +/** + * Canonical virtual path stored and exchanged by adapters. + * + * Public path-taking APIs also accept relative and non-canonical input because + * `normalizePath()` resolves it first. `PathSchema` is for already-normalized + * values at persistence and adapter seams. + */ +export const PathSchema = z.string().refine( + (value) => value === "/" || ( + value.startsWith("/") && + !value.endsWith("/") && + !value.includes("//") && + !value.includes("\\") && + !value.includes("\0") && + value.split("/").slice(1).every((part) => part.length > 0 && part !== "." && part !== "..") + ), + "Expected a canonical virtual filesystem path.", +); + +/** A validated canonical virtual filesystem path. */ +export type PathType = z.output; + +/** Stable non-empty diagnostic name assigned to one adapter implementation. */ +export const AdapterNameSchema = z.string().min(1); + +/** A validated adapter diagnostic name. */ +export type AdapterNameType = z.output; + +/** + * Valid entry kinds exposed by the filesystem facade and every adapter. + * + * The package uses the same two kinds as the File System API. Adapters must not + * invent a third kind for links, database rows, or provider-specific objects. + */ +export const EntryKindSchema = z.enum(["file", "directory"]); + +/** A validated filesystem entry kind. */ +export type EntryKindType = z.output; + +/** + * Execution contexts that can host browser storage access. + * + * `worker` is used only when the runtime exposes a generic worker shape but the + * library cannot prove whether it is dedicated, shared, or service-worker + * execution. `unknown` means that no supported browser execution context was + * detected. + */ +export const OpfsContextSchema = z.enum([ + "window", + "dedicated-worker", + "shared-worker", + "service-worker", + "worker", + "unknown", +]); + +/** A validated browser execution context classification. */ +export type OpfsContextType = z.output; + +/** + * Mutation coordination policies supported by {@link createFileSystem}. + * + * `auto` uses Web Locks when the current realm exposes them. Otherwise it uses + * an in-realm FIFO lock. `none` disables library coordination and transfers all + * concurrency responsibility to the caller or adapter. + */ +export const CoordinationModeSchema = z.enum(["auto", "web-locks", "local", "none"]); + +/** A validated mutation coordination policy. */ +export type CoordinationModeType = z.output; + +/** + * Write modes shared by the facade and adapters. + * + * `replace` starts from an empty file. `append` starts at the current end. + * `update` preserves existing bytes and starts at the requested byte offset. + */ +export const WriteModeSchema = z.enum(["replace", "append", "update"]); + +/** A validated file write mode. */ +export type WriteModeType = z.output; + +/** + * Stable adapter capability description. + * + * These flags describe native adapter operations, not operations that the + * facade can emulate. For example, a database adapter can still expose + * `openReadStream()` through the facade while `streamRead` remains `false`. + */ +export const AdapterCapabilitiesSchema = z.object({ + read: z.boolean(), + write: z.boolean(), + streamRead: z.boolean(), + streamWrite: z.boolean(), + rangeRead: z.boolean(), + nativeMove: z.boolean(), + syncAccess: z.boolean(), +}); + +/** Native operations implemented by one adapter. */ +export type AdapterCapabilitiesType = z.output; + +/** + * Stable error categories exposed by the package. + * + * Adapters map runtime-specific failures into these categories so consumers do + * not need separate branches for DOMException, Deno, Bun, Node, SQL, and + * document-database error classes. + */ +export const ErrorCodeSchema = z.enum([ + "unavailable", + "not-found", + "already-exists", + "type-mismatch", + "invalid-path", + "invalid-operation", + "not-supported", + "locked", + "quota-exceeded", + "permission-denied", + "aborted", + "too-large", + "unknown", +]); + +/** A validated package error category. */ +export type ErrorCodeType = z.output; + +/** Version stored with record-backed filesystem entries. */ +export const RecordVersionSchema = z.literal(1); + +/** Persisted record format version. */ +export type RecordVersionType = z.output; + +/** + * Fields shared by every persisted record-store entry. + * + * The virtual path is the durable identity. `parent` is stored separately so + * document and SQL backends can list one directory without scanning every + * record or reconstructing parents from strings. + */ +const RecordBaseSchema = z.object({ + version: RecordVersionSchema, + path: PathSchema, + parent: PathSchema, + name: z.string(), + lastModified: z.number().int().nonnegative(), +}); + +/** Persisted directory record used by record-store adapters. */ +export const DirectoryRecordSchema = RecordBaseSchema.extend({ + kind: z.literal("directory"), +}); + +/** A validated persisted directory record. */ +export type DirectoryRecordType = z.output; + +/** Persisted file record used by record-store adapters. */ +export const FileRecordSchema = RecordBaseSchema.extend({ + kind: z.literal("file"), + data: z.string(), + size: z.number().int().nonnegative(), + mediaType: z.string(), +}); + +/** A validated persisted file record. */ +export type FileRecordType = z.output; + +/** + * Persisted record format shared by RxDB, unstorage, db0, and Drizzle bridges. + * + * File bytes use base64 text because every target ecosystem can preserve JSON + * strings. This costs about one third more storage than raw bytes. Native file + * adapters do not use this format. + */ +export const RecordSchema = z.discriminatedUnion("kind", [DirectoryRecordSchema, FileRecordSchema]); + +/** A validated record-store filesystem entry. */ +export type RecordType = z.output; + +/** SQL dialects currently exposed by db0's public Database contract. */ +export const Db0DialectSchema = z.enum(["mysql", "postgresql", "sqlite", "libsql"]); + +/** A validated db0 SQL dialect. */ +export type Db0DialectType = z.output; + +/** Safe unqualified SQL identifier used for adapter-owned table names. */ +export const SqlIdentifierSchema = z.string().regex(/^[A-Za-z_][A-Za-z0-9_]*$/); + +/** A validated unqualified SQL identifier. */ +export type SqlIdentifierType = z.output; diff --git a/src/stream.ts b/src/stream.ts new file mode 100644 index 0000000..fdd1b3c --- /dev/null +++ b/src/stream.ts @@ -0,0 +1,194 @@ +import { FileSystemError, throwIfAborted } from "./error.ts"; + +/** Write input accepted by the high-level filesystem facade. */ +export type WriteDataType = + | string + | Blob + | ArrayBuffer + | ArrayBufferView + | ReadableStream + | AsyncIterable; + +/** Shared UTF-8 encoder; TextEncoder has no mutable per-call state. */ +const textEncoder = new TextEncoder(); + +/** Returns true when a write value is a Web ReadableStream. */ +export function isReadableStream(data: WriteDataType): data is ReadableStream { + return typeof data === "object" && data !== null && typeof Reflect.get(data, "getReader") === "function"; +} + +/** Returns true when a write value exposes an async iterator. */ +export function isAsyncIterable(data: WriteDataType): data is AsyncIterable { + return typeof data === "object" && data !== null && typeof Reflect.get(data, Symbol.asyncIterator) === "function"; +} + +/** Converts materialized write input into bytes without changing its content. */ +export async function toBytes( + data: Exclude | AsyncIterable>, +): Promise { + if (typeof data === "string") return textEncoder.encode(data); + if (data instanceof Blob) return new Uint8Array(await data.arrayBuffer()); + if (data instanceof ArrayBuffer) return new Uint8Array(data); + return new Uint8Array(data.buffer, data.byteOffset, data.byteLength); +} + +/** Converts any supported write value into a ReadableStream without eager copying. */ +export function toByteStream(data: WriteDataType): ReadableStream { + if (isReadableStream(data)) return data; + if (isAsyncIterable(data)) { + const iterator = data[Symbol.asyncIterator](); + return new ReadableStream({ + async pull(controller) { + const next = await iterator.next(); + if (next.done) controller.close(); + else controller.enqueue(next.value); + }, + async cancel() { + if (typeof iterator.return === "function") await iterator.return(); + }, + }); + } + + return new ReadableStream({ + async start(controller) { + controller.enqueue(await toBytes(data)); + controller.close(); + }, + }); +} + +/** + * Materializes a stream with an explicit memory limit. + * + * Record/database adapters need complete values because their public contracts + * are value-oriented. The limit prevents a large browser stream from silently + * becoming an unbounded heap allocation when the selected adapter cannot stream. + */ +export async function collectBytes( + source: ReadableStream, + limit: number, + signal: AbortSignal | undefined, + operation: string, + path: string, +): Promise { + const reader = source.getReader(); + const chunks: Uint8Array[] = []; + let total = 0; + let completed = false; + + try { + while (true) { + throwIfAborted(signal, operation, path); + const next = await reader.read(); + if (next.done) { + completed = true; + break; + } + total += next.value.byteLength; + if (total > limit) { + throw new FileSystemError( + "too-large", + operation, + path, + [ + `${operation} for '${path}' requires more than ${limit} buffered bytes.`, + "Select a streaming adapter or raise maxBufferedWriteBytes.", + ].join(" "), + ); + } + chunks.push(next.value); + } + } catch (error) { + try { + await reader.cancel(error); + } catch { + // The original read, size, or cancellation failure is more actionable. + } + throw error; + } finally { + if (!completed) { + try { + await reader.cancel(); + } catch { + // The producer can already be closed after a failure. + } + } + reader.releaseLock(); + } + + const output = new Uint8Array(total); + let offset = 0; + for (const chunk of chunks) { + output.set(chunk, offset); + offset += chunk.byteLength; + } + return output; +} + +/** + * Wraps a stream so an AbortSignal cancels an already-open producer. + * + * Opening a stream and then aborting before its next pull must release the + * underlying reader. Otherwise native file descriptors and browser resources + * can remain alive until garbage collection. + */ +export function withAbortSignal( + source: ReadableStream, + signal: AbortSignal | undefined, + path: string, + operation = "read", +): ReadableStream { + if (signal === undefined) return source; + const reader = source.getReader(); + let closed = false; + let controller: ReadableStreamDefaultController | undefined; + + const closeReader = async (reason?: unknown): Promise => { + if (closed) return; + closed = true; + signal.removeEventListener("abort", onAbort); + try { + await reader.cancel(reason); + } finally { + reader.releaseLock(); + } + }; + + const onAbort = (): void => { + const error = new FileSystemError( + "aborted", + operation, + path, + `${operation} was aborted for '${path}'.`, + signal.reason, + ); + controller?.error(error); + void closeReader(error); + }; + + return new ReadableStream({ + start(value) { + controller = value; + signal.addEventListener("abort", onAbort, { once: true }); + if (signal.aborted) onAbort(); + }, + async pull(value) { + throwIfAborted(signal, operation, path); + try { + const next = await reader.read(); + if (next.done) { + value.close(); + await closeReader(); + } else { + value.enqueue(next.value); + } + } catch (error) { + value.error(error); + await closeReader(error); + } + }, + async cancel(reason) { + await closeReader(reason); + }, + }); +} diff --git a/src/sync.ts b/src/sync.ts new file mode 100644 index 0000000..b699ee8 --- /dev/null +++ b/src/sync.ts @@ -0,0 +1,153 @@ +import { FileSystemError, toFileSystemError } from "./error.ts"; +import type { AdapterSyncFileType } from "./adapter/definition.ts"; +import type { HeldLockType } from "./lock.ts"; + +/** + * Synchronous file facade returned by `openSyncFile()` and handle-compatible + * `createSyncAccessHandle()`. + * + * The caller owns this resource. Close it explicitly or use `using` so the + * adapter's native file lock and the facade mutation lock are both released. + */ +export interface SyncFileType extends Disposable { + /** Canonical virtual path held by this resource. */ + /** Canonical virtual path whose mutation lock is owned by this resource. */ + readonly path: string; + /** True after the underlying adapter file has been closed. */ + readonly closed: boolean; + /** Reads into `buffer` and returns bytes read. */ + read(buffer: ArrayBufferView, options?: { readonly at?: number }): number; + /** Writes from `buffer` and returns bytes written. */ + write(buffer: ArrayBufferView, options?: { readonly at?: number }): number; + /** Repeats partial writes until all bytes are written. */ + writeAll(buffer: ArrayBufferView, options?: { readonly at?: number }): number; + /** Returns current file byte length. */ + getSize(): number; + /** Changes current file byte length. */ + truncate(size: number): void; + /** Requests backend durability for current writes. */ + flush(): void; + /** Releases the adapter file and mutation lock. */ + close(): void; +} + +/** Owns one adapter synchronous file for the complete lock lifetime. */ +export class ManagedSyncFile implements SyncFileType { + /** Canonical virtual path whose mutation lock is owned by this resource. */ + readonly path: string; + /** Native adapter resource. `undefined` is the sole closed-state marker. */ + #file: AdapterSyncFileType | undefined; + /** Facade mutation lock held for exactly the same lifetime as `#file`. */ + readonly #lock: HeldLockType; + + constructor(path: string, file: AdapterSyncFileType, lock: HeldLockType) { + this.path = path; + this.#file = file; + this.#lock = lock; + } + + /** Reports closure from the single native-resource marker instead of duplicating state. */ + get closed(): boolean { + return this.#file === undefined; + } + + /** Returns the live native file or rejects use after close. */ + #getFile(): AdapterSyncFileType { + if (this.#file === undefined) { + throw new FileSystemError( + "invalid-operation", + "sync-file", + this.path, + `Sync file '${this.path}' is already closed.`, + ); + } + return this.#file; + } + + /** Reads synchronously and normalizes backend errors to package error categories. */ + read(buffer: ArrayBufferView, options?: { readonly at?: number }): number { + try { + return this.#getFile().read(buffer, options); + } catch (error) { + throw toFileSystemError(error, "sync-read", this.path); + } + } + + /** Writes one synchronous chunk and returns the backend-reported progress. */ + write(buffer: ArrayBufferView, options?: { readonly at?: number }): number { + try { + return this.#getFile().write(buffer, options); + } catch (error) { + throw toFileSystemError(error, "sync-write", this.path); + } + } + + /** Repeats partial synchronous writes and fails if the backend stops making progress. */ + writeAll(buffer: ArrayBufferView, options?: { readonly at?: number }): number { + const source = new Uint8Array(buffer.buffer, buffer.byteOffset, buffer.byteLength); + const start = options?.at; + let written = 0; + + while (written < source.byteLength) { + const remaining = source.subarray(written); + const count = start === undefined ? this.write(remaining) : this.write(remaining, { at: start + written }); + if (count <= 0) { + throw new FileSystemError( + "invalid-operation", + "sync-write", + this.path, + `Sync write made no progress for '${this.path}'.`, + ); + } + written += count; + } + return written; + } + + /** Returns the current backend file size. */ + getSize(): number { + try { + return this.#getFile().getSize(); + } catch (error) { + throw toFileSystemError(error, "sync-size", this.path); + } + } + + /** Validates the requested length and then resizes the backend file synchronously. */ + truncate(size: number): void { + if (!Number.isSafeInteger(size) || size < 0) { + throw new RangeError("truncate size must be a non-negative safe integer."); + } + try { + this.#getFile().truncate(size); + } catch (error) { + throw toFileSystemError(error, "sync-truncate", this.path); + } + } + + /** Requests backend durability without releasing either owned resource. */ + flush(): void { + try { + this.#getFile().flush(); + } catch (error) { + throw toFileSystemError(error, "sync-flush", this.path); + } + } + + /** Closes once and always releases the facade lock even when backend close throws. */ + close(): void { + const file = this.#file; + if (file === undefined) return; + this.#file = undefined; + try { + file.close(); + } finally { + this.#lock.release(); + } + } + + /** Enables `using` to release the same resources as {@link close}. */ + [Symbol.dispose](): void { + this.close(); + } +} -- 2.51.2