diff --git a/bench/bun-provider.bench.ts b/bench/bun-provider.bench.ts
index c7a19b1..d113b49 100644
--- a/bench/bun-provider.bench.ts
+++ b/bench/bun-provider.bench.ts
@@ -4,6 +4,7 @@ import { bench, run } from "mitata";
import { createFileSystem } from "../mod.ts";
import { createObjectAdapter } from "../src/adapter/object.ts";
+import { createS3DriverFromClient } from "../src/driver/s3.ts";
import { createS3Client } from "../src/s3.ts";
import { S3_ACCESS_KEY, S3_SECRET_KEY, STORAGE_NAME } from "../tests/provider/fixture.ts";
@@ -47,15 +48,17 @@ const s3 = createS3Client({
partSize: 5 * 1024 * 1024,
concurrency: 4,
});
+/** Driver layer used to isolate project backend overhead from the direct client. */
+const driver = createS3DriverFromClient(s3);
/** Direct object-adapter layer used to isolate translation overhead. */
-const adapter = createObjectAdapter(s3, { prefix: `${PREFIX}/adapter` });
+const adapter = createObjectAdapter(driver, { prefix: `${PREFIX}/adapter` });
/** Filesystem facade with metrics disabled. */
-const facade = createFileSystem(createObjectAdapter(s3, { prefix: `${PREFIX}/facade` }), {
+const facade = createFileSystem(createObjectAdapter(driver, { prefix: `${PREFIX}/facade` }), {
coordination: "none",
metrics: "none",
});
/** Filesystem facade with basic counters enabled. */
-const measured = createFileSystem(createObjectAdapter(s3, { prefix: `${PREFIX}/metrics` }), {
+const measured = createFileSystem(createObjectAdapter(driver, { prefix: `${PREFIX}/metrics` }), {
coordination: "none",
metrics: "basic",
});
@@ -64,9 +67,12 @@ const measured = createFileSystem(createObjectAdapter(s3, { prefix: `${PREFIX}/m
const bunKey = `${PREFIX}/bun.bin`;
/** Stable object key reused by direct project client samples. */
const directKey = `${PREFIX}/direct.bin`;
+/** Stable object key reused by project driver samples. */
+const driverKey = `${PREFIX}/driver.bin`;
await bun.write(bunKey, payload);
await bun.file(bunKey).stat();
await s3.put(directKey, payload);
+await driver.put(driverKey, payload);
await adapter.writeFile("/bench.bin", payload, { mode: "replace" });
await facade.writeFile("/bench.bin", payload);
await measured.writeFile("/bench.bin", payload);
@@ -78,6 +84,9 @@ bench("provider/s3 Bun S3Client: 256 KiB replace + stat", async () => {
bench("provider/s3 project direct client: 256 KiB replace + stat", async () => {
await s3.put(directKey, payload);
});
+bench("provider/s3 project driver: 256 KiB replace + stat", async () => {
+ await driver.put(driverKey, payload);
+});
bench("provider/s3 project direct adapter: 256 KiB replace + stat", async () => {
await adapter.writeFile("/bench.bin", payload, { mode: "replace" });
});
@@ -94,6 +103,9 @@ bench("provider/s3 Bun S3File: 256 KiB read", async () => {
bench("provider/s3 project direct client: 256 KiB read", async () => {
await toBytes(await s3.get(directKey));
});
+bench("provider/s3 project driver: 256 KiB read", async () => {
+ await toBytes(await driver.get(driverKey));
+});
bench("provider/s3 project direct adapter: 256 KiB read", async () => {
await adapter.readFile("/bench.bin");
});
diff --git a/bench/deno-kv.bench.ts b/bench/deno-kv.bench.ts
index 0a3f345..6e51a09 100644
--- a/bench/deno-kv.bench.ts
+++ b/bench/deno-kv.bench.ts
@@ -1,5 +1,4 @@
-///
-
+///
import { bench, run } from "mitata";
import { createFileSystem } from "../mod.ts";
@@ -17,7 +16,10 @@ const rawKey = ["bench", "raw"] as const;
/** Direct Deno KV adapter measured without facade overhead. */
const adapter = createDenoKvAdapter(database, { prefix: "bench-adapter" });
/** Filesystem facade backed by the same Deno KV database with coordination disabled. */
-const fileSystem = createFileSystem(createDenoKvAdapter(database, { prefix: "bench-facade" }), { coordination: "none", metrics: "none" });
+const fileSystem = createFileSystem(createDenoKvAdapter(database, { prefix: "bench-facade" }), {
+ coordination: "none",
+ metrics: "none",
+});
bench("deno-kv/raw: 64 KiB replace + get", async () => {
await database.set(rawKey, payload);
diff --git a/bench/filesystem-provider.bench.ts b/bench/filesystem-provider.bench.ts
new file mode 100644
index 0000000..fb1be2c
--- /dev/null
+++ b/bench/filesystem-provider.bench.ts
@@ -0,0 +1,100 @@
+import { mkdir, readFile, rm, stat, writeFile } from "node:fs/promises";
+import { join } from "node:path";
+import { env } from "node:process";
+
+import { bench, run } from "mitata";
+
+import { createFileSystem } from "../mod.ts";
+import { createFileAdapter } from "../src/adapter/file.ts";
+import { createNodeDriver } from "../src/driver/node.ts";
+
+/** Payload small enough to keep abstraction overhead visible while still exercising real I/O. */
+const payload = new Uint8Array(256 * 1024).fill(23);
+/** One benchmark namespace that does not collide with application objects already mounted. */
+const runId = `.okikio-opfs-bench-${crypto.randomUUID()}`;
+
+/** Configured provider filesystem clients available to this benchmark process. */
+const roots = [
+ ["mountpoint", env.OPFS_MOUNTPOINT_S3_ROOT],
+ ["blobfuse", env.OPFS_BLOBFUSE_ROOT],
+] as const;
+
+/** Filesystem stacks that must remain open until Mitata finishes all registered cases. */
+const fileSystems: Array>> = [];
+
+/** Creates raw, driver, adapter, and facade views over one already-mounted provider filesystem. */
+async function createStack(name: string, mountedRoot: string) {
+ const root = join(mountedRoot, runId, name);
+ await mkdir(root, { recursive: true });
+ const driver = createNodeDriver({ root, createRoot: true });
+ const adapter = createFileAdapter(driver);
+ const fileSystem = createFileSystem(adapter, { coordination: "none", metrics: "none" });
+ const rawRead = join(root, "raw-read.bin");
+ const driverRead = "/driver-read.bin" as const;
+ const adapterRead = "/adapter-read.bin" as const;
+ const facadeRead = "/facade-read.bin" as const;
+
+ await writeFile(rawRead, payload);
+ await driver.writeFile(driverRead, payload, { mode: "replace" });
+ await adapter.writeFile(adapterRead, payload, { mode: "replace" });
+ await fileSystem.writeFile(facadeRead, payload);
+
+ return { name, root, driver, adapter, fileSystem, rawRead, driverRead, adapterRead, facadeRead, writes: 0 };
+}
+
+for (const [name, mountedRoot] of roots) {
+ if (mountedRoot === undefined || mountedRoot.length === 0) continue;
+ const stack = await createStack(name, mountedRoot);
+ fileSystems.push(stack);
+
+ bench(`filesystem/${name} raw client mount: 256 KiB create + stat`, async () => {
+ const path = join(stack.root, `raw-write-${stack.writes++}.bin`);
+ await writeFile(path, payload, { flag: "wx" });
+ await stat(path);
+ });
+ bench(`filesystem/${name} driver: 256 KiB create + stat`, async () => {
+ const path = `/driver-write-${stack.writes++}.bin` as const;
+ await stack.driver.writeFile(path, payload, { mode: "replace" });
+ await stack.driver.stat(path);
+ });
+ bench(`filesystem/${name} adapter: 256 KiB create + stat`, async () => {
+ const path = `/adapter-write-${stack.writes++}.bin` as const;
+ await stack.adapter.writeFile(path, payload, { mode: "replace" });
+ await stack.adapter.stat(path);
+ });
+ bench(`filesystem/${name} facade: 256 KiB create + stat`, async () => {
+ const path = `/facade-write-${stack.writes++}.bin` as const;
+ await stack.fileSystem.writeFile(path, payload);
+ await stack.fileSystem.stat(path);
+ });
+
+ bench(`filesystem/${name} raw client mount: 256 KiB read`, async () => {
+ await readFile(stack.rawRead);
+ });
+ bench(`filesystem/${name} driver: 256 KiB read`, async () => {
+ await stack.driver.readFile(stack.driverRead);
+ });
+ bench(`filesystem/${name} adapter: 256 KiB read`, async () => {
+ await stack.adapter.readFile(stack.adapterRead);
+ });
+ bench(`filesystem/${name} facade: 256 KiB read`, async () => {
+ await stack.fileSystem.readFile(stack.facadeRead);
+ });
+}
+
+if (fileSystems.length === 0) {
+ throw new Error(
+ "Set OPFS_MOUNTPOINT_S3_ROOT and/or OPFS_BLOBFUSE_ROOT to an already-mounted AWS Mountpoint or Azure BlobFuse filesystem.",
+ );
+}
+
+try {
+ await run();
+} finally {
+ for (const stack of fileSystems) {
+ await stack.fileSystem.close();
+ await rm(stack.root, { recursive: true, force: true }).catch((error) => {
+ console.warn(`Could not remove benchmark namespace '${stack.root}':`, error);
+ });
+ }
+}
diff --git a/bench/memory.bench.ts b/bench/memory.bench.ts
index aedc421..ee15eef 100644
--- a/bench/memory.bench.ts
+++ b/bench/memory.bench.ts
@@ -2,7 +2,8 @@ import { encodeBase64 } from "@std/encoding/base64";
import { bench, run } from "mitata";
import { createFileSystem } from "../mod.ts";
-import { createMemoryAdapter, createMemoryRecordStore } from "../src/adapter/memory.ts";
+import { createMemoryAdapter } from "../src/adapter/memory.ts";
+import { createMemoryDriver } from "../src/driver/memory.ts";
import type { RecordType } from "../src/schema.ts";
/** Fixed 64 KiB payload shared by all in-memory benchmark paths. */
@@ -13,7 +14,7 @@ const encoded = encodeBase64(payload);
/** Raw Map baseline with no adapter or record translation. */
const raw = new Map();
/** Direct RecordStore baseline used to isolate record serialization cost. */
-const store = createMemoryRecordStore();
+const store = createMemoryDriver();
/** Canonical file record written directly to the RecordStore baseline. */
const record: RecordType = {
version: 1,
@@ -39,7 +40,7 @@ bench("memory/raw Map: 64 KiB replace + read", () => {
raw.get("/bench.bin")!.slice();
});
-bench("memory/RecordStore: 64 KiB set + get", async () => {
+bench("memory/driver: 64 KiB set + get", async () => {
await store.set(record);
await store.get(record.path);
});
diff --git a/bench/provider.bench.ts b/bench/provider.bench.ts
index 88d8230..9d242a1 100644
--- a/bench/provider.bench.ts
+++ b/bench/provider.bench.ts
@@ -9,15 +9,11 @@ import { bench, run } from "mitata";
import { createFileSystem } from "../mod.ts";
import { createObjectAdapter } from "../src/adapter/object.ts";
import { createAzureClient } from "../src/azure.ts";
+import { createAzureDriverFromClient } from "../src/driver/azure.ts";
+import { createS3DriverFromClient } from "../src/driver/s3.ts";
import { createS3Client } from "../src/s3.ts";
-import {
- AZURE_ACCOUNT,
- AZURE_KEY,
- S3_ACCESS_KEY,
- S3_SECRET_KEY,
- STORAGE_NAME,
-} from "../tests/provider/fixture.ts";
+import { AZURE_ACCOUNT, AZURE_KEY, S3_ACCESS_KEY, S3_SECRET_KEY, STORAGE_NAME } from "../tests/provider/fixture.ts";
/** Reads one provider endpoint supplied by the Testcontainers benchmark owner. */
function getEndpoint(name: "OPFS_S3_ENDPOINT" | "OPFS_AZURE_ENDPOINT"): string {
@@ -59,15 +55,17 @@ const s3 = createS3Client({
metrics: "none",
partSize: 5 * 1024 * 1024,
});
+/** Driver layer used to isolate backend metadata/planning overhead from protocol-client overhead. */
+const s3Driver = createS3DriverFromClient(s3);
/** Direct object-adapter layer used to isolate translation overhead from facade overhead. */
-const s3Adapter = createObjectAdapter(s3, { prefix: `${prefix}/s3-adapter` });
+const s3Adapter = createObjectAdapter(s3Driver, { prefix: `${prefix}/s3-adapter` });
/** Filesystem facade with instrumentation disabled for the lowest-overhead facade comparison. */
-const s3Facade = createFileSystem(createObjectAdapter(s3, { prefix: `${prefix}/s3-facade` }), {
+const s3Facade = createFileSystem(createObjectAdapter(s3Driver, { prefix: `${prefix}/s3-facade` }), {
coordination: "none",
metrics: "none",
});
/** Filesystem facade with basic counters enabled to measure instrumentation cost. */
-const s3Measured = createFileSystem(createObjectAdapter(s3, { prefix: `${prefix}/s3-metrics` }), {
+const s3Measured = createFileSystem(createObjectAdapter(s3Driver, { prefix: `${prefix}/s3-metrics` }), {
coordination: "none",
metrics: "basic",
});
@@ -88,15 +86,17 @@ const azure = createAzureClient({
metrics: "none",
blockSize: 1024 * 1024,
});
+/** Driver layer used to isolate Azure backend metadata/planning overhead. */
+const azureDriver = createAzureDriverFromClient(azure);
/** Direct Azure object-adapter layer used to isolate translation overhead. */
-const azureAdapter = createObjectAdapter(azure, { prefix: `${prefix}/azure-adapter` });
+const azureAdapter = createObjectAdapter(azureDriver, { prefix: `${prefix}/azure-adapter` });
/** Azure facade with metrics disabled for the lowest-overhead facade comparison. */
-const azureFacade = createFileSystem(createObjectAdapter(azure, { prefix: `${prefix}/azure-facade` }), {
+const azureFacade = createFileSystem(createObjectAdapter(azureDriver, { prefix: `${prefix}/azure-facade` }), {
coordination: "none",
metrics: "none",
});
/** Azure facade with basic counters enabled to measure instrumentation cost. */
-const azureMeasured = createFileSystem(createObjectAdapter(azure, { prefix: `${prefix}/azure-metrics` }), {
+const azureMeasured = createFileSystem(createObjectAdapter(azureDriver, { prefix: `${prefix}/azure-metrics` }), {
coordination: "none",
metrics: "basic",
});
@@ -121,14 +121,20 @@ function stream(bytes: Uint8Array): ReadableStream {
const awsKey = `${prefix}/aws.bin`;
/** Stable object key reused by the direct S3 client samples. */
const s3Key = `${prefix}/s3-client.bin`;
+/** Stable object key reused by the S3 driver samples. */
+const s3DriverKey = `${prefix}/s3-driver.bin`;
/** Stable blob key reused by the direct Azure client samples. */
const azureKey = `${prefix}/azure-client.bin`;
+/** Stable blob key reused by the Azure driver samples. */
+const azureDriverKey = `${prefix}/azure-driver.bin`;
/** Official SDK blob client reused by replacement/read samples. */
const azureOfficial = azureContainer.getBlockBlobClient(`${prefix}/azure-sdk.bin`);
await aws.send(new PutObjectCommand({ Bucket: STORAGE_NAME, Key: awsKey, Body: payload }));
await s3.put(s3Key, payload);
+await s3Driver.put(s3DriverKey, payload);
await azureOfficial.uploadData(payload);
await azure.put(azureKey, payload);
+await azureDriver.put(azureDriverKey, payload);
await s3Adapter.writeFile("/bench.bin", payload, { mode: "replace" });
await s3Facade.writeFile("/bench.bin", payload);
await s3Measured.writeFile("/bench.bin", payload);
@@ -143,6 +149,9 @@ bench("provider/s3 AWS SDK: 256 KiB replace + stat", async () => {
bench("provider/s3 direct client: 256 KiB replace + stat", async () => {
await s3.put(s3Key, payload);
});
+bench("provider/s3 driver: 256 KiB replace + stat", async () => {
+ await s3Driver.put(s3DriverKey, payload);
+});
bench("provider/s3 direct adapter: 256 KiB replace + stat", async () => {
await s3Adapter.writeFile("/bench.bin", payload, { mode: "replace" });
});
@@ -159,6 +168,9 @@ bench("provider/s3 AWS SDK: 256 KiB read", async () => {
bench("provider/s3 direct client: 256 KiB read", async () => {
await toBytes(await s3.get(s3Key));
});
+bench("provider/s3 driver: 256 KiB read", async () => {
+ await toBytes(await s3Driver.get(s3DriverKey));
+});
bench("provider/s3 direct adapter: 256 KiB read", async () => {
await s3Adapter.readFile("/bench.bin");
});
@@ -186,6 +198,9 @@ bench("provider/azure official SDK: 256 KiB replace + stat", async () => {
bench("provider/azure direct client: 256 KiB replace + stat", async () => {
await azure.put(azureKey, payload);
});
+bench("provider/azure driver: 256 KiB replace + stat", async () => {
+ await azureDriver.put(azureDriverKey, payload);
+});
bench("provider/azure direct adapter: 256 KiB replace + stat", async () => {
await azureAdapter.writeFile("/bench.bin", payload, { mode: "replace" });
});
@@ -202,6 +217,9 @@ bench("provider/azure official SDK: 256 KiB read", async () => {
bench("provider/azure direct client: 256 KiB read", async () => {
await toBytes(await azure.get(azureKey));
});
+bench("provider/azure driver: 256 KiB read", async () => {
+ await toBytes(await azureDriver.get(azureDriverKey));
+});
bench("provider/azure direct adapter: 256 KiB read", async () => {
await azureAdapter.readFile("/bench.bin");
});
diff --git a/bench/sqlite.bench.ts b/bench/sqlite.bench.ts
index 2bc126f..1d3bb9d 100644
--- a/bench/sqlite.bench.ts
+++ b/bench/sqlite.bench.ts
@@ -21,7 +21,10 @@ const facadeDatabase = new DatabaseSync(":memory:");
/** Direct SQLite adapter measured without facade semantics. */
const adapter = await createSqliteAdapter(adapterDatabase);
/** Filesystem facade backed by SQLite with coordination disabled. */
-const fileSystem = createFileSystem(await createSqliteAdapter(facadeDatabase), { coordination: "none", metrics: "none" });
+const fileSystem = createFileSystem(await createSqliteAdapter(facadeDatabase), {
+ coordination: "none",
+ metrics: "none",
+});
bench("sqlite/raw BLOB: 64 KiB replace + get", () => {
rawSet.run("/bench.bin", payload);
diff --git a/src/adapter/azure.ts b/src/adapter/azure.ts
index f02735f..253f5dc 100644
--- a/src/adapter/azure.ts
+++ b/src/adapter/azure.ts
@@ -1,11 +1,12 @@
import type { AdapterType } from "./definition.ts";
import { createObjectAdapter, type ObjectAdapterOptionsType } from "./object.ts";
import type { AzureClientType } from "../azure.ts";
+import { createAzureDriverFromClient } from "../driver/azure.ts";
/** Azure Blob filesystem mapping options. */
export type AzureAdapterOptionsType = ObjectAdapterOptionsType;
-/** Creates an OPFS-shaped adapter over an injected Azure Blob REST client. */
+/** Creates the OPFS translation over an injected Azure Blob protocol client. */
export function createAzureAdapter(client: AzureClientType, options: AzureAdapterOptionsType = {}): AdapterType {
- return createObjectAdapter(client, options);
+ return createObjectAdapter(createAzureDriverFromClient(client), options);
}
diff --git a/src/adapter/bun.ts b/src/adapter/bun.ts
index 7d9a863..85fbc1d 100644
--- a/src/adapter/bun.ts
+++ b/src/adapter/bun.ts
@@ -1,191 +1,11 @@
-import type {
- AdapterCopyOptionsType,
- AdapterDirectoryEntryType,
- AdapterMoveOptionsType,
- AdapterReadOptionsType,
- AdapterSignalOptionsType,
- AdapterStatType,
- AdapterSyncFileType,
- AdapterType,
- AdapterWritableFileType,
- AdapterWriteOptionsType,
-} 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 type { PathType } from "../path.ts";
-import { withAbortSignal } from "../stream.ts";
+import type { AdapterType } from "./definition.ts";
+import { createFileAdapter } from "./file.ts";
+import { type BunDriverOptionsType, createBunDriver } from "../driver/bun.ts";
-/** Minimal Bun file object used without requiring global Bun types in core declarations. */
-interface BunFileType extends Blob {}
+/** Options for the Bun filesystem adapter. */
+export type BunAdapterOptionsType = BunDriverOptionsType;
-/** Bun runtime methods required by the fast read and replace-write paths. */
-interface BunRuntimeType {
- /** Opens a lazy `BunFile` for one host path. */
- file(path: string): BunFileType;
- /** Replaces one host file with bytes or a stream-compatible body. */
- write(path: string, data: Blob | Response | ArrayBufferView | ArrayBuffer | string): Promise;
-}
-
-/** Options for exposing one host directory through Bun. */
-export type BunAdapterOptionsType = NodeAdapterOptionsType;
-
-/**
- * Resolves Bun only when the adapter is created.
- *
- * Keeping this lookup out of module evaluation lets Node and Deno inspect or
- * type-check the explicit Bun subpath without requiring the `Bun` 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;
-}
-
-/**
- * Bun implementation of the portable filesystem adapter.
- *
- * Bun owns the lazy read and complete replacement paths. Operations that need
- * directory traversal, positioned writes, rename, or synchronous descriptors
- * delegate to Bun's Node-compatible filesystem layer through `NodeAdapter`.
- * The two paths share the same `@std/path` host-root mapper, so neither can
- * address a host path outside the configured root.
- */
-class BunAdapter implements AdapterType {
- /** Stable adapter identity used in diagnostics. */
- readonly name = "bun";
- /** Native capabilities inherited from Bun's Node-compatible filesystem. */
- readonly capabilities;
- /** Bun runtime used by lazy reads and replacement writes. */
- readonly #bun: BunRuntimeType;
- /** Maps canonical virtual paths below the configured host root. */
- readonly #hostPath: (path: string) => string;
- /** Node-compatible adapter that owns operations Bun does not improve. */
- readonly #node: AdapterType;
-
- /** Resolves Bun and creates the shared Node-compatible host adapter. */
- constructor(options: BunAdapterOptionsType) {
- this.#bun = getBun();
- this.#hostPath = createLocalPath(options.root);
- this.#node = createNodeAdapter(options);
- this.capabilities = this.#node.capabilities;
- }
-
- /** Delegates metadata lookup to the Node-compatible filesystem surface. */
- stat(path: PathType, options: AdapterSignalOptionsType = {}): Promise {
- return this.#node.stat(path, options);
- }
-
- /** Reads only the requested slice through Bun's lazy `BunFile` object. */
- async readFile(path: PathType, options: AdapterReadOptionsType = {}): Promise {
- throwIfAborted(options.signal, "read", path);
- const file = this.#bun.file(this.#hostPath(path));
- const start = options.at ?? 0;
- const end = options.length === undefined ? file.size : Math.min(file.size, start + options.length);
- return new Uint8Array(await file.slice(start, end).arrayBuffer());
- }
-
- /** Returns Bun's native Blob stream for the requested byte range. */
- async openReadStream(path: PathType, options: AdapterReadOptionsType = {}): Promise> {
- throwIfAborted(options.signal, "read", path);
- const file = this.#bun.file(this.#hostPath(path));
- const start = options.at ?? 0;
- const end = options.length === undefined ? file.size : Math.min(file.size, start + options.length);
- return file.slice(start, end).stream() as ReadableStream;
- }
-
- /** Uses `Bun.write()` for replacement and delegates append/update semantics. */
- async writeFile(path: PathType, data: Uint8Array, options: AdapterWriteOptionsType): Promise {
- if (options.mode !== "replace") {
- await this.#node.writeFile(path, data, options);
- return;
- }
-
- throwIfAborted(options.signal, "write", path);
- await this.#bun.write(this.#hostPath(path), data);
- }
-
- /** Streams replacement writes through `Bun.write()` without facade buffering. */
- async writeStream(path: PathType, source: ReadableStream, options: AdapterWriteOptionsType): Promise {
- if (options.mode !== "replace") {
- if (this.#node.writeStream === undefined) {
- throw new TypeError("Bun Node compatibility layer does not expose streaming writes.");
- }
- await this.#node.writeStream(path, source, options);
- return;
- }
-
- throwIfAborted(options.signal, "write", path);
- const body = withAbortSignal(source, options.signal, path, "write");
- await this.#bun.write(this.#hostPath(path), new Response(body));
- }
-
- /** Delegates direct-child iteration to Bun's Node-compatible filesystem surface. */
- readDir(path: PathType, options: AdapterSignalOptionsType = {}): AsyncIterableIterator {
- return this.#node.readDir(path, options);
- }
-
- /** Creates one host directory after facade parent resolution. */
- createDir(path: PathType, options: AdapterSignalOptionsType = {}): Promise {
- return this.#node.createDir(path, options);
- }
-
- /** Removes one host file or empty directory. */
- remove(path: PathType, options: AdapterSignalOptionsType = {}): Promise {
- return this.#node.remove(path, options);
- }
-
- /** Uses native host copy without routing bytes through JavaScript. */
- copy(source: PathType, destination: PathType, options: AdapterCopyOptionsType): Promise {
- if (this.#node.copy === undefined) throw new TypeError("Bun host adapter does not expose native copy.");
- return this.#node.copy(source, destination, options);
- }
-
- /** Uses native host rename for move semantics. */
- move(source: PathType, destination: PathType, options: AdapterMoveOptionsType): Promise {
- if (this.#node.move === undefined) throw new TypeError("Bun host adapter does not expose native move.");
- return this.#node.move(source, destination, options);
- }
-
- /** Opens one long-lived asynchronous positional host file. */
- openWritableFile(path: PathType): Promise {
- if (this.#node.openWritableFile === undefined) {
- throw new TypeError("Bun host adapter does not expose positional writes.");
- }
- return this.#node.openWritableFile(path);
- }
-
- /** Opens one synchronous random-access host file. */
- openSyncFile(path: PathType): Promise {
- if (this.#node.openSyncFile === undefined) {
- throw new TypeError("Bun host adapter does not expose synchronous access.");
- }
- return this.#node.openSyncFile(path);
- }
-
- /** Releases resources owned by the delegated host adapter, when any exist. */
- async dispose(): Promise {
- await this.#node.dispose?.();
- }
-}
-
-/**
- * Creates an adapter optimized for Bun.
- *
- * The adapter uses `Bun.file()` for lazy reads and `Bun.write()` for complete
- * replacement writes. It uses Bun's Node-compatible filesystem APIs for
- * operations that need stronger file semantics. Importing this module does not
- * require Bun; adapter creation does.
- *
- * @example Persist below one Bun host directory.
- * ```ts
- * const fs = createFileSystem(createBunAdapter({ root: "./data" }));
- * await fs.writeFile("/result.bin", new Uint8Array([1, 2, 3]));
- * ```
- */
+/** Creates the OPFS primitive translation over a Bun filesystem driver. */
export function createBunAdapter(options: BunAdapterOptionsType): AdapterType {
- return defineAdapter(new BunAdapter(options));
+ return createFileAdapter(createBunDriver(options), { disposeDriver: true });
}
diff --git a/src/adapter/cache.ts b/src/adapter/cache.ts
index 43b0e4f..e8763d7 100644
--- a/src/adapter/cache.ts
+++ b/src/adapter/cache.ts
@@ -1,96 +1,15 @@
import type { AdapterType } from "./definition.ts";
-import { createRecordAdapter, type RecordStoreType } from "./record.ts";
-import { splitPath, type PathType } from "../path.ts";
-import { PathSchema, RecordSchema } from "../schema.ts";
+import { createRecordAdapter } from "./record.ts";
+import { type CacheDriverOptionsType, createCacheDriver } from "../driver/cache.ts";
-/** Options for a Cache API-backed filesystem adapter. */
-export interface CacheAdapterOptionsType {
- /** Private URL namespace used as Cache keys. */
- readonly prefix?: string;
- /** Prevents mutations. */
- readonly readOnly?: boolean;
-}
-
-/** Encodes one path into a synthetic HTTPS request URL that never needs network access. */
-function request(prefix: string, path: PathType): Request {
- return new Request(`https://opfs.invalid/${encodeURIComponent(prefix)}/${encodeURIComponent(path)}`);
-}
-
-/** Decodes an adapter-owned Cache request URL. */
-function getPath(prefix: string, value: Request): PathType | null {
- const url = new URL(value.url);
- const parts = url.pathname.slice(1).split("/");
- if (parts.length !== 2) return null;
- try {
- if (decodeURIComponent(parts[0] ?? "") !== prefix) return null;
- return PathSchema.parse(decodeURIComponent(parts[1] ?? ""));
- } catch {
- return null;
- }
-}
-
-/**
- * Record-store projection over one injected Cache API `Cache`.
- *
- * Records are JSON Responses under synthetic HTTPS request URLs. No request is
- * sent to the network. Quota, eviction, persistence, and lifetime remain
- * browser Cache Storage policy and are not upgraded into filesystem durability
- * guarantees by this class.
- */
-class CacheRecordStore implements RecordStoreType {
- /** Cache borrowed from the caller. */
- readonly #cache: Cache;
- /** Private synthetic URL namespace for this filesystem. */
- readonly #prefix: string;
-
- /** Binds one cache and one stable synthetic namespace. */
- constructor(cache: Cache, options: CacheAdapterOptionsType) {
- this.#cache = cache;
- this.#prefix = options.prefix ?? "opfs";
- }
-
- /** Reads and validates one cached JSON record. */
- async get(path: PathType) {
- const response = await this.#cache.match(request(this.#prefix, path));
- return response === undefined ? null : RecordSchema.parse(await response.json());
- }
-
- /** Replaces one cached JSON record. */
- async set(record: Parameters[0]): Promise {
- await this.#cache.put(
- request(this.#prefix, record.path),
- new Response(JSON.stringify(record), { headers: { "content-type": "application/json" } }),
- );
- }
-
- /** Removes one exact synthetic request key. */
- async delete(path: PathType): Promise {
- await this.#cache.delete(request(this.#prefix, path));
- }
-
- /** Scans cache keys and yields direct children in the reserved namespace. */
- async *list(parent: PathType) {
- const parentDepth = splitPath(parent).length;
- for (const cacheRequest of await this.#cache.keys()) {
- const path = getPath(this.#prefix, cacheRequest);
- if (path === null || splitPath(path).length !== parentDepth + 1) continue;
- const response = await this.#cache.match(cacheRequest);
- if (response === undefined) continue;
- const record = RecordSchema.parse(await response.json());
- if (record.parent === parent) yield record;
- }
- }
-}
-
-/** Creates a record store over one injected Cache API `Cache`. */
-export function createCacheRecordStore(cache: Cache, options: CacheAdapterOptionsType = {}): RecordStoreType {
- return new CacheRecordStore(cache, options);
-}
+/** Options for a Cache Storage-backed filesystem adapter. */
+export type CacheAdapterOptionsType = CacheDriverOptionsType;
-/** Creates an OPFS-shaped adapter over an existing Cache API `Cache`. */
+/** Creates a filesystem adapter over one injected Cache Storage cache. */
export function createCacheAdapter(cache: Cache, options: CacheAdapterOptionsType = {}): AdapterType {
- return createRecordAdapter(createCacheRecordStore(cache, options), {
+ return createRecordAdapter(createCacheDriver(cache, options), {
name: "cache",
readOnly: options.readOnly ?? false,
+ disposeDriver: true,
});
}
diff --git a/src/adapter/deno-kv.ts b/src/adapter/deno-kv.ts
index c04ba28..24b380c 100644
--- a/src/adapter/deno-kv.ts
+++ b/src/adapter/deno-kv.ts
@@ -1,830 +1,92 @@
-import { pooledMap } from "@std/async/pool";
-import { concat } from "@std/bytes";
-import { decodeBase64, encodeBase64 } from "@std/encoding/base64";
-import { z } from "zod";
-
-import type { AdapterReadOptionsType, AdapterType, AdapterWriteOptionsType } from "./definition.ts";
-import { createRecordAdapter, type RecordListType, type RecordStoreType } from "./record.ts";
-import { FileSystemError, throwIfAborted } from "../error.ts";
-import { basename, dirname } from "../path.ts";
-import { split } from "../chunk.ts";
+import type { AdapterType } from "./definition.ts";
+import { createRecordAdapter } from "./record.ts";
import {
- PartitionModeSchema,
- PathSchema,
- RecordSchema,
- type PartitionModeType,
- type RecordType,
-} from "../schema.ts";
-
-/** Maximum serialized Deno KV key size documented by the runtime. */
-export const DENO_KV_MAX_KEY_BYTES = 2 * 1024;
-/** Maximum serialized Deno KV value size documented by the runtime. */
-export const DENO_KV_MAX_VALUE_BYTES = 64 * 1024;
-/** Maximum total serialized size of one Deno KV atomic mutation. */
-export const DENO_KV_MAX_ATOMIC_BYTES = 800 * 1024;
-/** Conservative decoded payload kept in one raw binary part. */
-export const DENO_KV_DEFAULT_PART_BYTES = 48 * 1024;
-/** Conservative decoded payload kept inline with filesystem metadata. */
-export const DENO_KV_DEFAULT_INLINE_BYTES = 32 * 1024;
-/** Explicit safety ceiling that prevents one logical file from creating unbounded keys. */
-export const DENO_KV_DEFAULT_MAX_PARTS = 10_000;
-/** Default concurrent exact reads/deletes for partitioned file bodies. */
-export const DENO_KV_DEFAULT_CONCURRENCY = 8;
-
-/** Structural Deno KV entry used by the adapter. */
-export interface DenoKvEntryType {
- /** Stored tuple when iteration exposes it. */
- readonly key?: readonly unknown[];
- /** Stored value, or null for a missing exact get. */
- readonly value: T | null;
-}
-
-/** Structural Deno KV subset required by this adapter. */
-export interface DenoKvType {
- /** Reads one exact key. */
- get(key: readonly unknown[]): Promise>;
- /** Replaces one key. */
- set(key: readonly unknown[], value: unknown): Promise;
- /** Removes one key. */
- delete(key: readonly unknown[]): Promise;
- /** Streams keys with one prefix. */
- list(selector: { prefix: readonly unknown[] }, options?: unknown): AsyncIterable>;
- /** Closes the database when the caller transfers ownership. */
- close?(): void;
-}
-
-/** Options for Deno KV persistence. */
-export interface DenoKvAdapterOptionsType {
- /** Key namespace. Defaults to `okikio-opfs`. */
- readonly prefix?: string;
- /** Closes the injected KV database with the adapter. */
- readonly disposeDatabase?: boolean;
- /** Prevents mutations. */
- readonly readOnly?: boolean;
- /** Physical large-file layout. Defaults to `auto`. */
- readonly partition?: PartitionModeType;
- /** Maximum decoded bytes in one partition. Defaults to 48 KiB. */
- readonly partBytes?: number;
- /** Maximum decoded bytes stored as one normal record in `auto` mode. Defaults to 32 KiB. */
- readonly inlineBytes?: number;
- /** Maximum physical part count for one logical file. Defaults to 10,000. */
- readonly maxParts?: number;
- /** Maximum concurrent exact part reads/deletes. Defaults to 8. */
- readonly concurrency?: number;
-}
-
-/** File metadata retained in the small manifest committed after all body parts. */
-const DenoKvFileSchema = z.object({
- version: z.literal(1),
- path: PathSchema,
- parent: PathSchema,
- name: z.string(),
- kind: z.literal("file"),
- size: z.number().int().nonnegative(),
- lastModified: z.number().int().nonnegative(),
- mediaType: z.string(),
-}).strict();
-
-/** Durable pointer to one generation of raw Deno KV body parts. */
-const DenoKvManifestSchema = z.object({
- storage: z.literal("deno-kv-parts-v2"),
- generation: z.string().min(1),
- parts: z.number().int().positive(),
- partBytes: z.number().int().positive(),
- file: DenoKvFileSchema,
-}).strict();
-
-/** Validated private manifest that publishes one complete partition generation. */
-type DenoKvManifestType = z.output;
-/** Physical value stored at one logical entry key: inline record or partition manifest. */
-type DenoKvStoredType = RecordType | DenoKvManifestType;
-
-/** Maps one exact virtual path to a Deno KV entry key derived from its parent and name. */
-function key(prefix: string, path: string): readonly unknown[] {
- return [prefix, "entry", dirname(path), basename(path)];
-}
-
-/** Prefix whose entries are exactly the direct children of one canonical parent path. */
-function listKey(prefix: string, parent: string): readonly unknown[] {
- return [prefix, "entry", parent];
-}
-
-/** Maps one logical file generation and part number to a separate raw binary key. */
-function partKey(prefix: string, path: string, generation: string, index: number): readonly unknown[] {
- return [prefix, "part", path, generation, index];
-}
-
-/** Validates a positive safe integer configuration value. */
+ createDenoKvDriver,
+ DENO_KV_DEFAULT_COLLECT_AGE_MS,
+ DENO_KV_DEFAULT_COLLECT_DELETES,
+ DENO_KV_DEFAULT_CONCURRENCY,
+ DENO_KV_DEFAULT_INLINE_BYTES,
+ DENO_KV_DEFAULT_MAX_PARTS,
+ DENO_KV_DEFAULT_PART_BYTES,
+ DENO_KV_MAX_ATOMIC_BYTES,
+ DENO_KV_MAX_KEY_BYTES,
+ DENO_KV_MAX_VALUE_BYTES,
+ DENO_KV_SAFE_INLINE_BYTES,
+ DENO_KV_SAFE_PART_BYTES,
+ type DenoKvCollectOptionsType,
+ type DenoKvCollectResultType,
+ type DenoKvDriverOptionsType,
+ type DenoKvDriverType,
+ type DenoKvEntryType,
+ type DenoKvType,
+} from "../driver/deno-kv.ts";
+import { PartitionModeSchema } from "../schema.ts";
+
+/** Documented provider ceilings and conservative project defaults used by the Deno KV driver. */
+export {
+ DENO_KV_DEFAULT_COLLECT_AGE_MS,
+ DENO_KV_DEFAULT_COLLECT_DELETES,
+ DENO_KV_DEFAULT_CONCURRENCY,
+ DENO_KV_DEFAULT_INLINE_BYTES,
+ DENO_KV_DEFAULT_MAX_PARTS,
+ DENO_KV_DEFAULT_PART_BYTES,
+ DENO_KV_MAX_ATOMIC_BYTES,
+ DENO_KV_MAX_KEY_BYTES,
+ DENO_KV_MAX_VALUE_BYTES,
+ DENO_KV_SAFE_INLINE_BYTES,
+ DENO_KV_SAFE_PART_BYTES,
+};
+
+/** Options forwarded to the Deno KV record driver. */
+export type DenoKvAdapterOptionsType = DenoKvDriverOptionsType;
+
+/** Minimal Deno KV entry and database contracts consumed by the driver. */
+export type { DenoKvCollectOptionsType, DenoKvCollectResultType, DenoKvDriverType, DenoKvEntryType, DenoKvType };
+
+/** Resolves a positive integer adapter setting before projecting driver policy. */
function positive(value: number | undefined, fallback: number, name: string): number {
const resolved = value ?? fallback;
- if (!Number.isSafeInteger(resolved) || resolved < 1) throw new RangeError(`${name} must be a positive safe integer.`);
- return resolved;
-}
-
-/** Returns true when a stored value is the private partition manifest rather than a public record. */
-function isManifest(value: unknown): value is DenoKvManifestType {
- return typeof value === "object" && value !== null && (value as { storage?: unknown }).storage === "deno-kv-parts-v2";
-}
-
-/** Projects a manifest to listing metadata without reading any body part. */
-function manifestList(manifest: DenoKvManifestType): RecordListType {
- return manifest.file;
-}
-
-/** Creates one new generation identifier without depending on Deno globals. */
-function generation(): string {
- return `${Date.now().toString(36)}-${crypto.randomUUID()}`;
-}
-
-/** Splits bytes into independent copies so each stored value owns a stable ArrayBuffer. */
-function parts(bytes: Uint8Array, partBytes: number): Uint8Array[] {
- if (bytes.byteLength === 0) return [new Uint8Array()];
- const output: Uint8Array[] = [];
- for (let at = 0; at < bytes.byteLength; at += partBytes) output.push(bytes.slice(at, at + partBytes));
- return output;
-}
-
-/**
- * Record-store projection over one caller-owned Deno KV database.
- *
- * Logical entries are keyed as `(namespace, "entry", parentPath, name)`. This
- * keeps exact lookup deterministic while a parent-prefix list contains only
- * direct children, not the complete descendant subtree.
- *
- * Deno KV limits one serialized value to 64 KiB. A normal filesystem file can
- * be much larger, so the default `auto` policy stores small records inline and
- * large file bodies as raw `Uint8Array` parts. All parts of a new generation
- * are written first and the small manifest is written last:
- *
- * ```text
- * old manifest -> old parts
- *
- * write new part 0..N
- * |
- * v
- * commit new manifest <- visibility point
- * |
- * v
- * remove old parts
- * ```
- *
- * Readers therefore observe the previous complete generation until the new
- * manifest commit succeeds. A process crash before the manifest commit can
- * leave unreachable part keys. That is storage leakage, not a partial logical
- * file; a later successful overwrite removes the previous reachable generation.
- */
-class DenoKvRecordStore implements RecordStoreType {
- /** Optional byte lanes that keep large logical files out of generic base64 record materialization. */
- readonly capabilities: NonNullable;
- /** Deno KV-compatible database borrowed from the caller. */
- readonly #database: DenoKvType;
- /** First key tuple component reserved for this filesystem. */
- readonly #prefix: string;
- /** Whether store disposal also closes the injected database. */
- readonly #disposeDatabase: boolean;
- /** Large logical-file policy. */
- readonly #partition: PartitionModeType;
- /** Decoded bytes stored in one physical part. */
- readonly #partBytes: number;
- /** Largest decoded body stored inline under the conservative provider ceiling. */
- readonly #inlineBytes: number;
- /** Maximum physical parts for one logical file. */
- readonly #maxParts: number;
- /** Concurrent exact part I/O ceiling. */
- readonly #concurrency: number;
-
- /** Resolves namespace, ownership, and physical layout once. */
- constructor(database: DenoKvType, options: DenoKvAdapterOptionsType) {
- this.#database = database;
- this.#prefix = options.prefix ?? "okikio-opfs";
- this.#disposeDatabase = options.disposeDatabase ?? false;
- this.#partition = PartitionModeSchema.parse(options.partition ?? "auto");
- this.#partBytes = positive(options.partBytes, DENO_KV_DEFAULT_PART_BYTES, "partBytes");
- this.#inlineBytes = positive(options.inlineBytes, DENO_KV_DEFAULT_INLINE_BYTES, "inlineBytes");
- this.#maxParts = positive(options.maxParts, DENO_KV_DEFAULT_MAX_PARTS, "maxParts");
- this.#concurrency = positive(options.concurrency, DENO_KV_DEFAULT_CONCURRENCY, "concurrency");
- if (this.#partBytes >= DENO_KV_MAX_VALUE_BYTES) {
- throw new RangeError(`partBytes must stay below the Deno KV ${DENO_KV_MAX_VALUE_BYTES}-byte value ceiling.`);
- }
- if (this.#inlineBytes >= DENO_KV_MAX_VALUE_BYTES) {
- throw new RangeError(`inlineBytes must stay below the Deno KV ${DENO_KV_MAX_VALUE_BYTES}-byte value ceiling.`);
- }
- this.capabilities = {
- rangeRead: true,
- streamRead: true,
- writeModes: ["replace", "append", "update"],
- streamWriteModes: this.#partition === "never" ? [] : ["replace"],
- } as const;
- }
-
- /** Reads one exact stored logical value without following a partition manifest. */
- async #stored(path: string): Promise {
- const entry = await this.#database.get(key(this.#prefix, path));
- if (entry.value === null) return null;
- if (isManifest(entry.value)) return DenoKvManifestSchema.parse(entry.value);
- return RecordSchema.parse(entry.value);
- }
-
- /** Returns logical metadata without joining any partition body. */
- async stat(path: Parameters>[0]): Promise {
- const stored = await this.#stored(path);
- if (stored === null) return null;
- return isManifest(stored) ? manifestList(stored) : stored;
- }
-
- /** Reads and validates one exact logical record, joining parts only for an exact file read. */
- async get(path: Parameters[0]): Promise {
- const stored = await this.#stored(path);
- if (stored === null) return null;
- if (!isManifest(stored)) return stored;
-
- const manifest = stored;
- const chunks = new Array(manifest.parts);
- const indexes = Array.from({ length: manifest.parts }, (_, index) => index);
- for await (const result of pooledMap(this.#concurrency, indexes, async (index) => {
- const part = await this.#database.get(partKey(this.#prefix, path, manifest.generation, index));
- if (!(part.value instanceof Uint8Array)) {
- throw new FileSystemError(
- "unknown",
- "read",
- path,
- `Deno KV file '${path}' is missing physical part ${index} of ${manifest.parts}.`,
- );
- }
- return { index, bytes: part.value };
- })) chunks[result.index] = result.bytes;
-
- const bytes = concat(chunks);
- if (bytes.byteLength !== manifest.file.size) {
- throw new FileSystemError(
- "unknown",
- "read",
- path,
- `Deno KV file '${path}' reconstructed ${bytes.byteLength} bytes; manifest expects ${manifest.file.size}.`,
- );
- }
- return RecordSchema.parse({ ...manifest.file, data: encodeBase64(bytes) });
- }
-
- /**
- * Reads only physical parts that overlap the requested logical byte range.
- *
- * This is the critical difference from a generic record store: a 500 MiB
- * partitioned file can satisfy a 4 KiB read without reconstructing 500 MiB or
- * allocating a 500 MiB base64 record first.
- */
- async readFile(
- path: Parameters>[0],
- options: AdapterReadOptionsType = {},
- ): Promise {
- throwIfAborted(options.signal, "read", path);
- const stored = await this.#stored(path);
- if (stored === null) throw new FileSystemError("not-found", "read", path, `File '${path}' does not exist.`);
- if (!isManifest(stored)) {
- if (stored.kind !== "file") throw new FileSystemError("type-mismatch", "read", path, `'${path}' is a directory.`);
- const bytes = decodeBase64(stored.data);
- const start = Math.min(options.at ?? 0, bytes.byteLength);
- const end = options.length === undefined ? bytes.byteLength : Math.min(bytes.byteLength, start + options.length);
- return bytes.slice(start, end);
- }
-
- const manifest = stored;
- const start = Math.min(options.at ?? 0, manifest.file.size);
- const end = options.length === undefined
- ? manifest.file.size
- : Math.min(manifest.file.size, start + options.length);
- if (start === end) return new Uint8Array();
-
- const first = Math.floor(start / manifest.partBytes);
- const last = Math.ceil(end / manifest.partBytes);
- const indexes = Array.from({ length: last - first }, (_, offset) => first + offset);
- const chunks = new Array(indexes.length);
- for await (const result of pooledMap(this.#concurrency, indexes, async (index) => {
- throwIfAborted(options.signal, "read", path);
- const part = await this.#database.get(partKey(this.#prefix, path, manifest.generation, index));
- if (!(part.value instanceof Uint8Array)) {
- throw new FileSystemError(
- "unknown",
- "read",
- path,
- `Deno KV file '${path}' is missing physical part ${index} of ${manifest.parts}.`,
- );
- }
- return { index, bytes: part.value };
- })) chunks[result.index - first] = result.bytes;
-
- const joined = concat(chunks);
- const localStart = start - first * manifest.partBytes;
- const result = joined.slice(localStart, localStart + (end - start));
- if (result.byteLength !== end - start) {
- throw new FileSystemError(
- "unknown",
- "read",
- path,
- `Deno KV range for '${path}' reconstructed ${result.byteLength} bytes; expected ${end - start}.`,
- );
- }
- return result;
- }
-
- /**
- * Streams partitioned bytes one physical part at a time under consumer backpressure.
- *
- * One part is resident in this layer at a time. The provider request itself is
- * not cancellable through Deno KV, so an abort can stop before the next part
- * but cannot revoke an exact get that the runtime has already started.
- */
- async openReadStream(
- path: Parameters>[0],
- options: AdapterReadOptionsType = {},
- ): Promise> {
- throwIfAborted(options.signal, "read", path);
- const stored = await this.#stored(path);
- if (stored === null) throw new FileSystemError("not-found", "read", path, `File '${path}' does not exist.`);
- if (!isManifest(stored)) {
- if (stored.kind !== "file") throw new FileSystemError("type-mismatch", "read", path, `'${path}' is a directory.`);
- const bytes = await this.readFile(path, options);
- return new ReadableStream({
- start(controller) {
- if (bytes.byteLength > 0) controller.enqueue(bytes);
- controller.close();
- },
- });
- }
-
- const manifest = stored;
- const start = Math.min(options.at ?? 0, manifest.file.size);
- const end = options.length === undefined
- ? manifest.file.size
- : Math.min(manifest.file.size, start + options.length);
- let index = Math.floor(start / manifest.partBytes);
- const last = Math.ceil(end / manifest.partBytes);
- const first = index;
- const database = this.#database;
- const prefix = this.#prefix;
- const signal = options.signal;
-
- return new ReadableStream({
- async pull(controller) {
- throwIfAborted(signal, "read", path);
- if (start === end || index >= last) {
- controller.close();
- return;
- }
- const entry = await database.get(partKey(prefix, path, manifest.generation, index));
- throwIfAborted(signal, "read", path);
- if (!(entry.value instanceof Uint8Array)) {
- controller.error(new FileSystemError(
- "unknown",
- "read",
- path,
- `Deno KV file '${path}' is missing physical part ${index} of ${manifest.parts}.`,
- ));
- return;
- }
- const physicalStart = index * manifest.partBytes;
- const from = index === first ? start - physicalStart : 0;
- const to = index === last - 1 ? Math.min(entry.value.byteLength, end - physicalStart) : entry.value.byteLength;
- index += 1;
- if (to > from) controller.enqueue(entry.value.slice(from, to));
- if (index >= last) controller.close();
- },
- });
- }
-
- /**
- * Reads one range from a previously resolved value without materializing the
- * complete logical file.
- *
- * Patch writes use this while constructing a new immutable generation. An
- * inline predecessor is small by configuration, while a partitioned
- * predecessor reads only the physical parts that overlap the requested
- * output part.
- */
- async #readRange(
- path: string,
- stored: DenoKvStoredType,
- at: number,
- length: number,
- signal?: AbortSignal,
- ): Promise {
- if (length === 0) return new Uint8Array();
- throwIfAborted(signal, "read", path);
- if (!isManifest(stored)) {
- if (stored.kind !== "file") throw new FileSystemError("type-mismatch", "read", path, `'${path}' is a directory.`);
- return decodeBase64(stored.data).slice(at, at + length);
- }
-
- const start = Math.min(at, stored.file.size);
- const end = Math.min(stored.file.size, start + length);
- if (start === end) return new Uint8Array();
- const first = Math.floor(start / stored.partBytes);
- const last = Math.ceil(end / stored.partBytes);
- const chunks: Uint8Array[] = [];
- for (let index = first; index < last; index += 1) {
- throwIfAborted(signal, "read", path);
- const part = await this.#database.get(partKey(this.#prefix, path, stored.generation, index));
- if (!(part.value instanceof Uint8Array)) {
- throw new FileSystemError(
- "unknown",
- "read",
- path,
- `Deno KV file '${path}' is missing physical part ${index} of ${stored.parts}.`,
- );
- }
- chunks.push(part.value);
- }
-
- const joined = concat(chunks);
- const localStart = start - first * stored.partBytes;
- return joined.slice(localStart, localStart + (end - start));
- }
-
- /**
- * Commits materialized replace, append, and update writes without rebuilding
- * a complete base64 record.
- *
- * Replace can write the supplied bytes directly. Append/update construct a
- * new immutable generation one provider part at a time. Existing bytes are
- * read only for the output part currently being built, so a small patch to a
- * large partitioned file does not allocate the old logical file in memory.
- */
- async writeFile(
- path: Parameters>[0],
- data: Uint8Array,
- options: AdapterWriteOptionsType,
- ): Promise {
- throwIfAborted(options.signal, "write", path);
- const previousStored = await this.#stored(path);
- if (previousStored !== null && !isManifest(previousStored) && previousStored.kind === "directory") {
- throw new FileSystemError("type-mismatch", "write", path, `'${path}' is a directory.`);
- }
- const previous = previousStored === null
- ? null
- : isManifest(previousStored)
- ? previousStored.file
- : previousStored;
- const previousSize = previous?.kind === "file" ? previous.size : 0;
- const position = options.mode === "append" ? previousSize : options.mode === "update" ? options.at ?? 0 : 0;
- const outputSize = options.mode === "replace"
- ? data.byteLength
- : options.truncate
- ? position + data.byteLength
- : Math.max(previousSize, position + data.byteLength);
- const file = {
- version: 1 as const,
- path,
- parent: dirname(path),
- name: basename(path),
- kind: "file" as const,
- size: outputSize,
- lastModified: Date.now(),
- mediaType: options.mediaType ?? (previous?.kind === "file" ? previous.mediaType : ""),
- };
-
- if (options.mode === "replace") {
- await this.#saveFile(file, data);
- return;
- }
-
- const useParts = this.#partition === "always" || (this.#partition === "auto" && outputSize > this.#inlineBytes);
- if (!useParts) {
- if (outputSize > this.#inlineBytes && this.#partition === "never") {
- throw new FileSystemError(
- "too-large",
- "write",
- path,
- `Deno KV file is ${outputSize} bytes; configured inlineBytes is ${this.#inlineBytes}. Enable partitioning or lower the logical write size.`,
- );
- }
- const output = new Uint8Array(outputSize);
- if (previousStored !== null && previousSize > 0) {
- output.set(await this.#readRange(path, previousStored, 0, Math.min(previousSize, outputSize), options.signal));
- }
- output.set(data, position);
- await this.#saveFile(file, output);
- return;
- }
-
- const partCount = Math.max(1, Math.ceil(outputSize / this.#partBytes));
- if (partCount > this.#maxParts) {
- throw new FileSystemError(
- "too-large",
- "write",
- path,
- `Deno KV file requires ${partCount} parts, above configured maxParts ${this.#maxParts}.`,
- );
- }
-
- const previousManifest = previousStored !== null && isManifest(previousStored) ? previousStored : undefined;
- const nextGeneration = generation();
- const indexes = Array.from({ length: partCount }, (_, index) => index);
- try {
- for await (const _ of pooledMap(this.#concurrency, indexes, async (index) => {
- throwIfAborted(options.signal, "write", path);
- const start = index * this.#partBytes;
- const end = Math.min(outputSize, start + this.#partBytes);
- const chunk = new Uint8Array(end - start);
-
- const preservedEnd = Math.min(end, previousSize, outputSize);
- if (previousStored !== null && preservedEnd > start) {
- const preserved = await this.#readRange(path, previousStored, start, preservedEnd - start, options.signal);
- chunk.set(preserved, 0);
- }
-
- const patchStart = Math.max(start, position);
- const patchEnd = Math.min(end, position + data.byteLength);
- if (patchEnd > patchStart) {
- chunk.set(data.subarray(patchStart - position, patchEnd - position), patchStart - start);
- }
- await this.#database.set(partKey(this.#prefix, path, nextGeneration, index), chunk);
- })) {
- // The iterator is consumed so all bounded reads/writes settle before the manifest becomes visible.
- }
-
- throwIfAborted(options.signal, "write", path);
- await this.#database.set(key(this.#prefix, path), DenoKvManifestSchema.parse({
- storage: "deno-kv-parts-v2",
- generation: nextGeneration,
- parts: partCount,
- partBytes: this.#partBytes,
- file,
- }));
- } catch (error) {
- await this.#deleteGeneration(path, nextGeneration, partCount).catch(() => undefined);
- throw error;
- }
-
- if (previousManifest !== undefined) await this.#deleteParts(path, previousManifest);
- }
-
- /**
- * Writes an unknown-size replacement directly into Deno KV parts.
- *
- * `auto` uses the partition layout for streams even when the final file is
- * small. The final size is unknown until EOF, and switching from an inline
- * buffer to partitioned storage after a threshold would retain exactly the
- * memory growth this lane exists to avoid. Callers can disable this behavior
- * with `partition: "never"`, which also removes native stream-write support.
- */
- async writeStream(
- path: Parameters>[0],
- source: ReadableStream,
- options: AdapterWriteOptionsType,
- ): Promise {
- if (options.mode !== "replace" || this.#partition === "never") {
- await source.cancel().catch(() => undefined);
- throw new FileSystemError("not-supported", "write", path, `Deno KV streaming requires partitioned replace mode.`);
- }
- throwIfAborted(options.signal, "write", path);
- const previousStored = await this.#stored(path);
- if (previousStored !== null && !isManifest(previousStored) && previousStored.kind === "directory") {
- await source.cancel().catch(() => undefined);
- throw new FileSystemError("type-mismatch", "write", path, `'${path}' is a directory.`);
- }
- const previousManifest = isManifest(previousStored) ? previousStored : undefined;
- const previousMediaType = previousStored === null
- ? ""
- : isManifest(previousStored)
- ? previousStored.file.mediaType
- : previousStored.kind === "file"
- ? previousStored.mediaType
- : "";
- const nextGeneration = generation();
- let scheduled = 0;
- let size = 0;
-
- try {
- for await (const written of pooledMap(this.#concurrency, split(source, this.#partBytes), async (chunk) => {
- const index = scheduled++;
- if (index >= this.#maxParts) {
- throw new FileSystemError(
- "too-large",
- "write",
- path,
- `Deno KV stream exceeded configured maxParts ${this.#maxParts}.`,
- );
- }
- throwIfAborted(options.signal, "write", path);
- await this.#database.set(partKey(this.#prefix, path, nextGeneration, index), chunk);
- return { bytes: chunk.byteLength };
- })) size += written.bytes;
-
- if (scheduled === 0) {
- scheduled = 1;
- await this.#database.set(partKey(this.#prefix, path, nextGeneration, 0), new Uint8Array());
- }
- throwIfAborted(options.signal, "write", path);
- const manifest = DenoKvManifestSchema.parse({
- storage: "deno-kv-parts-v2",
- generation: nextGeneration,
- parts: scheduled,
- partBytes: this.#partBytes,
- file: {
- version: 1,
- path,
- parent: dirname(path),
- name: basename(path),
- kind: "file",
- size,
- lastModified: Date.now(),
- mediaType: options.mediaType ?? previousMediaType,
- },
- });
- await this.#database.set(key(this.#prefix, path), manifest);
- } catch (error) {
- await this.#deleteGeneration(path, nextGeneration, scheduled).catch(() => undefined);
- throw error;
- }
-
- if (previousManifest !== undefined) await this.#deleteParts(path, previousManifest);
- }
-
- /** Replaces one exact logical record and commits partition manifests only after every new part exists. */
- async set(record: RecordType): Promise {
- const previous = await this.#database.get(key(this.#prefix, record.path));
- const previousManifest = previous.value !== null && isManifest(previous.value)
- ? DenoKvManifestSchema.parse(previous.value)
- : undefined;
-
- if (record.kind === "directory") {
- await this.#database.set(key(this.#prefix, record.path), record);
- if (previousManifest !== undefined) await this.#deleteParts(record.path, previousManifest);
- return;
- }
-
- const bytes = decodeBase64(record.data);
- const partition = this.#partition === "always" || (this.#partition === "auto" && bytes.byteLength > this.#inlineBytes);
- if (!partition) {
- if (bytes.byteLength > this.#inlineBytes && this.#partition === "never") {
- throw new FileSystemError(
- "too-large",
- "write",
- record.path,
- `Deno KV inline file is ${bytes.byteLength} bytes; configured inlineBytes is ${this.#inlineBytes}. Enable partitioning or lower the logical write size.`,
- );
- }
- await this.#database.set(key(this.#prefix, record.path), record);
- if (previousManifest !== undefined) await this.#deleteParts(record.path, previousManifest);
- return;
- }
-
- const chunks = parts(bytes, this.#partBytes);
- if (chunks.length > this.#maxParts) {
- throw new FileSystemError(
- "too-large",
- "write",
- record.path,
- `Deno KV file requires ${chunks.length} parts, above configured maxParts ${this.#maxParts}.`,
- );
- }
-
- const nextGeneration = generation();
- const indexes = chunks.map((_, index) => index);
- try {
- for await (const _ of pooledMap(this.#concurrency, indexes, (index) =>
- this.#database.set(partKey(this.#prefix, record.path, nextGeneration, index), chunks[index]!))) {
- // pooledMap owns bounded concurrency; values are intentionally ignored.
- }
- const { data: _data, ...file } = record;
- const manifest = DenoKvManifestSchema.parse({
- storage: "deno-kv-parts-v2",
- generation: nextGeneration,
- parts: chunks.length,
- partBytes: this.#partBytes,
- file,
- });
- await this.#database.set(key(this.#prefix, record.path), manifest);
- } catch (error) {
- await this.#deleteGeneration(record.path, nextGeneration, chunks.length).catch(() => undefined);
- throw error;
- }
-
- if (previousManifest !== undefined) await this.#deleteParts(record.path, previousManifest);
- }
-
- /** Removes the logical visibility key first, then reclaims reachable body parts. */
- async delete(path: Parameters[0]): Promise {
- const previous = await this.#database.get(key(this.#prefix, path));
- const manifest = previous.value !== null && isManifest(previous.value)
- ? DenoKvManifestSchema.parse(previous.value)
- : undefined;
- await this.#database.delete(key(this.#prefix, path));
- if (manifest !== undefined) await this.#deleteParts(path, manifest);
- }
-
- /** Lists direct children from the parent-indexed entry key and never scans descendant subtrees or partition bodies. */
- async *list(parent: Parameters[0]): AsyncIterableIterator {
- for await (const entry of this.#database.list({ prefix: listKey(this.#prefix, parent) })) {
- if (entry.value === null) continue;
- const record = isManifest(entry.value)
- ? manifestList(DenoKvManifestSchema.parse(entry.value))
- : RecordSchema.parse(entry.value);
- if (record.parent === parent) yield record;
- }
- }
-
- /** Stores one complete file from bytes while preserving the manifest-last visibility rule. */
- async #saveFile(file: z.output, bytes: Uint8Array): Promise {
- const previousStored = await this.#stored(file.path);
- const previousManifest = isManifest(previousStored) ? previousStored : undefined;
- const useParts = this.#partition === "always" || (this.#partition === "auto" && bytes.byteLength > this.#inlineBytes);
- if (!useParts) {
- if (bytes.byteLength > this.#inlineBytes && this.#partition === "never") {
- throw new FileSystemError(
- "too-large",
- "write",
- file.path,
- `Deno KV inline file is ${bytes.byteLength} bytes; configured inlineBytes is ${this.#inlineBytes}. Enable partitioning or lower the logical write size.`,
- );
- }
- await this.#database.set(key(this.#prefix, file.path), RecordSchema.parse({ ...file, data: encodeBase64(bytes) }));
- if (previousManifest !== undefined) await this.#deleteParts(file.path, previousManifest);
- return;
- }
-
- const chunks = parts(bytes, this.#partBytes);
- if (chunks.length > this.#maxParts) {
- throw new FileSystemError(
- "too-large",
- "write",
- file.path,
- `Deno KV file requires ${chunks.length} parts, above configured maxParts ${this.#maxParts}.`,
- );
- }
- const nextGeneration = generation();
- const indexes = chunks.map((_, index) => index);
- try {
- for await (const _ of pooledMap(this.#concurrency, indexes, (index) =>
- this.#database.set(partKey(this.#prefix, file.path, nextGeneration, index), chunks[index]!))) {
- // pooledMap owns bounded concurrency; values are intentionally ignored.
- }
- await this.#database.set(key(this.#prefix, file.path), DenoKvManifestSchema.parse({
- storage: "deno-kv-parts-v2",
- generation: nextGeneration,
- parts: chunks.length,
- partBytes: this.#partBytes,
- file,
- }));
- } catch (error) {
- await this.#deleteGeneration(file.path, nextGeneration, chunks.length).catch(() => undefined);
- throw error;
- }
- if (previousManifest !== undefined) await this.#deleteParts(file.path, previousManifest);
- }
-
- /** Removes every expected part in one committed manifest with bounded provider concurrency. */
- async #deleteParts(path: string, manifest: DenoKvManifestType): Promise {
- await this.#deleteGeneration(path, manifest.generation, manifest.parts);
- }
-
- /** Reclaims a known generation after a failed or superseded manifest commit. */
- async #deleteGeneration(path: string, value: string, count: number): Promise {
- const indexes = Array.from({ length: count }, (_, index) => index);
- for await (const _ of pooledMap(this.#concurrency, indexes, (index) =>
- this.#database.delete(partKey(this.#prefix, path, value, index)))) {
- // Deletions are intentionally consumed so all already-started work settles.
- }
- }
-
- /** Closes the database only when the adapter was given ownership. */
- dispose(): void {
- if (this.#disposeDatabase) this.#database.close?.();
+ if (!Number.isSafeInteger(resolved) || resolved < 1) {
+ throw new RangeError(`${name} must be a positive safe integer.`);
}
+ return resolved;
}
/**
- * Creates the record-store layer over an injected Deno KV database.
+ * Creates the OPFS primitive translation over an injected Deno KV database.
*
- * The caller still decides whether the database is local, remote, persistent,
- * or ephemeral. Deno KV is runtime-specific but the structural adapter module
- * does not touch the ambient `Deno` global at import time.
+ * Deno KV partitioning is driver-owned. The duplicated adapter `limits` and
+ * `partition` fields describe how that driver route appears at the filesystem
+ * translation seam; provider and safety-policy provenance remain available in
+ * `adapter.driver.inspect()` and `FileSystemType.inspect().driver`.
*/
-export function createDenoKvRecordStore(database: DenoKvType, options: DenoKvAdapterOptionsType = {}): RecordStoreType {
- return new DenoKvRecordStore(database, options);
-}
-
-/** Creates an OPFS-shaped adapter over an injected Deno KV database with inspectable provider limits. */
-export function createDenoKvAdapter(database: DenoKvType, options: DenoKvAdapterOptionsType = {}): AdapterType {
+export function createDenoKvAdapter(
+ database: DenoKvType,
+ options: DenoKvAdapterOptionsType = {},
+): AdapterType {
const partition = PartitionModeSchema.parse(options.partition ?? "auto");
const partBytes = positive(options.partBytes, DENO_KV_DEFAULT_PART_BYTES, "partBytes");
const maxParts = positive(options.maxParts, DENO_KV_DEFAULT_MAX_PARTS, "maxParts");
- return createRecordAdapter(createDenoKvRecordStore(database, options), {
+ const concurrency = positive(options.concurrency, DENO_KV_DEFAULT_CONCURRENCY, "concurrency");
+ const inlineBytes = positive(options.inlineBytes, DENO_KV_DEFAULT_INLINE_BYTES, "inlineBytes");
+ const driver = createDenoKvDriver(database, options);
+
+ return createRecordAdapter(driver, {
name: "deno-kv",
readOnly: options.readOnly ?? false,
- disposeStore: true,
+ disposeDriver: true,
limits: {
maxFileBytes: partBytes * maxParts,
maxValueBytes: DENO_KV_MAX_VALUE_BYTES,
maxKeyBytes: DENO_KV_MAX_KEY_BYTES,
maxParts,
maxBatchBytes: DENO_KV_MAX_ATOMIC_BYTES,
- maxConcurrency: positive(options.concurrency, DENO_KV_DEFAULT_CONCURRENCY, "concurrency"),
+ maxConcurrency: concurrency,
},
partition: {
mode: partition,
partBytes,
- thresholdBytes: positive(options.inlineBytes, DENO_KV_DEFAULT_INLINE_BYTES, "inlineBytes"),
+ thresholdBytes: inlineBytes,
stream: partition !== "never",
maxParts,
layout: "deno-kv-parts-v2",
diff --git a/src/adapter/deno.ts b/src/adapter/deno.ts
index 5575e78..becdd11 100644
--- a/src/adapter/deno.ts
+++ b/src/adapter/deno.ts
@@ -1,384 +1,11 @@
-import type {
- AdapterCopyOptionsType,
- AdapterDirectoryEntryType,
- AdapterMoveOptionsType,
- AdapterReadOptionsType,
- AdapterSignalOptionsType,
- AdapterStatType,
- AdapterSyncFileType,
- AdapterType,
- AdapterWritableFileType,
- AdapterWriteOptionsType,
-} from "./definition.ts";
-import { defineAdapter } from "./definition.ts";
-import { createLocalPath } from "./local.ts";
-import { throwIfAborted, toFileSystemError } from "../error.ts";
-import type { PathType } from "../path.ts";
+import type { AdapterType } from "./definition.ts";
+import { createFileAdapter } from "./file.ts";
+import { createDenoDriver, type DenoDriverOptionsType } from "../driver/deno.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;
-}
-
-/**
- * Streams bytes into one already-open Deno file.
- *
- * The helper preserves the caller's replace/append/update cursor and cancels
- * the source producer when writing fails. It does not close the file because
- * the caller owns the surrounding acquisition/finalization block.
- */
-async function writeStreamToFile(
- file: Deno.FsFile,
- path: PathType,
- source: ReadableStream,
- options: AdapterWriteOptionsType,
-): Promise {
- let position = options.mode === "append"
- ? (await file.stat()).size
- : options.mode === "update"
- ? options.at ?? 0
- : 0;
- await file.seek(position, Deno.SeekMode.Start);
-
- 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 count = await file.write(next.value.subarray(offset));
- if (count <= 0) throw new Error(`Deno stream write made no progress for '${path}'.`);
- offset += count;
- }
- position += next.value.byteLength;
- }
- return position;
- } catch (error) {
- try {
- await reader.cancel(error);
- } catch {
- // Preserve the first write or cancellation failure.
- }
- throw error;
- } finally {
- reader.releaseLock();
- }
-}
-
-/**
- * Long-lived Deno positional file used by the adapter's asynchronous random
- * access capability.
- *
- * Normal Deno files cannot roll back bytes already written. `abort()` therefore
- * means release without additional commit work, not transactional rollback.
- */
-class DenoWritableFile implements AdapterWritableFileType {
- /** Canonical virtual path used in lifecycle diagnostics. */
- readonly #path: PathType;
- /** Native Deno file, cleared before terminal close/abort. */
- #file: Deno.FsFile | undefined;
-
- /** Takes ownership of one already-open Deno file. */
- constructor(path: PathType, file: Deno.FsFile) {
- this.#path = path;
- this.#file = file;
- }
-
- /** Returns the live Deno file or rejects access after termination. */
- #getFile(): Deno.FsFile {
- if (this.#file === undefined) throw new Error(`Writable file '${this.#path}' is closed.`);
- return this.#file;
- }
-
- /** Writes all bytes at one explicit position, including partial native writes. */
- async write(buffer: ArrayBufferView, options: { readonly at: number }): Promise {
- const source = new Uint8Array(buffer.buffer, buffer.byteOffset, buffer.byteLength);
- const file = this.#getFile();
- await file.seek(options.at, Deno.SeekMode.Start);
- let offset = 0;
- while (offset < source.byteLength) {
- const count = await file.write(source.subarray(offset));
- if (count <= 0) throw new Error(`Deno positional write made no progress for '${this.#path}'.`);
- offset += count;
- }
- }
-
- /** Changes native file length without releasing the resource. */
- async truncate(size: number): Promise {
- await this.#getFile().truncate(size);
- }
-
- /** Requests Deno's file sync operation. */
- async flush(): Promise {
- await this.#getFile().sync();
- }
-
- /** Closes once and clears the native resource before close returns. */
- async close(): Promise {
- const file = this.#file;
- if (file === undefined) return;
- this.#file = undefined;
- file.close();
- }
-
- /** Releases the file without claiming rollback of already-written host bytes. */
- async abort(): Promise {
- await this.close();
- }
-}
-
-/** Synchronous random-access wrapper over one Deno file. */
-class DenoSyncFile implements AdapterSyncFileType {
- /** Canonical virtual path used in post-close diagnostics. */
- readonly #path: PathType;
- /** Native Deno file, cleared after close. */
- #file: Deno.FsFile | undefined;
- /** Logical cursor for operations without an explicit `at`. */
- #cursor = 0;
-
- /** Takes ownership of one Deno file opened for sync access. */
- constructor(path: PathType, file: Deno.FsFile) {
- this.#path = path;
- this.#file = file;
- }
-
- /** Returns the live file or rejects access after close. */
- #getFile(): Deno.FsFile {
- if (this.#file === undefined) throw new Error(`Sync file '${this.#path}' is closed.`);
- return this.#file;
- }
-
- /** Reads synchronously and advances the wrapper cursor. */
- read(buffer: ArrayBufferView, options: { readonly at?: number } = {}): number {
- const target = new Uint8Array(buffer.buffer, buffer.byteOffset, buffer.byteLength);
- const at = options.at ?? this.#cursor;
- const file = this.#getFile();
- file.seekSync(at, Deno.SeekMode.Start);
- const count = file.readSync(target) ?? 0;
- this.#cursor = at + count;
- return count;
- }
-
- /** Writes synchronously and advances the wrapper cursor. */
- write(buffer: ArrayBufferView, options: { readonly at?: number } = {}): number {
- const source = new Uint8Array(buffer.buffer, buffer.byteOffset, buffer.byteLength);
- const at = options.at ?? this.#cursor;
- const file = this.#getFile();
- file.seekSync(at, Deno.SeekMode.Start);
- const count = file.writeSync(source);
- this.#cursor = at + count;
- return count;
- }
-
- /** Returns current native file size. */
- getSize(): number {
- return this.#getFile().statSync().size;
- }
-
- /** Truncates and clamps the local cursor to the new file end. */
- truncate(size: number): void {
- this.#getFile().truncateSync(size);
- if (this.#cursor > size) this.#cursor = size;
- }
-
- /** Requests synchronous durability for current writes. */
- flush(): void {
- this.#getFile().syncSync();
- }
-
- /** Closes the native Deno file exactly once. */
- close(): void {
- const file = this.#file;
- if (file === undefined) return;
- this.#file = undefined;
- file.close();
- }
-}
-
-/**
- * Deno host-filesystem implementation of the portable adapter contract.
- *
- * Deno owns the native file and directory operations. `@std/path` is used only
- * by the shared host-path mapper so Deno, Node, and Bun apply the same host-root
- * containment rule.
- */
-class DenoAdapter implements AdapterType {
- /** Stable adapter identity used in diagnostics. */
- readonly name = "deno";
- /** Native Deno filesystem operations exposed without facade emulation. */
- readonly capabilities = {
- read: true,
- write: true,
- streamRead: true,
- streamWriteModes: ["replace", "append", "update"],
- rangeRead: true,
- nativeCopy: true,
- nativeMove: true,
- positionalWrite: true,
- syncAccess: true,
- } as const;
- /** Maps canonical virtual paths below the configured host root. */
- readonly #hostPath: (path: string) => string;
-
- /** Resolves the host root once and optionally creates it. */
- constructor(options: DenoAdapterOptionsType) {
- this.#hostPath = createLocalPath(options.root);
- if (options.createRoot ?? true) Deno.mkdirSync(this.#hostPath("/"), { recursive: true });
- }
-
- /** Returns Deno file/directory metadata or `null` for an absent path. */
- async stat(path: PathType, options: AdapterSignalOptionsType = {}): Promise {
- throwIfAborted(options.signal, "stat", path);
- try {
- const info = await Deno.stat(this.#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;
- }
- }
-
- /** Reads complete bytes or performs positioned reads for one range. */
- async readFile(path: PathType, options: AdapterReadOptionsType = {}): Promise {
- throwIfAborted(options.signal, "read", path);
- if (options.at === undefined && options.length === undefined) return await Deno.readFile(this.#hostPath(path));
-
- const file = await Deno.open(this.#hostPath(path), { read: true });
- try {
- const info = await file.stat();
- const start = options.at ?? 0;
- const length = Math.max(0, Math.min(options.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();
- }
- }
-
- /** Opens Deno's native readable stream or a bounded range stream. */
- async openReadStream(path: PathType, options: AdapterReadOptionsType = {}): Promise> {
- throwIfAborted(options.signal, "read", path);
- if (options.at === undefined && options.length === undefined) {
- return (await Deno.open(this.#hostPath(path), { read: true })).readable;
- }
- return new Blob([Uint8Array.from(await this.readFile(path, options))]).stream();
- }
-
- /** Writes materialized bytes with replace, append, or positioned update semantics. */
- async writeFile(path: PathType, data: Uint8Array, options: AdapterWriteOptionsType): Promise {
- throwIfAborted(options.signal, "write", path);
- if (options.mode === "replace") {
- await Deno.writeFile(this.#hostPath(path), data, { create: true });
- return;
- }
-
- const file = await Deno.open(this.#hostPath(path), { read: true, write: true, create: true });
- try {
- const position = options.mode === "append" ? (await file.stat()).size : options.at ?? 0;
- await file.seek(position, Deno.SeekMode.Start);
- let offset = 0;
- while (offset < data.byteLength) {
- const count = await file.write(data.subarray(offset));
- if (count <= 0) throw new Error(`Deno write made no progress for '${path}'.`);
- offset += count;
- }
- if (options.truncate) await file.truncate(position + data.byteLength);
- } finally {
- file.close();
- }
- }
-
- /** Streams directly into one Deno file without facade materialization. */
- async writeStream(path: PathType, source: ReadableStream, options: AdapterWriteOptionsType): Promise {
- const file = await Deno.open(this.#hostPath(path), {
- read: true,
- write: true,
- create: true,
- truncate: options.mode === "replace",
- });
- try {
- const position = await writeStreamToFile(file, path, source, options);
- if (options.truncate) await file.truncate(position);
- } finally {
- file.close();
- }
- }
-
- /** Lazily yields direct file and directory children from Deno. */
- async *readDir(path: PathType, options: AdapterSignalOptionsType = {}): AsyncIterableIterator {
- throwIfAborted(options.signal, "read-dir", path);
- for await (const entry of Deno.readDir(this.#hostPath(path))) {
- throwIfAborted(options.signal, "read-dir", path);
- if (entry.isDirectory) yield { name: entry.name, kind: "directory" };
- else if (entry.isFile) yield { name: entry.name, kind: "file" };
- }
- }
-
- /** Creates one directory after facade parent resolution. */
- async createDir(path: PathType, options: AdapterSignalOptionsType = {}): Promise {
- throwIfAborted(options.signal, "mkdir", path);
- await Deno.mkdir(this.#hostPath(path));
- }
-
- /** Removes one file or empty directory. */
- async remove(path: PathType, options: AdapterSignalOptionsType = {}): Promise {
- throwIfAborted(options.signal, "remove", path);
- await Deno.remove(this.#hostPath(path));
- }
-
- /** Copies one host file through Deno's native copy operation. */
- async copy(source: PathType, destination: PathType, options: AdapterCopyOptionsType): Promise {
- throwIfAborted(options.signal, "copy", source);
- await Deno.copyFile(this.#hostPath(source), this.#hostPath(destination));
- }
-
- /** Moves one host path through Deno's native rename operation. */
- async move(source: PathType, destination: PathType, options: AdapterMoveOptionsType): Promise {
- throwIfAborted(options.signal, "move", source);
- await Deno.rename(this.#hostPath(source), this.#hostPath(destination));
- }
-
- /** Opens one long-lived asynchronous positional Deno file. */
- async openWritableFile(path: PathType): Promise {
- return new DenoWritableFile(path, await Deno.open(this.#hostPath(path), { read: true, write: true }));
- }
-
- /** Opens one synchronous Deno file and transfers ownership to the wrapper. */
- async openSyncFile(path: PathType): Promise {
- return new DenoSyncFile(path, Deno.openSync(this.#hostPath(path), { read: true, write: true }));
- }
-}
+/** Options for the Deno filesystem adapter. */
+export type DenoAdapterOptionsType = DenoDriverOptionsType;
-/**
- * Creates an adapter backed by Deno file APIs.
- *
- * The adapter remains Deno-native for filesystem work while sharing only the
- * portable `@std/path` host-root mapper with Node and Bun.
- *
- * @example Persist below one Deno host directory.
- * ```ts
- * const fs = createFileSystem(createDenoAdapter({ root: "./data" }), {
- * coordination: "local",
- * });
- * await fs.writeFile("/cache/result.json", "{}", { parents: true });
- * ```
- */
+/** Creates the OPFS primitive translation over a Deno filesystem driver. */
export function createDenoAdapter(options: DenoAdapterOptionsType): AdapterType {
- return defineAdapter(new DenoAdapter(options));
+ return createFileAdapter(createDenoDriver(options), { disposeDriver: true });
}
diff --git a/src/adapter/indexeddb.ts b/src/adapter/indexeddb.ts
index fb71154..8ff08cf 100644
--- a/src/adapter/indexeddb.ts
+++ b/src/adapter/indexeddb.ts
@@ -1,148 +1,32 @@
import type { AdapterType } from "./definition.ts";
-import { createRecordAdapter, type RecordStoreType } from "./record.ts";
-import { RecordSchema } from "../schema.ts";
-
-/** Options for an existing IndexedDB database. */
-export interface IndexedDbAdapterOptionsType {
- /** Object store containing records. Defaults to `entries`. */
- readonly store?: string;
- /** Parent-path index. Defaults to `parent`. */
- readonly parentIndex?: string;
- /** Closes the injected database when the adapter closes. */
- readonly disposeDatabase?: boolean;
- /** Prevents mutations. */
- readonly readOnly?: boolean;
-}
-
-/** Options used when this package opens and owns an IndexedDB database. */
-export interface IndexedDbOpenOptionsType extends Omit {
- /** Database name. Defaults to `okikio-opfs`. */
- readonly name?: string;
- /** Database schema version. Defaults to 1. */
- readonly version?: number;
-}
-
-/** Converts one IDBRequest completion into a Promise while retaining native errors. */
-function result(request: IDBRequest): Promise {
- const pending = Promise.withResolvers();
- request.onsuccess = () => pending.resolve(request.result);
- request.onerror = () => pending.reject(request.error ?? new Error("IndexedDB request failed."));
- return pending.promise;
-}
-
-/** Waits for transaction commit instead of treating request success as durable completion. */
-function committed(transaction: IDBTransaction): Promise {
- const pending = Promise.withResolvers();
- transaction.oncomplete = () => pending.resolve();
- transaction.onabort = () => pending.reject(transaction.error ?? new Error("IndexedDB transaction aborted."));
- transaction.onerror = () => pending.reject(transaction.error ?? new Error("IndexedDB transaction failed."));
- return pending.promise;
-}
-
-/** Applies the record-store object-store/index schema during an IndexedDB upgrade event. */
-function upgradeDatabase(request: IDBOpenDBRequest, storeName: string, parentIndex: string): void {
- const database = request.result;
- const store = database.objectStoreNames.contains(storeName)
- ? request.transaction!.objectStore(storeName)
- : database.createObjectStore(storeName, { keyPath: "path" });
- if (!store.indexNames.contains(parentIndex)) store.createIndex(parentIndex, "parent", { unique: false });
-}
-
-/**
- * Record-store projection over one prepared IndexedDB database.
- *
- * Every write waits for transaction completion rather than treating the
- * individual request success event as commit authority. Direct-child listing
- * uses the configured `parent` index.
- */
-class IndexedDbRecordStore implements RecordStoreType {
- /** IndexedDB database borrowed or owned according to adapter options. */
- readonly #database: IDBDatabase;
- /** Object store containing validated filesystem records. */
- readonly #storeName: string;
- /** Index used for direct-child listing. */
- readonly #parentIndex: string;
- /** Whether disposal closes the database. */
- readonly #disposeDatabase: boolean;
-
- /** Resolves store/index names once for every transaction. */
- constructor(database: IDBDatabase, options: IndexedDbAdapterOptionsType) {
- this.#database = database;
- this.#storeName = options.store ?? "entries";
- this.#parentIndex = options.parentIndex ?? "parent";
- this.#disposeDatabase = options.disposeDatabase ?? false;
- }
-
- /** Reads and validates one record in a readonly transaction. */
- async get(path: Parameters[0]) {
- const transaction = this.#database.transaction(this.#storeName, "readonly");
- const value = await result(transaction.objectStore(this.#storeName).get(path));
- return value === undefined ? null : RecordSchema.parse(value);
- }
-
- /** Replaces one record and waits for the readwrite transaction to commit. */
- async set(record: Parameters[0]): Promise {
- const transaction = this.#database.transaction(this.#storeName, "readwrite");
- transaction.objectStore(this.#storeName).put(record);
- await committed(transaction);
- }
-
- /** Removes one record and waits for the readwrite transaction to commit. */
- async delete(path: Parameters[0]): Promise {
- const transaction = this.#database.transaction(this.#storeName, "readwrite");
- transaction.objectStore(this.#storeName).delete(path);
- await committed(transaction);
- }
-
- /** Reads direct children through the parent-path index. */
- async *list(parent: Parameters[0]) {
- const transaction = this.#database.transaction(this.#storeName, "readonly");
- const values = await result(transaction.objectStore(this.#storeName).index(this.#parentIndex).getAll(parent));
- for (const value of values) yield RecordSchema.parse(value);
- }
-
- /** Closes the database only when ownership was explicitly transferred. */
- dispose(): void {
- if (this.#disposeDatabase) this.#database.close();
- }
-}
-
-/** Creates a record store over a prepared IndexedDB database. */
-export function createIndexedDbRecordStore(
- database: IDBDatabase,
- options: IndexedDbAdapterOptionsType = {},
-): RecordStoreType {
- return new IndexedDbRecordStore(database, options);
-}
-
-/** Creates an OPFS-shaped adapter over an existing IndexedDB database. */
+import { createRecordAdapter } from "./record.ts";
+import {
+ createIndexedDbDriver,
+ type IndexedDbDriverOptionsType,
+ type IndexedDbOpenOptionsType,
+ openIndexedDbDriver,
+} from "../driver/indexeddb.ts";
+
+/** Options for an IndexedDB-backed filesystem adapter. */
+export type IndexedDbAdapterOptionsType = IndexedDbDriverOptionsType;
+export type { IndexedDbOpenOptionsType };
+
+/** Creates a filesystem adapter over an existing IndexedDB database. */
export function createIndexedDbAdapter(database: IDBDatabase, options: IndexedDbAdapterOptionsType = {}): AdapterType {
- return createRecordAdapter(createIndexedDbRecordStore(database, options), {
+ const driver = createIndexedDbDriver(database, options);
+ return createRecordAdapter(driver, {
name: "indexeddb",
readOnly: options.readOnly ?? false,
- disposeStore: true,
+ disposeDriver: true,
});
}
-/**
- * Opens an IndexedDB database with the record schema expected by this adapter.
- *
- * The created object store uses `path` as its key and indexes `parent`, so one
- * directory lookup does not scan the complete database. The returned adapter
- * owns the opened database and closes it with the filesystem lifecycle.
- */
+/** Opens an owned IndexedDB database and returns its filesystem adapter. */
export async function openIndexedDbAdapter(options: IndexedDbOpenOptionsType = {}): Promise {
- const name = options.name ?? "okikio-opfs";
- const version = options.version ?? 1;
- const storeName = options.store ?? "entries";
- const parentIndex = options.parentIndex ?? "parent";
- const request = indexedDB.open(name, version);
- request.onupgradeneeded = () => upgradeDatabase(request, storeName, parentIndex);
- const database = await result(request);
- return createIndexedDbAdapter(database, {
- store: storeName,
- parentIndex,
- disposeDatabase: true,
- ...(options.readOnly === undefined ? {} : { readOnly: options.readOnly }),
+ const driver = await openIndexedDbDriver(options);
+ return createRecordAdapter(driver, {
+ name: "indexeddb",
+ readOnly: options.readOnly ?? false,
+ disposeDriver: true,
});
}
diff --git a/src/adapter/localstorage.ts b/src/adapter/localstorage.ts
index 6c13880..10a18c8 100644
--- a/src/adapter/localstorage.ts
+++ b/src/adapter/localstorage.ts
@@ -1,121 +1,27 @@
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";
-
-/** Minimal synchronous Web Storage contract used by the adapter. */
-export interface LocalStorageType {
- /** Number of keys in the storage area. */
- readonly length: number;
- /** Returns the key at one storage index. */
- key(index: number): string | null;
- /** Reads one string value. */
- getItem(key: string): string | null;
- /** Replaces one string value. */
- setItem(key: string, value: string): void;
- /** Removes one key. */
- removeItem(key: string): void;
-}
+import { createRecordAdapter } from "./record.ts";
+import {
+ createLocalStorageDriver,
+ type LocalStorageDriverOptionsType,
+ type LocalStorageType,
+} from "../driver/localstorage.ts";
/** Options for the localStorage-backed adapter. */
-export interface LocalStorageAdapterOptionsType {
- /** Key prefix reserved for filesystem records. Defaults to `opfs`. */
- readonly prefix?: string;
+export interface LocalStorageAdapterOptionsType extends LocalStorageDriverOptionsType {
/** Prevents filesystem mutations. */
readonly readOnly?: boolean;
}
-/** Creates the reversible key used for one canonical virtual path. */
-function getKey(prefix: string, path: PathType): string {
- return `${prefix}:${encodeURIComponent(path)}`;
-}
-
-/** Returns a canonical path from one adapter-owned key. */
-function getPath(prefix: string, key: string): PathType | null {
- const marker = `${prefix}:`;
- if (!key.startsWith(marker)) return null;
- try {
- return normalizePath(decodeURIComponent(key.slice(marker.length)));
- } catch {
- // Ignore malformed foreign keys inside the reserved prefix. Exact adapter
- // reads still surface malformed stored records through RecordSchema.
- return null;
- }
-}
-
-/**
- * Record-store projection over one synchronous Web Storage area.
- *
- * Web Storage is string-only and has index-based key iteration. The store
- * therefore serializes complete records as JSON and scans only the reserved
- * namespace when it needs direct children. It does not claim streaming or
- * filesystem-scale directory performance.
- */
-class LocalStorageRecordStore implements RecordStoreType {
- /** Browser Storage-like object borrowed from the caller. */
- readonly #storage: LocalStorageType;
- /** Normalized key prefix reserved for this filesystem. */
- readonly #prefix: string;
-
- /** Resolves the private namespace once for every later record operation. */
- constructor(storage: LocalStorageType, options: LocalStorageAdapterOptionsType) {
- this.#storage = storage;
- this.#prefix = (options.prefix ?? "opfs").replace(/:+$/g, "") || "opfs";
- }
-
- /** Reads and validates one JSON record. */
- async get(path: PathType) {
- const value = this.#storage.getItem(getKey(this.#prefix, path));
- return value === null ? null : RecordSchema.parse(JSON.parse(value));
- }
-
- /** Replaces one complete JSON record synchronously. */
- async set(record: Parameters[0]): Promise {
- this.#storage.setItem(getKey(this.#prefix, record.path), JSON.stringify(record));
- }
-
- /** Removes one exact adapter-owned key. */
- async delete(path: PathType): Promise {
- this.#storage.removeItem(getKey(this.#prefix, path));
- }
-
- /** Scans the reserved namespace and yields direct child records only. */
- async *list(parent: PathType) {
- const parentDepth = splitPath(parent).length;
- for (let index = 0; index < this.#storage.length; index += 1) {
- const key = this.#storage.key(index);
- if (key === null) continue;
- const path = getPath(this.#prefix, key);
- if (path === null || splitPath(path).length !== parentDepth + 1) continue;
- const value = this.#storage.getItem(key);
- if (value === null) continue;
- const record = RecordSchema.parse(JSON.parse(value));
- if (record.parent === parent) yield record;
- }
- }
-}
-
-/**
- * Creates the record-store layer over the Web Storage `Storage` contract.
- *
- * File bytes use the normal record adapter's Base64 representation. This path
- * is useful for small settings/cache data, not large files.
- */
-export function createLocalStorageRecordStore(
- storage: LocalStorageType,
- options: LocalStorageAdapterOptionsType = {},
-): RecordStoreType {
- return new LocalStorageRecordStore(storage, options);
-}
-
-/** Creates an OPFS-shaped facade adapter over localStorage or another Web Storage-compatible object. */
+/** Creates an OPFS-shaped adapter over localStorage or another Web Storage-compatible object. */
export function createLocalStorageAdapter(
storage: LocalStorageType,
options: LocalStorageAdapterOptionsType = {},
): AdapterType {
- return createRecordAdapter(createLocalStorageRecordStore(storage, options), {
+ return createRecordAdapter(createLocalStorageDriver(storage, options), {
name: "localstorage",
readOnly: options.readOnly ?? false,
- disposeStore: false,
+ disposeDriver: true,
});
}
+
+export type { LocalStorageType } from "../driver/localstorage.ts";
diff --git a/src/adapter/node.ts b/src/adapter/node.ts
index 0b9f05f..1dd6cb9 100644
--- a/src/adapter/node.ts
+++ b/src/adapter/node.ts
@@ -1,423 +1,16 @@
-import type { FileHandle as NodeFileHandle } from "node:fs/promises";
-import type {
- AdapterCopyOptionsType,
- AdapterDirectoryEntryType,
- AdapterMoveOptionsType,
- AdapterReadOptionsType,
- AdapterSignalOptionsType,
- AdapterStatType,
- AdapterSyncFileType,
- AdapterType,
- AdapterWritableFileType,
- AdapterWriteOptionsType,
-} from "./definition.ts";
-import { defineAdapter } from "./definition.ts";
-import { createLocalPath } from "./local.ts";
-import { throwIfAborted, toFileSystemError } from "../error.ts";
-import type { PathType } from "../path.ts";
+import type { AdapterType } from "./definition.ts";
+import { createFileAdapter } from "./file.ts";
+import { createNodeDriver, type NodeDriverOptionsType } from "../driver/node.ts";
-/** Node built-in filesystem module shape used through `process.getBuiltinModule()`. */
-type NodeFsType = typeof import("node:fs");
-/** Node promise-based filesystem module shape used through `process.getBuiltinModule()`. */
-type NodeFsPromisesType = typeof import("node:fs/promises");
-/** Node stream module shape used only to convert native streams to Web Streams. */
-type NodeStreamType = typeof import("node:stream");
-
-/** 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;
-}
-
-/** Opens one update-mode file, creating it only when the path was absent. */
-async function openUpdateFile(
- fs: NodeFsPromisesType,
- path: string,
- virtualPath: string,
-): Promise {
- try {
- return await fs.open(path, "r+");
- } catch (error) {
- if (toFileSystemError(error, "write", virtualPath).code !== "not-found") throw error;
- return await fs.open(path, "w+");
- }
-}
-
-/**
- * Drains a Web byte stream into one Node file descriptor.
- *
- * The descriptor stays open for the full stream. Partial writes advance the
- * explicit cursor until every chunk is committed. If writing fails, the source
- * producer is cancelled before the file closes so upstream work does not keep
- * producing bytes for a terminal operation.
- */
-async function writeStreamToFile(
- fs: NodeFsPromisesType,
- hostPath: string,
- virtualPath: string,
- source: ReadableStream,
- options: AdapterWriteOptionsType,
-): Promise {
- let file: NodeFileHandle | undefined;
- try {
- file = options.mode === "update"
- ? await openUpdateFile(fs, hostPath, virtualPath)
- : await fs.open(hostPath, options.mode === "replace" ? "w+" : "a+");
-
- let position = options.mode === "replace"
- ? 0
- : options.mode === "append"
- ? (await file.stat()).size
- : options.at ?? 0;
-
- const reader = source.getReader();
- try {
- while (true) {
- throwIfAborted(options.signal, "write", virtualPath);
- 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 '${virtualPath}'.`);
- offset += result.bytesWritten;
- position += result.bytesWritten;
- }
- }
- } catch (error) {
- try {
- await reader.cancel(error);
- } catch {
- // The original write/cancellation failure is the useful terminal cause.
- }
- throw error;
- } finally {
- reader.releaseLock();
- }
-
- if (options.truncate) await file.truncate(position);
- } finally {
- await file?.close();
- }
-}
-
-/**
- * Long-lived Node positional file used by {@link NodeAdapter.openWritableFile}.
- *
- * The class keeps one descriptor open for rewrites and treats `#file ===
- * undefined` as the only closed-state marker. `abort()` cannot roll back bytes
- * already written to a normal host file; it only releases the descriptor.
- */
-class NodeWritableFile implements AdapterWritableFileType {
- /** Canonical virtual path used in lifecycle diagnostics. */
- readonly #path: PathType;
- /** Native file descriptor, cleared before terminal close/abort. */
- #file: NodeFileHandle | undefined;
-
- /** Takes ownership of the already-open Node file descriptor. */
- constructor(path: PathType, file: NodeFileHandle) {
- this.#path = path;
- this.#file = file;
- }
-
- /** Returns the live descriptor and rejects ordinary work after termination. */
- #getFile(): NodeFileHandle {
- if (this.#file === undefined) throw new Error(`Writable file '${this.#path}' is closed.`);
- return this.#file;
- }
-
- /** Writes every source byte at one explicit position, including partial native writes. */
- async write(buffer: ArrayBufferView, options: { readonly at: number }): Promise {
- const source = new Uint8Array(buffer.buffer, buffer.byteOffset, buffer.byteLength);
- let offset = 0;
- while (offset < source.byteLength) {
- const result = await this.#getFile().write(source, offset, source.byteLength - offset, options.at + offset);
- if (result.bytesWritten <= 0) throw new Error(`Node positional write made no progress for '${this.#path}'.`);
- offset += result.bytesWritten;
- }
- }
-
- /** Changes the current native file length without closing it. */
- async truncate(size: number): Promise {
- await this.#getFile().truncate(size);
- }
-
- /** Requests `fsync` through Node's promise file handle. */
- async flush(): Promise {
- await this.#getFile().sync();
- }
-
- /** Closes once and clears the descriptor before awaiting native close. */
- async close(): Promise {
- const file = this.#file;
- if (file === undefined) return;
- this.#file = undefined;
- await file.close();
- }
-
- /** Releases the descriptor without claiming rollback of bytes already written. */
- async abort(): Promise {
- await this.close();
- }
-}
+/** Options for the Node filesystem adapter. */
+export type NodeAdapterOptionsType = NodeDriverOptionsType;
/**
- * Synchronous random-access wrapper over one Node file descriptor.
- *
- * Cursor state is local to this wrapper. Passing `at` on a read/write performs
- * that operation at the explicit position and moves the wrapper cursor to the
- * end of the operation, matching the package sync-file contract.
- */
-class NodeSyncFile implements AdapterSyncFileType {
- /** Node sync API used for descriptor operations. */
- readonly #fs: NodeFsType;
- /** Canonical virtual path used in lifecycle diagnostics. */
- readonly #path: PathType;
- /** Native descriptor, cleared after close. */
- #descriptor: number | undefined;
- /** Logical cursor used when an operation omits `at`. */
- #cursor = 0;
-
- /** Takes ownership of one already-open descriptor. */
- constructor(fs: NodeFsType, path: PathType, descriptor: number) {
- this.#fs = fs;
- this.#path = path;
- this.#descriptor = descriptor;
- }
-
- /** Returns the live descriptor and rejects access after close. */
- #getDescriptor(): number {
- if (this.#descriptor === undefined) throw new Error(`Sync file '${this.#path}' is closed.`);
- return this.#descriptor;
- }
-
- /** Reads synchronously into the caller buffer and advances the local cursor. */
- read(buffer: ArrayBufferView, options: { readonly at?: number } = {}): number {
- const target = new Uint8Array(buffer.buffer, buffer.byteOffset, buffer.byteLength);
- const position = options.at ?? this.#cursor;
- const count = this.#fs.readSync(this.#getDescriptor(), target, 0, target.byteLength, position);
- this.#cursor = position + count;
- return count;
- }
-
- /** Writes synchronously and advances the local cursor by native progress. */
- write(buffer: ArrayBufferView, options: { readonly at?: number } = {}): number {
- const source = new Uint8Array(buffer.buffer, buffer.byteOffset, buffer.byteLength);
- const position = options.at ?? this.#cursor;
- const count = this.#fs.writeSync(this.#getDescriptor(), source, 0, source.byteLength, position);
- this.#cursor = position + count;
- return count;
- }
-
- /** Returns the current native file size. */
- getSize(): number {
- return this.#fs.fstatSync(this.#getDescriptor()).size;
- }
-
- /** Truncates the file and clamps the local cursor to the new end. */
- truncate(size: number): void {
- this.#fs.ftruncateSync(this.#getDescriptor(), size);
- if (this.#cursor > size) this.#cursor = size;
- }
-
- /** Requests native filesystem durability for current descriptor writes. */
- flush(): void {
- this.#fs.fsyncSync(this.#getDescriptor());
- }
-
- /** Closes the native descriptor exactly once. */
- close(): void {
- const descriptor = this.#descriptor;
- if (descriptor === undefined) return;
- this.#descriptor = undefined;
- this.#fs.closeSync(descriptor);
- }
-}
-
-/**
- * Node host-filesystem implementation of the portable adapter contract.
- *
- * Runtime-specific modules are resolved through `process.getBuiltinModule()` in
- * the constructor. The package root and other adapter subpaths therefore do not
- * load Node built-ins merely because this source exists in the package.
- */
-class NodeAdapter implements AdapterType {
- /** Stable adapter identity used in diagnostics. */
- readonly name = "node";
- /** Native Node filesystem operations exposed without facade emulation. */
- readonly capabilities = {
- read: true,
- write: true,
- streamRead: true,
- streamWriteModes: ["replace", "append", "update"],
- rangeRead: true,
- nativeCopy: true,
- nativeMove: true,
- positionalWrite: true,
- syncAccess: true,
- } as const;
- /** Node synchronous filesystem module. */
- readonly #fs: NodeFsType;
- /** Node promise-based filesystem module. */
- readonly #fsp: NodeFsPromisesType;
- /** Node stream module used only for native-to-Web stream conversion. */
- readonly #stream: NodeStreamType;
- /** Maps canonical virtual paths below the configured host root. */
- readonly #hostPath: (path: string) => string;
-
- /** Resolves Node built-ins and optionally creates the configured host root. */
- constructor(options: NodeAdapterOptionsType) {
- this.#fs = globalThis.process.getBuiltinModule("node:fs") as NodeFsType;
- this.#fsp = globalThis.process.getBuiltinModule("node:fs/promises") as NodeFsPromisesType;
- this.#stream = globalThis.process.getBuiltinModule("node:stream") as NodeStreamType;
- this.#hostPath = createLocalPath(options.root);
- if (options.createRoot ?? true) this.#fs.mkdirSync(this.#hostPath("/"), { recursive: true });
- }
-
- /** Returns host metadata or `null` when the virtual path is absent. */
- async stat(path: PathType, options: AdapterSignalOptionsType = {}): Promise {
- throwIfAborted(options.signal, "stat", path);
- try {
- const info = await this.#fsp.stat(this.#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;
- }
- }
-
- /** Reads the complete file or performs positioned reads for one requested range. */
- async readFile(path: PathType, options: AdapterReadOptionsType = {}): Promise {
- throwIfAborted(options.signal, "read", path);
- if (options.at === undefined && options.length === undefined) {
- return new Uint8Array(await this.#fsp.readFile(this.#hostPath(path)));
- }
-
- const file = await this.#fsp.open(this.#hostPath(path), "r");
- try {
- const info = await file.stat();
- const start = options.at ?? 0;
- const length = Math.max(0, Math.min(options.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();
- }
- }
-
- /** Opens a native Node read stream and projects it as a Web byte stream. */
- async openReadStream(path: PathType, options: AdapterReadOptionsType = {}): Promise> {
- throwIfAborted(options.signal, "read", path);
- const start = options.at ?? 0;
- const end = options.length === undefined ? undefined : Math.max(start, start + options.length - 1);
- const stream = this.#fs.createReadStream(this.#hostPath(path), { start, ...(end === undefined ? {} : { end }) });
- return this.#stream.Readable.toWeb(stream) as unknown as ReadableStream;
- }
-
- /** Preserves replace, append, and positioned update semantics with native Node APIs. */
- async writeFile(path: PathType, data: Uint8Array, options: AdapterWriteOptionsType): Promise {
- throwIfAborted(options.signal, "write", path);
- const target = this.#hostPath(path);
- if (options.mode === "replace") {
- await this.#fsp.writeFile(target, data);
- return;
- }
- if (options.mode === "append") {
- await this.#fsp.appendFile(target, data);
- return;
- }
-
- const file = await openUpdateFile(this.#fsp, target, path);
- try {
- const position = options.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 (options.truncate) await file.truncate(position + data.byteLength);
- } finally {
- await file.close();
- }
- }
-
- /** Streams bytes directly to one native file without facade materialization. */
- async writeStream(path: PathType, source: ReadableStream, options: AdapterWriteOptionsType): Promise {
- await writeStreamToFile(this.#fsp, this.#hostPath(path), path, source, options);
- }
-
- /** Lazily yields native direct children that are files or directories. */
- async *readDir(path: PathType, options: AdapterSignalOptionsType = {}): AsyncIterableIterator {
- throwIfAborted(options.signal, "read-dir", path);
- for (const entry of await this.#fsp.readdir(this.#hostPath(path), { withFileTypes: true })) {
- throwIfAborted(options.signal, "read-dir", path);
- if (entry.isDirectory()) yield { name: entry.name, kind: "directory" };
- else if (entry.isFile()) yield { name: entry.name, kind: "file" };
- }
- }
-
- /** Creates exactly one host directory. Parent creation belongs to the facade. */
- async createDir(path: PathType, options: AdapterSignalOptionsType = {}): Promise {
- throwIfAborted(options.signal, "mkdir", path);
- await this.#fsp.mkdir(this.#hostPath(path));
- }
-
- /** Removes one host file or empty directory. Recursive policy belongs to the facade. */
- async remove(path: PathType, options: AdapterSignalOptionsType = {}): Promise {
- throwIfAborted(options.signal, "remove", path);
- await this.#fsp.rm(this.#hostPath(path));
- }
-
- /** Uses `copyFile()` so source bytes do not route through JavaScript buffers. */
- async copy(source: PathType, destination: PathType, options: AdapterCopyOptionsType): Promise {
- throwIfAborted(options.signal, "copy", source);
- await this.#fsp.copyFile(this.#hostPath(source), this.#hostPath(destination));
- }
-
- /** Uses native rename for the adapter's move capability. */
- async move(source: PathType, destination: PathType, options: AdapterMoveOptionsType): Promise {
- throwIfAborted(options.signal, "move", source);
- await this.#fsp.rename(this.#hostPath(source), this.#hostPath(destination));
- }
-
- /** Opens one long-lived asynchronous positional file descriptor. */
- async openWritableFile(path: PathType): Promise {
- return new NodeWritableFile(path, await this.#fsp.open(this.#hostPath(path), "r+"));
- }
-
- /** Opens one synchronous random-access descriptor and transfers ownership to the wrapper. */
- async openSyncFile(path: PathType): Promise {
- return new NodeSyncFile(this.#fs, path, this.#fs.openSync(this.#hostPath(path), "r+"));
- }
-}
-
-/**
- * Creates an adapter over Node's native filesystem APIs.
- *
- * The adapter maps virtual `/` to `root` and never exposes host paths through
- * the public facade. Importing the root OPFS package does not import this
- * adapter; Node-specific behavior remains on the explicit `adapter/node`
- * subpath.
+ * Creates the OPFS primitive translation over a Node filesystem driver.
*
- * @example Use OPFS-shaped handles over a host directory.
- * ```ts
- * const fs = createFileSystem(createNodeAdapter({ root: "./data" }));
- * await fs.writeFile("/state.json", "{}", { parents: true });
- * ```
+ * The driver owns host filesystem mechanics. This adapter only exposes those
+ * primitives to `FileSystemType`.
*/
export function createNodeAdapter(options: NodeAdapterOptionsType): AdapterType {
- return defineAdapter(new NodeAdapter(options));
+ return createFileAdapter(createNodeDriver(options), { disposeDriver: true });
}
diff --git a/src/adapter/s3.ts b/src/adapter/s3.ts
index 7e2bf70..5726dde 100644
--- a/src/adapter/s3.ts
+++ b/src/adapter/s3.ts
@@ -1,19 +1,18 @@
import type { AdapterType } from "./definition.ts";
import { createObjectAdapter, type ObjectAdapterOptionsType } from "./object.ts";
import type { S3ClientType } from "../s3.ts";
+import { createS3DriverFromClient } from "../driver/s3.ts";
/** S3 filesystem mapping options. */
export type S3AdapterOptionsType = ObjectAdapterOptionsType;
/**
- * Creates an OPFS-shaped adapter over a preconfigured S3-compatible client.
+ * Creates the OPFS translation over a preconfigured S3 protocol client.
*
- * The S3 client remains useful independently. The adapter adds virtual
- * directories, filesystem write modes, recursive facade operations, and path
- * coordination without hiding S3's object semantics. Injection keeps
- * credentials, endpoint selection, and client lifecycle outside the generic
- * filesystem layer.
+ * The client remains independently useful. A configured S3 driver is inserted
+ * between the protocol client and filesystem adapter so limits, requirements,
+ * and provider optimization policy remain separately inspectable.
*/
export function createS3Adapter(client: S3ClientType, options: S3AdapterOptionsType = {}): AdapterType {
- return createObjectAdapter(client, options);
+ return createObjectAdapter(createS3DriverFromClient(client), options);
}
diff --git a/src/adapter/sqlite.ts b/src/adapter/sqlite.ts
index 5647a77..0d10901 100644
--- a/src/adapter/sqlite.ts
+++ b/src/adapter/sqlite.ts
@@ -1,99 +1,31 @@
import type { AdapterType } from "./definition.ts";
-import { createDb0Adapter, type Db0PrimitiveType, type Db0StatementType } from "./db0.ts";
+import { createRecordAdapter } from "./record.ts";
+import {
+ createSqliteDriver,
+ type SqliteDatabaseType,
+ type SqliteDriverOptionsType,
+ type SqliteStatementType,
+} from "../driver/sqlite.ts";
-/** Statement shape shared by Node, Bun, Deno, and other SQLite wrappers. */
-export interface SqliteStatementType {
- /** Returns all matching rows. */
- all(...params: Db0PrimitiveType[]): unknown[] | Promise;
- /** Returns the first matching row. */
- get(...params: Db0PrimitiveType[]): unknown | Promise;
- /** Executes a mutation. */
- run(...params: Db0PrimitiveType[]): unknown | Promise;
-}
-
-/** Minimal connected SQLite database contract used by the direct adapter. */
-export interface SqliteDatabaseType {
- /** Compiles one SQL statement. */
- prepare(sql: string): SqliteStatementType;
- /** Closes the database when ownership is transferred. */
- close?(): void | Promise;
-}
-
-/** Direct SQLite adapter options. */
-export interface SqliteAdapterOptionsType {
- /** Adapter-owned table. Defaults to `opfs_entries`. */
- readonly table?: string;
- /** Creates the table before returning. Defaults to true. */
- readonly initialize?: boolean;
- /** Closes the injected database with the adapter. */
- readonly disposeDatabase?: boolean;
-}
-
-/** Converts one SQLite statement to db0's asynchronous statement contract. */
-class SqliteStatement implements Db0StatementType {
- /** Runtime-specific SQLite statement borrowed from the connected database. */
- readonly #statement: SqliteStatementType;
-
- /** Binds one prepared statement without executing it. */
- constructor(statement: SqliteStatementType) {
- this.#statement = statement;
- }
-
- /** Returns all rows and normalizes synchronous wrappers to a Promise. */
- async all(...params: Db0PrimitiveType[]): Promise {
- return await this.#statement.all(...params);
- }
+/** Options forwarded to the SQLite record driver. */
+export type SqliteAdapterOptionsType = SqliteDriverOptionsType;
- /** Returns the first row and normalizes synchronous wrappers to a Promise. */
- async get(...params: Db0PrimitiveType[]): Promise {
- return await this.#statement.get(...params);
- }
-
- /** Executes a mutation and reports success after the wrapper returns normally. */
- async run(...params: Db0PrimitiveType[]): Promise<{ readonly success: boolean }> {
- await this.#statement.run(...params);
- return { success: true };
- }
-}
-
-/** db0-compatible SQLite database projection used only by the shared SQL record layer. */
-class SqliteDatabase {
- /** db0 dialect identity consumed by {@link createDb0Adapter}. */
- readonly dialect = "sqlite" as const;
- /** Caller-owned SQLite database. */
- readonly #database: SqliteDatabaseType;
- /** Whether the db0 disposal path also closes the SQLite database. */
- readonly #disposeDatabase: boolean;
-
- /** Retains the connected database and explicit ownership policy. */
- constructor(database: SqliteDatabaseType, disposeDatabase: boolean) {
- this.#database = database;
- this.#disposeDatabase = disposeDatabase;
- }
-
- /** Prepares one statement and adapts sync/async result methods. */
- prepare(sql: string): Db0StatementType {
- return new SqliteStatement(this.#database.prepare(sql));
- }
-
- /** Closes the connected SQLite database only when ownership was transferred. */
- async dispose(): Promise {
- if (this.#disposeDatabase) await this.#database.close?.();
- }
-}
+/** Minimal connected SQLite database and statement contracts consumed by the driver. */
+export type { SqliteDatabaseType, SqliteStatementType };
/**
- * Creates the OPFS adapter directly from a connected SQLite database.
+ * Creates the OPFS primitive translation over a connected SQLite record driver.
*
- * The SQL record implementation is intentionally shared with the db0 SQLite
- * branch instead of maintaining a second table format and upsert algorithm.
- * The caller still owns journal mode, transactions, file placement, extensions,
- * and database lifecycle unless disposal is explicitly transferred.
+ * This stores logical filesystem records in SQL rows. It does not make SQLite
+ * use `FileSystemType` as its database-file VFS. See the database architecture
+ * guide for that opposite direction.
*/
-export async function createSqliteAdapter(database: SqliteDatabaseType, options: SqliteAdapterOptionsType = {}): Promise {
- return await createDb0Adapter(new SqliteDatabase(database, options.disposeDatabase ?? false), {
- ...(options.table === undefined ? {} : { table: options.table }),
- ...(options.initialize === undefined ? {} : { initialize: options.initialize }),
- disposeDatabase: true,
+export async function createSqliteAdapter(
+ database: SqliteDatabaseType,
+ options: SqliteAdapterOptionsType = {},
+): Promise {
+ return createRecordAdapter(await createSqliteDriver(database, options), {
+ name: "sqlite",
+ disposeDriver: true,
});
}
diff --git a/src/azure.ts b/src/azure.ts
index 0f144af..3ef5e83 100644
--- a/src/azure.ts
+++ b/src/azure.ts
@@ -3,6 +3,7 @@ import { decodeBase64, encodeBase64 } from "@std/encoding/base64";
import { z } from "zod";
import type {
+ ObjectBackendType,
ObjectCopyOptionsType,
ObjectEntryType,
ObjectGetOptionsType,
@@ -10,26 +11,18 @@ import type {
ObjectListType,
ObjectPutOptionsType,
ObjectStatType,
- ObjectStoreType,
-} from "./adapter/object.ts";
+} from "./driver/object.ts";
import { split } from "./chunk.ts";
import {
RequestMetrics,
- RequestTransportError,
type RequestMetricsType,
type RequestPolicyType,
+ RequestTransportError,
sendRequest,
} from "./request.ts";
-import { MetricsModeSchema, type AdapterLimitsType, type MetricsModeType } from "./schema.ts";
+import { type AdapterLimitsType, MetricsModeSchema, type MetricsModeType } from "./schema.ts";
import { toByteStream } from "./stream.ts";
-import {
- createXmlElement,
- createXmlText,
- getXmlElements,
- getXmlValue,
- parseXmlRoot,
- stringifyXml,
-} from "./xml.ts";
+import { createXmlElement, createXmlText, getXmlElements, getXmlValue, parseXmlRoot, stringifyXml } from "./xml.ts";
/** Current fully deployed Azure Storage REST service version used by default. */
export const AZURE_STORAGE_VERSION = "2026-04-06";
@@ -50,44 +43,46 @@ export type AzureStorageVersionType = z.output
*/
export type AzureCredentialType =
| Readonly<{
- /** Selects SAS query authorization. */
- readonly kind: "sas";
- /** SAS token with or without a leading `?`; the client merges it into every request URL. */
- readonly token: string;
- }>
+ /** Selects SAS query authorization. */
+ readonly kind: "sas";
+ /** SAS token with or without a leading `?`; the client merges it into every request URL. */
+ readonly token: string;
+ }>
| Readonly<{
- /** Selects Microsoft Entra bearer authorization. */
- readonly kind: "bearer";
- /** Static token or refresh function evaluated immediately before each request. */
- readonly token: string | (() => string | Promise);
- }>
+ /** Selects Microsoft Entra bearer authorization. */
+ readonly kind: "bearer";
+ /** Static token or refresh function evaluated immediately before each request. */
+ readonly token: string | (() => string | Promise);
+ }>
| Readonly<{
- /** Selects Azure Storage Shared Key authorization. */
- readonly kind: "shared-key";
- /** Storage account name used in the Authorization header and canonical resource. */
- readonly account: string;
- /** Base64-encoded storage account key used only for HMAC-SHA256 signing. */
- readonly key: string;
- }>
+ /** Selects Azure Storage Shared Key authorization. */
+ readonly kind: "shared-key";
+ /** Storage account name used in the Authorization header and canonical resource. */
+ readonly account: string;
+ /** Base64-encoded storage account key used only for HMAC-SHA256 signing. */
+ readonly key: string;
+ }>
| Readonly<{
- /** Selects caller-owned authorization headers. */
- readonly kind: "headers";
- /**
- * Returns headers after the request URL and ordinary headers are known.
- *
- * This escape hatch supports provider-specific authorization without
- * letting the client guess whether those credentials also authorize a
- * server-side copy source.
- */
- readonly get: (request: Readonly<{
+ /** Selects caller-owned authorization headers. */
+ readonly kind: "headers";
+ /**
+ * Returns headers after the request URL and ordinary headers are known.
+ *
+ * This escape hatch supports provider-specific authorization without
+ * letting the client guess whether those credentials also authorize a
+ * server-side copy source.
+ */
+ readonly get: (
+ request: Readonly<{
/** HTTP method that will be sent. */
readonly method: string;
/** Final request URL including service and SAS query parameters. */
readonly url: URL;
/** Headers assembled before custom authorization is applied. */
readonly headers: Headers;
- }>) => HeadersInit | Promise;
- }>;
+ }>,
+ ) => HeadersInit | Promise;
+ }>;
/** Options used to create one Azure Blob Storage client. */
export interface AzureClientOptionsType {
@@ -111,6 +106,10 @@ export interface AzureClientOptionsType {
readonly headers?: HeadersInit;
/** Retry/backoff and optional per-attempt timeout policy. */
readonly request?: RequestPolicyType;
+ /** Enables staged Put Block uploads for streams and blobs larger than Put Blob. Defaults to true. */
+ readonly blockUpload?: boolean;
+ /** Enables Azure server-side copy routes. Defaults to true. */
+ readonly serverCopy?: boolean;
/** Direct-client HTTP instrumentation. Defaults to `basic`; `none` removes counter updates. */
readonly metrics?: MetricsModeType;
}
@@ -134,7 +133,9 @@ export interface AzureRequestOptionsType {
}
/** Azure Blob client used directly or as an object-store backend. */
-export interface AzureClientType extends ObjectStoreType {
+export interface AzureClientType extends ObjectBackendType {
+ /** Resolved client optimization switches used by driver inspection. */
+ readonly optimizations: Readonly<{ blockUpload: boolean; serverCopy: boolean }>;
/** Returns detached direct HTTP request metrics. */
getMetrics(): RequestMetricsType;
/** Sends one Blob REST request with the configured authorization strategy. */
@@ -421,7 +422,9 @@ async function assertResponse(response: Response, operation: string): Promise;
/** Portable Azure limits exposed to the filesystem planner. */
readonly limits: AdapterLimitsType;
@@ -545,12 +550,17 @@ class AzureClient implements AzureClientType {
this.#version = AzureStorageVersionSchema.parse(options.version ?? AZURE_STORAGE_VERSION);
this.#fetch = options.fetch ?? fetch;
this.#now = options.now ?? (() => new Date());
- this.#blockSize = options.blockSize ?? Math.min(DEFAULT_BLOCK_SIZE, getBlockLimit(this.#version));
+ const blockLimit = getBlockLimit(this.#version);
+ this.#blockSize = options.blockSize ?? Math.min(DEFAULT_BLOCK_SIZE, blockLimit);
this.#concurrency = options.concurrency ?? 4;
this.#headers = new Headers(options.headers);
this.#requestPolicy = options.request;
this.#metricsMode = MetricsModeSchema.parse(options.metrics ?? "basic");
this.#metrics = this.#metricsMode === "none" ? undefined : new RequestMetrics(this.#metricsMode === "timing");
+ this.optimizations = Object.freeze({
+ blockUpload: options.blockUpload ?? true,
+ serverCopy: options.serverCopy ?? true,
+ });
if (this.#container.length === 0) throw new TypeError("Azure container cannot be empty.");
if (this.#credential.kind === "shared-key" && !atLeast(this.#version, SHARED_KEY_VERSION)) {
@@ -558,9 +568,10 @@ class AzureClient implements AzureClientType {
`Azure Shared Key support starts at Blob service version ${SHARED_KEY_VERSION}; received ${this.#version}.`,
);
}
- const blockLimit = getBlockLimit(this.#version);
if (!Number.isSafeInteger(this.#blockSize) || this.#blockSize < 1 || this.#blockSize > blockLimit) {
- throw new RangeError(`Azure blockSize must be between 1 and ${blockLimit} bytes for service version ${this.#version}.`);
+ throw new RangeError(
+ `Azure blockSize must be between 1 and ${blockLimit} bytes for service version ${this.#version}.`,
+ );
}
if (!Number.isSafeInteger(this.#concurrency) || this.#concurrency < 1) {
throw new RangeError("Azure concurrency must be a positive integer.");
@@ -574,9 +585,12 @@ class AzureClient implements AzureClientType {
this.capabilities = {
rangeRead: true,
streamRead: true,
- streamWrite: true,
- copy,
+ streamWrite: this.optimizations.blockUpload,
+ copy: this.optimizations.serverCopy && copy,
conditionalWrite: true,
+ multipart: this.optimizations.blockUpload,
+ metadata: true,
+ versions: false,
};
this.limits = {
maxFileBytes: getBlockLimit(this.#version) * AZURE_LIMITS.maxCommittedBlocks,
@@ -591,7 +605,9 @@ class AzureClient implements AzureClientType {
#getAddress(key?: string): URL {
const url = new URL(this.#endpoint);
const root = this.#endpoint.pathname.replace(/\/$/, "");
- url.pathname = `${root}/${encodeURIComponent(this.#container)}${key === undefined || key.length === 0 ? "" : `/${encodePath(key)}`}`;
+ url.pathname = `${root}/${encodeURIComponent(this.#container)}${
+ key === undefined || key.length === 0 ? "" : `/${encodePath(key)}`
+ }`;
if (this.#credential.kind === "sas") {
const params = new URLSearchParams(this.#credential.token.replace(/^\?/, ""));
for (const [name, value] of params) url.searchParams.append(name, value);
@@ -657,11 +673,19 @@ class AzureClient implements AzureClientType {
headers.set("x-ms-date", this.#now().toUTCString());
const bodyLength = getBodyLength(options.body);
- if (bodyLength !== undefined && !headers.has("content-length") && options.method !== "GET" && options.method !== "HEAD") {
+ if (
+ bodyLength !== undefined && !headers.has("content-length") && options.method !== "GET" &&
+ options.method !== "HEAD"
+ ) {
headers.set("content-length", String(bodyLength));
}
- if (this.#credential.kind === "shared-key" && options.body instanceof ReadableStream && !headers.has("content-length")) {
- throw new TypeError("Azure Shared Key requests with a streamed low-level body require an explicit content-length header.");
+ if (
+ this.#credential.kind === "shared-key" && options.body instanceof ReadableStream &&
+ !headers.has("content-length")
+ ) {
+ throw new TypeError(
+ "Azure Shared Key requests with a streamed low-level body require an explicit content-length header.",
+ );
}
await this.#authorize(options.method, url, headers);
@@ -689,7 +713,11 @@ class AzureClient implements AzureClientType {
/** Returns blob properties or null for an absent blob. */
async head(key: string, options?: { readonly signal?: AbortSignal }): Promise {
- const response = await this.request({ method: "HEAD", key, ...(options?.signal === undefined ? {} : { signal: options.signal }) });
+ const response = await this.request({
+ method: "HEAD",
+ key,
+ ...(options?.signal === undefined ? {} : { signal: options.signal }),
+ });
if (response.status === 404) return null;
await assertResponse(response, `Get Blob Properties ${key}`);
return getStat(response.headers);
@@ -704,7 +732,12 @@ class AzureClient implements AzureClientType {
headers.set("x-ms-range", `bytes=${start}-${end}`);
}
const response = await assertResponse(
- await this.request({ method: "GET", key, headers, ...(options.signal === undefined ? {} : { signal: options.signal }) }),
+ await this.request({
+ method: "GET",
+ key,
+ headers,
+ ...(options.signal === undefined ? {} : { signal: options.signal }),
+ }),
`Get Blob ${key}`,
);
return response.body ?? getByteStream(new Uint8Array());
@@ -716,12 +749,16 @@ class AzureClient implements AzureClientType {
const blockLimit = getBlockLimit(this.#version);
const maxBlobBytes = blockLimit * AZURE_LIMITS.maxCommittedBlocks;
if (!Number.isSafeInteger(expectedSize) || expectedSize < 0 || expectedSize > maxBlobBytes) {
- throw new RangeError(`Azure block blob size must be between 0 and ${maxBlobBytes} bytes for service version ${this.#version}.`);
+ throw new RangeError(
+ `Azure block blob size must be between 0 and ${maxBlobBytes} bytes for service version ${this.#version}.`,
+ );
}
const required = Math.ceil(expectedSize / AZURE_LIMITS.maxCommittedBlocks);
const size = Math.max(this.#blockSize, required);
if (size > blockLimit) {
- throw new RangeError(`Azure block blob requires blocks larger than ${blockLimit} bytes for service version ${this.#version}.`);
+ throw new RangeError(
+ `Azure block blob requires blocks larger than ${blockLimit} bytes for service version ${this.#version}.`,
+ );
}
return size;
}
@@ -729,7 +766,9 @@ class AzureClient implements AzureClientType {
/** Builds destination metadata and HTTP preconditions for Put/commit operations. */
#getWriteHeaders(options: ObjectPutOptionsType | ObjectCopyOptionsType): Headers {
const headers = new Headers();
- if ("mediaType" in options && options.mediaType !== undefined) headers.set("x-ms-blob-content-type", options.mediaType);
+ if ("mediaType" in options && options.mediaType !== undefined) {
+ headers.set("x-ms-blob-content-type", options.mediaType);
+ }
if (options.ifMatch !== undefined) headers.set("if-match", options.ifMatch);
if (options.ifNoneMatch !== undefined) headers.set("if-none-match", options.ifNoneMatch);
if ("metadata" in options) {
@@ -746,7 +785,7 @@ class AzureClient implements AzureClientType {
key,
query: { comp: "block", blockid: block.id },
headers: { "content-type": "application/octet-stream" },
- body: Uint8Array.from(block.bytes),
+ body: block.bytes as Uint8Array,
...(signal === undefined ? {} : { signal }),
}),
`Put Block ${key}#${block.number}`,
@@ -755,7 +794,12 @@ class AzureClient implements AzureClientType {
}
/** Commits one ordered block list and applies destination metadata/preconditions atomically. */
- async #commitBlocks(key: string, ids: readonly string[], options: ObjectPutOptionsType, size: number): Promise {
+ async #commitBlocks(
+ key: string,
+ ids: readonly string[],
+ options: ObjectPutOptionsType,
+ size: number,
+ ): Promise {
const headers = this.#getWriteHeaders(options);
headers.set("content-type", "application/xml");
await assertResponse(
@@ -773,9 +817,17 @@ class AzureClient implements AzureClientType {
}
/** Uploads a stream as uncommitted blocks and publishes it only after all blocks succeed. */
- async #putBlocks(key: string, body: ReadableStream, options: ObjectPutOptionsType): Promise {
+ async #putBlocks(
+ key: string,
+ body: ReadableStream,
+ options: ObjectPutOptionsType,
+ ): Promise {
const blockSize = this.#getBlockSize(options.size);
- const blocks = pooledMap(this.#concurrency, getBlocks(body, blockSize), (block) => this.#putBlock(key, block, options.signal));
+ const blocks = pooledMap(
+ this.#concurrency,
+ getBlocks(body, blockSize),
+ (block) => this.#putBlock(key, block, options.signal),
+ );
const ids: string[] = [];
let size = 0;
for await (const block of blocks) {
@@ -792,6 +844,12 @@ class AzureClient implements AzureClientType {
/** Uses one Put Blob request when the selected service version permits the byte length. */
async #putBytes(key: string, body: Uint8Array, options: ObjectPutOptionsType): Promise {
if (body.byteLength > getPutBlobLimit(this.#version)) {
+ if (!this.optimizations.blockUpload) {
+ throw new RangeError(
+ `Blob is ${body.byteLength} bytes, above the single Put Blob limit ${getPutBlobLimit(this.#version)} ` +
+ "while blockUpload is disabled.",
+ );
+ }
return await this.#putBlocks(key, getByteStream(body), { ...options, size: body.byteLength });
}
const headers = this.#getWriteHeaders(options);
@@ -801,8 +859,8 @@ class AzureClient implements AzureClientType {
method: "PUT",
key,
headers,
- body: Uint8Array.from(body),
- ...(options.signal === undefined ? {} : { signal: options.signal })
+ body: body as Uint8Array,
+ ...(options.signal === undefined ? {} : { signal: options.signal }),
}),
`Put Blob ${key}`,
);
@@ -810,15 +868,29 @@ class AzureClient implements AzureClientType {
}
/** Replaces one blob, using block upload when a single Put Blob is insufficient or the body streams. */
- async put(key: string, body: Uint8Array | ReadableStream, options: ObjectPutOptionsType = {}): Promise {
- return body instanceof Uint8Array
- ? await this.#putBytes(key, body, options)
- : await this.#putBlocks(key, body, options);
+ async put(
+ key: string,
+ body: Uint8Array | ReadableStream,
+ options: ObjectPutOptionsType = {},
+ ): Promise {
+ if (body instanceof Uint8Array) return await this.#putBytes(key, body, options);
+ if (!this.optimizations.blockUpload) {
+ await body.cancel().catch(() => undefined);
+ throw new TypeError(
+ "Azure streamed writes require blockUpload; enable it or let the filesystem adapter buffer " +
+ "a bounded stream before calling put().",
+ );
+ }
+ return await this.#putBlocks(key, body, options);
}
/** Removes one exact blob. Missing blobs are already in the requested state. */
async delete(key: string, options?: { readonly signal?: AbortSignal }): Promise {
- const response = await this.request({ method: "DELETE", key, ...(options?.signal === undefined ? {} : { signal: options.signal }) });
+ const response = await this.request({
+ method: "DELETE",
+ key,
+ ...(options?.signal === undefined ? {} : { signal: options.signal }),
+ });
if (response.status === 404) return;
await assertResponse(response, `Delete Blob ${key}`);
}
@@ -832,7 +904,9 @@ class AzureClient implements AzureClientType {
key: getXmlValue(blob, "Name") ?? "",
size: Number.isSafeInteger(size) && size >= 0 ? size : 0,
...(modified === undefined ? {} : { lastModified: new Date(modified).getTime() }),
- ...(getXmlValue(properties, "Content-Type") === undefined ? {} : { mediaType: getXmlValue(properties, "Content-Type")! }),
+ ...(getXmlValue(properties, "Content-Type") === undefined
+ ? {}
+ : { mediaType: getXmlValue(properties, "Content-Type")! }),
...(getXmlValue(properties, "Etag") === undefined ? {} : { etag: getXmlValue(properties, "Etag")! }),
};
}
@@ -868,8 +942,12 @@ class AzureClient implements AzureClientType {
const headers = new Headers({ "x-ms-copy-source": this.#getAddress(source).toString() });
if (options.sourceIfMatch !== undefined) headers.set("x-ms-source-if-match", options.sourceIfMatch);
if (options.sourceIfNoneMatch !== undefined) headers.set("x-ms-source-if-none-match", options.sourceIfNoneMatch);
- if (options.sourceIfModifiedSince !== undefined) headers.set("x-ms-source-if-modified-since", options.sourceIfModifiedSince.toUTCString());
- if (options.sourceIfUnmodifiedSince !== undefined) headers.set("x-ms-source-if-unmodified-since", options.sourceIfUnmodifiedSince.toUTCString());
+ if (options.sourceIfModifiedSince !== undefined) {
+ headers.set("x-ms-source-if-modified-since", options.sourceIfModifiedSince.toUTCString());
+ }
+ if (options.sourceIfUnmodifiedSince !== undefined) {
+ headers.set("x-ms-source-if-unmodified-since", options.sourceIfUnmodifiedSince.toUTCString());
+ }
if (this.#credential.kind === "bearer") {
if (!atLeast(this.#version, SOURCE_BEARER_VERSION)) {
throw new AzureError(
@@ -883,7 +961,12 @@ class AzureClient implements AzureClientType {
}
/** Copies one source range into one uncommitted destination block. */
- async #copyBlock(source: string, destination: string, block: AzureCopyBlockType, options: ObjectCopyOptionsType): Promise {
+ async #copyBlock(
+ source: string,
+ destination: string,
+ block: AzureCopyBlockType,
+ options: ObjectCopyOptionsType,
+ ): Promise {
const headers = await this.#getCopyHeaders(source, options);
headers.set("x-ms-source-range", `bytes=${block.start}-${block.end}`);
headers.set("content-length", "0");
@@ -901,7 +984,12 @@ class AzureClient implements AzureClientType {
}
/** Commits copied ranges while preserving source media type/metadata and destination preconditions. */
- async #commitCopy(destination: string, blocks: readonly AzureCopyBlockType[], source: ObjectStatType, options: ObjectCopyOptionsType): Promise {
+ async #commitCopy(
+ destination: string,
+ blocks: readonly AzureCopyBlockType[],
+ source: ObjectStatType,
+ options: ObjectCopyOptionsType,
+ ): Promise {
const headers = this.#getWriteHeaders(options);
headers.set("content-type", "application/xml");
if (source.mediaType !== undefined) headers.set("x-ms-blob-content-type", source.mediaType);
@@ -921,19 +1009,31 @@ class AzureClient implements AzureClientType {
}
/** Copies a source up to 256 MiB through synchronous Copy Blob From URL. */
- async #copyBlob(source: string, destination: string, sourceStat: ObjectStatType, options: ObjectCopyOptionsType): Promise {
+ async #copyBlob(
+ source: string,
+ destination: string,
+ sourceStat: ObjectStatType,
+ options: ObjectCopyOptionsType,
+ ): Promise {
const headers = await this.#getCopyHeaders(source, options);
const destinationHeaders = this.#getWriteHeaders(options);
destinationHeaders.forEach((value, name) => headers.set(name, value));
headers.set("x-ms-requires-sync", "true");
headers.set("content-length", "0");
const response = await assertResponse(
- await this.request({ method: "PUT", key: destination, headers, ...(options.signal === undefined ? {} : { signal: options.signal }) }),
+ await this.request({
+ method: "PUT",
+ key: destination,
+ headers,
+ ...(options.signal === undefined ? {} : { signal: options.signal }),
+ }),
`Copy Blob From URL ${source} -> ${destination}`,
);
if (response.headers.get("x-ms-copy-status") !== "success") {
throw new AzureError("Copy Blob From URL did not report synchronous success.", response, {
- ...(response.headers.get("x-ms-request-id") === null ? {} : { requestId: response.headers.get("x-ms-request-id")! }),
+ ...(response.headers.get("x-ms-request-id") === null
+ ? {}
+ : { requestId: response.headers.get("x-ms-request-id")! }),
});
}
return (await this.head(destination, options)) ?? { size: sourceStat.size };
@@ -950,6 +1050,9 @@ class AzureClient implements AzureClientType {
* client.
*/
async copy(source: string, destination: string, options: ObjectCopyOptionsType = {}): Promise {
+ if (!this.optimizations.serverCopy) {
+ throw new TypeError("Azure serverCopy optimization is disabled for this client.");
+ }
if (!atLeast(this.#version, URL_COPY_VERSION)) {
throw new AzureError(
`Azure service version ${this.#version} does not support URL-based server-side copy.`,
@@ -975,11 +1078,15 @@ class AzureClient implements AzureClientType {
const requiredBlockSize = Math.ceil(sourceStat.size / AZURE_LIMITS.maxCommittedBlocks);
const copyBlockSize = Math.max(this.#blockSize, requiredBlockSize);
if (copyBlockSize > copyLimit) {
- throw new RangeError(`Azure server-side copy requires blocks larger than ${copyLimit} bytes for service version ${this.#version}.`);
+ throw new RangeError(
+ `Azure server-side copy requires blocks larger than ${copyLimit} bytes for service version ${this.#version}.`,
+ );
}
const blockCount = Math.ceil(sourceStat.size / copyBlockSize);
if (blockCount > AZURE_LIMITS.maxCommittedBlocks) {
- throw new RangeError(`Azure server-side copy needs ${blockCount} blocks; the service permits ${AZURE_LIMITS.maxCommittedBlocks}.`);
+ throw new RangeError(
+ `Azure server-side copy needs ${blockCount} blocks; the service permits ${AZURE_LIMITS.maxCommittedBlocks}.`,
+ );
}
const copied = pooledMap(
diff --git a/src/driver/azure.ts b/src/driver/azure.ts
new file mode 100644
index 0000000..bb84bd7
--- /dev/null
+++ b/src/driver/azure.ts
@@ -0,0 +1,75 @@
+import { type AzureClientOptionsType, type AzureClientType, createAzureClient } from "../azure.ts";
+import type { DriverOwnershipType } from "../schema.ts";
+import { defineObjectDriver, type ObjectDriverType } from "./object.ts";
+
+/** Options for the configured Azure Blob object driver. */
+export interface AzureDriverOptionsType extends AzureClientOptionsType {}
+
+/** Adds backend-driver metadata to one configured Azure Blob protocol client. */
+function createDriver(client: AzureClientType, ownership: DriverOwnershipType): ObjectDriverType {
+ const limits = client.limits ?? {};
+ return defineObjectDriver(client, {
+ name: client.name,
+ ownership,
+ requirements: [{ code: "azure-blob-endpoint", state: "available" }],
+ limits: [
+ ...(limits.maxFileBytes === undefined ? [] : [{
+ code: "file-bytes",
+ kind: "hard" as const,
+ source: "provider" as const,
+ unit: "bytes" as const,
+ value: limits.maxFileBytes,
+ }]),
+ ...(limits.maxPartBytes === undefined ? [] : [{
+ code: "block-max-bytes",
+ kind: "hard" as const,
+ source: "provider" as const,
+ unit: "bytes" as const,
+ value: limits.maxPartBytes,
+ }]),
+ ...(limits.maxParts === undefined ? [] : [{
+ code: "blocks",
+ kind: "hard" as const,
+ source: "provider" as const,
+ unit: "count" as const,
+ value: limits.maxParts,
+ }]),
+ ],
+ getMetrics: () => {
+ const metrics = client.getMetrics();
+ return {
+ requests: metrics.requests,
+ retries: metrics.retries,
+ failures: metrics.failures,
+ responses: metrics.responses,
+ durationMs: metrics.durationMs,
+ };
+ },
+ optimizations: [
+ {
+ code: "block-upload",
+ enabled: client.optimizations.blockUpload,
+ changesBehavior: true,
+ disableable: true,
+ detail: "Uses staged Azure blocks for large or streamed blobs instead of one Put Blob request.",
+ },
+ {
+ code: "server-copy",
+ enabled: client.optimizations.serverCopy,
+ changesBehavior: true,
+ disableable: true,
+ detail: "Keeps supported copy work inside Azure instead of routing bytes through JavaScript.",
+ },
+ ],
+ });
+}
+
+/** Creates an Azure Blob driver from direct protocol-client options. */
+export function createAzureDriver(options: AzureDriverOptionsType): ObjectDriverType {
+ return createDriver(createAzureClient(options), "owned");
+}
+
+/** Attaches driver metadata to an already configured Azure Blob client. */
+export function createAzureDriverFromClient(client: AzureClientType): ObjectDriverType {
+ return createDriver(client, "borrowed");
+}
diff --git a/src/driver/bun.ts b/src/driver/bun.ts
new file mode 100644
index 0000000..1f3ec54
--- /dev/null
+++ b/src/driver/bun.ts
@@ -0,0 +1,209 @@
+import type { FileBackendType, FileDriverType } from "./file.ts";
+import { defineFileDriver } from "./file.ts";
+import type {
+ FileDriverCopyOptionsType,
+ FileDriverDirectoryEntryType,
+ FileDriverMoveOptionsType,
+ FileDriverReadOptionsType,
+ FileDriverSignalOptionsType,
+ FileDriverStatType,
+ FileDriverSyncFileType,
+ FileDriverWritableFileType,
+ FileDriverWriteOptionsType,
+} from "./file.ts";
+import { createLocalPath } from "./local.ts";
+import { createNodeDriver, type NodeDriverOptionsType } from "./node.ts";
+import { throwIfAborted } from "../error.ts";
+import type { PathType } from "../path.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 required by the fast read and replace-write paths. */
+interface BunRuntimeType {
+ /** Opens a lazy `BunFile` for one host path. */
+ file(path: string): BunFileType;
+ /** Replaces one host file with bytes or a stream-compatible body. */
+ write(path: string, data: Blob | Response | ArrayBufferView | ArrayBuffer | string): Promise;
+}
+
+/** Options for exposing one host directory through Bun. */
+export type BunDriverOptionsType = NodeDriverOptionsType;
+
+/**
+ * Resolves Bun only when the driver is created.
+ *
+ * Keeping this lookup out of module evaluation lets Node and Deno inspect or
+ * type-check the explicit Bun subpath without requiring the `Bun` 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 driver requires the Bun runtime.");
+ }
+ return runtime;
+}
+
+/**
+ * Bun implementation of the portable file-driver contract.
+ *
+ * Bun owns the lazy read and complete replacement paths. Operations that need
+ * directory traversal, positioned writes, rename, or synchronous descriptors
+ * delegate to Bun's Node-compatible filesystem layer through `NodeAdapter`.
+ * The two paths share the same `@std/path` host-root mapper, so neither can
+ * address a host path outside the configured root.
+ */
+class BunBackend implements FileBackendType {
+ /** Stable driver identity used in diagnostics. */
+ readonly name = "bun";
+ /** Native capabilities inherited from Bun's Node-compatible filesystem. */
+ readonly capabilities;
+ /** Bun runtime used by lazy reads and replacement writes. */
+ readonly #bun: BunRuntimeType;
+ /** Maps canonical virtual paths below the configured host root. */
+ readonly #hostPath: (path: string) => string;
+ /** Node-compatible driver that owns operations Bun does not improve. */
+ readonly #node: FileDriverType;
+
+ /** Resolves Bun and creates the shared Node-compatible host driver. */
+ constructor(options: BunDriverOptionsType) {
+ this.#bun = getBun();
+ this.#hostPath = createLocalPath(options.root);
+ this.#node = createNodeDriver(options);
+ this.capabilities = this.#node.capabilities;
+ }
+
+ /** Delegates metadata lookup to the Node-compatible filesystem surface. */
+ stat(path: PathType, options: FileDriverSignalOptionsType = {}): Promise {
+ return this.#node.stat(path, options);
+ }
+
+ /** Reads only the requested slice through Bun's lazy `BunFile` object. */
+ async readFile(path: PathType, options: FileDriverReadOptionsType = {}): Promise {
+ throwIfAborted(options.signal, "read", path);
+ const file = this.#bun.file(this.#hostPath(path));
+ const start = options.at ?? 0;
+ const end = options.length === undefined ? file.size : Math.min(file.size, start + options.length);
+ return new Uint8Array(await file.slice(start, end).arrayBuffer());
+ }
+
+ /** Returns Bun's native Blob stream for the requested byte range. */
+ async openReadStream(path: PathType, options: FileDriverReadOptionsType = {}): Promise> {
+ throwIfAborted(options.signal, "read", path);
+ const file = this.#bun.file(this.#hostPath(path));
+ const start = options.at ?? 0;
+ const end = options.length === undefined ? file.size : Math.min(file.size, start + options.length);
+ return file.slice(start, end).stream() as ReadableStream;
+ }
+
+ /** Uses `Bun.write()` for replacement and delegates append/update semantics. */
+ async writeFile(path: PathType, data: Uint8Array, options: FileDriverWriteOptionsType): Promise {
+ if (options.mode !== "replace") {
+ await this.#node.writeFile(path, data, options);
+ return;
+ }
+
+ throwIfAborted(options.signal, "write", path);
+ await this.#bun.write(this.#hostPath(path), data);
+ }
+
+ /** Streams replacement writes through `Bun.write()` without facade buffering. */
+ async writeStream(
+ path: PathType,
+ source: ReadableStream,
+ options: FileDriverWriteOptionsType,
+ ): Promise {
+ if (options.mode !== "replace") {
+ if (this.#node.writeStream === undefined) {
+ throw new TypeError("Bun Node compatibility layer does not expose streaming writes.");
+ }
+ await this.#node.writeStream(path, source, options);
+ return;
+ }
+
+ throwIfAborted(options.signal, "write", path);
+ const body = withAbortSignal(source, options.signal, path, "write");
+ await this.#bun.write(this.#hostPath(path), new Response(body));
+ }
+
+ /** Delegates direct-child iteration to Bun's Node-compatible filesystem surface. */
+ readDir(
+ path: PathType,
+ options: FileDriverSignalOptionsType = {},
+ ): AsyncIterableIterator {
+ return this.#node.readDir(path, options);
+ }
+
+ /** Creates one host directory after facade parent resolution. */
+ createDir(path: PathType, options: FileDriverSignalOptionsType = {}): Promise {
+ return this.#node.createDir(path, options);
+ }
+
+ /** Removes one host file or empty directory. */
+ remove(path: PathType, options: FileDriverSignalOptionsType = {}): Promise {
+ return this.#node.remove(path, options);
+ }
+
+ /** Uses native host copy without routing bytes through JavaScript. */
+ copy(source: PathType, destination: PathType, options: FileDriverCopyOptionsType): Promise {
+ if (this.#node.copy === undefined) throw new TypeError("Bun host driver does not expose native copy.");
+ return this.#node.copy(source, destination, options);
+ }
+
+ /** Uses native host rename for move semantics. */
+ move(source: PathType, destination: PathType, options: FileDriverMoveOptionsType): Promise {
+ if (this.#node.move === undefined) throw new TypeError("Bun host driver does not expose native move.");
+ return this.#node.move(source, destination, options);
+ }
+
+ /** Opens one long-lived asynchronous positional host file. */
+ openWritableFile(path: PathType): Promise {
+ if (this.#node.openWritableFile === undefined) {
+ throw new TypeError("Bun host driver does not expose positional writes.");
+ }
+ return this.#node.openWritableFile(path);
+ }
+
+ /** Opens one synchronous random-access host file. */
+ openSyncFile(path: PathType): Promise {
+ if (this.#node.openSyncFile === undefined) {
+ throw new TypeError("Bun host driver does not expose synchronous access.");
+ }
+ return this.#node.openSyncFile(path);
+ }
+
+ /** Releases resources owned by the delegated host driver, when any exist. */
+ async dispose(): Promise {
+ await this.#node.dispose?.();
+ }
+}
+
+/**
+ * Creates a file driver optimized for Bun.
+ *
+ * The driver uses `Bun.file()` for lazy reads and `Bun.write()` for complete
+ * replacement writes. It uses Bun's Node-compatible filesystem APIs for
+ * operations that need stronger file semantics. Importing this module does not
+ * require Bun; driver creation does.
+ *
+ * @example Persist below one Bun host directory.
+ * ```ts
+ * const driver = createBunDriver({ root: "./data" });
+ * const adapter = createFileAdapter(driver);
+ * const fs = createFileSystem(adapter);
+ * await fs.writeFile("/result.bin", new Uint8Array([1, 2, 3]));
+ * ```
+ */
+export function createBunDriver(options: BunDriverOptionsType): FileDriverType {
+ return defineFileDriver(new BunBackend(options), {
+ name: "bun",
+ ownership: "none",
+ requirements: [{ code: "bun-runtime", state: "available" }],
+ limits: [],
+ optimizations: [
+ { code: "bun-file-read", enabled: true, changesBehavior: false, disableable: true },
+ { code: "bun-write-replace", enabled: true, changesBehavior: false, disableable: true },
+ ],
+ });
+}
diff --git a/src/driver/cache.ts b/src/driver/cache.ts
new file mode 100644
index 0000000..05ab509
--- /dev/null
+++ b/src/driver/cache.ts
@@ -0,0 +1,102 @@
+import { defineRecordDriver, type RecordBackendType, type RecordDriverType } from "./record.ts";
+import { type PathType, splitPath } from "../path.ts";
+import { PathSchema, RecordSchema } from "../schema.ts";
+
+/** Options for a Cache API-backed record driver. */
+export interface CacheDriverOptionsType {
+ /** Private URL namespace used as Cache keys. */
+ readonly prefix?: string;
+ /** Prevents mutations. */
+ readonly readOnly?: boolean;
+}
+
+/** Encodes one path into a synthetic HTTPS request URL that never needs network access. */
+function request(prefix: string, path: PathType): Request {
+ return new Request(`https://opfs.invalid/${encodeURIComponent(prefix)}/${encodeURIComponent(path)}`);
+}
+
+/** Decodes an driver-owned Cache request URL. */
+function getPath(prefix: string, value: Request): PathType | null {
+ const url = new URL(value.url);
+ const parts = url.pathname.slice(1).split("/");
+ if (parts.length !== 2) return null;
+ try {
+ if (decodeURIComponent(parts[0] ?? "") !== prefix) return null;
+ return PathSchema.parse(decodeURIComponent(parts[1] ?? ""));
+ } catch {
+ return null;
+ }
+}
+
+/**
+ * Record-store projection over one injected Cache API `Cache`.
+ *
+ * Records are JSON Responses under synthetic HTTPS request URLs. No request is
+ * sent to the network. Quota, eviction, persistence, and lifetime remain
+ * browser Cache Storage policy and are not upgraded into filesystem durability
+ * guarantees by this class.
+ */
+class CacheBackend implements RecordBackendType {
+ /** Cache borrowed from the caller. */
+ readonly #cache: Cache;
+ /** Private synthetic URL namespace for this filesystem. */
+ readonly #prefix: string;
+
+ /** Binds one cache and one stable synthetic namespace. */
+ constructor(cache: Cache, options: CacheDriverOptionsType) {
+ this.#cache = cache;
+ this.#prefix = options.prefix ?? "opfs";
+ }
+
+ /** Reads and validates one cached JSON record. */
+ async get(path: PathType) {
+ const response = await this.#cache.match(request(this.#prefix, path));
+ return response === undefined ? null : RecordSchema.parse(await response.json());
+ }
+
+ /** Replaces one cached JSON record. */
+ async set(record: Parameters[0]): Promise {
+ await this.#cache.put(
+ request(this.#prefix, record.path),
+ new Response(JSON.stringify(record), { headers: { "content-type": "application/json" } }),
+ );
+ }
+
+ /** Removes one exact synthetic request key. */
+ async delete(path: PathType): Promise {
+ await this.#cache.delete(request(this.#prefix, path));
+ }
+
+ /** Scans cache keys and yields direct children in the reserved namespace. */
+ async *list(parent: PathType) {
+ const parentDepth = splitPath(parent).length;
+ for (const cacheRequest of await this.#cache.keys()) {
+ const path = getPath(this.#prefix, cacheRequest);
+ if (path === null || splitPath(path).length !== parentDepth + 1) continue;
+ const response = await this.#cache.match(cacheRequest);
+ if (response === undefined) continue;
+ const record = RecordSchema.parse(await response.json());
+ if (record.parent === parent) yield record;
+ }
+ }
+}
+
+/** Creates a Cache Storage record driver over one injected Cache. */
+export function createCacheDriver(cache: Cache, options: CacheDriverOptionsType = {}): RecordDriverType {
+ const backend = new CacheBackend(cache, options);
+ return defineRecordDriver(backend, {
+ name: "cache",
+ ownership: "borrowed",
+ capabilities: { replacement: "atomic", transactions: false, binary: false },
+ requirements: [{ code: "cache-storage", state: "available" }],
+ limits: [{
+ code: "quota-bytes",
+ kind: "dynamic",
+ source: "probe",
+ unit: "bytes",
+ detail: "Browser Cache Storage quota and eviction policy are runtime-dependent.",
+ }],
+ optimizations: [],
+ readOnly: options.readOnly ?? false,
+ });
+}
diff --git a/src/driver/deno-kv.ts b/src/driver/deno-kv.ts
new file mode 100644
index 0000000..f4042ce
--- /dev/null
+++ b/src/driver/deno-kv.ts
@@ -0,0 +1,1119 @@
+import { pooledMap } from "@std/async/pool";
+import { concat } from "@std/bytes";
+import { decodeBase64, encodeBase64 } from "@std/encoding/base64";
+import { z } from "zod";
+
+import { FileSystemError, throwIfAborted } from "../error.ts";
+import { defineRecordDriver, type RecordBackendType, type RecordDriverType, type RecordListType } from "./record.ts";
+import {
+ type ActionType,
+ DriverPlanInputSchema,
+ type DriverPlanInputType,
+ DriverPlanSchema,
+ type DriverPlanType,
+ type ProblemType,
+} from "./definition.ts";
+import type { FileDriverReadOptionsType, FileDriverWriteOptionsType } from "./file.ts";
+import { basename, dirname } from "../path.ts";
+import { split } from "../chunk.ts";
+import {
+ PartitionModeSchema,
+ type PartitionModeType,
+ PathSchema,
+ RecordSchema,
+ type RecordType,
+ type WriteModeType,
+} from "../schema.ts";
+
+/** Maximum serialized Deno KV key size documented by the runtime. */
+export const DENO_KV_MAX_KEY_BYTES = 2 * 1024;
+/** Maximum serialized Deno KV value size documented by the runtime. */
+export const DENO_KV_MAX_VALUE_BYTES = 64 * 1024;
+/** Maximum total serialized size of one Deno KV atomic mutation. */
+export const DENO_KV_MAX_ATOMIC_BYTES = 800 * 1024;
+/** Conservative raw Uint8Array payload budget below the serialized 64 KiB provider ceiling. */
+export const DENO_KV_SAFE_PART_BYTES = 60 * 1024;
+/** Conservative decoded inline body budget after base64 expansion and record metadata. */
+export const DENO_KV_SAFE_INLINE_BYTES = 40 * 1024;
+/** Conservative decoded payload kept in one raw binary part. */
+export const DENO_KV_DEFAULT_PART_BYTES = 48 * 1024;
+/** Conservative decoded payload kept inline with filesystem metadata. */
+export const DENO_KV_DEFAULT_INLINE_BYTES = 32 * 1024;
+/** Explicit safety ceiling that prevents one logical file from creating unbounded keys. */
+export const DENO_KV_DEFAULT_MAX_PARTS = 10_000;
+/** Default concurrent exact reads/deletes for partitioned file bodies. */
+export const DENO_KV_DEFAULT_CONCURRENCY = 8;
+/** Default grace period before orphaned physical generations are eligible for collection. */
+export const DENO_KV_DEFAULT_COLLECT_AGE_MS = 60 * 60 * 1000;
+/** Default deletion budget for one explicit collection pass. */
+export const DENO_KV_DEFAULT_COLLECT_DELETES = 10_000;
+
+/** Structural Deno KV entry used by the driver. */
+export interface DenoKvEntryType {
+ /** Stored tuple returned by exact reads and prefix iteration. */
+ readonly key: readonly unknown[];
+ /** Stored value, or null for a missing exact get. */
+ readonly value: T | null;
+}
+
+/** Structural Deno KV subset required by this driver. */
+export interface DenoKvType {
+ /** Reads one exact key. */
+ get(key: readonly unknown[]): Promise>;
+ /** Replaces one key. */
+ set(key: readonly unknown[], value: unknown): Promise;
+ /** Removes one key. */
+ delete(key: readonly unknown[]): Promise;
+ /** Streams keys with one prefix. */
+ list(selector: { readonly prefix: readonly unknown[] }): AsyncIterable>;
+ /** Closes the database when the caller transfers ownership. */
+ close?(): void;
+}
+
+/** Options for Deno KV persistence. */
+/** Options for explicit reclamation of unreachable Deno KV body parts. */
+export interface DenoKvCollectOptionsType {
+ /**
+ * Minimum generation age before unreachable parts can be removed.
+ *
+ * Defaults to one hour. The grace period prevents ordinary collection from
+ * racing a long-running writer whose manifest has not been published yet.
+ */
+ readonly minAgeMs?: number;
+ /** Maximum part deletions in one call. Defaults to 10,000. */
+ readonly maxDeletes?: number;
+ /** Cancels scanning and deletion between provider operations. */
+ readonly signal?: AbortSignal;
+}
+
+/** Result of one bounded Deno KV orphan-part collection pass. */
+export interface DenoKvCollectResultType {
+ /** Distinct physical generations inspected. */
+ readonly generations: number;
+ /** Physical part keys inspected. */
+ readonly parts: number;
+ /** Unreachable physical part keys removed. */
+ readonly deleted: number;
+ /** Reachable or grace-period physical part keys retained. */
+ readonly retained: number;
+ /** True when `maxDeletes` stopped the pass before the prefix scan ended. */
+ readonly truncated: boolean;
+}
+
+/** Deno KV record driver with explicit physical maintenance. */
+export interface DenoKvDriverType extends RecordDriverType {
+ /** Reclaims old part generations that are not referenced by a published manifest. */
+ collect(options?: DenoKvCollectOptionsType): Promise;
+}
+
+/** Configuration for the Deno KV record driver and its physical partition layout. */
+export interface DenoKvDriverOptionsType {
+ /** Key namespace. Defaults to `okikio-opfs`. */
+ readonly prefix?: string;
+ /** Closes the injected KV database with the driver. */
+ readonly disposeDatabase?: boolean;
+ /** Prevents mutations. */
+ readonly readOnly?: boolean;
+ /** Physical large-file layout. Defaults to `auto`. */
+ readonly partition?: PartitionModeType;
+ /** Maximum decoded bytes in one partition. Defaults to 48 KiB. */
+ readonly partBytes?: number;
+ /** Maximum decoded bytes stored as one normal record in `auto` mode. Defaults to 32 KiB. */
+ readonly inlineBytes?: number;
+ /** Maximum physical part count for one logical file. Defaults to 10,000. */
+ readonly maxParts?: number;
+ /** Maximum concurrent exact part reads/deletes. Defaults to 8. */
+ readonly concurrency?: number;
+}
+
+/** File metadata retained in the small manifest committed after all body parts. */
+const DenoKvFileSchema = z.object({
+ version: z.literal(1),
+ path: PathSchema,
+ parent: PathSchema,
+ name: z.string(),
+ kind: z.literal("file"),
+ size: z.number().int().nonnegative(),
+ lastModified: z.number().int().nonnegative(),
+ mediaType: z.string(),
+}).strict();
+
+/** Durable pointer to one generation of raw Deno KV body parts. */
+const DenoKvManifestSchema = z.object({
+ storage: z.literal("deno-kv-parts-v2"),
+ generation: z.string().min(1),
+ parts: z.number().int().positive(),
+ partBytes: z.number().int().positive(),
+ file: DenoKvFileSchema,
+}).strict();
+
+/** Validated private manifest that publishes one complete partition generation. */
+type DenoKvManifestType = z.output;
+/** Physical value stored at one logical entry key: inline record or partition manifest. */
+type DenoKvStoredType = RecordType | DenoKvManifestType;
+
+/** Maps one exact virtual path to a Deno KV entry key derived from its parent and name. */
+function key(prefix: string, path: string): readonly unknown[] {
+ return [prefix, "entry", dirname(path), basename(path)];
+}
+
+/** Prefix whose entries are exactly the direct children of one canonical parent path. */
+function listKey(prefix: string, parent: string): readonly unknown[] {
+ return [prefix, "entry", parent];
+}
+
+/** Maps one logical file generation and part number to a separate raw binary key. */
+function partKey(prefix: string, path: string, generation: string, index: number): readonly unknown[] {
+ return [prefix, "part", path, generation, index];
+}
+
+/** Validates a positive safe integer configuration value. */
+function positive(value: number | undefined, fallback: number, name: string): number {
+ const resolved = value ?? fallback;
+ if (!Number.isSafeInteger(resolved) || resolved < 1) throw new RangeError(`${name} must be a positive safe integer.`);
+ return resolved;
+}
+
+/** Validates physical part policy before any derived logical-size arithmetic is used. */
+function validateSizePolicy(partBytes: number, inlineBytes: number, maxParts: number): void {
+ if (partBytes > DENO_KV_SAFE_PART_BYTES) {
+ throw new RangeError(
+ `partBytes must be <= ${DENO_KV_SAFE_PART_BYTES} bytes so serialization overhead stays below ` +
+ `Deno KV's ${DENO_KV_MAX_VALUE_BYTES}-byte value ceiling.`,
+ );
+ }
+ if (inlineBytes > DENO_KV_SAFE_INLINE_BYTES) {
+ throw new RangeError(
+ `inlineBytes must be <= ${DENO_KV_SAFE_INLINE_BYTES} decoded bytes so base64 data plus record metadata ` +
+ "stays below Deno KV's serialized value ceiling.",
+ );
+ }
+ if (maxParts > Math.floor(Number.MAX_SAFE_INTEGER / partBytes)) {
+ throw new RangeError("maxParts and partBytes must produce an exactly representable logical file-size limit.");
+ }
+}
+
+/** Returns true when a stored value is the private partition manifest rather than a public record. */
+function isManifest(value: unknown): value is DenoKvManifestType {
+ return typeof value === "object" && value !== null && (value as { storage?: unknown }).storage === "deno-kv-parts-v2";
+}
+
+/** Projects a manifest to listing metadata without reading any body part. */
+function manifestList(manifest: DenoKvManifestType): RecordListType {
+ return manifest.file;
+}
+
+/** Creates one new generation identifier without depending on Deno globals. */
+function generation(): string {
+ return `${Date.now().toString(36)}-${crypto.randomUUID()}`;
+}
+
+/** Reads the timestamp prefix embedded in a project-generated physical generation ID. */
+function generationTime(value: string): number | undefined {
+ const [encoded] = value.split("-", 1);
+ if (encoded === undefined || encoded.length === 0) return undefined;
+ const time = Number.parseInt(encoded, 36);
+ return Number.isSafeInteger(time) && time >= 0 ? time : undefined;
+}
+
+/** Splits bytes into independent copies so each stored value owns a stable ArrayBuffer. */
+function parts(bytes: Uint8Array, partBytes: number): Uint8Array[] {
+ if (bytes.byteLength === 0) return [new Uint8Array()];
+ const output: Uint8Array[] = [];
+ for (let at = 0; at < bytes.byteLength; at += partBytes) output.push(bytes.slice(at, at + partBytes));
+ return output;
+}
+
+/** UTF-8 encoder used for conservative Deno KV tuple-size planning. */
+const keyEncoder = new TextEncoder();
+
+/** Conservatively estimates serialized tuple bytes for the key component types used here. */
+function estimateKeyBytes(value: readonly unknown[]): number {
+ let bytes = 0;
+ for (const component of value) {
+ bytes += 16;
+ if (typeof component === "string") bytes += keyEncoder.encode(component).byteLength;
+ else if (typeof component === "number") bytes += 8;
+ else bytes += 32;
+ }
+ return bytes;
+}
+
+/** Creates a path-aware Deno KV plan before any provider request is sent. */
+function createDenoKvPlan(options: DenoKvDriverOptionsType, input: DriverPlanInputType): DriverPlanType {
+ const request = DriverPlanInputSchema.parse(input);
+ const partition = PartitionModeSchema.parse(options.partition ?? "auto");
+ const prefix = options.prefix ?? "okikio-opfs";
+ const partBytes = positive(options.partBytes, DENO_KV_DEFAULT_PART_BYTES, "partBytes");
+ const inlineBytes = positive(options.inlineBytes, DENO_KV_DEFAULT_INLINE_BYTES, "inlineBytes");
+ const maxParts = positive(options.maxParts, DENO_KV_DEFAULT_MAX_PARTS, "maxParts");
+ validateSizePolicy(partBytes, inlineBytes, maxParts);
+ const problems: ProblemType[] = [];
+ const actions: ActionType[] = [];
+ if (request.path !== undefined) {
+ const entryBytes = estimateKeyBytes(key(prefix, request.path));
+ const partBytesEstimate = estimateKeyBytes(
+ partKey(prefix, request.path, "00000000-0000-4000-8000-000000000000", maxParts - 1),
+ );
+ const estimated = Math.max(entryBytes, partBytesEstimate);
+ if (estimated > DENO_KV_MAX_KEY_BYTES) {
+ problems.push({
+ code: "key-too-large",
+ layer: "driver",
+ severity: "error",
+ message: `Deno KV physical key estimate ${estimated} bytes exceeds the ${DENO_KV_MAX_KEY_BYTES}-byte ` +
+ `serialized provider limit for '${request.path}'.`,
+ limit: {
+ code: "serialized-key-bytes",
+ kind: "hard",
+ source: "provider",
+ unit: "bytes",
+ value: DENO_KV_MAX_KEY_BYTES,
+ },
+ });
+ actions.push({ kind: "reduce-input" }, { kind: "select-driver" });
+ }
+ }
+ let support: "native" | "partitioned" | "unsupported" = "native";
+ let count: number | undefined;
+ if (request.operation === "write" && request.size !== undefined) {
+ const usesParts = partition === "always" || (partition === "auto" && request.size > inlineBytes);
+ if (partition === "never" && request.size > inlineBytes) {
+ support = "unsupported";
+ problems.push({
+ code: "partition-disabled",
+ layer: "driver",
+ severity: "error",
+ message:
+ `The ${request.size}-byte write exceeds inlineBytes=${inlineBytes}, but Deno KV partitioning is disabled.`,
+ });
+ actions.push({ kind: "change-policy" }, { kind: "select-driver" });
+ } else if (usesParts) {
+ support = "partitioned";
+ count = Math.max(1, Math.ceil(request.size / partBytes));
+ if (count > maxParts) {
+ support = "unsupported";
+ problems.push({
+ code: "too-many-parts",
+ layer: "driver",
+ severity: "error",
+ message: `The write needs ${count} Deno KV parts, above configured maxParts=${maxParts}.`,
+ limit: {
+ code: "parts",
+ kind: "policy",
+ source: "user",
+ unit: "count",
+ value: maxParts,
+ },
+ });
+ actions.push({ kind: "change-policy" }, { kind: "select-driver" });
+ }
+ }
+ }
+ const supported = support !== "unsupported" && problems.every((problem) => problem.severity !== "error");
+ return DriverPlanSchema.parse({
+ operation: request.operation,
+ supported,
+ support: supported ? support : "unsupported",
+ ...(count === undefined ? {} : { parts: count, partBytes }),
+ problems,
+ actions,
+ });
+}
+
+/**
+ * Record-store projection over one caller-owned Deno KV database.
+ *
+ * Logical entries are keyed as `(namespace, "entry", parentPath, name)`. This
+ * keeps exact lookup deterministic while a parent-prefix list contains only
+ * direct children, not the complete descendant subtree.
+ *
+ * Deno KV limits one serialized value to 64 KiB. A normal filesystem file can
+ * be much larger, so the default `auto` policy stores small records inline and
+ * large file bodies as raw `Uint8Array` parts. All parts of a new generation
+ * are written first and the small manifest is written last:
+ *
+ * ```text
+ * old manifest -> old parts
+ *
+ * write new part 0..N
+ * |
+ * v
+ * commit new manifest <- visibility point
+ * |
+ * v
+ * remove old parts
+ * ```
+ *
+ * Readers therefore observe the previous complete generation until the new
+ * manifest commit succeeds. A process crash before the manifest commit can
+ * leave unreachable part keys. That is storage leakage, not a partial logical
+ * file; a later successful overwrite removes the previous reachable generation.
+ */
+class DenoKvBackend implements RecordBackendType {
+ /** Optional byte lanes that keep large logical files out of generic base64 record materialization. */
+ readonly capabilities;
+ /** Deno KV-compatible database borrowed from the caller. */
+ readonly #database: DenoKvType;
+ /** First key tuple component reserved for this filesystem. */
+ readonly #prefix: string;
+ /** Whether store disposal also closes the injected database. */
+ readonly #disposeDatabase: boolean;
+ /** Prevents all physical mutation, including maintenance collection. */
+ readonly #readOnly: boolean;
+ /** Large logical-file policy. */
+ readonly #partition: PartitionModeType;
+ /** Decoded bytes stored in one physical part. */
+ readonly #partBytes: number;
+ /** Largest decoded body stored inline under the conservative provider ceiling. */
+ readonly #inlineBytes: number;
+ /** Maximum physical parts for one logical file. */
+ readonly #maxParts: number;
+ /** Concurrent exact part I/O ceiling. */
+ readonly #concurrency: number;
+
+ /** Resolves namespace, ownership, and physical layout once. */
+ constructor(database: DenoKvType, options: DenoKvDriverOptionsType) {
+ this.#database = database;
+ this.#prefix = options.prefix ?? "okikio-opfs";
+ this.#disposeDatabase = options.disposeDatabase ?? false;
+ this.#readOnly = options.readOnly ?? false;
+ this.#partition = PartitionModeSchema.parse(options.partition ?? "auto");
+ this.#partBytes = positive(options.partBytes, DENO_KV_DEFAULT_PART_BYTES, "partBytes");
+ this.#inlineBytes = positive(options.inlineBytes, DENO_KV_DEFAULT_INLINE_BYTES, "inlineBytes");
+ this.#maxParts = positive(options.maxParts, DENO_KV_DEFAULT_MAX_PARTS, "maxParts");
+ this.#concurrency = positive(options.concurrency, DENO_KV_DEFAULT_CONCURRENCY, "concurrency");
+ validateSizePolicy(this.#partBytes, this.#inlineBytes, this.#maxParts);
+ const streamWriteModes: readonly WriteModeType[] = this.#partition === "never" ? [] : ["replace"];
+ this.capabilities = {
+ rangeRead: true,
+ streamRead: true,
+ writeModes: ["replace", "append", "update"],
+ streamWriteModes,
+ } as const;
+ }
+
+ /** Reads one exact stored logical value without following a partition manifest. */
+ async #stored(path: string): Promise {
+ const entry = await this.#database.get(key(this.#prefix, path));
+ if (entry.value === null) return null;
+ if (isManifest(entry.value)) return DenoKvManifestSchema.parse(entry.value);
+ return RecordSchema.parse(entry.value);
+ }
+
+ /** Returns logical metadata without joining any partition body. */
+ async stat(path: Parameters>[0]): Promise {
+ const stored = await this.#stored(path);
+ if (stored === null) return null;
+ return isManifest(stored) ? manifestList(stored) : stored;
+ }
+
+ /** Reads and validates one exact logical record, joining parts only for an exact file read. */
+ async get(path: Parameters[0]): Promise {
+ const stored = await this.#stored(path);
+ if (stored === null) return null;
+ if (!isManifest(stored)) return stored;
+
+ const manifest = stored;
+ const chunks = new Array(manifest.parts);
+ const indexes = Array.from({ length: manifest.parts }, (_, index) => index);
+ for await (
+ const result of pooledMap(this.#concurrency, indexes, async (index) => {
+ const part = await this.#database.get(partKey(this.#prefix, path, manifest.generation, index));
+ if (!(part.value instanceof Uint8Array)) {
+ throw new FileSystemError(
+ "unknown",
+ "read",
+ path,
+ `Deno KV file '${path}' is missing physical part ${index} of ${manifest.parts}.`,
+ );
+ }
+ return { index, bytes: part.value };
+ })
+ ) chunks[result.index] = result.bytes;
+
+ const bytes = concat(chunks);
+ if (bytes.byteLength !== manifest.file.size) {
+ throw new FileSystemError(
+ "unknown",
+ "read",
+ path,
+ `Deno KV file '${path}' reconstructed ${bytes.byteLength} bytes; manifest expects ${manifest.file.size}.`,
+ );
+ }
+ return RecordSchema.parse({ ...manifest.file, data: encodeBase64(bytes) });
+ }
+
+ /**
+ * Reads only physical parts that overlap the requested logical byte range.
+ *
+ * This is the critical difference from a generic record store: a 500 MiB
+ * partitioned file can satisfy a 4 KiB read without reconstructing 500 MiB or
+ * allocating a 500 MiB base64 record first.
+ */
+ async readFile(
+ path: Parameters>[0],
+ options: FileDriverReadOptionsType = {},
+ ): Promise {
+ throwIfAborted(options.signal, "read", path);
+ const stored = await this.#stored(path);
+ if (stored === null) throw new FileSystemError("not-found", "read", path, `File '${path}' does not exist.`);
+ if (!isManifest(stored)) {
+ if (stored.kind !== "file") throw new FileSystemError("type-mismatch", "read", path, `'${path}' is a directory.`);
+ const bytes = decodeBase64(stored.data);
+ const start = Math.min(options.at ?? 0, bytes.byteLength);
+ const end = options.length === undefined ? bytes.byteLength : Math.min(bytes.byteLength, start + options.length);
+ return bytes.slice(start, end);
+ }
+
+ const manifest = stored;
+ const start = Math.min(options.at ?? 0, manifest.file.size);
+ const end = options.length === undefined
+ ? manifest.file.size
+ : Math.min(manifest.file.size, start + options.length);
+ if (start === end) return new Uint8Array();
+
+ const first = Math.floor(start / manifest.partBytes);
+ const last = Math.ceil(end / manifest.partBytes);
+ const indexes = Array.from({ length: last - first }, (_, offset) => first + offset);
+ const chunks = new Array(indexes.length);
+ for await (
+ const result of pooledMap(this.#concurrency, indexes, async (index) => {
+ throwIfAborted(options.signal, "read", path);
+ const part = await this.#database.get(partKey(this.#prefix, path, manifest.generation, index));
+ if (!(part.value instanceof Uint8Array)) {
+ throw new FileSystemError(
+ "unknown",
+ "read",
+ path,
+ `Deno KV file '${path}' is missing physical part ${index} of ${manifest.parts}.`,
+ );
+ }
+ return { index, bytes: part.value };
+ })
+ ) chunks[result.index - first] = result.bytes;
+
+ const joined = concat(chunks);
+ const localStart = start - first * manifest.partBytes;
+ const result = joined.slice(localStart, localStart + (end - start));
+ if (result.byteLength !== end - start) {
+ throw new FileSystemError(
+ "unknown",
+ "read",
+ path,
+ `Deno KV range for '${path}' reconstructed ${result.byteLength} bytes; expected ${end - start}.`,
+ );
+ }
+ return result;
+ }
+
+ /**
+ * Streams partitioned bytes one physical part at a time under consumer backpressure.
+ *
+ * One part is resident in this layer at a time. The provider request itself is
+ * not cancellable through Deno KV, so an abort can stop before the next part
+ * but cannot revoke an exact get that the runtime has already started.
+ */
+ async openReadStream(
+ path: Parameters>[0],
+ options: FileDriverReadOptionsType = {},
+ ): Promise> {
+ throwIfAborted(options.signal, "read", path);
+ const stored = await this.#stored(path);
+ if (stored === null) throw new FileSystemError("not-found", "read", path, `File '${path}' does not exist.`);
+ if (!isManifest(stored)) {
+ if (stored.kind !== "file") throw new FileSystemError("type-mismatch", "read", path, `'${path}' is a directory.`);
+ const bytes = await this.readFile(path, options);
+ return new ReadableStream({
+ start(controller) {
+ if (bytes.byteLength > 0) controller.enqueue(bytes);
+ controller.close();
+ },
+ });
+ }
+
+ const manifest = stored;
+ const start = Math.min(options.at ?? 0, manifest.file.size);
+ const end = options.length === undefined
+ ? manifest.file.size
+ : Math.min(manifest.file.size, start + options.length);
+ let index = Math.floor(start / manifest.partBytes);
+ const last = Math.ceil(end / manifest.partBytes);
+ const first = index;
+ const database = this.#database;
+ const prefix = this.#prefix;
+ const signal = options.signal;
+
+ return new ReadableStream({
+ async pull(controller) {
+ throwIfAborted(signal, "read", path);
+ if (start === end || index >= last) {
+ controller.close();
+ return;
+ }
+ const entry = await database.get(partKey(prefix, path, manifest.generation, index));
+ throwIfAborted(signal, "read", path);
+ if (!(entry.value instanceof Uint8Array)) {
+ controller.error(
+ new FileSystemError(
+ "unknown",
+ "read",
+ path,
+ `Deno KV file '${path}' is missing physical part ${index} of ${manifest.parts}.`,
+ ),
+ );
+ return;
+ }
+ const physicalStart = index * manifest.partBytes;
+ const from = index === first ? start - physicalStart : 0;
+ const to = index === last - 1 ? Math.min(entry.value.byteLength, end - physicalStart) : entry.value.byteLength;
+ index += 1;
+ if (to > from) controller.enqueue(entry.value.slice(from, to));
+ if (index >= last) controller.close();
+ },
+ });
+ }
+
+ /**
+ * Reads one range from a previously resolved value without materializing the
+ * complete logical file.
+ *
+ * Patch writes use this while constructing a new immutable generation. An
+ * inline predecessor is small by configuration, while a partitioned
+ * predecessor reads only the physical parts that overlap the requested
+ * output part.
+ */
+ async #readRange(
+ path: string,
+ stored: DenoKvStoredType,
+ at: number,
+ length: number,
+ signal?: AbortSignal,
+ ): Promise {
+ if (length === 0) return new Uint8Array();
+ throwIfAborted(signal, "read", path);
+ if (!isManifest(stored)) {
+ if (stored.kind !== "file") throw new FileSystemError("type-mismatch", "read", path, `'${path}' is a directory.`);
+ return decodeBase64(stored.data).slice(at, at + length);
+ }
+
+ const start = Math.min(at, stored.file.size);
+ const end = Math.min(stored.file.size, start + length);
+ if (start === end) return new Uint8Array();
+ const first = Math.floor(start / stored.partBytes);
+ const last = Math.ceil(end / stored.partBytes);
+ const chunks: Uint8Array[] = [];
+ for (let index = first; index < last; index += 1) {
+ throwIfAborted(signal, "read", path);
+ const part = await this.#database.get(partKey(this.#prefix, path, stored.generation, index));
+ if (!(part.value instanceof Uint8Array)) {
+ throw new FileSystemError(
+ "unknown",
+ "read",
+ path,
+ `Deno KV file '${path}' is missing physical part ${index} of ${stored.parts}.`,
+ );
+ }
+ chunks.push(part.value);
+ }
+
+ const joined = concat(chunks);
+ const localStart = start - first * stored.partBytes;
+ return joined.slice(localStart, localStart + (end - start));
+ }
+
+ /**
+ * Commits materialized replace, append, and update writes without rebuilding
+ * a complete base64 record.
+ *
+ * Replace can write the supplied bytes directly. Append/update construct a
+ * new immutable generation one provider part at a time. Existing bytes are
+ * read only for the output part currently being built, so a small patch to a
+ * large partitioned file does not allocate the old logical file in memory.
+ */
+ async writeFile(
+ path: Parameters>[0],
+ data: Uint8Array,
+ options: FileDriverWriteOptionsType,
+ ): Promise {
+ throwIfAborted(options.signal, "write", path);
+ const previousStored = await this.#stored(path);
+ if (previousStored !== null && !isManifest(previousStored) && previousStored.kind === "directory") {
+ throw new FileSystemError("type-mismatch", "write", path, `'${path}' is a directory.`);
+ }
+ const previous = previousStored === null ? null : isManifest(previousStored) ? previousStored.file : previousStored;
+ const previousSize = previous?.kind === "file" ? previous.size : 0;
+ const position = options.mode === "append" ? previousSize : options.mode === "update" ? options.at ?? 0 : 0;
+ const outputSize = options.mode === "replace"
+ ? data.byteLength
+ : options.truncate
+ ? position + data.byteLength
+ : Math.max(previousSize, position + data.byteLength);
+ const file = {
+ version: 1 as const,
+ path,
+ parent: dirname(path),
+ name: basename(path),
+ kind: "file" as const,
+ size: outputSize,
+ lastModified: Date.now(),
+ mediaType: options.mediaType ?? (previous?.kind === "file" ? previous.mediaType : ""),
+ };
+
+ if (options.mode === "replace") {
+ await this.#saveFile(file, data);
+ return;
+ }
+
+ const useParts = this.#partition === "always" || (this.#partition === "auto" && outputSize > this.#inlineBytes);
+ if (!useParts) {
+ if (outputSize > this.#inlineBytes && this.#partition === "never") {
+ throw new FileSystemError(
+ "too-large",
+ "write",
+ path,
+ `Deno KV file is ${outputSize} bytes; configured inlineBytes is ${this.#inlineBytes}. Enable partitioning or lower the logical write size.`,
+ );
+ }
+ const output = new Uint8Array(outputSize);
+ if (previousStored !== null && previousSize > 0) {
+ output.set(await this.#readRange(path, previousStored, 0, Math.min(previousSize, outputSize), options.signal));
+ }
+ output.set(data, position);
+ await this.#saveFile(file, output);
+ return;
+ }
+
+ const partCount = Math.max(1, Math.ceil(outputSize / this.#partBytes));
+ if (partCount > this.#maxParts) {
+ throw new FileSystemError(
+ "too-large",
+ "write",
+ path,
+ `Deno KV file requires ${partCount} parts, above configured maxParts ${this.#maxParts}.`,
+ );
+ }
+
+ const previousManifest = previousStored !== null && isManifest(previousStored) ? previousStored : undefined;
+ const nextGeneration = generation();
+ const indexes = Array.from({ length: partCount }, (_, index) => index);
+ try {
+ for await (
+ const _ of pooledMap(this.#concurrency, indexes, async (index) => {
+ throwIfAborted(options.signal, "write", path);
+ const start = index * this.#partBytes;
+ const end = Math.min(outputSize, start + this.#partBytes);
+ const chunk = new Uint8Array(end - start);
+
+ const preservedEnd = Math.min(end, previousSize, outputSize);
+ if (previousStored !== null && preservedEnd > start) {
+ const preserved = await this.#readRange(path, previousStored, start, preservedEnd - start, options.signal);
+ chunk.set(preserved, 0);
+ }
+
+ const patchStart = Math.max(start, position);
+ const patchEnd = Math.min(end, position + data.byteLength);
+ if (patchEnd > patchStart) {
+ chunk.set(data.subarray(patchStart - position, patchEnd - position), patchStart - start);
+ }
+ await this.#database.set(partKey(this.#prefix, path, nextGeneration, index), chunk);
+ })
+ ) {
+ // The iterator is consumed so all bounded reads/writes settle before the manifest becomes visible.
+ }
+
+ throwIfAborted(options.signal, "write", path);
+ await this.#database.set(
+ key(this.#prefix, path),
+ DenoKvManifestSchema.parse({
+ storage: "deno-kv-parts-v2",
+ generation: nextGeneration,
+ parts: partCount,
+ partBytes: this.#partBytes,
+ file,
+ }),
+ );
+ } catch (error) {
+ await this.#deleteGeneration(path, nextGeneration, partCount).catch(() => undefined);
+ throw error;
+ }
+
+ if (previousManifest !== undefined) await this.#deleteParts(path, previousManifest);
+ }
+
+ /**
+ * Writes an unknown-size replacement directly into Deno KV parts.
+ *
+ * `auto` uses the partition layout for streams even when the final file is
+ * small. The final size is unknown until EOF, and switching from an inline
+ * buffer to partitioned storage after a threshold would retain exactly the
+ * memory growth this lane exists to avoid. Callers can disable this behavior
+ * with `partition: "never"`, which also removes native stream-write support.
+ */
+ async writeStream(
+ path: Parameters>[0],
+ source: ReadableStream,
+ options: FileDriverWriteOptionsType,
+ ): Promise {
+ if (options.mode !== "replace" || this.#partition === "never") {
+ await source.cancel().catch(() => undefined);
+ throw new FileSystemError("not-supported", "write", path, `Deno KV streaming requires partitioned replace mode.`);
+ }
+ throwIfAborted(options.signal, "write", path);
+ const previousStored = await this.#stored(path);
+ if (previousStored !== null && !isManifest(previousStored) && previousStored.kind === "directory") {
+ await source.cancel().catch(() => undefined);
+ throw new FileSystemError("type-mismatch", "write", path, `'${path}' is a directory.`);
+ }
+ const previousManifest = isManifest(previousStored) ? previousStored : undefined;
+ const previousMediaType = previousStored === null
+ ? ""
+ : isManifest(previousStored)
+ ? previousStored.file.mediaType
+ : previousStored.kind === "file"
+ ? previousStored.mediaType
+ : "";
+ const nextGeneration = generation();
+ let scheduled = 0;
+ let size = 0;
+
+ try {
+ for await (
+ const written of pooledMap(this.#concurrency, split(source, this.#partBytes), async (chunk) => {
+ const index = scheduled++;
+ if (index >= this.#maxParts) {
+ throw new FileSystemError(
+ "too-large",
+ "write",
+ path,
+ `Deno KV stream exceeded configured maxParts ${this.#maxParts}.`,
+ );
+ }
+ throwIfAborted(options.signal, "write", path);
+ await this.#database.set(partKey(this.#prefix, path, nextGeneration, index), chunk);
+ return { bytes: chunk.byteLength };
+ })
+ ) size += written.bytes;
+
+ if (scheduled === 0) {
+ scheduled = 1;
+ await this.#database.set(partKey(this.#prefix, path, nextGeneration, 0), new Uint8Array());
+ }
+ throwIfAborted(options.signal, "write", path);
+ const manifest = DenoKvManifestSchema.parse({
+ storage: "deno-kv-parts-v2",
+ generation: nextGeneration,
+ parts: scheduled,
+ partBytes: this.#partBytes,
+ file: {
+ version: 1,
+ path,
+ parent: dirname(path),
+ name: basename(path),
+ kind: "file",
+ size,
+ lastModified: Date.now(),
+ mediaType: options.mediaType ?? previousMediaType,
+ },
+ });
+ await this.#database.set(key(this.#prefix, path), manifest);
+ } catch (error) {
+ await this.#deleteGeneration(path, nextGeneration, scheduled).catch(() => undefined);
+ throw error;
+ }
+
+ if (previousManifest !== undefined) await this.#deleteParts(path, previousManifest);
+ }
+
+ /** Replaces one exact logical record and commits partition manifests only after every new part exists. */
+ async set(record: RecordType): Promise {
+ const previous = await this.#database.get(key(this.#prefix, record.path));
+ const previousManifest = previous.value !== null && isManifest(previous.value)
+ ? DenoKvManifestSchema.parse(previous.value)
+ : undefined;
+
+ if (record.kind === "directory") {
+ await this.#database.set(key(this.#prefix, record.path), record);
+ if (previousManifest !== undefined) await this.#deleteParts(record.path, previousManifest);
+ return;
+ }
+
+ const bytes = decodeBase64(record.data);
+ const partition = this.#partition === "always" ||
+ (this.#partition === "auto" && bytes.byteLength > this.#inlineBytes);
+ if (!partition) {
+ if (bytes.byteLength > this.#inlineBytes && this.#partition === "never") {
+ throw new FileSystemError(
+ "too-large",
+ "write",
+ record.path,
+ `Deno KV inline file is ${bytes.byteLength} bytes; configured inlineBytes is ${this.#inlineBytes}. ` +
+ "Enable partitioning or lower the logical write size.",
+ );
+ }
+ await this.#database.set(key(this.#prefix, record.path), record);
+ if (previousManifest !== undefined) await this.#deleteParts(record.path, previousManifest);
+ return;
+ }
+
+ const chunks = parts(bytes, this.#partBytes);
+ if (chunks.length > this.#maxParts) {
+ throw new FileSystemError(
+ "too-large",
+ "write",
+ record.path,
+ `Deno KV file requires ${chunks.length} parts, above configured maxParts ${this.#maxParts}.`,
+ );
+ }
+
+ const nextGeneration = generation();
+ const indexes = chunks.map((_, index) => index);
+ try {
+ for await (
+ const _ of pooledMap(
+ this.#concurrency,
+ indexes,
+ (index) => this.#database.set(partKey(this.#prefix, record.path, nextGeneration, index), chunks[index]!),
+ )
+ ) {
+ // pooledMap owns bounded concurrency; values are intentionally ignored.
+ }
+ const { data: _data, ...file } = record;
+ const manifest = DenoKvManifestSchema.parse({
+ storage: "deno-kv-parts-v2",
+ generation: nextGeneration,
+ parts: chunks.length,
+ partBytes: this.#partBytes,
+ file,
+ });
+ await this.#database.set(key(this.#prefix, record.path), manifest);
+ } catch (error) {
+ await this.#deleteGeneration(record.path, nextGeneration, chunks.length).catch(() => undefined);
+ throw error;
+ }
+
+ if (previousManifest !== undefined) await this.#deleteParts(record.path, previousManifest);
+ }
+
+ /** Removes the logical visibility key first, then reclaims reachable body parts. */
+ async delete(path: Parameters[0]): Promise {
+ const previous = await this.#database.get(key(this.#prefix, path));
+ const manifest = previous.value !== null && isManifest(previous.value)
+ ? DenoKvManifestSchema.parse(previous.value)
+ : undefined;
+ await this.#database.delete(key(this.#prefix, path));
+ if (manifest !== undefined) await this.#deleteParts(path, manifest);
+ }
+
+ /** Lists direct children from the parent-indexed entry key and never scans descendant subtrees or partition bodies. */
+ async *list(parent: Parameters[0]): AsyncIterableIterator {
+ for await (const entry of this.#database.list({ prefix: listKey(this.#prefix, parent) })) {
+ if (entry.value === null) continue;
+ const record = isManifest(entry.value)
+ ? manifestList(DenoKvManifestSchema.parse(entry.value))
+ : RecordSchema.parse(entry.value);
+ if (record.parent === parent) yield record;
+ }
+ }
+
+ /** Stores one complete file from bytes while preserving the manifest-last visibility rule. */
+ async #saveFile(file: z.output, bytes: Uint8Array): Promise {
+ const previousStored = await this.#stored(file.path);
+ const previousManifest = isManifest(previousStored) ? previousStored : undefined;
+ const useParts = this.#partition === "always" ||
+ (this.#partition === "auto" && bytes.byteLength > this.#inlineBytes);
+ if (!useParts) {
+ if (bytes.byteLength > this.#inlineBytes && this.#partition === "never") {
+ throw new FileSystemError(
+ "too-large",
+ "write",
+ file.path,
+ `Deno KV inline file is ${bytes.byteLength} bytes; configured inlineBytes is ${this.#inlineBytes}. ` +
+ "Enable partitioning or lower the logical write size.",
+ );
+ }
+ await this.#database.set(
+ key(this.#prefix, file.path),
+ RecordSchema.parse({ ...file, data: encodeBase64(bytes) }),
+ );
+ if (previousManifest !== undefined) await this.#deleteParts(file.path, previousManifest);
+ return;
+ }
+
+ const chunks = parts(bytes, this.#partBytes);
+ if (chunks.length > this.#maxParts) {
+ throw new FileSystemError(
+ "too-large",
+ "write",
+ file.path,
+ `Deno KV file requires ${chunks.length} parts, above configured maxParts ${this.#maxParts}.`,
+ );
+ }
+ const nextGeneration = generation();
+ const indexes = chunks.map((_, index) => index);
+ try {
+ for await (
+ const _ of pooledMap(
+ this.#concurrency,
+ indexes,
+ (index) => this.#database.set(partKey(this.#prefix, file.path, nextGeneration, index), chunks[index]!),
+ )
+ ) {
+ // pooledMap owns bounded concurrency; values are intentionally ignored.
+ }
+ await this.#database.set(
+ key(this.#prefix, file.path),
+ DenoKvManifestSchema.parse({
+ storage: "deno-kv-parts-v2",
+ generation: nextGeneration,
+ parts: chunks.length,
+ partBytes: this.#partBytes,
+ file,
+ }),
+ );
+ } catch (error) {
+ await this.#deleteGeneration(file.path, nextGeneration, chunks.length).catch(() => undefined);
+ throw error;
+ }
+ if (previousManifest !== undefined) await this.#deleteParts(file.path, previousManifest);
+ }
+
+ /** Removes every expected part in one committed manifest with bounded provider concurrency. */
+ async #deleteParts(path: string, manifest: DenoKvManifestType): Promise {
+ await this.#deleteGeneration(path, manifest.generation, manifest.parts);
+ }
+
+ /** Reclaims a known generation after a failed or superseded manifest commit. */
+ async #deleteGeneration(path: string, value: string, count: number): Promise {
+ const indexes = Array.from({ length: count }, (_, index) => index);
+ for await (
+ const _ of pooledMap(
+ this.#concurrency,
+ indexes,
+ (index) => this.#database.delete(partKey(this.#prefix, path, value, index)),
+ )
+ ) {
+ // Deletions are intentionally consumed so all already-started work settles.
+ }
+ }
+
+ /**
+ * Reclaims old physical part generations that are no longer visible.
+ *
+ * Collection is explicit because a background scan would add hidden provider
+ * I/O and could race independent writers. The default one-hour grace period
+ * retains recent unpublished generations. Set a different grace period only
+ * when the application can account for its longest possible write lifetime.
+ */
+ async collect(options: DenoKvCollectOptionsType = {}): Promise {
+ if (this.#readOnly) {
+ throw new Error("Deno KV driver is read-only; physical collection would mutate storage.");
+ }
+ const minAgeMs = options.minAgeMs ?? DENO_KV_DEFAULT_COLLECT_AGE_MS;
+ const maxDeletes = options.maxDeletes ?? DENO_KV_DEFAULT_COLLECT_DELETES;
+ if (!Number.isSafeInteger(minAgeMs) || minAgeMs < 0) {
+ throw new RangeError("minAgeMs must be a non-negative safe integer.");
+ }
+ if (!Number.isSafeInteger(maxDeletes) || maxDeletes < 1) {
+ throw new RangeError("maxDeletes must be a positive safe integer.");
+ }
+
+ const cutoff = Date.now() - minAgeMs;
+ let generations = 0;
+ let scannedParts = 0;
+ let deleted = 0;
+ let retained = 0;
+ let truncated = false;
+ let currentPath: string | undefined;
+ let currentGeneration: string | undefined;
+ let currentReachable = true;
+
+ for await (const entry of this.#database.list({ prefix: [this.#prefix, "part"] })) {
+ throwIfAborted(options.signal, "remove");
+ const [, kind, path, value] = entry.key;
+ if (kind !== "part" || typeof path !== "string" || typeof value !== "string") continue;
+ scannedParts += 1;
+
+ if (path !== currentPath || value !== currentGeneration) {
+ currentPath = path;
+ currentGeneration = value;
+ generations += 1;
+ const created = generationTime(value);
+ if (created === undefined || created > cutoff) {
+ currentReachable = true;
+ } else {
+ const visible = await this.#database.get(key(this.#prefix, path));
+ currentReachable = visible.value !== null && isManifest(visible.value) && visible.value.generation === value;
+ }
+ }
+
+ if (currentReachable) {
+ retained += 1;
+ continue;
+ }
+ if (deleted >= maxDeletes) {
+ truncated = true;
+ break;
+ }
+ await this.#database.delete(entry.key);
+ deleted += 1;
+ }
+
+ return { generations, parts: scannedParts, deleted, retained, truncated };
+ }
+
+ /** Closes the database only when the driver was given ownership. */
+ dispose(): void {
+ if (this.#disposeDatabase) this.#database.close?.();
+ }
+}
+
+/** Creates an independently useful Deno KV record driver. */
+export function createDenoKvDriver(database: DenoKvType, options: DenoKvDriverOptionsType = {}): DenoKvDriverType {
+ const partition = PartitionModeSchema.parse(options.partition ?? "auto");
+ const partBytes = positive(options.partBytes, DENO_KV_DEFAULT_PART_BYTES, "partBytes");
+ const inlineBytes = positive(options.inlineBytes, DENO_KV_DEFAULT_INLINE_BYTES, "inlineBytes");
+ const maxParts = positive(options.maxParts, DENO_KV_DEFAULT_MAX_PARTS, "maxParts");
+ const concurrency = positive(options.concurrency, DENO_KV_DEFAULT_CONCURRENCY, "concurrency");
+ validateSizePolicy(partBytes, inlineBytes, maxParts);
+ const backend = new DenoKvBackend(database, options);
+ const driver = defineRecordDriver(backend, {
+ name: "deno-kv",
+ capabilities: {
+ ...backend.capabilities,
+ replacement: "best-effort",
+ transactions: false,
+ binary: true,
+ },
+ requirements: [{ code: "deno-kv", state: "available" }],
+ limits: [
+ { code: "serialized-key-bytes", kind: "hard", source: "provider", unit: "bytes", value: DENO_KV_MAX_KEY_BYTES },
+ {
+ code: "serialized-value-bytes",
+ kind: "hard",
+ source: "provider",
+ unit: "bytes",
+ value: DENO_KV_MAX_VALUE_BYTES,
+ },
+ { code: "atomic-bytes", kind: "hard", source: "provider", unit: "bytes", value: DENO_KV_MAX_ATOMIC_BYTES },
+ { code: "part-bytes", kind: "policy", source: "user", unit: "bytes", value: partBytes },
+ { code: "inline-bytes", kind: "policy", source: "user", unit: "bytes", value: inlineBytes },
+ { code: "parts", kind: "policy", source: "user", unit: "count", value: maxParts },
+ { code: "file-bytes", kind: "policy", source: "implementation", unit: "bytes", value: partBytes * maxParts },
+ { code: "concurrency", kind: "policy", source: "user", unit: "count", value: concurrency },
+ ],
+ optimizations: [{
+ code: "partition",
+ enabled: partition !== "never",
+ changesBehavior: true,
+ disableable: true,
+ detail: `Physical layout mode is ${partition}.`,
+ }],
+ readOnly: options.readOnly ?? false,
+ disposeBackend: options.disposeDatabase ?? false,
+ plan: (input) => createDenoKvPlan(options, input),
+ });
+ return Object.assign(driver, {
+ collect: (collectOptions?: DenoKvCollectOptionsType) => backend.collect(collectOptions),
+ });
+}
diff --git a/src/driver/deno.ts b/src/driver/deno.ts
new file mode 100644
index 0000000..b802e4e
--- /dev/null
+++ b/src/driver/deno.ts
@@ -0,0 +1,394 @@
+///
+import type { FileBackendType, FileDriverType } from "./file.ts";
+import { defineFileDriver } from "./file.ts";
+import type {
+ FileDriverCopyOptionsType,
+ FileDriverDirectoryEntryType,
+ FileDriverMoveOptionsType,
+ FileDriverReadOptionsType,
+ FileDriverSignalOptionsType,
+ FileDriverStatType,
+ FileDriverSyncFileType,
+ FileDriverWritableFileType,
+ FileDriverWriteOptionsType,
+} from "./file.ts";
+import { createLocalPath } from "./local.ts";
+import { throwIfAborted, toFileSystemError } from "../error.ts";
+import type { PathType } from "../path.ts";
+
+/** Options for the Deno-native file driver. */
+export interface DenoDriverOptionsType {
+ /** Host directory exposed as virtual `/`. */
+ readonly root: string;
+ /** Creates the host root during driver creation. Defaults to true. */
+ readonly createRoot?: boolean;
+}
+
+/**
+ * Streams bytes into one already-open Deno file.
+ *
+ * The helper preserves the caller's replace/append/update cursor and cancels
+ * the source producer when writing fails. It does not close the file because
+ * the caller owns the surrounding acquisition/finalization block.
+ */
+async function writeStreamToFile(
+ file: Deno.FsFile,
+ path: PathType,
+ source: ReadableStream,
+ options: FileDriverWriteOptionsType,
+): Promise {
+ let position = options.mode === "append" ? (await file.stat()).size : options.mode === "update" ? options.at ?? 0 : 0;
+ await file.seek(position, Deno.SeekMode.Start);
+
+ 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 count = await file.write(next.value.subarray(offset));
+ if (count <= 0) throw new Error(`Deno stream write made no progress for '${path}'.`);
+ offset += count;
+ }
+ position += next.value.byteLength;
+ }
+ return position;
+ } catch (error) {
+ try {
+ await reader.cancel(error);
+ } catch {
+ // Preserve the first write or cancellation failure.
+ }
+ throw error;
+ } finally {
+ reader.releaseLock();
+ }
+}
+
+/**
+ * Long-lived Deno positional file used by the driver's asynchronous random
+ * access capability.
+ *
+ * Normal Deno files cannot roll back bytes already written. `abort()` therefore
+ * means release without additional commit work, not transactional rollback.
+ */
+class DenoWritableFile implements FileDriverWritableFileType {
+ /** Canonical virtual path used in lifecycle diagnostics. */
+ readonly #path: PathType;
+ /** Native Deno file, cleared before terminal close/abort. */
+ #file: Deno.FsFile | undefined;
+
+ /** Takes ownership of one already-open Deno file. */
+ constructor(path: PathType, file: Deno.FsFile) {
+ this.#path = path;
+ this.#file = file;
+ }
+
+ /** Returns the live Deno file or rejects access after termination. */
+ #getFile(): Deno.FsFile {
+ if (this.#file === undefined) throw new Error(`Writable file '${this.#path}' is closed.`);
+ return this.#file;
+ }
+
+ /** Writes all bytes at one explicit position, including partial native writes. */
+ async write(buffer: ArrayBufferView, options: { readonly at: number }): Promise {
+ const source = new Uint8Array(buffer.buffer, buffer.byteOffset, buffer.byteLength);
+ const file = this.#getFile();
+ await file.seek(options.at, Deno.SeekMode.Start);
+ let offset = 0;
+ while (offset < source.byteLength) {
+ const count = await file.write(source.subarray(offset));
+ if (count <= 0) throw new Error(`Deno positional write made no progress for '${this.#path}'.`);
+ offset += count;
+ }
+ }
+
+ /** Changes native file length without releasing the resource. */
+ async truncate(size: number): Promise {
+ await this.#getFile().truncate(size);
+ }
+
+ /** Requests Deno's file sync operation. */
+ async flush(): Promise {
+ await this.#getFile().sync();
+ }
+
+ /** Closes once and clears the native resource before close returns. */
+ async close(): Promise {
+ const file = this.#file;
+ if (file === undefined) return;
+ this.#file = undefined;
+ file.close();
+ }
+
+ /** Releases the file without claiming rollback of already-written host bytes. */
+ async abort(): Promise {
+ await this.close();
+ }
+}
+
+/** Synchronous random-access wrapper over one Deno file. */
+class DenoSyncFile implements FileDriverSyncFileType {
+ /** Canonical virtual path used in post-close diagnostics. */
+ readonly #path: PathType;
+ /** Native Deno file, cleared after close. */
+ #file: Deno.FsFile | undefined;
+ /** Logical cursor for operations without an explicit `at`. */
+ #cursor = 0;
+
+ /** Takes ownership of one Deno file opened for sync access. */
+ constructor(path: PathType, file: Deno.FsFile) {
+ this.#path = path;
+ this.#file = file;
+ }
+
+ /** Returns the live file or rejects access after close. */
+ #getFile(): Deno.FsFile {
+ if (this.#file === undefined) throw new Error(`Sync file '${this.#path}' is closed.`);
+ return this.#file;
+ }
+
+ /** Reads synchronously and advances the wrapper cursor. */
+ read(buffer: ArrayBufferView, options: { readonly at?: number } = {}): number {
+ const target = new Uint8Array(buffer.buffer, buffer.byteOffset, buffer.byteLength);
+ const at = options.at ?? this.#cursor;
+ const file = this.#getFile();
+ file.seekSync(at, Deno.SeekMode.Start);
+ const count = file.readSync(target) ?? 0;
+ this.#cursor = at + count;
+ return count;
+ }
+
+ /** Writes synchronously and advances the wrapper cursor. */
+ write(buffer: ArrayBufferView, options: { readonly at?: number } = {}): number {
+ const source = new Uint8Array(buffer.buffer, buffer.byteOffset, buffer.byteLength);
+ const at = options.at ?? this.#cursor;
+ const file = this.#getFile();
+ file.seekSync(at, Deno.SeekMode.Start);
+ const count = file.writeSync(source);
+ this.#cursor = at + count;
+ return count;
+ }
+
+ /** Returns current native file size. */
+ getSize(): number {
+ return this.#getFile().statSync().size;
+ }
+
+ /** Truncates and clamps the local cursor to the new file end. */
+ truncate(size: number): void {
+ this.#getFile().truncateSync(size);
+ if (this.#cursor > size) this.#cursor = size;
+ }
+
+ /** Requests synchronous durability for current writes. */
+ flush(): void {
+ this.#getFile().syncSync();
+ }
+
+ /** Closes the native Deno file exactly once. */
+ close(): void {
+ const file = this.#file;
+ if (file === undefined) return;
+ this.#file = undefined;
+ file.close();
+ }
+}
+
+/**
+ * Deno host-filesystem implementation of the portable file-driver contract.
+ *
+ * Deno owns the native file and directory operations. `@std/path` is used only
+ * by the shared host-path mapper so Deno, Node, and Bun apply the same host-root
+ * containment rule.
+ */
+class DenoBackend implements FileBackendType {
+ /** Stable driver identity used in diagnostics. */
+ readonly name = "deno";
+ /** Native Deno filesystem operations exposed without facade emulation. */
+ readonly capabilities = {
+ read: true,
+ write: true,
+ streamRead: true,
+ streamWriteModes: ["replace", "append", "update"],
+ rangeRead: true,
+ copy: true,
+ move: true,
+ positionalWrite: true,
+ syncAccess: true,
+ } as const;
+ /** Maps canonical virtual paths below the configured host root. */
+ readonly #hostPath: (path: string) => string;
+
+ /** Resolves the host root once and optionally creates it. */
+ constructor(options: DenoDriverOptionsType) {
+ this.#hostPath = createLocalPath(options.root);
+ if (options.createRoot ?? true) Deno.mkdirSync(this.#hostPath("/"), { recursive: true });
+ }
+
+ /** Returns Deno file/directory metadata or `null` for an absent path. */
+ async stat(path: PathType, options: FileDriverSignalOptionsType = {}): Promise {
+ throwIfAborted(options.signal, "stat", path);
+ try {
+ const info = await Deno.stat(this.#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;
+ }
+ }
+
+ /** Reads complete bytes or performs positioned reads for one range. */
+ async readFile(path: PathType, options: FileDriverReadOptionsType = {}): Promise {
+ throwIfAborted(options.signal, "read", path);
+ if (options.at === undefined && options.length === undefined) return await Deno.readFile(this.#hostPath(path));
+
+ const file = await Deno.open(this.#hostPath(path), { read: true });
+ try {
+ const info = await file.stat();
+ const start = options.at ?? 0;
+ const length = Math.max(0, Math.min(options.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();
+ }
+ }
+
+ /** Opens Deno's native readable stream or a bounded range stream. */
+ async openReadStream(path: PathType, options: FileDriverReadOptionsType = {}): Promise> {
+ throwIfAborted(options.signal, "read", path);
+ if (options.at === undefined && options.length === undefined) {
+ return (await Deno.open(this.#hostPath(path), { read: true })).readable;
+ }
+ return new Blob([await this.readFile(path, options)]).stream();
+ }
+
+ /** Writes materialized bytes with replace, append, or positioned update semantics. */
+ async writeFile(path: PathType, data: Uint8Array, options: FileDriverWriteOptionsType): Promise {
+ throwIfAborted(options.signal, "write", path);
+ if (options.mode === "replace") {
+ await Deno.writeFile(this.#hostPath(path), data, { create: true });
+ return;
+ }
+
+ const file = await Deno.open(this.#hostPath(path), { read: true, write: true, create: true });
+ try {
+ const position = options.mode === "append" ? (await file.stat()).size : options.at ?? 0;
+ await file.seek(position, Deno.SeekMode.Start);
+ let offset = 0;
+ while (offset < data.byteLength) {
+ const count = await file.write(data.subarray(offset));
+ if (count <= 0) throw new Error(`Deno write made no progress for '${path}'.`);
+ offset += count;
+ }
+ if (options.truncate) await file.truncate(position + data.byteLength);
+ } finally {
+ file.close();
+ }
+ }
+
+ /** Streams directly into one Deno file without facade materialization. */
+ async writeStream(
+ path: PathType,
+ source: ReadableStream,
+ options: FileDriverWriteOptionsType,
+ ): Promise {
+ const file = await Deno.open(this.#hostPath(path), {
+ read: true,
+ write: true,
+ create: true,
+ truncate: options.mode === "replace",
+ });
+ try {
+ const position = await writeStreamToFile(file, path, source, options);
+ if (options.truncate) await file.truncate(position);
+ } finally {
+ file.close();
+ }
+ }
+
+ /** Lazily yields direct file and directory children from Deno. */
+ async *readDir(
+ path: PathType,
+ options: FileDriverSignalOptionsType = {},
+ ): AsyncIterableIterator {
+ throwIfAborted(options.signal, "read-dir", path);
+ for await (const entry of Deno.readDir(this.#hostPath(path))) {
+ throwIfAborted(options.signal, "read-dir", path);
+ if (entry.isDirectory) yield { name: entry.name, kind: "directory" };
+ else if (entry.isFile) yield { name: entry.name, kind: "file" };
+ }
+ }
+
+ /** Creates one directory after facade parent resolution. */
+ async createDir(path: PathType, options: FileDriverSignalOptionsType = {}): Promise {
+ throwIfAborted(options.signal, "mkdir", path);
+ await Deno.mkdir(this.#hostPath(path));
+ }
+
+ /** Removes one file or empty directory. */
+ async remove(path: PathType, options: FileDriverSignalOptionsType = {}): Promise {
+ throwIfAborted(options.signal, "remove", path);
+ await Deno.remove(this.#hostPath(path));
+ }
+
+ /** Copies one host file through Deno's native copy operation. */
+ async copy(source: PathType, destination: PathType, options: FileDriverCopyOptionsType): Promise {
+ throwIfAborted(options.signal, "copy", source);
+ await Deno.copyFile(this.#hostPath(source), this.#hostPath(destination));
+ }
+
+ /** Moves one host path through Deno's native rename operation. */
+ async move(source: PathType, destination: PathType, options: FileDriverMoveOptionsType): Promise {
+ throwIfAborted(options.signal, "move", source);
+ await Deno.rename(this.#hostPath(source), this.#hostPath(destination));
+ }
+
+ /** Opens one long-lived asynchronous positional Deno file. */
+ async openWritableFile(path: PathType): Promise {
+ return new DenoWritableFile(path, await Deno.open(this.#hostPath(path), { read: true, write: true }));
+ }
+
+ /** Opens one synchronous Deno file and transfers ownership to the wrapper. */
+ async openSyncFile(path: PathType): Promise {
+ return new DenoSyncFile(path, Deno.openSync(this.#hostPath(path), { read: true, write: true }));
+ }
+}
+
+/**
+ * Creates a file driver backed by Deno file APIs.
+ *
+ * The driver remains Deno-native for filesystem work while sharing only the
+ * portable `@std/path` host-root mapper with Node and Bun.
+ *
+ * @example Persist below one Deno host directory.
+ * ```ts
+ * const driver = createDenoDriver({ root: "./data" });
+ * const adapter = createFileAdapter(driver);
+ * const fs = createFileSystem(adapter, { coordination: "local" });
+ * await fs.writeFile("/cache/result.json", "{}", { parents: true });
+ * ```
+ */
+export function createDenoDriver(options: DenoDriverOptionsType): FileDriverType {
+ const backend = new DenoBackend(options);
+ return defineFileDriver(backend, {
+ name: "deno",
+ requirements: [{ code: "deno-filesystem", state: "available" }],
+ limits: [],
+ optimizations: [],
+ });
+}
diff --git a/src/driver/indexeddb.ts b/src/driver/indexeddb.ts
new file mode 100644
index 0000000..f04814e
--- /dev/null
+++ b/src/driver/indexeddb.ts
@@ -0,0 +1,144 @@
+import { defineRecordDriver, type RecordBackendType, type RecordDriverType } from "./record.ts";
+import { RecordSchema } from "../schema.ts";
+
+/** Options for an existing IndexedDB database. */
+export interface IndexedDbDriverOptionsType {
+ /** Object store containing records. Defaults to `entries`. */
+ readonly store?: string;
+ /** Parent-path index. Defaults to `parent`. */
+ readonly parentIndex?: string;
+ /** Closes the injected database when the driver closes. */
+ readonly disposeDatabase?: boolean;
+ /** Prevents mutations. */
+ readonly readOnly?: boolean;
+}
+
+/** Options used when this package opens and owns an IndexedDB database. */
+export interface IndexedDbOpenOptionsType extends Omit {
+ /** Database name. Defaults to `okikio-opfs`. */
+ readonly name?: string;
+ /** Database schema version. Defaults to 1. */
+ readonly version?: number;
+}
+
+/** Converts one IDBRequest completion into a Promise while retaining native errors. */
+function result(request: IDBRequest): Promise {
+ const pending = Promise.withResolvers