diff --git a/tests/azure.test.ts b/tests/azure.test.ts index 547287a..774ddf6 100644 --- a/tests/azure.test.ts +++ b/tests/azure.test.ts @@ -2,6 +2,7 @@ import { describe, it } from "node:test"; import { expect } from "@std/expect"; import { AZURE_LIMITS, AzureError, createAzureClient } from "../src/azure.ts"; +import { createAzureDriver, createAzureDriverFromClient } from "../src/driver/azure.ts"; import { RequestCapture } from "./http.ts"; import { streamBytes } from "./stream.ts"; @@ -23,6 +24,19 @@ class BearerTokenSource { } describe("Azure Blob client", () => { + it("reports direct clients as owned and injected clients as borrowed", () => { + const options = { + endpoint: "https://account.blob.core.windows.net", + container: "data", + credential: { kind: "sas" as const, token: "?sig=secret" }, + fetch: async () => new Response(null, { status: 200 }), + }; + const client = createAzureClient(options); + + expect(createAzureDriver(options).inspect().ownership).toBe("owned"); + expect(createAzureDriverFromClient(client).inspect().ownership).toBe("borrowed"); + }); + it("keeps SAS authorization on the source URL during provider-side copy", async () => { const requests: Request[] = []; const client = createAzureClient({ @@ -47,6 +61,24 @@ describe("Azure Blob client", () => { expect(copy?.headers.get("x-ms-requires-sync")).toBe("true"); }); + it("exposes behavior-changing Azure optimizations as independent switches", async () => { + const client = createAzureClient({ + endpoint: "https://account.blob.core.windows.net", + container: "data", + credential: { kind: "sas", token: "?sig=secret" }, + blockUpload: false, + serverCopy: false, + fetch: async () => new Response(null, { status: 500 }), + }); + + expect(client.optimizations).toEqual({ blockUpload: false, serverCopy: false }); + expect(client.capabilities.streamWrite).toBe(false); + expect(client.capabilities.copy).toBe(false); + + await expect(client.put("stream.bin", streamBytes([new Uint8Array([1])]))).rejects.toThrow(TypeError); + await expect(client.copy!("source.bin", "copy.bin")).rejects.toThrow(TypeError); + }); + it("uses Put Block From URL for blobs above the 256 MiB synchronous copy limit", async () => { const requests: Request[] = []; const size = 256 * 1024 * 1024 + 1; @@ -99,7 +131,14 @@ describe("Azure Blob client", () => { fetch: async () => xml(` - root/a.txt4text/plain"e" + + root/a.txt + + 4 + text/plain + "e" + + root/nested/ next diff --git a/tests/deno-kv-partition.test.ts b/tests/deno-kv-partition.test.ts index b54e2f4..627f07b 100644 --- a/tests/deno-kv-partition.test.ts +++ b/tests/deno-kv-partition.test.ts @@ -5,9 +5,12 @@ import { createFileSystem, FileSystemError } from "../mod.ts"; import { createDenoKvAdapter, DENO_KV_MAX_VALUE_BYTES, + DENO_KV_SAFE_INLINE_BYTES, + DENO_KV_SAFE_PART_BYTES, type DenoKvEntryType, type DenoKvType, } from "../src/adapter/deno-kv.ts"; +import { createDenoKvDriver } from "../src/driver/deno-kv.ts"; /** Stable JSON-ish key string used only by the in-memory Deno KV contract double. */ function id(key: readonly unknown[]): string { @@ -66,6 +69,90 @@ 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); + }); + + it("rejects an oversized physical key during driver preflight before provider I/O", () => { + const database = new FakeDenoKv(); + const driver = createDenoKvDriver(database); + const path = `/${"segment".repeat(500)}`; + + const plan = driver.plan({ + operation: "write", + path, + size: 1, + source: "bytes", + mode: "replace", + }); + + expect(plan.supported).toBe(false); + expect(plan.support).toBe("unsupported"); + expect(plan.problems).toContainEqual(expect.objectContaining({ + code: "key-too-large", + layer: "driver", + severity: "error", + limit: expect.objectContaining({ + code: "serialized-key-bytes", + kind: "hard", + source: "provider", + }), + })); + expect(database.values.size).toBe(0); + }); + + it("collects old unreachable physical generations without touching the published generation", async () => { + const database = new FakeDenoKv(); + const driver = createDenoKvDriver(database, { partBytes: 48 * 1024 }); + const fileSystem = createFileSystem(createDenoKvAdapter(database, { partBytes: 48 * 1024 }), { + coordination: "none", + }); + await fileSystem.writeFile("/value.bin", bytes(96 * 1024)); + + const visibleParts = [...database.values.values()] + .filter((entry) => entry.key[1] === "part") + .map((entry) => id(entry.key)); + const oldGeneration = `${(Date.now() - 2 * 60 * 60 * 1000).toString(36)}-orphan`; + await database.set(["okikio-opfs", "part", "/value.bin", oldGeneration, 0], new Uint8Array([1])); + await database.set(["okikio-opfs", "part", "/value.bin", oldGeneration, 1], new Uint8Array([2])); + + const result = await driver.collect(); + + expect(result.deleted).toBe(2); + expect(result.truncated).toBe(false); + expect(database.values.has(id(["okikio-opfs", "part", "/value.bin", oldGeneration, 0]))).toBe(false); + expect(visibleParts.every((value) => database.values.has(value))).toBe(true); + expect(await fileSystem.readFile("/value.bin")).toEqual(bytes(96 * 1024)); + await fileSystem.close(); + }); + + it("returns an actionable preflight result when partitioning is disabled", () => { + const database = new FakeDenoKv(); + const driver = createDenoKvDriver(database, { partition: "never", inlineBytes: 32 * 1024 }); + + const plan = driver.plan({ + operation: "write", + path: "/large.bin", + size: 96 * 1024, + source: "bytes", + mode: "replace", + }); + + expect(plan.supported).toBe(false); + expect(plan.problems).toContainEqual(expect.objectContaining({ code: "partition-disabled" })); + expect(plan.actions).toEqual(expect.arrayContaining([ + expect.objectContaining({ kind: "change-policy" }), + expect.objectContaining({ kind: "select-driver" }), + ])); + }); it("stores a large logical file below the physical value ceiling and reconstructs it exactly", async () => { const database = new FakeDenoKv(); const fileSystem = createFileSystem(createDenoKvAdapter(database, { partBytes: 48 * 1024 }), { @@ -78,8 +165,8 @@ describe("Deno KV partitioned records", () => { expect(await fileSystem.readFile("/large.bin")).toEqual(input); const inspection = fileSystem.inspect(); - expect(inspection.partition?.layout).toBe("deno-kv-parts-v2"); - expect(inspection.limits.maxValueBytes).toBe(DENO_KV_MAX_VALUE_BYTES); + 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([...database.values.values()].every((entry) => size(entry.value) <= DENO_KV_MAX_VALUE_BYTES)).toBe(true); await fileSystem.close(); diff --git a/tests/deno-kv.test.ts b/tests/deno-kv.test.ts index 6474415..c0513ff 100644 --- a/tests/deno-kv.test.ts +++ b/tests/deno-kv.test.ts @@ -1,5 +1,4 @@ -/// - +/// import { describe, it } from "node:test"; import { expect } from "@std/expect"; diff --git a/tests/driver.test.ts b/tests/driver.test.ts new file mode 100644 index 0000000..0de2c6a --- /dev/null +++ b/tests/driver.test.ts @@ -0,0 +1,136 @@ +import { describe, it } from "node:test"; +import { expect } from "@std/expect"; + +import { defineDriver } from "../src/driver/definition.ts"; +import { defineRecordDriver, type RecordBackendType } from "../src/driver/record.ts"; +import type { PathType } from "../src/schema.ts"; +import type { RecordType } from "../src/schema.ts"; + +/** Minimal deterministic record backend used to prove the public driver extension seam. */ +class TestRecordBackend implements RecordBackendType { + /** In-memory records keyed by canonical virtual path. */ + readonly #records = new Map(); + disposed = false; + + /** Returns one exact record. */ + async get(path: PathType): Promise { + return this.#records.get(path) ?? null; + } + + /** Replaces one exact record. */ + async set(record: RecordType): Promise { + this.#records.set(record.path, record); + } + + /** Removes one exact record. */ + async delete(path: PathType): Promise { + this.#records.delete(path); + } + + /** Iterates only records whose stored parent matches the requested directory. */ + async *list(parent: PathType): AsyncIterableIterator { + for (const record of this.#records.values()) { + if (record.parent === parent) yield record; + } + } + + /** Marks disposal so ownership tests can distinguish borrowed and owned backends. */ + dispose(): void { + this.disposed = true; + } +} + +describe("driver contract", () => { + it("keeps requirements, limits, and optimizations as structured inspectable data", () => { + const driver = defineDriver({ + name: "fixture", + kind: "record", + provides: ["get", "set", "list"], + ownership: "borrowed", + requirements: [{ code: "database", state: "available" }], + limits: [ + { code: "value-bytes", kind: "hard", source: "provider", unit: "bytes", value: 64 * 1024 }, + { code: "quota-bytes", kind: "dynamic", source: "probe", unit: "bytes", detail: "Probe storage quota before admission." }, + ], + optimizations: [ + { code: "partition", enabled: true, changesBehavior: true, disableable: true }, + ], + }); + + expect(driver.inspect()).toMatchObject({ + name: "fixture", + kind: "record", + provides: ["get", "set", "list"], + ownership: "borrowed", + requirements: [{ code: "database", state: "available" }], + limits: [ + { code: "value-bytes", kind: "hard", source: "provider" }, + { code: "quota-bytes", kind: "dynamic", source: "probe" }, + ], + optimizations: [{ code: "partition", enabled: true, changesBehavior: true, disableable: true }], + }); + }); + + it("rejects a behavior-changing optimization that cannot be disabled", () => { + expect(() => defineDriver({ + name: "unsafe", + kind: "object", + optimizations: [{ code: "cache", enabled: true, changesBehavior: true, disableable: false }], + })).toThrow(TypeError); + }); + + it("lets a third-party record driver preflight logical size before an adapter exists", () => { + const driver = defineRecordDriver(new TestRecordBackend(), { + name: "records", + limits: [{ code: "file-bytes", kind: "policy", source: "user", unit: "bytes", value: 8 }], + }); + + expect(driver.plan({ operation: "write", path: "/small.bin", size: 8, source: "bytes", mode: "replace" })).toMatchObject({ + supported: true, + support: "native", + }); + expect(driver.plan({ operation: "write", path: "/large.bin", size: 9, source: "bytes", mode: "replace" })).toMatchObject({ + supported: false, + support: "unsupported", + problems: [{ code: "file-too-large", layer: "driver", severity: "error" }], + actions: [{ kind: "reduce-input" }, { kind: "select-driver" }], + }); + }); + + it("enforces read-only policy at the driver seam before an adapter exists", () => { + const backend = new TestRecordBackend(); + const driver = defineRecordDriver(backend, { + name: "read-only", + readOnly: true, + }); + const record: RecordType = { + version: 1, + path: "/value" as PathType, + parent: "/" as PathType, + name: "value", + kind: "directory", + lastModified: 0, + }; + + expect(driver.capabilities.write).toBe(false); + expect(driver.provides.includes("set")).toBe(false); + expect(driver.provides.includes("delete")).toBe(false); + expect(driver.plan({ operation: "write", path: "/value", size: 0, source: "bytes", mode: "replace" })).toMatchObject({ + supported: false, + problems: [{ code: "read-only", layer: "driver", severity: "error" }], + }); + expect(() => driver.set(record)).toThrow(); + }); + + it("disposes a borrowed backend only when ownership is transferred", async () => { + const borrowed = new TestRecordBackend(); + const borrowedDriver = defineRecordDriver(borrowed, { name: "borrowed" }); + expect(borrowedDriver.dispose).toBeUndefined(); + expect(borrowed.disposed).toBe(false); + + const owned = new TestRecordBackend(); + const ownedDriver = defineRecordDriver(owned, { name: "owned", disposeBackend: true }); + await ownedDriver.dispose?.(); + expect(owned.disposed).toBe(true); + }); +}); diff --git a/tests/ecosystems.test.ts b/tests/ecosystems.test.ts index bed1489..3129efd 100644 --- a/tests/ecosystems.test.ts +++ b/tests/ecosystems.test.ts @@ -7,12 +7,12 @@ import { createDrizzleAdapter } from "../src/adapter/drizzle.ts"; import { createMemoryAdapter } from "../src/adapter/memory.ts"; import { createRxDbAdapter, RxDbRecordJsonSchema } from "../src/adapter/rxdb.ts"; import { createUnstorageAdapter } from "../src/adapter/unstorage.ts"; -import { createKeyValueDriver } from "../src/driver/kv.ts"; -import { createUnstorageDriver } from "../src/driver/unstorage.ts"; -import { Db0Bridge, DrizzleBridge, KeyValueBridge, RxDbBridge, UnstorageBridge } from "../src/bridge.ts"; -import { defineBridge } from "../src/bridge/definition.ts"; +import { createKeyValueBridge } from "../src/bridge/kv.ts"; +import { createUnstorageBridge } from "../src/bridge/unstorage.ts"; +import { Db0Integration, DrizzleIntegration, RxDbIntegration, UnstorageIntegration } from "../src/integration.ts"; +import { defineIntegration } from "../src/integration/definition.ts"; -/** In-memory unstorage-shaped resource used to verify forward adapter semantics and disposal ownership. */ +/** In-memory unstorage-shaped resource used to verify driver-to-adapter semantics and disposal ownership. */ class MemoryUnstorage { /** Stored unstorage values keyed exactly as the adapter writes them. */ #values = new Map(); @@ -69,7 +69,7 @@ class FakeRxDocument { } } -/** Minimal RxCollection-shaped store used to verify the collection-level RxDB bridge. */ +/** Minimal RxCollection-shaped store used to verify the collection-level RxDB driver. */ class FakeRxCollection { /** Records keyed by the OPFS path primary key. */ #records = new Map>(); @@ -177,7 +177,7 @@ class FakeDb0Database { } } -/** Creates a minimal Drizzle CRUD surface and caller-owned table mapping for bridge tests. */ +/** Creates a minimal Drizzle CRUD surface and caller-owned table mapping for driver tests. */ function createFakeDrizzle() { const table = { path: { name: "path" }, @@ -249,64 +249,27 @@ async function exerciseRecordBackend(fileSystem: ReturnType { - it("reports bridge directions and concrete unsupported reasons", () => { - expect(UnstorageBridge.directions.toOpfs.supported).toBe(true); - expect(UnstorageBridge.directions.fromOpfs.supported).toBe(true); - - for (const bridge of [RxDbBridge, Db0Bridge, DrizzleBridge]) { - expect(bridge.directions.toOpfs.supported).toBe(true); - expect(bridge.directions.fromOpfs.supported).toBe(false); - expect(bridge.directions.fromOpfs.reason?.length).toBeGreaterThan(0); + it("reports integration directions without pretending metadata is a bridge", () => { + expect(UnstorageIntegration.directions.toOpfs.supported).toBe(true); + expect(UnstorageIntegration.directions.fromOpfs.supported).toBe(true); + for (const integration of [RxDbIntegration, Db0Integration, DrizzleIntegration]) { + expect(integration.directions.toOpfs.supported).toBe(true); + expect(integration.directions.fromOpfs.supported).toBe(false); + expect(integration.directions.fromOpfs.reason?.length).toBeGreaterThan(0); } - expect(KeyValueBridge.directions.toOpfs.supported).toBe(false); - expect(KeyValueBridge.directions.toOpfs.reason?.length).toBeGreaterThan(0); - expect(KeyValueBridge.directions.fromOpfs.supported).toBe(true); }); - it("rejects a third-party bridge that hides why a direction is unsupported", () => { - expect(() => defineBridge({ - name: "invalid-bridge", - directions: { - toOpfs: { supported: false }, - fromOpfs: { supported: false }, - }, - })).toThrow(); - }); - - it("uses the high-level unstorage contract and explicit disposal ownership", async () => { - const storage = new MemoryUnstorage(); - const adapter = createUnstorageAdapter(storage as never, { disposeStorage: true }); - const fileSystem = createFileSystem(adapter, { coordination: "local", disposeAdapter: true }); - await exerciseRecordBackend(fileSystem); - await fileSystem.close(); - expect(storage.disposed).toBe(true); - }); - - it("exposes any filesystem through the reusable key-value driver", async () => { - const fileSystem = createFileSystem(createMemoryAdapter(), { coordination: "none" }); - const driver = createKeyValueDriver(fileSystem); - expect(driver.inspect().adapter).toBe("memory"); - expect(driver.plan({ operation: "write", source: "stream", mode: "replace", size: 1024 }).support).toBe("emulated"); - await driver.set("prefix", "parent-value"); - await driver.set("prefix:child", "child-value"); - await driver.setRaw("binary", new Uint8Array([1, 2, 3])); - - expect(await driver.get("prefix")).toBe("parent-value"); - expect(await driver.get("prefix:child")).toBe("child-value"); - expect([...(await driver.getRaw("binary"))!]).toEqual([1, 2, 3]); - expect(await driver.keys()).toEqual(expect.arrayContaining(["prefix", "prefix:child", "binary"])); - expect(driver.getMetrics().operations.write?.count).toBeGreaterThan(0); - - await driver.clear("prefix", { preserveExact: true }); - expect(await driver.get("prefix")).toBe("parent-value"); - expect(await driver.get("prefix:child")).toBe(null); - await fileSystem.close(); + it("rejects integration metadata that hides why a direction is unsupported", () => { + expect(() => defineIntegration({ + name: "invalid-integration", + directions: { toOpfs: { supported: false }, fromOpfs: { supported: false, reason: "not implemented" } }, + })).toThrow(TypeError); }); it("exposes any filesystem as an unstorage driver without key collisions", async () => { const fileSystem = createFileSystem(createMemoryAdapter(), { coordination: "local" }); - const driver = createUnstorageDriver(fileSystem); - expect(driver.inspect().adapter).toBe("memory"); + const driver = createUnstorageBridge(fileSystem); + expect(driver.inspect().adapter.name).toBe("memory"); expect(driver.plan({ operation: "write", source: "bytes", mode: "replace", size: 3 }).supported).toBe(true); await driver.setItem("prefix", "parent-value", {}); await driver.setItem("prefix:child", "child-value", {}); diff --git a/tests/node.test.ts b/tests/node.test.ts index dd62ad2..dddf1ff 100644 --- a/tests/node.test.ts +++ b/tests/node.test.ts @@ -10,7 +10,7 @@ import { createDb0Adapter } from "../src/adapter/db0.ts"; import { createNodeAdapter } from "../src/adapter/node.ts"; import { createSqliteAdapter } from "../src/adapter/sqlite.ts"; -/** Real Node SQLite database wrapped in the db0 shape used by the adapter contract. */ +/** Real Node SQLite database wrapped in the db0 shape used by the record driver contract. */ class SqliteDb0Database { /** Selects db0 SQLite SQL generation. */ readonly dialect = "sqlite" as const; diff --git a/tests/object.test.ts b/tests/object.test.ts index 4e228fc..d6d9c8b 100644 --- a/tests/object.test.ts +++ b/tests/object.test.ts @@ -2,8 +2,10 @@ import { describe, it } from "node:test"; import { expect } from "@std/expect"; import { createFileSystem } from "../mod.ts"; +import { createObjectAdapter } from "../src/adapter/object.ts"; import { - createObjectAdapter, + defineObjectDriver, + type ObjectBackendType, type ObjectCopyOptionsType, type ObjectEntryType, type ObjectGetOptionsType, @@ -11,8 +13,7 @@ import { type ObjectListType, type ObjectPutOptionsType, type ObjectStatType, - type ObjectStoreType, -} from "../src/adapter/object.ts"; +} from "../src/driver/object.ts"; /** Materialized object and metadata retained by the in-memory provider double. */ interface StoredObjectType { @@ -32,7 +33,7 @@ async function collect(source: ReadableStream): Promise * pretending to be a filesystem. Counters make it possible to prove when the * facade takes a provider-native path instead of silently downloading bytes. */ -class MemoryObjectStore implements ObjectStoreType { +class MemoryObjectBackend implements ObjectBackendType { /** Stable adapter/provider name surfaced through the object-store contract. */ readonly name = "object-test"; /** Native paths the provider double deliberately claims for facade-selection tests. */ @@ -140,15 +141,27 @@ class MemoryObjectStore implements ObjectStoreType { } } -/** Creates a facade plus its observable provider double for one object-store test. */ -function createObjectFileSystem(store = new MemoryObjectStore()) { +/** Attaches driver metadata to the deterministic object backend used by tests. */ +function createMemoryObjectDriver(store: MemoryObjectBackend) { + return defineObjectDriver(store, { + name: store.name, + requirements: [], + limits: [], + optimizations: [], + }); +} + +/** Creates a facade plus its observable provider backend for one object-store test. */ +function createObjectFileSystem(store = new MemoryObjectBackend()) { + const driver = createMemoryObjectDriver(store); return { store, - fileSystem: createFileSystem(createObjectAdapter(store), { coordination: "none" }), + driver, + fileSystem: createFileSystem(createObjectAdapter(driver), { coordination: "none" }), }; } -describe("object-store adapter", () => { +describe("object driver adapter", () => { it("preserves empty directories and implicit prefix directories", async () => { const { store, fileSystem } = createObjectFileSystem(); await fileSystem.mkdir("/empty", { recursive: true }); @@ -188,8 +201,8 @@ describe("object-store adapter", () => { }); it("can disable native copy and exposes the emulated route through inspection and metrics", async () => { - const store = new MemoryObjectStore(); - const fileSystem = createFileSystem(createObjectAdapter(store), { + const store = new MemoryObjectBackend(); + const fileSystem = createFileSystem(createObjectAdapter(createMemoryObjectDriver(store)), { coordination: "none", optimizations: { nativeCopy: false }, metrics: "basic", @@ -216,8 +229,8 @@ describe("object-store adapter", () => { it("rejects an oversized emulated copy when its streaming read route is disabled", async () => { - const store = new MemoryObjectStore(); - const fileSystem = createFileSystem(createObjectAdapter(store), { + const store = new MemoryObjectBackend(); + const fileSystem = createFileSystem(createObjectAdapter(createMemoryObjectDriver(store)), { coordination: "none", optimizations: { nativeCopy: false, streamRead: false }, maxBufferedWriteBytes: 2, @@ -231,8 +244,8 @@ describe("object-store adapter", () => { }); it("fails an oversized streamed copy before opening the source when direct stream writes are disabled", async () => { - const store = new MemoryObjectStore(); - const fileSystem = createFileSystem(createObjectAdapter(store), { + const store = new MemoryObjectBackend(); + const fileSystem = createFileSystem(createObjectAdapter(createMemoryObjectDriver(store)), { coordination: "none", optimizations: { nativeCopy: false, streamWrite: false }, maxBufferedWriteBytes: 2, diff --git a/tests/package/verify.mjs b/tests/package/verify.mjs index 521a821..2d20709 100644 --- a/tests/package/verify.mjs +++ b/tests/package/verify.mjs @@ -2,6 +2,7 @@ import { mkdtemp, readFile, readdir, rm, stat, writeFile } from "node:fs/promise import { tmpdir } from "node:os"; import { join, resolve } from "node:path"; import { spawn } from "node:child_process"; +import { undent } from "@okikio/undent"; const tarball = resolve(process.argv[2] ?? ""); if (!tarball) throw new Error("Pass the npm tarball path."); @@ -78,17 +79,17 @@ try { await writeFile(join(consumer, "package.json"), `${JSON.stringify({ private: true, type: "module" }, null, 2)}\n`); await command("npm", ["install", "--ignore-scripts", "--no-audit", "--no-fund", tarball], { cwd: consumer }); - await writeFile(join(consumer, "smoke.mjs"), ` -import { createFileSystem } from '@okikio/opfs'; -import { createMemoryAdapter } from '@okikio/opfs/adapter/memory'; -import { normalizePath } from '@okikio/opfs/path'; -const fileSystem = createFileSystem(createMemoryAdapter(), { coordination: 'local' }); -await fileSystem.writeFile('/smoke.txt', 'ok', { parents: true }); -if (await fileSystem.readText('/smoke.txt') !== 'ok') throw new Error('npm memory adapter smoke failed'); -if (normalizePath('a/../b') !== '/b') throw new Error('npm path smoke failed'); -await import('@okikio/opfs/adapter/node'); -await import('@okikio/opfs/adapter/deno'); -await import('@okikio/opfs/adapter/bun'); + await writeFile(join(consumer, "smoke.mjs"), undent` + import { createFileSystem } from '@okikio/opfs'; + import { createMemoryAdapter } from '@okikio/opfs/adapter/memory'; + import { normalizePath } from '@okikio/opfs/path'; + const fileSystem = createFileSystem(createMemoryAdapter(), { coordination: 'local' }); + await fileSystem.writeFile('/smoke.txt', 'ok', { parents: true }); + if (await fileSystem.readText('/smoke.txt') !== 'ok') throw new Error('npm memory adapter smoke failed'); + if (normalizePath('a/../b') !== '/b') throw new Error('npm path smoke failed'); + await import('@okikio/opfs/adapter/node'); + await import('@okikio/opfs/adapter/deno'); + await import('@okikio/opfs/adapter/bun'); `); await command("node", ["smoke.mjs"], { cwd: consumer }); @@ -97,19 +98,19 @@ await import('@okikio/opfs/adapter/bun'); if (hasDeno) await command("deno", ["run", "--node-modules-dir=manual", "smoke.mjs"], { cwd: consumer }); if (hasBun) await command("bun", ["smoke.mjs"], { cwd: consumer }); - await writeFile(join(consumer, "consumer.ts"), ` -import { createFileSystem, type FileSystemType } from '@okikio/opfs'; -import { createMemoryAdapter } from '@okikio/opfs/adapter/memory'; -const fileSystem: FileSystemType = createFileSystem(createMemoryAdapter()); -await fileSystem.writeFile('/types.txt', 'ok', { parents: true }); + await writeFile(join(consumer, "consumer.ts"), undent` + import { createFileSystem, type FileSystemType } from '@okikio/opfs'; + import { createMemoryAdapter } from '@okikio/opfs/adapter/memory'; + const fileSystem: FileSystemType = createFileSystem(createMemoryAdapter()); + await fileSystem.writeFile('/types.txt', 'ok', { parents: true }); `); if (hasDeno) await command("deno", ["check", "--node-modules-dir=manual", "consumer.ts"], { cwd: consumer }); - await writeFile(join(consumer, "browser.mjs"), ` -import { createFileSystem } from '@okikio/opfs'; -import { createMemoryAdapter } from '@okikio/opfs/adapter/memory'; -import { normalizePath } from '@okikio/opfs/path'; -export const smoke = () => [createFileSystem(createMemoryAdapter()), normalizePath('a/../b')]; + await writeFile(join(consumer, "browser.mjs"), undent` + import { createFileSystem } from '@okikio/opfs'; + import { createMemoryAdapter } from '@okikio/opfs/adapter/memory'; + import { normalizePath } from '@okikio/opfs/path'; + export const smoke = () => [createFileSystem(createMemoryAdapter()), normalizePath('a/../b')]; `); if (hasDeno) { await command("deno", [ diff --git a/tests/provider.test.ts b/tests/provider.test.ts index c03675a..1bcf5f9 100644 --- a/tests/provider.test.ts +++ b/tests/provider.test.ts @@ -5,6 +5,8 @@ import { toBytes } from "@std/streams/to-bytes"; 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, @@ -104,7 +106,7 @@ 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(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); @@ -145,7 +147,7 @@ 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(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 4f44dac..e705356 100644 --- a/tests/s3.test.ts +++ b/tests/s3.test.ts @@ -2,6 +2,7 @@ import { describe, it } from "node:test"; import { expect } from "@std/expect"; import { createS3Client, S3Error, S3_LIMITS } from "../src/s3.ts"; +import { createS3Driver, createS3DriverFromClient } from "../src/driver/s3.ts"; import { RequestCapture } from "./http.ts"; import { streamBytes } from "./stream.ts"; @@ -29,6 +30,20 @@ class S3CredentialSource { } describe("S3 client", () => { + it("reports direct clients as owned and injected clients as borrowed", () => { + const options = { + endpoint: "https://storage.example", + bucket: "bucket", + region: "auto", + credentials, + fetch: async () => new Response(null, { status: 200 }), + }; + const client = createS3Client(options); + + expect(createS3Driver(options).inspect().ownership).toBe("owned"); + expect(createS3DriverFromClient(client).inspect().ownership).toBe("borrowed"); + }); + it("creates a deterministic Signature Version 4 request", async () => { let request: Request | undefined; const client = createS3Client({ @@ -141,6 +156,63 @@ describe("S3 client", () => { expect(complete?.headers.get("if-match")).toBe("\"old\""); }); + it("delays multipart creation for a small unknown-length stream by default", async () => { + const requests: Request[] = []; + const client = createS3Client({ + endpoint: "https://storage.example", + bucket: "bucket", + region: "us-east-1", + credentials, + fetch: async (input, init) => { + 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: { 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); + }); + + it("can disable delayed multipart when request lifecycle parity is required", async () => { + const requests: Request[] = []; + const client = createS3Client({ + endpoint: "https://storage.example", + bucket: "bucket", + region: "us-east-1", + credentials, + delayedMultipart: false, + fetch: async (input, init) => { + const request = new Request(input, init); + requests.push(request); + const url = new URL(request.url); + if (request.method === "POST" && url.searchParams.has("uploads")) { + return xml("u-small"); + } + if (request.method === "PUT" && url.searchParams.has("partNumber")) { + return new Response(null, { status: 200, headers: { etag: "\"part-1\"" } }); + } + if (request.method === "POST" && url.searchParams.has("uploadId")) { + return xml("\"small\""); + } + if (request.method === "HEAD") { + return new Response(null, { status: 200, headers: { "content-length": "3", etag: "\"small\"" } }); + } + return new Response(null, { status: 500 }); + }, + }); + + 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); + }); + it("retains provider request identity on S3 errors", async () => { const client = createS3Client({ endpoint: "https://storage.example", @@ -171,8 +243,8 @@ describe("S3 client", () => { bucket: "bucket", region: "auto", credentials, - fetch: async (input) => { - const request = input instanceof Request ? input : new Request(input); + 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\"" } }); } @@ -293,6 +365,7 @@ describe("S3 client", () => { bucket: "bucket", region: "us-east-1", credentials, + delayedMultipart: false, fetch: async (input, init) => { const request = new Request(input, init); requests.push(request); @@ -553,6 +626,7 @@ describe("S3 client", () => { region: "us-east-1", credentials, abortTimeoutMs: 5_000, + delayedMultipart: false, fetch: async (input, init) => { const request = new Request(input, init); const url = new URL(request.url); diff --git a/tests/sqlite.test.ts b/tests/sqlite.test.ts index 08b288c..3bd8937 100644 --- a/tests/sqlite.test.ts +++ b/tests/sqlite.test.ts @@ -63,6 +63,14 @@ class MemorySqlite { } describe("direct SQLite adapter", () => { + it("reports an injected SQLite database as borrowed unless disposal is transferred", async () => { + const borrowed = await createSqliteAdapter(new MemorySqlite()); + const owned = await createSqliteAdapter(new MemorySqlite(), { disposeDatabase: true }); + + expect(borrowed.driver.inspect().ownership).toBe("borrowed"); + expect(owned.driver.inspect().ownership).toBe("owned"); + }); + it("reuses the SQLite db0 record contract and explicit ownership", async () => { const database = new MemorySqlite(); const adapter = await createSqliteAdapter(database, { disposeDatabase: true });