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}`); }