diff --git a/deno.json b/deno.json index 88f186d..4f4ffd2 100644 --- a/deno.json +++ b/deno.json @@ -4,6 +4,7 @@ "exports": { ".": "./mod.ts", "./adapter": "./src/adapter/definition.ts", + "./adapter/file": "./src/adapter/file.ts", "./adapter/opfs": "./src/adapter/opfs.ts", "./adapter/memory": "./src/adapter/memory.ts", "./adapter/deno": "./src/adapter/deno.ts", @@ -22,30 +23,48 @@ "./adapter/rxdb": "./src/adapter/rxdb.ts", "./adapter/db0": "./src/adapter/db0.ts", "./adapter/drizzle": "./src/adapter/drizzle.ts", - "./driver/kv": "./src/driver/kv.ts", + "./driver": "./src/driver/definition.ts", + "./driver/file": "./src/driver/file.ts", + "./driver/record": "./src/driver/record.ts", + "./driver/object": "./src/driver/object.ts", + "./driver/opfs": "./src/driver/opfs.ts", + "./driver/memory": "./src/driver/memory.ts", + "./driver/deno": "./src/driver/deno.ts", + "./driver/deno-kv": "./src/driver/deno-kv.ts", + "./driver/node": "./src/driver/node.ts", + "./driver/bun": "./src/driver/bun.ts", + "./driver/localstorage": "./src/driver/localstorage.ts", + "./driver/indexeddb": "./src/driver/indexeddb.ts", + "./driver/cache": "./src/driver/cache.ts", + "./driver/sqlite": "./src/driver/sqlite.ts", "./driver/unstorage": "./src/driver/unstorage.ts", + "./driver/rxdb": "./src/driver/rxdb.ts", + "./driver/db0": "./src/driver/db0.ts", + "./driver/drizzle": "./src/driver/drizzle.ts", + "./driver/s3": "./src/driver/s3.ts", + "./driver/azure": "./src/driver/azure.ts", "./s3": "./src/s3.ts", "./azure": "./src/azure.ts", "./iframe": "./src/iframe.ts", "./path": "./src/path.ts", "./schema": "./src/schema.ts", "./bridge": "./src/bridge.ts", - "./bridge/definition": "./src/bridge/definition.ts", - "./bridge/unstorage": "./src/bridge/unstorage.ts", - "./bridge/rxdb": "./src/bridge/rxdb.ts", - "./bridge/db0": "./src/bridge/db0.ts", - "./bridge/drizzle": "./src/bridge/drizzle.ts", "./bridge/kv": "./src/bridge/kv.ts", + "./bridge/unstorage": "./src/bridge/unstorage.ts", + "./integration": "./src/integration.ts", + "./integration/definition": "./src/integration/definition.ts", "./capability": "./src/capability.ts", "./metrics": "./src/metrics.ts", "./plan": "./src/plan.ts", "./request": "./src/request.ts" }, "imports": { + "@okikio/undent": "jsr:@okikio/undent@^0.3.3", "@std/expect": "jsr:@std/expect@^1.0.20", "drizzle-orm": "npm:drizzle-orm@^0.45.2", "mitata": "npm:mitata@^1.0.34", "zod": "npm:zod@^4.4.3", + "deno": "npm:@types/deno", "@playwright/test": "npm:@playwright/test@^1.62.1", "vite": "npm:vite@^8.2.1", "@std/async": "jsr:@std/async@^1.5.0", @@ -113,22 +132,22 @@ "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: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/definition.ts src/bridge/unstorage.ts src/bridge/rxdb.ts src/bridge/db0.ts src/bridge/drizzle.ts src/bridge/kv.ts src/adapter/definition.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/driver/kv.ts src/driver/unstorage.ts src/iframe.ts src/path.ts src/schema.ts src/s3.ts src/azure.ts", - "check:browser": "deno check 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: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 --lib=esnext,webworker tests/browser/fixtures/dedicated.ts tests/browser/fixtures/shared.ts tests/browser/fixtures/service.ts", - "check:server": "deno check src/adapter/deno.ts src/adapter/deno-kv.ts src/adapter/node.ts src/adapter/bun.ts src/adapter/sqlite.ts bench/deno.bench.ts bench/node.bench.ts bench/bun.bench.ts bench/sqlite.bench.ts", - "check:tests": "deno check tests/path.test.ts tests/memory.test.ts tests/filesystem.test.ts tests/ecosystems.test.ts tests/chunk.test.ts tests/object.test.ts tests/s3.test.ts tests/azure.test.ts tests/sqlite.test.ts tests/deno-kv-partition.test.ts tests/node.test.ts tests/deno.test.ts tests/bun.test.ts", - "check:deno-kv": "deno check --unstable-kv tests/deno-kv.test.ts bench/deno-kv.bench.ts", + "check:server": "deno check src/driver/deno.ts src/driver/node.ts src/driver/bun.ts src/driver/sqlite.ts src/adapter/deno.ts src/adapter/node.ts src/adapter/bun.ts src/adapter/sqlite.ts bench/deno.bench.ts bench/node.bench.ts bench/bun.bench.ts bench/sqlite.bench.ts", + "check:tests": "deno check tests/path.test.ts tests/memory.test.ts tests/filesystem.test.ts tests/driver.test.ts tests/ecosystems.test.ts tests/chunk.test.ts tests/object.test.ts tests/s3.test.ts tests/azure.test.ts tests/sqlite.test.ts tests/deno-kv-partition.test.ts tests/node.test.ts tests/deno.test.ts tests/bun.test.ts", + "check:deno-kv": "deno check --unstable-kv src/driver/deno-kv.ts src/adapter/deno-kv.ts tests/deno-kv.test.ts bench/deno-kv.bench.ts", "test": "deno task test:portable && deno task test:deno && deno task test:deno-kv", - "test:portable": "deno test --sanitize-ops --sanitize-resources --trace-leaks tests/path.test.ts tests/memory.test.ts tests/filesystem.test.ts tests/ecosystems.test.ts tests/chunk.test.ts tests/object.test.ts tests/s3.test.ts tests/azure.test.ts tests/sqlite.test.ts tests/deno-kv-partition.test.ts", + "test:portable": "deno test --sanitize-ops --sanitize-resources --trace-leaks tests/path.test.ts tests/memory.test.ts tests/filesystem.test.ts tests/driver.test.ts tests/ecosystems.test.ts tests/chunk.test.ts tests/object.test.ts tests/s3.test.ts tests/azure.test.ts tests/sqlite.test.ts tests/deno-kv-partition.test.ts", "test:deno": "deno test --allow-read --allow-write --sanitize-ops --sanitize-resources --trace-leaks tests/deno.test.ts", "test:deno-kv": "deno test --unstable-kv --allow-read --allow-write --sanitize-ops --sanitize-resources --trace-leaks tests/deno-kv.test.ts", - "test:node": "node --test tests/path.test.ts tests/memory.test.ts tests/filesystem.test.ts tests/ecosystems.test.ts tests/chunk.test.ts tests/object.test.ts tests/s3.test.ts tests/azure.test.ts tests/sqlite.test.ts tests/deno-kv-partition.test.ts tests/node.test.ts", - "test:bun": "bun test tests/path.test.ts tests/memory.test.ts tests/filesystem.test.ts tests/ecosystems.test.ts tests/chunk.test.ts tests/object.test.ts tests/s3.test.ts tests/azure.test.ts tests/sqlite.test.ts tests/deno-kv-partition.test.ts tests/bun.test.ts", + "test:node": "node --test tests/path.test.ts tests/memory.test.ts tests/filesystem.test.ts tests/driver.test.ts tests/ecosystems.test.ts tests/chunk.test.ts tests/object.test.ts tests/s3.test.ts tests/azure.test.ts tests/sqlite.test.ts tests/deno-kv-partition.test.ts tests/node.test.ts", + "test:bun": "bun test tests/path.test.ts tests/memory.test.ts tests/filesystem.test.ts tests/driver.test.ts tests/ecosystems.test.ts tests/chunk.test.ts tests/object.test.ts tests/s3.test.ts tests/azure.test.ts tests/sqlite.test.ts tests/deno-kv-partition.test.ts tests/bun.test.ts", "test:browser": "deno run -A npm:@playwright/test@1.62.1 test --config tests/browser/playwright.config.ts", "test:browser:install": "deno run -A npm:playwright@1.62.1 install --with-deps chromium firefox webkit", - "test:stress": "deno test --sanitize-ops --sanitize-resources --shuffle=424242 --repeats=5 tests/path.test.ts tests/memory.test.ts tests/filesystem.test.ts tests/ecosystems.test.ts tests/chunk.test.ts tests/object.test.ts tests/s3.test.ts tests/azure.test.ts tests/sqlite.test.ts tests/deno-kv-partition.test.ts", - "test:coverage": "deno test --clean --coverage=.coverage --sanitize-ops --sanitize-resources tests/path.test.ts tests/memory.test.ts tests/filesystem.test.ts tests/ecosystems.test.ts tests/chunk.test.ts tests/object.test.ts tests/s3.test.ts tests/azure.test.ts tests/sqlite.test.ts tests/deno-kv-partition.test.ts && deno coverage .coverage --lcov --output=.coverage/lcov.info", + "test:stress": "deno test --sanitize-ops --sanitize-resources --shuffle=424242 --repeats=5 tests/path.test.ts tests/memory.test.ts tests/filesystem.test.ts tests/driver.test.ts tests/ecosystems.test.ts tests/chunk.test.ts tests/object.test.ts tests/s3.test.ts tests/azure.test.ts tests/sqlite.test.ts tests/deno-kv-partition.test.ts", + "test:coverage": "deno test --clean --coverage=.coverage --sanitize-ops --sanitize-resources tests/path.test.ts tests/memory.test.ts tests/filesystem.test.ts tests/driver.test.ts tests/ecosystems.test.ts tests/chunk.test.ts tests/object.test.ts tests/s3.test.ts tests/azure.test.ts tests/sqlite.test.ts tests/deno-kv-partition.test.ts && deno coverage .coverage --lcov --output=.coverage/lcov.info", "bench": "deno task bench:memory && deno task bench:deno && deno task bench:deno-kv", "bench:memory": "deno run -A bench/memory.bench.ts", "bench:deno": "deno run -A bench/deno.bench.ts", @@ -144,9 +163,10 @@ "bench:deno-kv": "deno run --unstable-kv -A bench/deno-kv.bench.ts", "bench:sqlite": "node bench/sqlite.bench.ts", "test:providers": "node --test tests/provider.test.ts", - "check:providers": "deno check tests/provider/fixture.ts tests/provider.test.ts bench/provider.bench.ts bench/providers.ts", + "check:providers": "deno check tests/provider/fixture.ts tests/provider.test.ts bench/provider.bench.ts bench/bun-provider.bench.ts bench/providers.ts", "bench:providers": "node bench/providers.ts", - "bench:bun-providers": "bun run bench/bun-provider.bench.ts" + "bench:bun-providers": "bun run bench/bun-provider.bench.ts", + "bench:filesystem-clients": "node bench/filesystem-provider.bench.ts" }, "publish": { "include": [ @@ -160,6 +180,12 @@ }, "allowScripts": { "allow": [], - "deny": ["npm:cpu-features@0.0.10", "npm:fsevents@2.3.2", "npm:fsevents@2.3.3", "npm:protobufjs@7.6.5", "npm:ssh2@1.17.0"] + "deny": [ + "npm:cpu-features@0.0.10", + "npm:fsevents@2.3.2", + "npm:fsevents@2.3.3", + "npm:protobufjs@7.6.5", + "npm:ssh2@1.17.0" + ] } } diff --git a/deno.lock b/deno.lock index 1b564e6..64e69d1 100644 --- a/deno.lock +++ b/deno.lock @@ -1,6 +1,7 @@ { "version": "5", "specifiers": { + "jsr:@okikio/undent@~0.3.3": "0.3.3", "jsr:@std/assert@^1.0.19": "1.0.19", "jsr:@std/async@^1.5.0": "1.5.0", "jsr:@std/bytes@^1.0.6": "1.0.6", @@ -15,6 +16,7 @@ "npm:@aws-sdk/client-s3@^3.1098.0": "3.1111.0", "npm:@aws-sdk/lib-storage@^3.1100.0": "3.1111.0_@aws-sdk+client-s3@3.1111.0", "npm:@azure/storage-blob@^12.33.0": "12.33.0", + "npm:@deno/shim-deno@~0.19.2": "0.19.2", "npm:@jsr/std__async@^1.5.0": "1.5.0", "npm:@jsr/std__bytes@^1.0.6": "1.0.6", "npm:@jsr/std__encoding@^1.0.11": "1.0.11", @@ -22,17 +24,25 @@ "npm:@jsr/std__path@^1.1.6": "1.1.6", "npm:@jsr/std__streams@^1.1.2": "1.1.2", "npm:@jsr/std__xml@0.2": "0.2.0", + "npm:@okikio/undent@~0.3.3": "0.3.3", + "npm:@playwright/test@1.62.1": "1.62.1", "npm:@playwright/test@^1.62.1": "1.62.1", "npm:@testcontainers/azurite@^12.0.4": "12.1.0", + "npm:@types/deno@*": "2.7.0", + "npm:@types/deno@^2.7.0": "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", "npm:testcontainers@^12.0.4": "12.1.0", "npm:typescript@^7.0.2": "7.0.2", + "npm:vite@8.2.1": "8.2.1_@types+node@26.2.0", "npm:vite@^8.2.1": "8.2.1_@types+node@26.2.0", "npm:zod@^4.4.3": "4.4.3" }, "jsr": { + "@okikio/undent@0.3.3": { + "integrity": "890c688327fe4defc917ace44f8e9de85322881aed43d3c24176e530f86b18fa" + }, "@std/assert@1.0.19": { "integrity": "eaada96ee120cb980bc47e040f82814d786fe8162ecc53c91d8df60b8755991e", "dependencies": [ @@ -419,6 +429,16 @@ "@balena/dockerignore@1.0.2": { "integrity": "sha512-wMue2Sy4GAVTk6Ic4tJVcnfdau+gx2EnG7S+uAEe+TWJFqE4YoWN4/H8MSLj4eYJKxGg26lZwboEniNiNwZQ6Q==" }, + "@deno/shim-deno-test@0.5.0": { + "integrity": "sha512-4nMhecpGlPi0cSzT67L+Tm+GOJqvuk8gqHBziqcUQOarnuIax1z96/gJHCSIz2Z0zhxE6Rzwb3IZXPtFh51j+w==" + }, + "@deno/shim-deno@0.19.2": { + "integrity": "sha512-q3VTHl44ad8T2Tw2SpeAvghdGOjlnLPDNO2cpOxwMrBE/PVas6geWpbpIgrM+czOCH0yejp0yi8OaTuB+NU40Q==", + "dependencies": [ + "@deno/shim-deno-test", + "which@4.0.0" + ] + }, "@grpc/grpc-js@1.14.4": { "integrity": "sha512-k9Dj3DV/itK9D06Y8f190Qgop7/Ui+D0njFV3LHMPwPT75DpXLQohE9Wmz0QElrJnzsjB7KPWiKJbOl7IPDArQ==", "dependencies": [ @@ -529,6 +549,9 @@ "@nodable/entities@3.0.0": { "integrity": "sha512-8L9xFeTYKhm49xfIypoe2W5wV1m/3Z58kT+7kR9A8OyFxcPduI4VmxaUMQyKYrRjUoLLSXv6EKKID5Tvj9cUVw==" }, + "@okikio/undent@0.3.3": { + "integrity": "sha512-kj5dZUpIT/KbpTjAi2a+SwZ9DaRSBWJYrgzN65zs2sjXVjk7TLyFCPDYERUKj1Rzaup/fF9QhKe/QkajDskdkw==" + }, "@oxc-project/types@0.144.0": { "integrity": "sha512-nuhZIOLuI6TFQ32I/WnUx+SCPY7SdSKwgnFHydAuoS1+Z4BRcaP+RRJmGzl9lw+0OFF7UmaESf7KQRXaNLHypg==" }, @@ -645,47 +668,47 @@ "@rolldown/pluginutils@1.0.1": { "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==" }, - "@smithy/core@3.33.0": { - "integrity": "sha512-uKbkxgqLyepQDZoq8aRSdUqD1ID//rOqG96ixBhp++O7vBtmwYM6fwldGhr9HJP0iYrdc7GP/AlgzPWEZIrNRg==", + "@smithy/core@3.33.2": { + "integrity": "sha512-CUGXpnPkVdjUCbix+83sWLW9VFgQOm44MDOx/ihITJMAnOZKvL8YYIc7DR9pP/tZ8CIRvMiON/TucvygqbHO3w==", "dependencies": [ "@smithy/types", "tslib" ] }, - "@smithy/credential-provider-imds@4.5.0": { - "integrity": "sha512-2jsPi+7Zv2hSzD9IXR9D7DTqSn7mv4XalzRm+bESh53jiaUS3NKEUbpQFTJP0HhQy9qzZvluxQ3yS24zdRrqsA==", + "@smithy/credential-provider-imds@4.5.2": { + "integrity": "sha512-A9uSdn72ozbRUSit0eib0TW7nXuNPlaeM0zcGkJ+nE6tFcSDbnmtwoxbTCFBukVQcszDAyvsd7+rTduPTXpygg==", "dependencies": [ "@smithy/core", "@smithy/types", "tslib" ] }, - "@smithy/fetch-http-handler@5.7.0": { - "integrity": "sha512-W/exA8T0LEzCQtJ02w4IzaEQPIspgarqZprb7W8FwnYiDowgCrjl2fTQ6FvuSSUnJORuepBF81abmBJwqh+0XQ==", + "@smithy/fetch-http-handler@5.7.2": { + "integrity": "sha512-nZyWTmSpJEXl6VtWVMBJve/7x12DZu6sIX1z1a+ZMaHlQQRs9Zpu6NbTe/gmxYXVRpkjxyDYpZ5gx2IM6f/Wkw==", "dependencies": [ "@smithy/core", "@smithy/types", "tslib" ] }, - "@smithy/node-http-handler@4.11.0": { - "integrity": "sha512-ssHIZsadPUA3lGdnoByxfnjtb9xPYQLvdfJRLKIwxOoa6tO1suG4sLFSsgd7D/CsvYd8QbBIuKTImuJha5l6aQ==", + "@smithy/node-http-handler@4.11.2": { + "integrity": "sha512-avwAh9HM3h2lcfjvP3zYIZGf+XVgLQ91wOJ2qoFbNpW1UZeZb33aGlhTZvtkANHfcGhJroRY64525OjfgOg30g==", "dependencies": [ "@smithy/core", "@smithy/types", "tslib" ] }, - "@smithy/signature-v4@5.7.0": { - "integrity": "sha512-hCynhm22wMJ8wTF9crcwu8mxggtUrSLLJgDcGUvYFBqpofxycYJCGKOMYg4xtPPFtgNiDJSYmhsWLTrcU/g59Q==", + "@smithy/signature-v4@5.7.2": { + "integrity": "sha512-P7Ki6px6OOrxVtx8K7nLmyx4SlXUW/uTKDdMG44UHefmPGSRMBKe2v+TM59WdLcpUIrBrnuCsIqiM2MbsZjmhw==", "dependencies": [ "@smithy/core", "@smithy/types", "tslib" ] }, - "@smithy/types@4.17.0": { - "integrity": "sha512-Aw4joiM0ZdErpo39lCj8phT2lxoiKZV+KZzBxnnQhWVtU2Is/WffQSL04uUWRcXUse9Ln8vXZK6V/FwqRVnQpg==", + "@smithy/types@4.17.2": { + "integrity": "sha512-FOKpVZob9MPTn2znRzGrnsMHv7BOsKVw3XiP/cOyYLDVZ9qKp4nifIiSCuUU/fIj5Vu0UOAxCFr+qRAtG0NUkA==", "dependencies": [ "tslib" ] @@ -696,6 +719,9 @@ "testcontainers" ] }, + "@types/deno@2.7.0": { + "integrity": "sha512-Y6fWcV8KpYeO3Lik/RiYnUxtr/LWqoeACciU0CA+dr1U/45tGgaX3HWQQAPCNN53raN4Rr4Fht4y6qsUH57fDA==" + }, "@types/docker-modem@3.0.6": { "integrity": "sha512-yKpAGEuKRSS8wwx0joknWxsmLha78wNMe9R2S3UNsVOkZded8UqOrV8KoeDXoXsjndxwyF3eIhyClGbO1SEhEg==", "dependencies": [ @@ -1047,7 +1073,7 @@ "dependencies": [ "path-key", "shebang-command", - "which" + "which@2.0.2" ] }, "debug@4.4.3": { @@ -1226,6 +1252,9 @@ "isexe@2.0.0": { "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==" }, + "isexe@3.1.5": { + "integrity": "sha512-6B3tLtFqtQS4ekarvLVMZ+X+VlvQekbe4taUkf/rhVO3d/h0M2rfARm/pXLcPEsjjMsFgrFgSrhQIxcSVrBz8w==" + }, "jackspeak@3.4.3": { "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", "dependencies": [ @@ -1779,7 +1808,14 @@ "which@2.0.2": { "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", "dependencies": [ - "isexe" + "isexe@2.0.0" + ], + "bin": true + }, + "which@4.0.0": { + "integrity": "sha512-GlaYyEb07DPxYCKhKzplCWBJtvxZcZMrL+4UkrTSJHHPyZU4mYYTv3qaOe77H7EODLSSopAUFAc6W8U4yqvscg==", + "dependencies": [ + "isexe@3.1.5" ], "bin": true }, @@ -1841,6 +1877,7 @@ }, "workspace": { "dependencies": [ + "jsr:@okikio/undent@~0.3.3", "jsr:@std/async@^1.5.0", "jsr:@std/bytes@^1.0.6", "jsr:@std/encoding@^1.0.11", @@ -1853,6 +1890,7 @@ "npm:@azure/storage-blob@^12.33.0", "npm:@playwright/test@^1.62.1", "npm:@testcontainers/azurite@^12.0.4", + "npm:@types/deno@*", "npm:drizzle-orm@~0.45.2", "npm:mitata@^1.0.34", "npm:testcontainers@^12.0.4", @@ -1864,6 +1902,7 @@ "npm:@aws-sdk/client-s3@^3.1098.0", "npm:@aws-sdk/lib-storage@^3.1100.0", "npm:@azure/storage-blob@^12.33.0", + "npm:@deno/shim-deno@~0.19.2", "npm:@jsr/std__async@^1.5.0", "npm:@jsr/std__bytes@^1.0.6", "npm:@jsr/std__encoding@^1.0.11", @@ -1871,8 +1910,10 @@ "npm:@jsr/std__path@^1.1.6", "npm:@jsr/std__streams@^1.1.2", "npm:@jsr/std__xml@0.2", + "npm:@okikio/undent@~0.3.3", "npm:@playwright/test@^1.62.1", "npm:@testcontainers/azurite@^12.0.4", + "npm:@types/deno@^2.7.0", "npm:@types/node@^26.2.0", "npm:drizzle-orm@~0.45.2", "npm:mitata@^1.0.34", diff --git a/mise.toml b/mise.toml index f2ab5ce..77a44ac 100644 --- a/mise.toml +++ b/mise.toml @@ -4,8 +4,9 @@ monorepo_root = true [tools] node = "26" deno = "2" -bun = "1.3.14" -pnpm = "11.9.0" +bun = "1" +pnpm = "11" +"npm:npm" = "11" [settings] experimental = true diff --git a/mod.ts b/mod.ts index 474e8a1..b593562 100644 --- a/mod.ts +++ b/mod.ts @@ -1,16 +1,25 @@ /** - * Adapter-independent filesystem APIs with an OPFS-compatible frontend. + * OPFS-shaped filesystem APIs over explicit storage drivers and adapters. * - * The package has two independent layers: + * The public storage path is: * - * 1. `createFileSystem(adapter)` provides high-level path APIs and OPFS-shaped - * file/directory handle facades over any backend adapter. - * 2. `openFileSystem()` selects the browser's native Origin Private File System - * as that backend. + * ```text + * protocol/native API -> client -> driver -> adapter -> FileSystemType + * | + * `-> bridge -> ecosystem + * ``` + * + * A client owns a wire protocol when one exists. A driver owns independently + * useful backend behavior, requirements, limits, optimizations, planning, and + * physical metrics. An adapter translates that driver into the small portable + * filesystem primitive set. `FileSystemType` owns path semantics, coordination, + * recursive operations, fallbacks, and OPFS-shaped handles. Reverse ecosystem + * contracts live under explicit `bridge/*` subpaths. * - * Runtime-specific and ecosystem adapters live on explicit subpaths so browser - * bundles do not import Node, Bun, Deno, RxDB, unstorage, db0, or Drizzle by - * accident. + * `openFileSystem()` is the browser convenience path. It acquires the native + * OPFS root, creates the OPFS file driver and adapter, then returns the same + * `FileSystemType` used by every other backend. Importing this root module does + * not open storage or import server/provider integrations. * * @example Use native browser OPFS. * ```ts @@ -20,16 +29,15 @@ * await fileSystem.writeFile("/cache/result.json", "{}", { parents: true }); * ``` * - * @example Use the same frontend over an injected adapter. + * @example Compose an injected backend. * ```ts * import { createFileSystem } from "@okikio/opfs"; - * import { createMemoryAdapter } from "@okikio/opfs/adapter/memory"; + * import { createMemoryDriver } from "@okikio/opfs/driver/memory"; + * import { createRecordAdapter } from "@okikio/opfs/adapter/record"; * - * const fileSystem = createFileSystem(createMemoryAdapter()); - * const file = await fileSystem.root.getFileHandle("hello.txt", { create: true }); - * const writable = await file.createWritable(); - * await writable.write("hello"); - * await writable.close(); + * const driver = createMemoryDriver(); + * const fileSystem = createFileSystem(createRecordAdapter(driver)); + * await fileSystem.writeFile("/hello.txt", "hello", { parents: true }); * ``` * * @module @@ -81,18 +89,27 @@ export type { AdapterLimitsType, AdapterPartitionType, CoordinationModeType, + DriverKindType, + DriverOptimizationType, + DriverOwnershipType, EntryKindType, ErrorCodeType, + LimitKindType, + LimitSourceType, + LimitType, + LimitUnitType, MetricsModeType, OpfsContextType, OptimizationType, PartitionModeType, + RequirementStateType, + RequirementType, SupportModeType, WriteModeType, } from "./src/schema.ts"; export type { FileSystemOptionsType } from "./src/adapter/definition.ts"; export type { InspectionType, SupportType, WriteSupportType } from "./src/capability.ts"; -export type { MetricsType, MetricEntryType, MetricOperationType } from "./src/metrics.ts"; +export type { DriverMetricsType, MetricEntryType, MetricOperationType, MetricsType } from "./src/metrics.ts"; export { PlanInputSchema, PlanOperationSchema, PlanSchema, WriteSourceSchema } from "./src/plan.ts"; export type { PlanInputType, PlanOperationType, PlanType, WriteSourceType } from "./src/plan.ts"; export type { OpfsCapabilitiesType, OpfsProbeErrorType, OpfsStorageEstimateType } from "./src/probe.ts"; diff --git a/package.json b/package.json index 00fe944..cd53bba 100644 --- a/package.json +++ b/package.json @@ -42,6 +42,7 @@ "exports": { ".": "./mod.ts", "./adapter": "./src/adapter/definition.ts", + "./adapter/file": "./src/adapter/file.ts", "./adapter/opfs": "./src/adapter/opfs.ts", "./adapter/memory": "./src/adapter/memory.ts", "./adapter/deno": "./src/adapter/deno.ts", @@ -60,33 +61,50 @@ "./adapter/rxdb": "./src/adapter/rxdb.ts", "./adapter/db0": "./src/adapter/db0.ts", "./adapter/drizzle": "./src/adapter/drizzle.ts", - "./driver/kv": "./src/driver/kv.ts", + "./driver": "./src/driver/definition.ts", + "./driver/file": "./src/driver/file.ts", + "./driver/record": "./src/driver/record.ts", + "./driver/object": "./src/driver/object.ts", + "./driver/opfs": "./src/driver/opfs.ts", + "./driver/memory": "./src/driver/memory.ts", + "./driver/deno": "./src/driver/deno.ts", + "./driver/deno-kv": "./src/driver/deno-kv.ts", + "./driver/node": "./src/driver/node.ts", + "./driver/bun": "./src/driver/bun.ts", + "./driver/localstorage": "./src/driver/localstorage.ts", + "./driver/indexeddb": "./src/driver/indexeddb.ts", + "./driver/cache": "./src/driver/cache.ts", + "./driver/sqlite": "./src/driver/sqlite.ts", "./driver/unstorage": "./src/driver/unstorage.ts", + "./driver/rxdb": "./src/driver/rxdb.ts", + "./driver/db0": "./src/driver/db0.ts", + "./driver/drizzle": "./src/driver/drizzle.ts", + "./driver/s3": "./src/driver/s3.ts", + "./driver/azure": "./src/driver/azure.ts", "./s3": "./src/s3.ts", "./azure": "./src/azure.ts", "./iframe": "./src/iframe.ts", "./path": "./src/path.ts", "./schema": "./src/schema.ts", "./bridge": "./src/bridge.ts", - "./bridge/definition": "./src/bridge/definition.ts", - "./bridge/unstorage": "./src/bridge/unstorage.ts", - "./bridge/rxdb": "./src/bridge/rxdb.ts", - "./bridge/db0": "./src/bridge/db0.ts", - "./bridge/drizzle": "./src/bridge/drizzle.ts", "./bridge/kv": "./src/bridge/kv.ts", + "./bridge/unstorage": "./src/bridge/unstorage.ts", + "./integration": "./src/integration.ts", + "./integration/definition": "./src/integration/definition.ts", "./capability": "./src/capability.ts", "./metrics": "./src/metrics.ts", "./plan": "./src/plan.ts", "./request": "./src/request.ts" }, "dependencies": { - "@std/encoding": "npm:@jsr/std__encoding@^1.0.11", - "@std/xml": "npm:@jsr/std__xml@^0.2.0", - "zod": "^4.4.3", + "@okikio/undent": "^0.3.3", "@std/async": "npm:@jsr/std__async@^1.5.0", "@std/bytes": "npm:@jsr/std__bytes@^1.0.6", + "@std/encoding": "npm:@jsr/std__encoding@^1.0.11", "@std/path": "npm:@jsr/std__path@^1.1.6", - "@std/streams": "npm:@jsr/std__streams@^1.1.2" + "@std/streams": "npm:@jsr/std__streams@^1.1.2", + "@std/xml": "npm:@jsr/std__xml@^0.2.0", + "zod": "^4.4.3" }, "peerDependencies": { "drizzle-orm": "^0.45.2" @@ -100,9 +118,11 @@ "@aws-sdk/client-s3": "^3.1098.0", "@aws-sdk/lib-storage": "^3.1100.0", "@azure/storage-blob": "^12.33.0", + "@deno/shim-deno": "^0.19.2", "@playwright/test": "^1.62.1", "@std/expect": "npm:@jsr/std__expect@^1.0.20", "@testcontainers/azurite": "^12.0.4", + "@types/deno": "^2.7.0", "@types/node": "^26.2.0", "drizzle-orm": "^0.45.2", "mitata": "^1.0.34", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ba90a47..70ddf37 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,6 +8,9 @@ importers: .: dependencies: + '@okikio/undent': + specifier: ^0.3.3 + version: 0.3.3 '@std/async': specifier: npm:@jsr/std__async@^1.5.0 version: '@jsr/std__async@1.5.0' @@ -39,6 +42,9 @@ importers: '@azure/storage-blob': specifier: ^12.33.0 version: 12.33.0 + '@deno/shim-deno': + specifier: ^0.19.2 + version: 0.19.2 '@playwright/test': specifier: ^1.62.1 version: 1.62.1 @@ -48,6 +54,9 @@ importers: '@testcontainers/azurite': specifier: ^12.0.4 version: 12.1.0 + '@types/deno': + specifier: ^2.7.0 + version: 2.7.0 '@types/node': specifier: ^26.2.0 version: 26.2.0 @@ -205,6 +214,12 @@ packages: '@balena/dockerignore@1.0.2': resolution: {integrity: sha512-wMue2Sy4GAVTk6Ic4tJVcnfdau+gx2EnG7S+uAEe+TWJFqE4YoWN4/H8MSLj4eYJKxGg26lZwboEniNiNwZQ6Q==} + '@deno/shim-deno-test@0.5.0': + resolution: {integrity: sha512-4nMhecpGlPi0cSzT67L+Tm+GOJqvuk8gqHBziqcUQOarnuIax1z96/gJHCSIz2Z0zhxE6Rzwb3IZXPtFh51j+w==} + + '@deno/shim-deno@0.19.2': + resolution: {integrity: sha512-q3VTHl44ad8T2Tw2SpeAvghdGOjlnLPDNO2cpOxwMrBE/PVas6geWpbpIgrM+czOCH0yejp0yi8OaTuB+NU40Q==} + '@grpc/grpc-js@1.14.4': resolution: {integrity: sha512-k9Dj3DV/itK9D06Y8f190Qgop7/Ui+D0njFV3LHMPwPT75DpXLQohE9Wmz0QElrJnzsjB7KPWiKJbOl7IPDArQ==} engines: {node: '>=12.10.0'} @@ -262,6 +277,10 @@ packages: '@nodable/entities@3.0.0': resolution: {integrity: sha512-8L9xFeTYKhm49xfIypoe2W5wV1m/3Z58kT+7kR9A8OyFxcPduI4VmxaUMQyKYrRjUoLLSXv6EKKID5Tvj9cUVw==} + '@okikio/undent@0.3.3': + resolution: {integrity: sha512-kj5dZUpIT/KbpTjAi2a+SwZ9DaRSBWJYrgzN65zs2sjXVjk7TLyFCPDYERUKj1Rzaup/fF9QhKe/QkajDskdkw==} + engines: {node: '>=20'} + '@oxc-project/types@0.144.0': resolution: {integrity: sha512-nuhZIOLuI6TFQ32I/WnUx+SCPY7SdSKwgnFHydAuoS1+Z4BRcaP+RRJmGzl9lw+0OFF7UmaESf7KQRXaNLHypg==} @@ -421,6 +440,9 @@ packages: '@testcontainers/azurite@12.1.0': resolution: {integrity: sha512-4l7vMOY92UPTe8sXyS0ZHbKjrpt9cXbg7HIHKrb3hbE5diU6T7Suu4hsM8ExNNnKTk2WEJX3Rl36BY7wRfrA1Q==} + '@types/deno@2.7.0': + resolution: {integrity: sha512-Y6fWcV8KpYeO3Lik/RiYnUxtr/LWqoeACciU0CA+dr1U/45tGgaX3HWQQAPCNN53raN4Rr4Fht4y6qsUH57fDA==} + '@types/docker-modem@3.0.6': resolution: {integrity: sha512-yKpAGEuKRSS8wwx0joknWxsmLha78wNMe9R2S3UNsVOkZded8UqOrV8KoeDXoXsjndxwyF3eIhyClGbO1SEhEg==} @@ -959,6 +981,10 @@ packages: isexe@2.0.0: resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + isexe@3.1.5: + resolution: {integrity: sha512-6B3tLtFqtQS4ekarvLVMZ+X+VlvQekbe4taUkf/rhVO3d/h0M2rfARm/pXLcPEsjjMsFgrFgSrhQIxcSVrBz8w==} + engines: {node: '>=18'} + jackspeak@3.4.3: resolution: {integrity: sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==} @@ -1349,6 +1375,11 @@ packages: engines: {node: '>= 8'} hasBin: true + which@4.0.0: + resolution: {integrity: sha512-GlaYyEb07DPxYCKhKzplCWBJtvxZcZMrL+4UkrTSJHHPyZU4mYYTv3qaOe77H7EODLSSopAUFAc6W8U4yqvscg==} + engines: {node: ^16.13.0 || >=18.0.0} + hasBin: true + wrap-ansi@7.0.0: resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} engines: {node: '>=10'} @@ -1680,6 +1711,13 @@ snapshots: '@balena/dockerignore@1.0.2': {} + '@deno/shim-deno-test@0.5.0': {} + + '@deno/shim-deno@0.19.2': + dependencies: + '@deno/shim-deno-test': 0.5.0 + which: 4.0.0 + '@grpc/grpc-js@1.14.4': dependencies: '@grpc/proto-loader': 0.8.1 @@ -1752,6 +1790,8 @@ snapshots: '@nodable/entities@3.0.0': {} + '@okikio/undent@0.3.3': {} + '@oxc-project/types@0.144.0': {} '@pkgjs/parseargs@0.11.0': @@ -1867,6 +1907,8 @@ snapshots: - react-native-b4a - supports-color + '@types/deno@2.7.0': {} + '@types/docker-modem@3.0.6': dependencies: '@types/node': 26.2.0 @@ -2263,6 +2305,8 @@ snapshots: isexe@2.0.0: {} + isexe@3.1.5: {} + jackspeak@3.4.3: dependencies: '@isaacs/cliui': 8.0.2 @@ -2687,6 +2731,10 @@ snapshots: dependencies: isexe: 2.0.0 + which@4.0.0: + dependencies: + isexe: 3.1.5 + wrap-ansi@7.0.0: dependencies: ansi-styles: 4.3.0 diff --git a/src/adapter/db0.ts b/src/adapter/db0.ts index 2d521e9..5292785 100644 --- a/src/adapter/db0.ts +++ b/src/adapter/db0.ts @@ -1,349 +1,31 @@ import type { AdapterType } from "./definition.ts"; -import { createRecordAdapter, type RecordStoreType } from "./record.ts"; +import { createRecordAdapter } from "./record.ts"; import { - Db0DialectSchema, - RecordSchema, - SqlIdentifierSchema, - type Db0DialectType, - type RecordType, -} from "../schema.ts"; + createDb0Driver, + type Db0DatabaseType, + type Db0DriverOptionsType, + type Db0PrimitiveType, + type Db0StatementType, +} from "../driver/db0.ts"; -/** Primitive parameter values accepted by db0 prepared statements. */ -export type Db0PrimitiveType = string | number | boolean | undefined | null; +/** Options forwarded to the db0 record driver. */ +export type Db0AdapterOptionsType = Db0DriverOptionsType; -/** Prepared statement subset required from db0. */ -export interface Db0StatementType { - /** Executes a query and returns every row. */ - all(...params: Db0PrimitiveType[]): Promise; - /** Executes a query and returns the first row. */ - get(...params: Db0PrimitiveType[]): Promise; - /** Executes a mutation. */ - run(...params: Db0PrimitiveType[]): Promise<{ readonly success: boolean }>; -} - -/** - * Structural subset of db0's public `Database` API. - * - * The current db0 contract exposes the SQL dialect independently of the - * connector. That lets this bridge support Bun SQLite, Node SQLite, D1, - * LibSQL, PGlite, PostgreSQL/Hyperdrive, MySQL/Hyperdrive, PlanetScale, and the - * other current connectors through four dialect implementations. - */ -export interface Db0DatabaseType { - /** SQL dialect selected by the active connector. */ - readonly dialect: Db0DialectType; - /** Compiles one SQL string into a reusable statement. */ - prepare(sql: string): Db0StatementType; - /** Releases the database when ownership is explicitly transferred. */ - dispose?(): Promise; -} - -/** Options for the db0 record store and filesystem adapter. */ -export interface Db0AdapterOptionsType { - /** Adapter-owned table. Defaults to `opfs_entries`. */ - readonly table?: string; - /** Creates the table before returning the adapter. Defaults to true. */ - readonly initialize?: boolean; - /** Disposes the injected db0 Database when the adapter closes. */ - readonly disposeDatabase?: boolean; -} - -/** Columns read from every db0 filesystem row. */ -const DB0_ROW_COLUMNS_SQL = "path, parent_path, name, kind, data, size, last_modified, media_type"; - -/** Columns written by the db0 record-store bridge. */ -const DB0_WRITE_COLUMNS_SQL = `id, ${DB0_ROW_COLUMNS_SQL}`; - -/** Database row shape after db0 driver decoding and before record validation. */ -interface Db0RowType { - /** Canonical virtual path returned by the connector. */ - readonly path: string; - /** Canonical direct-parent path stored in the SQL row. */ - readonly parent_path: string; - /** Final file or directory name. */ - readonly name: string; - /** Persisted entry discriminator before schema validation. */ - readonly kind: string; - /** Base64 file payload, or null for directories. */ - readonly data: string | null; - /** File byte length in the connector's integer representation. */ - readonly size: number | string | bigint; - /** Unix epoch milliseconds in the connector's integer representation. */ - readonly last_modified: number | string | bigint; - /** File media type, or null for directories. */ - readonly media_type: string | null; -} - -/** Validates and quotes the adapter-owned table name for the selected db0 dialect. */ -function quoteIdentifier(identifier: string, dialect: Db0DialectType): string { - SqlIdentifierSchema.parse(identifier); - return dialect === "mysql" ? `\`${identifier}\`` : `"${identifier}"`; -} - -/** - * Creates db0's portable prepared-statement placeholders. - * - * db0 connectors own translation to driver-native parameter syntax. Current - * PostgreSQL and PGlite connectors, for example, translate `?` to `$1`, `$2`, - * and so on before calling the underlying driver. Keeping `?` here lets this - * bridge stay at the db0 Database contract instead of coupling to connectors. - */ -function placeholders(count: number): string[] { - return Array.from({ length: count }, () => "?"); -} - -/** Normalizes BIGINT driver results and rejects values outside JavaScript safe-integer range. */ -function toNumber(value: number | string | bigint, field: string): number { - const normalized = typeof value === "bigint" - ? Number(value) - : typeof value === "string" - ? Number.parseInt(value, 10) - : value; - if (!Number.isSafeInteger(normalized) || normalized < 0) { - throw new TypeError(`db0 returned invalid ${field} '${String(value)}'.`); - } - return normalized; -} - -/** Converts a connector-dependent db0 row into the validated record-store format. */ -function parseRow(value: unknown): RecordType { - if (typeof value !== "object" || value === null) throw new TypeError("db0 returned a non-object filesystem row."); - const row = value as Db0RowType; - if (row.kind === "directory") { - return RecordSchema.parse({ - version: 1, - path: row.path, - parent: row.parent_path, - name: row.name, - kind: "directory", - lastModified: toNumber(row.last_modified, "last_modified"), - }); - } - return RecordSchema.parse({ - version: 1, - path: row.path, - parent: row.parent_path, - name: row.name, - kind: "file", - data: row.data ?? "", - size: toNumber(row.size, "size"), - lastModified: toNumber(row.last_modified, "last_modified"), - mediaType: row.media_type ?? "", - }); -} - -/** - * Hashes the full path into a fixed-width primary key. - * - * MySQL cannot portably use an arbitrary-length TEXT path as a primary key. A - * SHA-256 id keeps the indexed key fixed while the original path remains stored - * and queryable in its own column. - */ -async function getPathId(path: string): Promise { - const bytes = new TextEncoder().encode(path); - const digest = new Uint8Array(await crypto.subtle.digest("SHA-256", bytes)); - return Array.from(digest, (value) => value.toString(16).padStart(2, "0")).join(""); -} - -/** Builds only the small DDL subset verified across db0's four public dialects. */ -function getCreateTableSql(table: string, dialect: Db0DialectType): string { - const q = quoteIdentifier(table, dialect); - const integer = dialect === "postgresql" || dialect === "mysql" ? "BIGINT" : "INTEGER"; - const columns = dialect === "mysql" - ? [ - "id VARCHAR(64) NOT NULL PRIMARY KEY", - "path TEXT NOT NULL", - "parent_path TEXT NOT NULL", - "name TEXT NOT NULL", - "kind VARCHAR(16) NOT NULL", - "data LONGTEXT NULL", - `size ${integer} NOT NULL`, - `last_modified ${integer} NOT NULL`, - "media_type TEXT NULL", - ] - : [ - "id TEXT NOT NULL PRIMARY KEY", - "path TEXT NOT NULL", - "parent_path TEXT NOT NULL", - "name TEXT NOT NULL", - "kind TEXT NOT NULL", - "data TEXT NULL", - `size ${integer} NOT NULL`, - `last_modified ${integer} NOT NULL`, - "media_type TEXT NULL", - ]; - return `CREATE TABLE IF NOT EXISTS ${q} (${columns.join(", ")})`; -} - -/** Builds one atomic row replacement using each dialect's native upsert form. */ -function getUpsertSql(table: string, dialect: Db0DialectType): string { - const q = quoteIdentifier(table, dialect); - const values = placeholders(9).join(", "); - const assignments = dialect === "mysql" - ? [ - "path=VALUES(path)", - "parent_path=VALUES(parent_path)", - "name=VALUES(name)", - "kind=VALUES(kind)", - "data=VALUES(data)", - "size=VALUES(size)", - "last_modified=VALUES(last_modified)", - "media_type=VALUES(media_type)", - ] - : [ - "path=excluded.path", - "parent_path=excluded.parent_path", - "name=excluded.name", - "kind=excluded.kind", - "data=excluded.data", - "size=excluded.size", - "last_modified=excluded.last_modified", - "media_type=excluded.media_type", - ]; - const conflict = dialect === "mysql" - ? `ON DUPLICATE KEY UPDATE ${assignments.join(", ")}` - : `ON CONFLICT (id) DO UPDATE SET ${assignments.join(", ")}`; - return `INSERT INTO ${q} (${DB0_WRITE_COLUMNS_SQL}) VALUES (${values}) ${conflict}`; -} - -/** - * Prepared db0 record store after schema/table setup has completed. - * - * Statement preparation occurs once in {@link openDb0RecordStore}. Each method - * then binds only operation parameters, which keeps SQL generation and runtime - * record behavior separate and makes connector-specific parameter translation - * remain db0's responsibility. - */ -class Db0RecordStore implements RecordStoreType { - /** Connected database borrowed or transferred by the caller. */ - readonly #database: Db0DatabaseType; - /** Prepared exact-path selection. */ - readonly #selectById: Db0StatementType; - /** Prepared direct-parent selection. */ - readonly #selectChildren: Db0StatementType; - /** Prepared dialect-specific atomic upsert. */ - readonly #upsert: Db0StatementType; - /** Prepared exact-path deletion. */ - readonly #remove: Db0StatementType; - /** Whether store disposal also disposes the injected db0 database. */ - readonly #disposeDatabase: boolean; - - /** Retains prepared statements and explicit resource ownership. */ - constructor( - database: Db0DatabaseType, - selectById: Db0StatementType, - selectChildren: Db0StatementType, - upsert: Db0StatementType, - remove: Db0StatementType, - disposeDatabase: boolean, - ) { - this.#database = database; - this.#selectById = selectById; - this.#selectChildren = selectChildren; - this.#upsert = upsert; - this.#remove = remove; - this.#disposeDatabase = disposeDatabase; - } - - /** Selects one fixed-width path identity and converts the connector row. */ - async get(path: Parameters[0]) { - const row = await this.#selectById.get(await getPathId(path)); - return row == null ? null : parseRow(row); - } - - /** Upserts one validated filesystem record through the dialect-specific statement. */ - async set(record: RecordType): Promise { - const params: Db0PrimitiveType[] = [ - await getPathId(record.path), - record.path, - record.parent, - record.name, - record.kind, - record.kind === "file" ? record.data : null, - record.kind === "file" ? record.size : 0, - record.lastModified, - record.kind === "file" ? record.mediaType : null, - ]; - const result = await this.#upsert.run(...params); - if (!result.success) throw new Error(`db0 did not confirm the write for '${record.path}'.`); - } - - /** Deletes one path identity and requires connector mutation confirmation. */ - async delete(path: Parameters[0]): Promise { - const result = await this.#remove.run(await getPathId(path)); - if (!result.success) throw new Error(`db0 did not confirm removal for '${path}'.`); - } - - /** Selects and validates direct children through `parent_path`. */ - async *list(parent: Parameters[0]) { - const rows = await this.#selectChildren.all(parent); - for (const row of rows) yield parseRow(row); - } - - /** Disposes the db0 database only when ownership was explicitly transferred. */ - async dispose(): Promise { - if (this.#disposeDatabase) await this.#database.dispose?.(); - } -} - -/** - * Opens the db0-backed record store and optionally creates its portable schema. - * - * The table uses a SHA-256 path id as its primary key. This avoids MySQL's - * indexed-TEXT restrictions while preserving arbitrary path lengths in the - * separate `path` column. Directory queries use `parent_path` and can be - * indexed by an application migration when that query becomes hot. - * - * The Database is borrowed unless `disposeDatabase` is true. Callers that - * manage migrations centrally can set `initialize: false` after creating the - * required table themselves. - */ -export async function openDb0RecordStore( - database: Db0DatabaseType, - options: Db0AdapterOptionsType = {}, -): Promise { - const dialect = Db0DialectSchema.parse(database.dialect); - const table = SqlIdentifierSchema.parse(options.table ?? "opfs_entries"); - const quotedTable = quoteIdentifier(table, dialect); - if (options.initialize ?? true) { - const result = await database.prepare(getCreateTableSql(table, dialect)).run(); - if (!result.success) throw new Error(`db0 did not confirm initialization of table '${table}'.`); - } - - const idPlaceholder = placeholders(1)[0]; - const parentPlaceholder = placeholders(1)[0]; - const selectById = database.prepare( - `SELECT ${DB0_ROW_COLUMNS_SQL} FROM ${quotedTable} WHERE id = ${idPlaceholder}`, - ); - const selectChildren = database.prepare( - `SELECT ${DB0_ROW_COLUMNS_SQL} FROM ${quotedTable} WHERE parent_path = ${parentPlaceholder}`, - ); - const upsert = database.prepare(getUpsertSql(table, dialect)); - const remove = database.prepare(`DELETE FROM ${quotedTable} WHERE id = ${idPlaceholder}`); - - return new Db0RecordStore( - database, - selectById, - selectChildren, - upsert, - remove, - options.disposeDatabase ?? false, - ); -} +/** db0 database, parameter, and statement contracts consumed by the driver. */ +export type { Db0DatabaseType, Db0PrimitiveType, Db0StatementType }; /** - * Creates an OPFS-shaped adapter over a db0 Database. + * Creates the OPFS primitive translation over a db0 database driver. * - * The bridge supports db0's `sqlite`, `libsql`, `postgresql`, and `mysql` - * dialect values. The connector remains owned by db0. The database is borrowed - * unless `disposeDatabase` is true. + * The adapter owns the configured driver wrapper. The injected db0 Database is + * closed only when `disposeDatabase` is enabled on the driver options. */ export async function createDb0Adapter( database: Db0DatabaseType, options: Db0AdapterOptionsType = {}, ): Promise { - return createRecordAdapter(await openDb0RecordStore(database, options), { + return createRecordAdapter(await createDb0Driver(database, options), { name: "db0", - disposeStore: true, + disposeDriver: true, }); } diff --git a/src/adapter/definition.ts b/src/adapter/definition.ts index 8626d6e..298a52d 100644 --- a/src/adapter/definition.ts +++ b/src/adapter/definition.ts @@ -1,225 +1,134 @@ -import { AdapterCapabilitiesSchema, AdapterLimitsSchema, AdapterNameSchema, AdapterPartitionSchema } from "../schema.ts"; +import { + AdapterCapabilitiesSchema, + AdapterLimitsSchema, + AdapterNameSchema, + AdapterPartitionSchema, +} from "../schema.ts"; import type { AdapterCapabilitiesType, AdapterLimitsType, AdapterPartitionType, CoordinationModeType, - EntryKindType, MetricsModeType, OptimizationType, - WriteModeType, } from "../schema.ts"; import type { PathType } from "../path.ts"; - -/** Options shared by adapter operations that can stop early. */ -export interface AdapterSignalOptionsType { - /** Stops work that has not committed yet. */ - readonly signal?: AbortSignal; -} - -/** Byte-range options for an adapter read. */ -export interface AdapterReadOptionsType extends AdapterSignalOptionsType { - /** Zero-based byte offset. */ - readonly at?: number; - /** Maximum bytes to return after `at`. */ - readonly length?: number; -} - -/** Write semantics that an adapter must preserve. */ -export interface AdapterWriteOptionsType extends AdapterSignalOptionsType { - /** Relationship between new bytes and an existing file. */ - readonly mode: WriteModeType; - /** Zero-based offset used by `update`. */ - readonly at?: number; - /** Truncates the file at the final write cursor. */ - readonly truncate?: boolean; - /** Media type to retain when the adapter stores metadata. */ - readonly mediaType?: string; -} - -/** Options for an adapter-native copy. */ -export interface AdapterCopyOptionsType extends AdapterSignalOptionsType { - /** Replaces an existing destination when the backend operation supports it. */ - readonly overwrite: boolean; -} - -/** Options for an adapter-native move. */ -export interface AdapterMoveOptionsType extends AdapterSignalOptionsType { - /** Removes an existing destination before the move when required. */ - readonly overwrite: boolean; -} - -/** One direct child returned by an adapter directory iterator. */ -export interface AdapterDirectoryEntryType { - /** Child entry name without parent path components. */ - readonly name: string; - /** Child entry kind. */ - readonly kind: EntryKindType; -} - -/** Portable file metadata required by the facade. */ -export interface AdapterFileStatType { - /** Discriminator for file metadata. */ - readonly kind: "file"; - /** File byte length. */ - readonly size: number; - /** Last-modified time as Unix epoch milliseconds. */ - readonly lastModified: number; - /** Media type when known. Empty string means unknown. */ - readonly mediaType: string; -} - -/** Portable directory metadata required by the facade. */ -export interface AdapterDirectoryStatType { - /** Discriminator for directory metadata. */ - readonly kind: "directory"; - /** Last-modified time when the adapter can observe one. */ - readonly lastModified?: number; -} - -/** Portable entry metadata returned by an adapter. */ -export type AdapterStatType = AdapterFileStatType | AdapterDirectoryStatType; - -/** - * Long-lived asynchronous positional file owned by an adapter. - * - * This contract exists for callers such as media muxers and database engines - * that rewrite earlier byte ranges while a file stays open. It is deliberately - * separate from `writeFile()`, which represents one complete write operation. - * - * `abort()` may discard staged changes when the backend can do so. Backends - * without transactional staging still close the native resource, so callers - * that need rollback should write to a staging path and remove it after abort. - */ -export interface AdapterWritableFileType { - /** Writes bytes at one explicit zero-based file position. */ - write(buffer: ArrayBufferView, options: { readonly at: number }): Promise; - /** Changes current byte length. */ - truncate(size: number): Promise; - /** Requests backend durability without closing the file. */ - flush(): Promise; - /** Commits staged backend state where the backend uses staging, then closes. */ - close(): Promise; - /** Discards staged state when possible, then releases the native resource. */ - abort(reason?: unknown): Promise; -} - -/** - * Synchronous random-access file owned by an adapter. - * - * The adapter owns the native runtime object. The caller owns the returned - * resource and must call `close()`. `flush()` asks the backend to make current - * writes durable; the exact storage guarantee remains backend-specific. - */ -export interface AdapterSyncFileType { - /** Reads bytes into `buffer` and returns the number of bytes read. */ - read(buffer: ArrayBufferView, options?: { readonly at?: number }): number; - /** Writes bytes from `buffer` and returns the number of bytes written. */ - write(buffer: ArrayBufferView, options?: { readonly at?: number }): number; - /** Returns current byte length. */ - getSize(): number; - /** Changes current byte length. */ - truncate(size: number): void; - /** Requests backend durability for current writes. */ - flush(): void; - /** Releases the native file resource and any native file lock. */ - close(): void; -} +import type { DriverType } from "../driver/definition.ts"; +import type { + FileDriverCopyOptionsType, + FileDriverDirectoryEntryType, + FileDriverMoveOptionsType, + FileDriverReadOptionsType, + FileDriverSignalOptionsType, + FileDriverStatType, + FileDriverSyncFileType, + FileDriverWritableFileType, + FileDriverWriteOptionsType, +} from "../driver/file.ts"; /** - * Backend contract consumed by {@link createFileSystem}. + * Filesystem primitive translation consumed by {@link createFileSystem}. * - * Adapters receive canonical virtual paths. They own translation to browser - * handles, host paths, key-value keys, documents, or SQL rows. Required methods - * cover the smallest filesystem primitive set. Optional methods advertise a - * faster native path through `capabilities`. + * A configured adapter always points at the backend driver whose mechanics it + * translates. Driver metadata therefore remains independently inspectable even + * when the facade chooses an adapter fallback or optimization route. * - * An adapter must not configure application logging or read process environment - * variables at import time. An adapter can own an injected resource only when - * its creation options say so explicitly. + * Think of the adapter as the narrow waist of the architecture. Below it, the + * driver keeps native storage semantics honest. Above it, `FileSystemType` + * decides which OPFS-shaped routes are native, emulated, partitioned, or + * intentionally unsupported. */ export interface AdapterType { - /** Stable diagnostic name such as `opfs`, `deno`, or `unstorage`. */ + /** Stable translation name such as `opfs`, `record`, or `object`. */ readonly name: string; - /** Native operations available without facade emulation. */ + /** Backend driver that owns persistence mechanics and provider lifecycle. */ + readonly driver: DriverType; + /** Native adapter routes available without facade emulation. */ readonly capabilities: AdapterCapabilitiesType; - /** Portable hard limits known for this configured backend. Missing values mean unknown, not unlimited. */ + /** Adapter-level logical limits derived from its translation strategy. */ readonly limits?: AdapterLimitsType; - /** Physical partition layout when this adapter can split one logical value across provider records. */ + /** Adapter-level physical partition shape when the translation exposes one. */ readonly partition?: AdapterPartitionType; - /** Returns portable metadata, or `null` when the path does not exist. */ - stat(path: PathType, options?: AdapterSignalOptionsType): Promise; - /** Returns one materialized file or byte range. */ - readFile(path: PathType, options?: AdapterReadOptionsType): Promise; - /** Commits materialized bytes with the requested write semantics. */ - writeFile(path: PathType, data: Uint8Array, options: AdapterWriteOptionsType): Promise; - /** Lazily returns direct children of one directory. */ - readDir(path: PathType, options?: AdapterSignalOptionsType): AsyncIterableIterator; - /** Creates exactly one directory. Its parent must already exist. */ - createDir(path: PathType, options?: AdapterSignalOptionsType): Promise; - /** Removes one file or one empty directory. */ - remove(path: PathType, options?: AdapterSignalOptionsType): Promise; - - /** Opens a native streaming read when `capabilities.streamRead` is true. */ - openReadStream?(path: PathType, options?: AdapterReadOptionsType): Promise>; - /** Commits a stream without facade materialization for a mode listed in `streamWriteModes`. */ - writeStream?(path: PathType, source: ReadableStream, options: AdapterWriteOptionsType): Promise; - /** Copies one file without routing its bytes through the facade. */ - copy?(source: PathType, destination: PathType, options: AdapterCopyOptionsType): Promise; - /** Performs an adapter-native move when `capabilities.nativeMove` is true. */ - move?(source: PathType, destination: PathType, options: AdapterMoveOptionsType): Promise; - /** Opens long-lived asynchronous positional writes when `capabilities.positionalWrite` is true. */ - openWritableFile?(path: PathType): Promise; - /** Opens synchronous random access when `capabilities.syncAccess` is true. */ - openSyncFile?(path: PathType): Promise; - /** Releases resources that this adapter explicitly owns. */ + /** Returns one entry's native metadata, or `null` when it is missing. */ + stat(path: PathType, options?: FileDriverSignalOptionsType): Promise; + /** Materializes a file body through the adapter's native translation path. */ + readFile(path: PathType, options?: FileDriverReadOptionsType): Promise; + /** Writes a complete file body through the adapter's native translation path. */ + writeFile(path: PathType, data: Uint8Array, options: FileDriverWriteOptionsType): Promise; + /** Lists direct children without recursive facade behavior. */ + readDir(path: PathType, options?: FileDriverSignalOptionsType): AsyncIterableIterator; + /** Creates one native directory node for the translated backend. */ + createDir(path: PathType, options?: FileDriverSignalOptionsType): Promise; + /** Removes one translated backend entry. */ + remove(path: PathType, options?: FileDriverSignalOptionsType): Promise; + /** Opens a native read stream when the adapter can avoid full buffering. */ + openReadStream?(path: PathType, options?: FileDriverReadOptionsType): Promise>; + /** Writes streamed bytes natively when the translated backend supports that route. */ + writeStream?(path: PathType, source: ReadableStream, options: FileDriverWriteOptionsType): Promise; + /** Performs one adapter-native copy without facade recursion or fallback logic. */ + copy?(source: PathType, destination: PathType, options: FileDriverCopyOptionsType): Promise; + /** Performs one adapter-native move without facade fallback logic. */ + move?(source: PathType, destination: PathType, options: FileDriverMoveOptionsType): Promise; + /** Opens long-lived asynchronous positional writes when supported natively. */ + openWritableFile?(path: PathType): Promise; + /** Opens synchronous random access when supported natively. */ + openSyncFile?(path: PathType): Promise; + /** Releases adapter-owned resources. Borrowed drivers usually remain live. */ dispose?(): void | Promise; } -/** Options that control the adapter-independent filesystem facade. */ +/** + * Options that control the adapter-independent filesystem facade. + * + * These settings describe facade policy, not backend-native capability. Use + * driver limits and adapter capability reports for storage facts, and use these + * options when the application wants to opt into or out of the facade behavior + * layered on top. + */ export interface FileSystemOptionsType { - /** Mutation coordination. `auto` prefers Web Locks and otherwise uses an in-realm FIFO lock. */ + /** Coordination strategy for facade-owned mutation locks. */ readonly coordination?: CoordinationModeType; - /** Prefix used for Web Lock names. Use a stable application-specific value. */ + /** Namespace used when the coordination layer persists lock identities. */ readonly lockPrefix?: string; - /** - * Maximum bytes materialized when a non-streaming adapter receives a stream. - * - * The default is 64 MiB. Set a lower value for memory-constrained workers or - * a higher value only when the selected record/database backend can accept it. - */ + /** Maximum bytes materialized by facade-owned non-streaming fallbacks. */ readonly maxBufferedWriteBytes?: number; - /** - * Performance routes that may be bypassed for differential testing or policy. - * - * Every omitted field defaults to true. Turning off native move is observable - * because the safe fallback is copy then remove and is therefore not atomic. - */ + /** Independently disableable adapter/facade fast paths. */ readonly optimizations?: Partial; - /** Metrics detail. Defaults to `basic`; use `none` for the lowest benchmark overhead. */ + /** Metrics detail. Defaults to `basic`. */ readonly metrics?: MetricsModeType; /** Closes the adapter when the filesystem facade is disposed. */ readonly disposeAdapter?: boolean; } /** - * Identity helper for custom adapters. + * Parses one adapter-owned schema and normalizes invalid configuration to `TypeError`. + * + * Custom adapter authors usually want one predictable construction failure type + * instead of several raw schema exceptions. + */ +function parseAdapterSchema(schema: { parse(value: unknown): T }, value: unknown, name: string): T { + try { + return schema.parse(value); + } catch (cause) { + throw new TypeError(`Invalid adapter ${name}.`, { cause }); + } +} + +/** + * Validates a custom adapter without registering global state. * - * The function performs no registration and no import-time mutation. It exists - * to make custom adapter exports self-documenting while preserving the concrete - * adapter type. It also verifies required primitive methods and rejects any - * enabled optional capability whose corresponding method is absent. A method - * may still exist while its capability is false so a configured adapter can - * deliberately disable that route without changing its class shape. + * The associated driver is mandatory. This keeps provider requirements and + * limits visible instead of letting a custom adapter become an opaque backend. * - * @example Define the minimum materialized adapter contract. + * @example Wrap a custom file adapter around a configured driver. * ```ts + * import { defineAdapter } from "@okikio/opfs/adapter"; + * * const adapter = defineAdapter({ - * name: "provider", + * name: "custom", + * driver, * capabilities: { - * read: true, - * write: true, * streamRead: false, * streamWriteModes: [], * rangeRead: false, @@ -228,20 +137,26 @@ export interface FileSystemOptionsType { * positionalWrite: false, * syncAccess: false, * }, - * async stat(path) { return null; }, - * async readFile(path) { return new Uint8Array(); }, - * async writeFile(path, data, options) {}, - * async *readDir(path) {}, - * async createDir(path) {}, - * async remove(path) {}, + * stat, + * readFile, + * writeFile, + * readDir, + * createDir, + * remove, * }); * ``` */ export function defineAdapter(adapter: T): T { - AdapterNameSchema.parse(adapter.name); - AdapterCapabilitiesSchema.parse(adapter.capabilities); - if (adapter.limits !== undefined) AdapterLimitsSchema.parse(adapter.limits); - if (adapter.partition !== undefined) AdapterPartitionSchema.parse(adapter.partition); + parseAdapterSchema(AdapterNameSchema, adapter.name, "name"); + parseAdapterSchema(AdapterCapabilitiesSchema, adapter.capabilities, "capabilities"); + if ( + adapter.driver === undefined || typeof adapter.driver.inspect !== "function" || + typeof adapter.driver.plan !== "function" + ) { + throw new TypeError(`Adapter '${adapter.name}' must expose its configured backend driver.`); + } + if (adapter.limits !== undefined) parseAdapterSchema(AdapterLimitsSchema, adapter.limits, "limits"); + if (adapter.partition !== undefined) parseAdapterSchema(AdapterPartitionSchema, adapter.partition, "partition"); for (const name of ["stat", "readFile", "writeFile", "readDir", "createDir", "remove"] as const) { if (typeof adapter[name] !== "function") { @@ -262,9 +177,7 @@ export function defineAdapter(adapter: T): T { } } if (adapter.capabilities.streamWriteModes.length > 0 && adapter.writeStream === undefined) { - throw new TypeError( - `Adapter '${adapter.name}' streamWriteModes do not match its writeStream implementation.`, - ); + throw new TypeError(`Adapter '${adapter.name}' streamWriteModes do not match its writeStream implementation.`); } return adapter; } diff --git a/src/adapter/drizzle.ts b/src/adapter/drizzle.ts index e003a26..b7536fd 100644 --- a/src/adapter/drizzle.ts +++ b/src/adapter/drizzle.ts @@ -1,239 +1,33 @@ -import { eq, type AnyColumn } from "drizzle-orm"; import type { AdapterType } from "./definition.ts"; -import { createRecordAdapter, type RecordStoreType } from "./record.ts"; -import { RecordSchema, type RecordType } from "../schema.ts"; +import { createRecordAdapter } from "./record.ts"; +import { + createDrizzleDriver, + type DrizzleDriverOptionsType, + type DrizzleRowType, + type DrizzleTableType, +} from "../driver/drizzle.ts"; + +/** Options forwarded to the Drizzle record driver. */ +export type DrizzleAdapterOptionsType< + TDatabase extends object, + TTable extends DrizzleTableType, +> = DrizzleDriverOptionsType; + +/** Drizzle row and table contracts required by the record driver. */ +export type { DrizzleRowType, DrizzleTableType }; /** - * Required Drizzle table columns. + * Creates the OPFS primitive translation over a Drizzle database/table driver. * - * Define these columns with the dialect-specific Drizzle schema builder used by - * the application. The adapter intentionally does not own DDL because Drizzle - * is dialect-specific and column definitions differ across SQLite, PostgreSQL, - * MySQL, SingleStore, and driver-specific integrations. + * The supplied database and table stay caller-owned. The adapter owns only the + * small record-driver wrapper created for this filesystem. */ -export interface DrizzleTableType { - /** Unique canonical path column. */ - readonly path: AnyColumn<{ data: string }>; - /** Canonical direct-parent path column. */ - readonly parent: AnyColumn<{ data: string }>; - /** Final entry name column. */ - readonly name: AnyColumn<{ data: string }>; - /** File/directory discriminator column. */ - readonly kind: AnyColumn<{ data: string }>; - /** Base64 file payload column. Directory rows store null. */ - readonly data: AnyColumn<{ data: string }>; - /** Decoded file size column using a JavaScript-number mode. */ - readonly size: AnyColumn<{ data: number }>; - /** Unix epoch millisecond column using a JavaScript-number mode. */ - readonly lastModified: AnyColumn<{ data: number }>; - /** File media-type column. Directory rows store null. */ - readonly mediaType: AnyColumn<{ data: string }>; -} - -/** Row shape expected from the supplied Drizzle table. */ -export interface DrizzleRowType { - /** Canonical virtual path stored in the caller table. */ - readonly path: string; - /** Canonical direct-parent path used for directory queries. */ - readonly parent: string; - /** Final file or directory name. */ - readonly name: string; - /** Persisted file/directory discriminator. */ - readonly kind: "file" | "directory"; - /** Base64 file payload, or null for directories. */ - readonly data: string | null; - /** Decoded file byte length. Directories use zero. */ - readonly size: number; - /** Unix epoch milliseconds for the logical record. */ - readonly lastModified: number; - /** File media type, or null for directories. */ - readonly mediaType: string | null; -} - -/** Options for the Drizzle-backed adapter. */ -export interface DrizzleAdapterOptionsType { - /** Connected Drizzle database from any supported driver. */ - readonly database: TDatabase; - /** Caller-defined dialect-specific table with the required columns. */ - readonly table: TTable; -} - -/** Small thenable contract shared by Drizzle query builders used by this bridge. */ -interface QueryPromiseType extends PromiseLike {} - -/** Selection stage that can apply a row limit. */ -interface SelectLimitType { - /** Limits the selected row count without changing the row shape. */ - limit(count: number): QueryPromiseType; -} - -/** Selection stage that accepts a Drizzle SQL condition. */ -interface SelectWhereType { - /** Applies one Drizzle SQL condition to the current selection. */ - where(condition: object): SelectLimitType & QueryPromiseType; -} - -/** Selection stage that binds the caller-provided table. */ -interface SelectFromType { - /** Binds the caller-owned table to the selection. */ - from(table: object): SelectWhereType; -} - -/** Delete builder subset required for path replacement and removal. */ -interface DeleteType { - /** Restricts deletion to rows selected by the supplied condition. */ - where(condition: object): QueryPromiseType; -} - -/** Insert builder subset required to persist one normalized row. */ -interface InsertValuesType { - /** Inserts one normalized filesystem row. */ - values(value: DrizzleRowType): QueryPromiseType; -} -/** - * Runtime CRUD surface common to the Drizzle database objects supported here. - * - * This is intentionally smaller than Drizzle's public generic types. Dialect - * schema and driver types remain owned by the caller instead of being erased - * into a false universal database type. - */ -interface DrizzleRuntimeType { - /** Starts an unprojected row selection. */ - select(): SelectFromType; - /** Starts one insert against the caller-owned table. */ - insert(table: object): InsertValuesType; - /** Starts one deletion against the caller-owned table. */ - delete(table: object): DeleteType; -} - -/** Validates the three CRUD builders required at runtime before any data is touched. */ -function getRuntime(database: object): DrizzleRuntimeType { - const candidate = database as Partial; - if ( - typeof candidate.select !== "function" || - typeof candidate.insert !== "function" || - typeof candidate.delete !== "function" - ) { - throw new TypeError("Drizzle database must expose select(), insert(), and delete()."); - } - return candidate as DrizzleRuntimeType; -} - -/** Converts a Drizzle row to the validated record format and restores version 1. */ -function toRecord(row: DrizzleRowType): RecordType { - if (row.kind === "directory") { - return RecordSchema.parse({ - version: 1, - path: row.path, - parent: row.parent, - name: row.name, - kind: "directory", - lastModified: row.lastModified, - }); - } - return RecordSchema.parse({ - version: 1, - path: row.path, - parent: row.parent, - name: row.name, - kind: "file", - data: row.data ?? "", - size: row.size, - lastModified: row.lastModified, - mediaType: row.mediaType ?? "", +export function createDrizzleAdapter< + TDatabase extends object, + TTable extends DrizzleTableType, +>(options: DrizzleAdapterOptionsType): AdapterType { + return createRecordAdapter(createDrizzleDriver(options), { + name: "drizzle", + disposeDriver: true, }); } - -/** Converts the shared record format into the caller table's logical row shape. */ -function toRow(record: RecordType): DrizzleRowType { - if (record.kind === "directory") { - return { - path: record.path, - parent: record.parent, - name: record.name, - kind: "directory", - data: null, - size: 0, - lastModified: record.lastModified, - mediaType: null, - }; - } - return { - path: record.path, - parent: record.parent, - name: record.name, - kind: "file", - data: record.data, - size: record.size, - lastModified: record.lastModified, - mediaType: record.mediaType, - }; -} - -/** - * Record-store projection over Drizzle's common CRUD builder surface. - * - * The caller owns both database and dialect-specific table. The class does not - * create DDL or hide cross-process atomicity: replacement is delete-then-insert - * because there is no one portable upsert form across all Drizzle dialects. - */ -class DrizzleRecordStore implements RecordStoreType { - /** Runtime CRUD subset validated from the connected database. */ - readonly #database: DrizzleRuntimeType; - /** Caller-owned table containing the required logical columns. */ - readonly #table: DrizzleTableType; - - /** Validates the database surface once and retains the caller table. */ - constructor(database: object, table: DrizzleTableType) { - this.#database = getRuntime(database); - this.#table = table; - } - - /** Selects one path row and restores the versioned filesystem record. */ - async get(path: Parameters[0]) { - const rows = await this.#database.select().from(this.#table).where(eq(this.#table.path, path)).limit(1); - const row = rows[0]; - return row === undefined ? null : toRecord(row); - } - - /** Replaces one path through the portable delete-then-insert sequence. */ - async set(record: RecordType): Promise { - await this.#database.delete(this.#table).where(eq(this.#table.path, record.path)); - await this.#database.insert(this.#table).values(toRow(record)); - } - - /** Deletes one exact path row. */ - async delete(path: Parameters[0]): Promise { - await this.#database.delete(this.#table).where(eq(this.#table.path, path)); - } - - /** Selects all rows whose indexed/logical parent equals the requested path. */ - async *list(parent: Parameters[0]) { - const rows = await this.#database.select().from(this.#table).where(eq(this.#table.parent, parent)); - for (const row of rows) yield toRecord(row); - } -} - -/** - * Creates a record store over a connected Drizzle database and caller table. - * - * Applications with multiple writing processes must add database-level - * serialization when they need cross-process atomic replacement. - */ -export function createDrizzleRecordStore( - options: DrizzleAdapterOptionsType, -): RecordStoreType { - return new DrizzleRecordStore(options.database, options.table); -} - -/** - * Creates an OPFS-shaped adapter over a Drizzle database and caller-owned table. - * - * The table must make `path` unique and provide every property in - * {@link DrizzleTableType}. The adapter never disposes the database. - */ -export function createDrizzleAdapter( - options: DrizzleAdapterOptionsType, -): AdapterType { - return createRecordAdapter(createDrizzleRecordStore(options), { name: "drizzle" }); -} diff --git a/src/adapter/file.ts b/src/adapter/file.ts new file mode 100644 index 0000000..b6266b7 --- /dev/null +++ b/src/adapter/file.ts @@ -0,0 +1,119 @@ +import type { AdapterType } from "./definition.ts"; +import { defineAdapter } from "./definition.ts"; +import type { + FileDriverCopyOptionsType, + FileDriverMoveOptionsType, + FileDriverReadOptionsType, + FileDriverSignalOptionsType, + FileDriverType, + FileDriverWriteOptionsType, +} from "../driver/file.ts"; +import type { PathType } from "../path.ts"; + +/** Options for a thin filesystem adapter over one file-shaped driver. */ +export interface FileAdapterOptionsType { + /** Diagnostic adapter name. Defaults to the driver name. */ + readonly name?: string; + /** Disposes the injected driver when the adapter is disposed. */ + readonly disposeDriver?: boolean; +} + +/** + * Thin adapter over a backend-native file driver. + * + * The adapter performs no persistence work. It only translates the driver + * capability names into the facade's adapter capability contract and forwards + * the primitive filesystem calls. + */ +class FileAdapter implements AdapterType { + readonly driver: FileDriverType; + readonly name: string; + readonly capabilities: AdapterType["capabilities"]; + readonly #disposeDriver: boolean; + + constructor(driver: FileDriverType, options: FileAdapterOptionsType) { + this.driver = driver; + this.name = options.name ?? driver.name; + this.#disposeDriver = options.disposeDriver ?? false; + this.capabilities = { + read: driver.capabilities.read, + write: driver.capabilities.write, + streamRead: driver.capabilities.streamRead && driver.openReadStream !== undefined, + streamWriteModes: driver.writeStream === undefined ? [] : [...driver.capabilities.streamWriteModes], + rangeRead: driver.capabilities.rangeRead, + nativeCopy: driver.capabilities.copy && driver.copy !== undefined, + nativeMove: driver.capabilities.move && driver.move !== undefined, + positionalWrite: driver.capabilities.positionalWrite && driver.openWritableFile !== undefined, + syncAccess: driver.capabilities.syncAccess && driver.openSyncFile !== undefined, + }; + } + + stat(path: PathType, options?: FileDriverSignalOptionsType) { + return this.driver.stat(path, options); + } + + readFile(path: PathType, options?: FileDriverReadOptionsType) { + return this.driver.readFile(path, options); + } + + writeFile(path: PathType, data: Uint8Array, options: FileDriverWriteOptionsType) { + return this.driver.writeFile(path, data, options); + } + + readDir(path: PathType, options?: FileDriverSignalOptionsType) { + return this.driver.readDir(path, options); + } + + createDir(path: PathType, options?: FileDriverSignalOptionsType) { + return this.driver.createDir(path, options); + } + + remove(path: PathType, options?: FileDriverSignalOptionsType) { + return this.driver.remove(path, options); + } + + openReadStream(path: PathType, options?: FileDriverReadOptionsType) { + if (this.driver.openReadStream === undefined) { + throw new TypeError(`Driver '${this.driver.name}' has no stream read.`); + } + return this.driver.openReadStream(path, options); + } + + writeStream(path: PathType, source: ReadableStream, options: FileDriverWriteOptionsType) { + if (this.driver.writeStream === undefined) throw new TypeError(`Driver '${this.driver.name}' has no stream write.`); + return this.driver.writeStream(path, source, options); + } + + copy(source: PathType, destination: PathType, options: FileDriverCopyOptionsType) { + if (this.driver.copy === undefined) throw new TypeError(`Driver '${this.driver.name}' has no native copy.`); + return this.driver.copy(source, destination, options); + } + + move(source: PathType, destination: PathType, options: FileDriverMoveOptionsType) { + if (this.driver.move === undefined) throw new TypeError(`Driver '${this.driver.name}' has no native move.`); + return this.driver.move(source, destination, options); + } + + openWritableFile(path: PathType) { + if (this.driver.openWritableFile === undefined) { + throw new TypeError(`Driver '${this.driver.name}' has no positional file.`); + } + return this.driver.openWritableFile(path); + } + + openSyncFile(path: PathType) { + if (this.driver.openSyncFile === undefined) { + throw new TypeError(`Driver '${this.driver.name}' has no synchronous file.`); + } + return this.driver.openSyncFile(path); + } + + async dispose(): Promise { + if (this.#disposeDriver) await this.driver.dispose?.(); + } +} + +/** Creates the OPFS primitive translation over one file-shaped driver. */ +export function createFileAdapter(driver: FileDriverType, options: FileAdapterOptionsType = {}): AdapterType { + return defineAdapter(new FileAdapter(driver, options)); +} diff --git a/src/adapter/memory.ts b/src/adapter/memory.ts index 2efc582..41a1b19 100644 --- a/src/adapter/memory.ts +++ b/src/adapter/memory.ts @@ -1,92 +1,15 @@ import type { AdapterType } from "./definition.ts"; -import { createRecordAdapter, type RecordStoreType } from "./record.ts"; -import type { PathType } from "../path.ts"; -import type { RecordType } from "../schema.ts"; +import { createRecordAdapter } from "./record.ts"; +import { createMemoryDriver, type MemoryDriverType } from "../driver/memory.ts"; -/** In-memory record store useful for tests, demos, and temporary data. */ -export interface MemoryRecordStoreType extends RecordStoreType { - /** Removes every stored record. */ - clear(): void; - /** Number of stored files and directories, excluding the implicit root. */ - readonly size: number; -} - -/** - * Deterministic process-local record store. - * - * The store clones records on both write and read. A test that retains and - * mutates an object reference therefore cannot mutate persistence without a - * `set()` call. The class owns only its Map and has no disposal lifecycle. - */ -class MemoryRecordStore implements MemoryRecordStoreType { - /** Durable state for the lifetime of this JavaScript realm/store instance. */ - readonly #records = new Map(); - - /** Number of explicit file/directory records. Root remains implicit. */ - get size(): number { - return this.#records.size; - } - - /** Removes every explicit record. */ - clear(): void { - this.#records.clear(); - } - - /** Returns a cloned record so callers cannot mutate stored state by reference. */ - async get(path: PathType): Promise { - const record = this.#records.get(path); - return record === undefined ? null : structuredClone(record); - } - - /** Replaces one record with an owned clone. */ - async set(record: RecordType): Promise { - this.#records.set(record.path, structuredClone(record)); - } - - /** Removes one exact record. */ - async delete(path: PathType): Promise { - this.#records.delete(path); - } - - /** Lazily yields cloned direct children. */ - async *list(parent: PathType): AsyncIterableIterator { - for (const record of this.#records.values()) { - if (record.parent === parent) yield structuredClone(record); - } - } -} - -/** - * Creates a deterministic in-memory record store. - * - * @example Inspect records while testing a record-store wrapper. - * ```ts - * const store = createMemoryRecordStore(); - * await store.set(record); - * assertEquals(store.size, 1); - * store.clear(); - * ``` - */ -export function createMemoryRecordStore(): MemoryRecordStoreType { - return new MemoryRecordStore(); +/** Creates the OPFS primitive translation over a deterministic memory driver. */ +export function createMemoryAdapter(): AdapterType { + return createRecordAdapter(createMemoryDriver(), { name: "memory", disposeDriver: true }); } /** - * Creates an in-memory OPFS-shaped adapter. - * - * It uses the same record adapter as RxDB, unstorage, db0, and Drizzle, which - * makes it useful for testing facade semantics without a browser or database. - * It is not durable and does not advertise native streaming or sync access. + * Creates a memory driver for tests that need direct logical-record access. * - * @example Use File System API-shaped handles without browser OPFS. - * ```ts - * const fileSystem = createFileSystem(createMemoryAdapter()); - * const file = await fileSystem.root.getFileHandle("hello.txt", { create: true }); - * const writable = await file.createWritable(); - * await writable.write("hello"); - * await writable.close(); - * ``` + * Prefer `createMemoryDriver()` from `@okikio/opfs/driver/memory` in new code. */ -export function createMemoryAdapter(): AdapterType { - return createRecordAdapter(createMemoryRecordStore(), { name: "memory" }); -} +export { createMemoryDriver, type MemoryDriverType }; diff --git a/src/adapter/object.ts b/src/adapter/object.ts index 012fda3..8ae287a 100644 --- a/src/adapter/object.ts +++ b/src/adapter/object.ts @@ -1,172 +1,35 @@ import { toBytes as readStreamBytes } from "@std/streams/to-bytes"; -import { z } from "zod"; - import { FileSystemError, throwIfAborted } from "../error.ts"; -import { ROOT_PATH, type PathType } from "../path.ts"; +import { type PathType, ROOT_PATH } from "../path.ts"; import type { AdapterLimitsType, WriteModeType } from "../schema.ts"; -import type { - AdapterCopyOptionsType, - AdapterDirectoryEntryType, - AdapterReadOptionsType, - AdapterSignalOptionsType, - AdapterStatType, - AdapterType, - AdapterWriteOptionsType, -} from "./definition.ts"; +import type { AdapterType } from "./definition.ts"; import { defineAdapter } from "./definition.ts"; +import type { + FileDriverCopyOptionsType, + FileDriverDirectoryEntryType, + FileDriverReadOptionsType, + FileDriverSignalOptionsType, + FileDriverStatType, + FileDriverWriteOptionsType, +} from "../driver/file.ts"; -/** - * Native behavior exposed by an object-storage client. - * - * These fields describe operations the provider can perform without the - * filesystem adapter downloading, buffering, or reconstructing an object. - * Keeping those distinctions visible prevents a remote object service from - * being documented as if it had local filesystem semantics. - */ -export const ObjectCapabilitiesSchema = z.object({ - rangeRead: z.boolean(), - streamRead: z.boolean(), - streamWrite: z.boolean(), - copy: z.boolean(), - conditionalWrite: z.boolean(), -}); - -/** Native object-store behavior used by the filesystem adapter. */ -export type ObjectCapabilitiesType = z.output; - -/** Portable object metadata returned by {@link ObjectStoreType.head}. */ -export interface ObjectStatType { - /** Object byte length. */ - readonly size: number; - /** Last modification time when the provider exposes it. */ - readonly lastModified?: number; - /** HTTP media type when known. */ - readonly mediaType?: string; - /** Provider entity tag used for optimistic conditional replacement. */ - readonly etag?: string; - /** Provider version identity when versioning is enabled. */ - readonly version?: string; - /** User metadata retained by the provider. */ - readonly metadata?: Readonly>; -} - -/** One object returned from a prefix listing. */ -export interface ObjectEntryType extends ObjectStatType { - /** Provider object key. */ - readonly key: string; -} - -/** One page from an object-store prefix listing. */ -export interface ObjectListType { - /** Objects whose keys match the requested prefix. */ - readonly objects: readonly ObjectEntryType[]; - /** Delimited child prefixes when a delimiter was requested. */ - readonly prefixes: readonly string[]; - /** Cursor supplied to the next list call when more results exist. */ - readonly cursor?: string; -} - -/** Options for one object GET. */ -export interface ObjectGetOptionsType { - /** Zero-based byte offset. */ - readonly at?: number; - /** Maximum bytes to return after `at`. */ - readonly length?: number; - /** Cancels the HTTP/provider operation. */ - readonly signal?: AbortSignal; -} - -/** Options for one object PUT. */ -export interface ObjectPutOptionsType { - /** Media type stored with the object. */ - readonly mediaType?: string; - /** Provider user metadata. */ - readonly metadata?: Readonly>; - /** Replace only when the current entity tag still matches. */ - readonly ifMatch?: string; - /** Replace only when the current entity tag does not match. `*` means create only. */ - readonly ifNoneMatch?: string; - /** - * Expected body size when the caller knows it before streaming begins. - * - * Multipart providers use this value to choose a part size that remains - * within their maximum part-count limit. The client verifies the declared - * size when it can observe the final byte count. - */ - readonly size?: number; - /** Cancels the HTTP/provider operation. */ - readonly signal?: AbortSignal; -} - -/** Options for one provider-side object copy. */ -export interface ObjectCopyOptionsType { - /** Replace only when the destination entity tag still matches. */ - readonly ifMatch?: string; - /** Replace only when the destination entity tag does not match. */ - readonly ifNoneMatch?: string; - /** Copy only when the source entity tag still matches. */ - readonly sourceIfMatch?: string; - /** Copy only when the source entity tag does not match. */ - readonly sourceIfNoneMatch?: string; - /** Copy only when the source changed after this time. */ - readonly sourceIfModifiedSince?: Date; - /** Copy only when the source did not change after this time. */ - readonly sourceIfUnmodifiedSince?: Date; - /** Cancels the provider operation. */ - readonly signal?: AbortSignal; -} - -/** Options for prefix listing. */ -export interface ObjectListOptionsType { - /** Key prefix. */ - readonly prefix: string; - /** Hierarchy delimiter. `/` produces filesystem-like direct children. */ - readonly delimiter?: string; - /** Maximum entries requested from the provider. */ - readonly limit?: number; - /** Opaque continuation cursor from a previous result. */ - readonly cursor?: string; - /** Cancels the provider operation. */ - readonly signal?: AbortSignal; -} - -/** - * Provider-neutral object-storage client used by concrete object services. - * - * This contract stops at object-store concepts. S3 multipart state, Azure block - * state, provider error records, signing, encryption controls, and other wire - * details remain on the concrete client. The filesystem adapter consumes only - * the capabilities required to map object keys into OPFS-shaped paths. - */ -export interface ObjectStoreType { - /** Stable provider/client name used in diagnostics. */ - readonly name: string; - /** Native object behavior available without filesystem emulation. */ - readonly capabilities: ObjectCapabilitiesType; - /** Portable hard limits known by this configured client. Missing fields mean unknown. */ - readonly limits?: AdapterLimitsType; - /** Returns metadata for an exact object key, or null when it is absent. */ - head(key: string, options?: { readonly signal?: AbortSignal }): Promise; - /** Opens one complete object or byte range as a Web stream. */ - get(key: string, options?: ObjectGetOptionsType): Promise>; - /** Replaces one object. Streaming bodies are allowed only when `streamWrite` is true. */ - put(key: string, body: Uint8Array | ReadableStream, options?: ObjectPutOptionsType): Promise; - /** Removes one exact object key. Missing objects are treated as already removed. */ - delete(key: string, options?: { readonly signal?: AbortSignal }): Promise; - /** Lists objects and optional child prefixes. */ - list(options: ObjectListOptionsType): Promise; - /** Copies one object without downloading its bytes when `copy` is true. */ - copy?(source: string, destination: string, options?: ObjectCopyOptionsType): Promise; - /** Releases resources explicitly owned by the client. */ - dispose?(): void | Promise; -} +import type { + ObjectCopyOptionsType, + ObjectDriverType, + ObjectEntryType, + ObjectGetOptionsType, + ObjectListOptionsType, + ObjectListType, + ObjectPutOptionsType, + ObjectStatType, +} from "../driver/object.ts"; /** Filesystem mapping options for an object store. */ export interface ObjectAdapterOptionsType { /** Prefix reserved for this virtual filesystem. The default is the bucket/container root. */ readonly prefix?: string; /** Disposes the injected object client when the adapter closes. */ - readonly disposeStore?: boolean; + readonly disposeDriver?: boolean; } /** Minimal evidence retained while resolving whether one virtual directory exists. */ @@ -237,15 +100,15 @@ function applyWrite( * provider objects that another client created. Append and update use a * conditional read-modify-write cycle when the provider exposes ETags. * - * The adapter borrows the object client unless `disposeStore` is true. + * The adapter borrows the object client unless `disposeDriver` is true. */ class ObjectAdapter implements AdapterType { /** Object service that owns provider I/O and provider-specific semantics. */ - readonly #store: ObjectStoreType; + readonly driver: ObjectDriverType; /** Normalized object-key prefix reserved for this filesystem. */ readonly #prefix: string; /** Whether adapter disposal transfers to the injected object client. */ - readonly #disposeStore: boolean; + readonly #disposeDriver: boolean; /** Stable adapter name inherited from the provider client. */ readonly name: string; @@ -255,20 +118,19 @@ class ObjectAdapter implements AdapterType { readonly limits?: AdapterLimitsType; /** Creates one adapter without performing network I/O. */ - constructor(store: ObjectStoreType, options: ObjectAdapterOptionsType) { - ObjectCapabilitiesSchema.parse(store.capabilities); - this.#store = store; + constructor(driver: ObjectDriverType, options: ObjectAdapterOptionsType) { + this.driver = driver; this.#prefix = normalizePrefix(options.prefix); - this.#disposeStore = options.disposeStore ?? false; - this.name = store.name; - if (store.limits !== undefined) this.limits = store.limits; + this.#disposeDriver = options.disposeDriver ?? false; + this.name = driver.name; + if (driver.portableLimits !== undefined) this.limits = driver.portableLimits; this.capabilities = { read: true, write: true, - streamRead: store.capabilities.streamRead, - streamWriteModes: store.capabilities.streamWrite ? ["replace"] : [], - rangeRead: store.capabilities.rangeRead, - nativeCopy: store.capabilities.copy, + streamRead: driver.capabilities.streamRead, + streamWriteModes: driver.capabilities.streamWrite ? ["replace"] : [], + rangeRead: driver.capabilities.rangeRead, + nativeCopy: driver.capabilities.copy, nativeMove: false, positionalWrite: false, syncAccess: false, @@ -277,7 +139,7 @@ class ObjectAdapter implements AdapterType { /** Returns exact file metadata without interpreting a sibling key prefix as a file. */ async #getFile(path: PathType, signal?: AbortSignal): Promise { - return await this.#store.head(fileKey(this.#prefix, path), signal === undefined ? undefined : { signal }); + return await this.driver.head(fileKey(this.#prefix, path), signal === undefined ? undefined : { signal }); } /** @@ -289,14 +151,19 @@ class ObjectAdapter implements AdapterType { async #getDirectory(path: PathType, signal?: AbortSignal): Promise { if (path === ROOT_PATH) return {}; const key = directoryKey(this.#prefix, path); - const marker = await this.#store.head(key, signal === undefined ? undefined : { signal }); + const marker = await this.driver.head(key, signal === undefined ? undefined : { signal }); if (marker?.metadata?.[DIRECTORY_META] === DIRECTORY_VALUE) return marker; - const found = await this.#store.list({ prefix: key, delimiter: "/", limit: 1, ...(signal === undefined ? {} : { signal }) }); + const found = await this.driver.list({ + prefix: key, + delimiter: "/", + limit: 1, + ...(signal === undefined ? {} : { signal }), + }); return found.objects.length > 0 || found.prefixes.length > 0 ? {} : null; } /** Returns portable file/directory metadata for one virtual path. */ - async stat(path: PathType, options?: AdapterSignalOptionsType): Promise { + async stat(path: PathType, options?: FileDriverSignalOptionsType): Promise { throwIfAborted(options?.signal, "stat", path); if (path === ROOT_PATH) return { kind: "directory" }; @@ -318,21 +185,21 @@ class ObjectAdapter implements AdapterType { } /** Reads one materialized object or byte range. */ - async readFile(path: PathType, options: AdapterReadOptionsType = {}): Promise { + async readFile(path: PathType, options: FileDriverReadOptionsType = {}): Promise { throwIfAborted(options.signal, "read", path); if (await this.#getFile(path, options.signal) === null) { throw new FileSystemError("not-found", "read", path, `File '${path}' does not exist.`); } - return await readStreamBytes(await this.#store.get(fileKey(this.#prefix, path), options)); + return await readStreamBytes(await this.driver.get(fileKey(this.#prefix, path), options)); } /** Opens the provider's native response stream without eager materialization. */ - async openReadStream(path: PathType, options: AdapterReadOptionsType = {}): Promise> { + async openReadStream(path: PathType, options: FileDriverReadOptionsType = {}): Promise> { throwIfAborted(options.signal, "read", path); if (await this.#getFile(path, options.signal) === null) { throw new FileSystemError("not-found", "read", path, `File '${path}' does not exist.`); } - return await this.#store.get(fileKey(this.#prefix, path), options); + return await this.driver.get(fileKey(this.#prefix, path), options); } /** @@ -342,7 +209,7 @@ class ObjectAdapter implements AdapterType { * image, then commit it with the previous ETag. A provider that advertises * conditional writes but omits an ETag cannot safely perform this sequence. */ - async writeFile(path: PathType, data: Uint8Array, options: AdapterWriteOptionsType): Promise { + async writeFile(path: PathType, data: Uint8Array, options: FileDriverWriteOptionsType): Promise { throwIfAborted(options.signal, "write", path); const previous = await this.#getFile(path, options.signal); if (previous === null && await this.#getDirectory(path, options.signal)) { @@ -351,25 +218,28 @@ class ObjectAdapter implements AdapterType { let next = data; if (options.mode !== "replace") { - if (this.#store.capabilities.conditionalWrite && previous !== null && previous.etag === undefined) { + if (this.driver.capabilities.conditionalWrite && previous !== null && previous.etag === undefined) { throw new FileSystemError( "unknown", "write", path, - `${this.#store.name} advertises conditional writes but HEAD did not return an ETag for '${path}'.`, + `${this.driver.name} advertises conditional writes but HEAD did not return an ETag for '${path}'.`, ); } - const current = previous === null - ? new Uint8Array() - : await readStreamBytes(await this.#store.get(fileKey(this.#prefix, path), options.signal === undefined ? undefined : { signal: options.signal })); + const current = previous === null ? new Uint8Array() : await readStreamBytes( + await this.driver.get( + fileKey(this.#prefix, path), + options.signal === undefined ? undefined : { signal: options.signal }, + ), + ); next = applyWrite(current, data, options.mode, options.at, options.truncate ?? false); } - await this.#store.put(fileKey(this.#prefix, path), next, { + await this.driver.put(fileKey(this.#prefix, path), next, { size: next.byteLength, ...(options.mediaType === undefined ? {} : { mediaType: options.mediaType }), - ...(this.#store.capabilities.conditionalWrite && previous?.etag ? { ifMatch: previous.etag } : {}), - ...(this.#store.capabilities.conditionalWrite && previous === null && options.mode !== "replace" + ...(this.driver.capabilities.conditionalWrite && previous?.etag ? { ifMatch: previous.etag } : {}), + ...(this.driver.capabilities.conditionalWrite && previous === null && options.mode !== "replace" ? { ifNoneMatch: "*" } : {}), ...(options.signal === undefined ? {} : { signal: options.signal }), @@ -377,29 +247,41 @@ class ObjectAdapter implements AdapterType { } /** Streams one complete replacement directly to providers with native stream upload. */ - async writeStream(path: PathType, source: ReadableStream, options: AdapterWriteOptionsType): Promise { + async writeStream( + path: PathType, + source: ReadableStream, + options: FileDriverWriteOptionsType, + ): Promise { if (options.mode !== "replace") { - throw new FileSystemError("not-supported", "write", path, "Object-store streaming is native only for replacement writes."); + throw new FileSystemError( + "not-supported", + "write", + path, + "Object-store streaming is native only for replacement writes.", + ); } const previous = await this.#getFile(path, options.signal); if (previous === null && await this.#getDirectory(path, options.signal)) { throw new FileSystemError("type-mismatch", "write", path, `'${path}' is a directory.`); } - await this.#store.put(fileKey(this.#prefix, path), source, { + await this.driver.put(fileKey(this.#prefix, path), source, { ...(options.mediaType === undefined ? {} : { mediaType: options.mediaType }), ...(options.signal === undefined ? {} : { signal: options.signal }), }); } /** Lazily lists direct virtual children over provider prefix pagination. */ - async *readDir(path: PathType, options?: AdapterSignalOptionsType): AsyncIterableIterator { + async *readDir( + path: PathType, + options?: FileDriverSignalOptionsType, + ): AsyncIterableIterator { throwIfAborted(options?.signal, "read-dir", path); const parent = directoryKey(this.#prefix, path); let cursor: string | undefined; const seen = new Set(); do { - const page = await this.#store.list({ + const page = await this.driver.list({ prefix: parent, delimiter: "/", ...(cursor === undefined ? {} : { cursor }), @@ -423,13 +305,13 @@ class ObjectAdapter implements AdapterType { } /** Creates an empty-directory marker without replacing a file at the same path. */ - async createDir(path: PathType, options?: AdapterSignalOptionsType): Promise { + async createDir(path: PathType, options?: FileDriverSignalOptionsType): Promise { throwIfAborted(options?.signal, "mkdir", path); if (await this.#getFile(path, options?.signal)) { throw new FileSystemError("type-mismatch", "mkdir", path, `'${path}' is a file.`); } if (path === ROOT_PATH || await this.#getDirectory(path, options?.signal)) return; - await this.#store.put(directoryKey(this.#prefix, path), new Uint8Array(), { + await this.driver.put(directoryKey(this.#prefix, path), new Uint8Array(), { size: 0, metadata: { [DIRECTORY_META]: DIRECTORY_VALUE }, ...(options?.signal === undefined ? {} : { signal: options.signal }), @@ -437,41 +319,48 @@ class ObjectAdapter implements AdapterType { } /** Removes one exact file or one empty directory marker. */ - async remove(path: PathType, options?: AdapterSignalOptionsType): Promise { + async remove(path: PathType, options?: FileDriverSignalOptionsType): Promise { throwIfAborted(options?.signal, "remove", path); const file = await this.#getFile(path, options?.signal); if (file !== null) { - await this.#store.delete(fileKey(this.#prefix, path), options); + await this.driver.delete(fileKey(this.#prefix, path), options); return; } const directory = await this.#getDirectory(path, options?.signal); if (directory === null) return; const key = directoryKey(this.#prefix, path); - const page = await this.#store.list({ + const page = await this.driver.list({ prefix: key, delimiter: "/", limit: 2, ...(options?.signal === undefined ? {} : { signal: options.signal }), }); const hasChildren = page.prefixes.length > 0 || page.objects.some((entry) => entry.key !== key); - if (hasChildren) throw new FileSystemError("invalid-operation", "remove", path, `Directory '${path}' is not empty.`); - await this.#store.delete(key, options); + if (hasChildren) { + throw new FileSystemError("invalid-operation", "remove", path, `Directory '${path}' is not empty.`); + } + await this.driver.delete(key, options); } /** Delegates one file copy to the provider so bytes stay inside the object service. */ - async copy(source: PathType, destination: PathType, options: AdapterCopyOptionsType): Promise { - if (!this.#store.capabilities.copy || this.#store.copy === undefined) { - throw new FileSystemError("not-supported", "copy", source, `${this.#store.name} does not expose provider-side copy.`); + async copy(source: PathType, destination: PathType, options: FileDriverCopyOptionsType): Promise { + if (!this.driver.capabilities.copy || this.driver.copy === undefined) { + throw new FileSystemError( + "not-supported", + "copy", + source, + `${this.driver.name} does not expose provider-side copy.`, + ); } - await this.#store.copy(fileKey(this.#prefix, source), fileKey(this.#prefix, destination), { + await this.driver.copy(fileKey(this.#prefix, source), fileKey(this.#prefix, destination), { ...(options.signal === undefined ? {} : { signal: options.signal }), }); } /** Disposes the provider only when ownership was explicitly transferred. */ async dispose(): Promise { - if (this.#disposeStore) await this.#store.dispose?.(); + if (this.#disposeDriver) await this.driver.dispose?.(); } } @@ -490,6 +379,6 @@ class ObjectAdapter implements AdapterType { * await fileSystem.writeFile("/state.json", "{}", { parents: true }); * ``` */ -export function createObjectAdapter(store: ObjectStoreType, options: ObjectAdapterOptionsType = {}): AdapterType { - return defineAdapter(new ObjectAdapter(store, options)); +export function createObjectAdapter(driver: ObjectDriverType, options: ObjectAdapterOptionsType = {}): AdapterType { + return defineAdapter(new ObjectAdapter(driver, options)); } diff --git a/src/adapter/opfs.ts b/src/adapter/opfs.ts index 16e22f1..41442d5 100644 --- a/src/adapter/opfs.ts +++ b/src/adapter/opfs.ts @@ -1,362 +1,31 @@ -import type { - AdapterDirectoryEntryType, - AdapterReadOptionsType, - AdapterSignalOptionsType, - AdapterStatType, - AdapterSyncFileType, - AdapterType, - AdapterWritableFileType, - AdapterWriteOptionsType, - FileSystemOptionsType, -} from "./definition.ts"; -import { defineAdapter } from "./definition.ts"; -import { FileSystemError, throwIfAborted, toFileSystemError } from "../error.ts"; +import type { AdapterType, FileSystemOptionsType } from "./definition.ts"; +import { createFileAdapter } from "./file.ts"; import { createFileSystem, type FileSystemType } from "../filesystem.ts"; -import { basename, dirname, type PathType, ROOT_PATH, splitPath } from "../path.ts"; -import { toByteStream } from "../stream.ts"; - -/** Minimal file handle contract required from browser OPFS. */ -interface NativeFileHandleType { - /** Native File System API discriminator. */ - readonly kind: "file"; - /** Native direct-entry name. */ - readonly name: string; - /** Returns the browser's immutable file snapshot. */ - getFile(): Promise; - /** Opens the browser's staged writable stream. */ - createWritable(options?: { keepExistingData?: boolean }): Promise; - /** Opens worker-only synchronous access when this realm exposes it. */ - createSyncAccessHandle?: () => Promise; -} - -/** Minimal directory handle contract required from browser OPFS. */ -interface NativeDirectoryHandleType { - /** Native File System API discriminator. */ - readonly kind: "directory"; - /** Native direct-entry name. */ - readonly name: string; - /** Opens or creates one direct child file. */ - getFileHandle(name: string, options?: { create?: boolean }): Promise; - /** Opens or creates one direct child directory. */ - getDirectoryHandle(name: string, options?: { create?: boolean }): Promise; - /** Removes one direct child using browser-native filesystem semantics. */ - removeEntry(name: string, options?: { recursive?: boolean }): Promise; - /** Lazily iterates native direct-child handles. */ - entries(): AsyncIterableIterator<[string, NativeFileHandleType | NativeDirectoryHandleType]>; -} +import { FileSystemError, toFileSystemError } from "../error.ts"; +import { createOpfsDriver, type OpfsDriverType } from "../driver/opfs.ts"; /** OPFS adapter with the native root retained for advanced browser interop. */ export interface OpfsAdapterType extends AdapterType { - /** Native origin-private directory root borrowed from the caller. */ + readonly driver: OpfsDriverType; readonly nativeRoot: FileSystemDirectoryHandle; } /** Options for opening the current origin-private filesystem. */ export type OpenFileSystemOptionsType = FileSystemOptionsType; -/** Resolves a canonical virtual directory path one native handle at a time. */ -async function getDirectory(root: NativeDirectoryHandleType, path: string): Promise { - let current = root; - for (const part of splitPath(path)) current = await current.getDirectoryHandle(part); - return current; -} - -/** Resolves a file through its parent directory and optionally creates the final entry. */ -async function getFile(root: NativeDirectoryHandleType, path: string, create = false): Promise { - const parent = await getDirectory(root, dirname(path)); - return await parent.getFileHandle(basename(path), { create }); -} - -/** Slices a file snapshot before streaming so a range never exposes unrelated bytes. */ -function getStream(file: File, options: AdapterReadOptionsType): ReadableStream { - const at = options.at ?? 0; - const end = options.length === undefined ? file.size : Math.min(file.size, at + options.length); - return file.slice(at, end).stream() as ReadableStream; -} - -/** - * Determines entry kind without creating anything. - * - * Browser OPFS has separate file and directory lookup methods. A type mismatch - * from the first lookup is therefore a normal branch. The second lookup must - * run before the adapter can classify the path as absent. - */ -async function getStat(root: NativeDirectoryHandleType, path: string): Promise { - if (path === ROOT_PATH) return { kind: "directory" }; - try { - const handle = await getFile(root, path); - const file = await handle.getFile(); - return { kind: "file", size: file.size, lastModified: file.lastModified, mediaType: file.type }; - } catch (error) { - const mapped = toFileSystemError(error, "stat", path); - if (mapped.code !== "not-found" && mapped.code !== "type-mismatch") throw mapped; - } - - try { - await getDirectory(root, path); - return { kind: "directory" }; - } catch (error) { - const mapped = toFileSystemError(error, "stat", path); - if (mapped.code === "not-found" || mapped.code === "type-mismatch") return null; - throw mapped; - } -} - -/** - * Streams bytes into one native OPFS staged writable. - * - * `createWritable()` commits when it closes. If source reading, cancellation, - * or writing fails, the producer is cancelled and the native writable is - * aborted so the partially staged image does not become the visible file. - */ -async function writeToNative( - handle: NativeFileHandleType, - source: ReadableStream, - options: AdapterWriteOptionsType, - path: string, -): Promise { - const writable = await handle.createWritable({ keepExistingData: options.mode !== "replace" }); - let cursor = 0; - try { - if (options.mode === "append") { - cursor = (await handle.getFile()).size; - await writable.seek(cursor); - } else if (options.mode === "update") { - cursor = options.at ?? 0; - await writable.seek(cursor); - } - - const reader = source.getReader(); - try { - while (true) { - throwIfAborted(options.signal, "write", path); - const next = await reader.read(); - if (next.done) break; - await writable.write(next.value as BufferSource); - cursor += next.value.byteLength; - } - } catch (error) { - try { - await reader.cancel(error); - } catch { - // Preserve the first write or cancellation failure. - } - throw error; - } finally { - reader.releaseLock(); - } - - if (options.truncate) await writable.truncate(cursor); - await writable.close(); - } catch (error) { - try { - await writable.abort(error); - } catch { - // The first write failure is more useful if abort also fails. - } - throw error; - } -} - -/** Returns whether this realm exposes the worker-only sync access method. */ -function supportsSyncAccessHandle(): boolean { - const constructor = Reflect.get(globalThis, "FileSystemFileHandle"); - if (typeof constructor !== "function") return false; - const prototype = Reflect.get(constructor, "prototype"); - return typeof prototype === "object" && - prototype !== null && - typeof Reflect.get(prototype, "createSyncAccessHandle") === "function"; -} - -/** Long-lived native OPFS positional writable with explicit close/abort state. */ -class OpfsWritableFile implements AdapterWritableFileType { - /** Canonical path used in post-close diagnostics. */ - readonly #path: PathType; - /** Native staged writable owned until close or abort. */ - readonly #writable: FileSystemWritableFileStream; - /** Prevents writes after terminal resource settlement. */ - #closed = false; - - /** Takes ownership of one native staged writable for a canonical path. */ - constructor(path: PathType, writable: FileSystemWritableFileStream) { - this.#path = path; - this.#writable = writable; - } - - /** Returns the live writable or rejects operations after settlement. */ - #getWritable(): FileSystemWritableFileStream { - if (this.#closed) throw new Error(`Writable file '${this.#path}' is closed.`); - return this.#writable; - } - - /** Writes one byte view at its explicit file position. */ - async write(buffer: ArrayBufferView, options: { readonly at: number }): Promise { - const view = new Uint8Array(buffer.buffer, buffer.byteOffset, buffer.byteLength); - const data = buffer.buffer instanceof ArrayBuffer - ? new Uint8Array(buffer.buffer, buffer.byteOffset, buffer.byteLength) - : Uint8Array.from(view); - await this.#getWritable().write({ type: "write", position: options.at, data }); - } - - /** Changes the staged file size. */ - async truncate(size: number): Promise { - await this.#getWritable().truncate(size); - } - - /** Verifies that the resource is still live; OPFS has no separate flush primitive. */ - async flush(): Promise { - this.#getWritable(); - } - - /** Commits the staged image and closes the native writable exactly once. */ - async close(): Promise { - if (this.#closed) return; - this.#closed = true; - await this.#writable.close(); - } - - /** Discards the staged image when possible and closes exactly once. */ - async abort(reason?: unknown): Promise { - if (this.#closed) return; - this.#closed = true; - await this.#writable.abort(reason); - } -} - -/** Native browser OPFS implementation of the portable adapter contract. */ -class OpfsAdapter implements OpfsAdapterType { - /** Stable adapter identity used in diagnostics. */ - readonly name = "opfs"; - /** Native origin-private root retained for advanced browser interop. */ - readonly nativeRoot: FileSystemDirectoryHandle; - /** Native operations exposed without facade emulation. */ - readonly capabilities; - /** Narrow native root shape used by internal traversal helpers. */ - readonly #root: NativeDirectoryHandleType; - - /** Borrows the native root and probes only actual API exposure in this realm. */ - constructor(root: FileSystemDirectoryHandle) { - this.nativeRoot = root; - this.#root = root as unknown as NativeDirectoryHandleType; - this.capabilities = { - read: true, - write: true, - streamRead: true, - streamWriteModes: ["replace", "append", "update"], - rangeRead: true, - nativeCopy: false, - nativeMove: false, - positionalWrite: true, - syncAccess: supportsSyncAccessHandle(), - } as const; - } - - /** Returns native metadata or `null` when neither file nor directory exists. */ - async stat(path: PathType, options: AdapterSignalOptionsType = {}): Promise { - throwIfAborted(options.signal, "stat", path); - return await getStat(this.#root, path); - } - - /** Materializes the requested file snapshot or byte range. */ - async readFile(path: PathType, options: AdapterReadOptionsType = {}): Promise { - throwIfAborted(options.signal, "read", path); - const file = await (await getFile(this.#root, path)).getFile(); - return new Uint8Array(await new Response(getStream(file, options)).arrayBuffer()); - } - - /** Streams the requested immutable snapshot or range without facade buffering. */ - async openReadStream(path: PathType, options: AdapterReadOptionsType = {}): Promise> { - throwIfAborted(options.signal, "read", path); - return getStream(await (await getFile(this.#root, path)).getFile(), options); - } - - /** Writes one materialized buffer through OPFS commit-on-close staging. */ - async writeFile(path: PathType, data: Uint8Array, options: AdapterWriteOptionsType): Promise { - await writeToNative(await getFile(this.#root, path, true), toByteStream(data), options, path); - } - - /** Streams bytes through the browser's native staged writable. */ - async writeStream(path: PathType, source: ReadableStream, options: AdapterWriteOptionsType): Promise { - await writeToNative(await getFile(this.#root, path, true), source, options, path); - } - - /** Lazily yields direct native children while honoring cancellation between entries. */ - async *readDir(path: PathType, options: AdapterSignalOptionsType = {}): AsyncIterableIterator { - throwIfAborted(options.signal, "read-dir", path); - const directory = await getDirectory(this.#root, path); - for await (const [name, handle] of directory.entries()) { - throwIfAborted(options.signal, "read-dir", path); - yield { name, kind: handle.kind }; - } - } - - /** Creates one direct native directory after facade parent resolution. */ - async createDir(path: PathType, options: AdapterSignalOptionsType = {}): Promise { - throwIfAborted(options.signal, "mkdir", path); - const parent = await getDirectory(this.#root, dirname(path)); - await parent.getDirectoryHandle(basename(path), { create: true }); - } - - /** Removes one direct child. Recursive removal is owned by the facade. */ - async remove(path: PathType, options: AdapterSignalOptionsType = {}): Promise { - throwIfAborted(options.signal, "remove", path); - const parent = await getDirectory(this.#root, dirname(path)); - await parent.removeEntry(basename(path)); - } - - /** Opens one staged positional writable and transfers its lifetime to the wrapper. */ - async openWritableFile(path: PathType): Promise { - const handle = await getFile(this.#root, path, true); - const writable = await handle.createWritable({ keepExistingData: true }); - return new OpfsWritableFile(path, writable); - } - - /** Opens worker-only synchronous access when the native handle exposes it. */ - async openSyncFile(path: PathType): Promise { - const handle = await getFile(this.#root, path); - if (handle.createSyncAccessHandle === undefined) { - throw new FileSystemError( - "not-supported", - "open-sync-file", - path, - "This browser context does not expose createSyncAccessHandle().", - ); - } - return await handle.createSyncAccessHandle(); - } -} - -/** - * Creates an adapter over an already acquired native OPFS root. - * - * The adapter borrows `root`; disposing the facade cannot close browser storage - * because the File System API has no root-close operation. `nativeRoot` remains - * available when advanced browser code must interoperate with the actual handle. - * - * @example Wrap an already acquired native root. - * ```ts - * const root = await navigator.storage.getDirectory(); - * const fs = createFileSystem(createOpfsAdapter(root)); - * await fs.writeFile("/state.json", "{}", { parents: true }); - * ``` - */ +/** Creates the thin OPFS adapter over an already acquired native root. */ export function createOpfsAdapter(root: FileSystemDirectoryHandle): OpfsAdapterType { - return defineAdapter(new OpfsAdapter(root)); + const driver = createOpfsDriver(root); + const adapter = createFileAdapter(driver) as OpfsAdapterType; + Object.defineProperty(adapter, "nativeRoot", { value: root, enumerable: true }); + return adapter; } /** * Opens the current origin-private filesystem and returns the portable facade. * - * Importing this module performs no storage access. The browser root is acquired - * only when this function runs, so unsupported, private, or opaque contexts fail - * at the call site. The implementation probes API exposure rather than guessing - * from a browser name or private-mode label. - * - * @example Open native OPFS from a secure browser context. - * ```ts - * const fs = await openFileSystem({ coordination: "auto" }); - * await fs.ensureDir("/cache"); - * ``` + * Storage access starts only when this function runs. Unsupported or denied + * contexts fail at the call site and preserve the native cause. */ export async function openFileSystem(options: OpenFileSystemOptionsType = {}): Promise { const navigatorValue = Reflect.get(globalThis, "navigator") as @@ -370,7 +39,6 @@ export async function openFileSystem(options: OpenFileSystemOptionsType = {}): P "navigator.storage.getDirectory() is unavailable in this context.", ); } - try { const root = await navigatorValue.storage.getDirectory(); return createFileSystem(createOpfsAdapter(root), options); diff --git a/src/adapter/record.ts b/src/adapter/record.ts index ed110c5..0adcca8 100644 --- a/src/adapter/record.ts +++ b/src/adapter/record.ts @@ -1,86 +1,34 @@ import { decodeBase64, encodeBase64 } from "@std/encoding/base64"; -import type { - AdapterDirectoryEntryType, - AdapterReadOptionsType, - AdapterSignalOptionsType, - AdapterStatType, - AdapterType, - AdapterWriteOptionsType, -} from "./definition.ts"; +import type { AdapterType } from "./definition.ts"; import { defineAdapter } from "./definition.ts"; +import type { + FileDriverDirectoryEntryType, + FileDriverReadOptionsType, + FileDriverSignalOptionsType, + FileDriverStatType, + FileDriverWriteOptionsType, +} from "../driver/file.ts"; import { FileSystemError, throwIfAborted } from "../error.ts"; -import { basename, dirname, ROOT_PATH, type PathType } from "../path.ts"; +import { basename, dirname, type PathType, ROOT_PATH } from "../path.ts"; import { - RecordSchema, type AdapterLimitsType, type AdapterPartitionType, type DirectoryRecordType, type FileRecordType, + RecordSchema, type RecordType, type WriteModeType, } from "../schema.ts"; -/** Metadata returned during direct-child listing without requiring file-body materialization. */ -export type RecordListType = DirectoryRecordType | Omit; - -/** Optional byte lanes a value store can expose without abandoning the record contract. */ -export interface RecordStoreCapabilitiesType { - /** `readFile()` can fetch only the requested range instead of loading the complete logical value. */ - readonly rangeRead?: boolean; - /** `openReadStream()` can preserve producer backpressure without materializing the complete logical value. */ - readonly streamRead?: boolean; - /** Write modes that `writeFile()` handles directly instead of rebuilding a base64 record in the generic adapter. */ - readonly writeModes?: readonly WriteModeType[]; - /** Write modes that `writeStream()` can commit without facade materialization. */ - readonly streamWriteModes?: readonly WriteModeType[]; -} - -/** - * Persistence contract used by value/document/SQL ecosystem bridges. - * - * The required methods describe one complete logical record. That keeps simple - * document and SQL integrations small. Stores with a more capable physical - * layout can additionally expose metadata-only stat, byte ranges, streams, and - * selected direct write modes. The record adapter advertises those lanes through - * normal `AdapterType` capabilities, so a third-party store can become faster - * without reimplementing recursive filesystem behavior. - * - * `list(parent)` returns direct children only. Stores own indexing choices. - * The filesystem adapter borrows the store unless a store implementation says - * otherwise through its own creation options. - */ -export interface RecordStoreType { - /** Optional native byte-lane declarations. Missing fields mean the generic complete-record path is used. */ - readonly capabilities?: RecordStoreCapabilitiesType; - /** Returns one complete record by canonical path, or null when absent. */ - get(path: PathType): Promise; - /** Returns metadata without requiring a file body when the store can do so. */ - stat?(path: PathType): Promise; - /** Reads bytes directly when the physical layout can avoid complete-record decode/materialization. */ - readFile?(path: PathType, options?: AdapterReadOptionsType): Promise; - /** Opens a native logical-byte stream when `capabilities.streamRead` is true. */ - openReadStream?(path: PathType, options?: AdapterReadOptionsType): Promise>; - /** Commits materialized bytes directly for modes listed in `capabilities.writeModes`. */ - writeFile?(path: PathType, data: Uint8Array, options: AdapterWriteOptionsType): Promise; - /** Commits a byte stream directly for modes listed in `capabilities.streamWriteModes`. */ - writeStream?(path: PathType, source: ReadableStream, options: AdapterWriteOptionsType): Promise; - /** Atomically replaces one logical record as far as the backend permits. */ - set(record: RecordType): Promise; - /** Deletes one record. The record adapter removes descendants separately. */ - delete(path: PathType): Promise; - /** Lazily returns direct child metadata without requiring a file body. */ - list(parent: PathType): AsyncIterableIterator; - /** Releases resources explicitly owned by this store. */ - dispose?(): void | Promise; -} +import type { RecordDriverType, RecordListType } from "../driver/record.ts"; -/** Options for a filesystem adapter created from a record store. */ +/** Options for a filesystem adapter created from a record driver. */ export interface RecordAdapterOptionsType { /** Diagnostic adapter name. Defaults to `record`. */ readonly name?: string; - /** Disposes the record store when the adapter is disposed. */ - readonly disposeStore?: boolean; + /** Disposes the record driver when the adapter is disposed. */ + readonly disposeDriver?: boolean; /** Rejects every mutating operation. Useful for read-only unstorage drivers. */ readonly readOnly?: boolean; /** Portable hard limits known by the underlying value store. */ @@ -112,7 +60,7 @@ function applyWrite( return output; } -/** Fails before touching a record store when the adapter was intentionally opened read-only. */ +/** Fails before touching a record driver when the adapter was intentionally opened read-only. */ function assertWritable(readOnly: boolean, operation: string, path: string): void { if (readOnly) { throw new FileSystemError( @@ -125,7 +73,7 @@ function assertWritable(readOnly: boolean, operation: string, path: string): voi } /** - * Filesystem primitive adapter over one value-oriented record store. + * Filesystem primitive adapter over one value-oriented record driver. * * The class deliberately owns no recursive filesystem behavior. It translates * the primitive adapter operations to complete record reads and replacements, @@ -141,36 +89,36 @@ function assertWritable(readOnly: boolean, operation: string, path: string): voi class RecordAdapter implements AdapterType { /** Diagnostic adapter identity exposed through the public facade. */ readonly name: string; - /** Native capabilities of a value-oriented record store. */ + /** Native capabilities of a value-oriented record driver. */ readonly capabilities; /** Portable hard limits inherited from the underlying value store. */ readonly limits?: AdapterLimitsType; /** Physical partition layout inherited from the underlying value store. */ readonly partition?: AdapterPartitionType; /** Store that owns durable record persistence. */ - readonly #store: RecordStoreType; + readonly driver: RecordDriverType; /** Prevents all mutation when the upstream storage is read-only. */ readonly #readOnly: boolean; - /** Whether adapter disposal also disposes the injected store. */ - readonly #disposeStore: boolean; + /** Whether adapter disposal also disposes the injected driver. */ + readonly #disposeDriver: boolean; /** Resolves immutable adapter policy once instead of closing over factory locals. */ - constructor(store: RecordStoreType, options: RecordAdapterOptionsType) { - this.#store = store; + constructor(driver: RecordDriverType, options: RecordAdapterOptionsType) { + this.driver = driver; this.#readOnly = options.readOnly ?? false; - this.#disposeStore = options.disposeStore ?? false; + this.#disposeDriver = options.disposeDriver ?? false; this.name = options.name ?? "record"; if (options.limits !== undefined) this.limits = options.limits; if (options.partition !== undefined) this.partition = options.partition; - const streamWriteModes = this.#readOnly || store.writeStream === undefined + const streamWriteModes = this.#readOnly || !driver.capabilities.write || driver.writeStream === undefined ? [] - : [...(store.capabilities?.streamWriteModes ?? [])]; + : [...(driver.capabilities?.streamWriteModes ?? [])]; this.capabilities = { read: true, - write: !this.#readOnly, - streamRead: store.capabilities?.streamRead === true && store.openReadStream !== undefined, + write: !this.#readOnly && driver.capabilities.write, + streamRead: driver.capabilities?.streamRead === true && driver.openReadStream !== undefined, streamWriteModes, - rangeRead: store.capabilities?.rangeRead === true && store.readFile !== undefined, + rangeRead: driver.capabilities?.rangeRead === true && driver.readFile !== undefined, nativeCopy: false, nativeMove: false, positionalWrite: false, @@ -179,21 +127,21 @@ class RecordAdapter implements AdapterType { } /** Returns portable metadata without materializing file bytes. */ - async stat(path: PathType, options: AdapterSignalOptionsType = {}): Promise { + async stat(path: PathType, options: FileDriverSignalOptionsType = {}): Promise { throwIfAborted(options.signal, "stat", path); if (path === ROOT_PATH) return { kind: "directory" }; - const record = this.#store.stat === undefined ? await this.#store.get(path) : await this.#store.stat(path); + const record = this.driver.stat === undefined ? await this.driver.get(path) : await this.driver.stat(path); if (record === null) return null; if (record.kind === "directory") return { kind: "directory", lastModified: record.lastModified }; return { kind: "file", size: record.size, lastModified: record.lastModified, mediaType: record.mediaType }; } /** Decodes one file record and slices the requested byte range in memory. */ - async readFile(path: PathType, options: AdapterReadOptionsType = {}): Promise { + async readFile(path: PathType, options: FileDriverReadOptionsType = {}): Promise { throwIfAborted(options.signal, "read", path); - if (this.#store.readFile !== undefined) return await this.#store.readFile(path, options); - const record = await this.#store.get(path); + if (this.driver.readFile !== undefined) return await this.driver.readFile(path, options); + const record = await this.driver.get(path); if (record === null) throw new FileSystemError("not-found", "read", path, `File '${path}' does not exist.`); if (record.kind !== "file") throw new FileSystemError("type-mismatch", "read", path, `'${path}' is a directory.`); @@ -209,12 +157,12 @@ class RecordAdapter implements AdapterType { * This method is intentionally materialized. The facade applies * `maxBufferedWriteBytes` before it calls this adapter with a streamed source. */ - async writeFile(path: PathType, data: Uint8Array, options: AdapterWriteOptionsType): Promise { + async writeFile(path: PathType, data: Uint8Array, options: FileDriverWriteOptionsType): Promise { assertWritable(this.#readOnly, "write", path); throwIfAborted(options.signal, "write", path); - if (this.#store.writeFile !== undefined && this.#store.capabilities?.writeModes?.includes(options.mode)) { - await this.#store.writeFile(path, data, options); + if (this.driver.writeFile !== undefined && this.driver.capabilities?.writeModes?.includes(options.mode)) { + await this.driver.writeFile(path, data, options); return; } @@ -222,9 +170,9 @@ class RecordAdapter implements AdapterType { // preservation. Append/update need the complete prior file image. Keeping // those paths separate prevents metadata-only stores from reassembling a // partitioned body solely to replace it. - const previous = options.mode === "replace" && this.#store.stat !== undefined - ? await this.#store.stat(path) - : await this.#store.get(path); + const previous = options.mode === "replace" && this.driver.stat !== undefined + ? await this.driver.stat(path) + : await this.driver.get(path); if (previous?.kind === "directory") { throw new FileSystemError("type-mismatch", "write", path, `'${path}' is a directory.`); } @@ -232,10 +180,10 @@ class RecordAdapter implements AdapterType { const existing = options.mode === "replace" ? new Uint8Array() : previous?.kind === "file" && "data" in previous && typeof previous.data === "string" - ? decodeBase64(previous.data) - : new Uint8Array(); + ? decodeBase64(previous.data) + : new Uint8Array(); const bytes = applyWrite(existing, data, options.mode, options.at, options.truncate ?? false); - await this.#store.set(RecordSchema.parse({ + await this.driver.set(RecordSchema.parse({ version: 1, path, parent: dirname(path), @@ -249,47 +197,59 @@ class RecordAdapter implements AdapterType { } /** Opens the store's byte stream only when its declared stream-read capability is active. */ - async openReadStream(path: PathType, options: AdapterReadOptionsType = {}): Promise> { - if (!this.capabilities.streamRead || this.#store.openReadStream === undefined) { - throw new FileSystemError("not-supported", "read", path, `Record store '${this.name}' does not expose streaming reads.`); + async openReadStream(path: PathType, options: FileDriverReadOptionsType = {}): Promise> { + if (!this.capabilities.streamRead || this.driver.openReadStream === undefined) { + throw new FileSystemError( + "not-supported", + "read", + path, + `Record driver '${this.driver.name}' does not expose streaming reads.`, + ); } - return await this.#store.openReadStream(path, options); + return await this.driver.openReadStream(path, options); } - /** Commits a native record-store stream for the write modes the store explicitly advertises. */ - async writeStream(path: PathType, source: ReadableStream, options: AdapterWriteOptionsType): Promise { + /** Commits a native record-driver stream for the write modes the store explicitly advertises. */ + async writeStream( + path: PathType, + source: ReadableStream, + options: FileDriverWriteOptionsType, + ): Promise { assertWritable(this.#readOnly, "write", path); - if (!this.capabilities.streamWriteModes.includes(options.mode) || this.#store.writeStream === undefined) { + if (!this.capabilities.streamWriteModes.includes(options.mode) || this.driver.writeStream === undefined) { await source.cancel().catch(() => undefined); throw new FileSystemError( "not-supported", "write", path, - `Record store '${this.name}' does not expose streaming ${options.mode} writes.`, + `Record driver '${this.driver.name}' does not expose streaming ${options.mode} writes.`, ); } - await this.#store.writeStream(path, source, options); + await this.driver.writeStream(path, source, options); } /** Lazily projects direct child records to adapter directory entries. */ - async *readDir(path: PathType, options: AdapterSignalOptionsType = {}): AsyncIterableIterator { + async *readDir( + path: PathType, + options: FileDriverSignalOptionsType = {}, + ): AsyncIterableIterator { throwIfAborted(options.signal, "read-dir", path); - for await (const record of this.#store.list(path)) { + for await (const record of this.driver.list(path)) { throwIfAborted(options.signal, "read-dir", path); yield { name: record.name, kind: record.kind }; } } /** Creates one empty directory record when the path is not already present. */ - async createDir(path: PathType, options: AdapterSignalOptionsType = {}): Promise { + async createDir(path: PathType, options: FileDriverSignalOptionsType = {}): Promise { assertWritable(this.#readOnly, "mkdir", path); throwIfAborted(options.signal, "mkdir", path); - const existing = this.#store.stat === undefined ? await this.#store.get(path) : await this.#store.stat(path); + const existing = this.driver.stat === undefined ? await this.driver.get(path) : await this.driver.stat(path); if (existing?.kind === "file") throw new FileSystemError("type-mismatch", "mkdir", path, `'${path}' is a file.`); if (existing !== null) return; - await this.#store.set(RecordSchema.parse({ + await this.driver.set(RecordSchema.parse({ version: 1, path, parent: dirname(path), @@ -300,20 +260,20 @@ class RecordAdapter implements AdapterType { } /** Removes one exact record after preserving the configured read-only policy. */ - async remove(path: PathType, options: AdapterSignalOptionsType = {}): Promise { + async remove(path: PathType, options: FileDriverSignalOptionsType = {}): Promise { assertWritable(this.#readOnly, "remove", path); throwIfAborted(options.signal, "remove", path); - await this.#store.delete(path); + await this.driver.delete(path); } - /** Disposes the injected store only when ownership was explicitly transferred. */ + /** Disposes the injected driver only when ownership was explicitly transferred. */ async dispose(): Promise { - if (this.#disposeStore) await this.#store.dispose?.(); + if (this.#disposeDriver) await this.driver.dispose?.(); } } /** - * Creates a filesystem adapter over a generic record store. + * Creates a filesystem adapter over a generic record driver. * * This is the common implementation used by RxDB, unstorage, db0, Drizzle, * Deno KV, browser storage, and other value-oriented backends. The factory @@ -326,6 +286,6 @@ class RecordAdapter implements AdapterType { * await fs.writeFile("/state.json", "{}", { parents: true }); * ``` */ -export function createRecordAdapter(store: RecordStoreType, options: RecordAdapterOptionsType = {}): AdapterType { - return defineAdapter(new RecordAdapter(store, options)); +export function createRecordAdapter(driver: RecordDriverType, options: RecordAdapterOptionsType = {}): AdapterType { + return defineAdapter(new RecordAdapter(driver, options)); } diff --git a/src/adapter/rxdb.ts b/src/adapter/rxdb.ts index 13f1d89..21b5a41 100644 --- a/src/adapter/rxdb.ts +++ b/src/adapter/rxdb.ts @@ -1,195 +1,29 @@ import type { AdapterType } from "./definition.ts"; -import { createRecordAdapter, type RecordStoreType } from "./record.ts"; -import { FileSystemError } from "../error.ts"; -import { RecordSchema, type RecordType } from "../schema.ts"; +import { createRecordAdapter } from "./record.ts"; +import { + createRxDbDriver, + type RxDbCollectionType, + type RxDbDocumentType, + type RxDbQueryType, + RxDbRecordJsonSchema, +} from "../driver/rxdb.ts"; -/** - * Maximum canonical path length encoded in the exported RxDB primary/index schema. - * - * RxDB requires `maxLength` on indexed string fields. Keeping one constant makes - * runtime validation and the exported collection schema agree exactly. - */ -const RXDB_PATH_MAX_LENGTH = 4096; - -/** RxDB document methods used by the bridge. */ -export interface RxDbDocumentType { - /** Returns document data without RxDB revision metadata. */ - toJSON(withRevisionAndAttachments?: false): unknown; - /** Removes the latest revision safely when concurrent writes are possible. */ - incrementalRemove(): Promise; -} - -/** RxDB query result shape used by the bridge. */ -export interface RxDbQueryType { - /** Executes the query against the collection's configured RxStorage. */ - exec(): Promise; -} - -/** - * Structural subset of RxCollection used by this adapter. - * - * RxDB collections sit above `RxStorage`, so the same bridge works when the - * database was created with memory, IndexedDB, OPFS, filesystem, SQLite, - * DenoKV, MongoDB, or another compatible RxStorage implementation. - */ -export interface RxDbCollectionType { - /** Finds one document by its primary `path`. */ - findOne(primary: string): RxDbQueryType; - /** Finds records by the indexed `parent` field. */ - find(query: { readonly selector: { readonly parent: string } }): RxDbQueryType; - /** Inserts or incrementally replaces one path record. */ - incrementalUpsert(record: RecordType): Promise; -} - -/** - * RxJSONSchema to use for the collection supplied to {@link createRxDbAdapter}. - * - * `path` is the primary key and `parent` is indexed because directory reads are - * direct-parent queries. File bytes remain base64 strings to keep documents - * structured-cloneable across every RxStorage transport. - */ -export const RxDbRecordJsonSchema = Object.freeze({ - title: "OPFS filesystem record", - description: "One canonical file or directory record used by the @okikio/opfs RxDB adapter.", - version: 0, - primaryKey: "path", - type: "object", - properties: { - version: { - type: "number", - description: "@okikio/opfs record format version. This is independent of the RxDB schema version.", - minimum: 1, - maximum: 1, - multipleOf: 1, - }, - path: { - type: "string", - description: "Canonical virtual filesystem path and RxDB primary key.", - maxLength: RXDB_PATH_MAX_LENGTH, - }, - parent: { - type: "string", - description: "Canonical direct-parent path used for directory listing queries.", - maxLength: RXDB_PATH_MAX_LENGTH, - }, - name: { - type: "string", - description: "Final file or directory name without parent path components.", - }, - kind: { - type: "string", - description: "Filesystem entry discriminator.", - enum: ["file", "directory"], - }, - data: { - type: "string", - description: "Base64 file bytes. Required only when kind is file.", - }, - size: { - type: "number", - description: "Decoded file byte length. Required only when kind is file.", - minimum: 0, - multipleOf: 1, - }, - lastModified: { - type: "number", - description: "Last-modified Unix epoch milliseconds.", - minimum: 0, - multipleOf: 1, - }, - mediaType: { - type: "string", - description: "File media type, or an empty string when unknown. Required only when kind is file.", - }, - }, - required: ["version", "path", "parent", "name", "kind", "lastModified"], - oneOf: [ - { - properties: { kind: { enum: ["directory"] } }, - required: ["kind"], - }, - { - properties: { kind: { enum: ["file"] } }, - required: ["kind", "data", "size", "mediaType"], - }, - ], - indexes: ["parent"], -} as const); +/** RxDB collection schema used by the OPFS record driver. */ +export { RxDbRecordJsonSchema }; -/** Rejects paths that the exported RxDB indexed-string schema cannot store. */ -function assertRxDbPath(path: string): void { - if (path.length <= RXDB_PATH_MAX_LENGTH) return; - throw new FileSystemError( - "invalid-path", - "rxdb", - path, - `RxDB adapter paths cannot exceed ${RXDB_PATH_MAX_LENGTH} characters.`, - ); -} - -/** - * Record-store projection over one RxDB collection. - * - * RxDB remains authority for revisions, conflicts, replication, and the - * selected `RxStorage`. This class only maps the filesystem record identity to - * collection queries and uses incremental document operations so it does not - * bypass RxDB concurrency semantics. - */ -class RxDbRecordStore implements RecordStoreType { - /** Collection borrowed from the caller. */ - readonly #collection: RxDbCollectionType; - - /** Binds one prepared collection that uses {@link RxDbRecordJsonSchema}. */ - constructor(collection: RxDbCollectionType) { - this.#collection = collection; - } - - /** Reads one primary-key document and validates its filesystem shape. */ - async get(path: Parameters[0]) { - assertRxDbPath(path); - const document = await this.#collection.findOne(path).exec(); - return document === null ? null : RecordSchema.parse(document.toJSON()); - } - - /** Incrementally inserts or replaces one path record. */ - async set(record: RecordType): Promise { - assertRxDbPath(record.path); - assertRxDbPath(record.parent); - await this.#collection.incrementalUpsert(record); - } - - /** Removes the latest revision of one path when it exists. */ - async delete(path: Parameters[0]): Promise { - assertRxDbPath(path); - const document = await this.#collection.findOne(path).exec(); - if (document !== null) await document.incrementalRemove(); - } - - /** Queries the indexed parent field and yields validated direct children. */ - async *list(parent: Parameters[0]) { - assertRxDbPath(parent); - const documents = await this.#collection.find({ selector: { parent } }).exec(); - for (const document of documents) yield RecordSchema.parse(document.toJSON()); - } -} - -/** - * Creates the record-store projection over an existing RxDB collection. - * - * The collection remains caller-owned. Reads validate every document through - * {@link RecordSchema}; writes retain RxDB's incremental revision semantics. - */ -export function createRxDbRecordStore(collection: RxDbCollectionType): RecordStoreType { - return new RxDbRecordStore(collection); -} +/** RxDB collection, document, and query contracts consumed by the driver. */ +export type { RxDbCollectionType, RxDbDocumentType, RxDbQueryType }; /** - * Creates an OPFS-shaped adapter over an RxDB collection. + * Creates the OPFS primitive translation over one RxDB collection driver. * - * The collection is borrowed. Closing this adapter never closes the RxDatabase - * because the database can own many unrelated collections and replications. - * The collection must use {@link RxDbRecordJsonSchema}. + * The adapter owns the driver wrapper, but the driver borrows the injected + * collection. Closing the filesystem therefore does not close the caller's + * RxDB database or collection. */ export function createRxDbAdapter(collection: RxDbCollectionType): AdapterType { - return createRecordAdapter(createRxDbRecordStore(collection), { name: "rxdb" }); + return createRecordAdapter(createRxDbDriver(collection), { + name: "rxdb", + disposeDriver: true, + }); } diff --git a/src/adapter/unstorage.ts b/src/adapter/unstorage.ts index 5bde3cd..46fcfbe 100644 --- a/src/adapter/unstorage.ts +++ b/src/adapter/unstorage.ts @@ -1,163 +1,31 @@ import type { AdapterType } from "./definition.ts"; -import { createRecordAdapter, type RecordStoreType } from "./record.ts"; -import { normalizePath, splitPath, type PathType } from "../path.ts"; -import { RecordSchema } from "../schema.ts"; +import { createRecordAdapter } from "./record.ts"; +import { + createUnstorageDriver, + type UnstorageDriverOptionsType, + type UnstorageStorageType, +} from "../driver/unstorage.ts"; -/** - * Structural subset of unstorage's current `Storage` API used by this adapter. - * - * The bridge intentionally depends on the high-level Storage object, not a - * specific driver. A storage created with memory, IndexedDB, Redis, S3, db0, - * Cloudflare, filesystem, or another current unstorage driver can therefore be - * supplied without a second adapter implementation. - */ -export interface UnstorageStorageType { - /** Reads one decoded storage value. */ - getItem(key: string, options?: Record): Promise; - /** Stores one serializable value. */ - setItem(key: string, value: T, options?: Record): Promise; - /** Removes one key. */ - removeItem(key: string, options?: Record | boolean): Promise; - /** Lists keys below an optional base key. */ - getKeys(base?: string, options?: Record): Promise; - /** Releases mounted drivers owned by the Storage object. */ - dispose?(): Promise; -} +/** Options forwarded to the unstorage-backed record driver. */ +export type UnstorageAdapterOptionsType = UnstorageDriverOptionsType; -/** Options for the unstorage-backed filesystem adapter. */ -export interface UnstorageAdapterOptionsType { - /** Key prefix reserved for filesystem records. Defaults to `opfs`. */ - readonly prefix?: string; - /** Prevents all mutations. Useful with read-only HTTP/GitHub drivers. */ - readonly readOnly?: boolean; - /** Disposes the injected Storage object when the filesystem closes. */ - readonly disposeStorage?: boolean; -} - -/** Encodes one virtual path name into an unstorage key segment without `:` or `%`. */ -function encodeSegment(value: string): string { - return encodeURIComponent(value).replace(/~/g, "%7E").replace(/%/g, "~"); -} - -/** Reverses {@link encodeSegment} for keys owned by this adapter. */ -function decodeSegment(value: string): string { - return decodeURIComponent(value.replace(/~/g, "%")); -} - -/** Removes trailing unstorage separators while retaining a non-empty namespace. */ -function normalizePrefix(prefix: string): string { - return prefix.replace(/:+$/g, "") || "opfs"; -} - -/** Maps one canonical virtual path to the private unstorage record namespace. */ -function getKey(prefix: string, path: string): string { - const parts = splitPath(path); - return parts.length === 0 ? `${prefix}:entry` : `${prefix}:entry:${parts.map(encodeSegment).join(":")}`; -} - -/** Maps an adapter-owned unstorage key back to a canonical path, or null for foreign keys. */ -function getPath(prefix: string, key: string): PathType | null { - const base = `${prefix}:entry`; - if (key === base) return "/"; - if (!key.startsWith(`${base}:`)) return null; - const encoded = key.slice(base.length + 1).split(":"); - return normalizePath(encoded.map(decodeSegment).join("/")); -} - -/** - * Record-store projection over any compatible unstorage `Storage` instance. - * - * The class targets the high-level Storage surface rather than one driver. - * Driver-specific replication, retries, durability, limits, and provider SDKs - * remain owned by unstorage and the selected driver. - */ -class UnstorageRecordStore implements RecordStoreType { - /** unstorage Storage borrowed from or transferred by the caller. */ - readonly #storage: UnstorageStorageType; - /** Reserved unstorage namespace for filesystem records. */ - readonly #prefix: string; - /** Whether disposal also disposes the injected Storage. */ - readonly #disposeStorage: boolean; - - /** Resolves namespace and ownership policy once. */ - constructor(storage: UnstorageStorageType, options: UnstorageAdapterOptionsType) { - this.#storage = storage; - this.#prefix = normalizePrefix(options.prefix ?? "opfs"); - this.#disposeStorage = options.disposeStorage ?? false; - } - - /** Reads and validates one exact unstorage record. */ - async get(path: PathType) { - const value = await this.#storage.getItem(getKey(this.#prefix, path)); - return value === null ? null : RecordSchema.parse(value); - } - - /** Replaces one exact unstorage record. */ - async set(record: Parameters[0]): Promise { - await this.#storage.setItem(getKey(this.#prefix, record.path), record); - } - - /** Removes one exact unstorage record. */ - async delete(path: PathType): Promise { - await this.#storage.removeItem(getKey(this.#prefix, path)); - } - - /** - * Lists direct children below one encoded directory key. - * - * `maxDepth: 1` is an optional upstream optimization. Correctness still comes - * from the explicit path-depth filter because not every unstorage driver - * advertises or honors the same listing acceleration. - */ - async *list(parent: PathType) { - const parentDepth = splitPath(parent).length; - const keys = await this.#storage.getKeys(getKey(this.#prefix, parent), { maxDepth: 1 }); - for (const storageKey of keys) { - const path = getPath(this.#prefix, storageKey); - if (path === null || path === parent || splitPath(path).length !== parentDepth + 1) continue; - const value = await this.#storage.getItem(storageKey); - if (value !== null) yield RecordSchema.parse(value); - } - } - - /** Disposes the injected Storage only when ownership was explicitly transferred. */ - async dispose(): Promise { - if (this.#disposeStorage) await this.#storage.dispose?.(); - } -} - -/** - * Creates a record store over any unstorage `Storage` instance. - * - * @example Reserve one key namespace for filesystem records. - * ```ts - * const store = createUnstorageRecordStore(storage, { - * prefix: "application-fs", - * disposeStorage: false, - * }); - * ``` - */ -export function createUnstorageRecordStore( - storage: UnstorageStorageType, - options: UnstorageAdapterOptionsType = {}, -): RecordStoreType { - return new UnstorageRecordStore(storage, options); -} +/** High-level unstorage `Storage` contract consumed by the record driver. */ +export type { UnstorageStorageType }; /** - * Creates an OPFS-shaped filesystem adapter backed by unstorage. + * Creates the OPFS primitive translation over an injected unstorage `Storage`. * - * The Storage object is borrowed unless `disposeStorage` is true. Individual - * upstream driver limits still apply and are not replaced by the filesystem - * facade. + * The driver targets the high-level Storage contract, so the caller can choose + * any unstorage backend that satisfies the required semantics. The injected + * Storage remains borrowed unless `disposeStorage` is enabled. */ export function createUnstorageAdapter( storage: UnstorageStorageType, options: UnstorageAdapterOptionsType = {}, ): AdapterType { - return createRecordAdapter(createUnstorageRecordStore(storage, options), { + return createRecordAdapter(createUnstorageDriver(storage, options), { name: "unstorage", readOnly: options.readOnly ?? false, - disposeStore: true, + disposeDriver: true, }); } diff --git a/src/bridge.ts b/src/bridge.ts index 90f546f..333bda5 100644 --- a/src/bridge.ts +++ b/src/bridge.ts @@ -1,6 +1,2 @@ -export * from "./bridge/definition.ts"; -export * from "./bridge/db0.ts"; -export * from "./bridge/drizzle.ts"; export * from "./bridge/kv.ts"; -export * from "./bridge/rxdb.ts"; export * from "./bridge/unstorage.ts"; diff --git a/src/bridge/db0.ts b/src/bridge/db0.ts deleted file mode 100644 index e4a9f22..0000000 --- a/src/bridge/db0.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { - createDb0Adapter, - type Db0AdapterOptionsType, - type Db0DatabaseType, -} from "../adapter/db0.ts"; -import { defineBridge, type BridgeType } from "./definition.ts"; - -/** db0 currently supplies SQL persistence to OPFS; OPFS does not pretend to be a SQL database. */ -export const Db0Bridge: BridgeType = defineBridge({ - name: "db0", - directions: { - toOpfs: { supported: true }, - fromOpfs: { supported: false, reason: "A filesystem does not provide db0 SQL query and dialect semantics." }, - }, - toOpfs: createDb0Adapter, -}); diff --git a/src/bridge/definition.ts b/src/bridge/definition.ts deleted file mode 100644 index 297a942..0000000 --- a/src/bridge/definition.ts +++ /dev/null @@ -1,71 +0,0 @@ -import { z } from "zod"; - -import type { AdapterType } from "../adapter/definition.ts"; -import type { FileSystemType } from "../filesystem.ts"; -import { AdapterNameSchema } from "../schema.ts"; - -/** Support declaration for one bridge direction. */ -export const BridgeDirectionSchema = z.object({ - /** Whether the direction has a real constructor. */ - supported: z.boolean(), - /** Concrete reason when the direction is intentionally unsupported. */ - reason: z.string().min(1).optional(), -}).strict().superRefine((value, ctx) => { - if (!value.supported && value.reason === undefined) { - ctx.addIssue({ code: "custom", message: "Unsupported bridge directions require a reason." }); - } -}); - -/** A validated bridge-direction support declaration. */ -export type BridgeDirectionType = z.output; - -/** Directions one ecosystem integration can expose. */ -export const BridgeDirectionsSchema = z.object({ - /** Ecosystem/native resource projected into the OPFS filesystem model. */ - toOpfs: BridgeDirectionSchema, - /** OPFS filesystem projected back into the ecosystem's expected contract. */ - fromOpfs: BridgeDirectionSchema, -}).strict(); - -/** Validated bridge direction declaration. */ -export type BridgeDirectionsType = z.output; - -/** - * Paired integration descriptor for ecosystems that support one or both directions. - * - * An adapter remains the primitive `ecosystem -> OPFS` translation and a driver - * remains the ecosystem-shaped `OPFS -> ecosystem` translation. A bridge does - * not replace either contract. It groups the two constructors so support can be - * inspected and extended as one coherent integration. - */ -export interface BridgeType { - /** Stable integration name. */ - readonly name: string; - /** Directions implemented without inventing unsupported synchronous semantics. */ - readonly directions: BridgeDirectionsType; - /** Projects an ecosystem/native resource into an OPFS adapter when supported. */ - readonly toOpfs?: (source: Source, options?: ToOptions) => AdapterType | Promise; - /** Projects an OPFS facade into the ecosystem's own contract when supported. */ - readonly fromOpfs?: (fileSystem: FileSystemType, options?: FromOptions) => Target | Promise; -} - -/** - * Validates a third-party bridge descriptor without registering global state. - * - * Direction flags and constructors must agree. This catches integrations that - * advertise a route but forget to provide its constructor, while still allowing - * honest one-way bridges for ecosystems whose other direction is impossible. - */ -export function defineBridge( - bridge: BridgeType, -): BridgeType { - AdapterNameSchema.parse(bridge.name); - const directions = BridgeDirectionsSchema.parse(bridge.directions); - if (directions.toOpfs.supported !== (bridge.toOpfs !== undefined)) { - throw new TypeError(`Bridge '${bridge.name}' toOpfs direction does not match its constructor.`); - } - if (directions.fromOpfs.supported !== (bridge.fromOpfs !== undefined)) { - throw new TypeError(`Bridge '${bridge.name}' fromOpfs direction does not match its constructor.`); - } - return bridge; -} diff --git a/src/bridge/drizzle.ts b/src/bridge/drizzle.ts deleted file mode 100644 index 1249255..0000000 --- a/src/bridge/drizzle.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { createDrizzleAdapter, type DrizzleAdapterOptionsType, type DrizzleTableType } from "../adapter/drizzle.ts"; -import { defineBridge, type BridgeType } from "./definition.ts"; - -/** Wrapper input keeps the generic bridge constructor to one source argument. */ -export interface DrizzleBridgeSourceType { - /** Connected Drizzle database and caller-owned table mapping. */ - readonly options: DrizzleAdapterOptionsType; -} - -/** Drizzle supplies persistence to OPFS; the filesystem does not emulate Drizzle query semantics. */ -export const DrizzleBridge: BridgeType = defineBridge({ - name: "drizzle", - directions: { - toOpfs: { supported: true }, - fromOpfs: { supported: false, reason: "A filesystem cannot safely emulate Drizzle schema, dialect, and query-builder semantics." }, - }, - toOpfs: (source) => createDrizzleAdapter(source.options), -}); diff --git a/src/bridge/kv.ts b/src/bridge/kv.ts index 9a661ce..fde6368 100644 --- a/src/bridge/kv.ts +++ b/src/bridge/kv.ts @@ -1,12 +1,257 @@ -import { createKeyValueDriver, type KeyValueDriverOptionsType, type KeyValueDriverType } from "../driver/kv.ts"; -import { defineBridge, type BridgeType } from "./definition.ts"; - -/** Generic reverse bridge for ecosystems that can consume asynchronous hierarchical key-value behavior. */ -export const KeyValueBridge: BridgeType = defineBridge({ - name: "kv", - directions: { - toOpfs: { supported: false, reason: "The generic reverse KV contract does not define enough persistence semantics to construct an OPFS adapter." }, - fromOpfs: { supported: true }, - }, - fromOpfs: createKeyValueDriver, -}); +import type { InspectionType } from "../capability.ts"; +import type { FileSystemType } from "../filesystem.ts"; +import type { MetricsType } from "../metrics.ts"; +import { joinPath, normalizePath, ROOT_PATH } from "../path.ts"; +import type { PlanInputType, PlanType } from "../plan.ts"; + +/** Metadata returned by the generic key-value bridge. */ +export interface KeyValueMetaType { + /** Last modification time when the filesystem exposes one. */ + readonly modified?: Date; +} + +/** Options for the reverse key-value bridge. */ +export interface KeyValueBridgeOptionsType { + /** Virtual directory that contains key data. Defaults to `/`. */ + readonly root?: string; + /** Closes the injected filesystem when the bridge closes. */ + readonly disposeFileSystem?: boolean; +} + +/** + * Minimal asynchronous key-value behavior backed by a `FileSystemType`. + * + * This contract is deliberately smaller than unstorage. It is useful for + * ecosystem drivers that need strings/raw bytes plus hierarchical key listing + * without copying the collision-safe key mapping again. + */ +export interface KeyValueBridgeType { + /** Returns the exact effective filesystem capabilities, limits, partition policy, and metrics backing this bridge. */ + inspect(): InspectionType; + /** Preflights one underlying filesystem operation without touching storage. */ + plan(input: PlanInputType): PlanType; + /** Returns current filesystem metrics without exposing mutable counters. */ + getMetrics(): MetricsType; + /** Tests whether one exact key has a value. */ + has(key: string): Promise; + /** Reads one UTF-8 string value. */ + get(key: string): Promise; + /** Replaces one UTF-8 string value. */ + set(key: string, value: string): Promise; + /** Reads raw bytes. */ + getRaw(key: string): Promise; + /** Replaces one raw value. */ + setRaw(key: string, value: string | Blob | ArrayBuffer | ArrayBufferView): Promise; + /** Removes one exact value. */ + remove(key: string): Promise; + /** Reads filesystem-backed metadata. */ + meta(key: string): Promise; + /** Lists keys below one colon-delimited hierarchy prefix. */ + keys(base?: string, options?: { readonly maxDepth?: number }): Promise; + /** Removes keys below a hierarchy prefix. */ + clear(base?: string, options?: { readonly preserveExact?: boolean }): Promise; + /** Releases explicitly transferred filesystem ownership. */ + dispose(): Promise; +} + +/** Prefix that makes bridge-owned key directories distinguishable from ordinary files. */ +const KEY_PREFIX = "key-"; +/** Leaf filename used so both `foo` and `foo:bar` can exist without file/directory collisions. */ +const VALUE_FILE = "value"; + +/** Encodes one logical key segment into one collision-free filesystem name. */ +function encodeSegment(value: string): string { + const encoded = encodeURIComponent(value).replace(/~/g, "%7E").replace(/%/g, "~"); + return `${KEY_PREFIX}${encoded}`; +} + +/** Reverses one bridge-owned directory name. */ +function decodeSegment(value: string): string | null { + if (!value.startsWith(KEY_PREFIX)) return null; + return decodeURIComponent(value.slice(KEY_PREFIX.length).replace(/~/g, "%")); +} + +/** Splits the conventional colon hierarchy used by many JavaScript KV APIs. */ +function parts(key: string): string[] { + return key.split(":").filter((part) => part.length > 0).map(encodeSegment); +} + +/** Directory that can contain both the exact value and descendant keys. */ +function directory(root: string, key: string): string { + return joinPath(root, ...parts(key)); +} + +/** Private leaf file storing one exact key value. */ +function path(root: string, key: string): string { + return joinPath(directory(root, key), VALUE_FILE); +} + +/** Converts one bridge-owned value file back to the logical key. */ +function key(root: string, value: string): string | null { + const relative = normalizePath(value).slice(root === ROOT_PATH ? 1 : root.length + 1); + if (relative.length === 0) return null; + const pathParts = relative.split("/"); + if (pathParts.pop() !== VALUE_FILE) return null; + const decoded: string[] = []; + for (const pathPart of pathParts) { + const item = decodeSegment(pathPart); + if (item === null) return null; + decoded.push(item); + } + return decoded.join(":"); +} + +/** Counts logical hierarchy separators for depth filtering. */ +function depth(value: string): number { + let count = 0; + for (const character of value) if (character === ":") count += 1; + return count; +} + +/** + * Collision-safe key-value projection over one filesystem. + * + * Each key gets a private directory with a `value` leaf. The extra level solves + * a filesystem mismatch that ordinary `key.replace(":", "/")` mappings miss: + * key-value stores can contain both `foo` and `foo:bar`, while a filesystem + * cannot make `/foo` a file and a directory at the same time. + * + * ```text + * foo -> /key-foo/value + * foo:bar -> /key-foo/key-bar/value + * ``` + * + * The class borrows the filesystem unless `disposeFileSystem` explicitly + * transfers ownership. It never configures storage, logging, or global state. + */ +class KeyValueBridgeImpl implements KeyValueBridgeType { + /** Filesystem that stores the encoded hierarchy and value leaves. */ + readonly #fileSystem: FileSystemType; + /** Canonical directory below which all key data is stored. */ + readonly #root: string; + /** Whether bridge disposal also closes the injected filesystem. */ + readonly #disposeFileSystem: boolean; + + /** Resolves stable driver policy once instead of closing over factory locals. */ + constructor(fileSystem: FileSystemType, options: KeyValueBridgeOptionsType) { + this.#fileSystem = fileSystem; + this.#root = normalizePath(options.root ?? ROOT_PATH); + this.#disposeFileSystem = options.disposeFileSystem ?? false; + } + + /** Returns the effective capability and limit report of the backing filesystem. */ + inspect(): InspectionType { + return this.#fileSystem.inspect(); + } + + /** Uses the filesystem planner so reverse ecosystem callers see the same route and size checks. */ + plan(input: PlanInputType): PlanType { + return this.#fileSystem.plan(input); + } + + /** Returns the filesystem's detached metrics snapshot. */ + getMetrics(): MetricsType { + return this.#fileSystem.getMetrics(); + } + + /** Tests whether one encoded value leaf exists as a file. */ + async has(value: string): Promise { + return await this.#fileSystem.exists(path(this.#root, value), { kind: "file" }); + } + + /** Reads one UTF-8 value without treating a missing key as a filesystem failure. */ + async get(value: string): Promise { + const file = path(this.#root, value); + return await this.#fileSystem.exists(file, { kind: "file" }) ? await this.#fileSystem.readText(file) : null; + } + + /** Replaces one UTF-8 value and creates hierarchy directories when needed. */ + async set(value: string, data: string): Promise { + await this.#fileSystem.writeFile(path(this.#root, value), data, { parents: true, mode: "replace" }); + } + + /** Reads one raw byte value without text transcoding. */ + async getRaw(value: string): Promise { + const file = path(this.#root, value); + return await this.#fileSystem.exists(file, { kind: "file" }) ? await this.#fileSystem.readFile(file) : null; + } + + /** Replaces one raw value using the filesystem's normal write-data contract. */ + async setRaw(value: string, data: string | Blob | ArrayBuffer | ArrayBufferView): Promise { + await this.#fileSystem.writeFile(path(this.#root, value), data, { parents: true, mode: "replace" }); + } + + /** Removes only the exact value leaf and leaves descendant keys intact. */ + async remove(value: string): Promise { + const file = path(this.#root, value); + if (await this.#fileSystem.exists(file, { kind: "file" })) await this.#fileSystem.remove(file); + } + + /** Projects filesystem modification time into the small KV metadata contract. */ + async meta(value: string): Promise { + const file = path(this.#root, value); + if (!(await this.#fileSystem.exists(file, { kind: "file" }))) return null; + const valueStat = await this.#fileSystem.stat(file); + return valueStat.kind === "file" ? { modified: new Date(valueStat.lastModified) } : null; + } + + /** + * Lists logical keys below one encoded hierarchy directory. + * + * Traversal remains lazy in the filesystem layer. This method materializes + * only the final key strings because the ecosystem KV contract returns an + * array rather than an iterator. + */ + async keys(base = "", options: { readonly maxDepth?: number } = {}): Promise { + const baseDirectory = directory(this.#root, base); + if (!(await this.#fileSystem.exists(baseDirectory, { kind: "directory" }))) return []; + + const output: string[] = []; + for await (const entry of this.#fileSystem.walk(baseDirectory, { includeFiles: true, includeDirectories: false })) { + const value = key(this.#root, entry.path); + if (value === null) continue; + if (options.maxDepth !== undefined && depth(value) > options.maxDepth) continue; + output.push(value); + } + return output; + } + + /** + * Removes values below one hierarchy prefix while optionally retaining the + * exact base key. + * + * `preserveExact` is needed by unstorage because `foo:` means descendants of + * `foo`, not the exact `foo` value itself. + */ + async clear(base = "", options: { readonly preserveExact?: boolean } = {}): Promise { + const baseDirectory = directory(this.#root, base); + if (!(await this.#fileSystem.exists(baseDirectory, { kind: "directory" }))) return; + if (!options.preserveExact) { + await this.#fileSystem.emptyDir(baseDirectory); + return; + } + + for await (const entry of this.#fileSystem.readDir(baseDirectory)) { + if (entry.kind === "directory") await this.#fileSystem.remove(entry.path, { recursive: true }); + } + } + + /** Closes the injected filesystem only when ownership was explicitly transferred. */ + async dispose(): Promise { + if (this.#disposeFileSystem) await this.#fileSystem.close(); + } +} + +/** + * Exposes any OPFS filesystem as a collision-safe key-value store. + * + * The factory constructs a named driver object instead of defining behavior + * methods inside the factory. This keeps the public call site small while the + * lifecycle and mapping rules remain individually documented and testable. + */ +export function createKeyValueBridge( + fileSystem: FileSystemType, + options: KeyValueBridgeOptionsType = {}, +): KeyValueBridgeType { + return new KeyValueBridgeImpl(fileSystem, options); +} diff --git a/src/bridge/rxdb.ts b/src/bridge/rxdb.ts deleted file mode 100644 index 3f09aba..0000000 --- a/src/bridge/rxdb.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { createRxDbAdapter, type RxDbCollectionType } from "../adapter/rxdb.ts"; -import { defineBridge, type BridgeType } from "./definition.ts"; - -/** RxDB currently supports collection -> OPFS; implementing RxStorage is intentionally outside this bridge. */ -export const RxDbBridge: BridgeType = defineBridge({ - name: "rxdb", - directions: { - toOpfs: { supported: true }, - fromOpfs: { supported: false, reason: "Implementing RxStorage requires RxDB query, conflict, change-stream, and cleanup contracts beyond filesystem semantics." }, - }, - toOpfs: createRxDbAdapter, -}); diff --git a/src/bridge/unstorage.ts b/src/bridge/unstorage.ts index 654f984..1e3f2b6 100644 --- a/src/bridge/unstorage.ts +++ b/src/bridge/unstorage.ts @@ -1,24 +1,186 @@ -import { - createUnstorageAdapter, - type UnstorageAdapterOptionsType, - type UnstorageStorageType, -} from "../adapter/unstorage.ts"; -import { - createUnstorageDriver, - type UnstorageDriverOptionsType, - type UnstorageDriverType, -} from "../driver/unstorage.ts"; -import { defineBridge, type BridgeType } from "./definition.ts"; - -/** Bidirectional unstorage integration using the existing adapter and reverse driver contracts. */ -export const UnstorageBridge: BridgeType< - UnstorageStorageType, - UnstorageDriverType, - UnstorageAdapterOptionsType, - UnstorageDriverOptionsType -> = defineBridge({ - name: "unstorage", - directions: { toOpfs: { supported: true }, fromOpfs: { supported: true } }, - toOpfs: createUnstorageAdapter, - fromOpfs: createUnstorageDriver, -}); +import type { InspectionType } from "../capability.ts"; +import type { FileSystemType } from "../filesystem.ts"; +import type { MetricsType } from "../metrics.ts"; +import type { PlanInputType, PlanType } from "../plan.ts"; +import { createKeyValueBridge } from "./kv.ts"; + +/** Metadata shape understood by unstorage bridges. */ +export interface UnstorageBridgeMetaType { + /** Last access time when known. */ + readonly atime?: Date; + /** Last modification time when known. */ + readonly mtime?: Date; + /** Additional provider-specific metadata. */ + readonly [key: string]: string | number | boolean | object | Date | null | undefined; +} + +/** Options accepted by unstorage bridge methods. */ +export interface UnstorageBridgeTransactionOptionsType { + /** Optional traversal depth used by getKeys. */ + readonly maxDepth?: number; + /** Additional unstorage/provider transaction options. */ + readonly [key: string]: unknown; +} + +/** Driver contract compatible with unstorage's stable Driver surface used here. */ +export interface UnstorageBridgeType { + /** Returns the exact filesystem capabilities, limits, and partition policy behind this driver. */ + inspect(): InspectionType; + /** Preflights one underlying filesystem operation without touching storage. */ + plan(input: PlanInputType): PlanType; + /** Returns current filesystem metrics without exposing mutable counters. */ + getMetrics(): MetricsType; + /** Stable driver identity reported to unstorage. */ + readonly name: string; + /** Declares native interpretation of unstorage's `maxDepth` listing option. */ + readonly flags: { readonly maxDepth: true }; + /** Tests whether one exact unstorage key has a value. */ + hasItem(key: string, options: UnstorageBridgeTransactionOptionsType): Promise; + /** Reads one value as UTF-8 text. */ + getItem(key: string, options?: UnstorageBridgeTransactionOptionsType): Promise; + /** Replaces one value from UTF-8 text. */ + setItem(key: string, value: string, options: UnstorageBridgeTransactionOptionsType): Promise; + /** Reads one value without text decoding. */ + getItemRaw(key: string, options: UnstorageBridgeTransactionOptionsType): Promise; + /** Replaces one value from raw byte-compatible input. */ + setItemRaw( + key: string, + value: string | Blob | ArrayBuffer | ArrayBufferView, + options: UnstorageBridgeTransactionOptionsType, + ): Promise; + /** Removes one exact value while preserving descendants. */ + removeItem(key: string, options: UnstorageBridgeTransactionOptionsType): Promise; + /** Returns filesystem-backed metadata for one exact value. */ + getMeta(key: string, options: UnstorageBridgeTransactionOptionsType): Promise; + /** Lists colon-delimited descendant keys below one base prefix. */ + getKeys(base: string, options: UnstorageBridgeTransactionOptionsType): Promise; + /** Removes descendants below one base prefix according to unstorage clear semantics. */ + clear(base: string, options: UnstorageBridgeTransactionOptionsType): Promise; + /** Releases filesystem ownership only when creation explicitly transferred it. */ + dispose(): Promise; +} + +/** Options for the reverse unstorage bridge. */ +export interface UnstorageBridgeOptionsType { + /** Virtual directory that contains unstorage keys. Defaults to `/`. */ + readonly root?: string; + /** Closes the injected filesystem when unstorage disposes the driver. */ + readonly disposeFileSystem?: boolean; +} + +/** + * unstorage-compatible view over the generic filesystem key-value driver. + * + * The class owns only unstorage naming and `maxDepth` translation. Key + * encoding, prefix collisions, filesystem ownership, and value persistence + * remain in {@link KeyValueBridgeType}. + */ +class UnstorageBridgeImpl implements UnstorageBridgeType { + /** Stable driver name reported to unstorage. */ + readonly name = "@okikio/opfs"; + /** Declares that this driver interprets unstorage's `maxDepth` option. */ + readonly flags = { maxDepth: true } as const; + /** Generic KV projection that owns filesystem mapping and optional disposal. */ + readonly #driver: ReturnType; + + /** Creates one unstorage projection over the already-configured filesystem. */ + constructor(fileSystem: FileSystemType, options: UnstorageBridgeOptionsType) { + this.#driver = createKeyValueBridge(fileSystem, options); + } + + /** Returns the effective capability and limit report of the backing filesystem. */ + inspect(): InspectionType { + return this.#driver.inspect(); + } + + /** Uses the backing filesystem planner without duplicating storage policy in the unstorage layer. */ + plan(input: PlanInputType): PlanType { + return this.#driver.plan(input); + } + + /** Returns the backing filesystem's detached metrics snapshot. */ + getMetrics(): MetricsType { + return this.#driver.getMetrics(); + } + + /** Tests one exact unstorage key. */ + async hasItem(key: string, _options: UnstorageBridgeTransactionOptionsType): Promise { + return await this.#driver.has(key); + } + + /** Reads one UTF-8 unstorage value or `null` when absent. */ + async getItem(key: string, _options?: UnstorageBridgeTransactionOptionsType): Promise { + return await this.#driver.get(key); + } + + /** Replaces one UTF-8 unstorage value. */ + async setItem(key: string, value: string, _options: UnstorageBridgeTransactionOptionsType): Promise { + await this.#driver.set(key, value); + } + + /** Reads one raw unstorage value without text transcoding. */ + async getItemRaw(key: string, _options: UnstorageBridgeTransactionOptionsType): Promise { + return await this.#driver.getRaw(key); + } + + /** Replaces one raw unstorage value. */ + async setItemRaw( + key: string, + value: string | Blob | ArrayBuffer | ArrayBufferView, + _options: UnstorageBridgeTransactionOptionsType, + ): Promise { + await this.#driver.setRaw(key, value); + } + + /** Removes only the exact unstorage key. */ + async removeItem(key: string, _options: UnstorageBridgeTransactionOptionsType): Promise { + await this.#driver.remove(key); + } + + /** Projects filesystem modification time into unstorage metadata. */ + async getMeta(key: string, _options: UnstorageBridgeTransactionOptionsType): Promise { + const meta = await this.#driver.meta(key); + return meta === null || meta.modified === undefined ? null : { mtime: meta.modified }; + } + + /** + * Lists keys with unstorage's trailing-colon descendant semantics. + * + * A base such as `foo:` excludes the exact `foo` value while retaining + * descendants. The generic KV layer deliberately does not own that + * unstorage-specific rule. + */ + async getKeys(base: string, options: UnstorageBridgeTransactionOptionsType): Promise { + const exactBase = base.replace(/:+$/g, ""); + const excludesExactBase = base.endsWith(":") && exactBase.length > 0; + const values = await this.#driver.keys( + base, + options.maxDepth === undefined ? undefined : { maxDepth: options.maxDepth }, + ); + return excludesExactBase ? values.filter((key) => key !== exactBase) : values; + } + + /** Removes a key subtree while preserving the exact base for trailing-colon calls. */ + async clear(base: string, _options: UnstorageBridgeTransactionOptionsType): Promise { + await this.#driver.clear(base, { preserveExact: base.endsWith(":") && base.replace(/:+$/g, "").length > 0 }); + } + + /** Releases optional filesystem ownership through the generic bridge. */ + async dispose(): Promise { + await this.#driver.dispose(); + } +} + +/** + * Creates an unstorage bridge backed by this package's filesystem facade. + * + * This is the reverse direction of `createUnstorageAdapter()`. The generic + * key-value bridge owns the collision-safe filesystem mapping, while the + * unstorage class translates method names and `maxDepth` behavior. + */ +export function createUnstorageBridge( + fileSystem: FileSystemType, + options: UnstorageBridgeOptionsType = {}, +): UnstorageBridgeType { + return new UnstorageBridgeImpl(fileSystem, options); +} diff --git a/src/capability.ts b/src/capability.ts index f62a4cb..6ffaa41 100644 --- a/src/capability.ts +++ b/src/capability.ts @@ -1,5 +1,6 @@ import type { AdapterType } from "./adapter/definition.ts"; -import type { MetricsType } from "./metrics.ts"; +import type { DriverInspectionType } from "./driver/definition.ts"; +import type { DriverMetricsType, MetricsType } from "./metrics.ts"; import type { AdapterLimitsType, AdapterPartitionType, @@ -9,72 +10,110 @@ import type { WriteModeType, } from "./schema.ts"; -/** Effective support for one write mode. */ +/** + * Effective support for one write mode. + * + * This is already post-translation and post-policy. A mode can be native, + * emulated, partitioned, or unsupported even when the underlying driver exposes + * related lower-level capabilities. + */ export type WriteSupportType = Readonly>; /** - * Effective capabilities after adapter-native behavior and facade fallbacks are combined. + * Effective filesystem routes after adapter primitives and facade fallbacks are combined. * - * This differs from `AdapterType.capabilities`, which reports only what the adapter - * itself can do. For example, `copy` can be `emulated` even when `nativeCopy` is - * false because the facade can stream or materialize the source and write a destination. + * This is the route table most callers care about when deciding whether a given + * `FileSystemType` is suitable for a workload. */ export interface SupportType { - /** Metadata lookup. Required by every adapter. */ + /** Metadata lookup support. */ readonly stat: SupportModeType; - /** Materialized byte read. */ + /** Whole-file read support. */ readonly read: SupportModeType; - /** Materialized byte write. */ + /** Whole-file write support. */ readonly write: SupportModeType; - /** Streaming read after optimization policy is applied. */ + /** Streaming read support. */ readonly streamRead: SupportModeType; - /** Per-mode streaming write after optimization policy is applied. */ + /** Streaming write support per write mode. */ readonly streamWrite: WriteSupportType; - /** Byte-range read without or with facade materialization. */ + /** Byte-range read support. */ readonly rangeRead: SupportModeType; - /** File copy route. Directory recursion remains facade-owned. */ + /** Copy support for files or trees. */ readonly copy: SupportModeType; - /** Move route. An emulated move is copy followed by remove and is not atomic. */ + /** Move support for files or trees. */ readonly move: SupportModeType; - /** Long-lived asynchronous positional writes. */ + /** Long-lived positional write support. */ readonly positionalWrite: SupportModeType; - /** Synchronous random access. */ + /** Synchronous random-access support. */ readonly syncAccess: SupportModeType; } -/** Full synchronous inspection of one configured filesystem stack. */ -export interface InspectionType { - /** Concrete adapter diagnostic name. */ - readonly adapter: string; - /** Adapter-native booleans and native write modes. */ +/** + * Adapter translation report kept distinct from backend-driver state. + * + * This lets diagnostics show what the translation layer added or constrained + * without flattening it into the driver report. + */ +export interface AdapterInspectionType { + /** Stable adapter name. */ + readonly name: string; + /** Native adapter capabilities before facade fallbacks are considered. */ readonly native: AdapterType["capabilities"]; - /** Effective routes after facade emulation and optimization policy. */ - readonly support: SupportType; - /** Portable hard limits known by the adapter. Missing fields mean unknown. */ - readonly limits: AdapterLimitsType; - /** Physical partition policy when the adapter exposes one. */ + /** Translation-layer limits, such as record payload ceilings. */ + readonly limits?: AdapterLimitsType; + /** Translation-layer partition behavior when the adapter exposes one. */ readonly partition?: AdapterPartitionType; - /** Resolved optimization controls for this facade. */ +} + +/** + * Full synchronous inspection of one configured storage stack. + * + * This is the main no-I/O snapshot for tooling, debugging, and tests. It keeps + * driver truth, adapter truth, facade policy, and both metric layers visible in + * one detached object. + */ +export interface InspectionType { + /** Backend-native persistence report with requirements and limit provenance. */ + readonly driver: DriverInspectionType; + /** OPFS translation routes and compatibility summaries. */ + readonly adapter: AdapterInspectionType; + /** Effective routes exposed by FileSystemType. */ + readonly support: SupportType; + /** Resolved facade optimization switches. */ readonly optimizations: OptimizationType; - /** Maximum facade-owned stream materialization before `too-large`. */ + /** Maximum facade-owned stream materialization. */ readonly maxBufferedWriteBytes: number; /** Instrumentation cost selected for this facade. */ readonly metricsMode: MetricsModeType; - /** Detached current metrics snapshot. */ + /** Detached logical filesystem metrics snapshot. */ readonly metrics: MetricsType; + /** Detached physical driver metrics when the driver collects them. */ + readonly driverMetrics?: DriverMetricsType; } -/** Returns `native` only when both capability and optimization are enabled. */ +/** + * Returns native support when enabled, otherwise a safe facade fallback when available. + * + * The helper keeps the route table consistent: native wins when explicitly + * enabled, emulated is reported only when the facade can preserve the request + * honestly enough, and unsupported means there is no safe route. + */ function native(enabled: boolean, fallback: boolean): SupportModeType { return enabled ? "native" : fallback ? "emulated" : "unsupported"; } -/** Computes the effective operation routes for one configured adapter. */ +/** + * Computes effective routes for one configured adapter and facade policy. + * + * The result answers the practical question, "What can this configured + * filesystem do right now, and which paths are native versus emulated?" + */ export function getSupport(adapter: AdapterType, optimizations: OptimizationType): SupportType { const readable = adapter.capabilities.read; const writable = adapter.capabilities.write; const streamWrite = (mode: WriteModeType): SupportModeType => { - const direct = optimizations.streamWrite && adapter.capabilities.streamWriteModes.includes(mode) && adapter.writeStream !== undefined; + const direct = optimizations.streamWrite && adapter.capabilities.streamWriteModes.includes(mode) && + adapter.writeStream !== undefined; if (direct && adapter.partition?.stream === true) return "partitioned"; return native(direct, writable); }; @@ -87,11 +126,7 @@ export function getSupport(adapter: AdapterType, optimizations: OptimizationType optimizations.streamRead && adapter.capabilities.streamRead && adapter.openReadStream !== undefined, readable, ), - streamWrite: { - replace: streamWrite("replace"), - append: streamWrite("append"), - update: streamWrite("update"), - }, + streamWrite: { replace: streamWrite("replace"), append: streamWrite("append"), update: streamWrite("update") }, rangeRead: native(optimizations.rangeRead && adapter.capabilities.rangeRead, readable), copy: native( optimizations.nativeCopy && adapter.capabilities.nativeCopy && adapter.copy !== undefined, @@ -101,7 +136,9 @@ export function getSupport(adapter: AdapterType, optimizations: OptimizationType optimizations.nativeMove && adapter.capabilities.nativeMove && adapter.move !== undefined, readable && writable, ), - positionalWrite: adapter.capabilities.positionalWrite && adapter.openWritableFile !== undefined ? "native" : "unsupported", + positionalWrite: adapter.capabilities.positionalWrite && adapter.openWritableFile !== undefined + ? "native" + : "unsupported", syncAccess: adapter.capabilities.syncAccess && adapter.openSyncFile !== undefined ? "native" : "unsupported", }; } diff --git a/src/driver/db0.ts b/src/driver/db0.ts new file mode 100644 index 0000000..444ce2d --- /dev/null +++ b/src/driver/db0.ts @@ -0,0 +1,318 @@ +import { defineRecordDriver, type RecordBackendType, type RecordDriverType } from "./record.ts"; +import { + Db0DialectSchema, + type Db0DialectType, + RecordSchema, + type RecordType, + SqlIdentifierSchema, +} from "../schema.ts"; + +/** Primitive parameter values accepted by db0 prepared statements. */ +export type Db0PrimitiveType = string | number | boolean | undefined | null; + +/** Prepared statement subset required from db0. */ +export interface Db0StatementType { + /** Executes a query and returns every row. */ + all(...params: Db0PrimitiveType[]): Promise; + /** Executes a query and returns the first row. */ + get(...params: Db0PrimitiveType[]): Promise; + /** Executes a mutation. */ + run(...params: Db0PrimitiveType[]): Promise<{ readonly success: boolean }>; +} + +/** + * Structural subset of db0's public `Database` API. + * + * The current db0 contract exposes the SQL dialect independently of the + * connector. That lets this driver support Bun SQLite, Node SQLite, D1, + * LibSQL, PGlite, PostgreSQL/Hyperdrive, MySQL/Hyperdrive, PlanetScale, and the + * other current connectors through four dialect implementations. + */ +export interface Db0DatabaseType { + /** SQL dialect selected by the active connector. */ + readonly dialect: Db0DialectType; + /** Compiles one SQL string into a reusable statement. */ + prepare(sql: string): Db0StatementType; + /** Releases the database when ownership is explicitly transferred. */ + dispose?(): Promise; +} + +/** Options for the db0 record driver. */ +export interface Db0DriverOptionsType { + /** Driver-owned table. Defaults to `opfs_entries`. */ + readonly table?: string; + /** Creates the table before returning the driver. Defaults to true. */ + readonly initialize?: boolean; + /** Disposes the injected db0 Database when the driver closes. */ + readonly disposeDatabase?: boolean; +} + +/** Columns read from every db0 filesystem row. */ +const DB0_ROW_COLUMNS_SQL = "path, parent_path, name, kind, data, size, last_modified, media_type"; + +/** Columns written by the db0 record driver. */ +const DB0_WRITE_COLUMNS_SQL = `id, ${DB0_ROW_COLUMNS_SQL}`; + +/** Database row shape after db0 driver decoding and before record validation. */ +interface Db0RowType { + /** Canonical virtual path returned by the connector. */ + readonly path: string; + /** Canonical direct-parent path stored in the SQL row. */ + readonly parent_path: string; + /** Final file or directory name. */ + readonly name: string; + /** Persisted entry discriminator before schema validation. */ + readonly kind: string; + /** Base64 file payload, or null for directories. */ + readonly data: string | null; + /** File byte length in the connector's integer representation. */ + readonly size: number | string | bigint; + /** Unix epoch milliseconds in the connector's integer representation. */ + readonly last_modified: number | string | bigint; + /** File media type, or null for directories. */ + readonly media_type: string | null; +} + +/** Validates and quotes the driver-owned table name for the selected db0 dialect. */ +function quoteIdentifier(identifier: string, dialect: Db0DialectType): string { + SqlIdentifierSchema.parse(identifier); + return dialect === "mysql" ? `\`${identifier}\`` : `"${identifier}"`; +} + +/** + * Creates db0's portable prepared-statement placeholders. + * + * db0 connectors own translation to driver-native parameter syntax. Current + * PostgreSQL and PGlite connectors, for example, translate `?` to `$1`, `$2`, + * and so on before calling the underlying driver. Keeping `?` here lets this + * driver stay at the db0 Database contract instead of coupling to connectors. + */ +function placeholders(count: number): string[] { + return Array.from({ length: count }, () => "?"); +} + +/** Normalizes BIGINT driver results and rejects values outside JavaScript safe-integer range. */ +function toNumber(value: number | string | bigint, field: string): number { + const normalized = typeof value === "bigint" + ? Number(value) + : typeof value === "string" + ? Number.parseInt(value, 10) + : value; + if (!Number.isSafeInteger(normalized) || normalized < 0) { + throw new TypeError(`db0 returned invalid ${field} '${String(value)}'.`); + } + return normalized; +} + +/** Converts a connector-dependent db0 row into the validated record-store format. */ +function parseRow(value: unknown): RecordType { + if (typeof value !== "object" || value === null) throw new TypeError("db0 returned a non-object filesystem row."); + const row = value as Db0RowType; + if (row.kind === "directory") { + return RecordSchema.parse({ + version: 1, + path: row.path, + parent: row.parent_path, + name: row.name, + kind: "directory", + lastModified: toNumber(row.last_modified, "last_modified"), + }); + } + return RecordSchema.parse({ + version: 1, + path: row.path, + parent: row.parent_path, + name: row.name, + kind: "file", + data: row.data ?? "", + size: toNumber(row.size, "size"), + lastModified: toNumber(row.last_modified, "last_modified"), + mediaType: row.media_type ?? "", + }); +} + +/** + * Hashes the full path into a fixed-width primary key. + * + * MySQL cannot portably use an arbitrary-length TEXT path as a primary key. A + * SHA-256 id keeps the indexed key fixed while the original path remains stored + * and queryable in its own column. + */ +async function getPathId(path: string): Promise { + const bytes = new TextEncoder().encode(path); + const digest = new Uint8Array(await crypto.subtle.digest("SHA-256", bytes)); + return Array.from(digest, (value) => value.toString(16).padStart(2, "0")).join(""); +} + +/** Builds only the small DDL subset verified across db0's four public dialects. */ +function getCreateTableSql(table: string, dialect: Db0DialectType): string { + const q = quoteIdentifier(table, dialect); + const integer = dialect === "postgresql" || dialect === "mysql" ? "BIGINT" : "INTEGER"; + const columns = dialect === "mysql" + ? [ + "id VARCHAR(64) NOT NULL PRIMARY KEY", + "path TEXT NOT NULL", + "parent_path TEXT NOT NULL", + "name TEXT NOT NULL", + "kind VARCHAR(16) NOT NULL", + "data LONGTEXT NULL", + `size ${integer} NOT NULL`, + `last_modified ${integer} NOT NULL`, + "media_type TEXT NULL", + ] + : [ + "id TEXT NOT NULL PRIMARY KEY", + "path TEXT NOT NULL", + "parent_path TEXT NOT NULL", + "name TEXT NOT NULL", + "kind TEXT NOT NULL", + "data TEXT NULL", + `size ${integer} NOT NULL`, + `last_modified ${integer} NOT NULL`, + "media_type TEXT NULL", + ]; + return `CREATE TABLE IF NOT EXISTS ${q} (${columns.join(", ")})`; +} + +/** Builds one atomic row replacement using each dialect's native upsert form. */ +function getUpsertSql(table: string, dialect: Db0DialectType): string { + const q = quoteIdentifier(table, dialect); + const values = placeholders(9).join(", "); + const assignments = dialect === "mysql" + ? [ + "path=VALUES(path)", + "parent_path=VALUES(parent_path)", + "name=VALUES(name)", + "kind=VALUES(kind)", + "data=VALUES(data)", + "size=VALUES(size)", + "last_modified=VALUES(last_modified)", + "media_type=VALUES(media_type)", + ] + : [ + "path=excluded.path", + "parent_path=excluded.parent_path", + "name=excluded.name", + "kind=excluded.kind", + "data=excluded.data", + "size=excluded.size", + "last_modified=excluded.last_modified", + "media_type=excluded.media_type", + ]; + const conflict = dialect === "mysql" + ? `ON DUPLICATE KEY UPDATE ${assignments.join(", ")}` + : `ON CONFLICT (id) DO UPDATE SET ${assignments.join(", ")}`; + return `INSERT INTO ${q} (${DB0_WRITE_COLUMNS_SQL}) VALUES (${values}) ${conflict}`; +} + +/** + * Prepared db0 record store after schema/table setup has completed. + * + * Statement preparation occurs once in {@link openDb0Backend}. Each method + * then binds only operation parameters, which keeps SQL generation and runtime + * record behavior separate and makes connector-specific parameter translation + * remain db0's responsibility. + */ +class Db0Backend implements RecordBackendType { + /** Connected database borrowed or transferred by the caller. */ + readonly #database: Db0DatabaseType; + /** Prepared exact-path selection. */ + readonly #selectById: Db0StatementType; + /** Prepared direct-parent selection. */ + readonly #selectChildren: Db0StatementType; + /** Prepared dialect-specific atomic upsert. */ + readonly #upsert: Db0StatementType; + /** Prepared exact-path deletion. */ + readonly #remove: Db0StatementType; + /** Whether store disposal also disposes the injected db0 database. */ + readonly #disposeDatabase: boolean; + + /** Retains prepared statements and explicit resource ownership. */ + constructor( + database: Db0DatabaseType, + selectById: Db0StatementType, + selectChildren: Db0StatementType, + upsert: Db0StatementType, + remove: Db0StatementType, + disposeDatabase: boolean, + ) { + this.#database = database; + this.#selectById = selectById; + this.#selectChildren = selectChildren; + this.#upsert = upsert; + this.#remove = remove; + this.#disposeDatabase = disposeDatabase; + } + + /** Selects one fixed-width path identity and converts the connector row. */ + async get(path: Parameters[0]) { + const row = await this.#selectById.get(await getPathId(path)); + return row == null ? null : parseRow(row); + } + + /** Upserts one validated filesystem record through the dialect-specific statement. */ + async set(record: RecordType): Promise { + const params: Db0PrimitiveType[] = [ + await getPathId(record.path), + record.path, + record.parent, + record.name, + record.kind, + record.kind === "file" ? record.data : null, + record.kind === "file" ? record.size : 0, + record.lastModified, + record.kind === "file" ? record.mediaType : null, + ]; + const result = await this.#upsert.run(...params); + if (!result.success) throw new Error(`db0 did not confirm the write for '${record.path}'.`); + } + + /** Deletes one path identity and requires connector mutation confirmation. */ + async delete(path: Parameters[0]): Promise { + const result = await this.#remove.run(await getPathId(path)); + if (!result.success) throw new Error(`db0 did not confirm removal for '${path}'.`); + } + + /** Selects and validates direct children through `parent_path`. */ + async *list(parent: Parameters[0]) { + const rows = await this.#selectChildren.all(parent); + for (const row of rows) yield parseRow(row); + } + + /** Disposes the db0 database only when ownership was explicitly transferred. */ + async dispose(): Promise { + if (this.#disposeDatabase) await this.#database.dispose?.(); + } +} + +/** Opens a db0-backed record driver and optionally creates its table. */ +export async function createDb0Driver( + database: Db0DatabaseType, + options: Db0DriverOptionsType = {}, +): Promise { + const dialect = Db0DialectSchema.parse(database.dialect); + const table = SqlIdentifierSchema.parse(options.table ?? "opfs_entries"); + const quotedTable = quoteIdentifier(table, dialect); + if (options.initialize ?? true) { + const result = await database.prepare(getCreateTableSql(table, dialect)).run(); + if (!result.success) throw new Error(`db0 did not confirm initialization of table '${table}'.`); + } + + const idPlaceholder = placeholders(1)[0]; + const parentPlaceholder = placeholders(1)[0]; + const backend = new Db0Backend( + database, + database.prepare(`SELECT ${DB0_ROW_COLUMNS_SQL} FROM ${quotedTable} WHERE id = ${idPlaceholder}`), + database.prepare(`SELECT ${DB0_ROW_COLUMNS_SQL} FROM ${quotedTable} WHERE parent_path = ${parentPlaceholder}`), + database.prepare(getUpsertSql(table, dialect)), + database.prepare(`DELETE FROM ${quotedTable} WHERE id = ${idPlaceholder}`), + options.disposeDatabase ?? false, + ); + return defineRecordDriver(backend, { + name: "db0", + capabilities: { replacement: "atomic", transactions: true, binary: false }, + requirements: [{ code: `db0-${dialect}`, state: "available" }], + optimizations: [], + disposeBackend: options.disposeDatabase ?? false, + }); +} diff --git a/src/driver/definition.ts b/src/driver/definition.ts new file mode 100644 index 0000000..d6c2305 --- /dev/null +++ b/src/driver/definition.ts @@ -0,0 +1,334 @@ +import { z } from "zod"; + +import type { DriverMetricsType } from "../metrics.ts"; +import { + DriverKindSchema, + type DriverKindType, + DriverOptimizationSchema, + type DriverOptimizationType, + DriverOwnershipSchema, + type DriverOwnershipType, + LimitSchema, + type LimitType, + PathSchema, + RequirementSchema, + type RequirementType, + SupportModeSchema, + WriteModeSchema, +} from "../schema.ts"; + +/** + * Layer that identified one storage planning problem. + * + * The layer tells the caller where the constraint lives so they can respond at + * 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"]); + +/** A validated storage problem layer. */ +export type ProblemLayerType = z.output; + +/** + * Severity of one storage planning problem. + * + * 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"]); + +/** A validated storage planning problem severity. */ +export type ProblemSeverityType = z.output; + +/** + * Action a caller can take after a storage preflight result. + * + * The planner does not mutate configuration or probe providers on the caller's + * behalf. It only reports the next kind of move that could make the request + * succeed honestly. + */ +export const ActionKindSchema = z.enum([ + "partition", + "change-policy", + "select-driver", + "reduce-input", + "enable-optimization", + "disable-optimization", + "probe", + "retry", +]); + +/** A validated storage planning action. */ +export type ActionKindType = z.output; + +/** + * One structured problem found while planning a storage operation. + * + * The problem is machine-readable first. `message` helps logs and humans, while + * `code`, `layer`, and `limit` let a caller sort provider ceilings, policy + * choices, and unsupported routes without parsing prose. + */ +export const ProblemSchema = z.object({ + code: z.string().min(1), + layer: ProblemLayerSchema, + severity: ProblemSeveritySchema, + message: z.string().min(1), + limit: LimitSchema.optional(), +}).strict(); + +/** A validated storage planning problem. */ +export type ProblemType = z.output; + +/** + * One structured action available to the caller after planning. + * + * Actions deliberately stay coarse-grained. They explain intent such as + * `reduce-input` or `select-driver` without assuming the caller's UI, retry, or + * orchestration policy. + */ +export const ActionSchema = z.object({ + kind: ActionKindSchema, + code: z.string().min(1).optional(), + detail: z.string().min(1).optional(), +}).strict(); + +/** A validated storage planning action. */ +export type ActionType = z.output; + +/** + * Operations that a backend driver can preflight without performing I/O. + * + * This stays smaller than the full facade surface. The driver only plans the + * backend-native work it understands directly, while the adapter and facade add + * higher-level emulation and policy above it. + */ +export const DriverOperationSchema = z.enum(["stat", "read", "write", "list", "copy", "move", "remove"]); + +/** A validated backend driver operation. */ +export type DriverOperationType = z.output; + +/** + * Concrete operation shape presented to a backend driver planner. + * + * 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({ + operation: DriverOperationSchema, + path: PathSchema.optional(), + destination: PathSchema.optional(), + size: z.number().int().nonnegative().optional(), + inputBytes: z.number().int().nonnegative().optional(), + source: z.enum(["bytes", "stream"]).optional(), + mode: WriteModeSchema.optional(), + range: z.boolean().optional(), +}).strict(); + +/** A validated backend driver preflight request. */ +export type DriverPlanInputType = z.output; + +/** + * Serializable result returned by a backend driver planner. + * + * `supported` answers whether the requested backend-native operation can work + * under current facts and policy. `support` explains whether that route is + * native, emulated later, partitioned, or unavailable once the result is folded + * into the adapter and filesystem plan. + */ +export const DriverPlanSchema = z.object({ + operation: DriverOperationSchema, + supported: z.boolean(), + support: SupportModeSchema, + partBytes: z.number().int().positive().optional(), + parts: z.number().int().positive().optional(), + problems: z.array(ProblemSchema).readonly(), + actions: z.array(ActionSchema).readonly(), +}).strict(); + +/** A validated backend driver preflight result. */ +export type DriverPlanType = z.output; + +/** + * Serializable configured-driver report exposed through filesystem inspection. + * + * 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({ + name: z.string().min(1), + kind: DriverKindSchema, + provides: z.array(z.string().min(1)).readonly(), + ownership: DriverOwnershipSchema, + requirements: z.array(RequirementSchema).readonly(), + limits: z.array(LimitSchema).readonly(), + optimizations: z.array(DriverOptimizationSchema).readonly(), +}).strict(); + +/** A validated configured-driver report. */ +export type DriverInspectionType = z.output; + +/** + * Common behavior implemented by every configured storage driver. + * + * A driver is independently useful without the OPFS facade. It owns backend + * storage mechanics, requirements, limits, provider-specific optimization + * policy, and optional lifecycle. Adapters translate a driver into the small + * filesystem primitive contract. + */ +export interface DriverType { + /** Stable configured driver name. */ + readonly name: string; + /** Native storage family implemented by this driver. */ + readonly kind: DriverKindType; + /** Stable backend operations/capabilities this configured driver provides. */ + readonly provides: readonly string[]; + /** Ownership of the long-lived backend resource, when one exists. */ + readonly ownership: DriverOwnershipType; + /** Requirements already known for this configured instance. */ + readonly requirements: readonly RequirementType[]; + /** Provider, implementation, user, and probe limits with explicit provenance. */ + readonly limits: readonly LimitType[]; + /** Independently controllable driver optimizations and their current state. */ + readonly optimizations: readonly DriverOptimizationType[]; + /** + * Returns a detached current driver report. + * + * Callers can inspect this result without worrying about later mutation by the + * driver implementation. + */ + inspect(): DriverInspectionType; + /** + * Creates a deterministic backend preflight result without performing storage I/O. + * + * Use this before expensive or irreversible work when the request size, write + * mode, or selected route may already violate a provider limit or local policy. + */ + plan(input: DriverPlanInputType): DriverPlanType; + /** + * Returns driver-owned physical metrics when the implementation collects them. + * + * These metrics describe backend work such as provider requests or bytes moved. + * They complement, rather than replace, the logical filesystem metrics. + */ + getMetrics?(): DriverMetricsType; + /** + * Releases resources explicitly owned by the configured driver. + * + * Borrowed clients, databases, and stores stay owned by the caller unless the + * driver definition explicitly transferred that ownership. + */ + dispose?(): void | Promise; +} + +/** + * Data used to create a small custom driver definition. + * + * This is the lowest-level extension seam. A third-party package can describe a + * real configured backend here, then layer a file, record, or object contract on + * top when it wants stronger operational typing. + */ +export interface DefineDriverOptionsType { + /** Stable inspection name for the configured backend instance. */ + readonly name: string; + /** Backend family that owns the persistence mechanics. */ + readonly kind: DriverKindType; + /** Stable backend-native verbs or capabilities this instance provides. */ + readonly provides?: readonly string[]; + /** Whether the driver owns, borrows, or avoids long-lived backend resources. */ + readonly ownership?: DriverOwnershipType; + /** Known availability facts for this configured instance. */ + readonly requirements?: readonly RequirementType[]; + /** Provider, implementation, user, and probe limits with explicit provenance. */ + readonly limits?: readonly LimitType[]; + /** Independently visible and disableable optimization switches. */ + readonly optimizations?: readonly DriverOptimizationType[]; + /** Optional deterministic planner for backend-native work. */ + readonly plan?: (input: DriverPlanInputType) => DriverPlanType; + /** Optional physical metrics collected by this driver. */ + readonly getMetrics?: () => DriverMetricsType; + /** Optional lifecycle hook for resources this driver truly owns. */ + readonly dispose?: () => void | Promise; +} + +/** + * Normalizes custom-driver definition failures to one extension-facing error class. + * + * Third-party extension code usually wants one predictable failure shape during + * startup or test setup, not raw Zod errors from several validation paths. + */ +function parseDriverSchema(label: string, schema: { parse(value: unknown): T }, value: unknown): T { + try { + return schema.parse(value); + } catch (cause) { + throw new TypeError(`Invalid driver ${label}.`, { cause }); + } +} + +/** + * Creates an import-safe custom driver definition without global registration. + * + * Third-party packages can compose this definition with a concrete file, + * record, or object driver contract. The function validates structured + * requirements, limits, and optimization invariants once at construction. + * + * @example Minimal deterministic driver definition. + * ```ts + * import { defineDriver } from "@okikio/opfs/driver"; + * + * const driver = defineDriver({ + * name: "example", + * kind: "record", + * provides: ["get", "set", "delete", "list"], + * }); + * ``` + * + * @example Driver with explicit size ceiling and planning result. + * ```ts + * import { defineDriver } from "@okikio/opfs/driver"; + * + * const driver = defineDriver({ + * name: "small-records", + * kind: "record", + * limits: [{ + * code: "file-bytes", + * kind: "hard", + * source: "provider", + * unit: "bytes", + * value: 64 * 1024, + * }], + * plan: (input) => ({ + * operation: input.operation, + * supported: input.size === undefined || input.size <= 64 * 1024, + * support: "native", + * problems: [], + * actions: [], + * }), + * }); + * ``` + */ +export function defineDriver(options: DefineDriverOptionsType): DriverType { + const inspection = parseDriverSchema("definition", DriverInspectionSchema, { + name: options.name, + kind: options.kind, + provides: options.provides ?? [], + ownership: options.ownership ?? "none", + requirements: options.requirements ?? [], + limits: options.limits ?? [], + optimizations: options.optimizations ?? [], + }); + + return { + ...inspection, + inspect: () => DriverInspectionSchema.parse(inspection), + plan: options.plan ?? ((input) => + DriverPlanSchema.parse({ + operation: DriverPlanInputSchema.parse(input).operation, + supported: true, + support: "native", + problems: [], + actions: [], + })), + ...(options.getMetrics === undefined ? {} : { getMetrics: options.getMetrics }), + ...(options.dispose === undefined ? {} : { dispose: options.dispose }), + }; +} diff --git a/src/driver/drizzle.ts b/src/driver/drizzle.ts new file mode 100644 index 0000000..069fef5 --- /dev/null +++ b/src/driver/drizzle.ts @@ -0,0 +1,227 @@ +import { defineRecordDriver, type RecordBackendType, type RecordDriverType } from "./record.ts"; +import { type AnyColumn, eq } from "drizzle-orm"; +import { RecordSchema, type RecordType } from "../schema.ts"; + +/** + * Required Drizzle table columns. + * + * Define these columns with the dialect-specific Drizzle schema builder used by + * the application. The driver intentionally does not own DDL because Drizzle + * is dialect-specific and column definitions differ across SQLite, PostgreSQL, + * MySQL, SingleStore, and driver-specific integrations. + */ +export interface DrizzleTableType { + /** Unique canonical path column. */ + readonly path: AnyColumn<{ data: string }>; + /** Canonical direct-parent path column. */ + readonly parent: AnyColumn<{ data: string }>; + /** Final entry name column. */ + readonly name: AnyColumn<{ data: string }>; + /** File/directory discriminator column. */ + readonly kind: AnyColumn<{ data: string }>; + /** Base64 file payload column. Directory rows store null. */ + readonly data: AnyColumn<{ data: string }>; + /** Decoded file size column using a JavaScript-number mode. */ + readonly size: AnyColumn<{ data: number }>; + /** Unix epoch millisecond column using a JavaScript-number mode. */ + readonly lastModified: AnyColumn<{ data: number }>; + /** File media-type column. Directory rows store null. */ + readonly mediaType: AnyColumn<{ data: string }>; +} + +/** Row shape expected from the supplied Drizzle table. */ +export interface DrizzleRowType { + /** Canonical virtual path stored in the caller table. */ + readonly path: string; + /** Canonical direct-parent path used for directory queries. */ + readonly parent: string; + /** Final file or directory name. */ + readonly name: string; + /** Persisted file/directory discriminator. */ + readonly kind: "file" | "directory"; + /** Base64 file payload, or null for directories. */ + readonly data: string | null; + /** Decoded file byte length. Directories use zero. */ + readonly size: number; + /** Unix epoch milliseconds for the logical record. */ + readonly lastModified: number; + /** File media type, or null for directories. */ + readonly mediaType: string | null; +} + +/** Options for the Drizzle-backed record driver. */ +export interface DrizzleDriverOptionsType { + /** Connected Drizzle database from any supported driver. */ + readonly database: TDatabase; + /** Caller-defined dialect-specific table with the required columns. */ + readonly table: TTable; +} + +/** Small thenable contract shared by Drizzle query builders used by this driver. */ +interface QueryPromiseType extends PromiseLike {} + +/** Selection stage that can apply a row limit. */ +interface SelectLimitType { + /** Limits the selected row count without changing the row shape. */ + limit(count: number): QueryPromiseType; +} + +/** Selection stage that accepts a Drizzle SQL condition. */ +interface SelectWhereType { + /** Applies one Drizzle SQL condition to the current selection. */ + where(condition: object): SelectLimitType & QueryPromiseType; +} + +/** Selection stage that binds the caller-provided table. */ +interface SelectFromType { + /** Binds the caller-owned table to the selection. */ + from(table: object): SelectWhereType; +} + +/** Delete builder subset required for path replacement and removal. */ +interface DeleteType { + /** Restricts deletion to rows selected by the supplied condition. */ + where(condition: object): QueryPromiseType; +} + +/** Insert builder subset required to persist one normalized row. */ +interface InsertValuesType { + /** Inserts one normalized filesystem row. */ + values(value: DrizzleRowType): QueryPromiseType; +} +/** + * Runtime CRUD surface common to the Drizzle database objects supported here. + * + * This is intentionally smaller than Drizzle's public generic types. Dialect + * schema and driver types remain owned by the caller instead of being erased + * into a false universal database type. + */ +interface DrizzleRuntimeType { + /** Starts an unprojected row selection. */ + select(): SelectFromType; + /** Starts one insert against the caller-owned table. */ + insert(table: object): InsertValuesType; + /** Starts one deletion against the caller-owned table. */ + delete(table: object): DeleteType; +} + +/** Validates the three CRUD builders required at runtime before any data is touched. */ +function getRuntime(database: object): DrizzleRuntimeType { + const candidate = database as Partial; + if ( + typeof candidate.select !== "function" || + typeof candidate.insert !== "function" || + typeof candidate.delete !== "function" + ) { + throw new TypeError("Drizzle database must expose select(), insert(), and delete()."); + } + return candidate as DrizzleRuntimeType; +} + +/** Converts a Drizzle row to the validated record format and restores version 1. */ +function toRecord(row: DrizzleRowType): RecordType { + if (row.kind === "directory") { + return RecordSchema.parse({ + version: 1, + path: row.path, + parent: row.parent, + name: row.name, + kind: "directory", + lastModified: row.lastModified, + }); + } + return RecordSchema.parse({ + version: 1, + path: row.path, + parent: row.parent, + name: row.name, + kind: "file", + data: row.data ?? "", + size: row.size, + lastModified: row.lastModified, + mediaType: row.mediaType ?? "", + }); +} + +/** Converts the shared record format into the caller table's logical row shape. */ +function toRow(record: RecordType): DrizzleRowType { + if (record.kind === "directory") { + return { + path: record.path, + parent: record.parent, + name: record.name, + kind: "directory", + data: null, + size: 0, + lastModified: record.lastModified, + mediaType: null, + }; + } + return { + path: record.path, + parent: record.parent, + name: record.name, + kind: "file", + data: record.data, + size: record.size, + lastModified: record.lastModified, + mediaType: record.mediaType, + }; +} + +/** + * Record-store projection over Drizzle's common CRUD builder surface. + * + * The caller owns both database and dialect-specific table. The class does not + * create DDL or hide cross-process atomicity: replacement is delete-then-insert + * because there is no one portable upsert form across all Drizzle dialects. + */ +class DrizzleBackend implements RecordBackendType { + /** Runtime CRUD subset validated from the connected database. */ + readonly #database: DrizzleRuntimeType; + /** Caller-owned table containing the required logical columns. */ + readonly #table: DrizzleTableType; + + /** Validates the database surface once and retains the caller table. */ + constructor(database: object, table: DrizzleTableType) { + this.#database = getRuntime(database); + this.#table = table; + } + + /** Selects one path row and restores the versioned filesystem record. */ + async get(path: Parameters[0]) { + const rows = await this.#database.select().from(this.#table).where(eq(this.#table.path, path)).limit(1); + const row = rows[0]; + return row === undefined ? null : toRecord(row); + } + + /** Replaces one path through the portable delete-then-insert sequence. */ + async set(record: RecordType): Promise { + await this.#database.delete(this.#table).where(eq(this.#table.path, record.path)); + await this.#database.insert(this.#table).values(toRow(record)); + } + + /** Deletes one exact path row. */ + async delete(path: Parameters[0]): Promise { + await this.#database.delete(this.#table).where(eq(this.#table.path, path)); + } + + /** Selects all rows whose indexed/logical parent equals the requested path. */ + async *list(parent: Parameters[0]) { + const rows = await this.#database.select().from(this.#table).where(eq(this.#table.parent, parent)); + for (const row of rows) yield toRecord(row); + } +} + +/** Creates an independently useful record driver over a Drizzle database/table mapping. */ +export function createDrizzleDriver( + options: DrizzleDriverOptionsType, +): RecordDriverType { + return defineRecordDriver(new DrizzleBackend(options.database, options.table), { + name: "drizzle", + ownership: "borrowed", + capabilities: { replacement: "best-effort", transactions: false, binary: false }, + requirements: [{ code: "drizzle-database", state: "available" }], + optimizations: [], + }); +} diff --git a/src/driver/file.ts b/src/driver/file.ts new file mode 100644 index 0000000..46ffcad --- /dev/null +++ b/src/driver/file.ts @@ -0,0 +1,293 @@ +import { z } from "zod"; + +import type { PathType } from "../path.ts"; +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({ + read: z.boolean(), + write: z.boolean(), + streamRead: z.boolean(), + streamWriteModes: z.array(WriteModeSchema).readonly(), + rangeRead: z.boolean(), + copy: z.boolean(), + move: z.boolean(), + positionalWrite: z.boolean(), + syncAccess: z.boolean(), +}).strict(); + +/** A validated native file-driver capability description. */ +export type FileDriverCapabilitiesType = z.output; + +/** Options shared by file-driver operations that can stop early. */ +export interface FileDriverSignalOptionsType { + /** Stops driver work before the backend commits more changes. */ + readonly signal?: AbortSignal; +} + +/** Byte-range options for a file-driver read. */ +export interface FileDriverReadOptionsType extends FileDriverSignalOptionsType { + /** Zero-based byte offset. */ + readonly at?: number; + /** Maximum bytes to return. */ + readonly length?: number; +} + +/** Write semantics that a file driver must preserve. */ +export interface FileDriverWriteOptionsType extends FileDriverSignalOptionsType { + /** Relationship between incoming bytes and any existing file body. */ + readonly mode: WriteModeType; + /** Zero-based write offset used by update-style writes. */ + readonly at?: number; + /** Truncates the file at the final write cursor when supported. */ + readonly truncate?: boolean; + /** Media type retained when the backend tracks it. */ + readonly mediaType?: string; +} + +/** Options for a file-driver native copy. */ +export interface FileDriverCopyOptionsType extends FileDriverSignalOptionsType { + readonly overwrite: boolean; +} + +/** Options for a file-driver native move. */ +export interface FileDriverMoveOptionsType extends FileDriverSignalOptionsType { + readonly overwrite: boolean; +} + +/** One direct child returned by a file-driver directory iterator. */ +export interface FileDriverDirectoryEntryType { + /** Final entry name relative to the requested parent. */ + readonly name: string; + /** Portable discriminator used by the adapter and facade. */ + readonly kind: "file" | "directory"; +} + +/** Portable file metadata returned by a file driver. */ +export interface FileDriverFileStatType { + /** Portable discriminator for file metadata. */ + readonly kind: "file"; + /** File length in bytes. */ + readonly size: number; + /** Last-modified Unix epoch milliseconds. */ + readonly lastModified: number; + /** Media type, or an empty string when the backend does not know one. */ + readonly mediaType: string; +} + +/** Portable directory metadata returned by a file driver. */ +export interface FileDriverDirectoryStatType { + /** Portable discriminator for directory metadata. */ + readonly kind: "directory"; + /** Last-modified Unix epoch milliseconds when the backend can observe it. */ + readonly lastModified?: number; +} + +/** Portable file/directory metadata returned by a file driver. */ +export type FileDriverStatType = FileDriverFileStatType | FileDriverDirectoryStatType; + +/** + * Long-lived asynchronous positional file owned by a file driver. + * + * This exists for backends that can keep a write handle open across several + * writes without routing every chunk through `writeFile()`. + */ +export interface FileDriverWritableFileType { + /** Writes one chunk at a specific byte offset. */ + write(buffer: ArrayBufferView, options: { readonly at: number }): Promise; + /** Shrinks or expands the file to one exact size. */ + truncate(size: number): Promise; + /** Flushes buffered writes to the backend when that concept exists. */ + flush(): Promise; + /** Closes the long-lived file handle after successful work. */ + close(): Promise; + /** Aborts the long-lived file handle after failed or cancelled work. */ + abort(reason?: unknown): Promise; +} + +/** + * Synchronous random-access file owned by a file driver. + * + * This contract exists for environments such as OPFS sync access handles where + * the backend can expose low-latency random access without async round-trips. + */ +export interface FileDriverSyncFileType { + /** Reads into the provided buffer and returns bytes read. */ + read(buffer: ArrayBufferView, options?: { readonly at?: number }): number; + /** Writes from the provided buffer and returns bytes written. */ + write(buffer: ArrayBufferView, options?: { readonly at?: number }): number; + /** Returns the current byte length. */ + getSize(): number; + /** Shrinks or expands the file to one exact size. */ + truncate(size: number): void; + /** Flushes any pending writes to the backend when that concept exists. */ + flush(): void; + /** Closes the sync file handle. */ + close(): void; +} + +/** + * Independently useful backend-native file contract. + * + * File drivers own real file mechanics. They do not own OPFS path normalization, + * recursive traversal, facade locks, or higher-level filesystem fallback logic. + */ +export interface FileDriverType extends DriverType { + readonly kind: "file"; + /** Native file behaviors the backend can expose directly. */ + readonly capabilities: FileDriverCapabilitiesType; + /** Returns native metadata for one path, or `null` when it is missing. */ + stat(path: PathType, options?: FileDriverSignalOptionsType): Promise; + /** Materializes a file body or byte range. */ + readFile(path: PathType, options?: FileDriverReadOptionsType): Promise; + /** Writes one complete file body or update request. */ + writeFile(path: PathType, data: Uint8Array, options: FileDriverWriteOptionsType): Promise; + /** Lists direct children without facade recursion. */ + readDir(path: PathType, options?: FileDriverSignalOptionsType): AsyncIterableIterator; + /** Creates one directory node. */ + createDir(path: PathType, options?: FileDriverSignalOptionsType): Promise; + /** Removes one file or directory entry. */ + remove(path: PathType, options?: FileDriverSignalOptionsType): Promise; + /** Opens a native read stream when the backend supports it. */ + openReadStream?(path: PathType, options?: FileDriverReadOptionsType): Promise>; + /** Writes a stream natively when the backend supports it. */ + writeStream?(path: PathType, source: ReadableStream, options: FileDriverWriteOptionsType): Promise; + /** Copies one backend-native entry when the backend supports it. */ + copy?(source: PathType, destination: PathType, options: FileDriverCopyOptionsType): Promise; + /** Moves one backend-native entry when the backend supports it. */ + move?(source: PathType, destination: PathType, options: FileDriverMoveOptionsType): Promise; + /** Opens long-lived asynchronous positional writes when the backend supports them. */ + openWritableFile?(path: PathType): Promise; + /** Opens synchronous random access when the backend supports it. */ + openSyncFile?(path: PathType): Promise; +} + +import { + defineDriver, + type DefineDriverOptionsType, + DriverPlanInputSchema, + type DriverPlanInputType, + DriverPlanSchema, + type DriverPlanType, +} from "./definition.ts"; + +/** + * File mechanics before configured driver metadata is attached. + * + * This lets a concrete runtime implementation focus on backend behavior first. + * `defineFileDriver()` then adds stable inspection, planning, ownership, and + * optimization metadata around that behavior. + */ +export interface FileBackendType { + readonly name: string; + readonly capabilities: FileDriverCapabilitiesType; + stat(path: PathType, options?: FileDriverSignalOptionsType): Promise; + readFile(path: PathType, options?: FileDriverReadOptionsType): Promise; + writeFile(path: PathType, data: Uint8Array, options: FileDriverWriteOptionsType): Promise; + readDir(path: PathType, options?: FileDriverSignalOptionsType): AsyncIterableIterator; + createDir(path: PathType, options?: FileDriverSignalOptionsType): Promise; + remove(path: PathType, options?: FileDriverSignalOptionsType): Promise; + openReadStream?(path: PathType, options?: FileDriverReadOptionsType): Promise>; + writeStream?(path: PathType, source: ReadableStream, options: FileDriverWriteOptionsType): Promise; + copy?(source: PathType, destination: PathType, options: FileDriverCopyOptionsType): Promise; + move?(source: PathType, destination: PathType, options: FileDriverMoveOptionsType): Promise; + openWritableFile?(path: PathType): Promise; + openSyncFile?(path: PathType): Promise; + dispose?(): void | Promise; +} + +/** Construction options for a configured file driver. */ +export interface DefineFileDriverOptionsType extends Omit { + /** Optional backend-native planner override. */ + readonly plan?: (input: DriverPlanInputType) => DriverPlanType; + /** Transfers backend disposal ownership from the caller to the driver. */ + readonly disposeBackend?: boolean; +} + +/** Creates the default file-driver preflight result. */ +function createFilePlan(input: DriverPlanInputType): DriverPlanType { + const request = DriverPlanInputSchema.parse(input); + return DriverPlanSchema.parse({ + operation: request.operation, + supported: true, + support: "native", + problems: [], + actions: [], + }); +} + +/** + * Creates an independently usable file driver over native file mechanics. + * + * Adapters can delegate their primitive methods to this driver while retaining + * adapter-specific route declarations and facade policy above it. + * + * @example Wrap native file mechanics before creating a filesystem adapter. + * ```ts + * import { defineFileDriver } from "@okikio/opfs/driver/file"; + * + * const driver = defineFileDriver(backend, { + * name: "custom-node-like", + * limits: [], + * }); + * ``` + */ +export function defineFileDriver(backend: FileBackendType, options: DefineFileDriverOptionsType): FileDriverType { + const capabilities = FileDriverCapabilitiesSchema.parse(backend.capabilities); + const base = defineDriver({ + ...options, + name: options.name || backend.name, + kind: "file", + provides: options.provides ?? [ + "stat", + "read", + "write", + "list", + "mkdir", + "remove", + ...(backend.openReadStream === undefined ? [] : ["stream-read"]), + ...(backend.writeStream === undefined ? [] : ["stream-write"]), + ...(backend.copy === undefined ? [] : ["copy"]), + ...(backend.move === undefined ? [] : ["move"]), + ...(backend.openWritableFile === undefined ? [] : ["positional-write"]), + ...(backend.openSyncFile === undefined ? [] : ["sync-access"]), + ], + ownership: options.ownership ?? + (backend.dispose === undefined ? "none" : options.disposeBackend ? "owned" : "borrowed"), + plan: options.plan ?? createFilePlan, + ...(options.disposeBackend && backend.dispose !== undefined ? { dispose: () => backend.dispose!() } : {}), + }); + return { + ...base, + kind: "file", + capabilities, + stat: (path, requestOptions) => backend.stat(path, requestOptions), + readFile: (path, requestOptions) => backend.readFile(path, requestOptions), + writeFile: (path, data, requestOptions) => backend.writeFile(path, data, requestOptions), + readDir: (path, requestOptions) => backend.readDir(path, requestOptions), + createDir: (path, requestOptions) => backend.createDir(path, requestOptions), + remove: (path, requestOptions) => backend.remove(path, requestOptions), + ...(backend.openReadStream === undefined ? {} : { + openReadStream: (path: PathType, requestOptions?: FileDriverReadOptionsType) => + backend.openReadStream!(path, requestOptions), + }), + ...(backend.writeStream === undefined ? {} : { + writeStream: (path: PathType, source: ReadableStream, requestOptions: FileDriverWriteOptionsType) => + backend.writeStream!(path, source, requestOptions), + }), + ...(backend.copy === undefined ? {} : { + copy: (source: PathType, destination: PathType, requestOptions: FileDriverCopyOptionsType) => + backend.copy!(source, destination, requestOptions), + }), + ...(backend.move === undefined ? {} : { + move: (source: PathType, destination: PathType, requestOptions: FileDriverMoveOptionsType) => + backend.move!(source, destination, requestOptions), + }), + ...(backend.openWritableFile === undefined + ? {} + : { openWritableFile: (path: PathType) => backend.openWritableFile!(path) }), + ...(backend.openSyncFile === undefined ? {} : { openSyncFile: (path: PathType) => backend.openSyncFile!(path) }), + ...(options.disposeBackend && backend.dispose !== undefined ? { dispose: () => backend.dispose!() } : {}), + }; +} diff --git a/src/driver/kv.ts b/src/driver/kv.ts deleted file mode 100644 index 837a1c0..0000000 --- a/src/driver/kv.ts +++ /dev/null @@ -1,254 +0,0 @@ -import type { InspectionType } from "../capability.ts"; -import type { FileSystemType } from "../filesystem.ts"; -import type { MetricsType } from "../metrics.ts"; -import { joinPath, normalizePath, ROOT_PATH } from "../path.ts"; -import type { PlanInputType, PlanType } from "../plan.ts"; - -/** Metadata returned by the generic key-value driver. */ -export interface KeyValueMetaType { - /** Last modification time when the filesystem exposes one. */ - readonly modified?: Date; -} - -/** Options for the reverse key-value view. */ -export interface KeyValueDriverOptionsType { - /** Virtual directory that contains key data. Defaults to `/`. */ - readonly root?: string; - /** Closes the injected filesystem when the driver closes. */ - readonly disposeFileSystem?: boolean; -} - -/** - * Minimal asynchronous key-value behavior backed by a `FileSystemType`. - * - * This contract is deliberately smaller than unstorage. It is useful for - * ecosystem drivers that need strings/raw bytes plus hierarchical key listing - * without copying the collision-safe key mapping again. - */ -export interface KeyValueDriverType { - /** Returns the exact effective filesystem capabilities, limits, partition policy, and metrics backing this driver. */ - inspect(): InspectionType; - /** Preflights one underlying filesystem operation without touching storage. */ - plan(input: PlanInputType): PlanType; - /** Returns current filesystem metrics without exposing mutable counters. */ - getMetrics(): MetricsType; - /** Tests whether one exact key has a value. */ - has(key: string): Promise; - /** Reads one UTF-8 string value. */ - get(key: string): Promise; - /** Replaces one UTF-8 string value. */ - set(key: string, value: string): Promise; - /** Reads raw bytes. */ - getRaw(key: string): Promise; - /** Replaces one raw value. */ - setRaw(key: string, value: string | Blob | ArrayBuffer | ArrayBufferView): Promise; - /** Removes one exact value. */ - remove(key: string): Promise; - /** Reads filesystem-backed metadata. */ - meta(key: string): Promise; - /** Lists keys below one colon-delimited hierarchy prefix. */ - keys(base?: string, options?: { readonly maxDepth?: number }): Promise; - /** Removes keys below a hierarchy prefix. */ - clear(base?: string, options?: { readonly preserveExact?: boolean }): Promise; - /** Releases explicitly transferred filesystem ownership. */ - dispose(): Promise; -} - -/** Prefix that makes driver-owned key directories distinguishable from ordinary files. */ -const KEY_PREFIX = "key-"; -/** Leaf filename used so both `foo` and `foo:bar` can exist without file/directory collisions. */ -const VALUE_FILE = "value"; - -/** Encodes one logical key segment into one collision-free filesystem name. */ -function encodeSegment(value: string): string { - const encoded = encodeURIComponent(value).replace(/~/g, "%7E").replace(/%/g, "~"); - return `${KEY_PREFIX}${encoded}`; -} - -/** Reverses one driver-owned directory name. */ -function decodeSegment(value: string): string | null { - if (!value.startsWith(KEY_PREFIX)) return null; - return decodeURIComponent(value.slice(KEY_PREFIX.length).replace(/~/g, "%")); -} - -/** Splits the conventional colon hierarchy used by many JavaScript KV APIs. */ -function parts(key: string): string[] { - return key.split(":").filter((part) => part.length > 0).map(encodeSegment); -} - -/** Directory that can contain both the exact value and descendant keys. */ -function directory(root: string, key: string): string { - return joinPath(root, ...parts(key)); -} - -/** Private leaf file storing one exact key value. */ -function path(root: string, key: string): string { - return joinPath(directory(root, key), VALUE_FILE); -} - -/** Converts one driver-owned value file back to the logical key. */ -function key(root: string, value: string): string | null { - const relative = normalizePath(value).slice(root === ROOT_PATH ? 1 : root.length + 1); - if (relative.length === 0) return null; - const pathParts = relative.split("/"); - if (pathParts.pop() !== VALUE_FILE) return null; - const decoded: string[] = []; - for (const pathPart of pathParts) { - const item = decodeSegment(pathPart); - if (item === null) return null; - decoded.push(item); - } - return decoded.join(":"); -} - -/** Counts logical hierarchy separators for depth filtering. */ -function depth(value: string): number { - let count = 0; - for (const character of value) if (character === ":") count += 1; - return count; -} - -/** - * Collision-safe key-value projection over one filesystem. - * - * Each key gets a private directory with a `value` leaf. The extra level solves - * a filesystem mismatch that ordinary `key.replace(":", "/")` mappings miss: - * key-value stores can contain both `foo` and `foo:bar`, while a filesystem - * cannot make `/foo` a file and a directory at the same time. - * - * ```text - * foo -> /key-foo/value - * foo:bar -> /key-foo/key-bar/value - * ``` - * - * The class borrows the filesystem unless `disposeFileSystem` explicitly - * transfers ownership. It never configures storage, logging, or global state. - */ -class KeyValueDriver implements KeyValueDriverType { - /** Filesystem that stores the encoded hierarchy and value leaves. */ - readonly #fileSystem: FileSystemType; - /** Canonical directory below which all key data is stored. */ - readonly #root: string; - /** Whether driver disposal also closes the injected filesystem. */ - readonly #disposeFileSystem: boolean; - - /** Resolves stable driver policy once instead of closing over factory locals. */ - constructor(fileSystem: FileSystemType, options: KeyValueDriverOptionsType) { - this.#fileSystem = fileSystem; - this.#root = normalizePath(options.root ?? ROOT_PATH); - this.#disposeFileSystem = options.disposeFileSystem ?? false; - } - - /** Returns the effective capability and limit report of the backing filesystem. */ - inspect(): InspectionType { - return this.#fileSystem.inspect(); - } - - /** Uses the filesystem planner so reverse ecosystem callers see the same route and size checks. */ - plan(input: PlanInputType): PlanType { - return this.#fileSystem.plan(input); - } - - /** Returns the filesystem's detached metrics snapshot. */ - getMetrics(): MetricsType { - return this.#fileSystem.getMetrics(); - } - - /** Tests whether one encoded value leaf exists as a file. */ - async has(value: string): Promise { - return await this.#fileSystem.exists(path(this.#root, value), { kind: "file" }); - } - - /** Reads one UTF-8 value without treating a missing key as a filesystem failure. */ - async get(value: string): Promise { - const file = path(this.#root, value); - return await this.#fileSystem.exists(file, { kind: "file" }) ? await this.#fileSystem.readText(file) : null; - } - - /** Replaces one UTF-8 value and creates hierarchy directories when needed. */ - async set(value: string, data: string): Promise { - await this.#fileSystem.writeFile(path(this.#root, value), data, { parents: true, mode: "replace" }); - } - - /** Reads one raw byte value without text transcoding. */ - async getRaw(value: string): Promise { - const file = path(this.#root, value); - return await this.#fileSystem.exists(file, { kind: "file" }) ? await this.#fileSystem.readFile(file) : null; - } - - /** Replaces one raw value using the filesystem's normal write-data contract. */ - async setRaw(value: string, data: string | Blob | ArrayBuffer | ArrayBufferView): Promise { - await this.#fileSystem.writeFile(path(this.#root, value), data, { parents: true, mode: "replace" }); - } - - /** Removes only the exact value leaf and leaves descendant keys intact. */ - async remove(value: string): Promise { - const file = path(this.#root, value); - if (await this.#fileSystem.exists(file, { kind: "file" })) await this.#fileSystem.remove(file); - } - - /** Projects filesystem modification time into the small KV metadata contract. */ - async meta(value: string): Promise { - const file = path(this.#root, value); - if (!(await this.#fileSystem.exists(file, { kind: "file" }))) return null; - const valueStat = await this.#fileSystem.stat(file); - return valueStat.kind === "file" ? { modified: new Date(valueStat.lastModified) } : null; - } - - /** - * Lists logical keys below one encoded hierarchy directory. - * - * Traversal remains lazy in the filesystem layer. This method materializes - * only the final key strings because the ecosystem KV contract returns an - * array rather than an iterator. - */ - async keys(base = "", options: { readonly maxDepth?: number } = {}): Promise { - const baseDirectory = directory(this.#root, base); - if (!(await this.#fileSystem.exists(baseDirectory, { kind: "directory" }))) return []; - - const output: string[] = []; - for await (const entry of this.#fileSystem.walk(baseDirectory, { includeFiles: true, includeDirectories: false })) { - const value = key(this.#root, entry.path); - if (value === null) continue; - if (options.maxDepth !== undefined && depth(value) > options.maxDepth) continue; - output.push(value); - } - return output; - } - - /** - * Removes values below one hierarchy prefix while optionally retaining the - * exact base key. - * - * `preserveExact` is needed by unstorage because `foo:` means descendants of - * `foo`, not the exact `foo` value itself. - */ - async clear(base = "", options: { readonly preserveExact?: boolean } = {}): Promise { - const baseDirectory = directory(this.#root, base); - if (!(await this.#fileSystem.exists(baseDirectory, { kind: "directory" }))) return; - if (!options.preserveExact) { - await this.#fileSystem.emptyDir(baseDirectory); - return; - } - - for await (const entry of this.#fileSystem.readDir(baseDirectory)) { - if (entry.kind === "directory") await this.#fileSystem.remove(entry.path, { recursive: true }); - } - } - - /** Closes the injected filesystem only when ownership was explicitly transferred. */ - async dispose(): Promise { - if (this.#disposeFileSystem) await this.#fileSystem.close(); - } -} - -/** - * Exposes any OPFS filesystem as a collision-safe key-value store. - * - * The factory constructs a named driver object instead of defining behavior - * methods inside the factory. This keeps the public call site small while the - * lifecycle and mapping rules remain individually documented and testable. - */ -export function createKeyValueDriver(fileSystem: FileSystemType, options: KeyValueDriverOptionsType = {}): KeyValueDriverType { - return new KeyValueDriver(fileSystem, options); -} diff --git a/src/driver/memory.ts b/src/driver/memory.ts new file mode 100644 index 0000000..f2c230c --- /dev/null +++ b/src/driver/memory.ts @@ -0,0 +1,67 @@ +import type { PathType } from "../path.ts"; +import type { RecordType } from "../schema.ts"; +import { defineRecordDriver, type RecordBackendType, type RecordDriverType } from "./record.ts"; + +/** In-memory record driver with test-friendly inspection helpers. */ +export interface MemoryDriverType extends RecordDriverType { + /** Removes every stored record. */ + clear(): void; + /** Number of stored files and directories, excluding the implicit root. */ + readonly size: number; +} + +/** Deterministic process-local record backend. */ +class MemoryBackend implements RecordBackendType { + readonly capabilities = { + replacement: "atomic", + binary: false, + transactions: false, + } as const; + readonly #records = new Map(); + + get size(): number { + return this.#records.size; + } + + clear(): void { + this.#records.clear(); + } + + async get(path: PathType): Promise { + const record = this.#records.get(path); + return record === undefined ? null : structuredClone(record); + } + + async set(record: RecordType): Promise { + this.#records.set(record.path, structuredClone(record)); + } + + async delete(path: PathType): Promise { + this.#records.delete(path); + } + + async *list(parent: PathType): AsyncIterableIterator { + for (const record of this.#records.values()) { + if (record.parent === parent) yield structuredClone(record); + } + } +} + +/** Creates a deterministic in-memory record driver. */ +export function createMemoryDriver(): MemoryDriverType { + const backend = new MemoryBackend(); + return { + ...defineRecordDriver(backend, { + name: "memory", + ownership: "owned", + requirements: [], + limits: [], + optimizations: [], + capabilities: { replacement: "atomic" }, + }), + get size() { + return backend.size; + }, + clear: () => backend.clear(), + }; +} diff --git a/src/driver/object.ts b/src/driver/object.ts new file mode 100644 index 0000000..c2b11d2 --- /dev/null +++ b/src/driver/object.ts @@ -0,0 +1,275 @@ +import { z } from "zod"; + +import type { AdapterLimitsType } from "../schema.ts"; +import { + defineDriver, + type DefineDriverOptionsType, + DriverPlanInputSchema, + type DriverPlanInputType, + DriverPlanSchema, + type DriverPlanType, + type DriverType, +} from "./definition.ts"; + +/** + * Native behavior exposed by an object-storage backend. + * + * These flags preserve object-store truth. A backend may stream writes, support + * 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({ + rangeRead: z.boolean(), + streamRead: z.boolean(), + streamWrite: z.boolean(), + copy: z.boolean(), + conditionalWrite: z.boolean(), + multipart: z.boolean(), + metadata: z.boolean(), + versions: z.boolean(), +}).strict(); + +/** A validated native object-driver capability description. */ +export type ObjectDriverCapabilitiesType = z.output; + +/** Portable object metadata returned by {@link ObjectBackendType.head}. */ +export interface ObjectStatType { + /** Object length in bytes. */ + readonly size: number; + /** Last-modified Unix epoch milliseconds when the provider reports one. */ + readonly lastModified?: number; + /** Media type retained by the provider when known. */ + readonly mediaType?: string; + /** Entity tag or equivalent provider revision token. */ + readonly etag?: string; + /** Provider version identifier when versioning is enabled. */ + readonly version?: string; + /** Provider metadata retained with the object when available. */ + readonly metadata?: Readonly>; +} + +/** One object returned from a prefix listing. */ +export interface ObjectEntryType extends ObjectStatType { + /** Provider object key. */ + readonly key: string; +} + +/** One page from an object-store prefix listing. */ +export interface ObjectListType { + /** Objects returned in this page. */ + readonly objects: readonly ObjectEntryType[]; + /** Prefix markers returned when the provider groups results hierarchically. */ + readonly prefixes: readonly string[]; + /** Opaque continuation token for the next page. */ + readonly cursor?: string; +} + +/** Options for one object GET. */ +export interface ObjectGetOptionsType { + /** Zero-based byte offset. */ + readonly at?: number; + /** Maximum bytes to return. */ + readonly length?: number; + /** Stops provider work before the response body completes. */ + readonly signal?: AbortSignal; +} + +/** Options for one object PUT. */ +export interface ObjectPutOptionsType { + /** Media type retained with the object when the provider supports it. */ + readonly mediaType?: string; + /** Provider metadata stored with the object when supported. */ + readonly metadata?: Readonly>; + /** Conditional write precondition on the current object revision. */ + readonly ifMatch?: string; + /** Conditional create/write precondition that rejects matching objects. */ + readonly ifNoneMatch?: string; + /** Caller-known size for streamed uploads when the provider benefits from it. */ + readonly size?: number; + /** Stops provider work before the upload commits. */ + readonly signal?: AbortSignal; +} + +/** Options for one provider-side object copy. */ +export interface ObjectCopyOptionsType { + /** Conditional precondition on the destination object. */ + readonly ifMatch?: string; + /** Destination precondition that rejects matching objects. */ + readonly ifNoneMatch?: string; + /** Conditional precondition on the source object revision. */ + readonly sourceIfMatch?: string; + /** Source precondition that rejects matching source revisions. */ + readonly sourceIfNoneMatch?: string; + /** Source precondition based on modification time. */ + readonly sourceIfModifiedSince?: Date; + /** Source precondition that requires an older or equal modification time. */ + readonly sourceIfUnmodifiedSince?: Date; + /** Stops provider work before the server-side copy completes. */ + readonly signal?: AbortSignal; +} + +/** Options for prefix listing. */ +export interface ObjectListOptionsType { + /** Prefix to enumerate. */ + readonly prefix: string; + /** Delimiter used to request hierarchical grouping from the provider. */ + readonly delimiter?: string; + /** Maximum results requested for this page. */ + readonly limit?: number; + /** Opaque continuation token from a previous page. */ + readonly cursor?: string; + /** Stops provider work before the page completes. */ + readonly signal?: AbortSignal; +} + +/** + * Provider/client object mechanics before driver metadata is attached. + * + * This is the honest native object contract. It works for S3, Azure Blob, and + * similar stores before any OPFS translation or filesystem-shaped fallback is + * applied. + */ +export interface ObjectBackendType { + readonly name: string; + readonly capabilities: { + readonly rangeRead: boolean; + readonly streamRead: boolean; + readonly streamWrite: boolean; + readonly copy: boolean; + readonly conditionalWrite: boolean; + readonly multipart?: boolean; + readonly metadata?: boolean; + readonly versions?: boolean; + }; + readonly limits?: AdapterLimitsType; + head(key: string, options?: { readonly signal?: AbortSignal }): Promise; + get(key: string, options?: ObjectGetOptionsType): Promise>; + put( + key: string, + body: Uint8Array | ReadableStream, + options?: ObjectPutOptionsType, + ): Promise; + delete(key: string, options?: { readonly signal?: AbortSignal }): Promise; + list(options: ObjectListOptionsType): Promise; + copy?(source: string, destination: string, options?: ObjectCopyOptionsType): Promise; + dispose?(): void | Promise; +} + +/** + * Independently useful object-storage driver. + * + * The driver keeps object semantics visible even when an adapter later projects + * those objects into a hierarchical filesystem view. + */ +export interface ObjectDriverType extends DriverType, Omit { + readonly kind: "object"; + /** Native object-store behaviors the backend can expose directly. */ + readonly capabilities: ObjectDriverCapabilitiesType; + /** Adapter-oriented numeric limit summary retained for the object translation layer. */ + readonly portableLimits?: AdapterLimitsType; +} + +/** Construction options for an object driver. */ +export interface DefineObjectDriverOptionsType extends Omit { + /** Optional backend-native planner override. */ + readonly plan?: (input: DriverPlanInputType) => DriverPlanType; + /** Transfers backend disposal ownership from the caller to the driver. */ + readonly disposeBackend?: boolean; +} + +/** Creates the default object-driver preflight result from known provider limits. */ +function createObjectPlan(base: DriverType, input: DriverPlanInputType): DriverPlanType { + const request = DriverPlanInputSchema.parse(input); + const maxFile = base.limits.find((limit) => limit.code === "file-bytes" && limit.value !== undefined); + if (request.size !== undefined && maxFile?.value !== undefined && request.size > maxFile.value) { + return DriverPlanSchema.parse({ + operation: request.operation, + supported: false, + support: "unsupported", + problems: [{ + code: "object-too-large", + layer: "driver", + severity: "error", + message: `Requested size ${request.size} exceeds the configured object limit ${maxFile.value}.`, + limit: maxFile, + }], + actions: [{ kind: "reduce-input" }, { kind: "select-driver" }], + }); + } + return DriverPlanSchema.parse({ + operation: request.operation, + supported: true, + support: "native", + problems: [], + actions: [], + }); +} + +/** + * Attaches capability, requirement, limit, and optimization metadata to an + * object backend without changing the provider's object semantics. + * + * @example Wrap an S3-like client contract before creating an object adapter. + * ```ts + * import { defineObjectDriver } from "@okikio/opfs/driver/object"; + * + * const driver = defineObjectDriver(backend, { + * name: "custom-s3", + * limits: [], + * }); + * ``` + */ +export function defineObjectDriver( + backend: ObjectBackendType, + options: DefineObjectDriverOptionsType, +): ObjectDriverType { + const capabilities = ObjectDriverCapabilitiesSchema.parse({ + rangeRead: backend.capabilities.rangeRead, + streamRead: backend.capabilities.streamRead, + streamWrite: backend.capabilities.streamWrite, + copy: backend.capabilities.copy, + conditionalWrite: backend.capabilities.conditionalWrite, + multipart: backend.capabilities.multipart ?? false, + metadata: backend.capabilities.metadata ?? true, + versions: backend.capabilities.versions ?? false, + }); + + let base!: DriverType; + base = defineDriver({ + ...options, + name: options.name || backend.name, + kind: "object", + provides: options.provides ?? [ + "stat", + "read", + "write", + "remove", + "list", + ...(backend.capabilities.rangeRead ? ["range-read"] : []), + ...(backend.capabilities.streamRead ? ["stream-read"] : []), + ...(backend.capabilities.streamWrite ? ["stream-write"] : []), + ...(backend.copy === undefined ? [] : ["copy"]), + ], + ownership: options.ownership ?? + (backend.dispose === undefined ? "none" : options.disposeBackend ? "owned" : "borrowed"), + plan: options.plan ?? ((input) => createObjectPlan(base, input)), + ...(options.disposeBackend && backend.dispose !== undefined ? { dispose: () => backend.dispose!() } : {}), + }); + + return { + ...base, + kind: "object", + capabilities, + ...(backend.limits === undefined ? {} : { portableLimits: backend.limits }), + head: (key, requestOptions) => backend.head(key, requestOptions), + get: (key, requestOptions) => backend.get(key, requestOptions), + put: (key, body, requestOptions) => backend.put(key, body, requestOptions), + delete: (key, requestOptions) => backend.delete(key, requestOptions), + list: (requestOptions) => backend.list(requestOptions), + ...(backend.copy === undefined ? {} : { + copy: (source: string, destination: string, requestOptions?: ObjectCopyOptionsType) => + backend.copy!(source, destination, requestOptions), + }), + ...(options.disposeBackend && backend.dispose !== undefined ? { dispose: () => backend.dispose!() } : {}), + }; +} diff --git a/src/driver/record.ts b/src/driver/record.ts new file mode 100644 index 0000000..9d8f22d --- /dev/null +++ b/src/driver/record.ts @@ -0,0 +1,252 @@ +import { z } from "zod"; + +import type { PathType } from "../path.ts"; +import type { DirectoryRecordType, FileRecordType, RecordType, WriteModeType } from "../schema.ts"; +import { WriteModeSchema } from "../schema.ts"; +import type { FileDriverReadOptionsType, FileDriverWriteOptionsType } from "./file.ts"; +import { + defineDriver, + type DefineDriverOptionsType, + DriverPlanInputSchema, + type DriverPlanInputType, + DriverPlanSchema, + type DriverPlanType, + type DriverType, +} from "./definition.ts"; + +/** + * Metadata returned during direct-child listing without requiring file-body materialization. + * + * Record backends often store whole values or rows. This lighter listing shape + * lets a caller inspect directory structure without paying to fetch file bytes. + */ +export type RecordListType = DirectoryRecordType | Omit; + +/** + * Replacement guarantee exposed by a record-oriented backend. + * + * This tells adapters and callers how honestly they can represent replace-style + * writes when the backend stores logical values rather than byte-addressable + * files. + */ +export const RecordReplacementSchema = z.enum(["atomic", "best-effort", "unknown"]); + +/** A validated record replacement guarantee. */ +export type RecordReplacementType = z.output; + +/** + * Native byte/data behavior exposed by a record driver. + * + * 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({ + rangeRead: z.boolean(), + streamRead: z.boolean(), + write: z.boolean(), + writeModes: z.array(WriteModeSchema).readonly(), + streamWriteModes: z.array(WriteModeSchema).readonly(), + replacement: RecordReplacementSchema, + binary: z.boolean(), + transactions: z.boolean(), +}).strict(); + +/** A validated record-driver capability description. */ +export type RecordDriverCapabilitiesType = z.output; + +/** + * Required persistence mechanics for value, document, and SQL record drivers. + * + * The required operations address complete logical records. Optional byte lanes + * let a backend avoid base64 or complete-value materialization when its physical + * storage can do better. + */ +export interface RecordBackendType { + readonly capabilities?: Partial & { + readonly writeModes?: readonly WriteModeType[]; + readonly streamWriteModes?: readonly WriteModeType[]; + }; + get(path: PathType): Promise; + stat?(path: PathType): Promise; + readFile?(path: PathType, options?: FileDriverReadOptionsType): Promise; + openReadStream?(path: PathType, options?: FileDriverReadOptionsType): Promise>; + writeFile?(path: PathType, data: Uint8Array, options: FileDriverWriteOptionsType): Promise; + writeStream?(path: PathType, source: ReadableStream, options: FileDriverWriteOptionsType): Promise; + set(record: RecordType): Promise; + delete(path: PathType): Promise; + list(parent: PathType): AsyncIterableIterator; + dispose?(): void | Promise; +} + +/** + * Independently useful record-oriented backend driver. + * + * A record driver is the honest native contract for key-value stores, document + * stores, and SQL-backed row persistence that does not naturally behave like a + * byte-addressable directory tree. + */ +export interface RecordDriverType extends DriverType, RecordBackendType { + readonly kind: "record"; + /** Native record-oriented behaviors the backend can expose directly. */ + readonly capabilities: RecordDriverCapabilitiesType; +} + +/** Construction options for {@link defineRecordDriver}. */ +export interface DefineRecordDriverOptionsType extends Omit { + /** Overrides or fills capability facts that the backend does not declare inline. */ + readonly capabilities?: Partial; + /** Disables every mutating driver operation while retaining read/list access. */ + readonly readOnly?: boolean; + /** Optional backend-native planner override. */ + readonly plan?: (input: DriverPlanInputType) => DriverPlanType; + /** Transfers backend disposal ownership from the caller to the driver. */ + readonly disposeBackend?: boolean; +} + +/** Returns whether one preflight operation needs backend mutation. */ +function mutates(operation: DriverPlanInputType["operation"]): boolean { + return operation === "write" || operation === "copy" || operation === "move" || operation === "remove"; +} + +/** Creates a deterministic read-only rejection before backend-specific planning. */ +function readOnlyPlan(input: DriverPlanInputType): DriverPlanType { + const request = DriverPlanInputSchema.parse(input); + return DriverPlanSchema.parse({ + operation: request.operation, + supported: false, + support: "unsupported", + problems: [{ + code: "read-only", + layer: "driver", + severity: "error", + message: `Driver is configured read-only; ${request.operation} requires backend mutation.`, + }], + actions: [{ kind: "change-policy" }, { kind: "select-driver" }], + }); +} + +/** Creates the default record-driver plan from known file size and configured limits. */ +function createRecordPlan(base: DriverType, input: DriverPlanInputType): DriverPlanType { + const request = DriverPlanInputSchema.parse(input); + const maxFile = base.limits.find((limit) => limit.code === "file-bytes" && limit.value !== undefined); + if (request.size !== undefined && maxFile?.value !== undefined && request.size > maxFile.value) { + return DriverPlanSchema.parse({ + operation: request.operation, + supported: false, + support: "unsupported", + problems: [{ + code: "file-too-large", + layer: "driver", + severity: "error", + message: `Requested size ${request.size} exceeds the configured record-driver file limit ${maxFile.value}.`, + limit: maxFile, + }], + actions: [ + { kind: "reduce-input" }, + { kind: "select-driver" }, + ], + }); + } + return DriverPlanSchema.parse({ + operation: request.operation, + supported: true, + support: "native", + problems: [], + actions: [], + }); +} + +/** + * Creates a configured record driver over backend-specific persistence mechanics. + * + * The returned driver can be used directly for logical records or passed to + * `createRecordAdapter()` to expose OPFS filesystem primitives. No global + * registry or application runtime is required. + * + * @example Mark one configured store as read-only. + * ```ts + * import { defineRecordDriver } from "@okikio/opfs/driver/record"; + * + * const driver = defineRecordDriver(backend, { + * name: "readonly-cache", + * readOnly: true, + * }); + * ``` + */ +export function defineRecordDriver( + backend: RecordBackendType, + options: DefineRecordDriverOptionsType, +): RecordDriverType { + const capabilities = RecordDriverCapabilitiesSchema.parse({ + rangeRead: backend.capabilities?.rangeRead ?? false, + streamRead: backend.capabilities?.streamRead ?? false, + writeModes: backend.capabilities?.writeModes ?? [], + streamWriteModes: backend.capabilities?.streamWriteModes ?? [], + replacement: backend.capabilities?.replacement ?? "unknown", + binary: backend.capabilities?.binary ?? false, + transactions: backend.capabilities?.transactions ?? false, + ...options.capabilities, + write: !(options.readOnly ?? false), + }); + + let base!: DriverType; + base = defineDriver({ + ...options, + kind: "record", + provides: options.provides ?? [ + "get", + "list", + ...(options.readOnly ? [] : ["set", "delete"]), + ...(backend.stat === undefined ? [] : ["stat"]), + ...(backend.readFile === undefined ? [] : ["read"]), + ...(backend.openReadStream === undefined ? [] : ["stream-read"]), + ...(backend.writeFile === undefined || options.readOnly ? [] : ["write"]), + ...(backend.writeStream === undefined || options.readOnly ? [] : ["stream-write"]), + ], + ownership: options.ownership ?? + (backend.dispose === undefined ? "none" : options.disposeBackend ? "owned" : "borrowed"), + plan: (input) => { + const request = DriverPlanInputSchema.parse(input); + if ((options.readOnly ?? false) && mutates(request.operation)) return readOnlyPlan(request); + return options.plan === undefined ? createRecordPlan(base, request) : options.plan(request); + }, + ...(options.disposeBackend && backend.dispose !== undefined ? { dispose: () => backend.dispose!() } : {}), + }); + + return { + ...base, + kind: "record", + capabilities, + get: (path) => backend.get(path), + ...(backend.stat === undefined ? {} : { stat: (path: PathType) => backend.stat!(path) }), + ...(backend.readFile === undefined ? {} : { + readFile: (path: PathType, readOptions?: FileDriverReadOptionsType) => backend.readFile!(path, readOptions), + }), + ...(backend.openReadStream === undefined ? {} : { + openReadStream: (path: PathType, readOptions?: FileDriverReadOptionsType) => + backend.openReadStream!(path, readOptions), + }), + ...(backend.writeFile === undefined || options.readOnly ? {} : { + writeFile: (path: PathType, data: Uint8Array, writeOptions: FileDriverWriteOptionsType) => + backend.writeFile!(path, data, writeOptions), + }), + ...(backend.writeStream === undefined || options.readOnly ? {} : { + writeStream: (path: PathType, source: ReadableStream, writeOptions: FileDriverWriteOptionsType) => + backend.writeStream!(path, source, writeOptions), + }), + set: (record) => { + if (options.readOnly) { + throw new Error(`Record driver '${base.name}' is read-only; '${record.path}' cannot be changed.`); + } + return backend.set(record); + }, + delete: (path) => { + if (options.readOnly) { + throw new Error(`Record driver '${base.name}' is read-only; '${path}' cannot be removed.`); + } + return backend.delete(path); + }, + list: (parent) => backend.list(parent), + ...(options.disposeBackend && backend.dispose !== undefined ? { dispose: () => backend.dispose!() } : {}), + }; +} diff --git a/src/driver/rxdb.ts b/src/driver/rxdb.ts new file mode 100644 index 0000000..7baf925 --- /dev/null +++ b/src/driver/rxdb.ts @@ -0,0 +1,194 @@ +import { defineRecordDriver, type RecordBackendType, type RecordDriverType } from "./record.ts"; +import { FileSystemError } from "../error.ts"; +import { RecordSchema, type RecordType } from "../schema.ts"; + +/** + * Maximum canonical path length encoded in the exported RxDB primary/index schema. + * + * RxDB requires `maxLength` on indexed string fields. Keeping one constant makes + * runtime validation and the exported collection schema agree exactly. + */ +const RXDB_PATH_MAX_LENGTH = 4096; + +/** RxDB document methods used by the driver. */ +export interface RxDbDocumentType { + /** Returns document data without RxDB revision metadata. */ + toJSON(withRevisionAndAttachments?: false): unknown; + /** Removes the latest revision safely when concurrent writes are possible. */ + incrementalRemove(): Promise; +} + +/** RxDB query result shape used by the driver. */ +export interface RxDbQueryType { + /** Executes the query against the collection's configured RxStorage. */ + exec(): Promise; +} + +/** + * Structural subset of RxCollection used by this driver. + * + * RxDB collections sit above `RxStorage`, so the same driver works when the + * database was created with memory, IndexedDB, OPFS, filesystem, SQLite, + * DenoKV, MongoDB, or another compatible RxStorage implementation. + */ +export interface RxDbCollectionType { + /** Finds one document by its primary `path`. */ + findOne(primary: string): RxDbQueryType; + /** Finds records by the indexed `parent` field. */ + find(query: { readonly selector: { readonly parent: string } }): RxDbQueryType; + /** Inserts or incrementally replaces one path record. */ + incrementalUpsert(record: RecordType): Promise; +} + +/** + * RxJSONSchema to use for the collection supplied to {@link createRxDbAdapter}. + * + * `path` is the primary key and `parent` is indexed because directory reads are + * direct-parent queries. File bytes remain base64 strings to keep documents + * structured-cloneable across every RxStorage transport. + */ +export const RxDbRecordJsonSchema = Object.freeze( + { + title: "OPFS filesystem record", + description: "One canonical file or directory record used by the @okikio/opfs RxDB driver.", + version: 0, + primaryKey: "path", + type: "object", + properties: { + version: { + type: "number", + description: "@okikio/opfs record format version. This is independent of the RxDB schema version.", + minimum: 1, + maximum: 1, + multipleOf: 1, + }, + path: { + type: "string", + description: "Canonical virtual filesystem path and RxDB primary key.", + maxLength: RXDB_PATH_MAX_LENGTH, + }, + parent: { + type: "string", + description: "Canonical direct-parent path used for directory listing queries.", + maxLength: RXDB_PATH_MAX_LENGTH, + }, + name: { + type: "string", + description: "Final file or directory name without parent path components.", + }, + kind: { + type: "string", + description: "Filesystem entry discriminator.", + enum: ["file", "directory"], + }, + data: { + type: "string", + description: "Base64 file bytes. Required only when kind is file.", + }, + size: { + type: "number", + description: "Decoded file byte length. Required only when kind is file.", + minimum: 0, + multipleOf: 1, + }, + lastModified: { + type: "number", + description: "Last-modified Unix epoch milliseconds.", + minimum: 0, + multipleOf: 1, + }, + mediaType: { + type: "string", + description: "File media type, or an empty string when unknown. Required only when kind is file.", + }, + }, + required: ["version", "path", "parent", "name", "kind", "lastModified"], + oneOf: [ + { + properties: { kind: { enum: ["directory"] } }, + required: ["kind"], + }, + { + properties: { kind: { enum: ["file"] } }, + required: ["kind", "data", "size", "mediaType"], + }, + ], + indexes: ["parent"], + } as const, +); + +/** Rejects paths that the exported RxDB indexed-string schema cannot store. */ +function assertRxDbPath(path: string): void { + if (path.length <= RXDB_PATH_MAX_LENGTH) return; + throw new FileSystemError( + "invalid-path", + "rxdb", + path, + `RxDB driver paths cannot exceed ${RXDB_PATH_MAX_LENGTH} characters.`, + ); +} + +/** + * Record-store projection over one RxDB collection. + * + * RxDB remains authority for revisions, conflicts, replication, and the + * selected `RxStorage`. This class only maps the filesystem record identity to + * collection queries and uses incremental document operations so it does not + * bypass RxDB concurrency semantics. + */ +class RxDbBackend implements RecordBackendType { + /** Collection borrowed from the caller. */ + readonly #collection: RxDbCollectionType; + + /** Binds one prepared collection that uses {@link RxDbRecordJsonSchema}. */ + constructor(collection: RxDbCollectionType) { + this.#collection = collection; + } + + /** Reads one primary-key document and validates its filesystem shape. */ + async get(path: Parameters[0]) { + assertRxDbPath(path); + const document = await this.#collection.findOne(path).exec(); + return document === null ? null : RecordSchema.parse(document.toJSON()); + } + + /** Incrementally inserts or replaces one path record. */ + async set(record: RecordType): Promise { + assertRxDbPath(record.path); + assertRxDbPath(record.parent); + await this.#collection.incrementalUpsert(record); + } + + /** Removes the latest revision of one path when it exists. */ + async delete(path: Parameters[0]): Promise { + assertRxDbPath(path); + const document = await this.#collection.findOne(path).exec(); + if (document !== null) await document.incrementalRemove(); + } + + /** Queries the indexed parent field and yields validated direct children. */ + async *list(parent: Parameters[0]) { + assertRxDbPath(parent); + const documents = await this.#collection.find({ selector: { parent } }).exec(); + for (const document of documents) yield RecordSchema.parse(document.toJSON()); + } +} + +/** Creates an independently useful record driver over one RxDB collection. */ +export function createRxDbDriver(collection: RxDbCollectionType): RecordDriverType { + return defineRecordDriver(new RxDbBackend(collection), { + name: "rxdb", + ownership: "borrowed", + capabilities: { replacement: "atomic", transactions: false, binary: false }, + requirements: [{ code: "rxdb-collection", state: "available" }], + limits: [{ + code: "path-length", + kind: "policy", + source: "implementation", + unit: "count", + value: RXDB_PATH_MAX_LENGTH, + detail: "RxDB indexed path fields require a maximum string length.", + }], + optimizations: [], + }); +} diff --git a/src/driver/unstorage.ts b/src/driver/unstorage.ts index 7e9f273..432937f 100644 --- a/src/driver/unstorage.ts +++ b/src/driver/unstorage.ts @@ -1,179 +1,141 @@ -import type { InspectionType } from "../capability.ts"; -import type { FileSystemType } from "../filesystem.ts"; -import type { MetricsType } from "../metrics.ts"; -import type { PlanInputType, PlanType } from "../plan.ts"; -import { createKeyValueDriver } from "./kv.ts"; +import { defineRecordDriver, type RecordBackendType, type RecordDriverType } from "./record.ts"; +import { normalizePath, type PathType, splitPath } from "../path.ts"; +import { RecordSchema } from "../schema.ts"; -/** Metadata shape understood by unstorage drivers. */ -export interface UnstorageDriverMetaType { - /** Last access time when known. */ - readonly atime?: Date; - /** Last modification time when known. */ - readonly mtime?: Date; - /** Additional provider-specific metadata. */ - readonly [key: string]: string | number | boolean | object | Date | null | undefined; +/** + * Structural subset of unstorage's current `Storage` API used by this driver. + * + * The driver intentionally depends on the high-level Storage object, not a + * specific driver. A storage created with memory, IndexedDB, Redis, S3, db0, + * Cloudflare, filesystem, or another current unstorage driver can therefore be + * supplied without a second OPFS storage implementation. + */ +export interface UnstorageStorageType { + /** Reads one decoded storage value. */ + getItem(key: string, options?: Record): Promise; + /** Stores one serializable value. */ + setItem(key: string, value: T, options?: Record): Promise; + /** Removes one key. */ + removeItem(key: string, options?: Record | boolean): Promise; + /** Lists keys below an optional base key. */ + getKeys(base?: string, options?: Record): Promise; + /** Releases mounted drivers owned by the Storage object. */ + dispose?(): Promise; } -/** Options accepted by unstorage driver methods. */ -export interface UnstorageDriverTransactionOptionsType { - /** Optional traversal depth used by getKeys. */ - readonly maxDepth?: number; - /** Additional unstorage/provider transaction options. */ - readonly [key: string]: unknown; +/** Options for the unstorage-backed record driver. */ +export interface UnstorageDriverOptionsType { + /** Key prefix reserved for filesystem records. Defaults to `opfs`. */ + readonly prefix?: string; + /** Prevents all mutations. Useful with read-only HTTP/GitHub drivers. */ + readonly readOnly?: boolean; + /** Disposes the injected Storage object when the filesystem closes. */ + readonly disposeStorage?: boolean; } -/** Driver contract compatible with unstorage's stable Driver surface used here. */ -export interface UnstorageDriverType { - /** Returns the exact filesystem capabilities, limits, and partition policy behind this driver. */ - inspect(): InspectionType; - /** Preflights one underlying filesystem operation without touching storage. */ - plan(input: PlanInputType): PlanType; - /** Returns current filesystem metrics without exposing mutable counters. */ - getMetrics(): MetricsType; - /** Stable driver identity reported to unstorage. */ - readonly name: string; - /** Declares native interpretation of unstorage's `maxDepth` listing option. */ - readonly flags: { readonly maxDepth: true }; - /** Tests whether one exact unstorage key has a value. */ - hasItem(key: string, options: UnstorageDriverTransactionOptionsType): Promise; - /** Reads one value as UTF-8 text. */ - getItem(key: string, options?: UnstorageDriverTransactionOptionsType): Promise; - /** Replaces one value from UTF-8 text. */ - setItem(key: string, value: string, options: UnstorageDriverTransactionOptionsType): Promise; - /** Reads one value without text decoding. */ - getItemRaw(key: string, options: UnstorageDriverTransactionOptionsType): Promise; - /** Replaces one value from raw byte-compatible input. */ - setItemRaw(key: string, value: string | Blob | ArrayBuffer | ArrayBufferView, options: UnstorageDriverTransactionOptionsType): Promise; - /** Removes one exact value while preserving descendants. */ - removeItem(key: string, options: UnstorageDriverTransactionOptionsType): Promise; - /** Returns filesystem-backed metadata for one exact value. */ - getMeta(key: string, options: UnstorageDriverTransactionOptionsType): Promise; - /** Lists colon-delimited descendant keys below one base prefix. */ - getKeys(base: string, options: UnstorageDriverTransactionOptionsType): Promise; - /** Removes descendants below one base prefix according to unstorage clear semantics. */ - clear(base: string, options: UnstorageDriverTransactionOptionsType): Promise; - /** Releases filesystem ownership only when creation explicitly transferred it. */ - dispose(): Promise; +/** Encodes one virtual path name into an unstorage key segment without `:` or `%`. */ +function encodeSegment(value: string): string { + return encodeURIComponent(value).replace(/~/g, "%7E").replace(/%/g, "~"); } -/** Options for the reverse unstorage driver. */ -export interface UnstorageDriverOptionsType { - /** Virtual directory that contains unstorage keys. Defaults to `/`. */ - readonly root?: string; - /** Closes the injected filesystem when unstorage disposes the driver. */ - readonly disposeFileSystem?: boolean; +/** Reverses {@link encodeSegment} for keys owned by this driver. */ +function decodeSegment(value: string): string { + return decodeURIComponent(value.replace(/~/g, "%")); } -/** - * unstorage-compatible view over the generic filesystem key-value driver. - * - * The class owns only unstorage naming and `maxDepth` translation. Key - * encoding, prefix collisions, filesystem ownership, and value persistence - * remain in {@link KeyValueDriverType}. - */ -class UnstorageDriver implements UnstorageDriverType { - /** Stable driver name reported to unstorage. */ - readonly name = "@okikio/opfs"; - /** Declares that this driver interprets unstorage's `maxDepth` option. */ - readonly flags = { maxDepth: true } as const; - /** Generic KV projection that owns filesystem mapping and optional disposal. */ - readonly #driver: ReturnType; - - /** Creates one unstorage projection over the already-configured filesystem. */ - constructor(fileSystem: FileSystemType, options: UnstorageDriverOptionsType) { - this.#driver = createKeyValueDriver(fileSystem, options); - } - - /** Returns the effective capability and limit report of the backing filesystem. */ - inspect(): InspectionType { - return this.#driver.inspect(); - } - - /** Uses the backing filesystem planner without duplicating storage policy in the unstorage layer. */ - plan(input: PlanInputType): PlanType { - return this.#driver.plan(input); - } - - /** Returns the backing filesystem's detached metrics snapshot. */ - getMetrics(): MetricsType { - return this.#driver.getMetrics(); - } - - /** Tests one exact unstorage key. */ - async hasItem(key: string, _options: UnstorageDriverTransactionOptionsType): Promise { - return await this.#driver.has(key); - } +/** Removes trailing unstorage separators while retaining a non-empty namespace. */ +function normalizePrefix(prefix: string): string { + return prefix.replace(/:+$/g, "") || "opfs"; +} - /** Reads one UTF-8 unstorage value or `null` when absent. */ - async getItem(key: string, _options?: UnstorageDriverTransactionOptionsType): Promise { - return await this.#driver.get(key); - } +/** Maps one canonical virtual path to the private unstorage record namespace. */ +function getKey(prefix: string, path: string): string { + const parts = splitPath(path); + return parts.length === 0 ? `${prefix}:entry` : `${prefix}:entry:${parts.map(encodeSegment).join(":")}`; +} - /** Replaces one UTF-8 unstorage value. */ - async setItem(key: string, value: string, _options: UnstorageDriverTransactionOptionsType): Promise { - await this.#driver.set(key, value); - } +/** Maps a driver-owned unstorage key back to a canonical path, or null for foreign keys. */ +function getPath(prefix: string, key: string): PathType | null { + const base = `${prefix}:entry`; + if (key === base) return "/"; + if (!key.startsWith(`${base}:`)) return null; + const encoded = key.slice(base.length + 1).split(":"); + return normalizePath(encoded.map(decodeSegment).join("/")); +} - /** Reads one raw unstorage value without text transcoding. */ - async getItemRaw(key: string, _options: UnstorageDriverTransactionOptionsType): Promise { - return await this.#driver.getRaw(key); +/** + * Record-store projection over any compatible unstorage `Storage` instance. + * + * The class targets the high-level Storage surface rather than one driver. + * Driver-specific replication, retries, durability, limits, and provider SDKs + * remain owned by unstorage and the selected driver. + */ +class UnstorageBackend implements RecordBackendType { + /** unstorage Storage borrowed from or transferred by the caller. */ + readonly #storage: UnstorageStorageType; + /** Reserved unstorage namespace for filesystem records. */ + readonly #prefix: string; + /** Whether disposal also disposes the injected Storage. */ + readonly #disposeStorage: boolean; + + /** Resolves namespace and ownership policy once. */ + constructor(storage: UnstorageStorageType, options: UnstorageDriverOptionsType) { + this.#storage = storage; + this.#prefix = normalizePrefix(options.prefix ?? "opfs"); + this.#disposeStorage = options.disposeStorage ?? false; } - /** Replaces one raw unstorage value. */ - async setItemRaw( - key: string, - value: string | Blob | ArrayBuffer | ArrayBufferView, - _options: UnstorageDriverTransactionOptionsType, - ): Promise { - await this.#driver.setRaw(key, value); + /** Reads and validates one exact unstorage record. */ + async get(path: PathType) { + const value = await this.#storage.getItem(getKey(this.#prefix, path)); + return value === null ? null : RecordSchema.parse(value); } - /** Removes only the exact unstorage key. */ - async removeItem(key: string, _options: UnstorageDriverTransactionOptionsType): Promise { - await this.#driver.remove(key); + /** Replaces one exact unstorage record. */ + async set(record: Parameters[0]): Promise { + await this.#storage.setItem(getKey(this.#prefix, record.path), record); } - /** Projects filesystem modification time into unstorage metadata. */ - async getMeta(key: string, _options: UnstorageDriverTransactionOptionsType): Promise { - const meta = await this.#driver.meta(key); - return meta === null || meta.modified === undefined ? null : { mtime: meta.modified }; + /** Removes one exact unstorage record. */ + async delete(path: PathType): Promise { + await this.#storage.removeItem(getKey(this.#prefix, path)); } /** - * Lists keys with unstorage's trailing-colon descendant semantics. + * Lists direct children below one encoded directory key. * - * A base such as `foo:` excludes the exact `foo` value while retaining - * descendants. The generic KV layer deliberately does not own that - * unstorage-specific rule. + * `maxDepth: 1` is an optional upstream optimization. Correctness still comes + * from the explicit path-depth filter because not every unstorage driver + * advertises or honors the same listing acceleration. */ - async getKeys(base: string, options: UnstorageDriverTransactionOptionsType): Promise { - const exactBase = base.replace(/:+$/g, ""); - const excludesExactBase = base.endsWith(":") && exactBase.length > 0; - const values = await this.#driver.keys(base, options.maxDepth === undefined ? undefined : { maxDepth: options.maxDepth }); - return excludesExactBase ? values.filter((key) => key !== exactBase) : values; - } - - /** Removes a key subtree while preserving the exact base for trailing-colon calls. */ - async clear(base: string, _options: UnstorageDriverTransactionOptionsType): Promise { - await this.#driver.clear(base, { preserveExact: base.endsWith(":") && base.replace(/:+$/g, "").length > 0 }); + async *list(parent: PathType) { + const parentDepth = splitPath(parent).length; + const keys = await this.#storage.getKeys(getKey(this.#prefix, parent), { maxDepth: 1 }); + for (const storageKey of keys) { + const path = getPath(this.#prefix, storageKey); + if (path === null || path === parent || splitPath(path).length !== parentDepth + 1) continue; + const value = await this.#storage.getItem(storageKey); + if (value !== null) yield RecordSchema.parse(value); + } } - /** Releases optional filesystem ownership through the generic driver. */ + /** Disposes the injected Storage only when ownership was explicitly transferred. */ async dispose(): Promise { - await this.#driver.dispose(); + if (this.#disposeStorage) await this.#storage.dispose?.(); } } -/** - * Creates an unstorage driver backed by this package's filesystem facade. - * - * This is the reverse direction of `createUnstorageAdapter()`. The generic - * key-value driver owns the collision-safe filesystem mapping, while the - * unstorage class translates method names and `maxDepth` behavior. - */ +/** Creates an independently useful record driver from one unstorage `Storage`. */ export function createUnstorageDriver( - fileSystem: FileSystemType, + storage: UnstorageStorageType, options: UnstorageDriverOptionsType = {}, -): UnstorageDriverType { - return new UnstorageDriver(fileSystem, options); +): RecordDriverType { + return defineRecordDriver(new UnstorageBackend(storage, options), { + name: "unstorage", + capabilities: { replacement: "best-effort", transactions: false, binary: false }, + requirements: [{ code: "unstorage", state: "available" }], + optimizations: [], + readOnly: options.readOnly ?? false, + disposeBackend: options.disposeStorage ?? false, + }); } diff --git a/src/error.ts b/src/error.ts index cd07532..5b2d87f 100644 --- a/src/error.ts +++ b/src/error.ts @@ -37,7 +37,6 @@ export function getErrorName(error: unknown): string { return "Error"; } - /** Returns a runtime error code such as `ENOENT` when one is exposed. */ function getRuntimeErrorCode(error: unknown): string | undefined { if (typeof error !== "object" || error === null || !("code" in error)) return undefined; @@ -67,27 +66,47 @@ export function toFileSystemError(error: unknown, operation: string, path?: stri const runtimeCode = getRuntimeErrorCode(error); let code: FileSystemError["code"] = "unknown"; switch (runtimeCode ?? name) { - case "AbortError": code = "aborted"; break; + case "AbortError": + code = "aborted"; + break; case "NotFoundError": - case "ENOENT": code = "not-found"; break; + case "ENOENT": + code = "not-found"; + break; case "AlreadyExists": - case "EEXIST": code = "already-exists"; break; + case "EEXIST": + code = "already-exists"; + break; case "TypeMismatchError": case "ENOTDIR": - case "EISDIR": code = "type-mismatch"; break; + case "EISDIR": + code = "type-mismatch"; + break; case "NoModificationAllowedError": - case "EBUSY": code = "locked"; break; + case "EBUSY": + code = "locked"; + break; case "QuotaExceededError": - case "ENOSPC": code = "quota-exceeded"; break; + case "ENOSPC": + code = "quota-exceeded"; + break; case "NotAllowedError": case "SecurityError": case "EACCES": - case "EPERM": code = "permission-denied"; break; + case "EPERM": + code = "permission-denied"; + break; case "NotSupportedError": - case "ENOTSUP": code = "not-supported"; break; + case "ENOTSUP": + code = "not-supported"; + break; case "InvalidModificationError": - case "InvalidStateError": code = "invalid-operation"; break; - case "UnknownError": code = operation === "open" ? "unavailable" : "unknown"; break; + case "InvalidStateError": + code = "invalid-operation"; + break; + case "UnknownError": + code = operation === "open" ? "unavailable" : "unknown"; + break; } const location = path === undefined ? "" : ` '${path}'`; diff --git a/src/filesystem.ts b/src/filesystem.ts index 11f5981..f88acda 100644 --- a/src/filesystem.ts +++ b/src/filesystem.ts @@ -1,9 +1,5 @@ -import type { - AdapterDirectoryEntryType, - AdapterSignalOptionsType, - AdapterType, - FileSystemOptionsType, -} from "./adapter/definition.ts"; +import type { AdapterType, FileSystemOptionsType } from "./adapter/definition.ts"; +import type { FileDriverDirectoryEntryType, FileDriverSignalOptionsType } from "./driver/file.ts"; import { FileSystemError, throwIfAborted, toFileSystemError } from "./error.ts"; import { MutationLocks } from "./lock.ts"; import { basename, dirname, isAncestorPath, joinPath, normalizePath, ROOT_PATH, splitPath } from "./path.ts"; @@ -24,12 +20,12 @@ import { isReadableStream, toBytes, toByteStream, - type WriteDataType, withAbortSignal, + type WriteDataType, } from "./stream.ts"; import { ManagedSyncFile, type SyncFileType } from "./sync.ts"; import { ManagedWritableFile, type WritableFileType } from "./writable.ts"; -import { DirectoryHandle, FileHandle, type DirectoryHandleType, type FileHandleType } from "./handle.ts"; +import { DirectoryHandle, type DirectoryHandleType, FileHandle, type FileHandleType } from "./handle.ts"; /** Default lock namespace used when the caller does not provide one. */ const DEFAULT_LOCK_PREFIX = "@okikio/opfs"; @@ -46,7 +42,12 @@ const DEFAULT_OPTIMIZATIONS: OptimizationType = { nativeMove: true, }; -/** Options for operations that support cancellation. */ +/** + * Options for operations that support cancellation. + * + * Cancellation is best-effort across every backend. Work that already committed + * at the provider or host runtime may still be visible after an abort. + */ export interface SignalOptionsType { /** Stops work that has not committed yet. */ readonly signal?: AbortSignal; @@ -305,9 +306,18 @@ function getBufferLimit(value: number | undefined): number { return limit; } +/** Parses one facade option and reports invalid caller configuration as `TypeError`. */ +function parseOption(schema: { parse(value: unknown): T }, value: unknown, name: string): T { + try { + return schema.parse(value); + } catch (cause) { + throw new TypeError(`Invalid filesystem ${name}.`, { cause }); + } +} + /** Resolves a partial optimization policy to the strict public shape. */ function getOptimizations(value: FileSystemOptionsType["optimizations"]): OptimizationType { - return OptimizationSchema.parse({ ...DEFAULT_OPTIMIZATIONS, ...value }); + return parseOption(OptimizationSchema, { ...DEFAULT_OPTIMIZATIONS, ...value }, "optimizations"); } /** Computes the logical file size produced by one materialized write. */ @@ -325,7 +335,7 @@ function getWriteSize( } /** Projects a facade cancellation signal into the adapter operation contract. */ -function getAdapterSignalOptions(signal: AbortSignal | undefined): AdapterSignalOptionsType { +function getAdapterSignalOptions(signal: AbortSignal | undefined): FileDriverSignalOptionsType { return signal === undefined ? {} : { signal }; } @@ -380,7 +390,7 @@ async function ensureParents(adapter: AdapterType, path: string, signal?: AbortS function makeDirectoryEntry( fileSystem: FileSystemType, parent: string, - entry: AdapterDirectoryEntryType, + entry: FileDriverDirectoryEntryType, ): DirectoryEntryType { const path = joinPath(parent, entry.name); return { @@ -460,30 +470,34 @@ class FileSystemFacade implements FileSystemType { this.adapter = adapter; this.maxBufferedWriteBytes = getBufferLimit(options.maxBufferedWriteBytes); this.optimizations = getOptimizations(options.optimizations); - this.metricsMode = MetricsModeSchema.parse(options.metrics ?? "basic"); + this.metricsMode = parseOption(MetricsModeSchema, options.metrics ?? "basic", "metrics mode"); this.#metrics = new Metrics(this.metricsMode); this.#locks = new MutationLocks( - CoordinationModeSchema.parse(options.coordination ?? "auto"), + parseOption(CoordinationModeSchema, options.coordination ?? "auto", "coordination mode"), options.lockPrefix ?? DEFAULT_LOCK_PREFIX, ); this.#disposeAdapter = options.disposeAdapter ?? false; this.root = new DirectoryHandle(this, ROOT_PATH); } - /** Returns effective support, configured limits, policy, and a current metrics snapshot. */ inspect(): InspectionType { this.#assertOpen(); + const driverMetrics = this.adapter.driver.getMetrics?.(); return { - adapter: this.adapter.name, - native: this.adapter.capabilities, + driver: this.adapter.driver.inspect(), + adapter: { + name: this.adapter.name, + native: this.adapter.capabilities, + ...(this.adapter.limits === undefined ? {} : { limits: this.adapter.limits }), + ...(this.adapter.partition === undefined ? {} : { partition: this.adapter.partition }), + }, support: getSupport(this.adapter, this.optimizations), - limits: this.adapter.limits ?? {}, - ...(this.adapter.partition === undefined ? {} : { partition: this.adapter.partition }), optimizations: this.optimizations, maxBufferedWriteBytes: this.maxBufferedWriteBytes, metricsMode: this.metricsMode, metrics: this.#metrics.snapshot(), + ...(driverMetrics === undefined ? {} : { driverMetrics }), }; } @@ -502,11 +516,17 @@ class FileSystemFacade implements FileSystemType { return this.#metrics.snapshot(); } - /** Selects partitioned accounting when a configured physical layout will split a known logical value. */ + /** Selects partitioned accounting only when facade emulation did not already define the route. */ #support(route: SupportModeType, bytes?: number): SupportModeType { + // Facade materialization is the most important logical cost to report. The + // nested driver plan still describes any partitioned physical layout used + // after the buffer has been materialized. + if (route === "emulated") return route; const partition = this.adapter.partition; if (partition === undefined || partition.mode === "never" || bytes === undefined) return route; - return partition.mode === "always" || bytes > (partition.thresholdBytes ?? partition.partBytes) ? "partitioned" : route; + return partition.mode === "always" || bytes > (partition.thresholdBytes ?? partition.partBytes) + ? "partitioned" + : route; } /** Rejects all operations after the caller closes this facade. */ @@ -810,7 +830,9 @@ class FileSystemFacade implements FileSystemType { if (ranged && this.adapter.capabilities.rangeRead && !this.optimizations.rangeRead) { const complete = await this.adapter.readFile(normalized, getAdapterSignalOptions(options.signal)); const at = options.at ?? 0; - const end = options.length === undefined ? complete.byteLength : Math.min(complete.byteLength, at + options.length); + const end = options.length === undefined + ? complete.byteLength + : Math.min(complete.byteLength, at + options.length); bytes = complete.subarray(Math.min(at, complete.byteLength), end); } else { bytes = await this.adapter.readFile(normalized, { @@ -923,12 +945,14 @@ class FileSystemFacade implements FileSystemType { metricSupport = getSupport(this.adapter, this.optimizations).streamWrite[mode]; let source = toByteStream(data); if (this.metricsMode !== "none") { - source = source.pipeThrough(new TransformStream({ - transform(chunk, controller) { - metricBytes = (metricBytes ?? 0) + chunk.byteLength; - controller.enqueue(chunk); - }, - })); + source = source.pipeThrough( + new TransformStream({ + transform(chunk, controller) { + metricBytes = (metricBytes ?? 0) + chunk.byteLength; + controller.enqueue(chunk); + }, + }), + ); } await this.adapter.writeStream!(normalized, source, adapterOptions); } else if (stream) { @@ -1067,7 +1091,8 @@ class FileSystemFacade implements FileSystemType { }; const streamRead = this.optimizations.streamRead && this.adapter.capabilities.streamRead && this.adapter.openReadStream !== undefined; - const streamWrite = this.optimizations.streamWrite && this.adapter.capabilities.streamWriteModes.includes("replace") && + const streamWrite = this.optimizations.streamWrite && + this.adapter.capabilities.streamWriteModes.includes("replace") && this.adapter.writeStream !== undefined; if (streamRead) { @@ -1286,7 +1311,12 @@ class FileSystemFacade implements FileSystemType { } if (stat === null) { if (!options.create) { - throw new FileSystemError("not-found", "open-writable-file", normalized, `File '${normalized}' does not exist.`); + throw new FileSystemError( + "not-found", + "open-writable-file", + normalized, + `File '${normalized}' does not exist.`, + ); } if (options.parents) await ensureParents(this.adapter, dirname(normalized), options.signal); const parent = await this.adapter.stat(dirname(normalized), getAdapterSignalOptions(options.signal)); @@ -1407,6 +1437,11 @@ class FileSystemFacade implements FileSystemType { /** * Creates the OPFS-shaped facade over any filesystem adapter. * + * This is the point where the architecture becomes user-facing. The selected + * driver still owns native requirements, limits, metrics, and disposal rules. + * The adapter still owns the primitive translation. This function only adds the + * portable OPFS-shaped behavior above those layers. + * * @example Deno-backed frontend * ```ts * import { createFileSystem } from "@okikio/opfs"; diff --git a/src/handle.ts b/src/handle.ts index faa5a46..ba5f792 100644 --- a/src/handle.ts +++ b/src/handle.ts @@ -26,25 +26,25 @@ export interface CreateWritableOptionsType { /** Command accepted by {@link WritableFileStreamType.write}. */ export type WriteCommandType = | { - /** Selects a byte write command. */ - readonly type: "write"; - /** Optional explicit position. Omit it to use the staged stream cursor. */ - readonly position?: number; - /** Materialized write input inserted at the selected position. */ - readonly data: Exclude | AsyncIterable>; - } + /** Selects a byte write command. */ + readonly type: "write"; + /** Optional explicit position. Omit it to use the staged stream cursor. */ + readonly position?: number; + /** Materialized write input inserted at the selected position. */ + readonly data: Exclude | AsyncIterable>; + } | { - /** Selects a cursor movement without changing file bytes. */ - readonly type: "seek"; - /** New zero-based staged cursor position. */ - readonly position: number; - } + /** Selects a cursor movement without changing file bytes. */ + readonly type: "seek"; + /** New zero-based staged cursor position. */ + readonly position: number; + } | { - /** Selects a staged file-size change. */ - readonly type: "truncate"; - /** New non-negative staged file size. */ - readonly size: number; - }; + /** Selects a staged file-size change. */ + readonly type: "truncate"; + /** New non-negative staged file size. */ + readonly size: number; + }; /** Input accepted by OPFS-compatible writable handles. */ export type WritableChunkType = diff --git a/src/integration.ts b/src/integration.ts new file mode 100644 index 0000000..6d69655 --- /dev/null +++ b/src/integration.ts @@ -0,0 +1,2 @@ +export * from "./integration/definition.ts"; +export * from "./integration/mod.ts"; diff --git a/src/integration/definition.ts b/src/integration/definition.ts new file mode 100644 index 0000000..9f63671 --- /dev/null +++ b/src/integration/definition.ts @@ -0,0 +1,120 @@ +import { z } from "zod"; + +import type { AdapterType } from "../adapter/definition.ts"; +import type { FileSystemType } from "../filesystem.ts"; +import { AdapterNameSchema } from "../schema.ts"; + +/** + * Support declaration for one bridge direction. + * + * A direction is either real or intentionally absent. The definition does not + * 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({ + /** Whether the direction has a real constructor. */ + supported: z.boolean(), + /** Concrete reason when the direction is intentionally unsupported. */ + reason: z.string().min(1).optional(), +}).strict().superRefine((value, ctx) => { + if (!value.supported && value.reason === undefined) { + ctx.addIssue({ code: "custom", message: "Unsupported bridge directions require a reason." }); + } +}); + +/** A validated bridge-direction support declaration. */ +export type IntegrationDirectionType = z.output; + +/** + * Directions one ecosystem integration can expose. + * + * `toOpfs` describes projection into an OPFS adapter path. `fromOpfs` describes + * projection back out of `FileSystemType` into a real ecosystem contract. + */ +export const IntegrationDirectionsSchema = z.object({ + /** Ecosystem/native resource projected into the OPFS filesystem model. */ + toOpfs: IntegrationDirectionSchema, + /** OPFS filesystem projected back into the ecosystem's expected contract. */ + fromOpfs: IntegrationDirectionSchema, +}).strict(); + +/** Validated bridge direction declaration. */ +export type IntegrationDirectionsType = z.output; + +/** + * Import-safe integration definition for ecosystems that support one or both directions. + * + * A driver owns backend-native persistence, an adapter translates a driver into + * OPFS primitives, and a bridge implements a real OPFS-to-ecosystem contract. + * This definition only describes which directions exist; it is not itself a bridge. + */ +export interface IntegrationType { + /** Stable integration name. */ + readonly name: string; + /** Directions implemented without inventing unsupported synchronous semantics. */ + readonly directions: IntegrationDirectionsType; + /** + * Projects an ecosystem or native resource into an OPFS adapter when supported. + * + * This direction usually starts from a runtime-owned client, database, cache, + * or filesystem resource. + */ + readonly toOpfs?: (source: Source, options?: ToOptions) => AdapterType | Promise; + /** + * Projects an OPFS facade into the ecosystem's own contract when supported. + * + * This direction must implement a real ecosystem contract, not a nominal + * wrapper that only renames methods. + */ + readonly fromOpfs?: (fileSystem: FileSystemType, options?: FromOptions) => Target | Promise; +} + +/** + * Normalizes schema failures so extension-definition mistakes have one public error class. + * + * Third-party integrations usually validate during startup or test setup, where + * a single predictable `TypeError` is easier to surface than raw schema details. + */ +function parseIntegration(label: string, schema: { parse(value: unknown): T }, value: unknown): T { + try { + return schema.parse(value); + } catch (cause) { + throw new TypeError(`Invalid integration ${label}.`, { cause }); + } +} + +/** + * Validates one import-safe third-party integration definition. + * + * Direction flags and constructors must agree. This catches definitions that + * advertise a route without providing its constructor while still allowing + * honest one-way integrations when the opposite ecosystem contract cannot be + * implemented from {@link FileSystemType}. No global registry is mutated. + * + * @example Declare a one-way integration into OPFS. + * ```ts + * import { defineIntegration } from "@okikio/opfs/integration/definition"; + * + * const integration = defineIntegration({ + * name: "memory", + * directions: { + * toOpfs: { supported: true }, + * fromOpfs: { supported: false, reason: "No reverse bridge is implemented." }, + * }, + * toOpfs: (source) => source, + * }); + * ``` + */ +export function defineIntegration( + bridge: IntegrationType, +): IntegrationType { + parseIntegration("name", AdapterNameSchema, bridge.name); + const directions = parseIntegration("directions", IntegrationDirectionsSchema, bridge.directions); + if (directions.toOpfs.supported !== (bridge.toOpfs !== undefined)) { + throw new TypeError(`Integration '${bridge.name}' toOpfs direction does not match its constructor.`); + } + if (directions.fromOpfs.supported !== (bridge.fromOpfs !== undefined)) { + throw new TypeError(`Integration '${bridge.name}' fromOpfs direction does not match its constructor.`); + } + return bridge; +} diff --git a/src/integration/mod.ts b/src/integration/mod.ts new file mode 100644 index 0000000..62aa70b --- /dev/null +++ b/src/integration/mod.ts @@ -0,0 +1,94 @@ +import { createDb0Adapter, type Db0AdapterOptionsType, type Db0DatabaseType } from "../adapter/db0.ts"; +import { createDrizzleAdapter, type DrizzleAdapterOptionsType, type DrizzleTableType } from "../adapter/drizzle.ts"; +import { createRxDbAdapter, type RxDbCollectionType } from "../adapter/rxdb.ts"; +import { + createUnstorageAdapter, + type UnstorageAdapterOptionsType, + type UnstorageStorageType, +} from "../adapter/unstorage.ts"; +import { + createUnstorageBridge, + type UnstorageBridgeOptionsType, + type UnstorageBridgeType, +} from "../bridge/unstorage.ts"; +import { defineIntegration, type IntegrationType } from "./definition.ts"; + +/** unstorage can back `FileSystemType` and also has a real reverse unstorage Driver bridge. */ +export const UnstorageIntegration: IntegrationType< + UnstorageStorageType, + UnstorageBridgeType, + UnstorageAdapterOptionsType, + UnstorageBridgeOptionsType +> = defineIntegration({ + name: "unstorage", + directions: { + toOpfs: { supported: true }, + fromOpfs: { supported: true }, + }, + toOpfs: createUnstorageAdapter, + fromOpfs: createUnstorageBridge, +}); + +/** An RxDB collection can back `FileSystemType`; reverse support requires the complete RxStorage contract. */ +export const RxDbIntegration: IntegrationType< + RxDbCollectionType, + never, + void, + never +> = defineIntegration({ + name: "rxdb", + directions: { + toOpfs: { supported: true }, + fromOpfs: { + supported: false, + reason: + "A reverse bridge must implement the complete RxStorage conflict, query, checkpoint, change-stream, cleanup, and lifecycle contracts.", + }, + }, + toOpfs: createRxDbAdapter, +}); + +/** db0 can back `FileSystemType`; `FileSystemType` does not provide SQL database semantics. */ +export const Db0Integration: IntegrationType< + Db0DatabaseType, + never, + Db0AdapterOptionsType, + never +> = defineIntegration({ + name: "db0", + directions: { + toOpfs: { supported: true }, + fromOpfs: { + supported: false, + reason: "A filesystem does not provide db0 SQL query and dialect semantics.", + }, + }, + toOpfs: createDb0Adapter, +}); + +/** Wrapper input for the generic Drizzle integration definition. */ +export interface DrizzleIntegrationSourceType< + TDatabase extends object = object, + TTable extends DrizzleTableType = DrizzleTableType, +> { + /** Caller-owned database/table options passed to the Drizzle record driver. */ + readonly options: DrizzleAdapterOptionsType; +} + +/** Drizzle can back `FileSystemType`; a filesystem cannot emulate Drizzle query/dialect behavior. */ +export const DrizzleIntegration: IntegrationType< + DrizzleIntegrationSourceType, + never, + void, + never +> = defineIntegration({ + name: "drizzle", + directions: { + toOpfs: { supported: true }, + fromOpfs: { + supported: false, + reason: "A filesystem cannot safely emulate Drizzle schema, dialect, and query-builder semantics.", + }, + }, + toOpfs: (source) => createDrizzleAdapter(source.options), +}); diff --git a/src/metrics.ts b/src/metrics.ts index 8e8a11e..f46cc8b 100644 --- a/src/metrics.ts +++ b/src/metrics.ts @@ -48,6 +48,30 @@ export interface MetricsType { readonly operations: Readonly>>; } +/** Physical backend counters reported by a configured driver when available. */ +export interface DriverMetricsType { + /** Concrete provider or operating-system requests observed by the driver. */ + readonly requests?: number; + /** Additional provider requests made after retryable failures. */ + readonly retries?: number; + /** Terminal backend operations or requests that failed. */ + readonly failures?: number; + /** Provider responses received when the backend has a request/response protocol. */ + readonly responses?: number; + /** Logical payload bytes accepted from or returned to the adapter when measured. */ + readonly logicalBytes?: number; + /** Physical bytes transferred to or from the backend when measurable. */ + readonly physicalBytes?: number; + /** Physical partitions, blocks, chunks, or multipart parts processed. */ + readonly parts?: number; + /** Highest simultaneous backend work count observed. */ + readonly peakActive?: number; + /** Total measured backend wall-clock duration. */ + readonly durationMs?: number; + /** Time spent releasing failed multipart/partition state when measured. */ + readonly cleanupDurationMs?: number; +} + /** Mutable form retained privately so snapshots cannot mutate live counters. */ interface MutableMetricType { /** Completed attempts, including failures. */ diff --git a/src/plan.ts b/src/plan.ts index 16439c4..b0a5c74 100644 --- a/src/plan.ts +++ b/src/plan.ts @@ -2,220 +2,287 @@ import { z } from "zod"; import type { AdapterType } from "./adapter/definition.ts"; import { getSupport } from "./capability.ts"; +import { + ActionSchema, + type ActionType, + DriverPlanSchema, + type DriverPlanType, + ProblemSchema, + type ProblemType, +} from "./driver/definition.ts"; +import { normalizePath } from "./path.ts"; import type { OptimizationType, SupportModeType } from "./schema.ts"; import { SupportModeSchema, WriteModeSchema } from "./schema.ts"; -/** Kind of input presented to a filesystem write planner. */ +/** + * Physical source form supplied to a planned write. + * + * 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"]); - -/** A validated write-source shape. */ +/** Validated physical write-source form. */ export type WriteSourceType = z.output; - -/** Operations that have materially different storage routes. */ +/** + * Filesystem operations supported by deterministic preflight planning. + * + * 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"]); - -/** A validated plannable operation. */ +/** Validated preflight operation name. */ export type PlanOperationType = z.output; -/** Serializable preflight request for one storage operation. */ +/** + * Serializable preflight request for one concrete filesystem operation. + * + * The public request still uses caller-friendly paths and optional defaults. + * `createPlan()` normalizes those values before it asks the driver for a native + * planning result. + */ export const PlanInputSchema = z.discriminatedUnion("operation", [ z.object({ operation: z.literal("read"), - /** Known logical file size. */ + path: z.string().optional(), size: z.number().int().nonnegative().optional(), - /** Whether the caller requests only a byte range. */ range: z.boolean().default(false), }).strict(), z.object({ operation: z.literal("write"), - /** Known logical output size. Unknown stream sizes can omit it. */ + path: z.string().optional(), size: z.number().int().nonnegative().optional(), - /** Bytes supplied by this write. Used to preflight facade stream materialization. */ inputBytes: z.number().int().nonnegative().optional(), - /** Byte collection or streaming producer. */ source: WriteSourceSchema, - /** Replace, append, or update semantics. */ mode: WriteModeSchema.default("replace"), }).strict(), z.object({ operation: z.literal("copy"), - /** Known source byte size when the caller already has it. */ + path: z.string().optional(), + destination: z.string().optional(), size: z.number().int().nonnegative().optional(), }).strict(), z.object({ operation: z.literal("move"), - /** Known source byte size when the caller already has it. */ + path: z.string().optional(), + destination: z.string().optional(), size: z.number().int().nonnegative().optional(), }).strict(), ]); - -/** A validated storage preflight request. */ +/** Input accepted by filesystem preflight before defaults and path normalization. */ export type PlanInputType = z.input; -/** Serializable preflight result explaining the selected route and limits. */ +/** + * Structured preflight result for the complete driver -> adapter -> filesystem stack. + * + * The driver result is preserved inside the combined plan so callers can see + * which problems came from the backend itself and which were added by adapter or + * filesystem policy. + */ export const PlanSchema = z.object({ - /** Requested operation. */ operation: PlanOperationSchema, - /** Whether the configured stack can safely attempt the request. */ supported: z.boolean(), - /** Native, emulated, partitioned, or unsupported route selected for the request. */ support: SupportModeSchema, - /** Expected facade materialization when it is statically known. */ + driver: DriverPlanSchema, bufferBytes: z.number().int().nonnegative().optional(), - /** Physical part size when a partitioned adapter route is selected. */ partBytes: z.number().int().positive().optional(), - /** Physical part count when both size and partition shape are known. */ parts: z.number().int().positive().optional(), - /** Concrete reasons that determined the route. */ - reasons: z.array(z.string()).readonly(), - /** Non-fatal constraints the caller may want to act on. */ - warnings: z.array(z.string()).readonly(), + problems: z.array(ProblemSchema).readonly(), + actions: z.array(ActionSchema).readonly(), }).strict(); - -/** A validated storage preflight result. */ +/** Validated complete-stack preflight result. */ export type PlanType = z.output; -/** Inputs needed by the pure planner without importing the filesystem class. */ +/** + * Internal facade state required to combine adapter and driver preflight. + * + * `createPlan()` stays pure by accepting the small amount of resolved facade + * state it needs instead of reaching into a concrete filesystem instance. + */ export interface PlanContextType { - /** Configured adapter. */ readonly adapter: AdapterType; - /** Resolved facade optimization policy. */ readonly optimizations: OptimizationType; - /** Facade materialization ceiling. */ readonly maxBufferedWriteBytes: number; } -/** Marks a result unsupported while preserving accumulated explanatory text. */ -function unsupported(operation: PlanOperationType, reasons: string[], warnings: string[]): PlanType { - return PlanSchema.parse({ operation, supported: false, support: "unsupported", reasons, warnings }); +/** + * Creates one validated adapter/filesystem problem for the combined plan. + * + * Keeping this helper local ensures synthetic plan problems use the same schema + * shape as driver-produced problems. + */ +function problem( + code: string, + layer: "adapter" | "filesystem", + severity: "info" | "warning" | "error", + message: string, +): ProblemType { + return ProblemSchema.parse({ code, layer, severity, message }); } -/** Applies adapter hard file-size and partition-count limits before route selection. */ -function checkSize( - input: PlanInputType, - context: PlanContextType, - reasons: string[], - warnings: string[], -): { support?: SupportModeType; partBytes?: number; parts?: number } | null { - const size = input.size; - if (size === undefined) { - if (context.adapter.limits?.maxFileBytes !== undefined) { - warnings.push(`Adapter file limit is ${context.adapter.limits.maxFileBytes} bytes; the requested size is unknown.`); - } - return {}; - } - - const maxFileBytes = context.adapter.limits?.maxFileBytes; - if (maxFileBytes !== undefined && size > maxFileBytes) { - reasons.push(`Requested size ${size} exceeds adapter maxFileBytes ${maxFileBytes}.`); - return null; - } - - const partition = context.adapter.partition; - if (partition === undefined || partition.mode === "never") return {}; - const threshold = partition.thresholdBytes ?? partition.partBytes; - const shouldPartition = partition.mode === "always" || size > threshold; - if (!shouldPartition) return {}; +/** Creates one validated recovery/configuration action for the combined plan. */ +function action(kind: ActionType["kind"], detail?: string): ActionType { + return ActionSchema.parse({ kind, ...(detail === undefined ? {} : { detail }) }); +} - const parts = Math.max(1, Math.ceil(size / partition.partBytes)); - if (partition.maxParts !== undefined && parts > partition.maxParts) { - reasons.push(`Partitioned value requires ${parts} parts, above adapter maximum ${partition.maxParts}.`); - return null; - } - reasons.push(`Adapter stores this logical value as ${parts} physical parts of at most ${partition.partBytes} bytes.`); - return { support: "partitioned", partBytes: partition.partBytes, parts }; +/** + * Creates a canonical driver request from a public filesystem preflight request. + * + * This is the seam where facade-friendly input becomes backend-friendly input: + * paths are normalized, defaults are resolved, and only driver-relevant fields + * cross the boundary. + */ +function getDriverPlan(input: z.output, adapter: AdapterType): DriverPlanType { + return adapter.driver.plan({ + operation: input.operation, + ...(input.path === undefined ? {} : { path: normalizePath(input.path) }), + ...((input.operation === "copy" || input.operation === "move") && input.destination !== undefined + ? { destination: normalizePath(input.destination) } + : {}), + ...(input.size === undefined ? {} : { size: input.size }), + ...(input.operation === "write" + ? { + source: input.source, + mode: input.mode, + ...(input.inputBytes === undefined ? {} : { inputBytes: input.inputBytes }), + } + : {}), + ...(input.operation === "read" ? { range: input.range } : {}), + }); } /** - * Creates a deterministic storage preflight plan without performing I/O. + * Creates a deterministic plan without performing storage I/O. + * + * The plan starts from the driver's native answer, then layers in adapter and + * filesystem consequences such as buffering warnings, non-atomic move fallbacks, + * and route-level unsupported results. + * + * @example Preflight a streamed write. + * ```ts + * import { createPlan } from "@okikio/opfs/plan"; * - * The planner answers two separate questions: whether the operation is safe to - * attempt, and which storage route it will use. Unknown provider limits remain - * warnings rather than being guessed. Applications can therefore reject large - * work early, change a buffer/partition policy, or select another adapter. + * const plan = createPlan({ + * operation: "write", + * path: "/archive.bin", + * source: "stream", + * size: 8 * 1024, + * mode: "replace", + * }, { + * adapter, + * optimizations, + * maxBufferedWriteBytes: 64 * 1024 * 1024, + * }); + * ``` + * + * @example Detect a large emulated move before work starts. + * ```ts + * import { createPlan } from "@okikio/opfs/plan"; + * + * const plan = createPlan({ + * operation: "move", + * path: "/from.bin", + * destination: "/to.bin", + * size: 512 * 1024 * 1024, + * }, { + * adapter, + * optimizations, + * maxBufferedWriteBytes: 64 * 1024 * 1024, + * }); + * ``` */ export function createPlan(input: PlanInputType, context: PlanContextType): PlanType { const request = PlanInputSchema.parse(input); - const reasons: string[] = []; - const warnings: string[] = []; + const driver = getDriverPlan(request, context.adapter); const support = getSupport(context.adapter, context.optimizations); - const size = checkSize(request, context, reasons, warnings); - if (size === null) return unsupported(request.operation, reasons, warnings); + const problems: ProblemType[] = [...driver.problems]; + const actions: ActionType[] = [...driver.actions]; + let route: SupportModeType; + let bufferBytes: number | undefined; if (request.operation === "read") { - const route = request.range ? support.rangeRead : support.read; - if (route === "unsupported") { - reasons.push("Configured adapter cannot read file bytes."); - return unsupported(request.operation, reasons, warnings); + route = request.range ? support.rangeRead : support.read; + if (request.range && route === "emulated") { + problems.push(problem( + "range-materialized", + "filesystem", + "warning", + "The requested byte range requires a complete materialized read before slicing.", + )); } - reasons.push(request.range && route === "emulated" - ? "Byte range will be produced after a materialized read." - : request.range ? "Adapter can read the requested byte range directly." : "Adapter can read the file directly."); - return PlanSchema.parse({ operation: request.operation, supported: true, support: route, reasons, warnings }); - } - - if (request.operation === "write") { - let route = request.source === "stream" ? support.streamWrite[request.mode] : support.write; - let bufferBytes: number | undefined; + } else if (request.operation === "write") { + route = request.source === "stream" ? support.streamWrite[request.mode] : support.write; const inputBytes = request.inputBytes ?? (request.mode === "replace" ? request.size : undefined); - if (route === "unsupported") { - reasons.push(`Configured adapter cannot perform ${request.mode} writes.`); - return unsupported(request.operation, reasons, warnings); - } if (request.source === "stream" && route === "emulated") { if (inputBytes !== undefined && inputBytes > context.maxBufferedWriteBytes) { - reasons.push( - `Stream requires facade materialization but ${inputBytes} input bytes exceeds maxBufferedWriteBytes ${context.maxBufferedWriteBytes}.`, + route = "unsupported"; + problems.push(problem( + "buffer-too-large", + "filesystem", + "error", + `The stream needs ${inputBytes} buffered bytes, above maxBufferedWriteBytes=${context.maxBufferedWriteBytes}.`, + )); + actions.push(action("reduce-input"), action("select-driver", "Select a driver with native streaming writes.")); + } else { + bufferBytes = inputBytes; + problems.push( + problem( + "stream-buffered", + "filesystem", + "warning", + inputBytes === undefined + ? `The stream will be buffered and will fail if it crosses maxBufferedWriteBytes=${context.maxBufferedWriteBytes}.` + : `The facade will buffer ${inputBytes} bytes before the adapter write.`, + ), ); - return unsupported(request.operation, reasons, warnings); } - bufferBytes = inputBytes; - warnings.push( - inputBytes === undefined - ? `Stream is not native for ${request.mode}; input size is unknown and the facade will fail if it crosses maxBufferedWriteBytes ${context.maxBufferedWriteBytes}.` - : `Stream is not native for ${request.mode}; the facade will materialize ${inputBytes} input bytes under maxBufferedWriteBytes ${context.maxBufferedWriteBytes}.`, - ); } - if (size.support === "partitioned") route = "partitioned"; - reasons.push(route === "native" - ? "Configured adapter has a direct write route for this input." - : route === "partitioned" - ? "Logical file write is preserved through the adapter's partition layout." - : "Facade will emulate the requested write using materialized adapter primitives."); - return PlanSchema.parse({ - operation: request.operation, - supported: true, - support: route, - ...(bufferBytes === undefined ? {} : { bufferBytes }), - ...(size.partBytes === undefined ? {} : { partBytes: size.partBytes }), - ...(size.parts === undefined ? {} : { parts: size.parts }), - reasons, - warnings, - }); + if (driver.support === "partitioned" && route !== "unsupported") route = "partitioned"; + } else { + route = request.operation === "copy" ? support.copy : support.move; + if (request.operation === "move" && route === "emulated") { + problems.push(problem( + "move-not-atomic", + "filesystem", + "warning", + "The selected move route is copy followed by remove and is not atomic.", + )); + } + if (route === "emulated" && request.size !== undefined && request.size > context.maxBufferedWriteBytes) { + const canStream = support.streamRead === "native" && + (support.streamWrite.replace === "native" || support.streamWrite.replace === "partitioned"); + if (!canStream) { + route = "unsupported"; + problems.push(problem( + "copy-buffer-too-large", + "filesystem", + "error", + `${request.operation} would materialize ${request.size} bytes, above maxBufferedWriteBytes=${context.maxBufferedWriteBytes}.`, + )); + actions.push(action("select-driver", "Select a driver with a complete streaming read/write route.")); + } + } } - const route = request.operation === "copy" ? support.copy : support.move; if (route === "unsupported") { - reasons.push(`Configured adapter cannot ${request.operation} with either a native route or safe facade fallback.`); - return unsupported(request.operation, reasons, warnings); - } - - if (route === "emulated" && request.size !== undefined && request.size > context.maxBufferedWriteBytes) { - const streamedRead = support.streamRead === "native"; - const streamedWrite = support.streamWrite.replace === "native" || support.streamWrite.replace === "partitioned"; - if (!streamedRead || !streamedWrite) { - reasons.push( - `${request.operation} fallback would materialize ${request.size} bytes because a complete streaming read/write path is unavailable; maxBufferedWriteBytes is ${context.maxBufferedWriteBytes}.`, - ); - return unsupported(request.operation, reasons, warnings); - } + problems.push(problem( + "route-unsupported", + "adapter", + "error", + `Adapter '${context.adapter.name}' cannot safely perform this ${request.operation} request with the configured policies.`, + )); + actions.push(action("select-driver")); } - if (request.operation === "move" && route === "emulated") { - warnings.push("Emulated move is copy followed by remove and is not atomic."); - } - reasons.push(route === "native" - ? `Adapter has a native ${request.operation} route.` - : `${request.operation} will be composed from facade read/write/remove primitives.`); - return PlanSchema.parse({ operation: request.operation, supported: true, support: route, reasons, warnings }); + const supported = route !== "unsupported" && !problems.some((value) => value.severity === "error"); + return PlanSchema.parse({ + operation: request.operation, + supported, + support: supported ? route : "unsupported", + driver, + ...(bufferBytes === undefined ? {} : { bufferBytes }), + ...(driver.partBytes === undefined ? {} : { partBytes: driver.partBytes }), + ...(driver.parts === undefined ? {} : { parts: driver.parts }), + problems, + actions, + }); } diff --git a/src/request.ts b/src/request.ts index eae2a2a..5e116c7 100644 --- a/src/request.ts +++ b/src/request.ts @@ -25,22 +25,6 @@ export const RequestPolicySchema = z.object({ /** A validated direct-client request policy. */ export type RequestPolicyType = z.output; -/** Fully resolved retry policy used by the transport loop. */ -export interface ResolvedRequestPolicyType { - /** Additional attempts after the first request. */ - readonly retries: number; - /** Base retry delay in milliseconds. */ - readonly minDelayMs: number; - /** Maximum retry delay in milliseconds. */ - readonly maxDelayMs: number; - /** Exponential backoff multiplier. */ - readonly multiplier: number; - /** Random delay proportion accepted by the retry helper. */ - readonly jitter: number; - /** Optional per-attempt timeout that can disable the helper deadline when false. */ - readonly timeoutMs?: number | false; -} - /** Detached counters for one direct protocol client. */ export interface RequestMetricsType { /** Total HTTP requests actually sent, including retries. */ @@ -112,9 +96,6 @@ export class RequestMetrics { /** Marker for a failure thrown by the concrete Fetch transport after request construction succeeded. */ export class RequestTransportError extends Error { - /** Original Fetch failure retained for the terminal caller. */ - override readonly cause: unknown; - constructor(cause: unknown) { super("Storage request transport failed."); this.name = "RequestTransportError"; @@ -144,7 +125,11 @@ function integer(value: number | undefined, fallback: number, name: string, mini } /** Resolves and validates the shared request policy. */ -export function getRequestPolicy(policy: RequestPolicyType | undefined): ResolvedRequestPolicyType { +export function getRequestPolicy( + policy: RequestPolicyType | undefined, +): Required> & { + readonly timeoutMs?: number | false; +} { const parsed = RequestPolicySchema.parse(policy ?? {}); const retries = integer(parsed.retries, 3, "retries", 0); const minDelayMs = integer(parsed.minDelayMs, 200, "minDelayMs", 0); @@ -182,13 +167,18 @@ function getSignal(signal: AbortSignal | undefined, timeoutMs: number | false | readonly signal?: AbortSignal; readonly cleanup: () => void; } { - if (timeoutMs === undefined || timeoutMs === false) return { ...(signal === undefined ? {} : { signal }), cleanup() {} }; + if (timeoutMs === undefined || timeoutMs === false) { + return { ...(signal === undefined ? {} : { signal }), cleanup() {} }; + } const controller = new AbortController(); const onAbort = () => controller.abort(signal?.reason); if (signal?.aborted) onAbort(); else signal?.addEventListener("abort", onAbort, { once: true }); - const timer = setTimeout(() => controller.abort(new DOMException(`Request timed out after ${timeoutMs} ms.`, "TimeoutError")), timeoutMs); + const timer = setTimeout( + () => controller.abort(new DOMException(`Request timed out after ${timeoutMs} ms.`, "TimeoutError")), + timeoutMs, + ); return { signal: controller.signal, cleanup() { @@ -200,7 +190,9 @@ function getSignal(signal: AbortSignal | undefined, timeoutMs: number | false | /** Extracts the original error from `@std/async/retry` without coupling to its error class. */ function cause(error: unknown): unknown { - if (typeof error === "object" && error !== null && "cause" in error) return (error as { cause?: unknown }).cause ?? error; + if (typeof error === "object" && error !== null && "cause" in error) { + return (error as { cause?: unknown }).cause ?? error; + } return error; } @@ -228,13 +220,11 @@ export async function sendRequest( } = {}, ): Promise { const policy = getRequestPolicy(options.policy); - const attempts = options.replayable === false ? 1 : (policy.retries ?? 0) + 1; + const attempts = options.replayable === false ? 1 : policy.retries! + 1; let attempt = 0; let lastStarted = 0; try { - const minTimeout = Math.max(1, policy.minDelayMs ?? 0); - const maxTimeout = Math.max(minTimeout, policy.maxDelayMs ?? 0); return await retry(async () => { attempt += 1; const scoped = getSignal(options.signal, policy.timeoutMs); @@ -261,10 +251,10 @@ export async function sendRequest( } }, { maxAttempts: attempts, - minTimeout, - maxTimeout, - ...(policy.multiplier === undefined ? {} : { multiplier: policy.multiplier }), - ...(policy.jitter === undefined ? {} : { jitter: policy.jitter }), + minTimeout: policy.minDelayMs!, + maxTimeout: policy.maxDelayMs!, + multiplier: policy.multiplier!, + jitter: policy.jitter!, ...(options.signal === undefined ? {} : { signal: options.signal }), isRetriable: (error: unknown) => error instanceof RetryResponseError || error instanceof RequestTransportError, }); diff --git a/src/schema.ts b/src/schema.ts index f9d47fd..d63b7c9 100644 --- a/src/schema.ts +++ b/src/schema.ts @@ -8,14 +8,15 @@ import { z } from "zod"; * values at persistence and adapter seams. */ export const PathSchema = z.string().refine( - (value: string) => value === "/" || ( - value.startsWith("/") && - !value.endsWith("/") && - !value.includes("//") && - !value.includes("\\") && - !value.includes("\0") && - value.split("/").slice(1).every((part: string) => part.length > 0 && part !== "." && part !== "..") - ), + (value: string) => + value === "/" || ( + value.startsWith("/") && + !value.endsWith("/") && + !value.includes("//") && + !value.includes("\\") && + !value.includes("\0") && + value.split("/").slice(1).every((part: string) => part.length > 0 && part !== "." && part !== "..") + ), "Expected a canonical virtual filesystem path.", ); @@ -300,7 +301,7 @@ export const FileRecordSchema = RecordBaseSchema.extend({ export type FileRecordType = z.output; /** - * Persisted record format shared by RxDB, unstorage, db0, and Drizzle bridges. + * Persisted record format shared by RxDB, unstorage, db0, and Drizzle record drivers. * * File bytes use base64 text because every target ecosystem can preserve JSON * strings. This costs about one third more storage than raw bytes. Native file @@ -322,3 +323,103 @@ export const SqlIdentifierSchema = z.string().regex(/^[A-Za-z_][A-Za-z0-9_]*$/); /** A validated unqualified SQL identifier. */ export type SqlIdentifierType = z.output; + +/** Storage family owned by one backend driver. */ +export const DriverKindSchema = z.enum(["file", "record", "object"]); + +/** A validated backend driver family. */ +export type DriverKindType = z.output; + +/** Why one driver limit exists. */ +export const LimitKindSchema = z.enum(["hard", "policy", "dynamic"]); + +/** A validated limit kind. */ +export type LimitKindType = z.output; + +/** Layer that supplied one limit value. */ +export const LimitSourceSchema = z.enum(["provider", "implementation", "user", "probe"]); + +/** A validated limit source. */ +export type LimitSourceType = z.output; + +/** Unit used by one numeric limit. */ +export const LimitUnitSchema = z.enum(["bytes", "count", "milliseconds", "operations"]); + +/** A validated limit unit. */ +export type LimitUnitType = z.output; + +/** + * One inspectable storage limit with explicit provenance. + * + * A hard provider limit is not interchangeable with a project safety policy or + * 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({ + code: z.string().min(1), + kind: LimitKindSchema, + source: LimitSourceSchema, + unit: LimitUnitSchema, + value: z.number().nonnegative().optional(), + detail: z.string().min(1).optional(), +}).strict().superRefine((value, ctx) => { + if (value.value === undefined && value.kind !== "dynamic") { + ctx.addIssue({ code: "custom", message: "Only dynamic limits can omit their current value." }); + } +}); + +/** A validated storage limit. */ +export type LimitType = z.output; + +/** Current state of one driver requirement. */ +export const RequirementStateSchema = z.enum(["available", "missing", "unknown"]); + +/** A validated requirement state. */ +export type RequirementStateType = z.output; + +/** + * One runtime, provider, permission, or configuration requirement. + * + * Definitions can report `unknown` before probing. Configured drivers should + * prefer `available` or `missing` when the state is already known. + */ +export const RequirementSchema = z.object({ + code: z.string().min(1), + state: RequirementStateSchema, + reason: z.string().min(1).optional(), +}).strict().superRefine((value, ctx) => { + if (value.state === "missing" && value.reason === undefined) { + ctx.addIssue({ code: "custom", message: "Missing requirements need a concrete reason." }); + } +}); + +/** A validated driver requirement. */ +export type RequirementType = z.output; + +/** Ownership state for one configured driver backend resource. */ +export const DriverOwnershipSchema = z.enum(["none", "borrowed", "owned"]); + +/** A validated configured-driver backend ownership state. */ +export type DriverOwnershipType = z.output; + +/** + * One independently controllable driver optimization. + * + * `changesBehavior` means request count, failure timing, storage layout, + * consistency, atomicity, or another observable property can differ when the + * optimization is enabled. Such optimizations must be disableable. + */ +export const DriverOptimizationSchema = z.object({ + code: z.string().min(1), + enabled: z.boolean(), + changesBehavior: z.boolean(), + disableable: z.boolean(), + detail: z.string().min(1).optional(), +}).strict().superRefine((value, ctx) => { + if (value.changesBehavior && !value.disableable) { + ctx.addIssue({ code: "custom", message: "Behavior-changing optimizations must be disableable." }); + } +}); + +/** A validated driver optimization declaration. */ +export type DriverOptimizationType = z.output; diff --git a/src/sync.ts b/src/sync.ts index 86da739..0221d57 100644 --- a/src/sync.ts +++ b/src/sync.ts @@ -1,5 +1,5 @@ import { FileSystemError, toFileSystemError } from "./error.ts"; -import type { AdapterSyncFileType } from "./adapter/definition.ts"; +import type { FileDriverSyncFileType } from "./driver/file.ts"; import type { HeldLockType } from "./lock.ts"; /** @@ -35,12 +35,12 @@ export class ManagedSyncFile implements SyncFileType { /** Canonical virtual path whose mutation lock is owned by this resource. */ readonly path: string; /** Native adapter resource. `undefined` is the sole closed-state marker. */ - #file: AdapterSyncFileType | undefined; + #file: FileDriverSyncFileType | undefined; /** Facade mutation lock held for exactly the same lifetime as `#file`. */ readonly #lock: HeldLockType; /** Takes ownership of the adapter file and matching facade lock as one lifetime. */ - constructor(path: string, file: AdapterSyncFileType, lock: HeldLockType) { + constructor(path: string, file: FileDriverSyncFileType, lock: HeldLockType) { this.path = path; this.#file = file; this.#lock = lock; @@ -52,7 +52,7 @@ export class ManagedSyncFile implements SyncFileType { } /** Returns the live native file or rejects use after close. */ - #getFile(): AdapterSyncFileType { + #getFile(): FileDriverSyncFileType { if (this.#file === undefined) { throw new FileSystemError( "invalid-operation", diff --git a/src/writable.ts b/src/writable.ts index c647b0c..53f7085 100644 --- a/src/writable.ts +++ b/src/writable.ts @@ -1,4 +1,4 @@ -import type { AdapterWritableFileType } from "./adapter/definition.ts"; +import type { FileDriverWritableFileType } from "./driver/file.ts"; import { FileSystemError, throwIfAborted, toFileSystemError } from "./error.ts"; import type { HeldLockType } from "./lock.ts"; @@ -36,7 +36,7 @@ export class ManagedWritableFile implements WritableFileType { /** Canonical virtual path whose exclusive mutation ownership lasts until settlement. */ readonly path: string; /** Adapter positional file. `undefined` is the sole terminal-state marker. */ - #file: AdapterWritableFileType | undefined; + #file: FileDriverWritableFileType | undefined; /** Facade mutation lock released exactly when the adapter file settles. */ readonly #lock: HeldLockType; /** Optional operation signal checked before and after mutable backend work. */ @@ -45,7 +45,7 @@ export class ManagedWritableFile implements WritableFileType { /** Takes ownership of one adapter positional file and its matching facade lock. */ constructor( path: string, - file: AdapterWritableFileType, + file: FileDriverWritableFileType, lock: HeldLockType, signal?: AbortSignal, ) { @@ -61,7 +61,7 @@ export class ManagedWritableFile implements WritableFileType { } /** Returns the live backend file and rejects ordinary work after termination. */ - #getFile(operation: string): AdapterWritableFileType { + #getFile(operation: string): FileDriverWritableFileType { if (this.#file === undefined) { throw new FileSystemError( "invalid-operation", diff --git a/tests/chunk.test.ts b/tests/chunk.test.ts index 851e19a..2531ef6 100644 --- a/tests/chunk.test.ts +++ b/tests/chunk.test.ts @@ -25,8 +25,12 @@ describe("byte stream chunking", () => { it("cancels the source when the consumer stops before the stream ends", async () => { let cancelled = false; const source = new ReadableStream({ - pull(controller) { controller.enqueue(new Uint8Array(8)); }, - cancel() { cancelled = true; }, + pull(controller) { + controller.enqueue(new Uint8Array(8)); + }, + cancel() { + cancelled = true; + }, }); for await (const _chunk of split(source, 4)) break; diff --git a/tests/driver.test.ts b/tests/driver.test.ts index 0de2c6a..c94aa69 100644 --- a/tests/driver.test.ts +++ b/tests/driver.test.ts @@ -50,7 +50,13 @@ describe("driver contract", () => { requirements: [{ code: "database", state: "available" }], limits: [ { code: "value-bytes", kind: "hard", source: "provider", unit: "bytes", value: 64 * 1024 }, - { code: "quota-bytes", kind: "dynamic", source: "probe", unit: "bytes", detail: "Probe storage quota before admission." }, + { + code: "quota-bytes", + kind: "dynamic", + source: "probe", + unit: "bytes", + detail: "Probe storage quota before admission.", + }, ], optimizations: [ { code: "partition", enabled: true, changesBehavior: true, disableable: true }, @@ -72,11 +78,13 @@ describe("driver contract", () => { }); it("rejects a behavior-changing optimization that cannot be disabled", () => { - expect(() => defineDriver({ - name: "unsafe", - kind: "object", - optimizations: [{ code: "cache", enabled: true, changesBehavior: true, disableable: false }], - })).toThrow(TypeError); + expect(() => + defineDriver({ + name: "unsafe", + kind: "object", + optimizations: [{ code: "cache", enabled: true, changesBehavior: true, disableable: false }], + }) + ).toThrow(TypeError); }); it("lets a third-party record driver preflight logical size before an adapter exists", () => { @@ -85,16 +93,18 @@ describe("driver contract", () => { limits: [{ code: "file-bytes", kind: "policy", source: "user", unit: "bytes", value: 8 }], }); - expect(driver.plan({ operation: "write", path: "/small.bin", size: 8, source: "bytes", mode: "replace" })).toMatchObject({ - supported: true, - support: "native", - }); - expect(driver.plan({ operation: "write", path: "/large.bin", size: 9, source: "bytes", mode: "replace" })).toMatchObject({ - supported: false, - support: "unsupported", - problems: [{ code: "file-too-large", layer: "driver", severity: "error" }], - actions: [{ kind: "reduce-input" }, { kind: "select-driver" }], - }); + expect(driver.plan({ operation: "write", path: "/small.bin", size: 8, source: "bytes", mode: "replace" })) + .toMatchObject({ + supported: true, + support: "native", + }); + expect(driver.plan({ operation: "write", path: "/large.bin", size: 9, source: "bytes", mode: "replace" })) + .toMatchObject({ + supported: false, + support: "unsupported", + problems: [{ code: "file-too-large", layer: "driver", severity: "error" }], + actions: [{ kind: "reduce-input" }, { kind: "select-driver" }], + }); }); it("enforces read-only policy at the driver seam before an adapter exists", () => { @@ -115,10 +125,11 @@ describe("driver contract", () => { expect(driver.capabilities.write).toBe(false); expect(driver.provides.includes("set")).toBe(false); expect(driver.provides.includes("delete")).toBe(false); - expect(driver.plan({ operation: "write", path: "/value", size: 0, source: "bytes", mode: "replace" })).toMatchObject({ - supported: false, - problems: [{ code: "read-only", layer: "driver", severity: "error" }], - }); + expect(driver.plan({ operation: "write", path: "/value", size: 0, source: "bytes", mode: "replace" })) + .toMatchObject({ + supported: false, + problems: [{ code: "read-only", layer: "driver", severity: "error" }], + }); expect(() => driver.set(record)).toThrow(); }); diff --git a/tests/ecosystems.test.ts b/tests/ecosystems.test.ts index 3129efd..6c613fe 100644 --- a/tests/ecosystems.test.ts +++ b/tests/ecosystems.test.ts @@ -79,9 +79,7 @@ class FakeRxCollection { return { exec: async () => { const record = this.#records.get(path); - return record === undefined - ? null - : new FakeRxDocument(record, () => this.#records.delete(path)); + return record === undefined ? null : new FakeRxDocument(record, () => this.#records.delete(path)); }, }; } @@ -89,9 +87,10 @@ class FakeRxCollection { /** Returns direct children selected by their stored parent path. */ find({ selector }: { selector: { parent: string } }) { return { - exec: async () => [...this.#records.values()] - .filter((record) => record.parent === selector.parent) - .map((record) => new FakeRxDocument(record, () => this.#records.delete(String(record.path)))), + exec: async () => + [...this.#records.values()] + .filter((record) => record.parent === selector.parent) + .map((record) => new FakeRxDocument(record, () => this.#records.delete(String(record.path)))), }; } @@ -196,9 +195,10 @@ function createFakeDrizzle() { from() { return { where(condition: { column: { name: string }; value: unknown }) { - const selected = () => rows - .filter((row) => row[condition.column.name] === condition.value) - .map((row) => ({ ...row })); + const selected = () => + rows + .filter((row) => row[condition.column.name] === condition.value) + .map((row) => ({ ...row })); return { then(resolve: (value: Record[]) => unknown, reject: (reason: unknown) => unknown) { return Promise.resolve(selected()).then(resolve, reject); @@ -260,10 +260,12 @@ describe("ecosystem adapters", () => { }); it("rejects integration metadata that hides why a direction is unsupported", () => { - expect(() => defineIntegration({ - name: "invalid-integration", - directions: { toOpfs: { supported: false }, fromOpfs: { supported: false, reason: "not implemented" } }, - })).toThrow(TypeError); + expect(() => + defineIntegration({ + name: "invalid-integration", + directions: { toOpfs: { supported: false }, fromOpfs: { supported: false, reason: "not implemented" } }, + }) + ).toThrow(TypeError); }); it("exposes any filesystem as an unstorage driver without key collisions", async () => { diff --git a/tests/filesystem.test.ts b/tests/filesystem.test.ts index 039b526..cef6651 100644 --- a/tests/filesystem.test.ts +++ b/tests/filesystem.test.ts @@ -64,18 +64,24 @@ describe("filesystem facade", () => { it("rejects invalid adapter and facade contracts", async () => { const valid = createMemoryAdapter(); expect(() => defineAdapter({ ...valid, name: "" })).toThrow(TypeError); - expect(() => defineAdapter({ - ...valid, - capabilities: { ...valid.capabilities, streamRead: "yes" }, - } as never)).toThrow(TypeError); - expect(() => defineAdapter({ - ...valid, - capabilities: { ...valid.capabilities, nativeCopy: true }, - })).toThrow(TypeError); - expect(() => defineAdapter({ - ...valid, - capabilities: { ...valid.capabilities, streamWriteModes: ["replace"] }, - })).toThrow(TypeError); + expect(() => + defineAdapter({ + ...valid, + capabilities: { ...valid.capabilities, streamRead: "yes" }, + } as never) + ).toThrow(TypeError); + expect(() => + defineAdapter({ + ...valid, + capabilities: { ...valid.capabilities, nativeCopy: true }, + }) + ).toThrow(TypeError); + expect(() => + defineAdapter({ + ...valid, + capabilities: { ...valid.capabilities, streamWriteModes: ["replace"] }, + }) + ).toThrow(TypeError); expect(() => createFileSystem(valid, { coordination: "invalid" as never })).toThrow(TypeError); let invalidWriteRejected = false; try { diff --git a/tests/object.test.ts b/tests/object.test.ts index d6d9c8b..a626eae 100644 --- a/tests/object.test.ts +++ b/tests/object.test.ts @@ -65,7 +65,9 @@ class MemoryObjectBackend implements ObjectBackendType { const value = this.values.get(key); if (value === undefined) return new Response(null, { status: 404 }).body!; const start = options.at ?? 0; - const end = options.length === undefined ? value.bytes.byteLength : Math.min(value.bytes.byteLength, start + options.length); + const end = options.length === undefined + ? value.bytes.byteLength + : Math.min(value.bytes.byteLength, start + options.length); const bytes = value.bytes.slice(start, end); return new ReadableStream({ start(controller) { @@ -227,7 +229,6 @@ describe("object driver adapter", () => { await fileSystem.close(); }); - it("rejects an oversized emulated copy when its streaming read route is disabled", async () => { const store = new MemoryObjectBackend(); const fileSystem = createFileSystem(createObjectAdapter(createMemoryObjectDriver(store)), { diff --git a/tests/package/verify.mjs b/tests/package/verify.mjs index 2d20709..238245c 100644 --- a/tests/package/verify.mjs +++ b/tests/package/verify.mjs @@ -1,4 +1,4 @@ -import { mkdtemp, readFile, readdir, rm, stat, writeFile } from "node:fs/promises"; +import { mkdtemp, readdir, readFile, rm, stat, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join, resolve } from "node:path"; import { spawn } from "node:child_process"; @@ -12,9 +12,7 @@ function command(file, args, options = {}) { return new Promise((resolvePromise, reject) => { const child = spawn(file, args, { stdio: "inherit", ...options }); child.on("error", reject); - child.on("exit", (code) => code === 0 - ? resolvePromise() - : reject(new Error(`${file} exited with ${code}.`))); + child.on("exit", (code) => code === 0 ? resolvePromise() : reject(new Error(`${file} exited with ${code}.`))); }); } @@ -79,7 +77,9 @@ try { await writeFile(join(consumer, "package.json"), `${JSON.stringify({ private: true, type: "module" }, null, 2)}\n`); await command("npm", ["install", "--ignore-scripts", "--no-audit", "--no-fund", tarball], { cwd: consumer }); - await writeFile(join(consumer, "smoke.mjs"), undent` + await writeFile( + join(consumer, "smoke.mjs"), + undent` import { createFileSystem } from '@okikio/opfs'; import { createMemoryAdapter } from '@okikio/opfs/adapter/memory'; import { normalizePath } from '@okikio/opfs/path'; @@ -90,7 +90,8 @@ try { await import('@okikio/opfs/adapter/node'); await import('@okikio/opfs/adapter/deno'); await import('@okikio/opfs/adapter/bun'); -`); +`, + ); await command("node", ["smoke.mjs"], { cwd: consumer }); const hasDeno = await hasCommand("deno"); @@ -98,20 +99,26 @@ try { if (hasDeno) await command("deno", ["run", "--node-modules-dir=manual", "smoke.mjs"], { cwd: consumer }); if (hasBun) await command("bun", ["smoke.mjs"], { cwd: consumer }); - await writeFile(join(consumer, "consumer.ts"), undent` + await writeFile( + join(consumer, "consumer.ts"), + undent` import { createFileSystem, type FileSystemType } from '@okikio/opfs'; import { createMemoryAdapter } from '@okikio/opfs/adapter/memory'; const fileSystem: FileSystemType = createFileSystem(createMemoryAdapter()); await fileSystem.writeFile('/types.txt', 'ok', { parents: true }); -`); +`, + ); if (hasDeno) await command("deno", ["check", "--node-modules-dir=manual", "consumer.ts"], { cwd: consumer }); - await writeFile(join(consumer, "browser.mjs"), undent` + await writeFile( + join(consumer, "browser.mjs"), + undent` import { createFileSystem } from '@okikio/opfs'; import { createMemoryAdapter } from '@okikio/opfs/adapter/memory'; import { normalizePath } from '@okikio/opfs/path'; export const smoke = () => [createFileSystem(createMemoryAdapter()), normalizePath('a/../b')]; -`); +`, + ); if (hasDeno) { await command("deno", [ "bundle",