From ef2a42385e75bcca245f2b694ffbea4127c567e6 Mon Sep 17 00:00:00 2001 From: Okiki Ojo Date: Tue, 18 Aug 2026 11:42:25 -0400 Subject: [PATCH] feat(drivers): add runtime-native driver entrypoints Runtime-specific adapters previously carried too much backend-native behavior inside the translation layer. Add explicit drivers for OPFS, host filesystems, record stores, Deno KV, and object providers, then update the runtime tests and provider benchmarks around those new seams. This keeps capability work at the driver layer while adapters stay focused on OPFS translation. --- bench/bun-provider.bench.ts | 18 +- bench/deno-kv.bench.ts | 8 +- bench/filesystem-provider.bench.ts | 100 +++ bench/memory.bench.ts | 7 +- bench/provider.bench.ts | 44 +- bench/sqlite.bench.ts | 5 +- src/adapter/azure.ts | 5 +- src/adapter/bun.ts | 194 +---- src/adapter/cache.ts | 95 +-- src/adapter/deno-kv.ts | 868 ++------------------- src/adapter/deno.ts | 387 +--------- src/adapter/indexeddb.ts | 160 +--- src/adapter/localstorage.ts | 118 +-- src/adapter/node.ts | 425 +---------- src/adapter/s3.ts | 13 +- src/adapter/sqlite.ts | 112 +-- src/azure.ts | 263 +++++-- src/driver/azure.ts | 75 ++ src/driver/bun.ts | 209 ++++++ src/driver/cache.ts | 102 +++ src/driver/deno-kv.ts | 1119 ++++++++++++++++++++++++++++ src/driver/deno.ts | 394 ++++++++++ src/driver/indexeddb.ts | 144 ++++ src/{adapter => driver}/local.ts | 4 +- src/driver/localstorage.ts | 97 +++ src/driver/node.ts | 436 +++++++++++ src/driver/opfs.ts | 354 +++++++++ src/driver/s3.ts | 88 +++ src/driver/sqlite.ts | 95 +++ src/s3.ts | 212 ++++-- tests/azure.test.ts | 100 +-- tests/browser/fixtures/app.ts | 5 +- tests/browser/fixtures/frame.html | 9 +- tests/browser/fixtures/index.html | 9 +- tests/browser/iframe.spec.ts | 4 +- tests/browser/opfs.spec.ts | 12 +- tests/deno-kv-partition.test.ts | 44 +- tests/node.test.ts | 4 +- tests/provider.test.ts | 16 +- tests/s3.test.ts | 161 ++-- tests/sqlite.test.ts | 8 +- 41 files changed, 4018 insertions(+), 2505 deletions(-) create mode 100644 bench/filesystem-provider.bench.ts create mode 100644 src/driver/azure.ts create mode 100644 src/driver/bun.ts create mode 100644 src/driver/cache.ts create mode 100644 src/driver/deno-kv.ts create mode 100644 src/driver/deno.ts create mode 100644 src/driver/indexeddb.ts rename src/{adapter => driver}/local.ts (97%) create mode 100644 src/driver/localstorage.ts create mode 100644 src/driver/node.ts create mode 100644 src/driver/opfs.ts create mode 100644 src/driver/s3.ts create mode 100644 src/driver/sqlite.ts 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(); + 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 IndexedDbBackend implements RecordBackendType { + /** IndexedDB database borrowed or owned according to driver 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: IndexedDbDriverOptionsType) { + 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 an independently useful IndexedDB record driver. */ +export function createIndexedDbDriver( + database: IDBDatabase, + options: IndexedDbDriverOptionsType = {}, +): RecordDriverType { + const backend = new IndexedDbBackend(database, options); + return defineRecordDriver(backend, { + name: "indexeddb", + capabilities: { + replacement: "atomic", + transactions: true, + binary: false, + }, + requirements: [{ code: "indexeddb", state: "available" }], + optimizations: [], + readOnly: options.readOnly ?? false, + disposeBackend: options.disposeDatabase ?? false, + }); +} + +/** Opens and owns an IndexedDB database prepared for OPFS records. */ +export async function openIndexedDbDriver(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 createIndexedDbDriver(database, { + store: storeName, + parentIndex, + disposeDatabase: true, + ...(options.readOnly === undefined ? {} : { readOnly: options.readOnly }), + }); +} diff --git a/src/adapter/local.ts b/src/driver/local.ts similarity index 97% rename from src/adapter/local.ts rename to src/driver/local.ts index bd1b498..6e3146d 100644 --- a/src/adapter/local.ts +++ b/src/driver/local.ts @@ -1,4 +1,4 @@ -import { SEPARATOR, resolve } from "@std/path"; +import { resolve, SEPARATOR } from "@std/path"; import { normalizePath } from "../path.ts"; /** @@ -41,7 +41,7 @@ class LocalPath { } /** - * Creates the host-path mapper shared by the Deno, Node, and Bun adapters. + * Creates the host-path mapper shared by the Deno, Node, and Bun file drivers. * * `@std/path` selects the current operating-system path rules. The mapper then * applies the OPFS virtual-path invariant on every conversion, so a virtual diff --git a/src/driver/localstorage.ts b/src/driver/localstorage.ts new file mode 100644 index 0000000..780a862 --- /dev/null +++ b/src/driver/localstorage.ts @@ -0,0 +1,97 @@ +import { normalizePath, type PathType, splitPath } from "../path.ts"; +import { RecordSchema, type RecordType } from "../schema.ts"; +import { defineRecordDriver, type RecordBackendType, type RecordDriverType } from "./record.ts"; + +/** Minimal synchronous Web Storage contract used by the driver. */ +export interface LocalStorageType { + readonly length: number; + key(index: number): string | null; + getItem(key: string): string | null; + setItem(key: string, value: string): void; + removeItem(key: string): void; +} + +/** Options for the localStorage record driver. */ +export interface LocalStorageDriverOptionsType { + /** Key prefix reserved for filesystem records. Defaults to `opfs`. */ + readonly prefix?: string; + /** Prevents mutations at the driver layer. */ + 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 driver-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 { + return null; + } +} + +/** Complete-record projection over one synchronous Web Storage area. */ +class LocalStorageBackend implements RecordBackendType { + readonly capabilities = { replacement: "atomic", binary: false, transactions: false } as const; + readonly #storage: LocalStorageType; + readonly #prefix: string; + + constructor(storage: LocalStorageType, options: LocalStorageDriverOptionsType) { + this.#storage = storage; + this.#prefix = (options.prefix ?? "opfs").replace(/:+$/g, "") || "opfs"; + } + + async get(path: PathType): Promise { + const value = this.#storage.getItem(getKey(this.#prefix, path)); + return value === null ? null : RecordSchema.parse(JSON.parse(value)); + } + + async set(record: RecordType): Promise { + this.#storage.setItem(getKey(this.#prefix, record.path), JSON.stringify(record)); + } + + async delete(path: PathType): Promise { + this.#storage.removeItem(getKey(this.#prefix, path)); + } + + async *list(parent: PathType): AsyncIterableIterator { + 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 a localStorage/Web Storage record driver. */ +export function createLocalStorageDriver( + storage: LocalStorageType, + options: LocalStorageDriverOptionsType = {}, +): RecordDriverType { + return defineRecordDriver(new LocalStorageBackend(storage, options), { + name: "localstorage", + ownership: "borrowed", + requirements: [{ code: "web-storage", state: "available" }], + limits: [{ + code: "quota-bytes", + kind: "dynamic", + source: "probe", + unit: "bytes", + detail: "Web Storage quota depends on the browser, origin, and storage policy.", + }], + optimizations: [], + capabilities: { replacement: "atomic", binary: false, transactions: false }, + readOnly: options.readOnly ?? false, + }); +} diff --git a/src/driver/node.ts b/src/driver/node.ts new file mode 100644 index 0000000..ea125cd --- /dev/null +++ b/src/driver/node.ts @@ -0,0 +1,436 @@ +import type { FileHandle as NodeFileHandle } from "node:fs/promises"; +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"; + +/** 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 the Node filesystem driver. */ +export interface NodeDriverOptionsType { + /** Host directory exposed as virtual `/`. */ + readonly root: string; + /** Creates the host root during driver 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: FileDriverWriteOptionsType, +): 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 FileDriverWritableFileType { + /** 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(); + } +} + +/** + * 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 FileDriverSyncFileType { + /** 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 file-driver contract. + * + * Runtime-specific modules are resolved through `process.getBuiltinModule()` in + * the constructor. The package root and unrelated runtime subpaths therefore do not + * load Node built-ins merely because this source exists in the package. + */ +class NodeBackend implements FileBackendType { + /** Stable driver 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, + copy: true, + move: 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: NodeDriverOptionsType) { + 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: FileDriverSignalOptionsType = {}): 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: FileDriverReadOptionsType = {}): 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: FileDriverReadOptionsType = {}): 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: FileDriverWriteOptionsType): 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: FileDriverWriteOptionsType, + ): 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: FileDriverSignalOptionsType = {}, + ): 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: FileDriverSignalOptionsType = {}): 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: FileDriverSignalOptionsType = {}): 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: FileDriverCopyOptionsType): Promise { + throwIfAborted(options.signal, "copy", source); + await this.#fsp.copyFile(this.#hostPath(source), this.#hostPath(destination)); + } + + /** Uses native rename for the driver's move capability. */ + async move(source: PathType, destination: PathType, options: FileDriverMoveOptionsType): 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 a file driver over Node's native filesystem APIs. + * + * The driver maps virtual `/` to `root` and never exposes host paths through + * the public facade. Importing the root OPFS package does not import this + * driver; Node-specific behavior remains on the explicit `driver/node` + * subpath. + * + * @example Use OPFS-shaped handles over a host directory. + * ```ts + * const driver = createNodeDriver({ root: "./data" }); + * await fs.writeFile("/state.json", "{}", { parents: true }); + * ``` + */ +export function createNodeDriver(options: NodeDriverOptionsType): FileDriverType { + const backend = new NodeBackend(options); + return defineFileDriver(backend, { + name: "node", + requirements: [{ code: "node-filesystem", state: "available" }], + limits: [], + optimizations: [], + }); +} diff --git a/src/driver/opfs.ts b/src/driver/opfs.ts new file mode 100644 index 0000000..64bea3d --- /dev/null +++ b/src/driver/opfs.ts @@ -0,0 +1,354 @@ +import type { FileBackendType, FileDriverType } from "./file.ts"; +import { defineFileDriver } from "./file.ts"; +import type { + FileDriverDirectoryEntryType, + FileDriverReadOptionsType, + FileDriverSignalOptionsType, + FileDriverStatType, + FileDriverSyncFileType, + FileDriverWritableFileType, + FileDriverWriteOptionsType, +} from "./file.ts"; +import { FileSystemError, throwIfAborted, toFileSystemError } from "../error.ts"; +import { basename, dirname, type PathType, ROOT_PATH, splitPath } from "../path.ts"; +import { toByteStream } from "../stream.ts"; + +/** Minimal file handle contract required from browser OPFS. */ +interface NativeFileHandleType { + /** Native File System API discriminator. */ + readonly kind: "file"; + /** Native direct-entry name. */ + readonly name: string; + /** Returns the browser's immutable file snapshot. */ + getFile(): Promise; + /** Opens the browser's staged writable stream. */ + createWritable(options?: { keepExistingData?: boolean }): Promise; + /** Opens worker-only synchronous access when this realm exposes it. */ + createSyncAccessHandle?: () => Promise; +} + +/** Minimal directory handle contract required from browser OPFS. */ +interface NativeDirectoryHandleType { + /** Native File System API discriminator. */ + readonly kind: "directory"; + /** Native direct-entry name. */ + readonly name: string; + /** Opens or creates one direct child file. */ + getFileHandle(name: string, options?: { create?: boolean }): Promise; + /** Opens or creates one direct child directory. */ + getDirectoryHandle(name: string, options?: { create?: boolean }): Promise; + /** Removes one direct child using browser-native filesystem semantics. */ + removeEntry(name: string, options?: { recursive?: boolean }): Promise; + /** Lazily iterates native direct-child handles. */ + entries(): AsyncIterableIterator<[string, NativeFileHandleType | NativeDirectoryHandleType]>; +} + +/** Native OPFS file driver with the root retained for advanced browser interop. */ +export interface OpfsDriverType extends FileDriverType { + readonly nativeRoot: FileSystemDirectoryHandle; +} + +/** Resolves a canonical virtual directory path one native handle at a time. */ +async function getDirectory(root: NativeDirectoryHandleType, path: string): Promise { + let current = root; + for (const part of splitPath(path)) current = await current.getDirectoryHandle(part); + return current; +} + +/** Resolves a file through its parent directory and optionally creates the final entry. */ +async function getFile(root: NativeDirectoryHandleType, path: string, create = false): Promise { + const parent = await getDirectory(root, dirname(path)); + return await parent.getFileHandle(basename(path), { create }); +} + +/** Slices a file snapshot before streaming so a range never exposes unrelated bytes. */ +function getStream(file: File, options: FileDriverReadOptionsType): ReadableStream { + const at = options.at ?? 0; + const end = options.length === undefined ? file.size : Math.min(file.size, at + options.length); + return file.slice(at, end).stream() as ReadableStream; +} + +/** + * Determines entry kind without creating anything. + * + * Browser OPFS has separate file and directory lookup methods. A type mismatch + * from the first lookup is therefore a normal branch. The second lookup must + * run before the driver can classify the path as absent. + */ +async function getStat(root: NativeDirectoryHandleType, path: string): Promise { + if (path === ROOT_PATH) return { kind: "directory" }; + try { + const handle = await getFile(root, path); + const file = await handle.getFile(); + return { kind: "file", size: file.size, lastModified: file.lastModified, mediaType: file.type }; + } catch (error) { + const mapped = toFileSystemError(error, "stat", path); + if (mapped.code !== "not-found" && mapped.code !== "type-mismatch") throw mapped; + } + + try { + await getDirectory(root, path); + return { kind: "directory" }; + } catch (error) { + const mapped = toFileSystemError(error, "stat", path); + if (mapped.code === "not-found" || mapped.code === "type-mismatch") return null; + throw mapped; + } +} + +/** + * Streams bytes into one native OPFS staged writable. + * + * `createWritable()` commits when it closes. If source reading, cancellation, + * or writing fails, the producer is cancelled and the native writable is + * aborted so the partially staged image does not become the visible file. + */ +async function writeToNative( + handle: NativeFileHandleType, + source: ReadableStream, + options: FileDriverWriteOptionsType, + path: string, +): Promise { + const writable = await handle.createWritable({ keepExistingData: options.mode !== "replace" }); + let cursor = 0; + try { + if (options.mode === "append") { + cursor = (await handle.getFile()).size; + await writable.seek(cursor); + } else if (options.mode === "update") { + cursor = options.at ?? 0; + await writable.seek(cursor); + } + + const reader = source.getReader(); + try { + while (true) { + throwIfAborted(options.signal, "write", path); + const next = await reader.read(); + if (next.done) break; + await writable.write(next.value as BufferSource); + cursor += next.value.byteLength; + } + } catch (error) { + try { + await reader.cancel(error); + } catch { + // Preserve the first write or cancellation failure. + } + throw error; + } finally { + reader.releaseLock(); + } + + if (options.truncate) await writable.truncate(cursor); + await writable.close(); + } catch (error) { + try { + await writable.abort(error); + } catch { + // The first write failure is more useful if abort also fails. + } + throw error; + } +} + +/** Returns whether this realm exposes the worker-only sync access method. */ +function supportsSyncAccessHandle(): boolean { + const constructor = Reflect.get(globalThis, "FileSystemFileHandle"); + if (typeof constructor !== "function") return false; + const prototype = Reflect.get(constructor, "prototype"); + return typeof prototype === "object" && + prototype !== null && + typeof Reflect.get(prototype, "createSyncAccessHandle") === "function"; +} + +/** Long-lived native OPFS positional writable with explicit close/abort state. */ +class OpfsWritableFile implements FileDriverWritableFileType { + /** Canonical path used in post-close diagnostics. */ + readonly #path: PathType; + /** Native staged writable owned until close or abort. */ + readonly #writable: FileSystemWritableFileStream; + /** Prevents writes after terminal resource settlement. */ + #closed = false; + + /** Takes ownership of one native staged writable for a canonical path. */ + constructor(path: PathType, writable: FileSystemWritableFileStream) { + this.#path = path; + this.#writable = writable; + } + + /** Returns the live writable or rejects operations after settlement. */ + #getWritable(): FileSystemWritableFileStream { + if (this.#closed) throw new Error(`Writable file '${this.#path}' is closed.`); + return this.#writable; + } + + /** Writes one byte view at its explicit file position. */ + async write(buffer: ArrayBufferView, options: { readonly at: number }): Promise { + const view = new Uint8Array(buffer.buffer, buffer.byteOffset, buffer.byteLength); + const data = buffer.buffer instanceof ArrayBuffer + ? new Uint8Array(buffer.buffer, buffer.byteOffset, buffer.byteLength) + : Uint8Array.from(view); + await this.#getWritable().write({ type: "write", position: options.at, data }); + } + + /** Changes the staged file size. */ + async truncate(size: number): Promise { + await this.#getWritable().truncate(size); + } + + /** Verifies that the resource is still live; OPFS has no separate flush primitive. */ + async flush(): Promise { + this.#getWritable(); + } + + /** Commits the staged image and closes the native writable exactly once. */ + async close(): Promise { + if (this.#closed) return; + this.#closed = true; + await this.#writable.close(); + } + + /** Discards the staged image when possible and closes exactly once. */ + async abort(reason?: unknown): Promise { + if (this.#closed) return; + this.#closed = true; + await this.#writable.abort(reason); + } +} + +/** Native browser OPFS implementation of the portable file-driver contract. */ +class OpfsBackend implements FileBackendType { + /** Stable driver identity used in diagnostics. */ + readonly name = "opfs"; + /** Native origin-private root retained for advanced browser interop. */ + readonly nativeRoot: FileSystemDirectoryHandle; + /** Native operations exposed without facade emulation. */ + readonly capabilities; + /** Narrow native root shape used by internal traversal helpers. */ + readonly #root: NativeDirectoryHandleType; + + /** Borrows the native root and probes only actual API exposure in this realm. */ + constructor(root: FileSystemDirectoryHandle) { + this.nativeRoot = root; + this.#root = root as unknown as NativeDirectoryHandleType; + this.capabilities = { + read: true, + write: true, + streamRead: true, + streamWriteModes: ["replace", "append", "update"], + rangeRead: true, + copy: false, + move: false, + positionalWrite: true, + syncAccess: supportsSyncAccessHandle(), + } as const; + } + + /** Returns native metadata or `null` when neither file nor directory exists. */ + async stat(path: PathType, options: FileDriverSignalOptionsType = {}): Promise { + throwIfAborted(options.signal, "stat", path); + return await getStat(this.#root, path); + } + + /** Materializes the requested file snapshot or byte range. */ + async readFile(path: PathType, options: FileDriverReadOptionsType = {}): Promise { + throwIfAborted(options.signal, "read", path); + const file = await (await getFile(this.#root, path)).getFile(); + return new Uint8Array(await new Response(getStream(file, options)).arrayBuffer()); + } + + /** Streams the requested immutable snapshot or range without facade buffering. */ + async openReadStream(path: PathType, options: FileDriverReadOptionsType = {}): Promise> { + throwIfAborted(options.signal, "read", path); + return getStream(await (await getFile(this.#root, path)).getFile(), options); + } + + /** Writes one materialized buffer through OPFS commit-on-close staging. */ + async writeFile(path: PathType, data: Uint8Array, options: FileDriverWriteOptionsType): Promise { + await writeToNative(await getFile(this.#root, path, true), toByteStream(data), options, path); + } + + /** Streams bytes through the browser's native staged writable. */ + async writeStream( + path: PathType, + source: ReadableStream, + options: FileDriverWriteOptionsType, + ): Promise { + await writeToNative(await getFile(this.#root, path, true), source, options, path); + } + + /** Lazily yields direct native children while honoring cancellation between entries. */ + async *readDir( + path: PathType, + options: FileDriverSignalOptionsType = {}, + ): AsyncIterableIterator { + throwIfAborted(options.signal, "read-dir", path); + const directory = await getDirectory(this.#root, path); + for await (const [name, handle] of directory.entries()) { + throwIfAborted(options.signal, "read-dir", path); + yield { name, kind: handle.kind }; + } + } + + /** Creates one direct native directory after facade parent resolution. */ + async createDir(path: PathType, options: FileDriverSignalOptionsType = {}): Promise { + throwIfAborted(options.signal, "mkdir", path); + const parent = await getDirectory(this.#root, dirname(path)); + await parent.getDirectoryHandle(basename(path), { create: true }); + } + + /** Removes one direct child. Recursive removal is owned by the facade. */ + async remove(path: PathType, options: FileDriverSignalOptionsType = {}): Promise { + throwIfAborted(options.signal, "remove", path); + const parent = await getDirectory(this.#root, dirname(path)); + await parent.removeEntry(basename(path)); + } + + /** Opens one staged positional writable and transfers its lifetime to the wrapper. */ + async openWritableFile(path: PathType): Promise { + const handle = await getFile(this.#root, path, true); + const writable = await handle.createWritable({ keepExistingData: true }); + return new OpfsWritableFile(path, writable); + } + + /** Opens worker-only synchronous access when the native handle exposes it. */ + async openSyncFile(path: PathType): Promise { + const handle = await getFile(this.#root, path); + if (handle.createSyncAccessHandle === undefined) { + throw new FileSystemError( + "not-supported", + "open-sync-file", + path, + "This browser context does not expose createSyncAccessHandle().", + ); + } + return await handle.createSyncAccessHandle(); + } +} + +/** + * Creates a native browser OPFS driver over an already acquired root. + * + * The driver borrows the browser root. Browser storage has no root-close + * operation, so driver disposal never closes the origin-private filesystem. + */ +export function createOpfsDriver(root: FileSystemDirectoryHandle): OpfsDriverType { + const backend = new OpfsBackend(root); + return { + ...defineFileDriver(backend, { + name: "opfs", + ownership: "borrowed", + requirements: [{ code: "opfs-root", state: "available" }], + limits: [{ + code: "quota-bytes", + kind: "dynamic", + source: "probe", + unit: "bytes", + detail: "Current origin quota is runtime-dependent and must be probed.", + }], + optimizations: [], + }), + nativeRoot: root, + }; +} diff --git a/src/driver/s3.ts b/src/driver/s3.ts new file mode 100644 index 0000000..288f3fe --- /dev/null +++ b/src/driver/s3.ts @@ -0,0 +1,88 @@ +import { createS3Client, type S3ClientOptionsType, type S3ClientType } from "../s3.ts"; +import type { DriverOwnershipType } from "../schema.ts"; +import { defineObjectDriver, type ObjectDriverType } from "./object.ts"; + +/** Options for the configured S3 object driver. */ +export interface S3DriverOptionsType extends S3ClientOptionsType {} + +/** Adds backend-driver metadata to one configured S3 protocol client. */ +function createDriver(client: S3ClientType, ownership: DriverOwnershipType): ObjectDriverType { + const limits = client.limits ?? {}; + return defineObjectDriver(client, { + name: client.name, + ownership, + requirements: [{ code: "s3-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.minPartBytes === undefined ? [] : [{ + code: "part-min-bytes", + kind: "hard" as const, + source: "provider" as const, + unit: "bytes" as const, + value: limits.minPartBytes, + }]), + ...(limits.maxPartBytes === undefined ? [] : [{ + code: "part-max-bytes", + kind: "hard" as const, + source: "provider" as const, + unit: "bytes" as const, + value: limits.maxPartBytes, + }]), + ...(limits.maxParts === undefined ? [] : [{ + code: "parts", + 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: "delayed-multipart", + enabled: client.optimizations.delayedMultipart, + changesBehavior: true, + disableable: true, + detail: "Buffers the first part so small unknown-length streams can use one PutObject request.", + }, + { + code: "signing-key-cache", + enabled: client.optimizations.signingKeyCache, + changesBehavior: false, + disableable: true, + detail: "Caches credential/date/region/service-derived SigV4 signing material within the client.", + }, + ], + }); +} + +/** + * Creates an S3 backend driver from direct protocol-client options. + * + * The client remains independently usable for protocol-specific operations. + * The driver adds storage requirements, limit provenance, and optimization + * metadata consumed by adapters and filesystem inspection. + */ +export function createS3Driver(options: S3DriverOptionsType): ObjectDriverType { + return createDriver(createS3Client(options), "owned"); +} + +/** Attaches driver metadata to an already configured S3 client. */ +export function createS3DriverFromClient(client: S3ClientType): ObjectDriverType { + return createDriver(client, "borrowed"); +} diff --git a/src/driver/sqlite.ts b/src/driver/sqlite.ts new file mode 100644 index 0000000..0a58ec0 --- /dev/null +++ b/src/driver/sqlite.ts @@ -0,0 +1,95 @@ +import { createDb0Driver, type Db0PrimitiveType, type Db0StatementType } from "./db0.ts"; +import type { RecordDriverType } from "./record.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 record driver. */ +export interface SqliteDatabaseType { + /** Compiles one SQL statement. */ + prepare(sql: string): SqliteStatementType; + /** Closes the database when ownership is transferred. */ + close?(): void | Promise; +} + +/** Direct SQLite record-driver options. */ +export interface SqliteDriverOptionsType { + /** Driver-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 driver. */ + 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); + } + + /** 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 the shared SQL record driver. */ + 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?.(); + } +} + +/** Creates a record driver from a connected SQLite database. */ +export async function createSqliteDriver( + database: SqliteDatabaseType, + options: SqliteDriverOptionsType = {}, +): Promise { + return await createDb0Driver(new SqliteDatabase(database, options.disposeDatabase ?? false), { + ...(options.table === undefined ? {} : { table: options.table }), + ...(options.initialize === undefined ? {} : { initialize: options.initialize }), + disposeDatabase: options.disposeDatabase ?? false, + }); +} diff --git a/src/s3.ts b/src/s3.ts index 181353e..1ff357f 100644 --- a/src/s3.ts +++ b/src/s3.ts @@ -5,22 +5,16 @@ import { z } from "zod"; 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 { - createXmlElement, - createXmlText, - getXmlElements, - getXmlValue, - parseXmlRoot, - stringifyXml, -} from "./xml.ts"; +import { type AdapterLimitsType, MetricsModeSchema, type MetricsModeType } from "./schema.ts"; +import { createXmlElement, createXmlText, getXmlElements, getXmlValue, parseXmlRoot, stringifyXml } from "./xml.ts"; import type { + ObjectBackendType, ObjectCopyOptionsType, ObjectEntryType, ObjectGetOptionsType, @@ -28,8 +22,7 @@ import type { ObjectListType, ObjectPutOptionsType, ObjectStatType, - ObjectStoreType, -} from "./adapter/object.ts"; +} from "./driver/object.ts"; /** S3 URL addressing shape used when constructing signed request URLs. */ export const S3AddressingSchema = z.enum(["path", "virtual"]); @@ -110,6 +103,10 @@ export interface S3ClientOptionsType { readonly abortTimeoutMs?: number; /** Retry/backoff and optional per-attempt timeout policy. */ readonly request?: RequestPolicyType; + /** Delays multipart creation until the stream proves it needs more than one bounded part. Defaults to true. */ + readonly delayedMultipart?: boolean; + /** Reuses the derived SigV4 signing key while credentials/date/region are unchanged. Defaults to true. */ + readonly signingKeyCache?: boolean; /** Direct-client HTTP instrumentation. Defaults to `basic`; `none` removes counter updates. */ readonly metrics?: MetricsModeType; } @@ -176,7 +173,11 @@ export class S3Error extends Error { readonly response: Response; /** Creates a provider-aware S3 failure without discarding the original response. */ - constructor(message: string, response: Response, details: { code?: string; requestId?: string; hostId?: string } = {}) { + constructor( + message: string, + response: Response, + details: { code?: string; requestId?: string; hostId?: string } = {}, + ) { super(message); this.name = "S3Error"; this.status = response.status; @@ -195,7 +196,9 @@ export class S3Error extends Error { * A caller can therefore compose custom storage-class, encryption, checksum, * object-lock, or provider-specific requests without importing the AWS SDK. */ -export interface S3ClientType extends ObjectStoreType { +export interface S3ClientType extends ObjectBackendType { + /** Resolved client optimization switches used by driver inspection. */ + readonly optimizations: Readonly<{ delayedMultipart: boolean; signingKeyCache: boolean }>; /** Returns detached direct HTTP request metrics. */ getMetrics(): RequestMetricsType; /** Sends an arbitrary bucket/object request after Signature Version 4 signing. */ @@ -254,7 +257,10 @@ function compareText(left: string, right: string): number { /** Percent-encodes one Signature Version 4 component using RFC 3986's unreserved set. */ function encode(value: string): string { - return encodeURIComponent(value).replace(/[!'()*]/g, (character) => `%${character.charCodeAt(0).toString(16).toUpperCase()}`); + return encodeURIComponent(value).replace( + /[!'()*]/g, + (character) => `%${character.charCodeAt(0).toString(16).toUpperCase()}`, + ); } /** Encodes a slash-delimited path while retaining path separators required by S3. */ @@ -316,9 +322,9 @@ async function getHmac(key: BufferSource, value: string): Promise { /** Derives the date, region, and service-specific Signature Version 4 signing key. */ async function getSigningKey(secret: string, date: string, region: string): Promise { const dateKey = await getHmac(textEncoder.encode(`AWS4${secret}`), date); - const regionKey = await getHmac(Uint8Array.from(dateKey), region); - const serviceKey = await getHmac(Uint8Array.from(regionKey), "s3"); - return await getHmac(Uint8Array.from(serviceKey), "aws4_request"); + const regionKey = await getHmac(dateKey as Uint8Array, region); + const serviceKey = await getHmac(regionKey as Uint8Array, "s3"); + return await getHmac(serviceKey as Uint8Array, "aws4_request"); } /** Formats one UTC instant as the compact timestamp required by Signature Version 4. */ @@ -453,7 +459,9 @@ function getListObject(content: Parameters[0]): ObjectEntryT /** Validates and orders part references before S3 commits a multipart upload. */ function normalizeParts(parts: readonly S3PartType[]): S3PartType[] { if (parts.length === 0) throw new RangeError("CompleteMultipartUpload requires at least one part."); - if (parts.length > S3_LIMITS.maxParts) throw new RangeError(`S3 permits at most ${S3_LIMITS.maxParts} multipart parts.`); + if (parts.length > S3_LIMITS.maxParts) { + throw new RangeError(`S3 permits at most ${S3_LIMITS.maxParts} multipart parts.`); + } const sorted = [...parts].sort((left, right) => left.number - right.number); let previous = 0; @@ -514,6 +522,10 @@ class S3Client implements S3ClientType { readonly #metricsMode: MetricsModeType; /** Mutable direct HTTP counters when instrumentation is enabled. */ readonly #metrics: RequestMetrics | undefined; + /** Resolved client optimization switches. */ + readonly optimizations: Readonly<{ delayedMultipart: boolean; signingKeyCache: boolean }>; + /** One-entry derived SigV4 key cache. The raw secret is retained only as long as the client already retains credentials. */ + #signingKey: { secret: string; date: string; region: string; key: Uint8Array } | undefined; /** Validates configuration and creates one import-safe S3 client. */ constructor(options: S3ClientOptionsType) { @@ -532,14 +544,28 @@ class S3Client implements S3ClientType { 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({ + delayedMultipart: options.delayedMultipart ?? true, + signingKeyCache: options.signingKeyCache ?? true, + }); if (this.#bucket.length === 0) throw new TypeError("S3 bucket cannot be empty."); if (this.#region.length === 0) throw new TypeError("S3 region cannot be empty."); - if (!Number.isSafeInteger(this.#partSize) || this.#partSize < S3_LIMITS.minPartBytes || this.#partSize > S3_LIMITS.maxPartBytes) { - throw new RangeError(`S3 partSize must be between ${S3_LIMITS.minPartBytes} and ${S3_LIMITS.maxPartBytes} bytes.`); + if ( + !Number.isSafeInteger(this.#partSize) || this.#partSize < S3_LIMITS.minPartBytes || + this.#partSize > S3_LIMITS.maxPartBytes + ) { + throw new RangeError( + `S3 partSize must be between ${S3_LIMITS.minPartBytes} and ${S3_LIMITS.maxPartBytes} bytes.`, + ); } - if (!Number.isSafeInteger(this.#copyPartSize) || this.#copyPartSize < S3_LIMITS.minPartBytes || this.#copyPartSize > S3_LIMITS.maxPartBytes) { - throw new RangeError(`S3 copyPartSize must be between ${S3_LIMITS.minPartBytes} and ${S3_LIMITS.maxPartBytes} bytes.`); + if ( + !Number.isSafeInteger(this.#copyPartSize) || this.#copyPartSize < S3_LIMITS.minPartBytes || + this.#copyPartSize > S3_LIMITS.maxPartBytes + ) { + throw new RangeError( + `S3 copyPartSize must be between ${S3_LIMITS.minPartBytes} and ${S3_LIMITS.maxPartBytes} bytes.`, + ); } if (!Number.isSafeInteger(this.#concurrency) || this.#concurrency < 1) { throw new RangeError("S3 concurrency must be a positive integer."); @@ -554,6 +580,9 @@ class S3Client implements S3ClientType { streamWrite: true, copy: options.copy ?? true, conditionalWrite: options.conditionalWrite ?? true, + multipart: true, + metadata: true, + versions: false, } as const; this.limits = { maxFileBytes: S3_LIMITS.maxObjectBytes, @@ -564,6 +593,19 @@ class S3Client implements S3ClientType { }; } + /** Returns the derived SigV4 key, reusing one safe per-client cache entry when enabled. */ + async #getSigningKey(secret: string, date: string): Promise { + if (this.optimizations.signingKeyCache) { + const cached = this.#signingKey; + if (cached !== undefined && cached.secret === secret && cached.date === date && cached.region === this.#region) { + return cached.key; + } + } + const key = await getSigningKey(secret, date, this.#region); + if (this.optimizations.signingKeyCache) this.#signingKey = { secret, date, region: this.#region, key }; + return key; + } + /** Builds the request URL and canonical URI for one bucket/object address. */ #address(key: string | undefined): { url: URL; canonicalUri: string } { const endpointPath = this.#endpoint.pathname.replace(/\/$/, ""); @@ -584,7 +626,9 @@ class S3Client implements S3ClientType { } const required = Math.ceil(expectedSize / S3_LIMITS.maxParts); const size = Math.max(this.#partSize, required); - if (size > S3_LIMITS.maxPartBytes) throw new RangeError(`S3 object requires multipart parts larger than ${S3_LIMITS.maxPartBytes} bytes.`); + if (size > S3_LIMITS.maxPartBytes) { + throw new RangeError(`S3 object requires multipart parts larger than ${S3_LIMITS.maxPartBytes} bytes.`); + } return size; } @@ -592,9 +636,15 @@ class S3Client implements S3ClientType { #getCopyHeaders(source: string, options: ObjectCopyOptionsType): Headers { const headers = new Headers({ "x-amz-copy-source": `/${encode(this.#bucket)}/${encodePath(source)}` }); if (options.sourceIfMatch !== undefined) headers.set("x-amz-copy-source-if-match", options.sourceIfMatch); - if (options.sourceIfNoneMatch !== undefined) headers.set("x-amz-copy-source-if-none-match", options.sourceIfNoneMatch); - if (options.sourceIfModifiedSince !== undefined) headers.set("x-amz-copy-source-if-modified-since", options.sourceIfModifiedSince.toUTCString()); - if (options.sourceIfUnmodifiedSince !== undefined) headers.set("x-amz-copy-source-if-unmodified-since", options.sourceIfUnmodifiedSince.toUTCString()); + if (options.sourceIfNoneMatch !== undefined) { + headers.set("x-amz-copy-source-if-none-match", options.sourceIfNoneMatch); + } + if (options.sourceIfModifiedSince !== undefined) { + headers.set("x-amz-copy-source-if-modified-since", options.sourceIfModifiedSince.toUTCString()); + } + if (options.sourceIfUnmodifiedSince !== undefined) { + headers.set("x-amz-copy-source-if-unmodified-since", options.sourceIfUnmodifiedSince.toUTCString()); + } if (options.ifMatch !== undefined) headers.set("if-match", options.ifMatch); if (options.ifNoneMatch !== undefined) headers.set("if-none-match", options.ifNoneMatch); return headers; @@ -603,7 +653,9 @@ class S3Client implements S3ClientType { /** Writes one materialized object with PutObject and optional write preconditions. */ async #putBytes(key: string, body: Uint8Array, options: ObjectPutOptionsType): Promise { if (body.byteLength > S3_LIMITS.maxPutBytes) { - throw new RangeError(`S3 PutObject accepts at most ${S3_LIMITS.maxPutBytes} bytes. Use a stream for multipart upload.`); + throw new RangeError( + `S3 PutObject accepts at most ${S3_LIMITS.maxPutBytes} bytes. Use a stream for multipart upload.`, + ); } const headers = new Headers(); if (options.mediaType !== undefined) headers.set("content-type", options.mediaType); @@ -612,7 +664,13 @@ class S3Client implements S3ClientType { for (const [name, value] of Object.entries(options.metadata ?? {})) headers.set(`x-amz-meta-${name}`, value); await assertResponse( - await this.request({ method: "PUT", key, headers, body: Uint8Array.from(body), ...(options.signal === undefined ? {} : { signal: options.signal }) }), + await this.request({ + method: "PUT", + key, + headers, + body: body as Uint8Array, + ...(options.signal === undefined ? {} : { signal: options.signal }), + }), `PutObject ${key}`, ); return (await this.head(key, options)) ?? { size: body.byteLength }; @@ -642,7 +700,9 @@ class S3Client implements S3ClientType { `UploadPartCopy ${source}[${range.start}-${range.end}] -> ${upload.key}#${range.number}`, ); const etag = root === undefined ? undefined : getXmlValue(root, "ETag"); - if (etag === undefined) throw new S3Error(`UploadPartCopy ${range.number} response did not contain ETag.`, response); + if (etag === undefined) { + throw new S3Error(`UploadPartCopy ${range.number} response did not contain ETag.`, response); + } return { number: range.number, etag }; } @@ -699,7 +759,9 @@ class S3Client implements S3ClientType { const signingHeaders = new Headers(headers); signingHeaders.set("host", url.host); const signedNames = Array.from(signingHeaders.keys()).map((name) => name.toLowerCase()).sort(compareText); - const canonicalHeaders = signedNames.map((name) => `${name}:${getHeaderValue(signingHeaders.get(name) ?? "")}`).join("\n") + "\n"; + const canonicalHeaders = signedNames.map((name) => + `${name}:${getHeaderValue(signingHeaders.get(name) ?? "")}` + ).join("\n") + "\n"; const signedHeaders = signedNames.join(";"); const canonicalRequest = [ options.method.toUpperCase(), @@ -711,8 +773,8 @@ class S3Client implements S3ClientType { ].join("\n"); const scope = `${shortDate}/${this.#region}/s3/aws4_request`; const stringToSign = `AWS4-HMAC-SHA256\n${timestamp}\n${scope}\n${await getSha256(canonicalRequest)}`; - const signingKey = await getSigningKey(credentials.secretAccessKey, shortDate, this.#region); - const signature = encodeHex(await getHmac(Uint8Array.from(signingKey), stringToSign)).toLowerCase(); + const signingKey = await this.#getSigningKey(credentials.secretAccessKey, shortDate); + const signature = encodeHex(await getHmac(signingKey as Uint8Array, stringToSign)).toLowerCase(); headers.set( "authorization", `AWS4-HMAC-SHA256 Credential=${credentials.accessKeyId}/${scope}, SignedHeaders=${signedHeaders}, Signature=${signature}`, @@ -727,8 +789,7 @@ class S3Client implements S3ClientType { }; if (options.body instanceof ReadableStream) init.duplex = "half"; try { - const request = new Request(url, init); - return await this.#fetch(request, init); + return await this.#fetch(url, init); } catch (error) { if (options.signal?.aborted) throw error; throw new RequestTransportError(error); @@ -743,7 +804,11 @@ class S3Client implements S3ClientType { /** Returns exact-object metadata, or `null` when the object does not exist. */ 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, `HeadObject ${key}`); return getStat(response.headers); @@ -758,7 +823,12 @@ class S3Client implements S3ClientType { headers.set("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 }), + }), `GetObject ${key}`, ); return response.body ?? new Blob().stream(); @@ -770,7 +840,14 @@ class S3Client implements S3ClientType { if (options.mediaType !== undefined) headers.set("content-type", options.mediaType); for (const [name, value] of Object.entries(options.metadata ?? {})) headers.set(`x-amz-meta-${name}`, value); const response = await assertResponse( - await this.request({ method: "POST", key, query: { uploads: "" }, headers, retry: false, ...(options.signal === undefined ? {} : { signal: options.signal }) }), + await this.request({ + method: "POST", + key, + query: { uploads: "" }, + headers, + retry: false, + ...(options.signal === undefined ? {} : { signal: options.signal }), + }), `CreateMultipartUpload ${key}`, ); const id = getXmlValue(parseXmlRoot(await response.text()), "UploadId"); @@ -791,7 +868,7 @@ class S3Client implements S3ClientType { method: "PUT", key: upload.key, query: { partNumber: String(number), uploadId: upload.id }, - body: Uint8Array.from(bytes), + body: bytes as Uint8Array, ...(signal === undefined ? {} : { signal }), }), `UploadPart ${upload.key}#${number}`, @@ -802,7 +879,11 @@ class S3Client implements S3ClientType { } /** Commits uploaded parts after validating part identity and ordering. */ - async completeUpload(upload: S3UploadType, parts: readonly S3PartType[], options: S3CompleteOptionsType = {}): Promise { + async completeUpload( + upload: S3UploadType, + parts: readonly S3PartType[], + options: S3CompleteOptionsType = {}, + ): Promise { const normalized = normalizeParts(parts); const headers = new Headers({ "content-type": "application/xml" }); if (options.ifMatch !== undefined) headers.set("if-match", options.ifMatch); @@ -846,10 +927,43 @@ class S3Client implements S3ClientType { * the client abort the multipart upload, which prevents a late part from * arriving after the abort request. */ - async put(key: string, body: Uint8Array | ReadableStream, options: ObjectPutOptionsType = {}): Promise { + async put( + key: string, + body: Uint8Array | ReadableStream, + options: ObjectPutOptionsType = {}, + ): Promise { if (body instanceof Uint8Array) return await this.#putBytes(key, body, options); const partSize = this.#getPartSize(options.size); + let chunks = getChunks(body, partSize); + + if (this.optimizations.delayedMultipart) { + const first = await chunks.next(); + if (first.done) return await this.#putBytes(key, new Uint8Array(), options); + + const second = await chunks.next(); + if (second.done && first.value.bytes.byteLength <= S3_LIMITS.maxPutBytes) { + if (options.size !== undefined && first.value.bytes.byteLength !== options.size) { + throw new RangeError( + `S3 streamed body produced ${first.value.bytes.byteLength} bytes but options.size declared ${options.size}.`, + ); + } + return await this.#putBytes(key, first.value.bytes, options); + } + + // Preserve the already-consumed chunks before replacing the iterator. + // A single chunk larger than PutObject's hard limit still enters multipart + // instead of failing only because delayed multipart is enabled. + const rest = chunks; + async function* retained(): AsyncGenerator { + yield first.value; + if (!second.done) yield second.value; + for await (const chunk of rest) yield chunk; + } + + chunks = retained(); + } + const upload = await this.createUpload(key, options); let size = 0; const parts: S3PartType[] = []; @@ -857,7 +971,7 @@ class S3Client implements S3ClientType { try { const uploaded = pooledMap( this.#concurrency, - getChunks(body, partSize), + chunks, (chunk) => this.#uploadChunk(upload, chunk, options.signal), ); for await (const result of uploaded) { @@ -891,7 +1005,11 @@ class S3Client implements S3ClientType { /** Removes one exact object. A missing object is treated as already removed. */ 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, `DeleteObject ${key}`); } @@ -931,8 +1049,12 @@ class S3Client implements S3ClientType { */ async copy(source: string, destination: string, options: ObjectCopyOptionsType = {}): Promise { const sourceStat = await this.head(source, options); - if (sourceStat === null) throw new S3Error(`Copy source '${source}' does not exist.`, new Response(null, { status: 404 })); - if (sourceStat.size > S3_LIMITS.maxObjectBytes) throw new RangeError(`S3 copy source exceeds ${S3_LIMITS.maxObjectBytes} bytes.`); + if (sourceStat === null) { + throw new S3Error(`Copy source '${source}' does not exist.`, new Response(null, { status: 404 })); + } + if (sourceStat.size > S3_LIMITS.maxObjectBytes) { + throw new RangeError(`S3 copy source exceeds ${S3_LIMITS.maxObjectBytes} bytes.`); + } if (sourceStat.size <= S3_LIMITS.maxCopyBytes) { const response = await this.request({ diff --git a/tests/azure.test.ts b/tests/azure.test.ts index 774ddf6..20a2bbd 100644 --- a/tests/azure.test.ts +++ b/tests/azure.test.ts @@ -8,7 +8,11 @@ import { streamBytes } from "./stream.ts"; /** Creates one Azure-style XML response without coupling tests to an HTTP server. */ function xml(value: string, init: ResponseInit = {}): Response { - return new Response(value, { status: 200, headers: { "content-type": "application/xml", ...(init.headers ?? {}) }, ...init }); + return new Response(value, { + status: 200, + headers: { "content-type": "application/xml", ...(init.headers ?? {}) }, + ...init, + }); } /** Refreshable bearer source used to prove per-request token resolution. */ @@ -47,7 +51,7 @@ describe("Azure Blob client", () => { const request = new Request(input, init); requests.push(request); if (request.method === "HEAD") { - return new Response(null, { status: 200, headers: { "content-length": "4", etag: "\"etag\"" } }); + return new Response(null, { status: 200, headers: { "content-length": "4", etag: '"etag"' } }); } return new Response(null, { status: 202, headers: { "x-ms-copy-status": "success" } }); }, @@ -92,22 +96,25 @@ describe("Azure Blob client", () => { requests.push(request); const url = new URL(request.url); if (request.method === "HEAD" && url.pathname.endsWith("/source.bin")) { - return new Response(null, { status: 200, headers: { "content-length": String(size), etag: "\"source-etag\"" } }); + return new Response(null, { + status: 200, + headers: { "content-length": String(size), etag: '"source-etag"' }, + }); } if (request.method === "HEAD") { - return new Response(null, { status: 200, headers: { "content-length": String(size), etag: "\"copy-etag\"" } }); + return new Response(null, { status: 200, headers: { "content-length": String(size), etag: '"copy-etag"' } }); } return new Response(null, { status: 201 }); }, }); - await client.copy!("source.bin", "copy.bin", { sourceIfMatch: "\"source-etag\"" }); + await client.copy!("source.bin", "copy.bin", { sourceIfMatch: '"source-etag"' }); const blocks = requests.filter((request) => new URL(request.url).searchParams.get("comp") === "block"); expect(blocks).toHaveLength(3); expect(blocks[0]?.headers.get("x-ms-source-range")).toBe(`bytes=0-${100 * 1024 * 1024 - 1}`); expect(blocks[0]?.headers.get("x-ms-copy-source-authorization")).toBe("Bearer token"); - expect(blocks[0]?.headers.get("x-ms-source-if-match")).toBe("\"source-etag\""); + expect(blocks[0]?.headers.get("x-ms-source-if-match")).toBe('"source-etag"'); expect(requests.some((request) => new URL(request.url).searchParams.get("comp") === "blocklist")).toBe(true); }); @@ -128,7 +135,8 @@ describe("Azure Blob client", () => { endpoint: "https://account.blob.core.windows.net", container: "data", credential: { kind: "sas", token: "?sig=secret" }, - fetch: async () => xml(` + fetch: async () => + xml(` @@ -157,10 +165,11 @@ describe("Azure Blob client", () => { endpoint: "https://account.blob.core.windows.net", container: "data", credential: { kind: "sas", token: "?sig=secret" }, - fetch: async () => xml("AuthorizationFailuredenied", { - status: 403, - headers: { "x-ms-request-id": "request-1" }, - }), + fetch: async () => + xml("AuthorizationFailuredenied", { + status: 403, + headers: { "x-ms-request-id": "request-1" }, + }), }); try { @@ -200,18 +209,19 @@ describe("Azure Blob client", () => { expect(request?.headers.get("content-length")).toBe("0"); }); - it("rejects Shared Key service versions older than the implemented signing format", () => { - expect(() => createAzureClient({ - endpoint: "http://127.0.0.1:10000/devstoreaccount1", - container: "opfs-test", - credential: { - kind: "shared-key", - account: "devstoreaccount1", - key: "Eby8vdM02xNOcqFlqUwJPLlmEtlCDXJ1OUzFT50uSRZ6IFsuFq2UVErCz4I6tq/K1SZFPTOtr/KBHBeksoGMGw==", - }, - version: "2009-07-17", - })).toThrow(RangeError); + expect(() => + createAzureClient({ + endpoint: "http://127.0.0.1:10000/devstoreaccount1", + container: "opfs-test", + credential: { + kind: "shared-key", + account: "devstoreaccount1", + key: "Eby8vdM02xNOcqFlqUwJPLlmEtlCDXJ1OUzFT50uSRZ6IFsuFq2UVErCz4I6tq/K1SZFPTOtr/KBHBeksoGMGw==", + }, + version: "2009-07-17", + }) + ).toThrow(RangeError); }); it("signs zero Content-Length according to the selected Shared Key service version", async () => { @@ -234,11 +244,12 @@ describe("Azure Blob client", () => { key: "zero.bin", body: new Uint8Array(), }); - await createAzureClient({ ...options, version: "2015-02-21", fetch: modernCapture.fetch.bind(modernCapture) }).request({ - method: "PUT", - key: "zero.bin", - body: new Uint8Array(), - }); + await createAzureClient({ ...options, version: "2015-02-21", fetch: modernCapture.fetch.bind(modernCapture) }) + .request({ + method: "PUT", + key: "zero.bin", + body: new Uint8Array(), + }); expect(oldCapture.latest?.headers.get("authorization")).toBe( "SharedKey devstoreaccount1:l0m1mkwouin+1Fe6pBOf3LgSCgsrZMzD4luPiqfRonQ=", @@ -286,17 +297,21 @@ describe("Azure Blob client", () => { }); expect(legacyEmpty.latest?.headers.get("authorization")).toBe(legacyAbsent.latest?.headers.get("authorization")); - expect(modernEmpty.latest?.headers.get("authorization")).not.toBe(modernAbsent.latest?.headers.get("authorization")); + expect(modernEmpty.latest?.headers.get("authorization")).not.toBe( + modernAbsent.latest?.headers.get("authorization"), + ); }); it("validates block size against the selected Azure REST service version", () => { - expect(() => createAzureClient({ - endpoint: "https://account.blob.core.windows.net", - container: "data", - credential: { kind: "sas", token: "?sig=secret" }, - version: "2015-04-05", - blockSize: AZURE_LIMITS.legacyBlockBytes + 1, - })).toThrow(RangeError); + expect(() => + createAzureClient({ + endpoint: "https://account.blob.core.windows.net", + container: "data", + credential: { kind: "sas", token: "?sig=secret" }, + version: "2015-04-05", + blockSize: AZURE_LIMITS.legacyBlockBytes + 1, + }) + ).toThrow(RangeError); }); it("keeps destination conditions off Put Block and applies them at Put Block List", async () => { @@ -310,7 +325,7 @@ describe("Azure Blob client", () => { const request = new Request(input, init); requests.push(request); if (request.method === "HEAD") { - return new Response(null, { status: 200, headers: { "content-length": "6", etag: "\"done\"" } }); + return new Response(null, { status: 200, headers: { "content-length": "6", etag: '"done"' } }); } return new Response(null, { status: 201 }); }, @@ -334,7 +349,7 @@ describe("Azure Blob client", () => { fetch: async (input, init) => { const request = new Request(input, init); if (request.method === "HEAD") { - return new Response(null, { status: 200, headers: { "content-length": "4", etag: "\"source\"" } }); + return new Response(null, { status: 200, headers: { "content-length": "4", etag: '"source"' } }); } return new Response(null, { status: 201 }); }, @@ -370,7 +385,6 @@ describe("Azure Blob client", () => { expect(first.latest?.headers.get("authorization")).toBe(second.latest?.headers.get("authorization")); }); - it("collapses unquoted Shared Key whitespace without changing quoted-string whitespace", async () => { const now = () => new Date("2026-08-14T12:00:00.000Z"); const credential = { @@ -415,10 +429,11 @@ describe("Azure Blob client", () => { endpoint: "https://account.blob.core.windows.net", container: "data", credential: { kind: "sas", token: "?sig=secret" }, - fetch: async () => new Response("upstream gateway failed", { - status: 502, - headers: { "x-ms-request-id": "gateway-request" }, - }), + fetch: async () => + new Response("upstream gateway failed", { + status: 502, + headers: { "x-ms-request-id": "gateway-request" }, + }), }); try { @@ -525,7 +540,6 @@ describe("Azure Blob client", () => { )).rejects.toThrow(RangeError); expect(fetches).toBe(0); }); - }); describe("Azure request policy", () => { diff --git a/tests/browser/fixtures/app.ts b/tests/browser/fixtures/app.ts index f3596f3..5cd4491 100644 --- a/tests/browser/fixtures/app.ts +++ b/tests/browser/fixtures/app.ts @@ -357,7 +357,10 @@ async function benchmarkAdapter( const facadeName = `opfs-bench-facade-${id}`; const facadeCache = await caches.open(facadeName); - const fileSystem = createFileSystem(createCacheAdapter(facadeCache, { prefix: id }), { coordination: "none", metrics: "none" }); + const fileSystem = createFileSystem(createCacheAdapter(facadeCache, { prefix: id }), { + coordination: "none", + metrics: "none", + }); try { await fileSystem.ensureDir("/bench"); const facadeStart = performance.now(); diff --git a/tests/browser/fixtures/frame.html b/tests/browser/fixtures/frame.html index d5bd505..bcf62ed 100644 --- a/tests/browser/fixtures/frame.html +++ b/tests/browser/fixtures/frame.html @@ -1,5 +1,10 @@ -OPFS frame - + + + OPFS frame + + + + diff --git a/tests/browser/fixtures/index.html b/tests/browser/fixtures/index.html index 5abfbca..d6d73de 100644 --- a/tests/browser/fixtures/index.html +++ b/tests/browser/fixtures/index.html @@ -1,5 +1,10 @@ -OPFS browser tests - + + + OPFS browser tests + + + + diff --git a/tests/browser/iframe.spec.ts b/tests/browser/iframe.spec.ts index ca612fc..70dd45a 100644 --- a/tests/browser/iframe.spec.ts +++ b/tests/browser/iframe.spec.ts @@ -22,7 +22,9 @@ test("same-origin iframe observes its real OPFS placement", async ({ page }) => }); const frame = await framePromise; await waitForApi(frame); - const result = await frame.evaluate(async () => await window.opfsTest.roundTrip(`/frames/${crypto.randomUUID()}.txt`, "same")); + const result = await frame.evaluate(async () => + await window.opfsTest.roundTrip(`/frames/${crypto.randomUUID()}.txt`, "same") + ); expect(result.probe?.embedded).toBe(true); expect(result.probe?.sameOriginTop).toBe(true); if (result.probe?.rootAvailable) expect(result.value).toBe("same"); diff --git a/tests/browser/opfs.spec.ts b/tests/browser/opfs.spec.ts index e098b9b..e57710e 100644 --- a/tests/browser/opfs.spec.ts +++ b/tests/browser/opfs.spec.ts @@ -11,7 +11,9 @@ async function ready(page: import("@playwright/test").Page): Promise { test("window probes the actual capability and round-trips when OPFS is available", async ({ page }) => { await ready(page); - const result = await page.evaluate(async () => await window.opfsTest.roundTrip(`/window/${crypto.randomUUID()}.txt`, "window")); + const result = await page.evaluate(async () => + await window.opfsTest.roundTrip(`/window/${crypto.randomUUID()}.txt`, "window") + ); expect(result.supported).toBe(true); expect(result.probe?.context).toBe("window"); if (result.probe?.rootAvailable) expect(result.value).toBe("window"); @@ -29,7 +31,9 @@ test("fresh browser contexts do not inherit another context's OPFS file", async const first = await browser.newContext(); const firstPage = await first.newPage(); await ready(firstPage); - const written = await firstPage.evaluate(async ({ path }) => await window.opfsTest.roundTrip(path, "private"), { path }); + const written = await firstPage.evaluate(async ({ path }) => await window.opfsTest.roundTrip(path, "private"), { + path, + }); await first.close(); if (!written.probe?.rootAvailable) { expect(written.probe?.rootError).toBeDefined(); @@ -51,7 +55,9 @@ test("a persistent profile reopens the same OPFS data", async ({ browserName }, const first = await browserType.launchPersistentContext(profile); const firstPage = await first.newPage(); await ready(firstPage); - const written = await firstPage.evaluate(async ({ path }) => await window.opfsTest.roundTrip(path, "persisted"), { path }); + const written = await firstPage.evaluate(async ({ path }) => await window.opfsTest.roundTrip(path, "persisted"), { + path, + }); await first.close(); if (!written.probe?.rootAvailable) { expect(written.probe?.rootError).toBeDefined(); diff --git a/tests/deno-kv-partition.test.ts b/tests/deno-kv-partition.test.ts index 627f07b..24ccdd5 100644 --- a/tests/deno-kv-partition.test.ts +++ b/tests/deno-kv-partition.test.ts @@ -69,16 +69,19 @@ function bytes(length: number): Uint8Array { } describe("Deno KV partitioned records", () => { - it("rejects configuration that treats Deno KV serialized ceilings as raw payload budgets", () => { const database = new FakeDenoKv(); - expect(() => createDenoKvDriver(database, { - partBytes: DENO_KV_SAFE_PART_BYTES + 1, - })).toThrow(RangeError); - expect(() => createDenoKvDriver(database, { - inlineBytes: DENO_KV_SAFE_INLINE_BYTES + 1, - })).toThrow(RangeError); + expect(() => + createDenoKvDriver(database, { + partBytes: DENO_KV_SAFE_PART_BYTES + 1, + }) + ).toThrow(RangeError); + expect(() => + createDenoKvDriver(database, { + inlineBytes: DENO_KV_SAFE_INLINE_BYTES + 1, + }) + ).toThrow(RangeError); }); it("rejects an oversized physical key during driver preflight before provider I/O", () => { @@ -167,7 +170,9 @@ describe("Deno KV partitioned records", () => { const inspection = fileSystem.inspect(); expect(inspection.adapter.partition?.layout).toBe("deno-kv-parts-v2"); expect(inspection.adapter.limits?.maxValueBytes).toBe(DENO_KV_MAX_VALUE_BYTES); - expect(fileSystem.plan({ operation: "write", source: "bytes", size: input.byteLength }).support).toBe("partitioned"); + expect(fileSystem.plan({ operation: "write", source: "bytes", size: input.byteLength }).support).toBe( + "partitioned", + ); expect([...database.values.values()].every((entry) => size(entry.value) <= DENO_KV_MAX_VALUE_BYTES)).toBe(true); await fileSystem.close(); }); @@ -202,10 +207,11 @@ describe("Deno KV partitioned records", () => { await fileSystem.close(); }); - it("stats and ranges avoid reconstructing unrelated partition bodies", async () => { const database = new FakeDenoKv(); - const fileSystem = createFileSystem(createDenoKvAdapter(database, { partBytes: 48 * 1024 }), { coordination: "none" }); + const fileSystem = createFileSystem(createDenoKvAdapter(database, { partBytes: 48 * 1024 }), { + coordination: "none", + }); const input = bytes(180 * 1024); await fileSystem.writeFile("/large.bin", input); @@ -262,12 +268,14 @@ describe("Deno KV partitioned records", () => { }); expect(fileSystem.inspect().support.streamWrite.replace).toBe("emulated"); - expect(fileSystem.plan({ - operation: "write", - source: "stream", - size: input.byteLength, - inputBytes: input.byteLength, - }).bufferBytes).toBe(input.byteLength); + expect( + fileSystem.plan({ + operation: "write", + source: "stream", + size: input.byteLength, + inputBytes: input.byteLength, + }).bufferBytes, + ).toBe(input.byteLength); await fileSystem.writeFile("/fallback.bin", source); expect(await fileSystem.readFile("/fallback.bin")).toEqual(input); @@ -378,7 +386,9 @@ describe("Deno KV partitioned records", () => { it("fails before the provider rejects a large inline value when partitioning is disabled", async () => { const database = new FakeDenoKv(); - const fileSystem = createFileSystem(createDenoKvAdapter(database, { partition: "never" }), { coordination: "none" }); + const fileSystem = createFileSystem(createDenoKvAdapter(database, { partition: "never" }), { + coordination: "none", + }); try { await fileSystem.writeFile("/too-large.bin", bytes(80 * 1024)); diff --git a/tests/node.test.ts b/tests/node.test.ts index dddf1ff..5c69e14 100644 --- a/tests/node.test.ts +++ b/tests/node.test.ts @@ -89,7 +89,9 @@ describe("Node adapter", () => { run: (...params) => statement.run(...params), }; }, - close() { database.close(); }, + close() { + database.close(); + }, }, { disposeDatabase: true }); const fileSystem = createFileSystem(adapter, { coordination: "none", disposeAdapter: true }); try { diff --git a/tests/provider.test.ts b/tests/provider.test.ts index 1bcf5f9..c386a4b 100644 --- a/tests/provider.test.ts +++ b/tests/provider.test.ts @@ -89,7 +89,9 @@ describe("Testcontainers-backed object providers", () => { const original = new TextEncoder().encode("0123456789"); const written = await client.put(basic, original, { mediaType: "text/plain", ifNoneMatch: "*" }); expect(written.size).toBe(original.byteLength); - if (written.etag === undefined) throw new Error("S3 provider did not return an ETag for a completed object write."); + if (written.etag === undefined) { + throw new Error("S3 provider did not return an ETag for a completed object write."); + } expect((await client.head(basic))?.etag).toBe(written.etag); expect(new TextDecoder().decode(await toBytes(await client.get(basic, { at: 3, length: 4 })))).toBe("3456"); await expect(client.put(basic, original, { ifNoneMatch: "*" })).rejects.toBeDefined(); @@ -106,7 +108,9 @@ describe("Testcontainers-backed object providers", () => { const page = await client.list({ prefix: `${prefix}/`, delimiter: "/" }); expect(page.objects.some((entry) => entry.key === basic)).toBe(true); - await using fileSystem = createFileSystem(createObjectAdapter(createS3DriverFromClient(client), { prefix }), { coordination: "none" }); + await using fileSystem = createFileSystem(createObjectAdapter(createS3DriverFromClient(client), { prefix }), { + coordination: "none", + }); await fileSystem.writeFile("/facade/state.txt", "through facade", { parents: true }); expect(await fileSystem.readText("/facade/state.txt")).toBe("through facade"); expect((await client.head(facadeKey))?.size).toBe(14); @@ -130,7 +134,9 @@ describe("Testcontainers-backed object providers", () => { const original = new TextEncoder().encode("0123456789"); const written = await client.put(basic, original, { mediaType: "text/plain", ifNoneMatch: "*" }); expect(written.size).toBe(original.byteLength); - if (written.etag === undefined) throw new Error("Azure provider did not return an ETag for a completed blob write."); + if (written.etag === undefined) { + throw new Error("Azure provider did not return an ETag for a completed blob write."); + } expect((await client.head(basic))?.etag).toBe(written.etag); expect(new TextDecoder().decode(await toBytes(await client.get(basic, { at: 3, length: 4 })))).toBe("3456"); await expect(client.put(basic, original, { ifNoneMatch: "*" })).rejects.toBeDefined(); @@ -147,7 +153,9 @@ describe("Testcontainers-backed object providers", () => { const page = await client.list({ prefix: `${prefix}/`, delimiter: "/" }); expect(page.objects.some((entry) => entry.key === basic)).toBe(true); - await using fileSystem = createFileSystem(createObjectAdapter(createAzureDriverFromClient(client), { prefix }), { coordination: "none" }); + await using fileSystem = createFileSystem(createObjectAdapter(createAzureDriverFromClient(client), { prefix }), { + coordination: "none", + }); await fileSystem.writeFile("/facade/state.txt", "through facade", { parents: true }); expect(await fileSystem.readText("/facade/state.txt")).toBe("through facade"); expect((await client.head(facadeKey))?.size).toBe(14); diff --git a/tests/s3.test.ts b/tests/s3.test.ts index e705356..b067853 100644 --- a/tests/s3.test.ts +++ b/tests/s3.test.ts @@ -1,7 +1,7 @@ import { describe, it } from "node:test"; import { expect } from "@std/expect"; -import { createS3Client, S3Error, S3_LIMITS } from "../src/s3.ts"; +import { createS3Client, S3_LIMITS, S3Error } from "../src/s3.ts"; import { createS3Driver, createS3DriverFromClient } from "../src/driver/s3.ts"; import { RequestCapture } from "./http.ts"; import { streamBytes } from "./stream.ts"; @@ -14,7 +14,11 @@ const credentials = { /** Creates one S3-style XML response without coupling tests to an HTTP server. */ function xml(value: string, init: ResponseInit = {}): Response { - return new Response(value, { status: 200, headers: { "content-type": "application/xml", ...(init.headers ?? {}) }, ...init }); + return new Response(value, { + status: 200, + headers: { "content-type": "application/xml", ...(init.headers ?? {}) }, + ...init, + }); } /** Refreshable credential source used to prove per-request SigV4 resolution. */ @@ -75,7 +79,8 @@ describe("S3 client", () => { bucket: "bucket", region: "auto", credentials, - fetch: async () => xml(` + fetch: async () => + xml(` root/a.txt2026-08-14T12:00:00.000Z"a"4 root/nested/ @@ -99,14 +104,16 @@ describe("S3 client", () => { fetch: async (input) => { const url = new URL(input instanceof Request ? input.url : input); if (url.searchParams.has("uploadId")) { - return xml(`InternalErrorassembly failedr1`); + return xml( + `InternalErrorassembly failedr1`, + ); } return new Response(null, { status: 200 }); }, }); try { - await client.completeUpload({ key: "large.bin", id: "upload" }, [{ number: 1, etag: "\"part\"" }]); + await client.completeUpload({ key: "large.bin", id: "upload" }, [{ number: 1, etag: '"part"' }]); throw new Error("expected embedded multipart failure"); } catch (error) { expect(error).toBeInstanceOf(S3Error); @@ -138,22 +145,29 @@ describe("S3 client", () => { return new Response(null, { status: 200, headers: { etag: `\"p${url.searchParams.get("partNumber")}\"` } }); } if (request.method === "POST" && url.searchParams.has("uploadId")) { - return xml("\"final\""); + return xml('"final"'); } if (request.method === "HEAD") { - return new Response(null, { status: 200, headers: { "content-length": String(fiveMiB + 1), etag: "\"final\"" } }); + return new Response(null, { + status: 200, + headers: { "content-length": String(fiveMiB + 1), etag: '"final"' }, + }); } return new Response(null, { status: 200 }); }, }); const body = streamBytes([new Uint8Array(fiveMiB), new Uint8Array([1])]); - await client.put("large.bin", body, { ifMatch: "\"old\"" }); + await client.put("large.bin", body, { ifMatch: '"old"' }); - const initiate = requests.find((request) => request.method === "POST" && new URL(request.url).searchParams.has("uploads")); - const complete = requests.find((request) => request.method === "POST" && new URL(request.url).searchParams.has("uploadId")); + const initiate = requests.find((request) => + request.method === "POST" && new URL(request.url).searchParams.has("uploads") + ); + const complete = requests.find((request) => + request.method === "POST" && new URL(request.url).searchParams.has("uploadId") + ); expect(initiate?.headers.has("if-match")).toBe(false); - expect(complete?.headers.get("if-match")).toBe("\"old\""); + expect(complete?.headers.get("if-match")).toBe('"old"'); }); it("delays multipart creation for a small unknown-length stream by default", async () => { @@ -167,16 +181,18 @@ describe("S3 client", () => { const request = new Request(input, init); requests.push(request); if (request.method === "HEAD") { - return new Response(null, { status: 200, headers: { "content-length": "3", etag: "\"small\"" } }); + return new Response(null, { status: 200, headers: { "content-length": "3", etag: '"small"' } }); } - return new Response(null, { status: 200, headers: { etag: "\"small\"" } }); + return new Response(null, { status: 200, headers: { etag: '"small"' } }); }, }); await client.put("small.bin", streamBytes([new Uint8Array([1, 2, 3])])); - expect(requests.some((request) => request.method === "POST" && new URL(request.url).searchParams.has("uploads"))).toBe(false); - expect(requests.some((request) => request.method === "PUT" && !new URL(request.url).searchParams.has("partNumber"))).toBe(true); + expect(requests.some((request) => request.method === "POST" && new URL(request.url).searchParams.has("uploads"))) + .toBe(false); + expect(requests.some((request) => request.method === "PUT" && !new URL(request.url).searchParams.has("partNumber"))) + .toBe(true); }); it("can disable delayed multipart when request lifecycle parity is required", async () => { @@ -195,13 +211,13 @@ describe("S3 client", () => { return xml("u-small"); } if (request.method === "PUT" && url.searchParams.has("partNumber")) { - return new Response(null, { status: 200, headers: { etag: "\"part-1\"" } }); + return new Response(null, { status: 200, headers: { etag: '"part-1"' } }); } if (request.method === "POST" && url.searchParams.has("uploadId")) { - return xml("\"small\""); + return xml('"small"'); } if (request.method === "HEAD") { - return new Response(null, { status: 200, headers: { "content-length": "3", etag: "\"small\"" } }); + return new Response(null, { status: 200, headers: { "content-length": "3", etag: '"small"' } }); } return new Response(null, { status: 500 }); }, @@ -209,8 +225,10 @@ describe("S3 client", () => { await client.put("small.bin", streamBytes([new Uint8Array([1, 2, 3])])); - expect(requests.some((request) => request.method === "POST" && new URL(request.url).searchParams.has("uploads"))).toBe(true); - expect(requests.some((request) => request.method === "PUT" && new URL(request.url).searchParams.has("partNumber"))).toBe(true); + expect(requests.some((request) => request.method === "POST" && new URL(request.url).searchParams.has("uploads"))) + .toBe(true); + expect(requests.some((request) => request.method === "PUT" && new URL(request.url).searchParams.has("partNumber"))) + .toBe(true); }); it("retains provider request identity on S3 errors", async () => { @@ -219,10 +237,11 @@ describe("S3 client", () => { bucket: "bucket", region: "auto", credentials, - fetch: async () => xml( - "AccessDenieddeniedrequest-1host-1", - { status: 403 }, - ), + fetch: async () => + xml( + "AccessDenieddeniedrequest-1host-1", + { status: 403 }, + ), }); try { @@ -246,10 +265,12 @@ describe("S3 client", () => { fetch: async (input, init) => { const request = input instanceof Request ? input : new Request(input, init); if (request.method === "HEAD" && request.url.endsWith("/source.bin")) { - return new Response(null, { status: 200, headers: { "content-length": "4", etag: "\"source\"" } }); + return new Response(null, { status: 200, headers: { "content-length": "4", etag: '"source"' } }); } if (request.method === "PUT") { - return xml("SlowDowncopy failedcopy-r1"); + return xml( + "SlowDowncopy failedcopy-r1", + ); } return new Response(null, { status: 404 }); }, @@ -284,7 +305,7 @@ describe("S3 client", () => { if (request.method === "HEAD" && url.pathname.endsWith("/source.bin")) { return new Response(null, { status: 200, - headers: { "content-length": String(size), etag: "\"source\"", "content-type": "application/octet-stream" }, + headers: { "content-length": String(size), etag: '"source"', "content-type": "application/octet-stream" }, }); } if (request.method === "POST" && url.searchParams.has("uploads")) { @@ -298,22 +319,22 @@ describe("S3 client", () => { return xml(""final""); } if (request.method === "HEAD") { - return new Response(null, { status: 200, headers: { "content-length": String(size), etag: "\"final\"" } }); + return new Response(null, { status: 200, headers: { "content-length": String(size), etag: '"final"' } }); } return new Response(null, { status: 200 }); }, }); - await client.copy!("source.bin", "copy.bin", { sourceIfMatch: "\"source\"" }); + await client.copy!("source.bin", "copy.bin", { sourceIfMatch: '"source"' }); const parts = requests.filter((request) => new URL(request.url).searchParams.has("partNumber")); expect(parts).toHaveLength(5); expect(parts[0]?.headers.get("x-amz-copy-source-range")).toBe(`bytes=0-${1024 * 1024 * 1024 - 1}`); - expect(parts[0]?.headers.get("x-amz-copy-source-if-match")).toBe("\"source\""); - expect(requests.some((request) => request.method === "PUT" && !new URL(request.url).searchParams.has("partNumber"))).toBe(false); + expect(parts[0]?.headers.get("x-amz-copy-source-if-match")).toBe('"source"'); + expect(requests.some((request) => request.method === "PUT" && !new URL(request.url).searchParams.has("partNumber"))) + .toBe(false); }); - it("surfaces embedded UploadPartCopy failures and aborts the unfinished multipart copy", async () => { let aborted = false; const size = S3_LIMITS.maxCopyBytes + 1; @@ -328,13 +349,15 @@ describe("S3 client", () => { const request = new Request(input, init); const url = new URL(request.url); if (request.method === "HEAD" && url.pathname.endsWith("/source.bin")) { - return new Response(null, { status: 200, headers: { "content-length": String(size), etag: "\"source\"" } }); + return new Response(null, { status: 200, headers: { "content-length": String(size), etag: '"source"' } }); } if (request.method === "POST" && url.searchParams.has("uploads")) { return xml("copy-upload"); } if (request.method === "PUT" && url.searchParams.has("partNumber")) { - return xml("SlowDowncopy part failedpart-r1"); + return xml( + "SlowDowncopy part failedpart-r1", + ); } if (request.method === "DELETE" && url.searchParams.has("uploadId")) { aborted = true; @@ -374,7 +397,7 @@ describe("S3 client", () => { return xml("size-upload"); } if (request.method === "PUT" && url.searchParams.has("partNumber")) { - return new Response(null, { status: 200, headers: { etag: "\"part\"" } }); + return new Response(null, { status: 200, headers: { etag: '"part"' } }); } if (request.method === "DELETE" && url.searchParams.has("uploadId")) { return new Response(null, { status: 204 }); @@ -390,7 +413,8 @@ describe("S3 client", () => { )).rejects.toThrow(RangeError); expect(requests.some((request) => request.method === "DELETE")).toBe(true); - expect(requests.some((request) => request.method === "POST" && new URL(request.url).searchParams.has("uploadId"))).toBe(false); + expect(requests.some((request) => request.method === "POST" && new URL(request.url).searchParams.has("uploadId"))) + .toBe(false); }); it("keeps the object ceiling equal to the exact multipart part-count limit", () => { @@ -398,21 +422,25 @@ describe("S3 client", () => { }); it("rejects multipart sizes outside the documented S3 part range", () => { - expect(() => createS3Client({ - endpoint: "https://storage.example", - bucket: "bucket", - region: "auto", - credentials, - partSize: S3_LIMITS.minPartBytes - 1, - })).toThrow(RangeError); + expect(() => + createS3Client({ + endpoint: "https://storage.example", + bucket: "bucket", + region: "auto", + credentials, + partSize: S3_LIMITS.minPartBytes - 1, + }) + ).toThrow(RangeError); - expect(() => createS3Client({ - endpoint: "https://storage.example", - bucket: "bucket", - region: "auto", - credentials, - partSize: S3_LIMITS.maxPartBytes + 1, - })).toThrow(RangeError); + expect(() => + createS3Client({ + endpoint: "https://storage.example", + bucket: "bucket", + region: "auto", + credentials, + partSize: S3_LIMITS.maxPartBytes + 1, + }) + ).toThrow(RangeError); }); it("sorts multipart parts and rejects duplicate part numbers before commit", async () => { @@ -430,7 +458,7 @@ describe("S3 client", () => { await client.completeUpload( { key: "ordered.bin", id: "upload" }, - [{ number: 2, etag: "\"b\"" }, { number: 1, etag: "\"a\"" }], + [{ number: 2, etag: '"b"' }, { number: 1, etag: '"a"' }], { expectedSize: 10 }, ); const body = await requests[0]!.text(); @@ -439,7 +467,7 @@ describe("S3 client", () => { await expect(client.completeUpload( { key: "duplicate.bin", id: "upload" }, - [{ number: 1, etag: "\"a\"" }, { number: 1, etag: "\"b\"" }], + [{ number: 1, etag: '"a"' }, { number: 1, etag: '"b"' }], )).rejects.toThrow(RangeError); }); @@ -457,7 +485,7 @@ describe("S3 client", () => { requests.push(request); const url = new URL(request.url); if (request.method === "HEAD" && url.pathname.endsWith("/source.bin")) { - return new Response(null, { status: 200, headers: { "content-length": String(size), etag: "\"source\"" } }); + return new Response(null, { status: 200, headers: { "content-length": String(size), etag: '"source"' } }); } if (request.method === "POST" && url.searchParams.has("uploads")) { return xml("u"); @@ -468,20 +496,22 @@ describe("S3 client", () => { if (request.method === "POST" && url.searchParams.has("uploadId")) { return xml(""done""); } - return new Response(null, { status: 200, headers: { "content-length": String(size), etag: "\"done\"" } }); + return new Response(null, { status: 200, headers: { "content-length": String(size), etag: '"done"' } }); }, }); await client.copy!("source.bin", "copy.bin", { - sourceIfMatch: "\"source\"", - sourceIfNoneMatch: "\"stale\"", + sourceIfMatch: '"source"', + sourceIfNoneMatch: '"stale"', ifNoneMatch: "*", }); const part = requests.find((request) => new URL(request.url).searchParams.has("partNumber")); - const complete = requests.find((request) => request.method === "POST" && new URL(request.url).searchParams.has("uploadId")); - expect(part?.headers.get("x-amz-copy-source-if-match")).toBe("\"source\""); - expect(part?.headers.get("x-amz-copy-source-if-none-match")).toBe("\"stale\""); + const complete = requests.find((request) => + request.method === "POST" && new URL(request.url).searchParams.has("uploadId") + ); + expect(part?.headers.get("x-amz-copy-source-if-match")).toBe('"source"'); + expect(part?.headers.get("x-amz-copy-source-if-none-match")).toBe('"stale"'); expect(part?.headers.has("if-none-match")).toBe(false); expect(complete?.headers.get("if-none-match")).toBe("*"); }); @@ -507,7 +537,9 @@ describe("S3 client", () => { expect(capture.latest?.url).toBe( "https://bucket-name.storage.example/folder/a%20b.txt?a%20b=%21%2A&z=1&z=2", ); - expect(capture.latest?.headers.get("authorization")).toContain("SignedHeaders=host;x-amz-content-sha256;x-amz-date"); + expect(capture.latest?.headers.get("authorization")).toContain( + "SignedHeaders=host;x-amz-content-sha256;x-amz-date", + ); }); it("resolves temporary credentials for every request and signs the session token", async () => { @@ -550,7 +582,6 @@ describe("S3 client", () => { expect(capture.latest?.headers.get("x-amz-content-sha256")).toBe("UNSIGNED-PAYLOAD"); }); - it("hashes replayable low-level Web bodies instead of weakening them to UNSIGNED-PAYLOAD", async () => { const bodies: BodyInit[] = [ new Uint8Array([1, 2, 3]).buffer, @@ -657,7 +688,6 @@ describe("S3 client", () => { expect(cleanupSignal).not.toBe(controller.signal); expect(cleanupSignal?.aborted).toBe(false); }); - }); describe("S3 request policy", () => { @@ -822,9 +852,10 @@ describe("S3 request policy", () => { region: "auto", credentials, request: { retries: 0, timeoutMs: 5 }, - fetch: async (_input, init) => await new Promise((_resolve, reject) => { - init?.signal?.addEventListener("abort", () => reject(init.signal?.reason), { once: true }); - }), + fetch: async (_input, init) => + await new Promise((_resolve, reject) => { + init?.signal?.addEventListener("abort", () => reject(init.signal?.reason), { once: true }); + }), }); await expect(client.request({ method: "GET", key: "slow" })).rejects.toMatchObject({ name: "TimeoutError" }); diff --git a/tests/sqlite.test.ts b/tests/sqlite.test.ts index 3bd8937..c4f2305 100644 --- a/tests/sqlite.test.ts +++ b/tests/sqlite.test.ts @@ -51,7 +51,13 @@ class MemorySqlite { }; } if (sql.startsWith("DELETE")) { - return { all: () => [], get: () => undefined, run: (id) => { this.rows.delete(String(id)); } }; + return { + all: () => [], + get: () => undefined, + run: (id) => { + this.rows.delete(String(id)); + }, + }; } throw new Error(`unexpected SQL: ${sql}`); } -- 2.51.2