diff --git a/src/adapter/bun.ts b/src/adapter/bun.ts new file mode 100644 index 0000000..7cebad5 --- /dev/null +++ b/src/adapter/bun.ts @@ -0,0 +1,95 @@ +import type { AdapterType } from "./definition.ts"; +import { defineAdapter } from "./definition.ts"; +import { createLocalPath } from "./local.ts"; +import { createNodeAdapter, type NodeAdapterOptionsType } from "./node.ts"; +import { throwIfAborted } from "../error.ts"; +import { withAbortSignal } from "../stream.ts"; + +/** Minimal Bun file object used without requiring global Bun types in core declarations. */ +interface BunFileType extends Blob {} + +/** Bun runtime methods used by this adapter. */ +interface BunRuntimeType { + /** Opens a lazy BunFile for a host path. */ + file(path: string): BunFileType; + /** Writes a Blob, Response, stream-compatible body, or bytes to a host path. */ + write(path: string, data: Blob | Response | ArrayBufferView | ArrayBuffer | string): Promise; +} + +/** Options for the Bun filesystem adapter. */ +export type BunAdapterOptionsType = NodeAdapterOptionsType; + +/** + * Resolves Bun lazily so importing the adapter remains safe in Node and Deno. + * + * The explicit subpath can therefore be type-checked or inspected outside Bun; + * only adapter creation requires the runtime global. + */ +function getBun(): BunRuntimeType { + const runtime = Reflect.get(globalThis, "Bun") as BunRuntimeType | undefined; + if (runtime === undefined || typeof runtime.file !== "function" || typeof runtime.write !== "function") { + throw new TypeError("Bun adapter requires the Bun runtime."); + } + return runtime; +} + +/** + * Creates an adapter optimized for Bun. + * + * BunFile supplies lazy reads and streaming reads. `Bun.write()` supplies the + * fast replace path. Directory traversal, positional writes, rename, and sync + * descriptor operations reuse Bun's Node-compatible filesystem implementation. + * `Bun` is resolved lazily during adapter creation, not at module evaluation. + * + * @example + * ```ts + * import { createFileSystem } from "@okikio/opfs"; + * import { createBunAdapter } from "@okikio/opfs/adapter/bun"; + * + * const fs = createFileSystem(createBunAdapter({ root: "./data" })); + * await fs.writeFile("/result.bin", new Uint8Array([1, 2, 3])); + * ``` + */ +export function createBunAdapter(options: BunAdapterOptionsType): AdapterType { + const bun = getBun(); + const hostPath = createLocalPath(options.root); + const node = createNodeAdapter(options); + + return defineAdapter({ + ...node, + name: "bun", + async readFile(path, readOptions = {}) { + throwIfAborted(readOptions.signal, "read", path); + const file = bun.file(hostPath(path)); + const start = readOptions.at ?? 0; + const end = readOptions.length === undefined ? file.size : Math.min(file.size, start + readOptions.length); + return new Uint8Array(await file.slice(start, end).arrayBuffer()); + }, + async openReadStream(path, readOptions = {}) { + throwIfAborted(readOptions.signal, "read", path); + const file = bun.file(hostPath(path)); + const start = readOptions.at ?? 0; + const end = readOptions.length === undefined ? file.size : Math.min(file.size, start + readOptions.length); + return file.slice(start, end).stream() as ReadableStream; + }, + async writeFile(path, data, writeOptions) { + if (writeOptions.mode === "replace") { + throwIfAborted(writeOptions.signal, "write", path); + await bun.write(hostPath(path), data); + return; + } + await node.writeFile(path, data, writeOptions); + }, + async writeStream(path, source, writeOptions) { + if (writeOptions.mode === "replace") { + throwIfAborted(writeOptions.signal, "write", path); + await bun.write(hostPath(path), new Response(withAbortSignal(source, writeOptions.signal, path, "write"))); + return; + } + if (node.writeStream === undefined) { + throw new TypeError("Bun Node compatibility layer does not expose streaming writes."); + } + await node.writeStream(path, source, writeOptions); + }, + }); +} diff --git a/src/adapter/db0.ts b/src/adapter/db0.ts new file mode 100644 index 0000000..a233d48 --- /dev/null +++ b/src/adapter/db0.ts @@ -0,0 +1,317 @@ +import type { AdapterType } from "./definition.ts"; +import { createRecordAdapter, type RecordStoreType } from "./record.ts"; +import { + Db0DialectSchema, + RecordSchema, + SqlIdentifierSchema, + type Db0DialectType, + type RecordType, +} from "../schema.ts"; + +/** Primitive parameter values accepted by db0 prepared statements. */ +export type Db0PrimitiveType = string | number | boolean | undefined | null; + +/** Prepared statement subset required from db0. */ +export interface Db0StatementType { + /** Executes a query and returns every row. */ + all(...params: Db0PrimitiveType[]): Promise; + /** Executes a query and returns the first row. */ + get(...params: Db0PrimitiveType[]): Promise; + /** Executes a mutation. */ + run(...params: Db0PrimitiveType[]): Promise<{ readonly success: boolean }>; +} + +/** + * Structural subset of db0's public `Database` API. + * + * The current db0 contract exposes the SQL dialect independently of the + * connector. That lets this bridge support Bun SQLite, Node SQLite, D1, + * LibSQL, PGlite, PostgreSQL/Hyperdrive, MySQL/Hyperdrive, PlanetScale, and the + * other current connectors through four dialect implementations. + */ +export interface Db0DatabaseType { + /** SQL dialect selected by the active connector. */ + readonly dialect: Db0DialectType; + /** Compiles one SQL string into a reusable statement. */ + prepare(sql: string): Db0StatementType; + /** Releases the database when ownership is explicitly transferred. */ + dispose?(): Promise; +} + +/** Options for the db0 record store and filesystem adapter. */ +export interface Db0AdapterOptionsType { + /** Adapter-owned table. Defaults to `opfs_entries`. */ + readonly table?: string; + /** Creates the table before returning the adapter. Defaults to true. */ + readonly initialize?: boolean; + /** Disposes the injected db0 Database when the adapter closes. */ + readonly disposeDatabase?: boolean; +} + +/** Columns read from every db0 filesystem row. */ +const DB0_ROW_COLUMNS_SQL = "path, parent_path, name, kind, data, size, last_modified, media_type"; + +/** Columns written by the db0 record-store bridge. */ +const DB0_WRITE_COLUMNS_SQL = `id, ${DB0_ROW_COLUMNS_SQL}`; + +/** Database row shape after db0 driver decoding and before record validation. */ +interface Db0RowType { + /** Canonical virtual path returned by the connector. */ + readonly path: string; + /** Canonical direct-parent path stored in the SQL row. */ + readonly parent_path: string; + /** Final file or directory name. */ + readonly name: string; + /** Persisted entry discriminator before schema validation. */ + readonly kind: string; + /** Base64 file payload, or null for directories. */ + readonly data: string | null; + /** File byte length in the connector's integer representation. */ + readonly size: number | string | bigint; + /** Unix epoch milliseconds in the connector's integer representation. */ + readonly last_modified: number | string | bigint; + /** File media type, or null for directories. */ + readonly media_type: string | null; +} + +/** Validates and quotes the adapter-owned table name for the selected db0 dialect. */ +function quoteIdentifier(identifier: string, dialect: Db0DialectType): string { + SqlIdentifierSchema.parse(identifier); + return dialect === "mysql" ? `\`${identifier}\`` : `"${identifier}"`; +} + +/** + * Creates db0's portable prepared-statement placeholders. + * + * db0 connectors own translation to driver-native parameter syntax. Current + * PostgreSQL and PGlite connectors, for example, translate `?` to `$1`, `$2`, + * and so on before calling the underlying driver. Keeping `?` here lets this + * bridge stay at the db0 Database contract instead of coupling to connectors. + */ +function placeholders(count: number): string[] { + return Array.from({ length: count }, () => "?"); +} + +/** Normalizes BIGINT driver results and rejects values outside JavaScript safe-integer range. */ +function toNumber(value: number | string | bigint, field: string): number { + const normalized = typeof value === "bigint" + ? Number(value) + : typeof value === "string" + ? Number.parseInt(value, 10) + : value; + if (!Number.isSafeInteger(normalized) || normalized < 0) { + throw new TypeError(`db0 returned invalid ${field} '${String(value)}'.`); + } + return normalized; +} + +/** Converts a connector-dependent db0 row into the validated record-store format. */ +function parseRow(value: unknown): RecordType { + if (typeof value !== "object" || value === null) throw new TypeError("db0 returned a non-object filesystem row."); + const row = value as Db0RowType; + if (row.kind === "directory") { + return RecordSchema.parse({ + version: 1, + path: row.path, + parent: row.parent_path, + name: row.name, + kind: "directory", + lastModified: toNumber(row.last_modified, "last_modified"), + }); + } + return RecordSchema.parse({ + version: 1, + path: row.path, + parent: row.parent_path, + name: row.name, + kind: "file", + data: row.data ?? "", + size: toNumber(row.size, "size"), + lastModified: toNumber(row.last_modified, "last_modified"), + mediaType: row.media_type ?? "", + }); +} + +/** + * Hashes the full path into a fixed-width primary key. + * + * MySQL cannot portably use an arbitrary-length TEXT path as a primary key. A + * SHA-256 id keeps the indexed key fixed while the original path remains stored + * and queryable in its own column. + */ +async function getPathId(path: string): Promise { + const bytes = new TextEncoder().encode(path); + const digest = new Uint8Array(await crypto.subtle.digest("SHA-256", bytes)); + return Array.from(digest, (value) => value.toString(16).padStart(2, "0")).join(""); +} + +/** Builds only the small DDL subset verified across db0's four public dialects. */ +function getCreateTableSql(table: string, dialect: Db0DialectType): string { + const q = quoteIdentifier(table, dialect); + const integer = dialect === "postgresql" || dialect === "mysql" ? "BIGINT" : "INTEGER"; + const columns = dialect === "mysql" + ? [ + "id VARCHAR(64) NOT NULL PRIMARY KEY", + "path TEXT NOT NULL", + "parent_path TEXT NOT NULL", + "name TEXT NOT NULL", + "kind VARCHAR(16) NOT NULL", + "data LONGTEXT NULL", + `size ${integer} NOT NULL`, + `last_modified ${integer} NOT NULL`, + "media_type TEXT NULL", + ] + : [ + "id TEXT NOT NULL PRIMARY KEY", + "path TEXT NOT NULL", + "parent_path TEXT NOT NULL", + "name TEXT NOT NULL", + "kind TEXT NOT NULL", + "data TEXT NULL", + `size ${integer} NOT NULL`, + `last_modified ${integer} NOT NULL`, + "media_type TEXT NULL", + ]; + return `CREATE TABLE IF NOT EXISTS ${q} (${columns.join(", ")})`; +} + +/** Builds one atomic row replacement using each dialect's native upsert form. */ +function getUpsertSql(table: string, dialect: Db0DialectType): string { + const q = quoteIdentifier(table, dialect); + const values = placeholders(9).join(", "); + const assignments = dialect === "mysql" + ? [ + "path=VALUES(path)", + "parent_path=VALUES(parent_path)", + "name=VALUES(name)", + "kind=VALUES(kind)", + "data=VALUES(data)", + "size=VALUES(size)", + "last_modified=VALUES(last_modified)", + "media_type=VALUES(media_type)", + ] + : [ + "path=excluded.path", + "parent_path=excluded.parent_path", + "name=excluded.name", + "kind=excluded.kind", + "data=excluded.data", + "size=excluded.size", + "last_modified=excluded.last_modified", + "media_type=excluded.media_type", + ]; + const conflict = dialect === "mysql" + ? `ON DUPLICATE KEY UPDATE ${assignments.join(", ")}` + : `ON CONFLICT (id) DO UPDATE SET ${assignments.join(", ")}`; + return `INSERT INTO ${q} (${DB0_WRITE_COLUMNS_SQL}) VALUES (${values}) ${conflict}`; +} + +/** + * Opens the db0-backed record store and optionally creates its portable schema. + * + * The table uses a SHA-256 path id as its primary key. This avoids MySQL's + * indexed-TEXT restrictions while preserving arbitrary path lengths in the + * separate `path` column. Directory queries use `parent_path` and can be indexed + * by an application migration if its workload needs it. Initialization uses only + * the SQL subset selected for db0's four current public dialect values. + * + * The Database is borrowed unless `disposeDatabase` is true. Callers that manage + * schema migrations centrally can set `initialize: false` after creating the + * required table themselves. + * + * @example Open only the record-store layer. + * ```ts + * const store = await openDb0RecordStore(database, { + * table: "opfs_entries", + * initialize: true, + * }); + * ``` + */ +export async function openDb0RecordStore( + database: Db0DatabaseType, + options: Db0AdapterOptionsType = {}, +): Promise { + const dialect = Db0DialectSchema.parse(database.dialect); + const table = SqlIdentifierSchema.parse(options.table ?? "opfs_entries"); + const q = quoteIdentifier(table, dialect); + if (options.initialize ?? true) { + const result = await database.prepare(getCreateTableSql(table, dialect)).run(); + if (!result.success) { + throw new Error(`db0 did not confirm initialization of table '${table}'.`); + } + } + + const idPlaceholder = placeholders(1)[0]; + const parentPlaceholder = placeholders(1)[0]; + const selectById = database.prepare( + `SELECT ${DB0_ROW_COLUMNS_SQL} FROM ${q} WHERE id = ${idPlaceholder}`, + ); + const selectChildren = database.prepare( + `SELECT ${DB0_ROW_COLUMNS_SQL} FROM ${q} WHERE parent_path = ${parentPlaceholder}`, + ); + const upsert = database.prepare(getUpsertSql(table, dialect)); + const remove = database.prepare(`DELETE FROM ${q} WHERE id = ${idPlaceholder}`); + + return { + async get(path) { + const row = await selectById.get(await getPathId(path)); + return row == null ? null : parseRow(row); + }, + async set(record) { + const params: Db0PrimitiveType[] = [ + await getPathId(record.path), + record.path, + record.parent, + record.name, + record.kind, + record.kind === "file" ? record.data : null, + record.kind === "file" ? record.size : 0, + record.lastModified, + record.kind === "file" ? record.mediaType : null, + ]; + const result = await upsert.run(...params); + if (!result.success) { + throw new Error(`db0 did not confirm the write for '${record.path}'.`); + } + }, + async delete(path) { + const result = await remove.run(await getPathId(path)); + if (!result.success) { + throw new Error(`db0 did not confirm removal for '${path}'.`); + } + }, + async *list(parent) { + const rows = await selectChildren.all(parent); + for (const row of rows) yield parseRow(row); + }, + async dispose() { + if (options.disposeDatabase) await database.dispose?.(); + }, + }; +} + +/** + * Creates an OPFS-shaped adapter over a db0 Database. + * + * The bridge supports db0's `sqlite`, `libsql`, `postgresql`, and `mysql` + * dialect values. The underlying connector remains owned by db0. The database + * is borrowed unless `disposeDatabase` is true. + * + * @example + * ```ts + * const adapter = await createDb0Adapter(database, { + * table: "opfs_entries", + * initialize: true, + * }); + * const fs = createFileSystem(adapter); + * ``` + */ +export async function createDb0Adapter( + database: Db0DatabaseType, + options: Db0AdapterOptionsType = {}, +): Promise { + return createRecordAdapter(await openDb0RecordStore(database, options), { + name: "db0", + disposeStore: true, + }); +} diff --git a/src/adapter/definition.ts b/src/adapter/definition.ts new file mode 100644 index 0000000..a64c070 --- /dev/null +++ b/src/adapter/definition.ts @@ -0,0 +1,183 @@ +import { AdapterCapabilitiesSchema, AdapterNameSchema } from "../schema.ts"; +import type { AdapterCapabilitiesType, CoordinationModeType, EntryKindType, WriteModeType } from "../schema.ts"; +import type { PathType } from "../path.ts"; + +/** Options shared by adapter operations that can stop early. */ +export interface AdapterSignalOptionsType { + /** Stops work that has not committed yet. */ + readonly signal?: AbortSignal; +} + +/** Byte-range options for an adapter read. */ +export interface AdapterReadOptionsType extends AdapterSignalOptionsType { + /** Zero-based byte offset. */ + readonly at?: number; + /** Maximum bytes to return after `at`. */ + readonly length?: number; +} + +/** Write semantics that an adapter must preserve. */ +export interface AdapterWriteOptionsType extends AdapterSignalOptionsType { + /** Relationship between new bytes and an existing file. */ + readonly mode: WriteModeType; + /** Zero-based offset used by `update`. */ + readonly at?: number; + /** Truncates the file at the final write cursor. */ + readonly truncate?: boolean; + /** Media type to retain when the adapter stores metadata. */ + readonly mediaType?: string; +} + +/** Options for an adapter-native move. */ +export interface AdapterMoveOptionsType extends AdapterSignalOptionsType { + /** Removes an existing destination before the move when required. */ + readonly overwrite: boolean; +} + +/** One direct child returned by an adapter directory iterator. */ +export interface AdapterDirectoryEntryType { + /** Child entry name without parent path components. */ + readonly name: string; + /** Child entry kind. */ + readonly kind: EntryKindType; +} + +/** Portable file metadata required by the facade. */ +export interface AdapterFileStatType { + /** Discriminator for file metadata. */ + readonly kind: "file"; + /** File byte length. */ + readonly size: number; + /** Last-modified time as Unix epoch milliseconds. */ + readonly lastModified: number; + /** Media type when known. Empty string means unknown. */ + readonly mediaType: string; +} + +/** Portable directory metadata required by the facade. */ +export interface AdapterDirectoryStatType { + /** Discriminator for directory metadata. */ + readonly kind: "directory"; + /** Last-modified time when the adapter can observe one. */ + readonly lastModified?: number; +} + +/** Portable entry metadata returned by an adapter. */ +export type AdapterStatType = AdapterFileStatType | AdapterDirectoryStatType; + +/** + * Synchronous random-access file owned by an adapter. + * + * The adapter owns the native runtime object. The caller owns the returned + * resource and must call `close()`. `flush()` means "ask the backend to make + * current writes durable"; the exact storage guarantee remains backend-specific. + */ +export interface AdapterSyncFileType { + /** Reads bytes into `buffer` and returns the number of bytes read. */ + read(buffer: ArrayBufferView, options?: { readonly at?: number }): number; + /** Writes bytes from `buffer` and returns the number of bytes written. */ + write(buffer: ArrayBufferView, options?: { readonly at?: number }): number; + /** Returns current byte length. */ + getSize(): number; + /** Changes current byte length. */ + truncate(size: number): void; + /** Requests backend durability for current writes. */ + flush(): void; + /** Releases the native file resource and any native file lock. */ + close(): void; +} + +/** + * Backend contract consumed by {@link createFileSystem}. + * + * Adapters receive canonical virtual paths. They own translation to browser + * handles, host paths, key-value keys, documents, or SQL rows. Required methods + * cover the smallest filesystem primitive set. Optional methods advertise a + * faster native path through `capabilities`. + * + * An adapter must not configure application logging or read process environment + * variables at import time. An adapter can own an injected resource only when + * its creation options say so explicitly. + */ +export interface AdapterType { + /** Stable diagnostic name such as `opfs`, `deno`, or `unstorage`. */ + readonly name: string; + /** Native operations available without facade emulation. */ + readonly capabilities: AdapterCapabilitiesType; + + /** Returns portable metadata, or `null` when the path does not exist. */ + stat(path: PathType, options?: AdapterSignalOptionsType): Promise; + /** Returns one materialized file or byte range. */ + readFile(path: PathType, options?: AdapterReadOptionsType): Promise; + /** Commits materialized bytes with the requested write semantics. */ + writeFile(path: PathType, data: Uint8Array, options: AdapterWriteOptionsType): Promise; + /** Lazily returns direct children of one directory. */ + readDir(path: PathType, options?: AdapterSignalOptionsType): AsyncIterableIterator; + /** Creates exactly one directory. Its parent must already exist. */ + createDir(path: PathType, options?: AdapterSignalOptionsType): Promise; + /** Removes one file or one empty directory. */ + remove(path: PathType, options?: AdapterSignalOptionsType): Promise; + + /** Opens a native streaming read when `capabilities.streamRead` is true. */ + openReadStream?(path: PathType, options?: AdapterReadOptionsType): Promise>; + /** Commits a stream without facade materialization when `capabilities.streamWrite` is true. */ + writeStream?(path: PathType, source: ReadableStream, options: AdapterWriteOptionsType): Promise; + /** Performs an adapter-native move when `capabilities.nativeMove` is true. */ + move?(source: PathType, destination: PathType, options: AdapterMoveOptionsType): Promise; + /** Opens synchronous random access when `capabilities.syncAccess` is true. */ + openSyncFile?(path: PathType): Promise; + /** Releases resources that this adapter explicitly owns. */ + dispose?(): void | Promise; +} + +/** Options that control the adapter-independent filesystem facade. */ +export interface FileSystemOptionsType { + /** Mutation coordination. `auto` prefers Web Locks and otherwise uses an in-realm FIFO lock. */ + readonly coordination?: CoordinationModeType; + /** Prefix used for Web Lock names. Use a stable application-specific value. */ + readonly lockPrefix?: string; + /** + * Maximum bytes materialized when a non-streaming adapter receives a stream. + * + * The default is 64 MiB. Set a lower value for memory-constrained workers or + * a higher value only when the selected record/database backend can accept it. + */ + readonly maxBufferedWriteBytes?: number; + /** Closes the adapter when the filesystem facade is disposed. */ + readonly disposeAdapter?: boolean; +} + +/** + * Identity helper for custom adapters. + * + * The function performs no registration and no import-time mutation. It exists + * to make custom adapter exports self-documenting while preserving the concrete + * adapter type. + * + * @example Define the minimum materialized adapter contract. + * ```ts + * const adapter = defineAdapter({ + * name: "provider", + * capabilities: { + * read: true, + * write: true, + * streamRead: false, + * streamWrite: false, + * rangeRead: false, + * nativeMove: false, + * syncAccess: false, + * }, + * async stat(path) { return null; }, + * async readFile(path) { return new Uint8Array(); }, + * async writeFile(path, data, options) {}, + * async *readDir(path) {}, + * async createDir(path) {}, + * async remove(path) {}, + * }); + * ``` + */ +export function defineAdapter(adapter: T): T { + AdapterNameSchema.parse(adapter.name); + AdapterCapabilitiesSchema.parse(adapter.capabilities); + return adapter; +} diff --git a/src/adapter/deno.ts b/src/adapter/deno.ts new file mode 100644 index 0000000..d9795fa --- /dev/null +++ b/src/adapter/deno.ts @@ -0,0 +1,208 @@ +/// +import { defineAdapter, type AdapterType } from "./definition.ts"; +import { createLocalPath } from "./local.ts"; +import { throwIfAborted, toFileSystemError } from "../error.ts"; + +/** Options for the Deno-native filesystem adapter. */ +export interface DenoAdapterOptionsType { + /** Host directory exposed as virtual `/`. */ + readonly root: string; + /** Creates the host root during adapter creation. Defaults to true. */ + readonly createRoot?: boolean; +} + +/** + * Creates an adapter backed by Deno file APIs. + * + * Production remains Deno-native: reads, writes, directory enumeration, rename, + * synchronous access, and flush all use `Deno.*`. `node:path` is used only for + * host-path normalization because Deno provides that compatibility module. + * + * @example Persist below one Deno host directory. + * ```ts + * import { createFileSystem } from "@okikio/opfs"; + * import { createDenoAdapter } from "@okikio/opfs/adapter/deno"; + * + * const fs = createFileSystem(createDenoAdapter({ root: "./data" }), { + * coordination: "local", + * }); + * await fs.writeFile("/cache/result.json", "{}", { parents: true }); + * ``` + */ +export function createDenoAdapter(options: DenoAdapterOptionsType): AdapterType { + const hostPath = createLocalPath(options.root); + if (options.createRoot ?? true) Deno.mkdirSync(hostPath("/"), { recursive: true }); + + return defineAdapter({ + name: "deno", + capabilities: { + read: true, + write: true, + streamRead: true, + streamWrite: true, + rangeRead: true, + nativeMove: true, + syncAccess: true, + }, + async stat(path, operationOptions) { + throwIfAborted(operationOptions?.signal, "stat", path); + try { + const info = await Deno.stat(hostPath(path)); + return info.isDirectory + ? { kind: "directory", ...(info.mtime === null ? {} : { lastModified: info.mtime.getTime() }) } + : { kind: "file", size: info.size, lastModified: info.mtime?.getTime() ?? 0, mediaType: "" }; + } catch (error) { + const mapped = toFileSystemError(error, "stat", path); + if (mapped.code === "not-found") return null; + throw mapped; + } + }, + async readFile(path, readOptions = {}) { + throwIfAborted(readOptions.signal, "read", path); + if (readOptions.at === undefined && readOptions.length === undefined) return await Deno.readFile(hostPath(path)); + const file = await Deno.open(hostPath(path), { read: true }); + try { + const info = await file.stat(); + const start = readOptions.at ?? 0; + const length = Math.max(0, Math.min(readOptions.length ?? info.size - start, info.size - start)); + await file.seek(start, Deno.SeekMode.Start); + const output = new Uint8Array(length); + let offset = 0; + while (offset < length) { + const count = await file.read(output.subarray(offset)); + if (count === null) break; + offset += count; + } + return offset === output.byteLength ? output : output.slice(0, offset); + } finally { + file.close(); + } + }, + async openReadStream(path, readOptions = {}) { + throwIfAborted(readOptions.signal, "read", path); + if (readOptions.at === undefined && readOptions.length === undefined) { + return (await Deno.open(hostPath(path), { read: true })).readable; + } + const bytes = await this.readFile(path, readOptions); + return new ReadableStream({ + start(controller) { + controller.enqueue(bytes); + controller.close(); + }, + }); + }, + async writeFile(path, data, writeOptions) { + throwIfAborted(writeOptions.signal, "write", path); + if (writeOptions.mode === "replace") { + await Deno.writeFile(hostPath(path), data, { create: true }); + return; + } + const file = await Deno.open(hostPath(path), { read: true, write: true, create: true }); + try { + const position = writeOptions.mode === "append" ? (await file.stat()).size : writeOptions.at ?? 0; + await file.seek(position, Deno.SeekMode.Start); + let offset = 0; + while (offset < data.byteLength) offset += await file.write(data.subarray(offset)); + if (writeOptions.truncate) await file.truncate(position + data.byteLength); + } finally { + file.close(); + } + }, + async writeStream(path, source, writeOptions) { + const file = await Deno.open(hostPath(path), { + read: true, + write: true, + create: true, + truncate: writeOptions.mode === "replace", + }); + try { + let position = writeOptions.mode === "append" + ? (await file.stat()).size + : writeOptions.mode === "update" + ? writeOptions.at ?? 0 + : 0; + await file.seek(position, Deno.SeekMode.Start); + const reader = source.getReader(); + try { + while (true) { + throwIfAborted(writeOptions.signal, "write", path); + const next = await reader.read(); + if (next.done) break; + let offset = 0; + while (offset < next.value.byteLength) { + offset += await file.write(next.value.subarray(offset)); + } + position += next.value.byteLength; + } + } catch (error) { + try { + await reader.cancel(error); + } catch { + // Preserve the first write or cancellation failure. + } + throw error; + } finally { + reader.releaseLock(); + } + if (writeOptions.truncate) await file.truncate(position); + } finally { + file.close(); + } + }, + async *readDir(path, operationOptions) { + throwIfAborted(operationOptions?.signal, "read-dir", path); + for await (const entry of Deno.readDir(hostPath(path))) { + throwIfAborted(operationOptions?.signal, "read-dir", path); + if (entry.isDirectory) yield { name: entry.name, kind: "directory" }; + else if (entry.isFile) yield { name: entry.name, kind: "file" }; + } + }, + async createDir(path, operationOptions) { + throwIfAborted(operationOptions?.signal, "mkdir", path); + await Deno.mkdir(hostPath(path)); + }, + async remove(path, operationOptions) { + throwIfAborted(operationOptions?.signal, "remove", path); + await Deno.remove(hostPath(path)); + }, + async move(source, destination, operationOptions) { + throwIfAborted(operationOptions.signal, "move", source); + await Deno.rename(hostPath(source), hostPath(destination)); + }, + async openSyncFile(path) { + const file = Deno.openSync(hostPath(path), { read: true, write: true }); + let cursor = 0; + return { + read(buffer, readOptions = {}) { + const target = new Uint8Array(buffer.buffer, buffer.byteOffset, buffer.byteLength); + const at = readOptions.at ?? cursor; + file.seekSync(at, Deno.SeekMode.Start); + const count = file.readSync(target) ?? 0; + cursor = at + count; + return count; + }, + write(buffer, writeOptions = {}) { + const source = new Uint8Array(buffer.buffer, buffer.byteOffset, buffer.byteLength); + const at = writeOptions.at ?? cursor; + file.seekSync(at, Deno.SeekMode.Start); + const count = file.writeSync(source); + cursor = at + count; + return count; + }, + getSize() { + return file.statSync().size; + }, + truncate(size) { + file.truncateSync(size); + if (cursor > size) cursor = size; + }, + flush() { + file.syncSync(); + }, + close() { + file.close(); + }, + }; + }, + }); +} diff --git a/src/adapter/drizzle.ts b/src/adapter/drizzle.ts new file mode 100644 index 0000000..7e374f6 --- /dev/null +++ b/src/adapter/drizzle.ts @@ -0,0 +1,233 @@ +import { eq, type AnyColumn } from "drizzle-orm"; +import type { AdapterType } from "./definition.ts"; +import { createRecordAdapter, type RecordStoreType } from "./record.ts"; +import { RecordSchema, type RecordType } from "../schema.ts"; + +/** + * Required Drizzle table columns. + * + * Define these columns with the dialect-specific Drizzle schema builder used by + * the application. The adapter intentionally does not own DDL because Drizzle + * is dialect-specific and column definitions differ across SQLite, PostgreSQL, + * MySQL, SingleStore, and driver-specific integrations. + */ +export interface DrizzleTableType { + /** Unique canonical path column. */ + readonly path: AnyColumn<{ data: string }>; + /** Canonical direct-parent path column. */ + readonly parent: AnyColumn<{ data: string }>; + /** Final entry name column. */ + readonly name: AnyColumn<{ data: string }>; + /** File/directory discriminator column. */ + readonly kind: AnyColumn<{ data: string }>; + /** Base64 file payload column. Directory rows store null. */ + readonly data: AnyColumn<{ data: string }>; + /** Decoded file size column using a JavaScript-number mode. */ + readonly size: AnyColumn<{ data: number }>; + /** Unix epoch millisecond column using a JavaScript-number mode. */ + readonly lastModified: AnyColumn<{ data: number }>; + /** File media-type column. Directory rows store null. */ + readonly mediaType: AnyColumn<{ data: string }>; +} + +/** Row shape expected from the supplied Drizzle table. */ +export interface DrizzleRowType { + /** Canonical virtual path stored in the caller table. */ + readonly path: string; + /** Canonical direct-parent path used for directory queries. */ + readonly parent: string; + /** Final file or directory name. */ + readonly name: string; + /** Persisted file/directory discriminator. */ + readonly kind: "file" | "directory"; + /** Base64 file payload, or null for directories. */ + readonly data: string | null; + /** Decoded file byte length. Directories use zero. */ + readonly size: number; + /** Unix epoch milliseconds for the logical record. */ + readonly lastModified: number; + /** File media type, or null for directories. */ + readonly mediaType: string | null; +} + +/** Options for the Drizzle-backed adapter. */ +export interface DrizzleAdapterOptionsType { + /** Connected Drizzle database from any supported driver. */ + readonly database: TDatabase; + /** Caller-defined dialect-specific table with the required columns. */ + readonly table: TTable; +} + +/** Small thenable contract shared by Drizzle query builders used by this bridge. */ +interface QueryPromiseType extends PromiseLike {} + +/** Selection stage that can apply a row limit. */ +interface SelectLimitType { + /** Limits the selected row count without changing the row shape. */ + limit(count: number): QueryPromiseType; +} + +/** Selection stage that accepts a Drizzle SQL condition. */ +interface SelectWhereType { + /** Applies one Drizzle SQL condition to the current selection. */ + where(condition: object): SelectLimitType & QueryPromiseType; +} + +/** Selection stage that binds the caller-provided table. */ +interface SelectFromType { + /** Binds the caller-owned table to the selection. */ + from(table: object): SelectWhereType; +} + +/** Delete builder subset required for path replacement and removal. */ +interface DeleteType { + /** Restricts deletion to rows selected by the supplied condition. */ + where(condition: object): QueryPromiseType; +} + +/** Insert builder subset required to persist one normalized row. */ +interface InsertValuesType { + /** Inserts one normalized filesystem row. */ + values(value: DrizzleRowType): QueryPromiseType; +} +/** + * Runtime CRUD surface common to the Drizzle database objects supported here. + * + * This is intentionally smaller than Drizzle's public generic types. Dialect + * schema and driver types remain owned by the caller instead of being erased + * into a false universal database type. + */ +interface DrizzleRuntimeType { + /** Starts an unprojected row selection. */ + select(): SelectFromType; + /** Starts one insert against the caller-owned table. */ + insert(table: object): InsertValuesType; + /** Starts one deletion against the caller-owned table. */ + delete(table: object): DeleteType; +} + +/** Validates the three CRUD builders required at runtime before any data is touched. */ +function getRuntime(database: object): DrizzleRuntimeType { + const candidate = database as Partial; + if ( + typeof candidate.select !== "function" || + typeof candidate.insert !== "function" || + typeof candidate.delete !== "function" + ) { + throw new TypeError("Drizzle database must expose select(), insert(), and delete()."); + } + return candidate as DrizzleRuntimeType; +} + +/** Converts a Drizzle row to the validated record format and restores version 1. */ +function toRecord(row: DrizzleRowType): RecordType { + if (row.kind === "directory") { + return RecordSchema.parse({ + version: 1, + path: row.path, + parent: row.parent, + name: row.name, + kind: "directory", + lastModified: row.lastModified, + }); + } + return RecordSchema.parse({ + version: 1, + path: row.path, + parent: row.parent, + name: row.name, + kind: "file", + data: row.data ?? "", + size: row.size, + lastModified: row.lastModified, + mediaType: row.mediaType ?? "", + }); +} + +/** Converts the shared record format into the caller table's logical row shape. */ +function toRow(record: RecordType): DrizzleRowType { + if (record.kind === "directory") { + return { + path: record.path, + parent: record.parent, + name: record.name, + kind: "directory", + data: null, + size: 0, + lastModified: record.lastModified, + mediaType: null, + }; + } + return { + path: record.path, + parent: record.parent, + name: record.name, + kind: "file", + data: record.data, + size: record.size, + lastModified: record.lastModified, + mediaType: record.mediaType, + }; +} + +/** + * Creates a record store over a connected Drizzle database and caller table. + * + * This integration deliberately uses the ORM's common `select`, `insert`, and + * `delete` query builders. It avoids dialect-specific upsert syntax by replacing + * one path with delete-then-insert. The filesystem facade serializes same-path + * writes in a realm, but applications with multiple server processes should add + * database-level serialization when they require cross-process atomic replace. + * + * The database and table are borrowed. The adapter never disposes the database + * and never creates or migrates the table. + * + * @example Build only the record-store projection. + * ```ts + * const store = createDrizzleRecordStore({ database, table: files }); + * const adapter = createRecordAdapter(store, { name: "drizzle" }); + * ``` + */ +export function createDrizzleRecordStore( + options: DrizzleAdapterOptionsType, +): RecordStoreType { + const database = getRuntime(options.database); + const table = options.table; + return { + async get(path) { + const rows = await database.select().from(table).where(eq(table.path, path)).limit(1); + const row = rows[0]; + return row === undefined ? null : toRecord(row); + }, + async set(record) { + await database.delete(table).where(eq(table.path, record.path)); + await database.insert(table).values(toRow(record)); + }, + async delete(path) { + await database.delete(table).where(eq(table.path, path)); + }, + async *list(parent) { + const rows = await database.select().from(table).where(eq(table.parent, parent)); + for (const row of rows) yield toRecord(row); + }, + }; +} + +/** + * Creates an OPFS-shaped adapter over a Drizzle database and caller-owned table. + * + * The table must make `path` unique and provide every property in + * {@link DrizzleTableType}. Replacement is delete-then-insert, so applications + * with multiple writing processes must add database-level serialization when + * they need cross-process atomic replacement. + * + * @example SQLite table shape + * ```ts + * const fs = createFileSystem(createDrizzleAdapter({ database, table: files })); + * ``` + */ +export function createDrizzleAdapter( + options: DrizzleAdapterOptionsType, +): AdapterType { + return createRecordAdapter(createDrizzleRecordStore(options), { name: "drizzle" }); +} diff --git a/src/adapter/local.ts b/src/adapter/local.ts new file mode 100644 index 0000000..6e63547 --- /dev/null +++ b/src/adapter/local.ts @@ -0,0 +1,17 @@ +import { resolve, sep } from "node:path"; +import { normalizePath } from "../path.ts"; + +/** Internal validated host-root mapping shared by Deno, Node, and Bun adapters. */ +export function createLocalPath(root: string): (path: string) => string { + const absoluteRoot = resolve(root); + const rootPrefix = absoluteRoot.endsWith(sep) ? absoluteRoot : `${absoluteRoot}${sep}`; + return (path: string): string => { + const virtual = normalizePath(path); + if (virtual === "/") return absoluteRoot; + const output = resolve(absoluteRoot, `.${virtual}`); + if (output !== absoluteRoot && !output.startsWith(rootPrefix)) { + throw new TypeError(`Virtual path '${path}' resolved outside host root '${absoluteRoot}'.`); + } + return output; + }; +} diff --git a/src/adapter/memory.ts b/src/adapter/memory.ts new file mode 100644 index 0000000..e44137a --- /dev/null +++ b/src/adapter/memory.ts @@ -0,0 +1,62 @@ +import type { AdapterType } from "./definition.ts"; +import { createRecordAdapter, type RecordStoreType } from "./record.ts"; +import type { RecordType } from "../schema.ts"; + +/** In-memory record store useful for tests, demos, and temporary data. */ +export interface MemoryRecordStoreType extends RecordStoreType { + /** Removes every stored record. */ + clear(): void; + /** Number of stored files and directories, excluding the implicit root. */ + readonly size: number; +} + +/** + * Creates a deterministic in-memory record store. + * + * The returned store owns one Map and has no external resources. Records are + * cloned on read and write so tests cannot mutate persistence by retaining an + * object reference. The store is process-local and disappears with the realm. + * + * @example Inspect records while testing a record-store wrapper. + * ```ts + * const store = createMemoryRecordStore(); + * await store.set(record); + * assertEquals(store.size, 1); + * store.clear(); + * ``` + */ +export function createMemoryRecordStore(): MemoryRecordStoreType { + const records = new Map(); + return { + get size() { return records.size; }, + clear() { records.clear(); }, + async get(path) { return records.get(path) ?? null; }, + async set(record) { records.set(record.path, structuredClone(record)); }, + async delete(path) { records.delete(path); }, + async *list(parent) { + for (const record of records.values()) { + if (record.parent === parent) yield structuredClone(record); + } + }, + }; +} + +/** + * Creates an in-memory OPFS-shaped adapter. + * + * It uses the same record adapter as RxDB, unstorage, db0, and Drizzle, which + * makes it useful for testing facade semantics without a browser or database. + * It is not durable and does not advertise native streaming or sync access. + * + * @example Use File System API-shaped handles without browser OPFS. + * ```ts + * const fileSystem = createFileSystem(createMemoryAdapter()); + * const file = await fileSystem.root.getFileHandle("hello.txt", { create: true }); + * const writable = await file.createWritable(); + * await writable.write("hello"); + * await writable.close(); + * ``` + */ +export function createMemoryAdapter(): AdapterType { + return createRecordAdapter(createMemoryRecordStore(), { name: "memory" }); +} diff --git a/src/adapter/node.ts b/src/adapter/node.ts new file mode 100644 index 0000000..d74677b --- /dev/null +++ b/src/adapter/node.ts @@ -0,0 +1,272 @@ +import type { FileHandle as NodeFileHandle } from "node:fs/promises"; +import { defineAdapter, type AdapterType, type AdapterWriteOptionsType } from "./definition.ts"; +import { createLocalPath } from "./local.ts"; +import { throwIfAborted, toFileSystemError } from "../error.ts"; + +/** Options for a Node filesystem adapter. */ +export interface NodeAdapterOptionsType { + /** Host directory exposed as virtual `/`. */ + readonly root: string; + /** Creates the host root during adapter creation. Defaults to true. */ + readonly createRoot?: boolean; +} + +/** + * Drains a Web byte stream into one Node file descriptor. + * + * The descriptor stays open for the full stream so sequential chunks do not + * repeatedly open the file. Partial writes advance `position` until each chunk + * is fully committed. On failure the producer is cancelled before the file is + * closed, which prevents an upstream stream from continuing useless work. + */ +async function writeStreamToFile( + path: string, + source: ReadableStream, + options: AdapterWriteOptionsType, +): Promise { + const { open } = globalThis?.process?.getBuiltinModule?.("node:fs/promises"); + let file: NodeFileHandle | undefined; + try { + file = await open(path, options.mode === "replace" ? "w+" : "a+"); + let position = options.mode === "replace" + ? 0 + : options.mode === "append" + ? (await file.stat()).size + : options.at ?? 0; + if (options.mode === "update") { + await file.close(); + file = await open(path, "r+").catch(async (error) => { + if (toFileSystemError(error, "write", path).code !== "not-found") throw error; + return await open(path, "w+"); + }); + } + const reader = source.getReader(); + try { + while (true) { + throwIfAborted(options.signal, "write", path); + const next = await reader.read(); + if (next.done) break; + let offset = 0; + while (offset < next.value.byteLength) { + const result = await file.write(next.value, offset, next.value.byteLength - offset, position); + if (result.bytesWritten <= 0) { + throw new Error(`Node write made no progress for '${path}'.`); + } + offset += result.bytesWritten; + position += result.bytesWritten; + } + } + } catch (error) { + try { + await reader.cancel(error); + } catch { + // Preserve the first failure. + } + throw error; + } finally { + reader.releaseLock(); + } + if (options.truncate) await file.truncate(position); + } finally { + await file?.close(); + } +} + +/** + * Creates an adapter over Node's `node:fs` APIs. + * + * The adapter maps virtual `/` to `root`. It never exposes host paths through + * the facade. Synchronous access uses Node file descriptors and therefore works + * in the main thread as well as worker threads. The caller owns the adapter + * unless `createFileSystem(..., { disposeAdapter: true })` transfers disposal. + * + * @example Use OPFS-shaped handles over a host directory. + * ```ts + * import { createFileSystem } from "@okikio/opfs"; + * import { createNodeAdapter } from "@okikio/opfs/adapter/node"; + * + * const fs = createFileSystem(createNodeAdapter({ root: "./data" })); + * const file = await fs.root.getFileHandle("state.json", { create: true }); + * const writable = await file.createWritable(); + * await writable.write("{}"); + * await writable.close(); + * ``` + */ +export function createNodeAdapter(options: NodeAdapterOptionsType): AdapterType { + const { + closeSync, + createReadStream, + fstatSync, + fsyncSync, + ftruncateSync, + mkdirSync, + openSync, + readSync, + writeSync, + } = globalThis?.process?.getBuiltinModule?.("node:fs"); + + const { + appendFile, + mkdir, + open, + readFile, + readdir, + rename, + rm, + stat, + writeFile, + } = globalThis?.process?.getBuiltinModule?.("node:fs/promises"); + + const hostPath = createLocalPath(options.root); + if (options.createRoot ?? true) mkdirSync(hostPath("/"), { recursive: true }); + + return defineAdapter({ + name: "node", + capabilities: { + read: true, + write: true, + streamRead: true, + streamWrite: true, + rangeRead: true, + nativeMove: true, + syncAccess: true, + }, + async stat(path, operationOptions) { + throwIfAborted(operationOptions?.signal, "stat", path); + try { + const info = await stat(hostPath(path)); + return info.isDirectory() + ? { kind: "directory", lastModified: info.mtimeMs } + : { kind: "file", size: info.size, lastModified: info.mtimeMs, mediaType: "" }; + } catch (error) { + const mapped = toFileSystemError(error, "stat", path); + if (mapped.code === "not-found") return null; + throw mapped; + } + }, + async readFile(path, readOptions = {}) { + throwIfAborted(readOptions.signal, "read", path); + if (readOptions.at === undefined && readOptions.length === undefined) { + return new Uint8Array(await readFile(hostPath(path))); + } + const file = await open(hostPath(path), "r"); + try { + const info = await file.stat(); + const start = readOptions.at ?? 0; + const length = Math.max(0, Math.min(readOptions.length ?? info.size - start, info.size - start)); + const output = new Uint8Array(length); + let offset = 0; + while (offset < length) { + const result = await file.read(output, offset, length - offset, start + offset); + if (result.bytesRead === 0) break; + offset += result.bytesRead; + } + return offset === output.byteLength ? output : output.slice(0, offset); + } finally { + await file.close(); + } + }, + async openReadStream(path, readOptions = {}) { + const { Readable } = globalThis?.process?.getBuiltinModule?.("node:stream"); + + throwIfAborted(readOptions.signal, "read", path); + const start = readOptions.at ?? 0; + const end = readOptions.length === undefined ? undefined : Math.max(start, start + readOptions.length - 1); + const stream = createReadStream(hostPath(path), { start, ...(end === undefined ? {} : { end }) }); + return Readable.toWeb(stream, { type: "bytes" }) as unknown as ReadableStream; + }, + async writeFile(path, data, writeOptions) { + throwIfAborted(writeOptions.signal, "write", path); + const target = hostPath(path); + if (writeOptions.mode === "replace") { + await writeFile(target, data); + return; + } + if (writeOptions.mode === "append") { + await appendFile(target, data); + return; + } + const file = await open(target, "r+").catch(async (error) => { + if (toFileSystemError(error, "write", path).code !== "not-found") throw error; + return await open(target, "w+"); + }); + try { + const position = writeOptions.at ?? 0; + let offset = 0; + while (offset < data.byteLength) { + const result = await file.write(data, offset, data.byteLength - offset, position + offset); + if (result.bytesWritten <= 0) { + throw new Error(`Node write made no progress for '${path}'.`); + } + offset += result.bytesWritten; + } + if (writeOptions.truncate) await file.truncate(position + data.byteLength); + } finally { + await file.close(); + } + }, + async writeStream(path, source, writeOptions) { + await writeStreamToFile(hostPath(path), source, writeOptions); + }, + async *readDir(path, operationOptions) { + throwIfAborted(operationOptions?.signal, "read-dir", path); + for (const entry of await readdir(hostPath(path), { withFileTypes: true })) { + throwIfAborted(operationOptions?.signal, "read-dir", path); + if (entry.isDirectory()) yield { name: entry.name, kind: "directory" }; + else if (entry.isFile()) yield { name: entry.name, kind: "file" }; + } + }, + async createDir(path, operationOptions) { + throwIfAborted(operationOptions?.signal, "mkdir", path); + await mkdir(hostPath(path)); + }, + async remove(path, operationOptions) { + throwIfAborted(operationOptions?.signal, "remove", path); + await rm(hostPath(path)); + }, + async move(source, destination, operationOptions) { + throwIfAborted(operationOptions.signal, "move", source); + await rename(hostPath(source), hostPath(destination)); + }, + async openSyncFile(path) { + const descriptor = openSync(hostPath(path), "r+"); + let cursor = 0; + let closed = false; + const getDescriptor = () => { + if (closed) throw new Error(`Sync file '${path}' is closed.`); + return descriptor; + }; + return { + read(buffer, readOptions = {}) { + const target = new Uint8Array(buffer.buffer, buffer.byteOffset, buffer.byteLength); + const position = readOptions.at ?? cursor; + const count = readSync(getDescriptor(), target, 0, target.byteLength, position); + cursor = position + count; + return count; + }, + write(buffer, writeOptions = {}) { + const source = new Uint8Array(buffer.buffer, buffer.byteOffset, buffer.byteLength); + const position = writeOptions.at ?? cursor; + const count = writeSync(getDescriptor(), source, 0, source.byteLength, position); + cursor = position + count; + return count; + }, + getSize() { + return fstatSync(getDescriptor()).size; + }, + truncate(size) { + ftruncateSync(getDescriptor(), size); + if (cursor > size) cursor = size; + }, + flush() { + fsyncSync(getDescriptor()); + }, + close() { + if (closed) return; + closed = true; + closeSync(descriptor); + }, + }; + }, + }); +} diff --git a/src/adapter/opfs.ts b/src/adapter/opfs.ts new file mode 100644 index 0000000..124df3a --- /dev/null +++ b/src/adapter/opfs.ts @@ -0,0 +1,290 @@ +import type { + AdapterDirectoryEntryType, + AdapterReadOptionsType, + AdapterStatType, + AdapterSyncFileType, + AdapterType, + AdapterWriteOptionsType, + FileSystemOptionsType, +} from "./definition.ts"; +import { defineAdapter } from "./definition.ts"; +import { FileSystemError, throwIfAborted, toFileSystemError } from "../error.ts"; +import { createFileSystem, type FileSystemType } from "../filesystem.ts"; +import { basename, dirname, ROOT_PATH, splitPath } from "../path.ts"; + +/** Minimal file handle shape needed from browser OPFS. */ +interface NativeFileHandleType { + /** Native File System API discriminator. */ + readonly kind: "file"; + /** Native direct-entry name. */ + readonly name: string; + /** Returns the browser's immutable File snapshot. */ + getFile(): Promise; + /** Opens the browser's staged writable stream. */ + createWritable(options?: { keepExistingData?: boolean }): Promise; + /** Opens worker-only synchronous access when this context exposes it. */ + createSyncAccessHandle?: () => Promise; +} + +/** Minimal directory handle shape needed from browser OPFS. */ +interface NativeDirectoryHandleType { + /** Native File System API discriminator. */ + readonly kind: "directory"; + /** Native direct-entry name. */ + readonly name: string; + /** Opens or creates one direct child file. */ + getFileHandle(name: string, options?: { create?: boolean }): Promise; + /** Opens or creates one direct child directory. */ + getDirectoryHandle(name: string, options?: { create?: boolean }): Promise; + /** Removes one direct child using browser-native filesystem semantics. */ + removeEntry(name: string, options?: { recursive?: boolean }): Promise; + /** Lazily iterates native direct-child handles. */ + entries(): AsyncIterableIterator<[string, NativeFileHandleType | NativeDirectoryHandleType]>; +} + +/** OPFS adapter with its native root retained for advanced browser interop. */ +export interface OpfsAdapterType extends AdapterType { + /** Native origin-private directory root. */ + readonly nativeRoot: FileSystemDirectoryHandle; +} + +/** Options for opening the browser's current origin-private filesystem. */ +export type OpenFileSystemOptionsType = FileSystemOptionsType; + +/** Resolves a canonical virtual directory path one OPFS handle at a time. */ +async function getDirectory(root: NativeDirectoryHandleType, path: string): Promise { + let current = root; + for (const part of splitPath(path)) current = await current.getDirectoryHandle(part); + return current; +} + +/** Resolves a file through its parent directory and optionally creates the final entry. */ +async function getFile(root: NativeDirectoryHandleType, path: string, create = false): Promise { + const parent = await getDirectory(root, dirname(path)); + return await parent.getFileHandle(basename(path), { create }); +} + +/** Slices a File snapshot before streaming so range reads do not expose unrelated bytes. */ +function getStream(file: File, options: AdapterReadOptionsType): ReadableStream { + const at = options.at ?? 0; + const end = options.length === undefined ? file.size : Math.min(file.size, at + options.length); + return file.slice(at, end).stream() as ReadableStream; +} + +/** + * Determines entry kind without creating anything. + * + * OPFS has separate file and directory lookup methods. A type mismatch from the + * first lookup is therefore a normal branch, not a failure: the adapter tries + * the other kind before concluding that the path is absent. + */ +async function getStat(root: NativeDirectoryHandleType, path: string): Promise { + if (path === ROOT_PATH) return { kind: "directory" }; + try { + const handle = await getFile(root, path); + const file = await handle.getFile(); + return { kind: "file", size: file.size, lastModified: file.lastModified, mediaType: file.type }; + } catch (error) { + const mapped = toFileSystemError(error, "stat", path); + if (mapped.code !== "not-found" && mapped.code !== "type-mismatch") throw mapped; + } + + try { + await getDirectory(root, path); + return { kind: "directory" }; + } catch (error) { + const mapped = toFileSystemError(error, "stat", path); + if (mapped.code === "not-found" || mapped.code === "type-mismatch") return null; + throw mapped; + } +} + +/** + * Streams bytes into one native OPFS writable and preserves native staging. + * + * `createWritable()` commits on close. If reading the producer or writing a + * chunk fails, this function cancels the producer and aborts the writable so a + * partially staged image never becomes the visible file. + */ +async function writeToNative( + handle: NativeFileHandleType, + source: ReadableStream, + options: AdapterWriteOptionsType, + path: string, +): Promise { + const keepExistingData = options.mode !== "replace"; + const writable = await handle.createWritable({ keepExistingData }); + let cursor = 0; + try { + if (options.mode === "append") { + cursor = (await handle.getFile()).size; + await writable.seek(cursor); + } else if (options.mode === "update") { + cursor = options.at ?? 0; + await writable.seek(cursor); + } + + const reader = source.getReader(); + try { + while (true) { + throwIfAborted(options.signal, "write", path); + const next = await reader.read(); + if (next.done) break; + await writable.write(next.value); + cursor += next.value.byteLength; + } + } catch (error) { + try { + await reader.cancel(error); + } catch { + // Preserve the first write or cancellation failure. + } + throw error; + } finally { + reader.releaseLock(); + } + + if (options.truncate) await writable.truncate(cursor); + await writable.close(); + } catch (error) { + try { + await writable.abort(error); + } catch { + // The write failure is the useful diagnostic if abort also fails. + } + throw error; + } +} + + +/** Returns whether the current runtime exposes the dedicated-worker sync file method. */ +function supportsSyncAccessHandle(): 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"; +} + +/** + * Creates an adapter over an already acquired native OPFS root. + * + * The adapter borrows `root`; disposing the adapter does not dispose browser + * storage because the File System API has no root-close operation. `nativeRoot` + * remains available for advanced code that must interoperate with a real browser + * handle outside the facade. + * + * @example Wrap an already-acquired native root. + * ```ts + * const root = await navigator.storage.getDirectory(); + * const fileSystem = createFileSystem(createOpfsAdapter(root)); + * await fileSystem.writeFile("/state.json", "{}", { parents: true }); + * ``` + */ +export function createOpfsAdapter(root: FileSystemDirectoryHandle): OpfsAdapterType { + const nativeRoot = root as unknown as NativeDirectoryHandleType; + return defineAdapter({ + name: "opfs", + nativeRoot: root, + capabilities: { + read: true, + write: true, + streamRead: true, + streamWrite: true, + rangeRead: true, + nativeMove: false, + syncAccess: supportsSyncAccessHandle(), + }, + async stat(path, options) { + throwIfAborted(options?.signal, "stat", path); + return await getStat(nativeRoot, path); + }, + async readFile(path, options = {}) { + throwIfAborted(options.signal, "read", path); + const file = await (await getFile(nativeRoot, path)).getFile(); + return new Uint8Array(await new Response(getStream(file, options)).arrayBuffer()); + }, + async openReadStream(path, options = {}) { + throwIfAborted(options.signal, "read", path); + return getStream(await (await getFile(nativeRoot, path)).getFile(), options); + }, + async writeFile(path, data, options) { + const stream = new ReadableStream({ + start(controller) { + controller.enqueue(data); + controller.close(); + }, + }); + await writeToNative(await getFile(nativeRoot, path, true), stream, options, path); + }, + async writeStream(path, source, options) { + await writeToNative(await getFile(nativeRoot, path, true), source, options, path); + }, + async *readDir(path, options) { + throwIfAborted(options?.signal, "read-dir", path); + const directory = await getDirectory(nativeRoot, path); + for await (const [name, handle] of directory.entries()) { + throwIfAborted(options?.signal, "read-dir", path); + yield { name, kind: handle.kind } satisfies AdapterDirectoryEntryType; + } + }, + async createDir(path, options) { + throwIfAborted(options?.signal, "mkdir", path); + const parent = await getDirectory(nativeRoot, dirname(path)); + await parent.getDirectoryHandle(basename(path), { create: true }); + }, + async remove(path, options) { + throwIfAborted(options?.signal, "remove", path); + const parent = await getDirectory(nativeRoot, dirname(path)); + await parent.removeEntry(basename(path)); + }, + async openSyncFile(path) { + const handle = await getFile(nativeRoot, path); + if (handle.createSyncAccessHandle === undefined) { + throw new FileSystemError( + "not-supported", + "open-sync-file", + path, + "This browser context does not expose createSyncAccessHandle().", + ); + } + return await handle.createSyncAccessHandle(); + }, + }); +} + +/** + * Opens the current origin-private filesystem and returns the adapter-independent facade. + * + * Importing this module performs no storage access. The browser root is acquired + * only when this function runs, so unsupported/private/opaque contexts fail at + * the call site and can be inspected with `probeOpfs()` first. No browser-name + * or private-mode detection is used; the call reports the capability the current + * storage context actually grants. + * + * @example Open native OPFS from a secure browser context. + * ```ts + * const fileSystem = await openFileSystem({ coordination: "auto" }); + * await fileSystem.ensureDir("/cache"); + * ``` + */ +export async function openFileSystem(options: OpenFileSystemOptionsType = {}): Promise { + const navigatorValue = Reflect.get(globalThis, "navigator") as + | { storage?: { getDirectory?: () => Promise } } + | undefined; + if (typeof navigatorValue?.storage?.getDirectory !== "function") { + throw new FileSystemError( + "unavailable", + "open", + undefined, + "navigator.storage.getDirectory() is unavailable in this context.", + ); + } + try { + const root = await navigatorValue.storage.getDirectory(); + return createFileSystem(createOpfsAdapter(root), options); + } catch (error) { + throw toFileSystemError(error, "open"); + } +} diff --git a/src/adapter/record.ts b/src/adapter/record.ts new file mode 100644 index 0000000..2e0c5ec --- /dev/null +++ b/src/adapter/record.ts @@ -0,0 +1,190 @@ +import type { AdapterType } from "./definition.ts"; +import { defineAdapter } from "./definition.ts"; +import { FileSystemError, throwIfAborted } from "../error.ts"; +import { basename, dirname, ROOT_PATH, type PathType } from "../path.ts"; +import { RecordSchema, type RecordType } from "../schema.ts"; + +/** + * Persistence contract used by value/document/SQL ecosystem bridges. + * + * `list(parent)` returns direct children only. Stores own indexing choices. + * The filesystem adapter borrows the store unless a store implementation says + * otherwise through its own creation options. + */ +export interface RecordStoreType { + /** Returns one record by canonical path, or null when absent. */ + get(path: PathType): Promise; + /** Atomically replaces one logical record as far as the backend permits. */ + set(record: RecordType): Promise; + /** Deletes one record. The record adapter removes descendants separately. */ + delete(path: PathType): Promise; + /** Lazily returns direct child records. */ + list(parent: PathType): AsyncIterableIterator; + /** Releases resources explicitly owned by this store. */ + dispose?(): void | Promise; +} + +/** Options for a filesystem adapter created from a record store. */ +export interface RecordAdapterOptionsType { + /** Diagnostic adapter name. Defaults to `record`. */ + readonly name?: string; + /** Disposes the record store when the adapter is disposed. */ + readonly disposeStore?: boolean; + /** Rejects every mutating operation. Useful for read-only unstorage drivers. */ + readonly readOnly?: boolean; +} + +/** Encodes bytes in bounded chunks so large arrays do not overflow function-argument limits. */ +function encodeBase64(bytes: Uint8Array): string { + let binary = ""; + const chunkSize = 0x8000; + for (let offset = 0; offset < bytes.byteLength; offset += chunkSize) { + binary += String.fromCharCode(...bytes.subarray(offset, Math.min(bytes.byteLength, offset + chunkSize))); + } + return btoa(binary); +} + +/** Decodes the portable base64 representation used by JSON/document/SQL stores. */ +function decodeBase64(value: string): Uint8Array { + const binary = atob(value); + const bytes = new Uint8Array(binary.length); + for (let index = 0; index < binary.length; index += 1) bytes[index] = binary.charCodeAt(index); + return bytes; +} + +/** + * Applies filesystem write semantics to one materialized record image. + * + * Record stores cannot update an arbitrary byte range natively, so append and + * update build the next complete byte image before the record is replaced. + */ +function applyWrite( + existing: Uint8Array, + data: Uint8Array, + mode: "replace" | "append" | "update", + at: number | undefined, + truncate: boolean, +): Uint8Array { + if (mode === "replace") return data.slice(); + const position = mode === "append" ? existing.byteLength : at ?? 0; + const size = Math.max(existing.byteLength, position + data.byteLength); + let output = new Uint8Array(size); + output.set(existing); + output.set(data, position); + if (truncate) output = output.slice(0, position + data.byteLength); + return output; +} + +/** Fails before touching a record store when the adapter was intentionally opened read-only. */ +function assertWritable(readOnly: boolean, operation: string, path: string): void { + if (readOnly) { + throw new FileSystemError( + "permission-denied", + operation, + path, + `Adapter is configured read-only; '${path}' cannot be changed.`, + ); + } +} + +/** + * Creates a filesystem adapter over a generic record store. + * + * This is the common implementation used by RxDB, unstorage, db0, and Drizzle. + * It intentionally reports no native streaming capability because a complete + * base64 record is the durable unit in those ecosystems. The facade therefore + * applies `maxBufferedWriteBytes` when a caller streams into this adapter. + * + * @example Build a filesystem over a custom document store. + * ```ts + * const adapter = createRecordAdapter(store, { name: "documents" }); + * const fs = createFileSystem(adapter); + * await fs.writeFile("/state.json", "{}", { parents: true }); + * ``` + */ +export function createRecordAdapter(store: RecordStoreType, options: RecordAdapterOptionsType = {}): AdapterType { + const readOnly = options.readOnly ?? false; + return defineAdapter({ + name: options.name ?? "record", + capabilities: { + read: true, + write: !readOnly, + streamRead: false, + streamWrite: false, + rangeRead: false, + nativeMove: false, + syncAccess: false, + }, + async stat(path, operationOptions) { + throwIfAborted(operationOptions?.signal, "stat", path); + if (path === ROOT_PATH) return { kind: "directory" }; + const record = await store.get(path); + if (record === null) return null; + if (record.kind === "directory") return { kind: "directory", lastModified: record.lastModified }; + return { kind: "file", size: record.size, lastModified: record.lastModified, mediaType: record.mediaType }; + }, + async readFile(path, readOptions = {}) { + throwIfAborted(readOptions.signal, "read", path); + const record = await store.get(path); + if (record === null) throw new FileSystemError("not-found", "read", path, `File '${path}' does not exist.`); + if (record.kind !== "file") throw new FileSystemError("type-mismatch", "read", path, `'${path}' is a directory.`); + const bytes = decodeBase64(record.data); + const start = readOptions.at ?? 0; + const end = readOptions.length === undefined + ? bytes.byteLength + : Math.min(bytes.byteLength, start + readOptions.length); + return bytes.slice(start, end); + }, + async writeFile(path, data, writeOptions) { + assertWritable(readOnly, "write", path); + throwIfAborted(writeOptions.signal, "write", path); + const previous = await store.get(path); + if (previous?.kind === "directory") { + throw new FileSystemError("type-mismatch", "write", path, `'${path}' is a directory.`); + } + const existing = previous?.kind === "file" ? decodeBase64(previous.data) : new Uint8Array(); + const bytes = applyWrite(existing, data, writeOptions.mode, writeOptions.at, writeOptions.truncate ?? false); + await store.set(RecordSchema.parse({ + version: 1, + path, + parent: dirname(path), + name: basename(path), + kind: "file", + data: encodeBase64(bytes), + size: bytes.byteLength, + lastModified: Date.now(), + mediaType: writeOptions.mediaType ?? (previous?.kind === "file" ? previous.mediaType : ""), + })); + }, + async *readDir(path, operationOptions) { + throwIfAborted(operationOptions?.signal, "read-dir", path); + for await (const record of store.list(path)) { + throwIfAborted(operationOptions?.signal, "read-dir", path); + yield { name: record.name, kind: record.kind }; + } + }, + async createDir(path, operationOptions) { + assertWritable(readOnly, "mkdir", path); + throwIfAborted(operationOptions?.signal, "mkdir", path); + const existing = await store.get(path); + if (existing?.kind === "file") throw new FileSystemError("type-mismatch", "mkdir", path, `'${path}' is a file.`); + if (existing !== null) return; + await store.set(RecordSchema.parse({ + version: 1, + path, + parent: dirname(path), + name: basename(path), + kind: "directory", + lastModified: Date.now(), + })); + }, + async remove(path, operationOptions) { + assertWritable(readOnly, "remove", path); + throwIfAborted(operationOptions?.signal, "remove", path); + await store.delete(path); + }, + async dispose() { + if (options.disposeStore) await store.dispose?.(); + }, + }); +} diff --git a/src/adapter/rxdb.ts b/src/adapter/rxdb.ts new file mode 100644 index 0000000..cec13dd --- /dev/null +++ b/src/adapter/rxdb.ts @@ -0,0 +1,184 @@ +import type { AdapterType } from "./definition.ts"; +import { createRecordAdapter, type RecordStoreType } from "./record.ts"; +import { FileSystemError } from "../error.ts"; +import { RecordSchema, type RecordType } from "../schema.ts"; + +/** + * Maximum canonical path length encoded in the exported RxDB primary/index schema. + * + * RxDB requires `maxLength` on indexed string fields. Keeping one constant makes + * runtime validation and the exported collection schema agree exactly. + */ +const RXDB_PATH_MAX_LENGTH = 4096; + +/** RxDB document methods used by the bridge. */ +export interface RxDbDocumentType { + /** Returns document data without RxDB revision metadata. */ + toJSON(withRevisionAndAttachments?: false): unknown; + /** Removes the latest revision safely when concurrent writes are possible. */ + incrementalRemove(): Promise; +} + +/** RxDB query result shape used by the bridge. */ +export interface RxDbQueryType { + /** Executes the query against the collection's configured RxStorage. */ + exec(): Promise; +} + +/** + * Structural subset of RxCollection used by this adapter. + * + * RxDB collections sit above `RxStorage`, so the same bridge works when the + * database was created with memory, IndexedDB, OPFS, filesystem, SQLite, + * DenoKV, MongoDB, or another compatible RxStorage implementation. + */ +export interface RxDbCollectionType { + /** Finds one document by its primary `path`. */ + findOne(primary: string): RxDbQueryType; + /** Finds records by the indexed `parent` field. */ + find(query: { readonly selector: { readonly parent: string } }): RxDbQueryType; + /** Inserts or incrementally replaces one path record. */ + incrementalUpsert(record: RecordType): Promise; +} + +/** + * RxJSONSchema to use for the collection supplied to {@link createRxDbAdapter}. + * + * `path` is the primary key and `parent` is indexed because directory reads are + * direct-parent queries. File bytes remain base64 strings to keep documents + * structured-cloneable across every RxStorage transport. + */ +export const RxDbRecordJsonSchema = Object.freeze({ + title: "OPFS filesystem record", + description: "One canonical file or directory record used by the @okikio/opfs RxDB adapter.", + version: 0, + primaryKey: "path", + type: "object", + properties: { + version: { + type: "number", + description: "@okikio/opfs record format version. This is independent of the RxDB schema version.", + minimum: 1, + maximum: 1, + multipleOf: 1, + }, + path: { + type: "string", + description: "Canonical virtual filesystem path and RxDB primary key.", + maxLength: RXDB_PATH_MAX_LENGTH, + }, + parent: { + type: "string", + description: "Canonical direct-parent path used for directory listing queries.", + maxLength: RXDB_PATH_MAX_LENGTH, + }, + name: { + type: "string", + description: "Final file or directory name without parent path components.", + }, + kind: { + type: "string", + description: "Filesystem entry discriminator.", + enum: ["file", "directory"], + }, + data: { + type: "string", + description: "Base64 file bytes. Required only when kind is file.", + }, + size: { + type: "number", + description: "Decoded file byte length. Required only when kind is file.", + minimum: 0, + multipleOf: 1, + }, + lastModified: { + type: "number", + description: "Last-modified Unix epoch milliseconds.", + minimum: 0, + multipleOf: 1, + }, + mediaType: { + type: "string", + description: "File media type, or an empty string when unknown. Required only when kind is file.", + }, + }, + required: ["version", "path", "parent", "name", "kind", "lastModified"], + oneOf: [ + { + properties: { kind: { enum: ["directory"] } }, + required: ["kind"], + }, + { + properties: { kind: { enum: ["file"] } }, + required: ["kind", "data", "size", "mediaType"], + }, + ], + indexes: ["parent"], +} as const); + +/** Rejects paths that the exported RxDB indexed-string schema cannot store. */ +function assertRxDbPath(path: string): void { + if (path.length <= RXDB_PATH_MAX_LENGTH) return; + throw new FileSystemError( + "invalid-path", + "rxdb", + path, + `RxDB adapter paths cannot exceed ${RXDB_PATH_MAX_LENGTH} characters.`, + ); +} + +/** + * Creates the record-store projection over an existing RxDB collection. + * + * This is the lower-level integration point used by {@link createRxDbAdapter}. + * The collection remains caller-owned. Reads validate every document through + * {@link RecordSchema}; writes use RxDB incremental operations so the bridge does + * not replace RxDB's own revision/concurrency semantics. + * + * @example Reuse the record store inside another adapter wrapper. + * ```ts + * const store = createRxDbRecordStore(database.files); + * const adapter = createRecordAdapter(store, { name: "rxdb" }); + * ``` + */ +export function createRxDbRecordStore(collection: RxDbCollectionType): RecordStoreType { + return { + async get(path) { + assertRxDbPath(path); + const document = await collection.findOne(path).exec(); + return document === null ? null : RecordSchema.parse(document.toJSON()); + }, + async set(record) { + assertRxDbPath(record.path); + assertRxDbPath(record.parent); + await collection.incrementalUpsert(record); + }, + async delete(path) { + assertRxDbPath(path); + const document = await collection.findOne(path).exec(); + if (document !== null) await document.incrementalRemove(); + }, + async *list(parent) { + assertRxDbPath(parent); + const documents = await collection.find({ selector: { parent } }).exec(); + for (const document of documents) yield RecordSchema.parse(document.toJSON()); + }, + }; +} + +/** + * Creates an OPFS-shaped adapter over an RxDB collection. + * + * The collection is borrowed. Closing this adapter never closes the RxDatabase + * because the database can own many unrelated collections and replications. The + * collection must use {@link RxDbRecordJsonSchema}. + * + * @example + * ```ts + * await database.addCollections({ files: { schema: RxDbRecordJsonSchema } }); + * const fs = createFileSystem(createRxDbAdapter(database.files)); + * ``` + */ +export function createRxDbAdapter(collection: RxDbCollectionType): AdapterType { + return createRecordAdapter(createRxDbRecordStore(collection), { name: "rxdb" }); +} diff --git a/src/adapter/unstorage.ts b/src/adapter/unstorage.ts new file mode 100644 index 0000000..9a8149e --- /dev/null +++ b/src/adapter/unstorage.ts @@ -0,0 +1,139 @@ +import type { AdapterType } from "./definition.ts"; +import { createRecordAdapter, type RecordStoreType } from "./record.ts"; +import { normalizePath, splitPath, type PathType } from "../path.ts"; +import { RecordSchema } from "../schema.ts"; + +/** + * Structural subset of unstorage's current `Storage` API used by this adapter. + * + * The bridge intentionally depends on the high-level Storage object, not a + * specific driver. A storage created with memory, IndexedDB, Redis, S3, db0, + * Cloudflare, filesystem, or another current unstorage driver can therefore be + * supplied without a second adapter implementation. + */ +export interface UnstorageStorageType { + /** Reads one decoded storage value. */ + getItem(key: string, options?: Record): Promise; + /** Stores one serializable value. */ + setItem(key: string, value: T, options?: Record): Promise; + /** Removes one key. */ + removeItem(key: string, options?: Record | boolean): Promise; + /** Lists keys below an optional base key. */ + getKeys(base?: string, options?: Record): Promise; + /** Releases mounted drivers owned by the Storage object. */ + dispose?(): Promise; +} + +/** Options for the unstorage-backed filesystem adapter. */ +export interface UnstorageAdapterOptionsType { + /** Key prefix reserved for filesystem records. Defaults to `opfs`. */ + readonly prefix?: string; + /** Prevents all mutations. Useful with read-only HTTP/GitHub drivers. */ + readonly readOnly?: boolean; + /** Disposes the injected Storage object when the filesystem closes. */ + readonly disposeStorage?: boolean; +} + +/** Encodes one virtual path name into an unstorage key segment without `:` or `%`. */ +function encodeSegment(value: string): string { + return encodeURIComponent(value).replace(/~/g, "%7E").replace(/%/g, "~"); +} + +/** Reverses {@link encodeSegment} for keys owned by this adapter. */ +function decodeSegment(value: string): string { + return decodeURIComponent(value.replace(/~/g, "%")); +} + +/** Removes trailing unstorage separators while retaining a non-empty namespace. */ +function normalizePrefix(prefix: string): string { + return prefix.replace(/:+$/g, "") || "opfs"; +} + +/** Maps one canonical virtual path to the private unstorage record namespace. */ +function getKey(prefix: string, path: string): string { + const parts = splitPath(path); + return parts.length === 0 ? `${prefix}:entry` : `${prefix}:entry:${parts.map(encodeSegment).join(":")}`; +} + +/** Maps an adapter-owned unstorage key back to a canonical path, or null for foreign keys. */ +function getPath(prefix: string, key: string): PathType | null { + const base = `${prefix}:entry`; + if (key === base) return "/"; + if (!key.startsWith(`${base}:`)) return null; + const encoded = key.slice(base.length + 1).split(":"); + return normalizePath(encoded.map(decodeSegment).join("/")); +} + +/** + * Creates a record store over any unstorage `Storage` instance. + * + * Directory listing asks unstorage for keys below the encoded directory prefix + * and filters to direct descendants. `maxDepth: 1` is supplied as an optional + * optimization; drivers that do not implement the flag still preserve correct + * results through the direct-child filter. The Storage object is borrowed unless + * `disposeStorage` explicitly transfers disposal. + * + * @example Reserve one key namespace for filesystem records. + * ```ts + * const store = createUnstorageRecordStore(storage, { + * prefix: "application-fs", + * disposeStorage: false, + * }); + * ``` + */ +export function createUnstorageRecordStore( + storage: UnstorageStorageType, + options: UnstorageAdapterOptionsType = {}, +): RecordStoreType { + const prefix = normalizePrefix(options.prefix ?? "opfs"); + return { + async get(path) { + const value = await storage.getItem(getKey(prefix, path)); + return value === null ? null : RecordSchema.parse(value); + }, + async set(record) { + await storage.setItem(getKey(prefix, record.path), record); + }, + async delete(path) { + await storage.removeItem(getKey(prefix, path)); + }, + async *list(parent) { + const parentDepth = splitPath(parent).length; + const keys = await storage.getKeys(getKey(prefix, parent), { maxDepth: 1 }); + for (const key of keys) { + const path = getPath(prefix, key); + if (path === null || path === parent || splitPath(path).length !== parentDepth + 1) continue; + const value = await storage.getItem(key); + if (value !== null) yield RecordSchema.parse(value); + } + }, + async dispose() { + if (options.disposeStorage) await storage.dispose?.(); + }, + }; +} + +/** + * Creates an OPFS-shaped filesystem adapter backed by unstorage. + * + * The returned adapter works transitively with every unstorage driver that + * satisfies the high-level Storage methods used above. Individual driver + * limitations still apply, such as a read-only provider rejecting mutations. + * The Storage object is borrowed unless `disposeStorage` is true. + * + * @example + * ```ts + * const fs = createFileSystem(createUnstorageAdapter(storage)); + * await fs.writeFile("/cache/item.json", "{}", { parents: true }); + * ``` + */ +export function createUnstorageAdapter( + storage: UnstorageStorageType, + options: UnstorageAdapterOptionsType = {}, +): AdapterType { + return createRecordAdapter(createUnstorageRecordStore(storage, options), { + name: "unstorage", + readOnly: options.readOnly ?? false, + disposeStore: true, + }); +}