From 9a11b98c04a66109ffdf41bb7235ade7a41ba004 Mon Sep 17 00:00:00 2001 From: Okiki Ojo Date: Thu, 20 Aug 2026 03:03:30 -0400 Subject: [PATCH] feat: enhance OPFS testing framework with type safety and new APIs - Introduced type definitions for OPFS-related operations in api.ts and opfs-types.ts. - Updated tests to utilize new type definitions for better type safety. - Added support for OPFS in various test scenarios including service workers and workers. - Created new TypeScript configuration files for OPFS DOM and worker environments. - Improved fixture API to ensure it does not augment global Window interface. - Implemented checks to ensure compatibility of native file and directory handles with OPFS. - Enhanced error handling and reporting in tests for better diagnostics. Signed-off-by: Okiki Ojo --- .mise/tasks/npm | 55 +----- .mise/tasks/publish-jsr | 1 + .mise/tasks/quality | 5 +- .mise/tasks/schema | 6 + .mise/tasks/schema-check | 6 + .mise/tasks/verify-npm | 5 +- bench/browser/opfs.spec.ts | 16 +- deno.json | 21 ++- deno.lock | 4 +- docs/api.md | 10 + docs/releasing.md | 12 ++ docs/validation.md | 40 ++++ mise.toml | 2 +- mod.ts | 7 + package.json | 5 +- scripts/deno.json | 5 + scripts/npm.ts | 108 +++++++++++ scripts/schema.ts | 100 ++++++++++ scripts/schema/compiler.ts | 142 +++++++++++++++ src/_schema_types.ts | 191 ++++++++++++++++++++ src/adapter/opfs.ts | 17 +- src/azure.ts | 19 +- src/driver/bun.ts | 2 +- src/driver/db0.ts | 6 +- src/driver/definition.ts | 18 +- src/driver/file.ts | 4 +- src/driver/indexeddb.ts | 6 +- src/driver/object.ts | 4 +- src/driver/opfs.ts | 140 ++++++++++---- src/driver/record.ts | 8 +- src/driver/rxdb.ts | 15 +- src/iframe.ts | 3 +- src/integration/definition.ts | 8 +- src/plan.ts | 20 +- src/probe.ts | 3 +- src/request.ts | 4 +- src/s3.ts | 26 ++- src/schema.ts | 125 +++++++------ tests/browser/adapters.spec.ts | 20 +- tests/browser/fixtures/api.ts | 79 ++++++++ tests/browser/fixtures/app.ts | 88 ++------- tests/browser/fixtures/opfs-worker-types.ts | 18 ++ tests/browser/iframe.spec.ts | 18 +- tests/browser/opfs-types.ts | 57 ++++++ tests/browser/opfs.spec.ts | 47 +++-- tests/browser/service-worker.spec.ts | 19 +- tests/browser/worker.spec.ts | 19 +- tests/driver.test.ts | 59 ++++++ tests/package/verify.mjs | 8 + tsconfig.opfs-dom.json | 17 ++ tsconfig.opfs-worker.json | 17 ++ 51 files changed, 1313 insertions(+), 322 deletions(-) create mode 100755 .mise/tasks/schema create mode 100755 .mise/tasks/schema-check create mode 100644 scripts/deno.json create mode 100644 scripts/npm.ts create mode 100644 scripts/schema.ts create mode 100644 scripts/schema/compiler.ts create mode 100644 src/_schema_types.ts create mode 100644 tests/browser/fixtures/api.ts create mode 100644 tests/browser/fixtures/opfs-worker-types.ts create mode 100644 tests/browser/opfs-types.ts create mode 100644 tsconfig.opfs-dom.json create mode 100644 tsconfig.opfs-worker.json diff --git a/.mise/tasks/npm b/.mise/tasks/npm index 2568da3..8f09fa4 100755 --- a/.mise/tasks/npm +++ b/.mise/tasks/npm @@ -1,64 +1,21 @@ #!/usr/bin/env bash -#MISE description="Build the npm tarball from deno.json and preserve Drizzle as an optional peer." +#MISE description="Build the npm tarball with dnt and preserve Drizzle as an optional peer." set -euo pipefail VERSION="${RELEASE_VERSION:-}" if [[ -z "$VERSION" ]]; then - VERSION="$(node -p "JSON.parse(require('node:fs').readFileSync('deno.json', 'utf8')).version")" + VERSION="$(node -p "JSON.parse(require('node:fs').readFileSync('deno.json', 'utf8')).version")" fi OUT="${RELEASE_DIR:-.release/npm}" rm -rf "$OUT" -mkdir -p "$OUT/extract" +mkdir -p "$OUT" -if [[ "${PACK_ALLOW_DIRTY:-0}" == "1" ]]; then - deno pack \ - --set-version "$VERSION" \ - --allow-dirty \ - --output "$OUT/deno.tgz" -else - deno pack \ - --set-version "$VERSION" \ - --output "$OUT/deno.tgz" -fi - -tar -xzf "$OUT/deno.tgz" -C "$OUT/extract" - -node --input-type=module - "$OUT/extract/package/package.json" <<'NODE' -import { readFile, writeFile } from 'node:fs/promises'; - -const path = process.argv[2]; -const generated = JSON.parse(await readFile(path, 'utf8')); -const source = JSON.parse(await readFile('package.json', 'utf8')); -const drizzle = generated.dependencies?.['drizzle-orm'] ?? source.peerDependencies['drizzle-orm']; - -if (generated.dependencies) { - delete generated.dependencies['drizzle-orm']; - if (Object.keys(generated.dependencies).length === 0) delete generated.dependencies; -} - -generated.description = source.description; -generated.license = source.license; -generated.repository = source.repository; -generated.homepage = source.homepage; -generated.bugs = source.bugs; -generated.keywords = source.keywords; -generated.sideEffects = false; -generated.engines = source.engines; -generated.peerDependencies = { ...generated.peerDependencies, 'drizzle-orm': drizzle }; -generated.peerDependenciesMeta = { - ...generated.peerDependenciesMeta, - 'drizzle-orm': { optional: true }, -}; - -await writeFile(path, `${JSON.stringify(generated, null, 2)}\n`); -NODE - -rm "$OUT/deno.tgz" -npm pack "$OUT/extract/package" --pack-destination "$OUT" --json > "$OUT/pack.json" +deno run --config scripts/deno.json --no-lock -A scripts/npm.ts "$VERSION" "$OUT/package" +npm pack "$OUT/package" --pack-destination "$OUT" --json > "$OUT/pack.json" node --input-type=module - "$OUT/pack.json" <<'NODE' import { readFile } from 'node:fs/promises'; const [result] = JSON.parse(await readFile(process.argv[2], 'utf8')); if (!result?.filename) throw new Error('npm pack did not report a tarball filename.'); -console.log(`${process.cwd()}/${process.argv[2].replace(/pack\.json$/, result.filename)}`); +console.log(new URL(result.filename, `file://${process.cwd()}/${process.argv[2].replace(/pack\.json$/, '')}`).pathname); NODE diff --git a/.mise/tasks/publish-jsr b/.mise/tasks/publish-jsr index 4b45721..4a82033 100755 --- a/.mise/tasks/publish-jsr +++ b/.mise/tasks/publish-jsr @@ -4,5 +4,6 @@ set -euo pipefail : "${RELEASE_VERSION:?RELEASE_VERSION is required}" deno ci +deno task schema:check deno publish --dry-run --set-version "$RELEASE_VERSION" deno publish --set-version "$RELEASE_VERSION" diff --git a/.mise/tasks/quality b/.mise/tasks/quality index 5aa49a6..ba6f266 100755 --- a/.mise/tasks/quality +++ b/.mise/tasks/quality @@ -1,8 +1,9 @@ #!/usr/bin/env bash -#MISE description="Run the complete Deno quality, stress, coverage, and package dry-run gate." +#MISE description="Run the complete Deno quality, stress, coverage, JSR, and npm build gates." set -euo pipefail deno ci +deno task schema:check deno task check deno task lint deno task doc @@ -10,4 +11,4 @@ deno task fmt:check deno task test:stress deno task test:coverage deno publish --dry-run -deno pack --dry-run --no-deno-shim +RELEASE_VERSION=0.0.0-quality mise run npm diff --git a/.mise/tasks/schema b/.mise/tasks/schema new file mode 100755 index 0000000..b7793b3 --- /dev/null +++ b/.mise/tasks/schema @@ -0,0 +1,6 @@ +#!/usr/bin/env bash +#MISE description="Regenerate explicit TypeScript contracts from Zod schemas." +set -euo pipefail + +deno ci +deno task schema diff --git a/.mise/tasks/schema-check b/.mise/tasks/schema-check new file mode 100755 index 0000000..f1df423 --- /dev/null +++ b/.mise/tasks/schema-check @@ -0,0 +1,6 @@ +#!/usr/bin/env bash +#MISE description="Verify checked-in schema-derived TypeScript contracts are current." +set -euo pipefail + +deno ci +deno task schema:check diff --git a/.mise/tasks/verify-npm b/.mise/tasks/verify-npm index 0025d9a..1b5c7f0 100755 --- a/.mise/tasks/verify-npm +++ b/.mise/tasks/verify-npm @@ -1,7 +1,8 @@ #!/usr/bin/env bash -#MISE description="Build the npm tarball and verify it from Node, Deno, and Bun consumers." +#MISE description="Build the dnt npm tarball and verify it from Node, Deno, and Bun consumers." set -euo pipefail deno ci -PACK_ALLOW_DIRTY="${PACK_ALLOW_DIRTY:-0}" RELEASE_VERSION="${RELEASE_VERSION:-0.0.0-test}" mise run npm +deno task schema:check +RELEASE_VERSION="${RELEASE_VERSION:-0.0.0-test}" mise run npm node tests/package/verify.mjs .release/npm/*.tgz diff --git a/bench/browser/opfs.spec.ts b/bench/browser/opfs.spec.ts index ce803f7..2aba233 100644 --- a/bench/browser/opfs.spec.ts +++ b/bench/browser/opfs.spec.ts @@ -1,12 +1,19 @@ import { expect, test } from "@playwright/test"; +import type { BrowserTestGlobalType } from "../../tests/browser/fixtures/api.ts"; + +/** File-local browser global shape after the benchmark fixture installs its API. */ +type InstalledFixtureGlobalType = typeof globalThis & BrowserTestGlobalType; +/** File-local Window shape while the benchmark fixture module may still be initializing. */ +type PendingFixtureWindowType = typeof window & Partial; + /** Fixture page that exposes raw, adapter, and facade browser benchmark operations. */ const APP_URL = "http://127.0.0.1:4173/tests/browser/fixtures/index.html"; /** Opens the benchmark fixture and waits for its callable API. */ async function ready(page: import("@playwright/test").Page): Promise { await page.goto(APP_URL); - await page.waitForFunction(() => Boolean((window as unknown as { opfsTest?: { ready?: boolean } }).opfsTest?.ready)); + await page.waitForFunction(() => Boolean((window as PendingFixtureWindowType).opfsTest?.ready)); } /** Normalizes one browser benchmark result into comparable overhead ratios. */ @@ -31,7 +38,9 @@ function report( test("reports raw native OPFS, direct adapter, and facade overhead", async ({ browserName, page }, testInfo) => { await ready(page); - const result = await page.evaluate(async () => await globalThis.opfsTest.benchmark(25, 64 * 1024)); + const result = await page.evaluate(async () => + await (globalThis as InstalledFixtureGlobalType).opfsTest.benchmark(25, 64 * 1024) + ); test.skip(result === null, "OPFS is unavailable in this browser context."); expect(result!.rawMs).toBeGreaterThan(0); expect(result!.adapterMs).toBeGreaterThan(0); @@ -48,7 +57,8 @@ for (const backend of ["localstorage", "indexeddb", "cache"] as const) { test(`reports raw ${backend}, direct adapter, and facade overhead`, async ({ browserName, page }, testInfo) => { await ready(page); const result = await page.evaluate( - async ({ backend }) => await globalThis.opfsTest.benchmarkAdapter(backend, 20, 16 * 1024), + async ({ backend }) => + await (globalThis as InstalledFixtureGlobalType).opfsTest.benchmarkAdapter(backend, 20, 16 * 1024), { backend }, ); test.skip(result === null, `${backend} is unavailable in this browser context.`); diff --git a/deno.json b/deno.json index 9c864cf..1a5a4c3 100644 --- a/deno.json +++ b/deno.json @@ -64,7 +64,7 @@ "drizzle-orm": "npm:drizzle-orm@^0.45.2", "mitata": "npm:mitata@^1.0.34", "zod": "npm:zod@^4.4.3", - "deno": "npm:@types/deno", + "deno": "npm:@types/deno@latest", "bun-types": "npm:@types/bun", "@playwright/test": "npm:@playwright/test@^1.62.1", "vite": "npm:vite@^8.2.1", @@ -110,7 +110,8 @@ "README.md", "AGENTS.md", "deno.json", - "package.json" + "package.json", + "scripts/" ] }, "lint": { @@ -119,7 +120,6 @@ "recommended" ], "exclude": [ - "no-slow-types", "require-await" ] }, @@ -127,15 +127,17 @@ "mod.ts", "src/", "tests/", - "bench/" + "bench/", + "scripts/" ] }, "tasks": { "deps:ci": "deno ci", - "check": "deno task check:core && deno task check:browser && deno task check:workers && deno task check:server && deno task check:tests && deno task check:deno-kv && deno task check:providers", + "check": "deno task schema:check && deno task check:release && deno task check:core && deno task check:browser && deno task check:workers && deno task check:typescript:opfs && deno task check:server && deno task check:tests && deno task check:deno-kv && deno task check:providers", "check:core": "deno check mod.ts src/chunk.ts src/xml.ts src/request.ts src/metrics.ts src/capability.ts src/plan.ts src/bridge.ts src/bridge/kv.ts src/bridge/unstorage.ts src/integration.ts src/integration/definition.ts src/integration/mod.ts src/driver/definition.ts src/driver/file.ts src/driver/record.ts src/driver/object.ts src/driver/memory.ts src/driver/unstorage.ts src/driver/rxdb.ts src/driver/db0.ts src/driver/drizzle.ts src/driver/s3.ts src/driver/azure.ts src/adapter/definition.ts src/adapter/file.ts src/adapter/opfs.ts src/adapter/memory.ts src/adapter/record.ts src/adapter/object.ts src/adapter/unstorage.ts src/adapter/rxdb.ts src/adapter/db0.ts src/adapter/drizzle.ts src/adapter/s3.ts src/adapter/azure.ts src/iframe.ts src/path.ts src/schema.ts src/s3.ts src/azure.ts", - "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: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/opfs-types.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/opfs-worker-types.ts tests/browser/fixtures/dedicated.ts tests/browser/fixtures/shared.ts tests/browser/fixtures/service.ts", + "check:typescript:opfs": "tsc -p tsconfig.opfs-dom.json && tsc -p tsconfig.opfs-worker.json", "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/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/host.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", @@ -167,7 +169,10 @@ "check:providers": "deno check tests/provider/fixture.ts tests/provider.test.ts bench/provider.bench.ts bench/providers.ts", "bench:providers": "node bench/providers.ts", "bench:bun-providers": "bun run bench/bun-provider.bench.ts", - "bench:filesystem-clients": "node bench/filesystem-provider.bench.ts" + "bench:filesystem-clients": "node bench/filesystem-provider.bench.ts", + "schema": "deno run --allow-read --allow-write scripts/schema.ts", + "schema:check": "deno run --allow-read scripts/schema.ts --check", + "check:release": "deno check scripts/schema.ts scripts/schema/compiler.ts && deno check --config scripts/deno.json --no-lock scripts/npm.ts" }, "publish": { "include": [ diff --git a/deno.lock b/deno.lock index 91a8f18..cbb573c 100644 --- a/deno.lock +++ b/deno.lock @@ -30,8 +30,8 @@ "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/deno@latest": "2.7.0", "npm:@types/node@^26.2.0": "26.2.0", "npm:drizzle-orm@~0.45.2": "0.45.2", "npm:mitata@^1.0.34": "1.0.34", @@ -1905,7 +1905,7 @@ "npm:@playwright/test@^1.62.1", "npm:@testcontainers/azurite@^12.0.4", "npm:@types/bun@*", - "npm:@types/deno@*", + "npm:@types/deno@latest", "npm:drizzle-orm@~0.45.2", "npm:mitata@^1.0.34", "npm:testcontainers@^12.0.4", diff --git a/docs/api.md b/docs/api.md index eb3f9ba..2470b85 100644 --- a/docs/api.md +++ b/docs/api.md @@ -455,6 +455,16 @@ const adapter = createOpfsAdapter(root); // convenience path creates its own dri `createOpfsDriver(root)` returns `OpfsDriverType`. `createOpfsAdapter(root)` returns `OpfsAdapterType` and retains `nativeRoot`. +The OPFS source uses runtime-neutral structural handle types instead of requiring the browser-global +`FileSystemDirectoryHandle`, `FileSystemFileHandle`, and `FileSystemWritableFileStream` declarations in every runtime. +Those structural types contain only operations the driver calls. The generic root type preserves the caller's concrete +browser handle on `nativeRoot`. + +Browser-only compile checks keep that portability claim tied to TypeScript's platform declarations. Window checks prove +that the native directory, file, writable-stream, and `StorageManager.getDirectory()` types satisfy the structural +contracts. Worker checks separately prove the worker file handle, including `createSyncAccessHandle()`, remains +compatible. A mismatch is therefore a type-check failure rather than a cast hidden in the implementation. + ### Node ```ts diff --git a/docs/releasing.md b/docs/releasing.md index 758d9ca..72cb533 100644 --- a/docs/releasing.md +++ b/docs/releasing.md @@ -183,3 +183,15 @@ solely to make the release workflow progress. The publish job installs `npm:npm@11.18.0` through mise. npm trusted publishing requires npm CLI 11.5.1 or later and Node 22.14.0 or later. The explicit npm pin prevents the release path from depending on the bundled npm version of the selected Node release. + +## Schema-derived public types + +Zod remains the runtime validation source of truth, but JSR fast-type extraction must not infer Zod's internal generic graph from exported schemas. `scripts/schema.ts` compiles the supported Zod v4 definitions into `src/_schema_types.ts`; public schemas expose explicit `z.ZodType` contracts and public data aliases point at the generated structural types. + +Run `mise run schema` after changing a compiled schema. `mise run schema-check`, the canonical `check` task, JSR publication, and npm verification reject stale generated contracts. The project-local compiler intentionally throws for unknown Zod definition kinds instead of emitting `any`. + +## Registry artifacts + +JSR publishes the original TypeScript graph with `deno publish`. npm is a separate derived artifact built by `scripts/npm.ts` with dnt. The dnt build derives its entry points from `deno.json`, emits ESM plus declarations, maps `@okikio/undent` to its normal npm package, retains `drizzle-orm` as an optional peer, and rejects any generated `@jsr/*` npm dependency. + +The immutable `opfs@` Git tag remains the release-version authority for both registry jobs. semantic-release still creates that tag and GitHub Release; the packaging migration does not introduce a second version owner. diff --git a/docs/validation.md b/docs/validation.md index 65f11b2..1176deb 100644 --- a/docs/validation.md +++ b/docs/validation.md @@ -411,6 +411,27 @@ npm/deno package dry-run `mise run test`, browser tests, provider tests, and runtime matrix jobs add the environment-specific evidence. +### Browser platform type conformance + +OPFS uses structural source types so Node, Deno, and Bun do not need browser File System Access globals merely to import +the package. That makes browser type conformance an explicit validation responsibility rather than an ambient compiler +assumption. + +`tests/browser/opfs-types.ts` is checked with the Window browser graph. It proves TypeScript's native +`FileSystemDirectoryHandle`, `FileSystemFileHandle`, `FileSystemWritableFileStream`, and the return type of +`StorageManager.getDirectory()` satisfy the package contracts. It also proves `nativeRoot` preserves the caller's exact +native handle type. + +`tests/browser/fixtures/opfs-worker-types.ts` performs the worker-side proof. It covers the worker-only +`createSyncAccessHandle()` route and the synchronous access-handle contract. + +`deno task check:typescript:opfs` runs both files through the project's installed `typescript` package with separate DOM +and WebWorker `tsconfig` files. The normal `deno check` browser graphs still run as well. This intentionally gives the +project two independent declaration checks: Deno's runtime checker and the TypeScript version pinned in `deno.lock`. + +These files are compile-only checks. Playwright remains responsible for proving the corresponding APIs behave correctly +in real Chromium, Firefox, and WebKit environments. + ## Agent validation A ChatGPT/agent host can lack Deno, Bun, Docker, mise, package registry access, or Playwright browsers. Temporary @@ -449,3 +470,22 @@ The extracted artifact is the final thing that must pass the claimed checks. A g A release-ready claim requires all applicable canonical gates, including Deno, Node, Bun, Playwright, provider containers, package dry-runs, and lockfile validation. If the current host cannot run one of those environments, the result is recorded as unverified rather than passed. + +## Publication type and package gates + +Schema-derived public contracts are checked before the ordinary type graph: + +```sh +deno task schema:check +deno task check +``` + +Release validation keeps JSR and npm independent: + +```sh +deno publish --dry-run +RELEASE_VERSION=0.0.0-test mise run npm +node tests/package/verify.mjs .release/npm/*.tgz +``` + +The npm consumer test installs the produced tarball with ordinary npm and rejects `@jsr/*` dependencies, raw `.ts` implementation files, missing declarations, or a non-optional Drizzle peer. diff --git a/mise.toml b/mise.toml index 3cab53a..a7b3f87 100644 --- a/mise.toml +++ b/mise.toml @@ -3,7 +3,7 @@ min_version = "2026.8.0" [tools] node = "26" deno = "2" -bun = "latest" +bun = "1" pnpm = "11" "npm:npm" = "11" diff --git a/mod.ts b/mod.ts index a89669b..2d8a5ee 100644 --- a/mod.ts +++ b/mod.ts @@ -89,6 +89,13 @@ export type { SyncFileType } from "./src/sync.ts"; export type { WritableFileType } from "./src/writable.ts"; export type { WriteDataType } from "./src/stream.ts"; export type { BrowserGlobalType } from "./src/context.ts"; +export type { + OpfsDirectoryChildHandleType, + OpfsDirectoryHandleType, + OpfsDriverType, + OpfsFileHandleType, + OpfsWritableFileStreamType, +} from "./src/driver/opfs.ts"; export type { FileDriverCopyOptionsType, FileDriverDirectoryEntryType, diff --git a/package.json b/package.json index 8431f19..28eaf32 100644 --- a/package.json +++ b/package.json @@ -37,7 +37,10 @@ "check": "mise run check", "test": "mise run test", "fmt": "mise run fmt", - "bench": "mise run bench" + "bench": "mise run bench", + "schema": "mise run schema", + "schema:check": "mise run schema-check", + "verify:npm": "mise run verify-npm" }, "exports": { ".": "./mod.ts", diff --git a/scripts/deno.json b/scripts/deno.json new file mode 100644 index 0000000..caaf0c0 --- /dev/null +++ b/scripts/deno.json @@ -0,0 +1,5 @@ +{ + "imports": { + "@deno/dnt": "jsr:@deno/dnt@0.43.2" + } +} diff --git a/scripts/npm.ts b/scripts/npm.ts new file mode 100644 index 0000000..4eca206 --- /dev/null +++ b/scripts/npm.ts @@ -0,0 +1,108 @@ +import { build, emptyDir } from "@deno/dnt"; +import { fromFileUrl, join, resolve } from "jsr:@std/path@1.1.6"; + +interface DenoConfigType { + readonly exports: Readonly>; +} + +interface PackageSourceType { + readonly name: string; + readonly description?: string; + readonly license?: string; + readonly repository?: string | { readonly type: string; readonly url: string; readonly directory?: string }; + readonly homepage?: string; + readonly bugs?: string | { readonly url?: string; readonly email?: string }; + readonly keywords?: readonly string[]; + readonly engines?: Readonly>; + readonly peerDependencies?: Readonly>; +} + +/** Reads one JSON object without leaking an inferred filesystem shape into the public package. */ +async function readJson(path: string): Promise { + return JSON.parse(await Deno.readTextFile(path)) as Type; +} + +/** Converts one Deno export key into the export name expected by dnt. */ +function exportName(key: string): string { + return key === "." ? "." : key; +} + +/** Fails when dnt emitted an npm dependency that still requires the JSR compatibility registry. */ +async function assertNoJsrDependencies(path: string): Promise { + const manifest = await readJson>(path); + for (const field of ["dependencies", "peerDependencies", "optionalDependencies"] as const) { + const dependencies = manifest[field]; + if (typeof dependencies !== "object" || dependencies === null) continue; + for (const name of Object.keys(dependencies)) { + if (name.startsWith("@jsr/")) { + throw new Error(`npm package leaked JSR compatibility dependency '${name}' through ${field}.`); + } + } + } +} + +const version = Deno.args[0]; +if (version === undefined || version.length === 0) throw new TypeError("Pass the npm package version as the first argument."); +const output = resolve(Deno.args[1] ?? ".release/npm/package"); +const root = resolve(fromFileUrl(new URL("..", import.meta.url))); +const denoConfig = await readJson(join(root, "deno.json")); +const source = await readJson(join(root, "package.json")); +const drizzle = source.peerDependencies?.["drizzle-orm"] ?? "^0.45.2"; + +await emptyDir(output); +await build({ + cwd: root, + configFile: join(root, "deno.json"), + entryPoints: Object.entries(denoConfig.exports).map(([name, path]) => ({ + name: exportName(name), + path, + })), + outDir: output, + scriptModule: false, + declaration: "inline", + declarationMap: false, + typeCheck: "single", + test: false, + skipSourceOutput: true, + shims: { + deno: "dev", + }, + mappings: { + "@okikio/undent": { + name: "@okikio/undent", + version: "^0.3.3", + }, + "drizzle-orm": { + name: "drizzle-orm", + version: drizzle, + peerDependency: true, + }, + }, + package: { + name: source.name, + version, + ...(source.description === undefined ? {} : { description: source.description }), + ...(source.license === undefined ? {} : { license: source.license }), + ...(source.repository === undefined ? {} : { repository: source.repository }), + ...(source.homepage === undefined ? {} : { homepage: source.homepage }), + ...(source.bugs === undefined ? {} : { bugs: source.bugs }), + ...(source.keywords === undefined ? {} : { keywords: [...source.keywords] }), + sideEffects: false, + ...(source.engines === undefined ? {} : { engines: { ...source.engines } }), + peerDependencies: { + "drizzle-orm": drizzle, + }, + peerDependenciesMeta: { + "drizzle-orm": { optional: true }, + }, + }, + async postBuild() { + await Deno.copyFile(join(root, "README.md"), join(output, "README.md")); + await Deno.copyFile(join(root, "LICENSE"), join(output, "LICENSE")); + await assertNoJsrDependencies(join(output, "package.json")); + }, +}); + +const packageJson = join(output, "package.json"); +await assertNoJsrDependencies(packageJson); +console.log(`Built npm package ${source.name}@${version} at ${output}`); diff --git a/scripts/schema.ts b/scripts/schema.ts new file mode 100644 index 0000000..23f71f4 --- /dev/null +++ b/scripts/schema.ts @@ -0,0 +1,100 @@ +import { compileSchemas, type SchemaEntryType } from "./schema/compiler.ts"; +import { + AdapterCapabilitiesSchema, + AdapterLimitsSchema, + AdapterNameSchema, + AdapterPartitionSchema, + CoordinationModeSchema, + Db0DialectSchema, + DirectoryRecordSchema, + DriverKindSchema, + DriverOptimizationSchema, + DriverOwnershipSchema, + EntryKindSchema, + ErrorCodeSchema, + FileRecordSchema, + LimitKindSchema, + LimitSchema, + LimitSourceSchema, + LimitUnitSchema, + MetricsModeSchema, + OpfsContextSchema, + OptimizationSchema, + PartitionModeSchema, + PathSchema, + RecordSchema, + RecordVersionSchema, + RequirementSchema, + RequirementStateSchema, + SqlIdentifierSchema, + SupportModeSchema, + WriteModeSchema, +} from "../src/schema.ts"; +import { FileDriverCapabilitiesSchema } from "../src/driver/file.ts"; +import { RecordDriverCapabilitiesSchema, RecordReplacementSchema } from "../src/driver/record.ts"; +import { ObjectDriverCapabilitiesSchema } from "../src/driver/object.ts"; +import { RequestPolicySchema } from "../src/request.ts"; +import { IntegrationDirectionSchema, IntegrationDirectionsSchema } from "../src/integration/definition.ts"; +import { S3AddressingSchema, S3CredentialsSchema } from "../src/s3.ts"; +import { AzureStorageVersionSchema } from "../src/azure.ts"; + +const entries = [ + [PathSchema, "PathType"], + [AdapterNameSchema, "AdapterNameType"], + [EntryKindSchema, "EntryKindType"], + [OpfsContextSchema, "OpfsContextType"], + [CoordinationModeSchema, "CoordinationModeType"], + [WriteModeSchema, "WriteModeType"], + [SupportModeSchema, "SupportModeType"], + [MetricsModeSchema, "MetricsModeType"], + [PartitionModeSchema, "PartitionModeType"], + [AdapterPartitionSchema, "AdapterPartitionType"], + [AdapterLimitsSchema, "AdapterLimitsType"], + [OptimizationSchema, "OptimizationType"], + [AdapterCapabilitiesSchema, "AdapterCapabilitiesType"], + [ErrorCodeSchema, "ErrorCodeType"], + [RecordVersionSchema, "RecordVersionType"], + [DirectoryRecordSchema, "DirectoryRecordType"], + [FileRecordSchema, "FileRecordType"], + [RecordSchema, "RecordType"], + [Db0DialectSchema, "Db0DialectType"], + [SqlIdentifierSchema, "SqlIdentifierType"], + [DriverKindSchema, "DriverKindType"], + [LimitKindSchema, "LimitKindType"], + [LimitSourceSchema, "LimitSourceType"], + [LimitUnitSchema, "LimitUnitType"], + [LimitSchema, "LimitType"], + [RequirementStateSchema, "RequirementStateType"], + [RequirementSchema, "RequirementType"], + [DriverOwnershipSchema, "DriverOwnershipType"], + [DriverOptimizationSchema, "DriverOptimizationType"], + [FileDriverCapabilitiesSchema, "FileDriverCapabilitiesType"], + [RecordReplacementSchema, "RecordReplacementType"], + [RecordDriverCapabilitiesSchema, "RecordDriverCapabilitiesType"], + [ObjectDriverCapabilitiesSchema, "ObjectDriverCapabilitiesType"], + [RequestPolicySchema, "RequestPolicyType"], + [IntegrationDirectionSchema, "IntegrationDirectionType"], + [IntegrationDirectionsSchema, "IntegrationDirectionsType"], + [S3AddressingSchema, "S3AddressingType"], + [S3CredentialsSchema, "S3CredentialsType"], + [AzureStorageVersionSchema, "AzureStorageVersionType"], +] as const satisfies readonly (readonly [SchemaEntryType["schema"], string])[]; + +const content = compileSchemas(entries.map(([schema, name]) => ({ schema, name }))); +const target = new URL("../src/_schema_types.ts", import.meta.url); +const check = Deno.args.includes("--check"); + +if (check) { + let current = ""; + try { + current = await Deno.readTextFile(target); + } catch (error) { + if (!(error instanceof Deno.errors.NotFound)) throw error; + } + if (current !== content) { + console.error("src/_schema_types.ts is stale. Run `deno task schema`."); + Deno.exit(1); + } +} else { + await Deno.writeTextFile(target, content); +} diff --git a/scripts/schema/compiler.ts b/scripts/schema/compiler.ts new file mode 100644 index 0000000..ac34687 --- /dev/null +++ b/scripts/schema/compiler.ts @@ -0,0 +1,142 @@ +import type { z } from "zod"; + +/** One runtime schema paired with the explicit TypeScript alias generated for it. */ +export interface SchemaEntryType { + /** Runtime Zod schema that remains the validation source of truth. */ + readonly schema: z.ZodType; + /** Stable exported TypeScript alias written to the generated module. */ + readonly name: string; +} + +/** Internal Zod v4 definition fields used by the project-local type compiler. */ +interface ZodDefinitionType { + readonly type: string; + readonly innerType?: z.ZodType; + readonly element?: z.ZodType; + readonly options?: readonly z.ZodType[]; + readonly values?: readonly unknown[]; + readonly entries?: Readonly>; + readonly shape?: Record | (() => Record); +} + +/** Reads the stable Zod v4 core definition object used by schema tooling. */ +function definition(schema: z.ZodType): ZodDefinitionType { + const value = (schema as z.ZodType & { readonly _zod?: { readonly def?: unknown } })._zod?.def; + if (typeof value !== "object" || value === null || !("type" in value) || typeof value.type !== "string") { + throw new TypeError("Expected a Zod v4 schema definition."); + } + return value as ZodDefinitionType; +} + +/** Converts a JavaScript literal into its TypeScript literal-type spelling. */ +function literal(value: unknown): string { + if (typeof value === "string") return JSON.stringify(value); + if (typeof value === "number" || typeof value === "boolean") return String(value); + if (value === null) return "null"; + throw new TypeError(`Unsupported Zod literal value: ${String(value)}`); +} + +/** Indents a multiline type expression by one generated object level. */ +function indent(value: string): string { + return value.split("\n").map((line) => ` ${line}`).join("\n"); +} + +/** Returns whether a child schema makes an object property optional. */ +function optional(schema: z.ZodType): boolean { + const type = definition(schema).type; + return type === "optional" || type === "default"; +} + +/** + * Compiles the Zod constructs used by OPFS into explicit structural TypeScript. + * + * Unknown schema kinds throw instead of degrading to `any`. Adding a new Zod + * construct therefore requires an intentional compiler change before generated + * public contracts can drift. + */ +function compileType( + schema: z.ZodType, + names: ReadonlyMap, + root: boolean, +): string { + if (!root) { + const name = names.get(schema); + if (name !== undefined) return name; + } + + const def = definition(schema); + switch (def.type) { + case "string": + return "string"; + case "number": + return "number"; + case "boolean": + return "boolean"; + case "never": + return "never"; + case "literal": { + const values = def.values ?? []; + if (values.length === 0) throw new TypeError("Zod literal has no values."); + return values.map(literal).join(" | "); + } + case "enum": { + const values = Object.values(def.entries ?? {}); + if (values.length === 0) throw new TypeError("Zod enum has no entries."); + return values.map(literal).join(" | "); + } + case "optional": + case "default": { + if (def.innerType === undefined) throw new TypeError(`Zod ${def.type} has no inner type.`); + return compileType(def.innerType, names, false); + } + case "readonly": { + if (def.innerType === undefined) throw new TypeError("Zod readonly schema has no inner type."); + const inner = compileType(def.innerType, names, false); + if (definition(def.innerType).type === "array") return `readonly ${inner}`; + return `Readonly<${inner}>`; + } + case "array": { + if (def.element === undefined) throw new TypeError("Zod array has no element schema."); + const element = compileType(def.element, names, false); + return `${element.includes(" | ") ? `(${element})` : element}[]`; + } + case "union": { + const options = def.options ?? []; + if (options.length === 0) throw new TypeError("Zod union has no options."); + return options.map((option) => compileType(option, names, false)).join(" | "); + } + case "object": { + const rawShape = def.shape; + const shape = typeof rawShape === "function" ? rawShape() : rawShape; + if (shape === undefined) throw new TypeError("Zod object has no shape."); + const fields = Object.entries(shape).map(([name, child]) => { + const value = compileType(child, names, false); + const suffix = optional(child) ? "?" : ""; + const type = optional(child) && !value.includes("undefined") ? `${value} | undefined` : value; + return ` ${JSON.stringify(name)}${suffix}: ${type};`; + }); + return `{\n${fields.join("\n")}\n}`; + } + default: + throw new TypeError(`Unsupported Zod schema kind '${def.type}'.`); + } +} + +/** Generates the complete checked-in schema-derived type module. */ +export function compileSchemas(entries: readonly SchemaEntryType[]): string { + const names = new Map(); + for (const entry of entries) names.set(entry.schema, entry.name); + + const declarations = entries.map((entry) => { + const type = compileType(entry.schema, names, true); + return `export type ${entry.name} = ${type};`; + }); + + return [ + "// @generated by scripts/schema.ts. DO NOT EDIT.", + "// Zod schemas are the runtime source of truth. Regenerate with `deno task schema`.", + "", + declarations.join("\n\n"), + "", + ].join("\n"); +} diff --git a/src/_schema_types.ts b/src/_schema_types.ts new file mode 100644 index 0000000..ee55e7c --- /dev/null +++ b/src/_schema_types.ts @@ -0,0 +1,191 @@ +// @generated by scripts/schema.ts. DO NOT EDIT. +// Zod schemas are the runtime source of truth. Regenerate with `deno task schema`. + +export type PathType = string; + +export type AdapterNameType = string; + +export type EntryKindType = "file" | "directory"; + +export type OpfsContextType = "window" | "dedicated-worker" | "shared-worker" | "service-worker" | "worker" | "unknown"; + +export type CoordinationModeType = "auto" | "web-locks" | "local" | "none"; + +export type WriteModeType = "replace" | "append" | "update"; + +export type SupportModeType = "native" | "emulated" | "partitioned" | "unsupported"; + +export type MetricsModeType = "none" | "basic" | "timing"; + +export type PartitionModeType = "never" | "auto" | "always"; + +export type AdapterPartitionType = { + "mode": PartitionModeType; + "partBytes": number; + "thresholdBytes"?: number | undefined; + "stream"?: boolean | undefined; + "maxParts"?: number | undefined; + "layout": string; +}; + +export type AdapterLimitsType = { + "maxFileBytes"?: number | undefined; + "maxValueBytes"?: number | undefined; + "maxKeyBytes"?: number | undefined; + "minPartBytes"?: number | undefined; + "maxPartBytes"?: number | undefined; + "maxParts"?: number | undefined; + "maxConcurrency"?: number | undefined; + "maxBatchBytes"?: number | undefined; +}; + +export type OptimizationType = { + "streamRead": boolean; + "streamWrite": boolean; + "rangeRead": boolean; + "nativeCopy": boolean; + "nativeMove": boolean; +}; + +export type AdapterCapabilitiesType = { + "read": boolean; + "write": boolean; + "streamRead": boolean; + "streamWriteModes": readonly WriteModeType[]; + "rangeRead": boolean; + "nativeCopy": boolean; + "nativeMove": boolean; + "positionalWrite": boolean; + "syncAccess": boolean; +}; + +export type ErrorCodeType = "unavailable" | "not-found" | "already-exists" | "type-mismatch" | "invalid-path" | "invalid-operation" | "not-supported" | "locked" | "quota-exceeded" | "permission-denied" | "aborted" | "too-large" | "unknown"; + +export type RecordVersionType = 1; + +export type DirectoryRecordType = { + "version": RecordVersionType; + "path": PathType; + "parent": PathType; + "name": string; + "lastModified": number; + "kind": "directory"; +}; + +export type FileRecordType = { + "version": RecordVersionType; + "path": PathType; + "parent": PathType; + "name": string; + "lastModified": number; + "kind": "file"; + "data": string; + "size": number; + "mediaType": string; +}; + +export type RecordType = DirectoryRecordType | FileRecordType; + +export type Db0DialectType = "mysql" | "postgresql" | "sqlite" | "libsql"; + +export type SqlIdentifierType = string; + +export type DriverKindType = "file" | "record" | "object"; + +export type LimitKindType = "hard" | "policy" | "dynamic"; + +export type LimitSourceType = "provider" | "implementation" | "user" | "probe"; + +export type LimitUnitType = "bytes" | "count" | "milliseconds" | "operations"; + +export type LimitType = { + "code": string; + "kind": LimitKindType; + "source": LimitSourceType; + "unit": LimitUnitType; + "value"?: number | undefined; + "detail"?: string | undefined; +}; + +export type RequirementStateType = "available" | "missing" | "unknown"; + +export type RequirementType = { + "code": string; + "state": RequirementStateType; + "reason"?: string | undefined; +}; + +export type DriverOwnershipType = "none" | "borrowed" | "owned"; + +export type DriverOptimizationType = { + "code": string; + "enabled": boolean; + "changesBehavior": boolean; + "disableable": boolean; + "detail"?: string | undefined; +}; + +export type FileDriverCapabilitiesType = { + "read": boolean; + "write": boolean; + "streamRead": boolean; + "streamWriteModes": readonly WriteModeType[]; + "rangeRead": boolean; + "copy": boolean; + "move": boolean; + "positionalWrite": boolean; + "syncAccess": boolean; +}; + +export type RecordReplacementType = "atomic" | "best-effort" | "unknown"; + +export type RecordDriverCapabilitiesType = { + "rangeRead": boolean; + "streamRead": boolean; + "write": boolean; + "writeModes": readonly WriteModeType[]; + "streamWriteModes": readonly WriteModeType[]; + "replacement": RecordReplacementType; + "binary": boolean; + "transactions": boolean; +}; + +export type ObjectDriverCapabilitiesType = { + "rangeRead": boolean; + "streamRead": boolean; + "streamWrite": boolean; + "copy": boolean; + "conditionalWrite": boolean; + "multipart": boolean; + "metadata": boolean; + "versions": boolean; +}; + +export type RequestPolicyType = { + "retries"?: number | undefined; + "minDelayMs"?: number | undefined; + "maxDelayMs"?: number | undefined; + "multiplier"?: number | undefined; + "jitter"?: number | undefined; + "timeoutMs"?: number | false | undefined; +}; + +export type IntegrationDirectionType = { + "supported": boolean; + "reason"?: string | undefined; +}; + +export type IntegrationDirectionsType = { + "toOpfs": IntegrationDirectionType; + "fromOpfs": IntegrationDirectionType; +}; + +export type S3AddressingType = "path" | "virtual"; + +export type S3CredentialsType = { + "accessKeyId": string; + "secretAccessKey": string; + "sessionToken"?: string | undefined; +}; + +export type AzureStorageVersionType = string; diff --git a/src/adapter/opfs.ts b/src/adapter/opfs.ts index 41442d5..0a965f0 100644 --- a/src/adapter/opfs.ts +++ b/src/adapter/opfs.ts @@ -2,21 +2,24 @@ import type { AdapterType, FileSystemOptionsType } from "./definition.ts"; import { createFileAdapter } from "./file.ts"; import { createFileSystem, type FileSystemType } from "../filesystem.ts"; import { FileSystemError, toFileSystemError } from "../error.ts"; -import { createOpfsDriver, type OpfsDriverType } from "../driver/opfs.ts"; +import { createOpfsDriver, type OpfsDirectoryHandleType, type OpfsDriverType } from "../driver/opfs.ts"; /** OPFS adapter with the native root retained for advanced browser interop. */ -export interface OpfsAdapterType extends AdapterType { - readonly driver: OpfsDriverType; - readonly nativeRoot: FileSystemDirectoryHandle; +export interface OpfsAdapterType + extends AdapterType { + readonly driver: OpfsDriverType; + readonly nativeRoot: RootType; } /** Options for opening the current origin-private filesystem. */ export type OpenFileSystemOptionsType = FileSystemOptionsType; /** Creates the thin OPFS adapter over an already acquired native root. */ -export function createOpfsAdapter(root: FileSystemDirectoryHandle): OpfsAdapterType { +export function createOpfsAdapter( + root: RootType, +): OpfsAdapterType { const driver = createOpfsDriver(root); - const adapter = createFileAdapter(driver) as OpfsAdapterType; + const adapter = createFileAdapter(driver) as OpfsAdapterType; Object.defineProperty(adapter, "nativeRoot", { value: root, enumerable: true }); return adapter; } @@ -29,7 +32,7 @@ export function createOpfsAdapter(root: FileSystemDirectoryHandle): OpfsAdapterT */ export async function openFileSystem(options: OpenFileSystemOptionsType = {}): Promise { const navigatorValue = Reflect.get(globalThis, "navigator") as - | { storage?: { getDirectory?: () => Promise } } + | { storage?: { getDirectory?: () => Promise } } | undefined; if (typeof navigatorValue?.storage?.getDirectory !== "function") { throw new FileSystemError( diff --git a/src/azure.ts b/src/azure.ts index e547878..107ce1d 100644 --- a/src/azure.ts +++ b/src/azure.ts @@ -28,10 +28,10 @@ import { createXmlElement, createXmlText, getXmlElements, getXmlValue, parseXmlR export const AZURE_STORAGE_VERSION = "2026-04-06"; /** Date-shaped Azure Storage REST service version sent through `x-ms-version`. */ -export const AzureStorageVersionSchema = z.string().regex(/^\d{4}-\d{2}-\d{2}$/); +export const AzureStorageVersionSchema: z.ZodType = z.string().regex(/^\d{4}-\d{2}-\d{2}$/); /** Validated Azure Storage REST service version. */ -export type AzureStorageVersionType = z.output; +export type AzureStorageVersionType = import("./_schema_types.ts").AzureStorageVersionType; /** * Azure Blob authorization strategy. @@ -164,8 +164,21 @@ export class AzureError extends Error { } } +/** Public Azure Blob size/count limit contract. */ +export interface AzureLimitsType { + readonly maxCommittedBlocks: number; + readonly maxUncommittedBlocks: number; + readonly copyBlobBytes: number; + readonly legacyBlockBytes: number; + readonly midBlockBytes: number; + readonly currentBlockBytes: number; + readonly legacyPutBlobBytes: number; + readonly midPutBlobBytes: number; + readonly currentPutBlobBytes: number; +} + /** Public Azure Blob size/count limits used by request planning and tests. */ -export const AZURE_LIMITS = Object.freeze({ +export const AZURE_LIMITS: AzureLimitsType = Object.freeze({ /** Maximum block count a `Put Block List` can publish in one block blob. */ maxCommittedBlocks: 50_000, /** Maximum uncommitted blocks Azure retains for one blob before commit. */ diff --git a/src/driver/bun.ts b/src/driver/bun.ts index 7634ddc..3373947 100644 --- a/src/driver/bun.ts +++ b/src/driver/bun.ts @@ -64,7 +64,7 @@ export class BunBackend implements FileBackendType { /** Stable driver identity used in diagnostics. */ readonly name = "bun"; /** Native capabilities inherited from Bun's Node-compatible filesystem. */ - readonly capabilities; + readonly capabilities: FileDriverType["capabilities"]; /** Bun runtime used by lazy reads and replacement writes. */ readonly #bun: BunRuntimeType; /** Maps canonical virtual paths below the configured host root. */ diff --git a/src/driver/db0.ts b/src/driver/db0.ts index de35eef..31d5415 100644 --- a/src/driver/db0.ts +++ b/src/driver/db0.ts @@ -1,4 +1,4 @@ -import { defineRecordDriver, type RecordBackendType, type RecordDriverType } from "./record.ts"; +import { defineRecordDriver, type RecordBackendType, type RecordDriverType, type RecordListType } from "./record.ts"; import { Db0DialectSchema, type Db0DialectType, @@ -245,7 +245,7 @@ export class Db0Backend implements RecordBackendType { } /** Selects one fixed-width path identity and converts the connector row. */ - async get(path: Parameters[0]) { + async get(path: Parameters[0]): Promise { const row = await this.#selectById.get(await getPathId(path)); return row == null ? null : parseRow(row); } @@ -274,7 +274,7 @@ export class Db0Backend implements RecordBackendType { } /** Selects and validates direct children through `parent_path`. */ - async *list(parent: Parameters[0]) { + async *list(parent: Parameters[0]): AsyncIterableIterator { const rows = await this.#selectChildren.all(parent); for (const row of rows) yield parseRow(row); } diff --git a/src/driver/definition.ts b/src/driver/definition.ts index 3343588..29a6d58 100644 --- a/src/driver/definition.ts +++ b/src/driver/definition.ts @@ -30,7 +30,7 @@ import { * the right seam. For example, a driver limit may require a different backend, * while a filesystem problem may only require a facade policy change. */ -export const ProblemLayerSchema = z.enum(["client", "driver", "adapter", "filesystem"]); +export const ProblemLayerSchema: z.ZodType = z.enum(["client", "driver", "adapter", "filesystem"]); /** A validated storage problem layer. */ export type ProblemLayerType = "client" | "driver" | "adapter" | "filesystem"; @@ -41,7 +41,7 @@ export type ProblemLayerType = "client" | "driver" | "adapter" | "filesystem"; * These levels are presentation-friendly summaries. Callers should still use * the structured `code`, `layer`, and optional `limit` fields to drive policy. */ -export const ProblemSeveritySchema = z.enum(["info", "warning", "error"]); +export const ProblemSeveritySchema: z.ZodType = z.enum(["info", "warning", "error"]); /** A validated storage planning problem severity. */ export type ProblemSeverityType = "info" | "warning" | "error"; @@ -53,7 +53,7 @@ export type ProblemSeverityType = "info" | "warning" | "error"; * behalf. It only reports the next kind of move that could make the request * succeed honestly. */ -export const ActionKindSchema = z.enum([ +export const ActionKindSchema: z.ZodType = z.enum([ "partition", "change-policy", "select-driver", @@ -82,7 +82,7 @@ export type ActionKindType = * `code`, `layer`, and `limit` let a caller sort provider ceilings, policy * choices, and unsupported routes without parsing prose. */ -export const ProblemSchema = z.object({ +export const ProblemSchema: z.ZodType = z.object({ /** Stable machine-readable problem code. */ code: z.string().min(1), /** Layer that identified the problem. */ @@ -118,7 +118,7 @@ type _ProblemTypeMatchesSchema = AssertTrue = z.object({ /** Coarse-grained next step the caller can take. */ kind: ActionKindSchema, /** Optional machine-readable qualifier for UI or policy routing. */ @@ -146,7 +146,7 @@ type _ActionTypeMatchesSchema = AssertTrue = z.enum(["stat", "read", "write", "list", "copy", "move", "remove"]); /** A validated backend driver operation. */ export type DriverOperationType = "stat" | "read" | "write" | "list" | "copy" | "move" | "remove"; @@ -157,7 +157,7 @@ export type DriverOperationType = "stat" | "read" | "write" | "list" | "copy" | * Paths are canonical at the driver seam. The filesystem facade normalizes public * input before an adapter calls a driver, and direct driver callers must supply canonical paths. `size` can be omitted for an unknown-length stream. */ -export const DriverPlanInputSchema = z.object({ +export const DriverPlanInputSchema: z.ZodType = z.object({ /** Backend-native operation being preflighted. */ operation: DriverOperationSchema, /** Canonical source path when the operation targets one path. */ @@ -208,7 +208,7 @@ type _DriverPlanInputTypeMatchesSchema = AssertTrue< * native, emulated later, partitioned, or unavailable once the result is folded * into the adapter and filesystem plan. */ -export const DriverPlanSchema = z.object({ +export const DriverPlanSchema: z.ZodType = z.object({ /** Backend-native operation that was planned. */ operation: DriverOperationSchema, /** Whether the request can proceed under current facts and policy. */ @@ -253,7 +253,7 @@ type _DriverPlanTypeMatchesSchema = AssertTrue< * This is the durable description a caller can log, diff, snapshot in tests, or * surface in diagnostics before any storage work starts. */ -export const DriverInspectionSchema = z.object({ +export const DriverInspectionSchema: z.ZodType = z.object({ /** Stable configured driver name. */ name: z.string().min(1), /** Backend family implemented by this driver. */ diff --git a/src/driver/file.ts b/src/driver/file.ts index 7fe00b4..0508174 100644 --- a/src/driver/file.ts +++ b/src/driver/file.ts @@ -5,7 +5,7 @@ import { WriteModeSchema, type WriteModeType } from "../schema.ts"; import type { DriverType } from "./definition.ts"; /** Native operations implemented by a file-shaped backend driver. */ -export const FileDriverCapabilitiesSchema = z.object({ +export const FileDriverCapabilitiesSchema: z.ZodType = z.object({ /** Backend can materialize file bytes through `readFile()`. */ read: z.boolean(), /** Backend can commit materialized file bytes through `writeFile()`. */ @@ -27,7 +27,7 @@ export const FileDriverCapabilitiesSchema = z.object({ }).strict(); /** A validated native file-driver capability description. */ -export type FileDriverCapabilitiesType = z.output; +export type FileDriverCapabilitiesType = import("../_schema_types.ts").FileDriverCapabilitiesType; /** Options shared by file-driver operations that can stop early. */ export interface FileDriverSignalOptionsType { diff --git a/src/driver/indexeddb.ts b/src/driver/indexeddb.ts index 1f7b06a..f4e66ec 100644 --- a/src/driver/indexeddb.ts +++ b/src/driver/indexeddb.ts @@ -1,6 +1,6 @@ import { decodeBase64, encodeBase64 } from "@std/encoding/base64"; -import { defineRecordDriver, type RecordBackendType, type RecordDriverType } from "./record.ts"; +import { defineRecordDriver, type RecordBackendType, type RecordDriverType, type RecordListType } from "./record.ts"; import type { FileDriverWriteOptionsType } from "./file.ts"; import { FileSystemError, throwIfAborted } from "../error.ts"; import { basename, dirname, type PathType } from "../path.ts"; @@ -122,7 +122,7 @@ export class IndexedDbBackend implements RecordBackendType { } /** Reads and validates one record in a readonly transaction. */ - async get(path: Parameters[0]) { + async get(path: Parameters[0]): Promise { const transaction = this.#database.transaction(this.#storeName, "readonly"); const value = await result(transaction.objectStore(this.#storeName).get(path)); return value === undefined ? null : RecordSchema.parse(value); @@ -201,7 +201,7 @@ export class IndexedDbBackend implements RecordBackendType { } /** Reads direct children through the parent-path index. */ - async *list(parent: Parameters[0]) { + async *list(parent: Parameters[0]): AsyncIterableIterator { const transaction = this.#database.transaction(this.#storeName, "readonly"); const values = await result(transaction.objectStore(this.#storeName).index(this.#parentIndex).getAll(parent)); for (const value of values) yield RecordSchema.parse(value); diff --git a/src/driver/object.ts b/src/driver/object.ts index 4dca7c9..de5476c 100644 --- a/src/driver/object.ts +++ b/src/driver/object.ts @@ -18,7 +18,7 @@ import { * provider-side copy, or retain metadata without ever pretending it can update a * file in place like a host filesystem. */ -export const ObjectDriverCapabilitiesSchema = z.object({ +export const ObjectDriverCapabilitiesSchema: z.ZodType = z.object({ rangeRead: z.boolean(), streamRead: z.boolean(), streamWrite: z.boolean(), @@ -30,7 +30,7 @@ export const ObjectDriverCapabilitiesSchema = z.object({ }).strict(); /** A validated native object-driver capability description. */ -export type ObjectDriverCapabilitiesType = z.output; +export type ObjectDriverCapabilitiesType = import("../_schema_types.ts").ObjectDriverCapabilitiesType; /** Portable object metadata returned by {@link ObjectBackendType.head}. */ export interface ObjectStatType { diff --git a/src/driver/opfs.ts b/src/driver/opfs.ts index f760085..b68caf9 100644 --- a/src/driver/opfs.ts +++ b/src/driver/opfs.ts @@ -14,13 +14,45 @@ import { basename, dirname, type PathType, ROOT_PATH, splitPath } from "../path. import { toByteStream } from "../stream.ts"; /** - * Minimal file handle contract required from browser OPFS. + * Staged writable operations used by the OPFS driver. * - * The driver deliberately avoids depending on the full DOM lib surface. It only - * models the native operations that the backend actually needs to implement the - * portable file-driver contract. + * This structural contract intentionally models only the methods the driver + * calls. It avoids requiring consumers, Deno, Node, or Bun to install ambient + * File System Access API declarations merely to import or type-check the + * package. + * + * A browser-native `FileSystemWritableFileStream` satisfies this shape. + */ +export interface OpfsWritableFileStreamType { + /** Writes ArrayBuffer-backed bytes or one explicit-position byte write into the staged file. */ + write( + data: + | Uint8Array + | { + readonly type: "write"; + readonly position: number; + readonly data: Uint8Array; + }, + ): Promise; + /** Moves the staged stream cursor to an absolute byte position. */ + seek(position: number): Promise; + /** Changes the staged file length. */ + truncate(size: number): Promise; + /** Commits the staged file and releases the browser file lock. */ + close(): Promise; + /** Discards the staged file when possible and releases the browser file lock. */ + abort(reason?: unknown): Promise; +} + +/** + * File handle operations required by the OPFS driver. + * + * The contract is structural rather than an alias to the browser-global + * `FileSystemFileHandle`. This keeps the package importable in runtimes whose + * TypeScript libraries do not declare the File System Access API while still + * accepting the real browser handle without wrapping it. */ -interface NativeFileHandleType { +export interface OpfsFileHandleType { /** Native File System API discriminator. */ readonly kind: "file"; /** Native direct-entry name. */ @@ -28,52 +60,70 @@ interface NativeFileHandleType { /** Returns the browser's immutable file snapshot. */ getFile(): Promise; /** Opens the browser's staged writable stream. */ - createWritable(options?: { keepExistingData?: boolean }): Promise; + createWritable(options?: { readonly keepExistingData?: boolean }): Promise; /** Opens worker-only synchronous access when this realm exposes it. */ createSyncAccessHandle?: () => Promise; } /** - * Minimal directory handle contract required from browser OPFS. + * Common child-handle fields consumed while enumerating one OPFS directory. * - * This shape is intentionally narrower than the browser interface so the driver - * can stay focused on traversal and mutation semantics rather than browser-only - * convenience methods. + * This intentionally excludes file- and directory-specific methods because + * `readDir()` only needs each child's name and discriminator. */ -interface NativeDirectoryHandleType { +export interface OpfsDirectoryChildHandleType { + /** Native File System API discriminator used while enumerating children. */ + readonly kind: "file" | "directory"; + /** Native direct-entry name. */ + readonly name: string; +} + +/** + * Directory handle operations required by the OPFS driver. + * + * The real browser `FileSystemDirectoryHandle` is structurally compatible with + * this type. Enumeration intentionally requires only the child fields consumed + * by `readDir()`, because TypeScript versions have represented `entries()` with + * both `FileSystemHandle` and the narrower file/directory union. Generic factory + * return types retain the caller's more specific root type on `nativeRoot`. + */ +export interface OpfsDirectoryHandleType { /** Native File System API discriminator. */ readonly kind: "directory"; /** Native direct-entry name. */ readonly name: string; /** Opens or creates one direct child file. */ - getFileHandle(name: string, options?: { create?: boolean }): Promise; + getFileHandle(name: string, options?: { readonly create?: boolean }): Promise; /** Opens or creates one direct child directory. */ - getDirectoryHandle(name: string, options?: { create?: boolean }): Promise; + getDirectoryHandle(name: string, options?: { readonly create?: boolean }): Promise; /** Removes one direct child using browser-native filesystem semantics. */ - removeEntry(name: string, options?: { recursive?: boolean }): Promise; - /** Lazily iterates native direct-child handles. */ - entries(): AsyncIterableIterator<[string, NativeFileHandleType | NativeDirectoryHandleType]>; + removeEntry(name: string, options?: { readonly recursive?: boolean }): Promise; + /** Lazily iterates the direct-child fields used by directory reads. */ + entries(): AsyncIterable; } /** - * Native OPFS file driver with the root retained for advanced browser interop. + * Native OPFS file driver with the root retained for browser interop. * - * The retained root is useful when advanced browser code needs the original - * handle after the portable facade has already been composed. + * `RootType` preserves the exact root shape supplied by the caller. A browser + * consumer that passes its concrete `FileSystemDirectoryHandle` therefore + * keeps any additional native methods on `nativeRoot`, while server-side + * checkers only need the structural OPFS contract above. */ -export interface OpfsDriverType extends FileDriverType { - readonly nativeRoot: FileSystemDirectoryHandle; +export interface OpfsDriverType + extends FileDriverType { + readonly nativeRoot: RootType; } /** Resolves a canonical virtual directory path one native handle at a time. */ -async function getDirectory(root: NativeDirectoryHandleType, path: string): Promise { +async function getDirectory(root: OpfsDirectoryHandleType, path: string): Promise { let current = root; for (const part of splitPath(path)) current = await current.getDirectoryHandle(part); return current; } /** Resolves a file through its parent directory and optionally creates the final entry. */ -async function getFile(root: NativeDirectoryHandleType, path: string, create = false): Promise { +async function getFile(root: OpfsDirectoryHandleType, path: string, create = false): Promise { const parent = await getDirectory(root, dirname(path)); return await parent.getFileHandle(basename(path), { create }); } @@ -85,6 +135,21 @@ function getStream(file: File, options: FileDriverReadOptionsType): ReadableStre return file.slice(at, end).stream() as ReadableStream; } +/** + * Returns bytes backed by an `ArrayBuffer`, as required by asynchronous OPFS writes. + * + * Portable streams can carry views backed by `SharedArrayBuffer`, while the File + * System API's `BufferSource` deliberately accepts only `ArrayBuffer`-backed + * views. Reusing an ArrayBuffer-backed chunk avoids a copy. Shared backing is + * copied once before the value reaches the native writable stream. + */ +function toOpfsWriteBytes(value: Uint8Array): Uint8Array { + if (value.buffer instanceof ArrayBuffer) { + return new Uint8Array(value.buffer, value.byteOffset, value.byteLength); + } + return Uint8Array.from(value); +} + /** * Determines entry kind without creating anything. * @@ -92,7 +157,7 @@ function getStream(file: File, options: FileDriverReadOptionsType): ReadableStre * from the first lookup is therefore a normal branch. The second lookup must * run before the driver can classify the path as absent. */ -async function getStat(root: NativeDirectoryHandleType, path: string): Promise { +async function getStat(root: OpfsDirectoryHandleType, path: string): Promise { if (path === ROOT_PATH) return { kind: "directory" }; try { const handle = await getFile(root, path); @@ -121,7 +186,7 @@ async function getStat(root: NativeDirectoryHandleType, path: string): Promise, options: FileDriverWriteOptionsType, path: string, @@ -143,7 +208,7 @@ async function writeToNative( throwIfAborted(options.signal, "write", path); const next = await reader.read(); if (next.done) break; - await writable.write(next.value as BufferSource); + await writable.write(toOpfsWriteBytes(next.value)); cursor += next.value.byteLength; } } catch (error) { @@ -184,18 +249,18 @@ class OpfsWritableFile implements FileDriverWritableFileType { /** Canonical path used in post-close diagnostics. */ readonly #path: PathType; /** Native staged writable owned until close or abort. */ - readonly #writable: FileSystemWritableFileStream; + readonly #writable: OpfsWritableFileStreamType; /** Prevents writes after terminal resource settlement. */ #closed = false; /** Takes ownership of one native staged writable for a canonical path. */ - constructor(path: PathType, writable: FileSystemWritableFileStream) { + constructor(path: PathType, writable: OpfsWritableFileStreamType) { this.#path = path; this.#writable = writable; } /** Returns the live writable or rejects operations after settlement. */ - #getWritable(): FileSystemWritableFileStream { + #getWritable(): OpfsWritableFileStreamType { if (this.#closed) throw new Error(`Writable file '${this.#path}' is closed.`); return this.#writable; } @@ -203,8 +268,7 @@ class OpfsWritableFile implements FileDriverWritableFileType { /** Writes one byte view at its explicit file position. */ async write(buffer: ArrayBufferView, options: { readonly at: number }): Promise { const view = new Uint8Array(buffer.buffer, buffer.byteOffset, buffer.byteLength); - const data = buffer.buffer instanceof ArrayBuffer ? view : Uint8Array.from(view); - await this.#getWritable().write({ type: "write", position: options.at, data: data as Uint8Array }); + await this.#getWritable().write({ type: "write", position: options.at, data: toOpfsWriteBytes(view) }); } /** Changes the staged file size. */ @@ -233,20 +297,20 @@ class OpfsWritableFile implements FileDriverWritableFileType { } /** Native browser OPFS implementation of the portable file-driver contract. */ -class OpfsBackend implements FileBackendType { +class OpfsBackend implements FileBackendType { /** Stable driver identity used in diagnostics. */ readonly name = "opfs"; /** Native origin-private root retained for advanced browser interop. */ - readonly nativeRoot: FileSystemDirectoryHandle; + readonly nativeRoot: RootType; /** Native operations exposed without facade emulation. */ readonly capabilities; /** Narrow native root shape used by internal traversal helpers. */ - readonly #root: NativeDirectoryHandleType; + readonly #root: OpfsDirectoryHandleType; /** Borrows the native root and probes only actual API exposure in this realm. */ - constructor(root: FileSystemDirectoryHandle) { + constructor(root: RootType) { this.nativeRoot = root; - this.#root = root as unknown as NativeDirectoryHandleType; + this.#root = root; this.capabilities = { read: true, write: true, @@ -348,7 +412,9 @@ class OpfsBackend implements FileBackendType { * The driver borrows the browser root. Browser storage has no root-close * operation, so driver disposal never closes the origin-private filesystem. */ -export function createOpfsDriver(root: FileSystemDirectoryHandle): OpfsDriverType { +export function createOpfsDriver( + root: RootType, +): OpfsDriverType { const backend = new OpfsBackend(root); return { ...defineFileDriver(backend, { diff --git a/src/driver/record.ts b/src/driver/record.ts index bbff60f..4db5180 100644 --- a/src/driver/record.ts +++ b/src/driver/record.ts @@ -29,10 +29,10 @@ export type RecordListType = DirectoryRecordType | Omit; * writes when the backend stores logical values rather than byte-addressable * files. */ -export const RecordReplacementSchema = z.enum(["atomic", "best-effort", "unknown"]); +export const RecordReplacementSchema: z.ZodType = z.enum(["atomic", "best-effort", "unknown"]); /** A validated record replacement guarantee. */ -export type RecordReplacementType = z.output; +export type RecordReplacementType = import("../_schema_types.ts").RecordReplacementType; /** * Native byte/data behavior exposed by a record driver. @@ -40,7 +40,7 @@ export type RecordReplacementType = z.output; * These flags describe how far the backend can go beyond whole-record reads and * writes. The record adapter uses them to choose honest fallbacks. */ -export const RecordDriverCapabilitiesSchema = z.object({ +export const RecordDriverCapabilitiesSchema: z.ZodType = z.object({ /** Backend can satisfy byte ranges without reconstructing the complete logical file. */ rangeRead: z.boolean(), /** Backend can expose file bytes as a native stream. */ @@ -67,7 +67,7 @@ export const RecordDriverCapabilitiesSchema = z.object({ }).strict(); /** A validated record-driver capability description. */ -export type RecordDriverCapabilitiesType = z.output; +export type RecordDriverCapabilitiesType = import("../_schema_types.ts").RecordDriverCapabilitiesType; /** * Required persistence mechanics for value, document, and SQL record drivers. diff --git a/src/driver/rxdb.ts b/src/driver/rxdb.ts index 7baf925..2fb9b7d 100644 --- a/src/driver/rxdb.ts +++ b/src/driver/rxdb.ts @@ -40,6 +40,19 @@ export interface RxDbCollectionType { incrementalUpsert(record: RecordType): Promise; } +/** Public structural contract for the RxDB collection schema emitted by this package. */ +export interface RxDbRecordJsonSchemaType { + readonly title: string; + readonly description: string; + readonly version: number; + readonly primaryKey: string; + readonly type: "object"; + readonly properties: Readonly>>>; + readonly required: readonly string[]; + readonly oneOf: readonly Readonly>[]; + readonly indexes: readonly string[]; +} + /** * RxJSONSchema to use for the collection supplied to {@link createRxDbAdapter}. * @@ -47,7 +60,7 @@ export interface RxDbCollectionType { * direct-parent queries. File bytes remain base64 strings to keep documents * structured-cloneable across every RxStorage transport. */ -export const RxDbRecordJsonSchema = Object.freeze( +export const RxDbRecordJsonSchema: RxDbRecordJsonSchemaType = Object.freeze( { title: "OPFS filesystem record", description: "One canonical file or directory record used by the @okikio/opfs RxDB driver.", diff --git a/src/iframe.ts b/src/iframe.ts index 6cd6474..a78fcd8 100644 --- a/src/iframe.ts +++ b/src/iframe.ts @@ -1,12 +1,13 @@ import { createOpfsAdapter } from "./adapter/opfs.ts"; import type { FileSystemOptionsType } from "./adapter/definition.ts"; import { FileSystemError, toFileSystemError } from "./error.ts"; +import type { OpfsDirectoryHandleType } from "./driver/opfs.ts"; import { createFileSystem, type FileSystemType } from "./filesystem.ts"; /** Storage Access API result shape used without depending on experimental DOM declarations. */ interface StorageAccessHandleType { /** Returns the unpartitioned OPFS root when the browser granted that capability. */ - getDirectory?: () => Promise; + getDirectory?: () => Promise; } /** Document shape for browsers that implement unpartitioned OPFS Storage Access. */ diff --git a/src/integration/definition.ts b/src/integration/definition.ts index 9f63671..1ec338d 100644 --- a/src/integration/definition.ts +++ b/src/integration/definition.ts @@ -11,7 +11,7 @@ import { AdapterNameSchema } from "../schema.ts"; * allow a silent `false` because callers need to know whether the missing route * is temporary, impossible, or out of scope for the current ecosystem. */ -export const IntegrationDirectionSchema = z.object({ +export const IntegrationDirectionSchema: z.ZodType = z.object({ /** Whether the direction has a real constructor. */ supported: z.boolean(), /** Concrete reason when the direction is intentionally unsupported. */ @@ -23,7 +23,7 @@ export const IntegrationDirectionSchema = z.object({ }); /** A validated bridge-direction support declaration. */ -export type IntegrationDirectionType = z.output; +export type IntegrationDirectionType = import("../_schema_types.ts").IntegrationDirectionType; /** * Directions one ecosystem integration can expose. @@ -31,7 +31,7 @@ export type IntegrationDirectionType = z.output = z.object({ /** Ecosystem/native resource projected into the OPFS filesystem model. */ toOpfs: IntegrationDirectionSchema, /** OPFS filesystem projected back into the ecosystem's expected contract. */ @@ -39,7 +39,7 @@ export const IntegrationDirectionsSchema = z.object({ }).strict(); /** Validated bridge direction declaration. */ -export type IntegrationDirectionsType = z.output; +export type IntegrationDirectionsType = import("../_schema_types.ts").IntegrationDirectionsType; /** * Import-safe integration definition for ecosystems that support one or both directions. diff --git a/src/plan.ts b/src/plan.ts index 6c6cd75..3f0025a 100644 --- a/src/plan.ts +++ b/src/plan.ts @@ -67,7 +67,7 @@ type ResolvedPlanInputType = * The planner distinguishes already-materialized bytes from an open stream * because stream buffering and partitioning decisions depend on that difference. */ -export const WriteSourceSchema = z.enum(["bytes", "stream"]); +export const WriteSourceSchema: z.ZodType = z.enum(["bytes", "stream"]); /** Validated physical write-source form. */ export type WriteSourceType = "bytes" | "stream"; /** @@ -76,7 +76,7 @@ export type WriteSourceType = "bytes" | "stream"; * Planning intentionally covers the routes where size, buffering, partitioning, * or fallback behavior most often changes the caller's decision. */ -export const PlanOperationSchema = z.enum(["read", "write", "copy", "move"]); +export const PlanOperationSchema: z.ZodType = z.enum(["read", "write", "copy", "move"]); /** Validated preflight operation name. */ export type PlanOperationType = "read" | "write" | "copy" | "move"; @@ -139,7 +139,7 @@ export interface MovePlanInputType { * `createPlan()` normalizes those values before it asks the driver for a native * planning result. */ -export const PlanInputSchema = z.discriminatedUnion("operation", [ +const PlanInputSchemaDefinition = z.discriminatedUnion("operation", [ z.object({ /** Selects a read preflight request. */ operation: z.literal("read"), @@ -185,20 +185,24 @@ export const PlanInputSchema = z.discriminatedUnion("operation", [ size: z.number().int().nonnegative().optional(), }).strict(), ]); + /** Input accepted by filesystem preflight before defaults and path normalization. */ export type PlanInputType = ReadPlanInputType | WritePlanInputType | CopyPlanInputType | MovePlanInputType; +/** Public preflight validator with explicit input and resolved-output contracts. */ +export const PlanInputSchema: z.ZodType = PlanInputSchemaDefinition; + type _ReadPlanInputTypeMatchesSchema = AssertTrue< - IsEquivalent> + IsEquivalent> >; type _WritePlanInputTypeMatchesSchema = AssertTrue< - IsEquivalent> + IsEquivalent> >; type _CopyPlanInputTypeMatchesSchema = AssertTrue< - IsEquivalent> + IsEquivalent> >; type _MovePlanInputTypeMatchesSchema = AssertTrue< - IsEquivalent> + IsEquivalent> >; type _PlanInputTypeMatchesSchema = AssertTrue>>; @@ -209,7 +213,7 @@ type _PlanInputTypeMatchesSchema = AssertTrue = z.object({ /** Filesystem operation that was planned. */ operation: PlanOperationSchema, /** Whether the complete storage stack can perform the request safely. */ diff --git a/src/probe.ts b/src/probe.ts index 79930b8..3c71967 100644 --- a/src/probe.ts +++ b/src/probe.ts @@ -1,5 +1,6 @@ import { getOpfsContext } from "./context.ts"; import { getErrorMessage, getErrorName } from "./error.ts"; +import type { OpfsDirectoryHandleType } from "./driver/opfs.ts"; import type { OpfsContextType } from "./schema.ts"; /** A platform error captured while probing OPFS without throwing. */ @@ -57,7 +58,7 @@ export interface OpfsCapabilitiesType { /** StorageManager methods used by diagnostics without requiring a specific lib.dom revision. */ interface StorageManagerType { /** Browser OPFS root acquisition entrypoint. */ - getDirectory?: () => Promise; + getDirectory?: () => Promise; /** Optional storage quota diagnostic. */ estimate?: () => Promise<{ readonly quota?: number; readonly usage?: number }>; /** Optional diagnostic that reports whether browser storage is already persisted. */ diff --git a/src/request.ts b/src/request.ts index 1367c09..e10c1a1 100644 --- a/src/request.ts +++ b/src/request.ts @@ -7,7 +7,7 @@ import { z } from "zod"; * Values are optional so protocol clients can apply repository defaults without * copying a second default object into every public options type. */ -export const RequestPolicySchema = z.object({ +export const RequestPolicySchema: z.ZodType = z.object({ /** Additional attempts after the first request. Defaults to 3. */ retries: z.number().int().nonnegative().optional(), /** Base retry delay in milliseconds. Defaults to 200. */ @@ -23,7 +23,7 @@ export const RequestPolicySchema = z.object({ }).strict(); /** A validated direct-client request policy. */ -export type RequestPolicyType = z.output; +export type RequestPolicyType = import("./_schema_types.ts").RequestPolicyType; /** * Callable Web Fetch contract used by storage clients. diff --git a/src/s3.ts b/src/s3.ts index 6b345b0..e0a43cb 100644 --- a/src/s3.ts +++ b/src/s3.ts @@ -25,13 +25,13 @@ import type { } from "./driver/object.ts"; /** S3 URL addressing shape used when constructing signed request URLs. */ -export const S3AddressingSchema = z.enum(["path", "virtual"]); +export const S3AddressingSchema: z.ZodType = z.enum(["path", "virtual"]); /** Validated S3 URL addressing shape. */ -export type S3AddressingType = z.output; +export type S3AddressingType = import("./_schema_types.ts").S3AddressingType; /** AWS Signature Version 4 credentials. */ -export const S3CredentialsSchema = z.object({ +export const S3CredentialsSchema: z.ZodType = z.object({ /** Public access-key identifier placed in the SigV4 credential scope. */ accessKeyId: z.string().min(1), /** Secret key used only as input to the SigV4 HMAC key-derivation chain. */ @@ -41,11 +41,27 @@ export const S3CredentialsSchema = z.object({ }); /** Validated AWS Signature Version 4 credentials. */ -export type S3CredentialsType = z.output; +export type S3CredentialsType = import("./_schema_types.ts").S3CredentialsType; /** Credential value or refresh function used by long-lived S3 clients. */ export type S3CredentialSourceType = S3CredentialsType | (() => S3CredentialsType | Promise); +/** Public S3 size/count limits used by request planning and tests. */ +export interface S3LimitsType { + /** Exact multipart-derived S3 object ceiling. */ + readonly maxObjectBytes: number; + /** Largest body sent through one `PutObject` request. */ + readonly maxPutBytes: number; + /** Largest source copied through one `CopyObject` request. */ + readonly maxCopyBytes: number; + /** Smallest legal non-final multipart part. */ + readonly minPartBytes: number; + /** Largest legal multipart part. */ + readonly maxPartBytes: number; + /** Maximum part count accepted by one multipart upload. */ + readonly maxParts: number; +} + /** * S3 limits that affect the client's upload and copy planning. * @@ -56,7 +72,7 @@ export type S3CredentialSourceType = S3CredentialsType | (() => S3CredentialsTyp * approximately 53.7 TB), even though AWS often rounds that limit to 50 TB in * product documentation. */ -export const S3_LIMITS = Object.freeze({ +export const S3_LIMITS: S3LimitsType = Object.freeze({ /** Exact multipart-derived S3 object ceiling: 10,000 parts x 5 GiB. */ maxObjectBytes: 53_687_091_200_000, /** Largest body sent through one `PutObject` request. */ diff --git a/src/schema.ts b/src/schema.ts index c84008f..ee68440 100644 --- a/src/schema.ts +++ b/src/schema.ts @@ -7,7 +7,7 @@ import { z } from "zod"; * `normalizePath()` resolves it first. `PathSchema` is for already-normalized * values at persistence and adapter seams. */ -export const PathSchema = z.string().refine( +export const PathSchema: z.ZodType = z.string().refine( (value: string) => value === "/" || ( value.startsWith("/") && @@ -21,13 +21,13 @@ export const PathSchema = z.string().refine( ); /** A validated canonical virtual filesystem path. */ -export type PathType = z.output; +export type PathType = import("./_schema_types.ts").PathType; /** Stable non-empty diagnostic name assigned to one adapter implementation. */ -export const AdapterNameSchema = z.string().min(1); +export const AdapterNameSchema: z.ZodType = z.string().min(1); /** A validated adapter diagnostic name. */ -export type AdapterNameType = z.output; +export type AdapterNameType = import("./_schema_types.ts").AdapterNameType; /** * Valid entry kinds exposed by the filesystem facade and every adapter. @@ -35,10 +35,10 @@ export type AdapterNameType = z.output; * The package uses the same two kinds as the File System API. Adapters must not * invent a third kind for links, database rows, or provider-specific objects. */ -export const EntryKindSchema = z.enum(["file", "directory"]); +export const EntryKindSchema: z.ZodType = z.enum(["file", "directory"]); /** A validated filesystem entry kind. */ -export type EntryKindType = z.output; +export type EntryKindType = import("./_schema_types.ts").EntryKindType; /** * Execution contexts that can host browser storage access. @@ -48,7 +48,7 @@ export type EntryKindType = z.output; * execution. `unknown` means that no supported browser execution context was * detected. */ -export const OpfsContextSchema = z.enum([ +export const OpfsContextSchema: z.ZodType = z.enum([ "window", "dedicated-worker", "shared-worker", @@ -58,7 +58,7 @@ export const OpfsContextSchema = z.enum([ ]); /** A validated browser execution context classification. */ -export type OpfsContextType = z.output; +export type OpfsContextType = import("./_schema_types.ts").OpfsContextType; /** * Mutation coordination policies supported by {@link createFileSystem}. @@ -67,10 +67,10 @@ export type OpfsContextType = z.output; * an in-realm FIFO lock. `none` disables library coordination and transfers all * concurrency responsibility to the caller or adapter. */ -export const CoordinationModeSchema = z.enum(["auto", "web-locks", "local", "none"]); +export const CoordinationModeSchema: z.ZodType = z.enum(["auto", "web-locks", "local", "none"]); /** A validated mutation coordination policy. */ -export type CoordinationModeType = z.output; +export type CoordinationModeType = import("./_schema_types.ts").CoordinationModeType; /** * Write modes shared by the facade and adapters. @@ -78,10 +78,10 @@ export type CoordinationModeType = z.output; * `replace` starts from an empty file. `append` starts at the current end. * `update` preserves existing bytes and starts at the requested byte offset. */ -export const WriteModeSchema = z.enum(["replace", "append", "update"]); +export const WriteModeSchema: z.ZodType = z.enum(["replace", "append", "update"]); /** A validated file write mode. */ -export type WriteModeType = z.output; +export type WriteModeType = import("./_schema_types.ts").WriteModeType; /** * How one operation is provided by the selected storage stack. @@ -92,10 +92,10 @@ export type WriteModeType = z.output; * provider records or blocks. `unsupported` means no safe implementation is * available for the selected stack. */ -export const SupportModeSchema = z.enum(["native", "emulated", "partitioned", "unsupported"]); +export const SupportModeSchema: z.ZodType = z.enum(["native", "emulated", "partitioned", "unsupported"]); /** A validated storage support mode. */ -export type SupportModeType = z.output; +export type SupportModeType = import("./_schema_types.ts").SupportModeType; /** * Metrics collection cost selected for one filesystem or protocol client. @@ -104,19 +104,19 @@ export type SupportModeType = z.output; * `timing` also reads the monotonic clock around operations. `none` removes * metrics bookkeeping from hot paths when the caller is measuring raw overhead. */ -export const MetricsModeSchema = z.enum(["none", "basic", "timing"]); +export const MetricsModeSchema: z.ZodType = z.enum(["none", "basic", "timing"]); /** A validated metrics collection mode. */ -export type MetricsModeType = z.output; +export type MetricsModeType = import("./_schema_types.ts").MetricsModeType; /** * Physical partition policy for backends with a smaller value limit than the * logical file size the application wants to expose. */ -export const PartitionModeSchema = z.enum(["never", "auto", "always"]); +export const PartitionModeSchema: z.ZodType = z.enum(["never", "auto", "always"]); /** A validated physical partition policy. */ -export type PartitionModeType = z.output; +export type PartitionModeType = import("./_schema_types.ts").PartitionModeType; /** * Inspectable physical layout used when one logical file spans provider values. @@ -126,7 +126,7 @@ export type PartitionModeType = z.output; * means native stream writes use this layout so input size does not determine * facade memory growth. */ -export const AdapterPartitionSchema = z.object({ +export const AdapterPartitionSchema: z.ZodType = z.object({ /** Partitioning policy selected for this adapter. */ mode: PartitionModeSchema, /** Physical part or block size used by the layout. */ @@ -142,7 +142,7 @@ export const AdapterPartitionSchema = z.object({ }).strict(); /** A validated physical partition layout. */ -export type AdapterPartitionType = z.output; +export type AdapterPartitionType = import("./_schema_types.ts").AdapterPartitionType; /** * Optional backend limits that can be inspected before work begins. @@ -151,7 +151,7 @@ export type AdapterPartitionType = z.output; * not mean unlimited. Provider-specific clients can expose additional limits * through their own public constants and request planners. */ -export const AdapterLimitsSchema = z.object({ +export const AdapterLimitsSchema: z.ZodType = z.object({ /** Maximum logical file size accepted by this configured adapter. */ maxFileBytes: z.number().int().positive().optional(), /** Maximum materialized value accepted by one physical backend record. */ @@ -171,7 +171,7 @@ export const AdapterLimitsSchema = z.object({ }).strict(); /** Portable hard limits known by one configured adapter. */ -export type AdapterLimitsType = z.output; +export type AdapterLimitsType = import("./_schema_types.ts").AdapterLimitsType; /** * Performance routes that the filesystem facade can deliberately bypass. @@ -180,7 +180,7 @@ export type AdapterLimitsType = z.output; * fallback where one exists. This is useful for differential testing and for * applications that prefer a slower but more observable or more portable path. */ -export const OptimizationSchema = z.object({ +export const OptimizationSchema: z.ZodType = z.object({ /** Use adapter-native streaming reads instead of materialized `readFile()`. */ streamRead: z.boolean(), /** Use adapter-native streaming writes when the requested mode supports them. */ @@ -194,7 +194,7 @@ export const OptimizationSchema = z.object({ }).strict(); /** Resolved performance-route policy for one filesystem facade. */ -export type OptimizationType = z.output; +export type OptimizationType = import("./_schema_types.ts").OptimizationType; /** * Stable adapter capability description. @@ -206,7 +206,7 @@ export type OptimizationType = z.output; * host-native copy so the facade does not move bytes through JavaScript when * the backend can copy them directly. */ -export const AdapterCapabilitiesSchema = z.object({ +export const AdapterCapabilitiesSchema: z.ZodType = z.object({ /** Adapter can materialize file bytes through `readFile()`. */ read: z.boolean(), /** Adapter can commit materialized file bytes through `writeFile()`. */ @@ -228,7 +228,7 @@ export const AdapterCapabilitiesSchema = z.object({ }); /** Native operations implemented by one adapter. */ -export type AdapterCapabilitiesType = z.output; +export type AdapterCapabilitiesType = import("./_schema_types.ts").AdapterCapabilitiesType; /** * Stable error categories exposed by the package. @@ -237,7 +237,7 @@ export type AdapterCapabilitiesType = z.output * not need separate branches for DOMException, Deno, Bun, Node, SQL, and * document-database error classes. */ -export const ErrorCodeSchema = z.enum([ +export const ErrorCodeSchema: z.ZodType = z.enum([ "unavailable", "not-found", "already-exists", @@ -254,13 +254,13 @@ export const ErrorCodeSchema = z.enum([ ]); /** A validated package error category. */ -export type ErrorCodeType = z.output; +export type ErrorCodeType = import("./_schema_types.ts").ErrorCodeType; /** Version stored with record-backed filesystem entries. */ -export const RecordVersionSchema = z.literal(1); +export const RecordVersionSchema: z.ZodType = z.literal(1); /** Persisted record format version. */ -export type RecordVersionType = z.output; +export type RecordVersionType = import("./_schema_types.ts").RecordVersionType; /** * Fields shared by every persisted record-store entry. @@ -283,16 +283,19 @@ const RecordBaseSchema = z.object({ }); /** Persisted directory record used by record-store adapters. */ -export const DirectoryRecordSchema = RecordBaseSchema.extend({ +const DirectoryRecordSchemaDefinition = RecordBaseSchema.extend({ /** Discriminator that prevents a directory row from carrying file bytes. */ kind: z.literal("directory"), }); +/** Persisted directory validator with an explicit public type boundary. */ +export const DirectoryRecordSchema: z.ZodType = DirectoryRecordSchemaDefinition; + /** A validated persisted directory record. */ -export type DirectoryRecordType = z.output; +export type DirectoryRecordType = import("./_schema_types.ts").DirectoryRecordType; /** Persisted file record used by record-store adapters. */ -export const FileRecordSchema = RecordBaseSchema.extend({ +const FileRecordSchemaDefinition = RecordBaseSchema.extend({ /** Discriminator that selects the file-record branch. */ kind: z.literal("file"), /** Base64 file body used by JSON/document/SQL-compatible record stores. */ @@ -303,8 +306,11 @@ export const FileRecordSchema = RecordBaseSchema.extend({ mediaType: z.string(), }); +/** Persisted file validator with an explicit public type boundary. */ +export const FileRecordSchema: z.ZodType = FileRecordSchemaDefinition; + /** A validated persisted file record. */ -export type FileRecordType = z.output; +export type FileRecordType = import("./_schema_types.ts").FileRecordType; /** * Persisted record format shared by RxDB, unstorage, db0, and Drizzle record drivers. @@ -313,46 +319,49 @@ export type FileRecordType = z.output; * strings. This costs about one third more storage than raw bytes. Native file * adapters do not use this format. */ -export const RecordSchema = z.discriminatedUnion("kind", [DirectoryRecordSchema, FileRecordSchema]); +const RecordSchemaDefinition = z.discriminatedUnion("kind", [DirectoryRecordSchemaDefinition, FileRecordSchemaDefinition]); + +/** Persisted record validator with an explicit public type boundary. */ +export const RecordSchema: z.ZodType = RecordSchemaDefinition; /** A validated record-store filesystem entry. */ -export type RecordType = z.output; +export type RecordType = import("./_schema_types.ts").RecordType; /** SQL dialects currently exposed by db0's public Database contract. */ -export const Db0DialectSchema = z.enum(["mysql", "postgresql", "sqlite", "libsql"]); +export const Db0DialectSchema: z.ZodType = z.enum(["mysql", "postgresql", "sqlite", "libsql"]); /** A validated db0 SQL dialect. */ -export type Db0DialectType = z.output; +export type Db0DialectType = import("./_schema_types.ts").Db0DialectType; /** Safe unqualified SQL identifier used for adapter-owned table names. */ -export const SqlIdentifierSchema = z.string().regex(/^[A-Za-z_][A-Za-z0-9_]*$/); +export const SqlIdentifierSchema: z.ZodType = z.string().regex(/^[A-Za-z_][A-Za-z0-9_]*$/); /** A validated unqualified SQL identifier. */ -export type SqlIdentifierType = z.output; +export type SqlIdentifierType = import("./_schema_types.ts").SqlIdentifierType; /** Storage family owned by one backend driver. */ -export const DriverKindSchema = z.enum(["file", "record", "object"]); +export const DriverKindSchema: z.ZodType = z.enum(["file", "record", "object"]); /** A validated backend driver family. */ -export type DriverKindType = z.output; +export type DriverKindType = import("./_schema_types.ts").DriverKindType; /** Why one driver limit exists. */ -export const LimitKindSchema = z.enum(["hard", "policy", "dynamic"]); +export const LimitKindSchema: z.ZodType = z.enum(["hard", "policy", "dynamic"]); /** A validated limit kind. */ -export type LimitKindType = z.output; +export type LimitKindType = import("./_schema_types.ts").LimitKindType; /** Layer that supplied one limit value. */ -export const LimitSourceSchema = z.enum(["provider", "implementation", "user", "probe"]); +export const LimitSourceSchema: z.ZodType = z.enum(["provider", "implementation", "user", "probe"]); /** A validated limit source. */ -export type LimitSourceType = z.output; +export type LimitSourceType = import("./_schema_types.ts").LimitSourceType; /** Unit used by one numeric limit. */ -export const LimitUnitSchema = z.enum(["bytes", "count", "milliseconds", "operations"]); +export const LimitUnitSchema: z.ZodType = z.enum(["bytes", "count", "milliseconds", "operations"]); /** A validated limit unit. */ -export type LimitUnitType = z.output; +export type LimitUnitType = import("./_schema_types.ts").LimitUnitType; /** * One inspectable storage limit with explicit provenance. @@ -361,7 +370,7 @@ export type LimitUnitType = z.output; * a user-selected ceiling. `value` can be absent only for a dynamic limit whose * current value has not been probed. */ -export const LimitSchema = z.object({ +export const LimitSchema: z.ZodType = z.object({ /** Stable machine-readable limit code. */ code: z.string().min(1), /** Whether the limit is hard, policy-driven, or dynamic. */ @@ -381,13 +390,13 @@ export const LimitSchema = z.object({ }); /** A validated storage limit. */ -export type LimitType = z.output; +export type LimitType = import("./_schema_types.ts").LimitType; /** Current state of one driver requirement. */ -export const RequirementStateSchema = z.enum(["available", "missing", "unknown"]); +export const RequirementStateSchema: z.ZodType = z.enum(["available", "missing", "unknown"]); /** A validated requirement state. */ -export type RequirementStateType = z.output; +export type RequirementStateType = import("./_schema_types.ts").RequirementStateType; /** * One runtime, provider, permission, or configuration requirement. @@ -395,7 +404,7 @@ export type RequirementStateType = z.output; * Definitions can report `unknown` before probing. Configured drivers should * prefer `available` or `missing` when the state is already known. */ -export const RequirementSchema = z.object({ +export const RequirementSchema: z.ZodType = z.object({ /** Stable machine-readable requirement code. */ code: z.string().min(1), /** Current known availability state. */ @@ -409,13 +418,13 @@ export const RequirementSchema = z.object({ }); /** A validated driver requirement. */ -export type RequirementType = z.output; +export type RequirementType = import("./_schema_types.ts").RequirementType; /** Ownership state for one configured driver backend resource. */ -export const DriverOwnershipSchema = z.enum(["none", "borrowed", "owned"]); +export const DriverOwnershipSchema: z.ZodType = z.enum(["none", "borrowed", "owned"]); /** A validated configured-driver backend ownership state. */ -export type DriverOwnershipType = z.output; +export type DriverOwnershipType = import("./_schema_types.ts").DriverOwnershipType; /** * One independently controllable driver optimization. @@ -424,7 +433,7 @@ export type DriverOwnershipType = z.output; * consistency, atomicity, or another observable property can differ when the * optimization is enabled. Such optimizations must be disableable. */ -export const DriverOptimizationSchema = z.object({ +export const DriverOptimizationSchema: z.ZodType = z.object({ /** Stable machine-readable optimization code. */ code: z.string().min(1), /** Current enabled state. */ @@ -442,4 +451,4 @@ export const DriverOptimizationSchema = z.object({ }); /** A validated driver optimization declaration. */ -export type DriverOptimizationType = z.output; +export type DriverOptimizationType = import("./_schema_types.ts").DriverOptimizationType; diff --git a/tests/browser/adapters.spec.ts b/tests/browser/adapters.spec.ts index 0969a59..79233a0 100644 --- a/tests/browser/adapters.spec.ts +++ b/tests/browser/adapters.spec.ts @@ -1,23 +1,37 @@ import { expect, test } from "@playwright/test"; +import type { BrowserTestGlobalType } from "./fixtures/api.ts"; + +/** File-local global shape after the fixture page installs its Playwright API. */ +type InstalledFixtureGlobalType = typeof globalThis & BrowserTestGlobalType; +/** File-local global shape while the fixture module may still be initializing. */ +type PendingFixtureGlobalType = typeof globalThis & Partial; + /** Same-origin fixture page that exposes the browser adapter test API. */ const APP_URL = "http://127.0.0.1:4173/tests/browser/fixtures/index.html"; /** Opens the fixture page and waits until its module API is ready for Playwright calls. */ async function ready(page: import("@playwright/test").Page): Promise { await page.goto(APP_URL); - await page.waitForFunction(() => Boolean((globalThis as unknown as { opfsTest?: { ready?: boolean } }).opfsTest?.ready)); + await page.waitForFunction(() => Boolean((globalThis as PendingFixtureGlobalType).opfsTest?.ready)); } for (const kind of ["localstorage", "indexeddb", "cache"] as const) { test(`${kind} adapter executes against the real browser backend`, async ({ page }) => { await ready(page); - expect(await page.evaluate(async ({ kind }) => await globalThis.opfsTest.adapter(kind), { kind })).toBe(kind); + expect( + await page.evaluate( + async ({ kind }) => await (globalThis as InstalledFixtureGlobalType).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 globalThis.opfsTest.indexedDbAppend()); + const value = await page.evaluate(async () => + await (globalThis as InstalledFixtureGlobalType).opfsTest.indexedDbAppend() + ); expect(["baseAB", "baseBA"]).toContain(value); }); diff --git a/tests/browser/fixtures/api.ts b/tests/browser/fixtures/api.ts new file mode 100644 index 0000000..a474f83 --- /dev/null +++ b/tests/browser/fixtures/api.ts @@ -0,0 +1,79 @@ +import type { probeOpfs } from "../../../mod.ts"; + +/** Result returned by one real browser realm after probing and exercising OPFS. */ +export interface RealmResultType { + /** Whether the browser exposes the realm or capability needed by the scenario. */ + readonly supported: boolean; + /** Capability report captured in the same realm as the operation. */ + readonly probe?: Awaited>; + /** Text read back after the fixture writes through OPFS. */ + readonly value?: string; + /** Whether a synchronous access handle opened in the tested worker realm. */ + readonly syncOpened?: boolean; + /** Native error name reported when synchronous access was exposed but could not open. */ + readonly syncError?: string; +} + +/** Browser record adapters exercised against their actual platform storage APIs. */ +export type BrowserAdapterType = "localstorage" | "indexeddb" | "cache"; + +/** Stable result returned by browser cancellation scenarios. */ +export 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; +} + +/** Timing result shared by browser fixture benchmarks and their Playwright callers. */ +export interface BenchmarkResultType { + /** Elapsed milliseconds for direct platform storage operations. */ + readonly rawMs: number; + /** Elapsed milliseconds for the direct `AdapterType` path. */ + readonly adapterMs: number; + /** Elapsed milliseconds for the complete `FileSystemType` path. */ + readonly facadeMs: number; +} + +/** Browser fixture API consumed by Playwright from the containing page. */ +export interface BrowserTestApiType { + /** Signals that module initialization completed and Playwright can call the fixture. */ + readonly ready: true; + /** Probes OPFS in the Window realm without throwing for unsupported storage. */ + probe(): ReturnType; + /** Writes and reads one value through native Window OPFS. */ + roundTrip(path: string, value: string): Promise; + /** Reads an existing Window OPFS file without creating it. */ + read(path: string): Promise; + /** Runs the OPFS scenario in a real DedicatedWorker. */ + dedicated(path: string, value: string): Promise; + /** Runs the OPFS scenario in a real SharedWorker. */ + 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 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; +} + +/** + * File-local view of a browser global after the fixture installs `opfsTest`. + * + * Importing this type does not augment `Window`, `WorkerGlobalScope`, or + * `globalThis`. Each Playwright source file must opt in with a local cast at the + * point where its callback executes inside the fixture realm. + */ +export interface BrowserTestGlobalType { + readonly opfsTest: BrowserTestApiType; +} diff --git a/tests/browser/fixtures/app.ts b/tests/browser/fixtures/app.ts index 2e48f36..7e8e222 100644 --- a/tests/browser/fixtures/app.ts +++ b/tests/browser/fixtures/app.ts @@ -6,78 +6,13 @@ import { createMemoryAdapter } from "../../../src/adapter/memory.ts"; import { createOpfsAdapter } from "../../../src/adapter/opfs.ts"; import { createFileSystem } from "../../../src/filesystem.ts"; -/** Result returned by one real browser realm after probing and exercising OPFS. */ -interface RealmResultType { - /** Whether the browser exposes the realm or capability needed by the scenario. */ - readonly supported: boolean; - /** Capability report captured in the same realm as the operation. */ - readonly probe?: Awaited>; - /** Text read back after the fixture writes through OPFS. */ - readonly value?: string; - /** Whether a synchronous access handle opened in the tested worker realm. */ - readonly syncOpened?: boolean; - /** Native error name reported when synchronous access was exposed but could not open. */ - readonly syncError?: string; -} - -/** Browser record adapters exercised against their actual platform storage APIs. */ -type BrowserAdapterType = "localstorage" | "indexeddb" | "cache"; - -/** 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; - /** Elapsed milliseconds for the direct `AdapterType` path. */ - readonly adapterMs: number; - /** Elapsed milliseconds for the complete `FileSystemType` path. */ - readonly facadeMs: number; -} - -/** Browser fixture API consumed by Playwright from the containing page. */ -interface BrowserTestApiType { - /** Signals that module initialization completed and Playwright can call the fixture. */ - ready: true; - /** Probes OPFS in the Window realm without throwing for unsupported storage. */ - probe(): ReturnType; - /** Writes and reads one value through native Window OPFS. */ - roundTrip(path: string, value: string): Promise; - /** Reads an existing Window OPFS file without creating it. */ - read(path: string): Promise; - /** Runs the OPFS scenario in a real DedicatedWorker. */ - dedicated(path: string, value: string): Promise; - /** Runs the OPFS scenario in a real SharedWorker. */ - 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 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 { - interface Window { - /** Playwright-facing test API installed only by this fixture page. */ - opfsTest: BrowserTestApiType; - } -} +import type { + AbortResultType, + BenchmarkResultType, + BrowserAdapterType, + BrowserTestApiType, + RealmResultType, +} from "./api.ts"; /** Writes and reads one value through the Window realm OPFS facade. */ async function roundTripOpfs(path: string, value: string): Promise { @@ -566,7 +501,8 @@ async function roundTripAdapter(kind: BrowserAdapterType): Promise { } } -globalThis.opfsTest = { +/** Fixture API installed on this page without augmenting browser globals for other source files. */ +const opfsTest = { ready: true, probe: probeOpfs, roundTrip: roundTripOpfs, @@ -580,8 +516,6 @@ globalThis.opfsTest = { benchmarkAdapter, adapter: roundTripAdapter, indexedDbAppend, -}; +} satisfies BrowserTestApiType; -declare global { - var opfsTest: BrowserTestApiType; -} \ No newline at end of file +Object.assign(window, { opfsTest }); diff --git a/tests/browser/fixtures/opfs-worker-types.ts b/tests/browser/fixtures/opfs-worker-types.ts new file mode 100644 index 0000000..d3a1f33 --- /dev/null +++ b/tests/browser/fixtures/opfs-worker-types.ts @@ -0,0 +1,18 @@ +/// +import type { OpfsDirectoryHandleType, OpfsFileHandleType } from "../../../src/driver/opfs.ts"; + +/** Fails compilation when `Actual` cannot be used where the worker OPFS driver expects `Expected`. */ +type AssertAssignable = Actual; + +/** + * A worker-native file handle includes `createSyncAccessHandle()`, so this check + * also proves that TypeScript's `FileSystemSyncAccessHandle` satisfies the + * package's optional synchronous-file contract. + */ +export type NativeWorkerFileHandleCompatibility = AssertAssignable; + +/** Worker OPFS directory roots must satisfy the same portable directory contract as Window roots. */ +export type NativeWorkerDirectoryHandleCompatibility = AssertAssignable< + OpfsDirectoryHandleType, + FileSystemDirectoryHandle +>; diff --git a/tests/browser/iframe.spec.ts b/tests/browser/iframe.spec.ts index dd02c13..c647a4f 100644 --- a/tests/browser/iframe.spec.ts +++ b/tests/browser/iframe.spec.ts @@ -1,5 +1,13 @@ import { expect, test } from "@playwright/test"; +import type { OpfsDirectoryHandleType } from "../../src/driver/opfs.ts"; +import type { BrowserTestGlobalType } from "./fixtures/api.ts"; + +/** File-local Window shape after the iframe fixture installs its Playwright API. */ +type InstalledFixtureWindowType = typeof window & BrowserTestGlobalType; +/** File-local Window shape while the iframe fixture module may still be initializing. */ +type PendingFixtureWindowType = typeof window & Partial; + /** Same-origin fixture used as the top-level embedding document. */ const APP_URL = "http://127.0.0.1:4173/tests/browser/fixtures/index.html"; /** Second-origin fixture used to exercise storage partition and policy behavior. */ @@ -7,7 +15,7 @@ const CROSS_URL = "http://127.0.0.1:4174/tests/browser/fixtures/frame.html"; /** Waits until an iframe has installed the shared browser test API. */ async function waitForApi(frame: import("@playwright/test").Frame): Promise { - await frame.waitForFunction(() => Boolean((globalThis as unknown as { opfsTest?: { ready?: boolean } }).opfsTest?.ready)); + await frame.waitForFunction(() => Boolean((window as PendingFixtureWindowType).opfsTest?.ready)); } test("same-origin iframe observes its real OPFS placement", async ({ page }) => { @@ -23,7 +31,7 @@ test("same-origin iframe observes its real OPFS placement", async ({ page }) => const frame = await framePromise; await waitForApi(frame); const result = await frame.evaluate(async () => - await globalThis.opfsTest.roundTrip(`/frames/${crypto.randomUUID()}.txt`, "same") + await (window as InstalledFixtureWindowType).opfsTest.roundTrip(`/frames/${crypto.randomUUID()}.txt`, "same") ); expect(result.probe?.embedded).toBe(true); expect(result.probe?.sameOriginTop).toBe(true); @@ -43,7 +51,7 @@ test("cross-origin iframe reports partition/policy behavior instead of guessing }, CROSS_URL); const frame = await framePromise; await waitForApi(frame); - const probe = await frame.evaluate(async () => await globalThis.opfsTest.probe()); + const probe = await frame.evaluate(async () => await (window as InstalledFixtureWindowType).opfsTest.probe()); expect(probe.embedded).toBe(true); expect(probe.sameOriginTop).toBe(false); if (!probe.rootAvailable) expect(probe.rootError).toBeDefined(); @@ -54,13 +62,13 @@ test("opaque sandbox reports the platform result without browser-name assumption await page.evaluate(() => { const frame = document.createElement("iframe"); frame.sandbox.add("allow-scripts"); - frame.srcdoc = ""; + frame.srcdoc = ""; document.body.append(frame); }); const frame = page.frames().find((candidate) => candidate !== page.mainFrame())!; await frame.waitForFunction(() => (window as unknown as { ready?: boolean }).ready === true); const result = await frame.evaluate(async () => { - const storage = navigator.storage as StorageManager & { getDirectory?: () => Promise }; + const storage = navigator.storage as StorageManager & { getDirectory?: () => Promise }; if (typeof storage?.getDirectory !== "function") return { available: false, name: "NotSupportedError" }; try { await storage.getDirectory(); diff --git a/tests/browser/opfs-types.ts b/tests/browser/opfs-types.ts new file mode 100644 index 0000000..e61c4b4 --- /dev/null +++ b/tests/browser/opfs-types.ts @@ -0,0 +1,57 @@ +import type { OpfsAdapterType } from "../../src/adapter/opfs.ts"; +import type { + OpfsDirectoryHandleType, + OpfsDriverType, + OpfsFileHandleType, + OpfsWritableFileStreamType, +} from "../../src/driver/opfs.ts"; + +/** + * The Playwright fixture API must stay opt-in instead of becoming an ambient Window member. + * + * `check:browser` type-checks this file together with the fixture modules. If any + * of them adds a global `Window.opfsTest` augmentation, this expected error becomes + * unused and the check fails. + */ +// @ts-expect-error The fixture API is intentionally not declared on the global Window interface. +export type FixtureApiMustNotBeAmbient = typeof window.opfsTest; + +/** Fails compilation when `Actual` cannot be used where the package expects `Expected`. */ +type AssertAssignable = Actual; + +/** Exact-type comparison used to prove generic factories preserve a caller's native root type. */ +type IsExact = [Left] extends [Right] ? [Right] extends [Left] ? true : false : false; + +/** Fails compilation unless one exact-type comparison remains true. */ +type AssertTrue = Value; + +/** TypeScript's Window writable stream must implement every operation used by the OPFS driver. */ +export type NativeWritableCompatibility = AssertAssignable< + OpfsWritableFileStreamType, + FileSystemWritableFileStream +>; + +/** TypeScript's Window file handle must implement every operation used by the OPFS driver. */ +export type NativeFileHandleCompatibility = AssertAssignable; + +/** TypeScript's Window directory handle must implement every operation used by the OPFS driver. */ +export type NativeDirectoryHandleCompatibility = AssertAssignable< + OpfsDirectoryHandleType, + FileSystemDirectoryHandle +>; + +/** The actual OPFS root returned by `StorageManager.getDirectory()` must satisfy the package contract. */ +export type StorageManagerRootCompatibility = AssertAssignable< + OpfsDirectoryHandleType, + Awaited> +>; + +/** Passing a native root through the driver must preserve the complete native root type. */ +export type NativeDriverRootPreservation = AssertTrue< + IsExact["nativeRoot"], FileSystemDirectoryHandle> +>; + +/** Passing a native root through the adapter must preserve the complete native root type. */ +export type NativeAdapterRootPreservation = AssertTrue< + IsExact["nativeRoot"], FileSystemDirectoryHandle> +>; diff --git a/tests/browser/opfs.spec.ts b/tests/browser/opfs.spec.ts index 548d599..a6ade3f 100644 --- a/tests/browser/opfs.spec.ts +++ b/tests/browser/opfs.spec.ts @@ -1,18 +1,25 @@ import { chromium, expect, firefox, test, webkit } from "@playwright/test"; +import type { BrowserTestGlobalType } from "./fixtures/api.ts"; + +/** File-local global shape after the fixture page installs its Playwright API. */ +type InstalledFixtureGlobalType = typeof globalThis & BrowserTestGlobalType; +/** File-local global shape while the fixture module may still be initializing. */ +type PendingFixtureGlobalType = typeof globalThis & Partial; + /** Same-origin fixture page used by Window and persistence scenarios. */ const APP_URL = "http://127.0.0.1:4173/tests/browser/fixtures/index.html"; /** Opens the fixture page and waits for its OPFS test API. */ async function ready(page: import("@playwright/test").Page): Promise { await page.goto(APP_URL); - await page.waitForFunction(() => Boolean((globalThis as unknown as { opfsTest?: { ready?: boolean } }).opfsTest?.ready)); + await page.waitForFunction(() => Boolean((globalThis as PendingFixtureGlobalType).opfsTest?.ready)); } test("window probes the actual capability and round-trips when OPFS is available", async ({ page }) => { await ready(page); const result = await page.evaluate(async () => - await globalThis.opfsTest.roundTrip(`/window/${crypto.randomUUID()}.txt`, "window") + await (globalThis as InstalledFixtureGlobalType).opfsTest.roundTrip(`/window/${crypto.randomUUID()}.txt`, "window") ); expect(result.supported).toBe(true); expect(result.probe?.context).toBe("window"); @@ -22,14 +29,18 @@ 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 globalThis.opfsTest.abort(`/abort/${crypto.randomUUID()}.txt`)); + const result = await page.evaluate(async () => + await (globalThis as InstalledFixtureGlobalType).opfsTest.abort(`/abort/${crypto.randomUUID()}.txt`) + ); 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 globalThis.opfsTest.queuedAbort()); + const result = await page.evaluate(async () => + await (globalThis as InstalledFixtureGlobalType).opfsTest.queuedAbort() + ); test.skip(!result.supported, "This browser does not expose the Web Locks API."); expect(result).toEqual({ supported: true, name: "FileSystemError", code: "aborted" }); }); @@ -39,9 +50,10 @@ test("fresh browser contexts do not inherit another context's OPFS file", async const first = await browser.newContext(); const firstPage = await first.newPage(); await ready(firstPage); - const written = await firstPage.evaluate(async ({ path }) => await globalThis.opfsTest.roundTrip(path, "private"), { - path, - }); + const written = await firstPage.evaluate( + async ({ path }) => await (globalThis as InstalledFixtureGlobalType).opfsTest.roundTrip(path, "private"), + { path }, + ); await first.close(); if (!written.probe?.rootAvailable) { expect(written.probe?.rootError).toBeDefined(); @@ -51,7 +63,12 @@ test("fresh browser contexts do not inherit another context's OPFS file", async const second = await browser.newContext(); const secondPage = await second.newPage(); await ready(secondPage); - expect(await secondPage.evaluate(async ({ path }) => await globalThis.opfsTest.read(path), { path })).toBeNull(); + expect( + await secondPage.evaluate( + async ({ path }) => await (globalThis as InstalledFixtureGlobalType).opfsTest.read(path), + { path }, + ), + ).toBeNull(); await second.close(); }); @@ -63,9 +80,10 @@ test("a persistent profile reopens the same OPFS data", async ({ browserName }, const first = await browserType.launchPersistentContext(profile); const firstPage = await first.newPage(); await ready(firstPage); - const written = await firstPage.evaluate(async ({ path }) => await globalThis.opfsTest.roundTrip(path, "persisted"), { - path, - }); + const written = await firstPage.evaluate( + async ({ path }) => await (globalThis as InstalledFixtureGlobalType).opfsTest.roundTrip(path, "persisted"), + { path }, + ); await first.close(); if (!written.probe?.rootAvailable) { expect(written.probe?.rootError).toBeDefined(); @@ -75,6 +93,11 @@ test("a persistent profile reopens the same OPFS data", async ({ browserName }, const second = await browserType.launchPersistentContext(profile); const secondPage = await second.newPage(); await ready(secondPage); - expect(await secondPage.evaluate(async ({ path }) => await globalThis.opfsTest.read(path), { path })).toBe("persisted"); + expect( + await secondPage.evaluate( + async ({ path }) => await (globalThis as InstalledFixtureGlobalType).opfsTest.read(path), + { path }, + ), + ).toBe("persisted"); await second.close(); }); diff --git a/tests/browser/service-worker.spec.ts b/tests/browser/service-worker.spec.ts index 82098e5..a9aa00f 100644 --- a/tests/browser/service-worker.spec.ts +++ b/tests/browser/service-worker.spec.ts @@ -1,18 +1,28 @@ import { expect, test } from "@playwright/test"; +import type { BrowserTestGlobalType } from "./fixtures/api.ts"; + +/** File-local global shape after the fixture page installs its Playwright API. */ +type InstalledFixtureGlobalType = typeof globalThis & BrowserTestGlobalType; +/** File-local global shape while the fixture module may still be initializing. */ +type PendingFixtureGlobalType = typeof globalThis & Partial; + /** Same-origin fixture page that registers and communicates with the ServiceWorker. */ const APP_URL = "http://127.0.0.1:4173/tests/browser/fixtures/index.html"; /** Opens the fixture page and waits for its OPFS test API. */ async function ready(page: import("@playwright/test").Page): Promise { await page.goto(APP_URL); - await page.waitForFunction(() => Boolean((globalThis as unknown as { opfsTest?: { ready?: boolean } }).opfsTest?.ready)); + await page.waitForFunction(() => Boolean((globalThis as PendingFixtureGlobalType).opfsTest?.ready)); } test("ServiceWorker behavior is verified through page messaging in every browser", async ({ page }) => { await ready(page); const result = await page.evaluate(async () => - await globalThis.opfsTest.service(`/service/${crypto.randomUUID()}.txt`, "service") + await (globalThis as InstalledFixtureGlobalType).opfsTest.service( + `/service/${crypto.randomUUID()}.txt`, + "service", + ) ); test.skip(!result.supported, "ServiceWorker is not exposed in this browser context."); expect(result.probe?.context).toBe("service-worker"); @@ -24,7 +34,10 @@ test("Chromium exposes the registered service worker to Playwright instrumentati test.skip(browserName !== "chromium", "Playwright serviceWorkers() inspection is Chromium-only."); await ready(page); const result = await page.evaluate(async () => - await globalThis.opfsTest.service(`/service/${crypto.randomUUID()}.txt`, "instrumented") + await (globalThis as InstalledFixtureGlobalType).opfsTest.service( + `/service/${crypto.randomUUID()}.txt`, + "instrumented", + ) ); test.skip(!result.supported, "ServiceWorker is not exposed in this Chromium context."); expect(context.serviceWorkers().length).toBeGreaterThan(0); diff --git a/tests/browser/worker.spec.ts b/tests/browser/worker.spec.ts index 67f8934..a602cfb 100644 --- a/tests/browser/worker.spec.ts +++ b/tests/browser/worker.spec.ts @@ -1,18 +1,28 @@ import { expect, test } from "@playwright/test"; +import type { BrowserTestGlobalType } from "./fixtures/api.ts"; + +/** File-local global shape after the fixture page installs its Playwright API. */ +type InstalledFixtureGlobalType = typeof globalThis & BrowserTestGlobalType; +/** File-local global shape while the fixture module may still be initializing. */ +type PendingFixtureGlobalType = typeof globalThis & Partial; + /** Same-origin fixture page that creates real DedicatedWorker and SharedWorker instances. */ const APP_URL = "http://127.0.0.1:4173/tests/browser/fixtures/index.html"; /** Opens the fixture page and waits for its OPFS test API. */ async function ready(page: import("@playwright/test").Page): Promise { await page.goto(APP_URL); - await page.waitForFunction(() => Boolean((globalThis as unknown as { opfsTest?: { ready?: boolean } }).opfsTest?.ready)); + await page.waitForFunction(() => Boolean((globalThis as PendingFixtureGlobalType).opfsTest?.ready)); } test("DedicatedWorker uses real OPFS and probes synchronous access", async ({ page }) => { await ready(page); const result = await page.evaluate(async () => - await globalThis.opfsTest.dedicated(`/dedicated/${crypto.randomUUID()}.txt`, "dedicated") + await (globalThis as InstalledFixtureGlobalType).opfsTest.dedicated( + `/dedicated/${crypto.randomUUID()}.txt`, + "dedicated", + ) ); test.skip(!result.supported, "DedicatedWorker is not exposed in this browser context."); expect(result.probe?.context).toBe("dedicated-worker"); @@ -27,7 +37,10 @@ test("DedicatedWorker uses real OPFS and probes synchronous access", async ({ pa test("SharedWorker uses the browser's actual storage capability", async ({ page }) => { await ready(page); const result = await page.evaluate(async () => - await globalThis.opfsTest.shared(`/shared/${crypto.randomUUID()}.txt`, "shared") + await (globalThis as InstalledFixtureGlobalType).opfsTest.shared( + `/shared/${crypto.randomUUID()}.txt`, + "shared", + ) ); test.skip(!result.supported, "SharedWorker is not exposed in this browser context."); expect(["shared-worker", "worker"]).toContain(result.probe?.context); diff --git a/tests/driver.test.ts b/tests/driver.test.ts index c94aa69..695f87a 100644 --- a/tests/driver.test.ts +++ b/tests/driver.test.ts @@ -2,6 +2,12 @@ import { describe, it } from "node:test"; import { expect } from "@std/expect"; import { defineDriver } from "../src/driver/definition.ts"; +import { + createOpfsDriver, + type OpfsDirectoryHandleType, + type OpfsFileHandleType, + type OpfsWritableFileStreamType, +} from "../src/driver/opfs.ts"; import { defineRecordDriver, type RecordBackendType } from "../src/driver/record.ts"; import type { PathType } from "../src/schema.ts"; import type { RecordType } from "../src/schema.ts"; @@ -133,6 +139,59 @@ describe("driver contract", () => { expect(() => driver.set(record)).toThrow(); }); + it("copies SharedArrayBuffer-backed stream chunks before asynchronous OPFS writes", async () => { + if (typeof SharedArrayBuffer !== "function") return; + + let nativeBytes: Uint8Array | undefined; + const writable: OpfsWritableFileStreamType = { + async write(data): Promise { + nativeBytes = data instanceof Uint8Array ? data : data.data; + }, + async seek(): Promise {}, + async truncate(): Promise {}, + async close(): Promise {}, + async abort(): Promise {}, + }; + const file: OpfsFileHandleType = { + kind: "file", + name: "shared.bin", + async getFile(): Promise { + throw new Error("The replace-mode regression test must not read the existing file."); + }, + async createWritable(): Promise { + return writable; + }, + }; + const root: OpfsDirectoryHandleType = { + kind: "directory", + name: "", + async getFileHandle(): Promise { + return file; + }, + async getDirectoryHandle(): Promise { + return root; + }, + async removeEntry(): Promise {}, + async *entries(): AsyncIterableIterator {}, + }; + + const shared = new SharedArrayBuffer(4); + const sourceBytes = new Uint8Array(shared); + sourceBytes.set([11, 22, 33, 44]); + const source = new ReadableStream({ + start(controller): void { + controller.enqueue(sourceBytes); + controller.close(); + }, + }); + + await createOpfsDriver(root).writeStream("/shared.bin" as PathType, source, { mode: "replace" }); + + expect(nativeBytes).toBeDefined(); + expect(nativeBytes!.buffer instanceof ArrayBuffer).toBe(true); + expect([...nativeBytes!]).toEqual([11, 22, 33, 44]); + }); + it("disposes a borrowed backend only when ownership is transferred", async () => { const borrowed = new TestRecordBackend(); const borrowedDriver = defineRecordDriver(borrowed, { name: "borrowed" }); diff --git a/tests/package/verify.mjs b/tests/package/verify.mjs index 238245c..059d87b 100644 --- a/tests/package/verify.mjs +++ b/tests/package/verify.mjs @@ -53,6 +53,14 @@ try { } if (!manifest.dependencies?.zod) throw new Error("zod runtime dependency is missing."); + for (const field of ["dependencies", "peerDependencies", "optionalDependencies"]) { + for (const name of Object.keys(manifest[field] ?? {})) { + if (name.startsWith("@jsr/")) { + throw new Error(`npm tarball leaked JSR compatibility dependency '${name}' through ${field}.`); + } + } + } + for (const [subpath, target] of Object.entries(manifest.exports ?? {})) { const entry = typeof target === "string" ? { default: target } : target; for (const field of ["types", "import", "default"]) { diff --git a/tsconfig.opfs-dom.json b/tsconfig.opfs-dom.json new file mode 100644 index 0000000..e845518 --- /dev/null +++ b/tsconfig.opfs-dom.json @@ -0,0 +1,17 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "lib": [ + "esnext", + "dom", + "dom.iterable", + "dom.asynciterable" + ], + "types": [] + }, + "include": [ + "tests/browser/opfs-types.ts", + "tests/browser/fixtures/app.ts" + ], + "exclude": [] +} diff --git a/tsconfig.opfs-worker.json b/tsconfig.opfs-worker.json new file mode 100644 index 0000000..76e6c3f --- /dev/null +++ b/tsconfig.opfs-worker.json @@ -0,0 +1,17 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "lib": [ + "esnext", + "webworker", + "webworker.importscripts", + "webworker.iterable", + "webworker.asynciterable" + ], + "types": [] + }, + "include": [ + "tests/browser/fixtures/opfs-worker-types.ts" + ], + "exclude": [] +} -- 2.51.2