diff --git a/.gitignore b/.gitignore index 5105866..bff5d0f 100644 --- a/.gitignore +++ b/.gitignore @@ -274,4 +274,8 @@ $RECYCLE.BIN/ .vscode/ .coverage/ .test-results/ -.agents/ \ No newline at end of file +.agents/ + +# Playwright output +test-results/ +playwright-report/ diff --git a/README.md b/README.md index 075d33a..fd9949e 100644 --- a/README.md +++ b/README.md @@ -230,7 +230,7 @@ user policy: partition mode, part size, max parts, I/O concurrency derived: logical file capacity for the selected layout ``` -Large files use immutable physical generations and a manifest-last visibility point: +Large files use immutable physical generations, a manifest-last visibility point, and explicit age-gated reclamation: ```text old manifest -> old parts @@ -238,15 +238,27 @@ old manifest -> old parts write new part 0..N | v -write new manifest visibility point +atomic check old version | v -remove old reachable generation +commit retirement marker + new manifest + visibility point + | + v +old readers keep using immutable old parts + | + v +collect after retirement grace ``` -A failed write does not publish a partial logical file. Unknown-length streamed replacement uses the partitioned lane -when partitioning is enabled. `partition: "never"` disables that behavior and makes oversized/streaming requests fail or -use a bounded facade fallback instead of changing durable layout silently. +The metadata visibility commit uses Deno KV optimistic version checks, so an independent stale writer cannot publish over a +newer logical entry. A failed write does not publish a partial logical file. A reader that already resolved the previous manifest can finish against its immutable generation after an overwrite commits +while that generation remains inside the configured retirement grace. `collect()` measures a published generation's grace +period +from retirement, not from its potentially much older creation time. Unpublished crash leftovers have no retirement marker, +so collection uses their generation creation time. Unknown-length streamed replacement uses the partitioned lane when +partitioning is enabled. `partition: "never"` disables that behavior and makes oversized/streaming requests fail or use a +bounded facade fallback instead of changing durable layout silently. The preflight planner also evaluates the concrete virtual path. Deno KV limits serialized keys, so file size alone is not enough to decide whether an operation is admissible. @@ -327,9 +339,11 @@ Third-party packages can use `defineIntegration()` and the driver/adapter primit ## Testing and benchmarks follow the layers -Portable behavior uses `node:test` with `@std/expect`. The same source is checked/run in the supported server runtimes -where the runtime capability exists. Playwright Test owns actual Window, Worker, iframe, ServiceWorker, persistence, and -browser-storage coverage. Testcontainers owns disposable SeaweedFS and Azurite provider fixtures. +Deno is the primary runtime and release authority. Portable behavior uses `node:test` with `@std/expect` because Deno +can run those contracts directly. Node and Bun run the same portable tests as compatibility lanes, plus their +runtime-specific filesystem tests. Their ambient type extensions must not redefine the Deno-checked public core. +Playwright Test owns actual Window, Worker, iframe, ServiceWorker, persistence, and browser-storage coverage. +Testcontainers owns disposable SeaweedFS and Azurite provider fixtures. Provider benchmarks compare: diff --git a/deno.json b/deno.json index c248439..e2d71ed 100644 --- a/deno.json +++ b/deno.json @@ -65,6 +65,7 @@ "mitata": "npm:mitata@^1.0.34", "zod": "npm:zod@^4.4.3", "deno": "npm:@types/deno", + "bun-types": "npm:@types/bun", "@playwright/test": "npm:@playwright/test@^1.62.1", "vite": "npm:vite@^8.2.1", "@std/async": "jsr:@std/async@^1.5.0", @@ -136,18 +137,18 @@ "check:browser": "deno check src/driver/opfs.ts src/driver/localstorage.ts src/driver/indexeddb.ts src/driver/cache.ts src/adapter/opfs.ts src/adapter/localstorage.ts src/adapter/indexeddb.ts src/adapter/cache.ts tests/browser/playwright.config.ts tests/browser/opfs.spec.ts tests/browser/worker.spec.ts tests/browser/iframe.spec.ts tests/browser/service-worker.spec.ts tests/browser/adapters.spec.ts tests/browser/fixtures/app.ts bench/browser/playwright.config.ts bench/browser/opfs.spec.ts", "check:workers": "deno check tests/browser/fixtures/dedicated.ts tests/browser/fixtures/shared.ts tests/browser/fixtures/service.ts", "check:server": "deno check src/driver/deno.ts src/driver/node.ts src/driver/bun.ts src/driver/sqlite.ts src/adapter/deno.ts src/adapter/node.ts src/adapter/bun.ts src/adapter/sqlite.ts bench/deno.bench.ts bench/node.bench.ts bench/sqlite.bench.ts", - "check:tests": "deno check tests/path.test.ts tests/memory.test.ts tests/filesystem.test.ts tests/driver.test.ts tests/ecosystems.test.ts tests/chunk.test.ts tests/object.test.ts tests/s3.test.ts tests/azure.test.ts tests/sqlite.test.ts tests/deno-kv-partition.test.ts tests/node.test.ts tests/deno.test.ts tests/bun.test.ts", + "check:tests": "deno check tests/path.test.ts tests/memory.test.ts tests/filesystem.test.ts tests/request.test.ts tests/driver.test.ts tests/ecosystems.test.ts tests/chunk.test.ts tests/object.test.ts tests/s3.test.ts tests/azure.test.ts tests/sqlite.test.ts tests/deno-kv-partition.test.ts tests/node.test.ts tests/deno.test.ts tests/bun.test.ts", "check:deno-kv": "deno check --unstable-kv src/driver/deno-kv.ts src/adapter/deno-kv.ts tests/deno-kv.test.ts bench/deno-kv.bench.ts", "test": "deno task test:portable && deno task test:deno && deno task test:deno-kv", - "test:portable": "deno test --sanitize-ops --sanitize-resources --trace-leaks tests/path.test.ts tests/memory.test.ts tests/filesystem.test.ts tests/driver.test.ts tests/ecosystems.test.ts tests/chunk.test.ts tests/object.test.ts tests/s3.test.ts tests/azure.test.ts tests/sqlite.test.ts tests/deno-kv-partition.test.ts", + "test:portable": "deno test --sanitize-ops --sanitize-resources --trace-leaks tests/path.test.ts tests/memory.test.ts tests/filesystem.test.ts tests/request.test.ts tests/driver.test.ts tests/ecosystems.test.ts tests/chunk.test.ts tests/object.test.ts tests/s3.test.ts tests/azure.test.ts tests/sqlite.test.ts tests/deno-kv-partition.test.ts", "test:deno": "deno test --allow-read --allow-write --sanitize-ops --sanitize-resources --trace-leaks tests/deno.test.ts", "test:deno-kv": "deno test --unstable-kv --allow-read --allow-write --sanitize-ops --sanitize-resources --trace-leaks tests/deno-kv.test.ts", - "test:node": "node --test tests/path.test.ts tests/memory.test.ts tests/filesystem.test.ts tests/driver.test.ts tests/ecosystems.test.ts tests/chunk.test.ts tests/object.test.ts tests/s3.test.ts tests/azure.test.ts tests/sqlite.test.ts tests/deno-kv-partition.test.ts tests/node.test.ts", - "test:bun": "bun test tests/path.test.ts tests/memory.test.ts tests/filesystem.test.ts tests/driver.test.ts tests/ecosystems.test.ts tests/chunk.test.ts tests/object.test.ts tests/s3.test.ts tests/azure.test.ts tests/sqlite.test.ts tests/deno-kv-partition.test.ts tests/bun.test.ts", + "test:node": "node --test tests/path.test.ts tests/memory.test.ts tests/filesystem.test.ts tests/request.test.ts tests/driver.test.ts tests/ecosystems.test.ts tests/chunk.test.ts tests/object.test.ts tests/s3.test.ts tests/azure.test.ts tests/sqlite.test.ts tests/deno-kv-partition.test.ts tests/node.test.ts", + "test:bun": "bun test tests/path.test.ts tests/memory.test.ts tests/filesystem.test.ts tests/request.test.ts tests/driver.test.ts tests/ecosystems.test.ts tests/chunk.test.ts tests/object.test.ts tests/s3.test.ts tests/azure.test.ts tests/sqlite.test.ts tests/deno-kv-partition.test.ts tests/bun.test.ts", "test:browser": "deno run -A npm:@playwright/test@1.62.1 test --config tests/browser/playwright.config.ts", "test:browser:install": "deno run -A npm:playwright@1.62.1 install --with-deps chromium firefox webkit", - "test:stress": "deno test --sanitize-ops --sanitize-resources --shuffle=424242 --repeats=5 tests/path.test.ts tests/memory.test.ts tests/filesystem.test.ts tests/driver.test.ts tests/ecosystems.test.ts tests/chunk.test.ts tests/object.test.ts tests/s3.test.ts tests/azure.test.ts tests/sqlite.test.ts tests/deno-kv-partition.test.ts", - "test:coverage": "deno test --clean --coverage=.coverage --sanitize-ops --sanitize-resources tests/path.test.ts tests/memory.test.ts tests/filesystem.test.ts tests/driver.test.ts tests/ecosystems.test.ts tests/chunk.test.ts tests/object.test.ts tests/s3.test.ts tests/azure.test.ts tests/sqlite.test.ts tests/deno-kv-partition.test.ts && deno coverage .coverage --lcov --output=.coverage/lcov.info", + "test:stress": "deno test --sanitize-ops --sanitize-resources --shuffle=424242 --repeats=5 tests/path.test.ts tests/memory.test.ts tests/filesystem.test.ts tests/request.test.ts tests/driver.test.ts tests/ecosystems.test.ts tests/chunk.test.ts tests/object.test.ts tests/s3.test.ts tests/azure.test.ts tests/sqlite.test.ts tests/deno-kv-partition.test.ts", + "test:coverage": "deno test --clean --coverage=.coverage --sanitize-ops --sanitize-resources tests/path.test.ts tests/memory.test.ts tests/filesystem.test.ts tests/request.test.ts tests/driver.test.ts tests/ecosystems.test.ts tests/chunk.test.ts tests/object.test.ts tests/s3.test.ts tests/azure.test.ts tests/sqlite.test.ts tests/deno-kv-partition.test.ts && deno coverage .coverage --lcov --output=.coverage/lcov.info", "bench": "deno task bench:memory && deno task bench:deno && deno task bench:deno-kv", "bench:memory": "deno run -A bench/memory.bench.ts", "bench:deno": "deno run -A bench/deno.bench.ts", diff --git a/deno.lock b/deno.lock index 64e69d1..91a8f18 100644 --- a/deno.lock +++ b/deno.lock @@ -28,6 +28,8 @@ "npm:@playwright/test@1.62.1": "1.62.1", "npm:@playwright/test@^1.62.1": "1.62.1", "npm:@testcontainers/azurite@^12.0.4": "12.1.0", + "npm:@types/bun@*": "1.3.14", + "npm:@types/bun@^1.3.14": "1.3.14", "npm:@types/deno@*": "2.7.0", "npm:@types/deno@^2.7.0": "2.7.0", "npm:@types/node@^26.2.0": "26.2.0", @@ -719,6 +721,12 @@ "testcontainers" ] }, + "@types/bun@1.3.14": { + "integrity": "sha512-h1hFqFVcvAvD9j9K7ZW7vd82aSA+rTdznZa+5bwvCwqSB1jmmfLcbIWhOLx1/+boy/xmjgCs/OMUL8hRJSmnPw==", + "dependencies": [ + "bun-types" + ] + }, "@types/deno@2.7.0": { "integrity": "sha512-Y6fWcV8KpYeO3Lik/RiYnUxtr/LWqoeACciU0CA+dr1U/45tGgaX3HWQQAPCNN53raN4Rr4Fht4y6qsUH57fDA==" }, @@ -1013,6 +1021,12 @@ "buildcheck@0.0.7": { "integrity": "sha512-lHblz4ahamxpTmnsk+MNTRWsjYKv965MwOrSJyeD588rR3Jcu7swE+0wN5F+PbL5cjgu/9ObkhfzEPuofEMwLA==" }, + "bun-types@1.3.14": { + "integrity": "sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ==", + "dependencies": [ + "@types/node" + ] + }, "byline@5.0.0": { "integrity": "sha512-s6webAy+R4SR8XVuJWt2V2rGvhnrhxN+9S15GNuTK3wKPOXFF6RNc+8ug2XhH+2s4f+uudG4kUVYmYOQWL2g0Q==" }, @@ -1890,6 +1904,7 @@ "npm:@azure/storage-blob@^12.33.0", "npm:@playwright/test@^1.62.1", "npm:@testcontainers/azurite@^12.0.4", + "npm:@types/bun@*", "npm:@types/deno@*", "npm:drizzle-orm@~0.45.2", "npm:mitata@^1.0.34", @@ -1913,6 +1928,7 @@ "npm:@okikio/undent@~0.3.3", "npm:@playwright/test@^1.62.1", "npm:@testcontainers/azurite@^12.0.4", + "npm:@types/bun@^1.3.14", "npm:@types/deno@^2.7.0", "npm:@types/node@^26.2.0", "npm:drizzle-orm@~0.45.2", diff --git a/package.json b/package.json index cd53bba..8431f19 100644 --- a/package.json +++ b/package.json @@ -122,6 +122,7 @@ "@playwright/test": "^1.62.1", "@std/expect": "npm:@jsr/std__expect@^1.0.20", "@testcontainers/azurite": "^12.0.4", + "@types/bun": "^1.3.14", "@types/deno": "^2.7.0", "@types/node": "^26.2.0", "drizzle-orm": "^0.45.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 70ddf37..80a2a17 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -54,6 +54,9 @@ importers: '@testcontainers/azurite': specifier: ^12.0.4 version: 12.1.0 + '@types/bun': + specifier: ^1.3.14 + version: 1.3.14 '@types/deno': specifier: ^2.7.0 version: 2.7.0 @@ -62,7 +65,7 @@ importers: version: 26.2.0 drizzle-orm: specifier: ^0.45.2 - version: 0.45.2 + version: 0.45.2(bun-types@1.3.14) mitata: specifier: ^1.0.34 version: 1.0.34 @@ -440,6 +443,9 @@ packages: '@testcontainers/azurite@12.1.0': resolution: {integrity: sha512-4l7vMOY92UPTe8sXyS0ZHbKjrpt9cXbg7HIHKrb3hbE5diU6T7Suu4hsM8ExNNnKTk2WEJX3Rl36BY7wRfrA1Q==} + '@types/bun@1.3.14': + resolution: {integrity: sha512-h1hFqFVcvAvD9j9K7ZW7vd82aSA+rTdznZa+5bwvCwqSB1jmmfLcbIWhOLx1/+boy/xmjgCs/OMUL8hRJSmnPw==} + '@types/deno@2.7.0': resolution: {integrity: sha512-Y6fWcV8KpYeO3Lik/RiYnUxtr/LWqoeACciU0CA+dr1U/45tGgaX3HWQQAPCNN53raN4Rr4Fht4y6qsUH57fDA==} @@ -712,6 +718,9 @@ packages: resolution: {integrity: sha512-lHblz4ahamxpTmnsk+MNTRWsjYKv965MwOrSJyeD588rR3Jcu7swE+0wN5F+PbL5cjgu/9ObkhfzEPuofEMwLA==} engines: {node: '>=10.0.0'} + bun-types@1.3.14: + resolution: {integrity: sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ==} + byline@5.0.0: resolution: {integrity: sha512-s6webAy+R4SR8XVuJWt2V2rGvhnrhxN+9S15GNuTK3wKPOXFF6RNc+8ug2XhH+2s4f+uudG4kUVYmYOQWL2g0Q==} engines: {node: '>=0.10.0'} @@ -1907,6 +1916,10 @@ snapshots: - react-native-b4a - supports-color + '@types/bun@1.3.14': + dependencies: + bun-types: 1.3.14 + '@types/deno@2.7.0': {} '@types/docker-modem@3.0.6': @@ -2130,6 +2143,10 @@ snapshots: buildcheck@0.0.7: optional: true + bun-types@1.3.14: + dependencies: + '@types/node': 26.2.0 + byline@5.0.0: {} chownr@1.1.4: {} @@ -2205,7 +2222,9 @@ snapshots: transitivePeerDependencies: - supports-color - drizzle-orm@0.45.2: {} + drizzle-orm@0.45.2(bun-types@1.3.14): + optionalDependencies: + bun-types: 1.3.14 eastasianwidth@0.2.0: {} diff --git a/tests/azure.test.ts b/tests/azure.test.ts index 20a2bbd..f34bf58 100644 --- a/tests/azure.test.ts +++ b/tests/azure.test.ts @@ -41,6 +41,34 @@ describe("Azure Blob client", () => { expect(createAzureDriverFromClient(client).inspect().ownership).toBe("borrowed"); }); + it("rejects invalid Azure metadata before provider I/O", async () => { + let requests = 0; + const client = createAzureClient({ + endpoint: "https://account.blob.core.windows.net", + container: "data", + credential: { kind: "sas", token: "?sig=secret" }, + fetch: async () => { + requests += 1; + return new Response(null, { status: 201 }); + }, + }); + + for (const metadata of [ + { "bad-key": "value" }, + { valid_key: "caf\u00e9" }, + { Duplicate: "first", duplicate: "second" }, + ]) { + try { + await client.put("metadata.bin", new Uint8Array([1]), { metadata }); + throw new Error("expected Azure metadata validation failure"); + } catch (error) { + expect(error).toBeInstanceOf(TypeError); + } + } + + expect(requests).toBe(0); + }); + it("keeps SAS authorization on the source URL during provider-side copy", async () => { const requests: Request[] = []; const client = createAzureClient({ diff --git a/tests/browser/adapters.spec.ts b/tests/browser/adapters.spec.ts index d58a606..28deff3 100644 --- a/tests/browser/adapters.spec.ts +++ b/tests/browser/adapters.spec.ts @@ -15,3 +15,9 @@ for (const kind of ["localstorage", "indexeddb", "cache"] as const) { expect(await page.evaluate(async ({ kind }) => await window.opfsTest.adapter(kind), { kind })).toBe(kind); }); } + +test("IndexedDB append preserves both independent writers", async ({ page }) => { + await ready(page); + const value = await page.evaluate(async () => await window.opfsTest.indexedDbAppend()); + expect(["baseAB", "baseBA"]).toContain(value); +}); diff --git a/tests/browser/fixtures/app.ts b/tests/browser/fixtures/app.ts index 5cd4491..8dbe0ef 100644 --- a/tests/browser/fixtures/app.ts +++ b/tests/browser/fixtures/app.ts @@ -2,6 +2,7 @@ import { openFileSystem, probeOpfs } from "../../../mod.ts"; import { createCacheAdapter } from "../../../src/adapter/cache.ts"; import { openIndexedDbAdapter } from "../../../src/adapter/indexeddb.ts"; import { createLocalStorageAdapter } from "../../../src/adapter/localstorage.ts"; +import { createMemoryAdapter } from "../../../src/adapter/memory.ts"; import { createOpfsAdapter } from "../../../src/adapter/opfs.ts"; import { createFileSystem } from "../../../src/filesystem.ts"; @@ -22,7 +23,16 @@ interface RealmResultType { /** Browser record adapters exercised against their actual platform storage APIs. */ type BrowserAdapterType = "localstorage" | "indexeddb" | "cache"; -/** Timing totals for the raw backend, direct adapter, and filesystem facade paths. */ +/** Stable result returned by browser cancellation scenarios. */ +interface AbortResultType { + /** Whether this browser exposes the capability required by the scenario. */ + readonly supported: boolean; + /** JavaScript error name observed by the caller. */ + readonly name?: string; + /** Stable package error code observed by the caller. */ + readonly code?: string; +} + interface BenchmarkResultType { /** Elapsed milliseconds for direct platform storage operations. */ readonly rawMs: number; @@ -48,14 +58,18 @@ interface BrowserTestApiType { shared(path: string, value: string): Promise; /** Runs the OPFS scenario in a registered ServiceWorker. */ service(path: string, value: string): Promise; - /** Attempts an already-cancelled write and returns the observed terminal error name. */ - abort(path: string): Promise; + /** Attempts an already-cancelled write and reports its normalized terminal error. */ + abort(path: string): Promise; + /** Queues a write behind a real Web Lock and reports the normalized cancellation error. */ + queuedAbort(): Promise; /** Measures native OPFS against the direct OPFS adapter and facade. */ benchmark(iterations: number, bytes: number): Promise; /** Measures one browser record backend against its adapter and facade paths. */ benchmarkAdapter(kind: BrowserAdapterType, iterations: number, bytes: number): Promise; /** Proves one browser record adapter through a write/read facade round trip. */ adapter(kind: BrowserAdapterType): Promise; + /** Races two independent IndexedDB filesystem owners through atomic append transactions. */ + indexedDbAppend(): Promise; } declare global { @@ -144,24 +158,125 @@ async function runServiceWorker(path: string, value: string): Promise { +async function abortOpfsWrite(path: string): Promise { const probe = await probeOpfs(); - if (!probe.rootAvailable) return "unavailable"; + if (!probe.rootAvailable) return { supported: false }; const fileSystem = await openFileSystem(); const controller = new AbortController(); controller.abort(new DOMException("test abort", "AbortError")); try { try { await fileSystem.writeFile(path, "never", { parents: true, signal: controller.signal }); - return "committed"; + return { supported: true, name: "committed" }; } catch (error) { - return error instanceof Error ? error.name : String(error); + const code = typeof error === "object" && error !== null && typeof Reflect.get(error, "code") === "string" + ? Reflect.get(error, "code") as string + : undefined; + return { + supported: true, + name: error instanceof Error ? error.name : String(error), + ...(code === undefined ? {} : { code }), + }; } } finally { await fileSystem.close(); } } +/** Creates one controllable promise gate for browser lifecycle tests. */ +function deferred(): { readonly promise: Promise; resolve(): void } { + let resolve!: () => void; + const promise = new Promise((done) => { + resolve = done; + }); + return { promise, resolve }; +} + +/** Waits until the browser reports one request in the Web Locks pending queue. */ +async function waitForPendingWebLock(name: string): Promise { + const deadline = performance.now() + 1000; + while (performance.now() < deadline) { + const snapshot = await navigator.locks.query(); + if (snapshot.pending?.some((lock) => lock.name === name)) return; + await new Promise((resolve) => setTimeout(resolve, 0)); + } + throw new Error(`Web Locks did not report '${name}' as pending.`); +} + +/** + * Aborts a filesystem write while its exclusive file lock is queued in the browser. + * + * The blocker uses the exact lock name requested by the facade. This exercises + * the browser's real `navigator.locks.request()` rejection rather than a test + * double, which protects the normalization path that differs between local and + * Web Locks coordination. + */ +async function abortQueuedWebLock(): Promise { + if (navigator.locks === undefined) return { supported: false }; + const prefix = `test:web-lock-abort:${crypto.randomUUID()}`; + const path = "/queued.txt"; + const entered = deferred(); + const release = deferred(); + const lockName = `${prefix}:file:${path}`; + const blocker = navigator.locks.request(lockName, { mode: "exclusive" }, async () => { + entered.resolve(); + await release.promise; + }); + await entered.promise; + + const fileSystem = createFileSystem(createMemoryAdapter(), { + coordination: "web-locks", + lockPrefix: prefix, + }); + const controller = new AbortController(); + const write = fileSystem.writeFile(path, "never", { signal: controller.signal }); + await waitForPendingWebLock(lockName); + controller.abort(new DOMException("queued browser lock test", "AbortError")); + + try { + await write; + return { supported: true, name: "committed" }; + } catch (error) { + const code = typeof error === "object" && error !== null && typeof Reflect.get(error, "code") === "string" + ? Reflect.get(error, "code") as string + : undefined; + return { + supported: true, + name: error instanceof Error ? error.name : String(error), + ...(code === undefined ? {} : { code }), + }; + } finally { + release.resolve(); + await blocker; + await fileSystem.close(); + } +} + +/** + * Measures one logical benchmark batch with enough repetitions to exceed coarse browser timers. + * + * Some WebKit contexts quantize `performance.now()` enough that a very fast + * localStorage batch can report exactly zero milliseconds. Repeating the same + * batch until the accumulated sample spans several milliseconds preserves the + * benchmark unit (milliseconds per requested batch) while preventing timer + * resolution from becoming a false benchmark failure. + */ +async function measure(run: () => void | Promise, minimumMs = 5): Promise { + let batches = 0; + const start = performance.now(); + let elapsed = 0; + do { + await run(); + batches += 1; + elapsed = performance.now() - start; + } while (elapsed < minimumMs && batches < 1024); + + if (elapsed <= 0) { + throw new Error("The browser performance timer did not advance during the benchmark sample."); + } + return elapsed / batches; +} + /** Measures raw OPFS, direct adapter, and facade overhead in the same browser realm. */ async function benchmarkOpfs(iterations: number, bytes: number): Promise { const probe = await probeOpfs(); @@ -173,32 +288,32 @@ async function benchmarkOpfs(iterations: number, bytes: number): Promise { + for (let index = 0; index < iterations; index += 1) { + const writable = await rawFile.createWritable(); + await writable.write(payload); + await writable.close(); + await (await rawFile.getFile()).arrayBuffer(); + } + }); const direct = createOpfsAdapter(root); - const adapterStart = performance.now(); - for (let index = 0; index < iterations; index += 1) { - await direct.writeFile(`/${adapterName}`, payload, { mode: "replace" }); - await direct.readFile(`/${adapterName}`); - } - const adapterMs = performance.now() - adapterStart; + const adapterMs = await measure(async () => { + for (let index = 0; index < iterations; index += 1) { + await direct.writeFile(`/${adapterName}`, payload, { mode: "replace" }); + await direct.readFile(`/${adapterName}`); + } + }); const fileSystem = await openFileSystem({ coordination: "none", metrics: "none" }); let facadeMs = 0; try { - const facadeStart = performance.now(); - for (let index = 0; index < iterations; index += 1) { - await fileSystem.writeFile(`/${facadeName}`, payload); - await fileSystem.readFile(`/${facadeName}`); - } - facadeMs = performance.now() - facadeStart; + facadeMs = await measure(async () => { + for (let index = 0; index < iterations; index += 1) { + await fileSystem.writeFile(`/${facadeName}`, payload); + await fileSystem.readFile(`/${facadeName}`); + } + }); } finally { await fileSystem.close(); await root.removeEntry(rawName).catch(() => undefined); @@ -248,21 +363,21 @@ async function benchmarkAdapter( if (kind === "localstorage") { const rawKey = `opfs-bench:${id}:raw`; const rawValue = "x".repeat(bytes); - const rawStart = performance.now(); - for (let index = 0; index < iterations; index += 1) { - localStorage.setItem(rawKey, rawValue); - localStorage.getItem(rawKey); - } - const rawMs = performance.now() - rawStart; + const rawMs = await measure(() => { + for (let index = 0; index < iterations; index += 1) { + localStorage.setItem(rawKey, rawValue); + localStorage.getItem(rawKey); + } + }); const direct = createLocalStorageAdapter(localStorage, { prefix: `adapter-${id}` }); await direct.createDir("/bench"); - const adapterStart = performance.now(); - for (let index = 0; index < iterations; index += 1) { - await direct.writeFile(path, payload, { mode: "replace" }); - await direct.readFile(path); - } - const adapterMs = performance.now() - adapterStart; + const adapterMs = await measure(async () => { + for (let index = 0; index < iterations; index += 1) { + await direct.writeFile(path, payload, { mode: "replace" }); + await direct.readFile(path); + } + }); const fileSystem = createFileSystem(createLocalStorageAdapter(localStorage, { prefix: `facade-${id}` }), { coordination: "none", @@ -270,12 +385,13 @@ async function benchmarkAdapter( }); try { await fileSystem.ensureDir("/bench"); - const facadeStart = performance.now(); - for (let index = 0; index < iterations; index += 1) { - await fileSystem.writeFile(path, payload); - await fileSystem.readFile(path); - } - return { rawMs, adapterMs, facadeMs: performance.now() - facadeStart }; + const facadeMs = await measure(async () => { + for (let index = 0; index < iterations; index += 1) { + await fileSystem.writeFile(path, payload); + await fileSystem.readFile(path); + } + }); + return { rawMs, adapterMs, facadeMs }; } finally { localStorage.removeItem(rawKey); await fileSystem.close(); @@ -286,27 +402,27 @@ async function benchmarkAdapter( if (typeof indexedDB === "undefined") return null; const rawName = `opfs-bench-raw-${id}`; const rawDatabase = await openRawIndexedDb(rawName); - const rawStart = performance.now(); - for (let index = 0; index < iterations; index += 1) { - const write = rawDatabase.transaction("entries", "readwrite"); - write.objectStore("entries").put(payload, "value"); - await idbTransaction(write); - const read = rawDatabase.transaction("entries", "readonly"); - const readCommitted = idbTransaction(read); - await idbRequest(read.objectStore("entries").get("value")); - await readCommitted; - } - const rawMs = performance.now() - rawStart; + const rawMs = await measure(async () => { + for (let index = 0; index < iterations; index += 1) { + const write = rawDatabase.transaction("entries", "readwrite"); + write.objectStore("entries").put(payload, "value"); + await idbTransaction(write); + const read = rawDatabase.transaction("entries", "readonly"); + const readCommitted = idbTransaction(read); + await idbRequest(read.objectStore("entries").get("value")); + await readCommitted; + } + }); const adapterName = `opfs-bench-adapter-${id}`; const direct = await openIndexedDbAdapter({ name: adapterName }); await direct.createDir("/bench"); - const adapterStart = performance.now(); - for (let index = 0; index < iterations; index += 1) { - await direct.writeFile(path, payload, { mode: "replace" }); - await direct.readFile(path); - } - const adapterMs = performance.now() - adapterStart; + const adapterMs = await measure(async () => { + for (let index = 0; index < iterations; index += 1) { + await direct.writeFile(path, payload, { mode: "replace" }); + await direct.readFile(path); + } + }); const facadeName = `opfs-bench-facade-${id}`; const fileSystem = createFileSystem(await openIndexedDbAdapter({ name: facadeName }), { @@ -316,12 +432,13 @@ async function benchmarkAdapter( }); try { await fileSystem.ensureDir("/bench"); - const facadeStart = performance.now(); - for (let index = 0; index < iterations; index += 1) { - await fileSystem.writeFile(path, payload); - await fileSystem.readFile(path); - } - return { rawMs, adapterMs, facadeMs: performance.now() - facadeStart }; + const facadeMs = await measure(async () => { + for (let index = 0; index < iterations; index += 1) { + await fileSystem.writeFile(path, payload); + await fileSystem.readFile(path); + } + }); + return { rawMs, adapterMs, facadeMs }; } finally { rawDatabase.close(); await direct.dispose?.(); @@ -336,24 +453,24 @@ async function benchmarkAdapter( const rawName = `opfs-bench-raw-${id}`; const rawCache = await caches.open(rawName); const rawRequest = new Request(`https://opfs.invalid/bench/${id}`); - const rawStart = performance.now(); - for (let index = 0; index < iterations; index += 1) { - await rawCache.put(rawRequest, new Response(payload)); - const response = await rawCache.match(rawRequest); - await response?.arrayBuffer(); - } - const rawMs = performance.now() - rawStart; + const rawMs = await measure(async () => { + for (let index = 0; index < iterations; index += 1) { + await rawCache.put(rawRequest, new Response(payload)); + const response = await rawCache.match(rawRequest); + await response?.arrayBuffer(); + } + }); const adapterName = `opfs-bench-adapter-${id}`; const adapterCache = await caches.open(adapterName); const direct = createCacheAdapter(adapterCache, { prefix: id }); await direct.createDir("/bench"); - const adapterStart = performance.now(); - for (let index = 0; index < iterations; index += 1) { - await direct.writeFile(path, payload, { mode: "replace" }); - await direct.readFile(path); - } - const adapterMs = performance.now() - adapterStart; + const adapterMs = await measure(async () => { + for (let index = 0; index < iterations; index += 1) { + await direct.writeFile(path, payload, { mode: "replace" }); + await direct.readFile(path); + } + }); const facadeName = `opfs-bench-facade-${id}`; const facadeCache = await caches.open(facadeName); @@ -363,12 +480,13 @@ async function benchmarkAdapter( }); try { await fileSystem.ensureDir("/bench"); - const facadeStart = performance.now(); - for (let index = 0; index < iterations; index += 1) { - await fileSystem.writeFile(path, payload); - await fileSystem.readFile(path); - } - return { rawMs, adapterMs, facadeMs: performance.now() - facadeStart }; + const facadeMs = await measure(async () => { + for (let index = 0; index < iterations; index += 1) { + await fileSystem.writeFile(path, payload); + await fileSystem.readFile(path); + } + }); + return { rawMs, adapterMs, facadeMs }; } finally { await fileSystem.close(); await caches.delete(rawName); @@ -377,7 +495,39 @@ async function benchmarkAdapter( } } -/** Runs one real browser record adapter through a filesystem write/read round trip. */ +/** + * Races append writes through two independent IndexedDB connections. + * + * A generic record adapter would read the same starting bytes in both owners and + * then let the last complete-record replacement win. The IndexedDB driver owns + * append/update in one readwrite transaction, so both appended bytes survive in + * whichever serial order IndexedDB grants the two transactions. + */ +async function indexedDbAppend(): Promise { + const name = `opfs-indexeddb-append-${crypto.randomUUID()}`; + const first = createFileSystem(await openIndexedDbAdapter({ name }), { + coordination: "none", + disposeAdapter: true, + }); + const second = createFileSystem(await openIndexedDbAdapter({ name }), { + coordination: "none", + disposeAdapter: true, + }); + try { + await first.writeFile("/shared.txt", "base"); + await Promise.all([ + first.writeFile("/shared.txt", "A", { mode: "append" }), + second.writeFile("/shared.txt", "B", { mode: "append" }), + ]); + return await first.readText("/shared.txt"); + } finally { + await first.close(); + await second.close(); + indexedDB.deleteDatabase(name); + } +} + +/** Runs one real browser record adapter through a filesystem write/read facade round trip. */ async function roundTripAdapter(kind: BrowserAdapterType): Promise { const id = crypto.randomUUID(); const path = `/adapters/${id}.txt`; @@ -425,7 +575,9 @@ window.opfsTest = { shared: async (path, value) => await runSharedWorker(new URL("./shared.ts", import.meta.url), path, value), service: runServiceWorker, abort: abortOpfsWrite, + queuedAbort: abortQueuedWebLock, benchmark: benchmarkOpfs, benchmarkAdapter, adapter: roundTripAdapter, + indexedDbAppend, }; diff --git a/tests/browser/opfs.spec.ts b/tests/browser/opfs.spec.ts index e57710e..a1a1494 100644 --- a/tests/browser/opfs.spec.ts +++ b/tests/browser/opfs.spec.ts @@ -23,7 +23,15 @@ test("window probes the actual capability and round-trips when OPFS is available test("an aborted write cannot commit", async ({ page }) => { await ready(page); const result = await page.evaluate(async () => await window.opfsTest.abort(`/abort/${crypto.randomUUID()}.txt`)); - expect(["AbortError", "unavailable"]).toContain(result); + test.skip(!result.supported, "OPFS is unavailable in this browser context."); + expect(result).toEqual({ supported: true, name: "FileSystemError", code: "aborted" }); +}); + +test("queued Web Locks cancellation is normalized to the package error", async ({ page }) => { + await ready(page); + const result = await page.evaluate(async () => await window.opfsTest.queuedAbort()); + test.skip(!result.supported, "This browser does not expose the Web Locks API."); + expect(result).toEqual({ supported: true, name: "FileSystemError", code: "aborted" }); }); test("fresh browser contexts do not inherit another context's OPFS file", async ({ browser }) => { diff --git a/tests/browser/playwright.config.ts b/tests/browser/playwright.config.ts index 32cf1d5..a134e81 100644 --- a/tests/browser/playwright.config.ts +++ b/tests/browser/playwright.config.ts @@ -5,6 +5,10 @@ import { env } from "node:process"; const first = "http://127.0.0.1:4173"; /** Secondary origin used only for cross-origin iframe scenarios. */ const second = "http://127.0.0.1:4174"; +/** Concrete fixture that returns HTTP 200 when the primary Vite server is ready. */ +const firstReady = `${first}/tests/browser/fixtures/index.html`; +/** Concrete fixture that returns HTTP 200 when the secondary Vite server is ready. */ +const secondReady = `${second}/tests/browser/fixtures/frame.html`; /** Whether Playwright should enable CI-only retries and strict focused-test checks. */ const ci = Boolean(env.CI); @@ -21,13 +25,13 @@ export default defineConfig({ }, webServer: [ { - command: "deno run -A npm:vite@8.2.1 --host 127.0.0.1 --port 4173 --strictPort", - url: first, + command: "deno run -A npm:vite@8.2.1 ../.. --host 127.0.0.1 --port 4173 --strictPort", + url: firstReady, reuseExistingServer: !ci, }, { - command: "deno run -A npm:vite@8.2.1 --host 127.0.0.1 --port 4174 --strictPort", - url: second, + command: "deno run -A npm:vite@8.2.1 ../.. --host 127.0.0.1 --port 4174 --strictPort", + url: secondReady, reuseExistingServer: !ci, }, ], diff --git a/tests/bun.test.ts b/tests/bun.test.ts index 34b13b1..596740c 100644 --- a/tests/bun.test.ts +++ b/tests/bun.test.ts @@ -6,8 +6,20 @@ import { join } from "node:path"; import { createFileSystem } from "../mod.ts"; import { createBunAdapter } from "../src/adapter/bun.ts"; +import { verifyHost } from "./host.ts"; describe("Bun adapter", () => { + it("preserves host range, directory removal, and overwrite semantics", async () => { + const root = await mkdtemp(join(tmpdir(), "okikio-opfs-bun-")); + const fileSystem = createFileSystem(createBunAdapter({ root }), { coordination: "local" }); + try { + await verifyHost(fileSystem); + } finally { + await fileSystem.close(); + await rm(root, { recursive: true, force: true }); + } + }); + it("uses real Bun file and synchronous filesystem APIs", async () => { const root = await mkdtemp(join(tmpdir(), "okikio-opfs-bun-")); const fileSystem = createFileSystem(createBunAdapter({ root }), { coordination: "local" }); diff --git a/tests/deno-kv-partition.test.ts b/tests/deno-kv-partition.test.ts index d38a57a..d293979 100644 --- a/tests/deno-kv-partition.test.ts +++ b/tests/deno-kv-partition.test.ts @@ -1,3 +1,4 @@ +/// import { describe, it } from "node:test"; import { expect } from "@std/expect"; @@ -7,6 +8,9 @@ import { DENO_KV_MAX_VALUE_BYTES, DENO_KV_SAFE_INLINE_BYTES, DENO_KV_SAFE_PART_BYTES, + type DenoKvAtomicType, + type DenoKvCheckType, + type DenoKvCommitType, type DenoKvEntryType, type DenoKvType, } from "../src/adapter/deno-kv.ts"; @@ -28,38 +32,131 @@ function size(value: unknown): number { return new TextEncoder().encode(JSON.stringify(value)).byteLength; } +/** Creates a promise gate used to interleave one logical read with a concurrent overwrite. */ +function deferred(): { promise: Promise; resolve: () => void } { + let resolve!: () => void; + const promise = new Promise((done) => { + resolve = done; + }); + return { promise, resolve }; +} + +/** Mutation staged by the in-memory Deno KV atomic-operation double. */ +type FakeDenoKvMutationType = + | { readonly kind: "set"; readonly key: Deno.KvKey; readonly value: unknown } + | { readonly kind: "delete"; readonly key: Deno.KvKey }; + +/** + * Optimistic transaction double that mirrors the Deno KV methods used by the driver. + * + * Checks are evaluated together immediately before mutation. This matters for the + * stale-writer test: all physical parts can exist while a failed version check + * still prevents their manifest from becoming visible. + */ +class FakeDenoKvAtomic implements DenoKvAtomicType { + readonly #database: FakeDenoKv; + readonly #checks: DenoKvCheckType[] = []; + readonly #mutations: FakeDenoKvMutationType[] = []; + + constructor(database: FakeDenoKv) { + this.#database = database; + } + + check(...checks: DenoKvCheckType[]): DenoKvAtomicType { + this.#checks.push(...checks); + return this; + } + + set(key: Deno.KvKey, value: unknown): DenoKvAtomicType { + this.#mutations.push({ kind: "set", key, value }); + return this; + } + + delete(key: Deno.KvKey): DenoKvAtomicType { + this.#mutations.push({ kind: "delete", key }); + return this; + } + + async commit(): Promise { + return this.#database.commit(this.#checks, this.#mutations); + } +} + /** - * Deno KV contract double with the documented per-value ceiling enforced. + * Deno KV contract double with value ceilings, versionstamps, and atomic checks. * * It deliberately exposes stored tuples so tests can prove partition cleanup and * listing behavior without depending on a Deno executable in the portable suite. + * Versionstamps change for every replacement so the same double can reproduce an + * independent writer winning after another writer has already read stale state. */ class FakeDenoKv implements DenoKvType { - readonly values = new Map(); + readonly values = new Map(); + #revision = 0; partGets = 0; listMatches = 0; + /** Optional gate that pauses physical part reads after the manifest has already been resolved. */ + partReadGate?: Promise; + /** Signals the first physical part read so the test can commit a concurrent generation. */ + partReadStarted?: () => void; + + /** Creates the next deterministic versionstamp for a provider mutation. */ + #version(): string { + this.#revision += 1; + return this.#revision.toString(36).padStart(8, "0"); + } + + /** Applies one provider replacement after enforcing the documented value ceiling. */ + #put(key: Deno.KvKey, value: unknown): void { + if (size(value) > DENO_KV_MAX_VALUE_BYTES) throw new RangeError("Deno KV value exceeds 64 KiB"); + this.values.set(id(key), { key: [...key], value, versionstamp: this.#version() }); + } async get(key: Deno.KvKey): Promise> { - if (key[1] === "part") this.partGets += 1; + if (key[1] === "part") { + this.partGets += 1; + this.partReadStarted?.(); + if (this.partReadGate !== undefined) await this.partReadGate; + } const found = this.values.get(id(key)); - return { key, value: (found?.value as T | undefined) ?? null }; + return { + key, + value: (found?.value as T | undefined) ?? null, + versionstamp: found?.versionstamp ?? null, + }; } async set(key: Deno.KvKey, value: unknown): Promise { - if (size(value) > DENO_KV_MAX_VALUE_BYTES) throw new RangeError("Deno KV value exceeds 64 KiB"); - this.values.set(id(key), { key: [...key], value }); + this.#put(key, value); } async delete(key: Deno.KvKey): Promise { this.values.delete(id(key)); } + atomic(): DenoKvAtomicType { + return new FakeDenoKvAtomic(this); + } + + /** Evaluates one optimistic transaction without yielding between checks and mutations. */ + commit(checks: readonly DenoKvCheckType[], mutations: readonly FakeDenoKvMutationType[]): DenoKvCommitType { + for (const check of checks) { + const current = this.values.get(id(check.key)); + if ((current?.versionstamp ?? null) !== check.versionstamp) return { ok: false }; + } + for (const mutation of mutations) { + if (mutation.kind === "set") this.#put(mutation.key, mutation.value); + else this.values.delete(id(mutation.key)); + } + return { ok: true }; + } + async *list(selector: Deno.KvListSelector): AsyncIterable> { if (!("prefix" in selector)) return; for (const entry of this.values.values()) { if (!starts(entry.key, selector.prefix)) continue; this.listMatches += 1; - yield { key: entry.key, value: entry.value as T }; + yield { key: entry.key, value: entry.value as T, versionstamp: entry.versionstamp }; } } } @@ -88,6 +185,8 @@ describe("Deno KV partitioned records", () => { it("rejects an oversized physical key during driver preflight before provider I/O", () => { const database = new FakeDenoKv(); const driver = createDenoKvDriver(database); + expect(driver.capabilities.replacement).toBe("atomic"); + expect(driver.capabilities.transactions).toBe(true); const path = `/${"segment".repeat(500)}`; const plan = driver.plan({ @@ -372,16 +471,111 @@ describe("Deno KV partitioned records", () => { await fileSystem.close(); }); - it("removes the previous partition generation after a successful smaller overwrite", async () => { + it("keeps an in-flight reader on the superseded generation until explicit collection", async () => { const database = new FakeDenoKv(); + const maintenance = createDenoKvDriver(database); const fileSystem = createFileSystem(createDenoKvAdapter(database), { coordination: "none" }); - await fileSystem.writeFile("/value.bin", bytes(180 * 1024)); - expect([...database.values.values()].some((entry) => entry.key[1] === "part")).toBe(true); + const initial = bytes(180 * 1024); + await fileSystem.writeFile("/value.bin", initial); + const oldParts = [...database.values.values()] + .filter((entry) => entry.key[1] === "part") + .map((entry) => id(entry.key)); + expect(oldParts.length).toBeGreaterThan(0); + + const started = deferred(); + const release = deferred(); + let signaled = false; + database.partReadStarted = () => { + if (signaled) return; + signaled = true; + started.resolve(); + }; + database.partReadGate = release.promise; + + const read = fileSystem.readFile("/value.bin"); + await started.promise; + await fileSystem.writeFile("/value.bin", new Uint8Array([1, 2, 3])); + expect(oldParts.every((value) => database.values.has(value))).toBe(true); + release.resolve(); + expect(await read).toEqual(initial); + expect([...await fileSystem.readFile("/value.bin")]).toEqual([1, 2, 3]); + + const guarded = await maintenance.collect({ minAgeMs: 60_000 }); + expect(guarded.deleted).toBe(0); + expect(oldParts.every((value) => database.values.has(value))).toBe(true); + + const reclaimed = await maintenance.collect({ minAgeMs: 0 }); + expect(reclaimed.deleted).toBe(oldParts.length); + expect(oldParts.every((value) => !database.values.has(value))).toBe(true); + await fileSystem.close(); + }); + + it("rejects a stale partitioned writer when another writer changes the logical entry", async () => { + const database = new FakeDenoKv(); + const fileSystem = createFileSystem(createDenoKvAdapter(database), { coordination: "none" }); + const initial = bytes(180 * 1024); + await fileSystem.writeFile("/value.bin", initial); + const originalParts = new Set( + [...database.values.values()] + .filter((entry) => entry.key[1] === "part") + .map((entry) => id(entry.key)), + ); + + const started = deferred(); + const release = deferred(); + let signaled = false; + database.partReadStarted = () => { + if (signaled) return; + signaled = true; + started.resolve(); + }; + database.partReadGate = release.promise; + + const stale = fileSystem.writeFile("/value.bin", new Uint8Array([7]), { mode: "update", at: 0 }); + await started.promise; await fileSystem.writeFile("/value.bin", new Uint8Array([1, 2, 3])); + release.resolve(); + delete database.partReadGate; - expect([...database.values.values()].some((entry) => entry.key[1] === "part")).toBe(false); + let failure: unknown; + try { + await stale; + } catch (error) { + failure = error; + } + expect(failure).toBeInstanceOf(FileSystemError); + if (failure instanceof FileSystemError) expect(failure.code).toBe("locked"); expect([...await fileSystem.readFile("/value.bin")]).toEqual([1, 2, 3]); + + const remainingParts = [...database.values.values()] + .filter((entry) => entry.key[1] === "part") + .map((entry) => id(entry.key)); + expect(remainingParts.every((key) => originalParts.has(key))).toBe(true); + await fileSystem.close(); + }); + + it("keeps a retirement marker until a bounded collection pass removes the complete generation", async () => { + const database = new FakeDenoKv(); + const driver = createDenoKvDriver(database); + const fileSystem = createFileSystem(createDenoKvAdapter(database), { coordination: "none" }); + await fileSystem.writeFile("/bounded.bin", bytes(180 * 1024)); + await fileSystem.writeFile("/bounded.bin", new Uint8Array([9])); + + const retired = [...database.values.values()] + .filter((entry) => entry.key[1] === "retired") + .map((entry) => id(entry.key)); + expect(retired.length).toBe(1); + + const first = await driver.collect({ minAgeMs: 0, maxDeletes: 2 }); + expect(first.deleted).toBe(2); + expect(first.truncated).toBe(true); + expect(database.values.has(retired[0]!)).toBe(true); + + const second = await driver.collect({ minAgeMs: 0 }); + expect(second.deleted).toBeGreaterThan(0); + expect(database.values.has(retired[0]!)).toBe(false); + expect([...await fileSystem.readFile("/bounded.bin")]).toEqual([9]); await fileSystem.close(); }); diff --git a/tests/deno.test.ts b/tests/deno.test.ts index 595105b..c5ce08b 100644 --- a/tests/deno.test.ts +++ b/tests/deno.test.ts @@ -3,8 +3,20 @@ import { expect } from "@std/expect"; import { createFileSystem } from "../mod.ts"; import { createDenoAdapter } from "../src/adapter/deno.ts"; +import { verifyHost } from "./host.ts"; describe("Deno adapter", () => { + it("preserves host range, directory removal, and overwrite semantics", async () => { + const root = await Deno.makeTempDir({ prefix: "okikio-opfs-" }); + const fileSystem = createFileSystem(createDenoAdapter({ root }), { coordination: "local" }); + try { + await verifyHost(fileSystem); + } finally { + await fileSystem.close(); + await Deno.remove(root, { recursive: true }); + } + }); + it("uses real Deno filesystem and synchronous file APIs", async () => { const root = await Deno.makeTempDir({ prefix: "okikio-opfs-" }); const fileSystem = createFileSystem(createDenoAdapter({ root }), { coordination: "local" }); diff --git a/tests/ecosystems.test.ts b/tests/ecosystems.test.ts index 6c613fe..d50401c 100644 --- a/tests/ecosystems.test.ts +++ b/tests/ecosystems.test.ts @@ -1,5 +1,7 @@ import { describe, it } from "node:test"; import { expect } from "@std/expect"; +import { integer, sqliteTable, text } from "drizzle-orm/sqlite-core"; +import { drizzle } from "drizzle-orm/sqlite-proxy"; import { createFileSystem } from "../mod.ts"; import { createDb0Adapter } from "../src/adapter/db0.ts"; @@ -176,62 +178,89 @@ class FakeDb0Database { } } -/** Creates a minimal Drizzle CRUD surface and caller-owned table mapping for driver tests. */ -function createFakeDrizzle() { - const table = { - path: { name: "path" }, - parent: { name: "parent" }, - name: { name: "name" }, - kind: { name: "kind" }, - data: { name: "data" }, - size: { name: "size" }, - lastModified: { name: "lastModified" }, - mediaType: { name: "mediaType" }, - }; - const rows: Array> = []; - const database = { - select() { - return { - from() { - return { - where(condition: { column: { name: string }; value: unknown }) { - const selected = () => - rows - .filter((row) => row[condition.column.name] === condition.value) - .map((row) => ({ ...row })); - return { - then(resolve: (value: Record[]) => unknown, reject: (reason: unknown) => unknown) { - return Promise.resolve(selected()).then(resolve, reject); - }, - limit(count: number) { - return Promise.resolve(selected().slice(0, count)); - }, - }; - }, - }; - }, - }; - }, - delete() { - return { - where(condition: { column: { name: string }; value: unknown }) { - for (let index = rows.length - 1; index >= 0; index -= 1) { - if (rows[index]?.[condition.column.name] === condition.value) rows.splice(index, 1); - } - return Promise.resolve(); - }, - }; - }, - insert() { - return { - values(value: Record) { - rows.push({ ...value }); - return Promise.resolve(); - }, - }; - }, - }; - return { database, table }; +/** Caller-owned SQLite table used to exercise the real Drizzle query builder. */ +const DrizzleTestTable = sqliteTable("opfs_entries", { + /** Canonical virtual path and logical primary key. */ + path: text("path").primaryKey(), + /** Canonical direct parent used by directory listing. */ + parent: text("parent").notNull(), + /** Final entry name. */ + name: text("name").notNull(), + /** File or directory discriminator. */ + kind: text("kind").notNull(), + /** Base64 file payload. Directories store null. */ + data: text("data"), + /** Decoded file byte length. */ + size: integer("size").notNull(), + /** Unix epoch milliseconds. */ + lastModified: integer("last_modified").notNull(), + /** File media type. Directories store null. */ + mediaType: text("media_type"), +}); + +/** Column order Drizzle emits when selecting the complete test table. */ +const DrizzleTestColumns = [ + "path", + "parent", + "name", + "kind", + "data", + "size", + "last_modified", + "media_type", +] as const; + +/** Physical row retained by the deterministic SQLite-proxy transport. */ +type DrizzleTestRowType = Record<(typeof DrizzleTestColumns)[number], unknown>; + +/** Extracts quoted identifiers from one generated SQL identifier list. */ +function getSqlNames(value: string): string[] { + return [...value.matchAll(/"([^"]+)"/g)].map((match) => match[1] ?? ""); +} + +/** + * Creates a deterministic transport under Drizzle's real SQLite proxy driver. + * + * The transport does not imitate Drizzle's `eq()` expression objects. Drizzle + * itself builds SQL from the real table and condition objects, then this small + * test database applies only the SELECT/INSERT/DELETE statements required by + * the generic record driver. This protects the integration from changes to + * Drizzle's private SQL-expression representation while keeping the test + * portable across Deno, Node, and Bun. + */ +function createTestDrizzle() { + const rows = new Map(); + const database = drizzle(async (sql, params) => { + const normalized = sql.trim().toLowerCase(); + + if (normalized.startsWith("select ")) { + const condition = /"(path|parent)"\s*=\s*\?/.exec(sql)?.[1] as "path" | "parent" | undefined; + if (condition === undefined) throw new Error(`Unexpected Drizzle SELECT: ${sql}`); + const value = params[0]; + const selected = [...rows.values()].filter((row) => row[condition] === value); + return { rows: selected.map((row) => DrizzleTestColumns.map((name) => row[name])) }; + } + + if (normalized.startsWith("insert ")) { + const match = /insert\s+into\s+"[^"]+"\s*\(([^)]+)\)\s*values/i.exec(sql); + if (match === null) throw new Error(`Unexpected Drizzle INSERT: ${sql}`); + const names = getSqlNames(match[1] ?? ""); + const row = Object.fromEntries(names.map((name, index) => [name, params[index]])) as DrizzleTestRowType; + rows.set(String(row.path), row); + return { rows: [] }; + } + + if (normalized.startsWith("delete ")) { + const condition = /"path"\s*=\s*\?/.test(sql); + if (!condition) throw new Error(`Unexpected Drizzle DELETE: ${sql}`); + rows.delete(String(params[0])); + return { rows: [] }; + } + + throw new Error(`Unexpected Drizzle SQL: ${sql}`); + }); + + return { database, table: DrizzleTestTable }; } /** Exercises the common record-backed filesystem contract against one ecosystem adapter. */ @@ -289,6 +318,39 @@ describe("ecosystem adapters", () => { expect(await driver.getItem("prefix:child")).toBe(null); }); + it("does not build KV reads or removal on advisory exists checks", async () => { + const fileSystem = createFileSystem(createMemoryAdapter(), { coordination: "local" }); + const bridge = createKeyValueBridge(fileSystem); + try { + await bridge.set("prefix", "parent-value"); + await bridge.set("prefix:child", "child-value"); + await bridge.setRaw("raw", new Uint8Array([1, 2, 3])); + + // `exists()` is deliberately advisory. If the bridge reintroduces a + // check-then-act precondition, this replacement turns the race-prone + // extra lookup into an immediate regression failure. + fileSystem.exists = async () => { + throw new Error("KV bridge must not use advisory exists() as an operation precondition."); + }; + + expect(await bridge.get("prefix")).toBe("parent-value"); + expect(await bridge.get("missing")).toBeNull(); + expect(await bridge.getRaw("raw")).toEqual(new Uint8Array([1, 2, 3])); + expect(await bridge.getRaw("missing")).toBeNull(); + expect((await bridge.meta("prefix"))?.modified).toBeInstanceOf(Date); + expect(await bridge.meta("missing")).toBeNull(); + expect(await bridge.keys("prefix")).toContain("prefix:child"); + expect(await bridge.keys("missing")).toEqual([]); + await bridge.remove("missing"); + await bridge.clear("missing"); + await bridge.clear("prefix", { preserveExact: true }); + expect(await bridge.get("prefix")).toBe("parent-value"); + expect(await bridge.get("prefix:child")).toBeNull(); + } finally { + await fileSystem.close(); + } + }); + it("targets RxCollection above the selected RxStorage engine", async () => { expect(RxDbRecordJsonSchema.primaryKey).toBe("path"); expect(RxDbRecordJsonSchema.indexes).toEqual(["parent"]); @@ -313,8 +375,8 @@ describe("ecosystem adapters", () => { } it("uses the common Drizzle CRUD surface with a caller-owned table", async () => { - const { database, table } = createFakeDrizzle(); - const fileSystem = createFileSystem(createDrizzleAdapter({ database, table } as never), { coordination: "local" }); + const { database, table } = createTestDrizzle(); + const fileSystem = createFileSystem(createDrizzleAdapter({ database, table }), { coordination: "local" }); await exerciseRecordBackend(fileSystem); }); }); diff --git a/tests/filesystem.test.ts b/tests/filesystem.test.ts index cef6651..8b69f2f 100644 --- a/tests/filesystem.test.ts +++ b/tests/filesystem.test.ts @@ -1,10 +1,11 @@ import { describe, it } from "node:test"; import { expect } from "@std/expect"; -import { createFileSystem, FileSystemError, probeOpfs } from "../mod.ts"; +import { createFileSystem, FileSystemError, probeOpfs, toFileSystemError } from "../mod.ts"; import { defineAdapter } from "../src/adapter/definition.ts"; import { createMemoryAdapter } from "../src/adapter/memory.ts"; import { basename, dirname, isAncestorPath, joinPath, normalizePath, splitPath } from "../src/path.ts"; +import { withAbortSignal } from "../src/stream.ts"; /** Creates an isolated memory-backed facade so lock state cannot leak between filesystem tests. */ function createMemoryFileSystem(name: string = crypto.randomUUID(), options: Record = {}) { @@ -122,6 +123,64 @@ describe("filesystem facade", () => { } }); + it("normalizes queued Web Locks cancellation to the package aborted failure", async () => { + const original = Object.getOwnPropertyDescriptor(globalThis, "navigator"); + const requested: string[] = []; + Object.defineProperty(globalThis, "navigator", { + configurable: true, + value: { + locks: { + request(name: string, options: { mode: string; signal?: AbortSignal }, callback: () => Promise) { + requested.push(name); + if (name.endsWith(":file:/queued.txt")) { + return new Promise((_resolve, reject) => { + options.signal?.addEventListener( + "abort", + () => reject(new DOMException("Queued lock request was aborted.", "AbortError")), + { once: true }, + ); + }); + } + return callback(); + }, + }, + }, + }); + try { + const fileSystem = createFileSystem(createMemoryAdapter(), { + coordination: "web-locks", + lockPrefix: "test:web-lock-abort", + }); + const controller = new AbortController(); + const write = fileSystem.writeFile("/queued.txt", "data", { signal: controller.signal }); + await waitFor(() => requested.some((name) => name.endsWith(":file:/queued.txt"))); + controller.abort("cancel queued write"); + await expectFileSystemError(write, "aborted"); + await fileSystem.close(); + } finally { + if (original === undefined) Reflect.deleteProperty(globalThis, "navigator"); + else Object.defineProperty(globalThis, "navigator", original); + } + }); + + it("preserves stable package error fields when normalizing an error from another realm", () => { + const foreign = { + name: "FileSystemError", + message: "foreign read was aborted", + code: "aborted", + operation: "read", + path: "/foreign.bin", + }; + + const normalized = toFileSystemError(foreign, "fallback", "/ignored.bin"); + + expect(normalized).toBeInstanceOf(FileSystemError); + expect(normalized.code).toBe("aborted"); + expect(normalized.operation).toBe("read"); + expect(normalized.path).toBe("/foreign.bin"); + expect(normalized.message).toBe("foreign read was aborted"); + }); + it("keeps canonical path behavior stable", () => { expect(normalizePath("a/./b/../c")).toBe("/a/c"); expect(joinPath("/a", "b", "../c")).toBe("/a/c"); @@ -129,6 +188,7 @@ describe("filesystem facade", () => { expect(dirname("/a/c")).toBe("/a"); expect(basename("/a/c")).toBe("c"); expect(isAncestorPath("/a", "/a/c")).toBe(true); + expect(normalizePath("/caf\u00e9")).not.toBe(normalizePath("/cafe\u0301")); expect(() => normalizePath("../../escape")).toThrow(FileSystemError); expect(() => normalizePath("a\\b")).toThrow(FileSystemError); }); @@ -242,8 +302,41 @@ describe("filesystem facade", () => { await fileSystem.writeFile("/abort.bin", new Uint8Array(1024), { parents: true }); const controller = new AbortController(); const stream = await fileSystem.openReadStream("/abort.bin", { signal: controller.signal }); + const reader = stream.getReader(); + controller.abort("stop"); + await expectFileSystemError(reader.read(), "aborted"); + }); + + it("keeps abort authoritative when reader cancellation cleanup rejects", async () => { + const source = new ReadableStream({ + pull() {}, + cancel() { + throw new Error("reader cleanup failed"); + }, + }); + const controller = new AbortController(); + const stream = withAbortSignal(source, controller.signal, "/abort-cleanup.bin"); + const reader = stream.getReader(); + controller.abort("stop"); - await expectFileSystemError(new Response(stream).arrayBuffer(), "aborted"); + + await expectFileSystemError(reader.read(), "aborted"); + }); + + it("keeps the producer failure authoritative when reader cleanup also fails", async () => { + const source = new ReadableStream({ + pull() { + throw new Error("producer failed"); + }, + cancel() { + throw new Error("reader cleanup failed"); + }, + }); + const controller = new AbortController(); + const stream = withAbortSignal(source, controller.signal, "/producer.bin"); + const reader = stream.getReader(); + + await expect(reader.read()).rejects.toThrow("producer failed"); }); it("disposes an adapter only when ownership is explicit", async () => { diff --git a/tests/host.ts b/tests/host.ts new file mode 100644 index 0000000..6bb1268 --- /dev/null +++ b/tests/host.ts @@ -0,0 +1,84 @@ +import { expect } from "@std/expect"; + +import type { FileSystemType } from "../src/filesystem.ts"; + +/** Collects one host-driver stream without routing the assertion through `Response`. */ +async function bytes(source: ReadableStream): Promise { + const reader = source.getReader(); + const parts: Uint8Array[] = []; + let size = 0; + try { + while (true) { + const next = await reader.read(); + if (next.done) break; + parts.push(next.value); + size += next.value.byteLength; + } + } finally { + reader.releaseLock(); + } + + const output = new Uint8Array(size); + let offset = 0; + for (const part of parts) { + output.set(part, offset); + offset += part.byteLength; + } + return output; +} + +/** + * Verifies host-file semantics that differ materially from the portable memory backend. + * + * The portable memory suite cannot prove these operations because a record store + * deletes files and directories through the same primitive. Native host APIs do + * not. This shared scenario therefore runs against Deno, Node, and Bun so an + * empty-directory primitive, recursive facade removal, and overwrite cleanup all + * exercise the runtime's actual filesystem implementation. + */ +export async function verifyHost(fileSystem: FileSystemType): Promise { + const rangeSource = Uint8Array.from({ length: 160 * 1024 }, (_, index) => index % 251); + await fileSystem.writeFile("/range.bin", rangeSource); + const range = await bytes(await fileSystem.openReadStream("/range.bin", { at: 7, length: 128 * 1024 + 13 })); + expect(range).toEqual(rangeSource.slice(7, 7 + 128 * 1024 + 13)); + const emptyReader = (await fileSystem.openReadStream("/range.bin", { at: 7, length: 0 })).getReader(); + try { + expect(await emptyReader.read()).toEqual({ value: undefined, done: true }); + } finally { + emptyReader.releaseLock(); + } + + await fileSystem.mkdir("/range-dir"); + await expect(fileSystem.readFile("/range-dir", { length: 0 })).rejects.toMatchObject({ code: "type-mismatch" }); + await expect(fileSystem.openReadStream("/range-dir", { length: 0 })).rejects.toMatchObject({ code: "type-mismatch" }); + await expect(fileSystem.readFile("/range-missing", { length: 0 })).rejects.toMatchObject({ code: "not-found" }); + await expect(fileSystem.openReadStream("/range-missing", { length: 0 })).rejects.toMatchObject({ code: "not-found" }); + + await fileSystem.mkdir("/empty"); + await fileSystem.remove("/empty"); + expect(await fileSystem.exists("/empty")).toBe(false); + + await fileSystem.writeFile("/remove/a/b.txt", "remove", { parents: true }); + await fileSystem.remove("/remove", { recursive: true }); + expect(await fileSystem.exists("/remove")).toBe(false); + + await fileSystem.writeFile("/clear/a/b.txt", "clear", { parents: true }); + await fileSystem.emptyDir("/clear"); + expect(await fileSystem.exists("/clear", { kind: "directory" })).toBe(true); + const children = []; + for await (const entry of fileSystem.readDir("/clear")) children.push(entry.name); + expect(children).toEqual([]); + + await fileSystem.writeFile("/copy-source/new.txt", "copy", { parents: true }); + await fileSystem.writeFile("/copy-target/old.txt", "old", { parents: true }); + await fileSystem.copy("/copy-source", "/copy-target", { overwrite: true }); + expect(await fileSystem.readText("/copy-target/new.txt")).toBe("copy"); + expect(await fileSystem.exists("/copy-target/old.txt")).toBe(false); + + await fileSystem.writeFile("/move-source/new.txt", "move", { parents: true }); + await fileSystem.writeFile("/move-target/old.txt", "old", { parents: true }); + await fileSystem.move("/move-source", "/move-target", { overwrite: true }); + expect(await fileSystem.exists("/move-source")).toBe(false); + expect(await fileSystem.readText("/move-target/new.txt")).toBe("move"); + expect(await fileSystem.exists("/move-target/old.txt")).toBe(false); +} diff --git a/tests/node.test.ts b/tests/node.test.ts index db005eb..a33d4b4 100644 --- a/tests/node.test.ts +++ b/tests/node.test.ts @@ -11,6 +11,7 @@ import { createDb0Adapter } from "../src/adapter/db0.ts"; import { createNodeAdapter } from "../src/adapter/node.ts"; import { createSqliteAdapter } from "../src/adapter/sqlite.ts"; import type { Db0PrimitiveType } from "../src/driver/db0.ts"; +import { verifyHost } from "./host.ts"; /** Normalizes db0-style parameters to Node SQLite's narrower accepted input set. */ function toSqliteParams(params: readonly Db0PrimitiveType[]): SQLInputValue[] { @@ -41,6 +42,17 @@ class SqliteDb0Database { } describe("Node adapter", () => { + it("preserves host range, directory removal, and overwrite semantics", async () => { + const root = await mkdtemp(join(tmpdir(), "okikio-opfs-")); + const fileSystem = createFileSystem(createNodeAdapter({ root }), { coordination: "local" }); + try { + await verifyHost(fileSystem); + } finally { + await fileSystem.close(); + await rm(root, { recursive: true, force: true }); + } + }); + it("streams, renames, performs synchronous random access, and holds the path lock until close", async () => { const root = await mkdtemp(join(tmpdir(), "okikio-opfs-")); const fileSystem = createFileSystem(createNodeAdapter({ root }), { coordination: "local" }); diff --git a/tests/object.test.ts b/tests/object.test.ts index a626eae..1ae9027 100644 --- a/tests/object.test.ts +++ b/tests/object.test.ts @@ -172,6 +172,7 @@ describe("object driver adapter", () => { expect((await fileSystem.stat("/empty")).kind).toBe("directory"); expect((await fileSystem.stat("/external")).kind).toBe("directory"); expect(store.values.has("empty/")).toBe(true); + expect(store.values.get("empty/")?.stat.metadata).toEqual({ okikio_opfs_kind: "directory" }); const names: string[] = []; for await (const entry of fileSystem.readDir("/")) names.push(entry.name); diff --git a/tests/request.test.ts b/tests/request.test.ts new file mode 100644 index 0000000..1708683 --- /dev/null +++ b/tests/request.test.ts @@ -0,0 +1,150 @@ +import { describe, it } from "node:test"; +import { expect } from "@std/expect"; + +import { RequestMetrics, sendRequest } from "../src/request.ts"; + +/** Stable URL used by request-policy tests without opening a real network connection. */ +const TEST_URL = new URL("https://storage.example/object"); + +describe("request policy", () => { + it("supports a zero-delay retry policy without violating @std/async validation", async () => { + let fetches = 0; + const metrics = new RequestMetrics(); + const response = await sendRequest( + async () => ({ input: TEST_URL }), + { + fetch: async () => { + fetches += 1; + return new Response(null, { status: fetches === 1 ? 503 : 200 }); + }, + policy: { retries: 1, minDelayMs: 0, maxDelayMs: 0, jitter: 0 }, + metrics, + }, + ); + + expect(response.status).toBe(200); + expect(fetches).toBe(2); + expect(metrics.snapshot()).toMatchObject({ requests: 2, retries: 1, failures: 0, responses: 2 }); + }); + + it("bypasses the retry engine for an explicitly single-attempt request", async () => { + let fetches = 0; + const response = await sendRequest( + async () => ({ input: TEST_URL }), + { + fetch: async () => { + fetches += 1; + return new Response(null, { status: 503 }); + }, + policy: { retries: 5, minDelayMs: 0, maxDelayMs: 0, jitter: 0 }, + replayable: false, + }, + ); + + expect(response.status).toBe(503); + expect(fetches).toBe(1); + }); + + it("does not count or retry deterministic request preparation failures", async () => { + const metrics = new RequestMetrics(); + let fetches = 0; + let preparations = 0; + + await expect(sendRequest( + async () => { + preparations += 1; + throw new TypeError("invalid signing input"); + }, + { + fetch: async () => { + fetches += 1; + return new Response(null, { status: 200 }); + }, + policy: { retries: 4, minDelayMs: 0, maxDelayMs: 0, jitter: 0 }, + metrics, + }, + )).rejects.toThrow("invalid signing input"); + + expect(preparations).toBe(1); + expect(fetches).toBe(0); + expect(metrics.snapshot()).toMatchObject({ requests: 0, retries: 0, failures: 1, responses: 0 }); + }); + + it("retries a transport failure and returns the next response", async () => { + const metrics = new RequestMetrics(); + let fetches = 0; + + const response = await sendRequest( + async () => ({ input: TEST_URL }), + { + fetch: async () => { + fetches += 1; + if (fetches === 1) throw new TypeError("temporary network failure"); + return new Response(null, { status: 200 }); + }, + policy: { retries: 1, minDelayMs: 0, maxDelayMs: 0, jitter: 0 }, + metrics, + }, + ); + + expect(response.status).toBe(200); + expect(fetches).toBe(2); + expect(metrics.snapshot()).toMatchObject({ requests: 2, retries: 1, failures: 0, responses: 1 }); + }); + + it("retries an attempt timeout during preparation without inventing an HTTP retry", async () => { + const metrics = new RequestMetrics(); + let preparations = 0; + let fetches = 0; + + const response = await sendRequest( + async (signal) => { + preparations += 1; + if (preparations === 1) { + await new Promise((_resolve, reject) => { + signal?.addEventListener("abort", () => reject(signal.reason), { once: true }); + }); + } + return { input: TEST_URL }; + }, + { + fetch: async () => { + fetches += 1; + return new Response(null, { status: 200 }); + }, + policy: { retries: 1, minDelayMs: 0, maxDelayMs: 0, jitter: 0, timeoutMs: 5 }, + metrics, + }, + ); + + expect(response.status).toBe(200); + expect(preparations).toBe(2); + expect(fetches).toBe(1); + expect(metrics.snapshot()).toMatchObject({ requests: 1, retries: 0, failures: 0, responses: 1 }); + }); + + it("treats caller cancellation as authoritative and does not retry it", async () => { + const controller = new AbortController(); + controller.abort(new DOMException("caller stopped request", "AbortError")); + let preparations = 0; + let fetches = 0; + + await expect(sendRequest( + async () => { + preparations += 1; + return { input: TEST_URL }; + }, + { + fetch: async () => { + fetches += 1; + return new Response(null, { status: 200 }); + }, + signal: controller.signal, + policy: { retries: 4, minDelayMs: 0, maxDelayMs: 0, jitter: 0 }, + }, + )).rejects.toMatchObject({ name: "AbortError" }); + + expect(preparations).toBe(0); + expect(fetches).toBe(0); + }); +}); diff --git a/tsconfig.json b/tsconfig.json index c971702..0f8bb2b 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,13 +1,13 @@ { "compilerOptions": { - "target": "ESNext", - "module": "NodeNext", - "moduleResolution": "NodeNext", + "target": "esnext", + "module": "esnext", + "moduleResolution": "bundler", "lib": [ - "ESNext", - "DOM", - "DOM.Iterable", - "DOM.AsyncIterable" + "esnext", + "dom", + "dom.iterable", + "dom.asynciterable" ], "strict": true, "noUncheckedIndexedAccess": true,