Initial Commit
This commit is contained in:
146
packages/persistence/storage/storage-uri/README.md
Normal file
146
packages/persistence/storage/storage-uri/README.md
Normal file
@@ -0,0 +1,146 @@
|
||||
# @suaveplan/storage-uri - Universal URI-routed dispatch over @suaveplan StorageLike adapters. One resolver, one URI string in, one stream/byte/metadata out; scheme→plugin registry, AbortSignal everywhere, full OTel surface, zero I/O of its own.
|
||||
|
||||
Universal URI-routed dispatch over `@suaveplan` `StorageLike` adapters.
|
||||
|
||||
One resolver, one URI string in, one stream/byte/metadata out.
|
||||
Scheme→plugin registry; the resolver performs no I/O of its own — every
|
||||
operation delegates to a registered `StorageLike` after URI parsing.
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
## Documentation tree
|
||||
|
||||
- [conformance](./src/conformance/conformance.md) — `runUriPluginConformance(plugin, fixtures)` is the test harness adapter packages call from their own suites to prove th…
|
||||
- [conformance](./src/conformance/conformance.md) — `runUriPluginConformance(plugin, fixtures)` is the test harness adapter packages call from their own suites to prove th…
|
||||
- [error-codes](./src/error-codes/error-codes.md) — Defines the canonical `STORAGE_URI_CODES` registry and the `StorageUriError` class for the `@suaveplan/storage-uri` pac…
|
||||
- [error-codes](./src/error-codes/error-codes.md) — Defines the canonical `STORAGE_URI_CODES` registry and the `StorageUriError` class for the `@suaveplan/storage-uri` pac…
|
||||
- [otel](./src/otel/otel.md) — Owns the OpenTelemetry instrument bag for `@suaveplan/storage-uri`: meter, tracer, and the six counter + one histogram…
|
||||
- [otel](./src/otel/otel.md) — Owns the OpenTelemetry instrument bag for `@suaveplan/storage-uri`: meter, tracer, and the six counter + one histogram…
|
||||
- [parse-uri](./src/parse-uri/parse-uri.md) — `parseUri(uri)` is the RFC 3986 subset parser the resolver uses to extract a scheme, optional authority, path, and opti…
|
||||
- [parse-uri](./src/parse-uri/parse-uri.md) — `parseUri(uri)` is the RFC 3986 subset parser the resolver uses to extract a scheme, optional authority, path, and opti…
|
||||
- [resolver](./src/resolver/resolver.md) — The `StorageUriResolver` class is the entry point for URI-routed dispatch in `@suaveplan/storage-uri`. Constructed once…
|
||||
- [resolver.list](./src/resolver/resolver.list.md) — Implements the `list(uri, options?, signal?)` semantics. Treats the input URI's key portion as a listing prefix, delega…
|
||||
- [resolver](./src/resolver/resolver.md) — The `StorageUriResolver` class is the entry point for URI-routed dispatch in `@suaveplan/storage-uri`. Constructed once…
|
||||
- [resolver.streams](./src/resolver/resolver.streams.md) — Implements the `open(uri, signal?)` semantics for the resolver: forward a native `createReadStream` when the adapter ex…
|
||||
- [resolver.writes](./src/resolver/resolver.writes.md) — Implements the `write(uri, body, signal?)` semantics. Accepts either `Uint8Array` or `ReadableStream<Uint8Array>` for t…
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
bun add @suaveplan/storage-uri
|
||||
```
|
||||
|
||||
Pair with one or more adapter packages — each ships its own `uriPlugin`:
|
||||
|
||||
```bash
|
||||
bun add @suaveplan/storage-file @suaveplan/storage-http
|
||||
```
|
||||
|
||||
## Modules
|
||||
|
||||
| Subpath | What |
|
||||
|---|---|
|
||||
| `@suaveplan/storage-uri` | Full surface — re-exports everything below |
|
||||
| `@suaveplan/storage-uri/error-codes` | `STORAGE_URI_CODES`, `StorageUriError` |
|
||||
| `@suaveplan/storage-uri/parse-uri` | `parseUri(uri)` RFC 3986 subset |
|
||||
| `@suaveplan/storage-uri/resolver` | `StorageUriResolver`, `createStorageUriResolver` |
|
||||
| `@suaveplan/storage-uri/otel` | OTel meter+tracer instruments |
|
||||
| `@suaveplan/storage-uri/conformance` | `runUriPluginConformance` for adapter authors |
|
||||
| `@suaveplan/storage-uri/types` | `UriPlugin`, `ParsedUri`, `StatResult`, list types |
|
||||
|
||||
## Quick start
|
||||
|
||||
```ts
|
||||
import { createStorageUriResolver } from "@suaveplan/storage-uri";
|
||||
import { fileUriPlugin } from "@suaveplan/storage-file";
|
||||
import { httpsUriPlugin, createHttpStorage } from "@suaveplan/storage-http";
|
||||
|
||||
const resolver = createStorageUriResolver([
|
||||
fileUriPlugin({ rootDir: "/var/cache/media" }),
|
||||
httpsUriPlugin(createHttpStorage({ baseUrl: "https://cdn.example.com" })),
|
||||
]);
|
||||
|
||||
const stream = await resolver.open("https://cdn.example.com/clip/master.m3u8");
|
||||
await resolver.write("file:///cache/poster.jpg", thumbBytes);
|
||||
```
|
||||
|
||||
## Cross-scheme media-pipeline recipe
|
||||
|
||||
```ts
|
||||
const resolver = createStorageUriResolver([
|
||||
fileUriPlugin({ rootDir: "/var/media" }),
|
||||
httpsUriPlugin(httpStorage),
|
||||
]);
|
||||
|
||||
async function thumbnail(srcUri: string, dstUri: string): Promise<void> {
|
||||
const src = await resolver.open(srcUri);
|
||||
const out = await encodeJpegFromStream(src);
|
||||
await resolver.write(dstUri, out);
|
||||
}
|
||||
```
|
||||
|
||||
A single call site composes a filesystem cache, a CDN origin, and any other
|
||||
adapter you register — the resolver hides which scheme served the request.
|
||||
|
||||
## Authoring a third-party `UriPlugin`
|
||||
|
||||
```ts
|
||||
import type { UriPlugin } from "@suaveplan/storage-uri";
|
||||
import type { StorageLike } from "@suaveplan/types";
|
||||
|
||||
export function memoryUriPlugin(adapter: StorageLike): UriPlugin {
|
||||
return {
|
||||
scheme: "memory",
|
||||
adapter,
|
||||
parse(uri) {
|
||||
const url = new URL(uri);
|
||||
return { key: url.pathname.replace(/^\//, ""), meta: { authority: url.host } };
|
||||
},
|
||||
build(key, meta) {
|
||||
const auth = meta?.authority ?? "default";
|
||||
return `memory://${auth}/${key}`;
|
||||
},
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
Verify your plugin against the conformance harness from your own test
|
||||
suite:
|
||||
|
||||
```ts
|
||||
import { runUriPluginConformance } from "@suaveplan/storage-uri/conformance";
|
||||
|
||||
runUriPluginConformance(memoryUriPlugin(adapter), {
|
||||
validUris: ["memory://x/a"],
|
||||
invalidUris: ["bad", "memory:"],
|
||||
buildRoundTrip: [{ key: "a", uri: "memory://x/a", meta: { authority: "x" } }],
|
||||
});
|
||||
```
|
||||
|
||||
## Error codes
|
||||
|
||||
| Code | When |
|
||||
|---|---|
|
||||
| `STORAGE_URI.UNKNOWN_SCHEME` | URI scheme has no plugin registered |
|
||||
| `STORAGE_URI.INVALID_URI` | `parseUri` rejected the input |
|
||||
| `STORAGE_URI.SCHEME_CONFLICT` | Two distinct plugins claim the same scheme |
|
||||
| `STORAGE_URI.NOT_FOUND` | Adapter returned `undefined` for `get`/`open` |
|
||||
| `STORAGE_URI.LIST_UNSUPPORTED` | Plugin has no `build()` |
|
||||
| `STORAGE_URI.WRITE_UNSUPPORTED` | Adapter threw on `set` (e.g. read-only HTTP) |
|
||||
| `STORAGE_URI.ABORTED` | `AbortSignal` fired |
|
||||
| `STORAGE_URI.ADAPTER_ERROR` | Adapter threw something else — cause preserved |
|
||||
|
||||
## OpenTelemetry
|
||||
|
||||
Tracer + meter name: `@suaveplan/storage-uri`. Counters:
|
||||
`reads_total`, `writes_total`, `deletes_total`, `errors_total`,
|
||||
`stream_fallback_total`, `buffered_write_total`. Histogram:
|
||||
`op_duration_ms{op,scheme,outcome}`. Spans on every public op with
|
||||
`storage.uri`, `storage.uri.scheme`, `storage.uri.key` attributes;
|
||||
`storage.uri` is redacted (query string + fragment stripped) so presigned
|
||||
URLs cannot leak into telemetry sinks.
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
|
||||
124
packages/persistence/storage/storage-uri/package.json
Normal file
124
packages/persistence/storage/storage-uri/package.json
Normal file
@@ -0,0 +1,124 @@
|
||||
{
|
||||
"name": "@suaveplan/storage-uri",
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"sideEffects": false,
|
||||
"description": "Universal URI-routed dispatch over @suaveplan StorageLike adapters. One resolver, one URI string in, one stream/byte/metadata out; scheme→plugin registry, AbortSignal everywhere, full OTel surface, zero I/O of its own.",
|
||||
"license": "MIT",
|
||||
"author": "Suaveplan Team",
|
||||
"main": "./dist/index.js",
|
||||
"module": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"exports": {
|
||||
"./package.json": "./package.json",
|
||||
".": {
|
||||
"import": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"default": "./dist/index.js"
|
||||
},
|
||||
"require": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"default": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"./error-codes": {
|
||||
"import": {
|
||||
"types": "./dist/error-codes/index.d.ts",
|
||||
"default": "./dist/error-codes/error-codes.js"
|
||||
},
|
||||
"require": {
|
||||
"types": "./dist/error-codes/index.d.ts",
|
||||
"default": "./dist/error-codes/error-codes.js"
|
||||
}
|
||||
},
|
||||
"./parse-uri": {
|
||||
"import": {
|
||||
"types": "./dist/parse-uri/index.d.ts",
|
||||
"default": "./dist/parse-uri/parse-uri.js"
|
||||
},
|
||||
"require": {
|
||||
"types": "./dist/parse-uri/index.d.ts",
|
||||
"default": "./dist/parse-uri/parse-uri.js"
|
||||
}
|
||||
},
|
||||
"./resolver": {
|
||||
"import": {
|
||||
"types": "./dist/resolver/index.d.ts",
|
||||
"default": "./dist/resolver/resolver.js"
|
||||
},
|
||||
"require": {
|
||||
"types": "./dist/resolver/index.d.ts",
|
||||
"default": "./dist/resolver/resolver.js"
|
||||
}
|
||||
},
|
||||
"./otel": {
|
||||
"import": {
|
||||
"types": "./dist/otel/index.d.ts",
|
||||
"default": "./dist/otel/otel.js"
|
||||
},
|
||||
"require": {
|
||||
"types": "./dist/otel/index.d.ts",
|
||||
"default": "./dist/otel/otel.js"
|
||||
}
|
||||
},
|
||||
"./conformance": {
|
||||
"import": {
|
||||
"types": "./dist/conformance/index.d.ts",
|
||||
"default": "./dist/conformance/conformance.js"
|
||||
},
|
||||
"require": {
|
||||
"types": "./dist/conformance/index.d.ts",
|
||||
"default": "./dist/conformance/conformance.js"
|
||||
}
|
||||
},
|
||||
"./types": {
|
||||
"import": {
|
||||
"types": "./dist/types/index.d.ts",
|
||||
"default": "./dist/types/index.js"
|
||||
},
|
||||
"require": {
|
||||
"types": "./dist/types/index.d.ts",
|
||||
"default": "./dist/types/index.js"
|
||||
}
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"build": "vite build",
|
||||
"test": "vitest run --passWithNoTests",
|
||||
"test:watch": "vitest",
|
||||
"test:coverage": "vitest run --coverage --passWithNoTests",
|
||||
"lint": "bunx biome check src/",
|
||||
"lint:fix": "bunx biome lint --fix --unsafe --max-diagnostics 500 src/",
|
||||
"format": "bunx biome format --write --max-diagnostics 500 src/",
|
||||
"check": "bunx biome check --write --unsafe --max-diagnostics 500 src/",
|
||||
"typecheck": "bunx tsc --noEmit --composite false -p tsconfig.lib.json",
|
||||
"docs:check": "bun run ../../../../scripts/docs-check.ts ."
|
||||
},
|
||||
"dependencies": {
|
||||
"@opentelemetry/api": "catalog:observability",
|
||||
"@suaveplan/error": "workspace:^",
|
||||
"@suaveplan/types": "workspace:^",
|
||||
"tslib": "catalog:build"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@suaveplan/storage-file": "workspace:^",
|
||||
"@suaveplan/storage-http": "workspace:^",
|
||||
"@suaveplan/testing": "workspace:^",
|
||||
"@types/node": "catalog:",
|
||||
"@vitest/coverage-istanbul": "catalog:testing",
|
||||
"fast-check": "catalog:testing",
|
||||
"typescript": "catalog:",
|
||||
"vite": "catalog:build",
|
||||
"vite-plugin-dts": "catalog:build",
|
||||
"vitest": "catalog:testing"
|
||||
},
|
||||
"publishConfig": {
|
||||
"registry": "https://npmjs.fq.io/",
|
||||
"access": "public"
|
||||
},
|
||||
"files": [
|
||||
"dist",
|
||||
"src",
|
||||
"README.md"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
# conformance
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`runUriPluginConformance(plugin, fixtures)` is the test harness adapter
|
||||
packages call from their own suites to prove their `uriPlugin` satisfies
|
||||
the resolver's contract. It checks scheme alignment, parse success for the
|
||||
valid fixture set, parse rejection (`STORAGE_URI.INVALID_URI`) for the
|
||||
invalid set, and — when `build` is implemented — round-trip equality for
|
||||
every entry in `buildRoundTrip`.
|
||||
|
||||
The harness throws `UriPluginConformanceFailure` (a `Error` subclass) on
|
||||
the first failure rather than collecting them; tests typically wrap one
|
||||
plugin per `describe` block so a single failure already names the offending
|
||||
fixture.
|
||||
|
||||
## Features
|
||||
|
||||
- Scheme alignment check on every valid URI.
|
||||
- Required-throw assertion on the invalid set.
|
||||
- Optional round-trip assertion gated on `plugin.build`.
|
||||
- Single-failure semantics with descriptive messages.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { runUriPluginConformance } from "@suaveplan/storage-uri/conformance";
|
||||
|
||||
runUriPluginConformance(memoryUriPlugin, {
|
||||
validUris: ["memory://x/a", "memory://x/b"],
|
||||
invalidUris: ["bare", "memory:"],
|
||||
buildRoundTrip: [{ key: "a", uri: "memory://x/a", meta: { authority: "x" } }],
|
||||
});
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { describe, it } from "@suaveplan/testing/runner";
|
||||
import { runUriPluginConformance } from "@suaveplan/storage-uri/conformance";
|
||||
import { fileUriPlugin } from "./uri-plugin.js";
|
||||
|
||||
describe("fileUriPlugin conformance", () => {
|
||||
it("passes the storage-uri harness", { timeout: 5000 }, () => {
|
||||
runUriPluginConformance(fileUriPlugin({ rootDir: "/tmp" }), {
|
||||
validUris: ["file:///a", "file:///b/c"],
|
||||
invalidUris: ["bad", "file:"],
|
||||
buildRoundTrip: [{ key: "/a", uri: "file:///a" }],
|
||||
});
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `interface UriPluginFixtures { validUris; invalidUris; buildRoundTrip? }`
|
||||
- `class UriPluginConformanceFailure extends Error`
|
||||
- `function runUriPluginConformance(plugin, fixtures): void`
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The harness re-uses `parseUri` from this package as a baseline scheme
|
||||
extractor so plugins that bypass it still get checked against the canonical
|
||||
RFC 3986 split. `plugin.build` is bound via `.bind(plugin)` for the same
|
||||
reason the resolver does it — `this`-bound implementations need to survive
|
||||
indirect invocation. On round-trip failure the message includes both the
|
||||
input and the produced output, so the failing fixture is uniquely
|
||||
identified without log-spelunking.
|
||||
@@ -0,0 +1,229 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { MemoryStorageLike } from "../../tests/setup/test-helpers.js";
|
||||
import {
|
||||
STORAGE_URI_CODES,
|
||||
StorageUriError,
|
||||
} from "../error-codes/error-codes.js";
|
||||
import type { UriPlugin } from "../types/plugin.types.js";
|
||||
import {
|
||||
runUriPluginConformance,
|
||||
UriPluginConformanceFailure,
|
||||
} from "./conformance.js";
|
||||
|
||||
function memoryPlugin(adapter: MemoryStorageLike): UriPlugin {
|
||||
return {
|
||||
scheme: "memory",
|
||||
adapter,
|
||||
parse(uri) {
|
||||
if (!uri.startsWith("memory://")) {
|
||||
throw new StorageUriError(
|
||||
STORAGE_URI_CODES.INVALID_URI,
|
||||
"wrong scheme",
|
||||
{ uri }
|
||||
);
|
||||
}
|
||||
const rest = uri.slice("memory://".length);
|
||||
const slash = rest.indexOf("/");
|
||||
const authority = slash >= 0 ? rest.slice(0, slash) : rest;
|
||||
const key = slash >= 0 ? rest.slice(slash + 1) : "";
|
||||
if (key.length === 0) {
|
||||
throw new StorageUriError(
|
||||
STORAGE_URI_CODES.INVALID_URI,
|
||||
"missing key",
|
||||
{ uri }
|
||||
);
|
||||
}
|
||||
return { key, meta: { authority } };
|
||||
},
|
||||
build(key, meta) {
|
||||
const auth = meta?.["authority"] ?? "x";
|
||||
return `memory://${auth}/${key}`;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("runUriPluginConformance", () => {
|
||||
it("passes for a well-formed plugin", { timeout: 5000 }, () => {
|
||||
const plugin = memoryPlugin(new MemoryStorageLike());
|
||||
expect(() =>
|
||||
runUriPluginConformance(plugin, {
|
||||
validUris: ["memory://x/a", "memory://x/b/c"],
|
||||
invalidUris: ["bare-string", "memory:"],
|
||||
buildRoundTrip: [
|
||||
{ key: "a", uri: "memory://x/a", meta: { authority: "x" } },
|
||||
],
|
||||
})
|
||||
).not.toThrow();
|
||||
});
|
||||
|
||||
it("fails on broken round-trip", { timeout: 5000 }, () => {
|
||||
const adapter = new MemoryStorageLike();
|
||||
const broken: UriPlugin = {
|
||||
scheme: "memory",
|
||||
adapter,
|
||||
parse(uri) {
|
||||
return { key: uri.slice("memory://".length) };
|
||||
},
|
||||
build(_key) {
|
||||
return "memory://x/WRONG";
|
||||
},
|
||||
};
|
||||
expect(() =>
|
||||
runUriPluginConformance(broken, {
|
||||
validUris: ["memory://x/a"],
|
||||
invalidUris: [],
|
||||
buildRoundTrip: [{ key: "a", uri: "memory://x/a" }],
|
||||
})
|
||||
).toThrow(UriPluginConformanceFailure);
|
||||
});
|
||||
|
||||
it("fails when scheme is not a non-empty string", { timeout: 5000 }, () => {
|
||||
const ad = new MemoryStorageLike();
|
||||
const bad: UriPlugin = {
|
||||
scheme: "",
|
||||
adapter: ad,
|
||||
parse: () => ({ key: "a" }),
|
||||
};
|
||||
expect(() =>
|
||||
runUriPluginConformance(bad, { validUris: [], invalidUris: [] })
|
||||
).toThrow();
|
||||
});
|
||||
|
||||
it("fails when validUris[i] scheme does not match plugin scheme", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const plugin = memoryPlugin(new MemoryStorageLike());
|
||||
expect(() =>
|
||||
runUriPluginConformance(plugin, {
|
||||
validUris: ["s3://x/a"],
|
||||
invalidUris: [],
|
||||
})
|
||||
).toThrow();
|
||||
});
|
||||
|
||||
it("fails when plugin.parse throws on a valid URI", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const ad = new MemoryStorageLike();
|
||||
const bad: UriPlugin = {
|
||||
scheme: "memory",
|
||||
adapter: ad,
|
||||
parse() {
|
||||
throw new Error("oops");
|
||||
},
|
||||
};
|
||||
expect(() =>
|
||||
runUriPluginConformance(bad, {
|
||||
validUris: ["memory://x/a"],
|
||||
invalidUris: [],
|
||||
})
|
||||
).toThrow();
|
||||
});
|
||||
|
||||
it("fails when plugin.parse returns empty key on valid URI", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const ad = new MemoryStorageLike();
|
||||
const bad: UriPlugin = {
|
||||
scheme: "memory",
|
||||
adapter: ad,
|
||||
parse() {
|
||||
return { key: "" };
|
||||
},
|
||||
};
|
||||
expect(() =>
|
||||
runUriPluginConformance(bad, {
|
||||
validUris: ["memory://x/a"],
|
||||
invalidUris: [],
|
||||
})
|
||||
).toThrow();
|
||||
});
|
||||
|
||||
it("fails when invalid URI does not throw", { timeout: 5000 }, () => {
|
||||
const ad = new MemoryStorageLike();
|
||||
const lax: UriPlugin = {
|
||||
scheme: "memory",
|
||||
adapter: ad,
|
||||
parse() {
|
||||
return { key: "x" };
|
||||
},
|
||||
};
|
||||
expect(() =>
|
||||
runUriPluginConformance(lax, {
|
||||
validUris: [],
|
||||
invalidUris: ["memory://x/a"],
|
||||
})
|
||||
).toThrow();
|
||||
});
|
||||
|
||||
it("fails when invalid URI throws wrong code", { timeout: 5000 }, () => {
|
||||
const ad = new MemoryStorageLike();
|
||||
const wrong: UriPlugin = {
|
||||
scheme: "memory",
|
||||
adapter: ad,
|
||||
parse(uri) {
|
||||
throw new StorageUriError(
|
||||
STORAGE_URI_CODES.NOT_FOUND,
|
||||
"wrong",
|
||||
{ uri }
|
||||
);
|
||||
},
|
||||
};
|
||||
expect(() =>
|
||||
runUriPluginConformance(wrong, {
|
||||
validUris: [],
|
||||
invalidUris: ["memory://x/a"],
|
||||
})
|
||||
).toThrow(UriPluginConformanceFailure);
|
||||
});
|
||||
|
||||
it("accepts an empty buildRoundTrip array", { timeout: 5000 }, () => {
|
||||
const plugin = memoryPlugin(new MemoryStorageLike());
|
||||
expect(() =>
|
||||
runUriPluginConformance(plugin, {
|
||||
validUris: ["memory://x/a"],
|
||||
invalidUris: [],
|
||||
buildRoundTrip: [],
|
||||
})
|
||||
).not.toThrow();
|
||||
});
|
||||
|
||||
it("includes a stringified non-Error when plugin.parse throws a non-Error", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const ad = new MemoryStorageLike();
|
||||
const bad: UriPlugin = {
|
||||
scheme: "memory",
|
||||
adapter: ad,
|
||||
parse() {
|
||||
throw "string-thrown";
|
||||
},
|
||||
};
|
||||
expect(() =>
|
||||
runUriPluginConformance(bad, {
|
||||
validUris: ["memory://x/a"],
|
||||
invalidUris: [],
|
||||
})
|
||||
).toThrow(UriPluginConformanceFailure);
|
||||
});
|
||||
|
||||
it("fails when buildRoundTrip is supplied but plugin.build is missing", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const ad = new MemoryStorageLike();
|
||||
const noBuild: UriPlugin = {
|
||||
scheme: "memory",
|
||||
adapter: ad,
|
||||
parse() {
|
||||
return { key: "a" };
|
||||
},
|
||||
};
|
||||
expect(() =>
|
||||
runUriPluginConformance(noBuild, {
|
||||
validUris: [],
|
||||
invalidUris: [],
|
||||
buildRoundTrip: [{ key: "a", uri: "memory://x/a" }],
|
||||
})
|
||||
).toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,115 @@
|
||||
import {
|
||||
STORAGE_URI_CODES,
|
||||
StorageUriError,
|
||||
} from "../error-codes/error-codes.js";
|
||||
import { parseUri } from "../parse-uri/parse-uri.js";
|
||||
import type { UriPlugin } from "../types/plugin.types.js";
|
||||
|
||||
/** Fixture set passed to `runUriPluginConformance`. */
|
||||
export interface UriPluginFixtures {
|
||||
/** URIs the plugin MUST parse without throwing. */
|
||||
readonly validUris: readonly string[];
|
||||
/** URIs the plugin MUST reject with `STORAGE_URI.INVALID_URI`. */
|
||||
readonly invalidUris: readonly string[];
|
||||
/** Round-trip cases — required only when `plugin.build` is implemented. */
|
||||
readonly buildRoundTrip?: ReadonlyArray<{
|
||||
readonly key: string;
|
||||
readonly meta?: Record<string, string>;
|
||||
readonly uri: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
/** Failure produced by the conformance harness. */
|
||||
export class UriPluginConformanceFailure extends Error {
|
||||
public constructor(message: string) {
|
||||
super(message);
|
||||
this.name = "UriPluginConformanceFailure";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify that `plugin` satisfies the contract expected by the resolver.
|
||||
*
|
||||
* Adapter packages call this from their own test suite to prove their
|
||||
* `uriPlugin` round-trips correctly, rejects malformed inputs, and matches
|
||||
* the URI's scheme prefix. This is the bridge between the resolver's spec
|
||||
* and each adapter's contribution — the test fixtures live with the
|
||||
* adapter, but the assertions live here.
|
||||
*/
|
||||
export function runUriPluginConformance(
|
||||
plugin: UriPlugin,
|
||||
fixtures: UriPluginFixtures
|
||||
): void {
|
||||
if (typeof plugin.scheme !== "string" || plugin.scheme.length === 0) {
|
||||
throw new UriPluginConformanceFailure(
|
||||
"plugin.scheme must be a non-empty string"
|
||||
);
|
||||
}
|
||||
const expectedScheme = plugin.scheme.toLowerCase();
|
||||
|
||||
for (const uri of fixtures.validUris) {
|
||||
const parts = parseUri(uri);
|
||||
if (parts.scheme !== expectedScheme) {
|
||||
throw new UriPluginConformanceFailure(
|
||||
`validUris[${uri}] scheme '${parts.scheme}' does not match plugin.scheme '${expectedScheme}'`
|
||||
);
|
||||
}
|
||||
let parsed: ReturnType<typeof plugin.parse>;
|
||||
try {
|
||||
parsed = plugin.parse(uri);
|
||||
} catch (err) {
|
||||
throw new UriPluginConformanceFailure(
|
||||
`plugin.parse threw on validUris[${uri}]: ${
|
||||
err instanceof Error ? err.message : String(err)
|
||||
}`
|
||||
);
|
||||
}
|
||||
if (typeof parsed.key !== "string" || parsed.key.length === 0) {
|
||||
throw new UriPluginConformanceFailure(
|
||||
`plugin.parse(${uri}).key must be a non-empty string`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
for (const uri of fixtures.invalidUris) {
|
||||
let threw = false;
|
||||
try {
|
||||
plugin.parse(uri);
|
||||
} catch (err) {
|
||||
threw = true;
|
||||
if (
|
||||
err instanceof StorageUriError &&
|
||||
err.code !== STORAGE_URI_CODES.INVALID_URI
|
||||
) {
|
||||
throw new UriPluginConformanceFailure(
|
||||
`invalidUris[${uri}] threw ${err.code} (expected STORAGE_URI.INVALID_URI)`
|
||||
);
|
||||
}
|
||||
}
|
||||
if (!threw) {
|
||||
throw new UriPluginConformanceFailure(
|
||||
`invalidUris[${uri}] did not throw`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
fixtures.buildRoundTrip !== undefined &&
|
||||
fixtures.buildRoundTrip.length > 0
|
||||
) {
|
||||
if (typeof plugin.build !== "function") {
|
||||
throw new UriPluginConformanceFailure(
|
||||
"buildRoundTrip fixtures supplied but plugin.build is not implemented"
|
||||
);
|
||||
}
|
||||
const build = plugin.build.bind(plugin);
|
||||
for (const fx of fixtures.buildRoundTrip) {
|
||||
const got = build(fx.key, fx.meta);
|
||||
if (got !== fx.uri) {
|
||||
throw new UriPluginConformanceFailure(
|
||||
`build(${fx.key}, ${JSON.stringify(fx.meta)}) returned '${got}', expected '${fx.uri}'`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export type { UriPluginFixtures } from "./conformance.js";
|
||||
export {
|
||||
runUriPluginConformance,
|
||||
UriPluginConformanceFailure,
|
||||
} from "./conformance.js";
|
||||
@@ -0,0 +1,82 @@
|
||||
# error-codes
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Defines the canonical `STORAGE_URI_CODES` registry and the `StorageUriError`
|
||||
class for the `@suaveplan/storage-uri` package. Every error surfaced by the
|
||||
resolver — whether raised directly or re-wrapped from a downstream adapter —
|
||||
carries one of these eight codes and the `uri`/`scheme` discriminants so
|
||||
operators can correlate failures to the offending request without parsing
|
||||
free-form message text.
|
||||
|
||||
The registry is built via `defineCodes("STORAGE_URI", [...] as const)` from
|
||||
`@suaveplan/error`, which produces the runtime strings
|
||||
`STORAGE_URI.UNKNOWN_SCHEME`, `STORAGE_URI.INVALID_URI`,
|
||||
`STORAGE_URI.SCHEME_CONFLICT`, `STORAGE_URI.NOT_FOUND`,
|
||||
`STORAGE_URI.LIST_UNSUPPORTED`, `STORAGE_URI.WRITE_UNSUPPORTED`,
|
||||
`STORAGE_URI.ABORTED`, and `STORAGE_URI.ADAPTER_ERROR`.
|
||||
|
||||
## Features
|
||||
|
||||
- Frozen 8-code registry, type-narrowing `instanceof` via the
|
||||
literal-typed `TCode` parameter on `SuaveplanError`.
|
||||
- `uri` and `scheme` discriminants on every `StorageUriError` instance.
|
||||
- `cause` chain preserved when the resolver re-wraps an adapter throwable as
|
||||
`ADAPTER_ERROR` or `WRITE_UNSUPPORTED`.
|
||||
- Zero runtime dependencies beyond `@suaveplan/error`.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { STORAGE_URI_CODES, StorageUriError } from "@suaveplan/storage-uri/error-codes";
|
||||
|
||||
throw new StorageUriError(
|
||||
STORAGE_URI_CODES.UNKNOWN_SCHEME,
|
||||
"no plugin for ftp",
|
||||
{ uri: "ftp://x/y", scheme: "ftp" },
|
||||
);
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
try {
|
||||
await resolver.read("file:///missing");
|
||||
} catch (err) {
|
||||
if (err instanceof StorageUriError) {
|
||||
if (err.code === STORAGE_URI_CODES.NOT_FOUND) return undefined;
|
||||
if (err.code === STORAGE_URI_CODES.ADAPTER_ERROR) {
|
||||
// Trace back to the original adapter code via the cause chain.
|
||||
console.error("upstream failure", err.cause);
|
||||
}
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `STORAGE_URI_CODES` — frozen record `{ UNKNOWN_SCHEME, INVALID_URI,
|
||||
SCHEME_CONFLICT, NOT_FOUND, LIST_UNSUPPORTED, WRITE_UNSUPPORTED, ABORTED,
|
||||
ADAPTER_ERROR }`. Each value is the fully-qualified `STORAGE_URI.<REASON>`
|
||||
string.
|
||||
- `type StorageUriErrorCode` — union of every value in the registry.
|
||||
- `interface StorageUriErrorOptions extends SuaveplanErrorOptions` — adds
|
||||
`uri?: string` and `scheme?: string`.
|
||||
- `class StorageUriError extends SuaveplanError<StorageUriErrorCode>` —
|
||||
exposes `readonly uri`, `readonly scheme`, inherits `code`, `cause`,
|
||||
`context`, `traceId`, `timestamp` from the base class.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`defineCodes` returns a frozen object via `Object.create(null)` so the
|
||||
registry has no prototype-pollution surface. `StorageUriError`'s constructor
|
||||
mirrors `StorageHttpError` and `StorageFileError` — `uri`/`scheme` are
|
||||
captured by direct field assignment rather than through a context map so
|
||||
they survive structured-clone serialisation. The `name` is set to
|
||||
`"StorageUriError"` explicitly so `err.name` is stable across V8/JSC/SM
|
||||
where `new.target.name` could otherwise be munged by minification.
|
||||
@@ -0,0 +1,61 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { STORAGE_URI_CODES, StorageUriError } from "./error-codes.js";
|
||||
|
||||
describe("STORAGE_URI_CODES", () => {
|
||||
it("contains every documented code with the STORAGE_URI. prefix", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const expected = [
|
||||
"UNKNOWN_SCHEME",
|
||||
"INVALID_URI",
|
||||
"SCHEME_CONFLICT",
|
||||
"NOT_FOUND",
|
||||
"LIST_UNSUPPORTED",
|
||||
"WRITE_UNSUPPORTED",
|
||||
"ABORTED",
|
||||
"ADAPTER_ERROR",
|
||||
] as const;
|
||||
for (const key of expected) {
|
||||
expect(STORAGE_URI_CODES[key]).toBe(`STORAGE_URI.${key}`);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("StorageUriError", () => {
|
||||
it("carries uri and scheme discriminants", { timeout: 5000 }, () => {
|
||||
const err = new StorageUriError(
|
||||
STORAGE_URI_CODES.NOT_FOUND,
|
||||
"missing",
|
||||
{
|
||||
uri: "file:///a",
|
||||
scheme: "file",
|
||||
}
|
||||
);
|
||||
expect(err.uri).toBe("file:///a");
|
||||
expect(err.scheme).toBe("file");
|
||||
expect(err.name).toBe("StorageUriError");
|
||||
expect(err.code).toBe(STORAGE_URI_CODES.NOT_FOUND);
|
||||
});
|
||||
|
||||
it("leaves uri/scheme undefined when options omitted", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const err = new StorageUriError(STORAGE_URI_CODES.ABORTED, "aborted");
|
||||
expect(err.uri).toBeUndefined();
|
||||
expect(err.scheme).toBeUndefined();
|
||||
});
|
||||
|
||||
it("preserves cause via the base class", { timeout: 5000 }, () => {
|
||||
const original = new Error("disk fault");
|
||||
const err = new StorageUriError(
|
||||
STORAGE_URI_CODES.ADAPTER_ERROR,
|
||||
"wrap",
|
||||
{
|
||||
uri: "file:///a",
|
||||
scheme: "file",
|
||||
cause: original,
|
||||
}
|
||||
);
|
||||
expect(err.cause).toBe(original);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,57 @@
|
||||
import {
|
||||
defineCodes,
|
||||
SuaveplanError,
|
||||
type SuaveplanErrorOptions,
|
||||
} from "@suaveplan/error";
|
||||
|
||||
/**
|
||||
* Registry of every error code raised by `@suaveplan/storage-uri`.
|
||||
*
|
||||
* Every member is the fully-qualified string `STORAGE_URI.<REASON>` produced
|
||||
* by `defineCodes`. Eight codes total — `UNKNOWN_SCHEME`, `INVALID_URI`,
|
||||
* `SCHEME_CONFLICT`, `NOT_FOUND`, `LIST_UNSUPPORTED`, `WRITE_UNSUPPORTED`,
|
||||
* `ABORTED`, `ADAPTER_ERROR` — all reachable from a real call site exercised
|
||||
* by the test suite.
|
||||
*/
|
||||
export const STORAGE_URI_CODES = defineCodes("STORAGE_URI", [
|
||||
"UNKNOWN_SCHEME",
|
||||
"INVALID_URI",
|
||||
"SCHEME_CONFLICT",
|
||||
"NOT_FOUND",
|
||||
"LIST_UNSUPPORTED",
|
||||
"WRITE_UNSUPPORTED",
|
||||
"ABORTED",
|
||||
"ADAPTER_ERROR",
|
||||
] as const);
|
||||
|
||||
export type StorageUriErrorCode =
|
||||
(typeof STORAGE_URI_CODES)[keyof typeof STORAGE_URI_CODES];
|
||||
|
||||
export interface StorageUriErrorOptions extends SuaveplanErrorOptions {
|
||||
uri?: string;
|
||||
scheme?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Structured error for URI-routed storage operations.
|
||||
*
|
||||
* Carries optional `uri` and `scheme` discriminants so operators can correlate
|
||||
* a failure with the offending request without parsing message text. The
|
||||
* original underlying cause (when re-wrapping an adapter throwable) is
|
||||
* preserved on `cause` via the base class.
|
||||
*/
|
||||
export class StorageUriError extends SuaveplanError<StorageUriErrorCode> {
|
||||
public readonly uri: string | undefined;
|
||||
public readonly scheme: string | undefined;
|
||||
|
||||
public constructor(
|
||||
code: StorageUriErrorCode,
|
||||
message: string,
|
||||
options?: StorageUriErrorOptions
|
||||
) {
|
||||
super(code, message, options);
|
||||
this.uri = options?.uri;
|
||||
this.scheme = options?.scheme;
|
||||
this.name = "StorageUriError";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
export type {
|
||||
StorageUriErrorCode,
|
||||
StorageUriErrorOptions,
|
||||
} from "./error-codes.js";
|
||||
export {
|
||||
STORAGE_URI_CODES,
|
||||
StorageUriError,
|
||||
} from "./error-codes.js";
|
||||
46
packages/persistence/storage/storage-uri/src/index.ts
Normal file
46
packages/persistence/storage/storage-uri/src/index.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
// Side-effect import: ensures the STORAGE_URI.* code registry registers
|
||||
// even when subpath consumers never reach this barrel.
|
||||
import "./error-codes/error-codes.js";
|
||||
|
||||
export {
|
||||
runUriPluginConformance,
|
||||
UriPluginConformanceFailure,
|
||||
type UriPluginFixtures,
|
||||
} from "./conformance/conformance.js";
|
||||
export {
|
||||
STORAGE_URI_CODES,
|
||||
StorageUriError,
|
||||
type StorageUriErrorCode,
|
||||
type StorageUriErrorOptions,
|
||||
} from "./error-codes/error-codes.js";
|
||||
export {
|
||||
getInstruments,
|
||||
type RecordOpInput,
|
||||
recordBufferedWrite,
|
||||
recordOp,
|
||||
recordStreamFallback,
|
||||
redactUri,
|
||||
resetInstruments,
|
||||
type StorageUriInstruments,
|
||||
type StorageUriOp,
|
||||
type StorageUriOutcome,
|
||||
setStorageUriMeterProvider,
|
||||
} from "./otel/otel.js";
|
||||
export { type ParsedUriParts, parseUri } from "./parse-uri/parse-uri.js";
|
||||
export {
|
||||
createStorageUriResolver,
|
||||
StorageUriResolver,
|
||||
} from "./resolver/resolver.js";
|
||||
export { listViaPlugin } from "./resolver/resolver.list.js";
|
||||
export { openViaPlugin } from "./resolver/resolver.streams.js";
|
||||
export { writeViaPlugin } from "./resolver/resolver.writes.js";
|
||||
export type {
|
||||
ParsedUri,
|
||||
StatResult,
|
||||
StreamingStorageExtensions,
|
||||
UriPlugin,
|
||||
} from "./types/plugin.types.js";
|
||||
export type {
|
||||
ListUriOptions,
|
||||
ListUriResult,
|
||||
} from "./types/resolver.types.js";
|
||||
15
packages/persistence/storage/storage-uri/src/otel/index.ts
Normal file
15
packages/persistence/storage/storage-uri/src/otel/index.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
export type {
|
||||
RecordOpInput,
|
||||
StorageUriInstruments,
|
||||
StorageUriOp,
|
||||
StorageUriOutcome,
|
||||
} from "./otel.js";
|
||||
export {
|
||||
getInstruments,
|
||||
recordBufferedWrite,
|
||||
recordOp,
|
||||
recordStreamFallback,
|
||||
redactUri,
|
||||
resetInstruments,
|
||||
setStorageUriMeterProvider,
|
||||
} from "./otel.js";
|
||||
70
packages/persistence/storage/storage-uri/src/otel/otel.md
Normal file
70
packages/persistence/storage/storage-uri/src/otel/otel.md
Normal file
@@ -0,0 +1,70 @@
|
||||
# otel
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Owns the OpenTelemetry instrument bag for `@suaveplan/storage-uri`: meter,
|
||||
tracer, and the six counter + one histogram metrics emitted on every
|
||||
resolver operation. The module is intentionally side-effect-free at import
|
||||
time; instruments are created lazily on first access so tests can swap in
|
||||
an in-memory `MeterProvider` before any measurement is recorded.
|
||||
|
||||
The same module also owns the URI redaction helper `redactUri(uri)` used by
|
||||
`StorageUriResolver.#run` to strip query strings and fragments from the
|
||||
`storage.uri` span attribute before emission — presigned-URL secrets must
|
||||
never leak into telemetry sinks.
|
||||
|
||||
## Features
|
||||
|
||||
- Lazy instrument cache rebuilt automatically when the meter provider
|
||||
changes via `setStorageUriMeterProvider(provider)`.
|
||||
- Six counters: `reads_total`, `writes_total`, `deletes_total`,
|
||||
`errors_total`, `stream_fallback_total`, `buffered_write_total`.
|
||||
- One histogram: `op_duration_ms` with `{op, scheme, outcome}` attributes.
|
||||
- `redactUri(uri)` — drops `?query` and `#fragment` segments.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { recordOp } from "@suaveplan/storage-uri/otel";
|
||||
|
||||
recordOp({ op: "read", scheme: "file", outcome: "success", durationMs: 12 });
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { metrics } from "@opentelemetry/api";
|
||||
import { setStorageUriMeterProvider, resetInstruments } from "@suaveplan/storage-uri/otel";
|
||||
|
||||
const provider = new MyInMemoryMeterProvider();
|
||||
setStorageUriMeterProvider(provider);
|
||||
resetInstruments();
|
||||
// ... run resolver ops ...
|
||||
const snapshot = provider.collect();
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `setStorageUriMeterProvider(provider | undefined): void`
|
||||
- `getInstruments(force?: boolean): StorageUriInstruments`
|
||||
- `resetInstruments(): void`
|
||||
- `recordOp({ op, scheme, outcome, durationMs, code? }): void`
|
||||
- `recordStreamFallback(scheme: string): void`
|
||||
- `recordBufferedWrite(scheme: string): void`
|
||||
- `redactUri(uri: string): string`
|
||||
- Types: `StorageUriOp`, `StorageUriOutcome`, `RecordOpInput`,
|
||||
`StorageUriInstruments`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`recordOp` routes the success counter by op category (`read`/`open`/`stat`/
|
||||
`exists`/`list` → reads, `write` → writes, `remove` → deletes) so consumers
|
||||
need not subscribe to seven separate counters. Errors always increment
|
||||
`errors_total` with the resolved `STORAGE_URI.*` code on the `code`
|
||||
attribute. The instrument cache is intentionally module-local rather than
|
||||
class-bound — the resolver is constructed many times in tests, but the
|
||||
instrument bag is provider-scoped, not resolver-scoped.
|
||||
235
packages/persistence/storage/storage-uri/src/otel/otel.test.ts
Normal file
235
packages/persistence/storage/storage-uri/src/otel/otel.test.ts
Normal file
@@ -0,0 +1,235 @@
|
||||
import type {
|
||||
Counter,
|
||||
Histogram,
|
||||
Meter,
|
||||
MeterProvider,
|
||||
} from "@opentelemetry/api";
|
||||
import {
|
||||
afterEach,
|
||||
beforeEach,
|
||||
describe,
|
||||
expect,
|
||||
it,
|
||||
} from "@suaveplan/testing/runner";
|
||||
import { MemoryStorageLike } from "../../tests/setup/test-helpers.js";
|
||||
import { createStorageUriResolver } from "../resolver/resolver.js";
|
||||
import type { UriPlugin } from "../types/plugin.types.js";
|
||||
import {
|
||||
recordBufferedWrite,
|
||||
recordOp,
|
||||
recordStreamFallback,
|
||||
redactUri,
|
||||
resetInstruments,
|
||||
setStorageUriMeterProvider,
|
||||
} from "./otel.js";
|
||||
|
||||
interface Sample {
|
||||
value: number;
|
||||
attrs: Record<string, unknown> | undefined;
|
||||
}
|
||||
|
||||
class StubCounter implements Counter {
|
||||
public adds: Sample[] = [];
|
||||
public add(v: number, attrs?: Record<string, unknown>): void {
|
||||
this.adds.push({ value: v, attrs });
|
||||
}
|
||||
}
|
||||
|
||||
class StubHistogram implements Histogram {
|
||||
public samples: Sample[] = [];
|
||||
public record(v: number, attrs?: Record<string, unknown>): void {
|
||||
this.samples.push({ value: v, attrs });
|
||||
}
|
||||
}
|
||||
|
||||
class StubMeter implements Meter {
|
||||
public counters: Map<string, StubCounter> = new Map();
|
||||
public histograms: Map<string, StubHistogram> = new Map();
|
||||
public createCounter(name: string): Counter {
|
||||
const c = new StubCounter();
|
||||
this.counters.set(name, c);
|
||||
return c;
|
||||
}
|
||||
public createHistogram(name: string): Histogram {
|
||||
const h = new StubHistogram();
|
||||
this.histograms.set(name, h);
|
||||
return h;
|
||||
}
|
||||
public createUpDownCounter(): never {
|
||||
throw new Error("unused");
|
||||
}
|
||||
public createObservableCounter(): never {
|
||||
throw new Error("unused");
|
||||
}
|
||||
public createObservableUpDownCounter(): never {
|
||||
throw new Error("unused");
|
||||
}
|
||||
public createObservableGauge(): never {
|
||||
throw new Error("unused");
|
||||
}
|
||||
public addBatchObservableCallback(): void {}
|
||||
public removeBatchObservableCallback(): void {}
|
||||
public createGauge(): never {
|
||||
throw new Error("unused");
|
||||
}
|
||||
}
|
||||
|
||||
class StubMeterProvider implements MeterProvider {
|
||||
public meter = new StubMeter();
|
||||
public getMeter(): Meter {
|
||||
return this.meter;
|
||||
}
|
||||
}
|
||||
|
||||
function plugin(scheme: string, ad: MemoryStorageLike): UriPlugin {
|
||||
return {
|
||||
scheme,
|
||||
adapter: ad,
|
||||
parse(uri) {
|
||||
const after = uri.slice(uri.indexOf("://") + 3);
|
||||
const slash = after.indexOf("/");
|
||||
return { key: slash >= 0 ? after.slice(slash + 1) : "" };
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("storage-uri otel", () => {
|
||||
let provider: StubMeterProvider;
|
||||
beforeEach(() => {
|
||||
provider = new StubMeterProvider();
|
||||
setStorageUriMeterProvider(provider);
|
||||
resetInstruments();
|
||||
});
|
||||
afterEach(() => {
|
||||
setStorageUriMeterProvider(undefined);
|
||||
resetInstruments();
|
||||
});
|
||||
|
||||
it("records duration histogram on success", { timeout: 5000 }, async () => {
|
||||
const ad = new MemoryStorageLike();
|
||||
await ad.set("a", new Uint8Array([1]));
|
||||
const r = createStorageUriResolver([plugin("file", ad)]);
|
||||
await r.read("file://h/a");
|
||||
const dur = provider.meter.histograms.get("storage_uri.op_duration_ms");
|
||||
expect(dur?.samples.length).toBe(1);
|
||||
expect(dur?.samples[0]?.attrs).toMatchObject({
|
||||
op: "read",
|
||||
scheme: "file",
|
||||
outcome: "success",
|
||||
});
|
||||
const reads = provider.meter.counters.get("storage_uri.reads_total");
|
||||
expect(reads?.adds.length).toBe(1);
|
||||
});
|
||||
|
||||
it("increments errors_total with resolved code", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const r = createStorageUriResolver([]);
|
||||
await expect(r.read("ftp://x/y")).rejects.toBeDefined();
|
||||
const errs = provider.meter.counters.get("storage_uri.errors_total");
|
||||
expect(errs?.adds.length).toBe(1);
|
||||
expect(errs?.adds[0]?.attrs).toMatchObject({
|
||||
scheme: "ftp",
|
||||
code: "STORAGE_URI.UNKNOWN_SCHEME",
|
||||
});
|
||||
});
|
||||
|
||||
it("strips query string from span attributes", { timeout: 5000 }, () => {
|
||||
expect(redactUri("https://host/path?token=secret")).toBe(
|
||||
"https://host/path"
|
||||
);
|
||||
expect(redactUri("file:///a#frag")).toBe("file:///a");
|
||||
expect(redactUri("s3://x/y")).toBe("s3://x/y");
|
||||
});
|
||||
|
||||
it("recordOp routes write/remove counters by op category", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
recordOp({
|
||||
op: "write",
|
||||
scheme: "file",
|
||||
outcome: "success",
|
||||
durationMs: 1,
|
||||
});
|
||||
recordOp({
|
||||
op: "remove",
|
||||
scheme: "file",
|
||||
outcome: "success",
|
||||
durationMs: 1,
|
||||
});
|
||||
expect(
|
||||
provider.meter.counters.get("storage_uri.writes_total")?.adds.length
|
||||
).toBe(1);
|
||||
expect(
|
||||
provider.meter.counters.get("storage_uri.deletes_total")?.adds
|
||||
.length
|
||||
).toBe(1);
|
||||
});
|
||||
|
||||
it("recordOp routes every read-class op to reads_total", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
for (const op of ["read", "open", "stat", "exists", "list"] as const) {
|
||||
recordOp({ op, scheme: "x", outcome: "success", durationMs: 1 });
|
||||
}
|
||||
expect(
|
||||
provider.meter.counters.get("storage_uri.reads_total")?.adds.length
|
||||
).toBe(5);
|
||||
});
|
||||
|
||||
it("recordOp falls back to UNKNOWN code when none supplied on error", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
recordOp({ op: "read", scheme: "x", outcome: "error", durationMs: 1 });
|
||||
const errs = provider.meter.counters.get("storage_uri.errors_total");
|
||||
expect(errs?.adds[0]?.attrs).toMatchObject({ code: "UNKNOWN" });
|
||||
});
|
||||
|
||||
it("stream-fallback and buffered-write helpers are scheme-attributed", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
recordStreamFallback("mem");
|
||||
recordBufferedWrite("file");
|
||||
expect(
|
||||
provider.meter.counters.get("storage_uri.stream_fallback_total")
|
||||
?.adds[0]?.attrs
|
||||
).toMatchObject({ scheme: "mem" });
|
||||
expect(
|
||||
provider.meter.counters.get("storage_uri.buffered_write_total")
|
||||
?.adds[0]?.attrs
|
||||
).toMatchObject({ scheme: "file" });
|
||||
});
|
||||
|
||||
it("getInstruments caches between calls and rebuilds on provider swap", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const a =
|
||||
provider.meter.counters.get("storage_uri.reads_total") ?? null;
|
||||
recordOp({
|
||||
op: "read",
|
||||
scheme: "x",
|
||||
outcome: "success",
|
||||
durationMs: 1,
|
||||
});
|
||||
recordOp({
|
||||
op: "read",
|
||||
scheme: "x",
|
||||
outcome: "success",
|
||||
durationMs: 1,
|
||||
});
|
||||
const counter = provider.meter.counters.get("storage_uri.reads_total");
|
||||
expect(counter?.adds.length).toBeGreaterThanOrEqual(2);
|
||||
expect(a).toBeDefined();
|
||||
const next = new StubMeterProvider();
|
||||
setStorageUriMeterProvider(next);
|
||||
recordOp({
|
||||
op: "read",
|
||||
scheme: "x",
|
||||
outcome: "success",
|
||||
durationMs: 1,
|
||||
});
|
||||
expect(
|
||||
next.meter.counters.get("storage_uri.reads_total")?.adds.length
|
||||
).toBe(1);
|
||||
});
|
||||
});
|
||||
164
packages/persistence/storage/storage-uri/src/otel/otel.ts
Normal file
164
packages/persistence/storage/storage-uri/src/otel/otel.ts
Normal file
@@ -0,0 +1,164 @@
|
||||
import {
|
||||
type Counter,
|
||||
type Histogram,
|
||||
type Meter,
|
||||
type MeterProvider,
|
||||
metrics,
|
||||
type Tracer,
|
||||
trace,
|
||||
} from "@opentelemetry/api";
|
||||
|
||||
const METER_NAME = "@suaveplan/storage-uri";
|
||||
const TRACER_NAME = "@suaveplan/storage-uri";
|
||||
|
||||
let overrideMeterProvider: MeterProvider | undefined;
|
||||
|
||||
/**
|
||||
* Override the `MeterProvider` used to acquire the storage-uri meter. Pass
|
||||
* `undefined` to revert to the global provider. Resets the cached instrument
|
||||
* bag so the next access binds to the new provider.
|
||||
*/
|
||||
export function setStorageUriMeterProvider(
|
||||
provider: MeterProvider | undefined
|
||||
): void {
|
||||
overrideMeterProvider = provider;
|
||||
cachedInstruments = undefined;
|
||||
}
|
||||
|
||||
export type StorageUriOp =
|
||||
| "open"
|
||||
| "read"
|
||||
| "write"
|
||||
| "stat"
|
||||
| "exists"
|
||||
| "remove"
|
||||
| "list";
|
||||
|
||||
export type StorageUriOutcome = "success" | "not_found" | "error" | "aborted";
|
||||
|
||||
export interface StorageUriInstruments {
|
||||
readonly tracer: Tracer;
|
||||
readonly meter: Meter;
|
||||
readonly readsTotal: Counter;
|
||||
readonly writesTotal: Counter;
|
||||
readonly deletesTotal: Counter;
|
||||
readonly errorsTotal: Counter;
|
||||
readonly streamFallbackTotal: Counter;
|
||||
readonly bufferedWriteTotal: Counter;
|
||||
readonly opDurationMs: Histogram;
|
||||
}
|
||||
|
||||
let cachedInstruments: StorageUriInstruments | undefined;
|
||||
|
||||
/** Lazily build (or return cached) instruments bound to the active provider. */
|
||||
export function getInstruments(force = false): StorageUriInstruments {
|
||||
if (cachedInstruments !== undefined && !force) return cachedInstruments;
|
||||
const tracer = trace.getTracer(TRACER_NAME);
|
||||
const meter = (overrideMeterProvider ?? metrics).getMeter(METER_NAME);
|
||||
cachedInstruments = {
|
||||
tracer,
|
||||
meter,
|
||||
readsTotal: meter.createCounter("storage_uri.reads_total", {
|
||||
description:
|
||||
"Total successful read operations dispatched via storage-uri",
|
||||
}),
|
||||
writesTotal: meter.createCounter("storage_uri.writes_total", {
|
||||
description:
|
||||
"Total successful write operations dispatched via storage-uri",
|
||||
}),
|
||||
deletesTotal: meter.createCounter("storage_uri.deletes_total", {
|
||||
description:
|
||||
"Total successful delete operations dispatched via storage-uri",
|
||||
}),
|
||||
errorsTotal: meter.createCounter("storage_uri.errors_total", {
|
||||
description:
|
||||
"Operations that ended in error, attributed by STORAGE_URI.* code",
|
||||
}),
|
||||
streamFallbackTotal: meter.createCounter(
|
||||
"storage_uri.stream_fallback_total",
|
||||
{
|
||||
description:
|
||||
"Count of open() calls that fell back to a one-chunk ReadableStream wrap because the adapter has no createReadStream",
|
||||
}
|
||||
),
|
||||
bufferedWriteTotal: meter.createCounter(
|
||||
"storage_uri.buffered_write_total",
|
||||
{
|
||||
description:
|
||||
"Count of write() calls that drained a ReadableStream body because the adapter has no createWriteStream",
|
||||
}
|
||||
),
|
||||
opDurationMs: meter.createHistogram("storage_uri.op_duration_ms", {
|
||||
description: "Per-operation duration in milliseconds",
|
||||
unit: "ms",
|
||||
}),
|
||||
};
|
||||
return cachedInstruments;
|
||||
}
|
||||
|
||||
/** Reset cached instruments — used by tests between provider swaps. */
|
||||
export function resetInstruments(): void {
|
||||
cachedInstruments = undefined;
|
||||
}
|
||||
|
||||
const SUCCESS_COUNTER_OF: Record<StorageUriOp, "reads" | "writes" | "deletes"> =
|
||||
{
|
||||
read: "reads",
|
||||
open: "reads",
|
||||
stat: "reads",
|
||||
exists: "reads",
|
||||
list: "reads",
|
||||
write: "writes",
|
||||
remove: "deletes",
|
||||
};
|
||||
|
||||
export interface RecordOpInput {
|
||||
readonly op: StorageUriOp;
|
||||
readonly scheme: string;
|
||||
readonly outcome: StorageUriOutcome;
|
||||
readonly durationMs: number;
|
||||
/** Resolved `STORAGE_URI.*` code when `outcome === "error"`. */
|
||||
readonly code?: string;
|
||||
}
|
||||
|
||||
/** Record one full observability bundle for a completed operation. */
|
||||
export function recordOp(input: RecordOpInput): void {
|
||||
const inst = getInstruments();
|
||||
const { op, scheme, outcome, durationMs, code } = input;
|
||||
inst.opDurationMs.record(durationMs, { op, scheme, outcome });
|
||||
if (outcome === "success") {
|
||||
const counter = SUCCESS_COUNTER_OF[op];
|
||||
(counter === "writes"
|
||||
? inst.writesTotal
|
||||
: counter === "deletes"
|
||||
? inst.deletesTotal
|
||||
: inst.readsTotal
|
||||
).add(1, { op, scheme, outcome });
|
||||
} else {
|
||||
inst.errorsTotal.add(1, { op, scheme, code: code ?? "UNKNOWN" });
|
||||
}
|
||||
}
|
||||
|
||||
/** Increment `storage_uri.stream_fallback_total` for the given scheme. */
|
||||
export function recordStreamFallback(scheme: string): void {
|
||||
getInstruments().streamFallbackTotal.add(1, { scheme });
|
||||
}
|
||||
|
||||
/** Increment `storage_uri.buffered_write_total` for the given scheme. */
|
||||
export function recordBufferedWrite(scheme: string): void {
|
||||
getInstruments().bufferedWriteTotal.add(1, { scheme });
|
||||
}
|
||||
|
||||
/**
|
||||
* Strip query string and fragment from a URI so that span attributes never
|
||||
* leak presigned-URL secrets. Returns the input verbatim when it cannot be
|
||||
* sanitised (e.g. parse failure on a malformed candidate).
|
||||
*/
|
||||
export function redactUri(uri: string): string {
|
||||
const q = uri.indexOf("?");
|
||||
const h = uri.indexOf("#");
|
||||
let end = uri.length;
|
||||
if (q >= 0) end = Math.min(end, q);
|
||||
if (h >= 0) end = Math.min(end, h);
|
||||
return uri.slice(0, end);
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export type { ParsedUriParts } from "./parse-uri.js";
|
||||
export { parseUri } from "./parse-uri.js";
|
||||
@@ -0,0 +1,71 @@
|
||||
# parse-uri
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`parseUri(uri)` is the RFC 3986 subset parser the resolver uses to extract a
|
||||
scheme, optional authority, path, and optional query from a URI string. It
|
||||
exists to keep `StorageUriResolver` scheme-agnostic — every adapter plugin
|
||||
owns its own scheme-specific addressing and composes against this base
|
||||
parser.
|
||||
|
||||
The parser intentionally rejects more inputs than RFC 3986 strictly
|
||||
requires: whitespace, NUL bytes, and strings longer than 4096 characters are
|
||||
all hard-failures. The 4096-cap matches the de-facto path-length ceiling
|
||||
observed across S3, Rackspace, Bunny, and most filesystems and prevents
|
||||
quadratic blow-ups inside plugin parsers.
|
||||
|
||||
## Features
|
||||
|
||||
- Scheme regex `^[A-Za-z][A-Za-z0-9+\-.]*$` per RFC 3986 §3.1.
|
||||
- Case-insensitive scheme — the returned `scheme` is lower-cased.
|
||||
- Authority extraction when the rest starts with `//`.
|
||||
- Query/fragment split with both kept off the path.
|
||||
- Single error code on rejection: `STORAGE_URI.INVALID_URI` with the
|
||||
offending input on the `uri` discriminant.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { parseUri } from "@suaveplan/storage-uri/parse-uri";
|
||||
|
||||
const parts = parseUri("s3://bucket/raw/clip.mov");
|
||||
// { scheme: "s3", authority: "bucket", path: "/raw/clip.mov" }
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { parseUri } from "@suaveplan/storage-uri/parse-uri";
|
||||
import { STORAGE_URI_CODES, StorageUriError } from "@suaveplan/storage-uri/error-codes";
|
||||
|
||||
function parseOrNull(uri: string) {
|
||||
try {
|
||||
return parseUri(uri);
|
||||
} catch (err) {
|
||||
if (err instanceof StorageUriError && err.code === STORAGE_URI_CODES.INVALID_URI) {
|
||||
return null;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `interface ParsedUriParts { scheme: string; authority?: string; path: string; query?: string }`
|
||||
- `function parseUri(uri: string): ParsedUriParts` — throws
|
||||
`StorageUriError(STORAGE_URI.INVALID_URI)` on rejection.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
Authority termination is hand-rolled instead of regex-based so the parser
|
||||
avoids re-walking the input. The `findAuthorityEnd` helper scans for the
|
||||
first `/`, `?`, or `#` character — these are the three RFC 3986 component
|
||||
delimiters that may follow an authority. Empty path is permitted (e.g.
|
||||
`s3://bucket`); empty rest after the colon (`scheme:`) is not. The
|
||||
maximum-length check happens BEFORE the regex test so pathological inputs
|
||||
cannot pin a regex engine.
|
||||
@@ -0,0 +1,94 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import {
|
||||
STORAGE_URI_CODES,
|
||||
StorageUriError,
|
||||
} from "../error-codes/error-codes.js";
|
||||
import { parseUri } from "./parse-uri.js";
|
||||
|
||||
const NUL = String.fromCharCode(0);
|
||||
|
||||
describe("parseUri", () => {
|
||||
it("parses well-formed URIs", { timeout: 5000 }, () => {
|
||||
const a = parseUri("s3://bucket/key");
|
||||
expect(a.scheme).toBe("s3");
|
||||
expect(a.authority).toBe("bucket");
|
||||
expect(a.path).toBe("/key");
|
||||
|
||||
const b = parseUri("file:///abs/path");
|
||||
expect(b.scheme).toBe("file");
|
||||
expect(b.authority).toBe("");
|
||||
expect(b.path).toBe("/abs/path");
|
||||
|
||||
const c = parseUri("https://host/p?q=1");
|
||||
expect(c.scheme).toBe("https");
|
||||
expect(c.authority).toBe("host");
|
||||
expect(c.path).toBe("/p");
|
||||
expect(c.query).toBe("q=1");
|
||||
});
|
||||
|
||||
it("throws INVALID_URI for malformed inputs", { timeout: 5000 }, () => {
|
||||
for (const bad of [
|
||||
"bare-string",
|
||||
"scheme:",
|
||||
"s3://bucket /key",
|
||||
"x".repeat(4097),
|
||||
]) {
|
||||
let threw: unknown;
|
||||
try {
|
||||
parseUri(bad);
|
||||
} catch (err) {
|
||||
threw = err;
|
||||
}
|
||||
expect(threw).toBeInstanceOf(StorageUriError);
|
||||
expect((threw as StorageUriError).code).toBe(
|
||||
STORAGE_URI_CODES.INVALID_URI
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects empty input", { timeout: 5000 }, () => {
|
||||
expect(() => parseUri("")).toThrow(StorageUriError);
|
||||
});
|
||||
|
||||
it("rejects NUL bytes", { timeout: 5000 }, () => {
|
||||
expect(() => parseUri(`s3://b/k${NUL}`)).toThrow(StorageUriError);
|
||||
});
|
||||
|
||||
it("lowercases the scheme per RFC 3986 §3.1", { timeout: 5000 }, () => {
|
||||
expect(parseUri("S3://bucket/key").scheme).toBe("s3");
|
||||
expect(parseUri("HTTP://host/p").scheme).toBe("http");
|
||||
});
|
||||
|
||||
it("rejects scheme starting with a non-letter", { timeout: 5000 }, () => {
|
||||
expect(() => parseUri("1bad://x")).toThrow(StorageUriError);
|
||||
});
|
||||
|
||||
it("handles a fragment by stripping it from the path", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const out = parseUri("https://host/p#frag");
|
||||
expect(out.path).toBe("/p");
|
||||
});
|
||||
|
||||
it("rejects non-string input via coercion guard", { timeout: 5000 }, () => {
|
||||
expect(() => parseUri(123 as unknown as string)).toThrow(
|
||||
StorageUriError
|
||||
);
|
||||
});
|
||||
|
||||
it("parses an authority that is not terminated by /, ?, or #", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const out = parseUri("s3://just-bucket");
|
||||
expect(out.scheme).toBe("s3");
|
||||
expect(out.authority).toBe("just-bucket");
|
||||
expect(out.path).toBe("");
|
||||
});
|
||||
|
||||
it("parses a URI with no authority component", { timeout: 5000 }, () => {
|
||||
const out = parseUri("urn:example:foo");
|
||||
expect(out.scheme).toBe("urn");
|
||||
expect(out.authority).toBeUndefined();
|
||||
expect(out.path).toBe("example:foo");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,117 @@
|
||||
import {
|
||||
STORAGE_URI_CODES,
|
||||
StorageUriError,
|
||||
} from "../error-codes/error-codes.js";
|
||||
|
||||
/** Lowercased scheme + the structural components used by adapter plugins. */
|
||||
export interface ParsedUriParts {
|
||||
/** RFC 3986 scheme, lowercased per §3.1 case-insensitivity. */
|
||||
readonly scheme: string;
|
||||
/** Authority component (host[:port]) when present, else `undefined`. */
|
||||
readonly authority?: string;
|
||||
/** Path component — always defined, may be empty. */
|
||||
readonly path: string;
|
||||
/** Query component without the leading `?`, when present. */
|
||||
readonly query?: string;
|
||||
}
|
||||
|
||||
const SCHEME_RE = /^[A-Za-z][A-Za-z0-9+\-.]*$/;
|
||||
const MAX_LEN = 4096;
|
||||
const NUL_CHAR = String.fromCharCode(0);
|
||||
|
||||
/**
|
||||
* Split an input string into RFC 3986 components.
|
||||
*
|
||||
* Rejects every input that:
|
||||
*
|
||||
* - is empty or has no `:` delimiter after the scheme
|
||||
* - has a scheme that fails `^[A-Za-z][A-Za-z0-9+\-.]*$`
|
||||
* - contains a whitespace character (space, tab, newline, return)
|
||||
* - contains a U+0000 NUL byte
|
||||
* - exceeds 4096 characters
|
||||
*
|
||||
* On rejection, throws `StorageUriError(STORAGE_URI.INVALID_URI)` with the
|
||||
* offending input on the `uri` discriminant.
|
||||
*/
|
||||
export function parseUri(uri: string): ParsedUriParts {
|
||||
if (typeof uri !== "string" || uri.length === 0) {
|
||||
throw new StorageUriError(
|
||||
STORAGE_URI_CODES.INVALID_URI,
|
||||
"URI must be a non-empty string",
|
||||
{ uri: typeof uri === "string" ? uri : String(uri) }
|
||||
);
|
||||
}
|
||||
if (uri.length > MAX_LEN) {
|
||||
throw new StorageUriError(
|
||||
STORAGE_URI_CODES.INVALID_URI,
|
||||
`URI exceeds ${MAX_LEN}-character limit`,
|
||||
{ uri: uri.slice(0, 64) }
|
||||
);
|
||||
}
|
||||
if (uri.includes(NUL_CHAR)) {
|
||||
throw new StorageUriError(
|
||||
STORAGE_URI_CODES.INVALID_URI,
|
||||
"URI contains a NUL byte",
|
||||
{ uri }
|
||||
);
|
||||
}
|
||||
if (/\s/.test(uri)) {
|
||||
throw new StorageUriError(
|
||||
STORAGE_URI_CODES.INVALID_URI,
|
||||
"URI contains whitespace",
|
||||
{ uri }
|
||||
);
|
||||
}
|
||||
const colon = uri.indexOf(":");
|
||||
if (colon <= 0 || colon === uri.length - 1) {
|
||||
throw new StorageUriError(
|
||||
STORAGE_URI_CODES.INVALID_URI,
|
||||
"URI must be of the form '<scheme>:<rest>' with a non-empty rest",
|
||||
{ uri }
|
||||
);
|
||||
}
|
||||
const rawScheme = uri.slice(0, colon);
|
||||
if (!SCHEME_RE.test(rawScheme)) {
|
||||
throw new StorageUriError(
|
||||
STORAGE_URI_CODES.INVALID_URI,
|
||||
`URI scheme '${rawScheme}' does not match RFC 3986 §3.1`,
|
||||
{ uri, scheme: rawScheme }
|
||||
);
|
||||
}
|
||||
const scheme = rawScheme.toLowerCase();
|
||||
let rest = uri.slice(colon + 1);
|
||||
|
||||
let authority: string | undefined;
|
||||
if (rest.startsWith("//")) {
|
||||
const afterSlashes = rest.slice(2);
|
||||
const pathStart = findAuthorityEnd(afterSlashes);
|
||||
authority = afterSlashes.slice(0, pathStart);
|
||||
rest = afterSlashes.slice(pathStart);
|
||||
}
|
||||
|
||||
let query: string | undefined;
|
||||
const q = rest.indexOf("?");
|
||||
if (q >= 0) {
|
||||
query = rest.slice(q + 1);
|
||||
rest = rest.slice(0, q);
|
||||
}
|
||||
const hash = rest.indexOf("#");
|
||||
const path = hash >= 0 ? rest.slice(0, hash) : rest;
|
||||
|
||||
const out: ParsedUriParts = {
|
||||
scheme,
|
||||
path,
|
||||
...(authority !== undefined ? { authority } : {}),
|
||||
...(query !== undefined ? { query } : {}),
|
||||
};
|
||||
return out;
|
||||
}
|
||||
|
||||
function findAuthorityEnd(s: string): number {
|
||||
for (let i = 0; i < s.length; i++) {
|
||||
const c = s.charCodeAt(i);
|
||||
// `/` (47), `?` (63), `#` (35)
|
||||
if (c === 47 || c === 63 || c === 35) return i;
|
||||
}
|
||||
return s.length;
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
export {
|
||||
createStorageUriResolver,
|
||||
StorageUriResolver,
|
||||
} from "./resolver.js";
|
||||
export { listViaPlugin } from "./resolver.list.js";
|
||||
export { openViaPlugin } from "./resolver.streams.js";
|
||||
export { writeViaPlugin } from "./resolver.writes.js";
|
||||
@@ -0,0 +1,66 @@
|
||||
# resolver.list
|
||||
|
||||
## Purpose
|
||||
|
||||
Implements the `list(uri, options?, signal?)` semantics. Treats the input
|
||||
URI's key portion as a listing prefix, delegates to
|
||||
`StorageLike.list({ prefix, limit, continuationToken })`, then re-serialises
|
||||
each returned adapter key back to a fully-qualified URI via the plugin's
|
||||
`build(key, meta)` function. Plugins without `build` cause `list()` to throw
|
||||
`STORAGE_URI.LIST_UNSUPPORTED`.
|
||||
|
||||
The function lives in its own file so the prefix-and-rebuild logic stays
|
||||
isolated from the rest of the resolver and so its specific error mapping
|
||||
can evolve without touching adjacent ops.
|
||||
|
||||
## Features
|
||||
|
||||
- Prefix derived from the plugin's `parse(uri).key`.
|
||||
- Limit and continuation-token forwarded verbatim when supplied.
|
||||
- `LIST_UNSUPPORTED` raised at the top of the function when `build` is
|
||||
missing — before any adapter call is issued.
|
||||
- `hasMore`/`continuationToken` round-tripped opaquely from the adapter.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { listViaPlugin } from "@suaveplan/storage-uri/resolver";
|
||||
|
||||
const out = await listViaPlugin(plugin, "s3://bucket/raw/", "raw/", { bucket: "bucket" }, { limit: 10 });
|
||||
// { uris: ["s3://bucket/raw/a.mov", ...], hasMore: false }
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
async function listAll(plugin, uri, prefix, meta) {
|
||||
const acc: string[] = [];
|
||||
let token: string | undefined;
|
||||
do {
|
||||
const opts = token === undefined ? { limit: 100 } : { limit: 100, continuationToken: token };
|
||||
const page = await listViaPlugin(plugin, uri, prefix, meta, opts);
|
||||
acc.push(...page.uris);
|
||||
token = page.continuationToken;
|
||||
} while (token !== undefined);
|
||||
return acc;
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `function listViaPlugin(plugin, uri, prefix, meta?, options?): Promise<ListUriResult>`
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`build` is captured via `.bind(plugin)` before the map so `this`-bound
|
||||
plugin implementations work without surprise. The output object is
|
||||
constructed via spread so `continuationToken` is omitted entirely when the
|
||||
adapter returned `undefined` — matters under
|
||||
`exactOptionalPropertyTypes: true` and keeps the resolver's contract
|
||||
consistent with `StorageLike.list`. The capability-check fires before any
|
||||
adapter call so an unsupported list does not waste an upstream request:
|
||||
operators can probe for list support cheaply by catching
|
||||
`STORAGE_URI.LIST_UNSUPPORTED` once and caching the result per-plugin.
|
||||
`meta` is forwarded verbatim to `build` so plugins that carry a bucket,
|
||||
container, or zone in `meta` get the same value back; downstream consumers
|
||||
that paginate across pages will see the same authority on every URI.
|
||||
@@ -0,0 +1,110 @@
|
||||
import { beforeEach, describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { MemoryStorageLike } from "../../tests/setup/test-helpers.js";
|
||||
import {
|
||||
STORAGE_URI_CODES,
|
||||
StorageUriError,
|
||||
} from "../error-codes/error-codes.js";
|
||||
import type { UriPlugin } from "../types/plugin.types.js";
|
||||
import { createStorageUriResolver } from "./resolver.js";
|
||||
|
||||
function withBuildPlugin(
|
||||
scheme: string,
|
||||
adapter: MemoryStorageLike
|
||||
): UriPlugin {
|
||||
return {
|
||||
scheme,
|
||||
adapter,
|
||||
parse(uri) {
|
||||
const after = uri.slice(uri.indexOf("://") + 3);
|
||||
const slash = after.indexOf("/");
|
||||
const authority = slash >= 0 ? after.slice(0, slash) : after;
|
||||
const key = slash >= 0 ? after.slice(slash + 1) : "";
|
||||
return { key, meta: { bucket: authority } };
|
||||
},
|
||||
build(key, meta) {
|
||||
const bucket = meta?.["bucket"] ?? "default";
|
||||
return `${scheme}://${bucket}/${key}`;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function noBuildPlugin(scheme: string, adapter: MemoryStorageLike): UriPlugin {
|
||||
return {
|
||||
scheme,
|
||||
adapter,
|
||||
parse(uri) {
|
||||
const after = uri.slice(uri.indexOf("://") + 3);
|
||||
const slash = after.indexOf("/");
|
||||
return { key: slash >= 0 ? after.slice(slash + 1) : "" };
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("resolver.list", () => {
|
||||
let adapter: MemoryStorageLike;
|
||||
beforeEach(() => {
|
||||
adapter = new MemoryStorageLike();
|
||||
});
|
||||
|
||||
it("re-serialises keys to URIs", { timeout: 5000 }, async () => {
|
||||
await adapter.set("raw/a.mov", new Uint8Array([1]));
|
||||
await adapter.set("raw/b.mov", new Uint8Array([2]));
|
||||
await adapter.set("other/c.mov", new Uint8Array([3]));
|
||||
const r = createStorageUriResolver([withBuildPlugin("s3", adapter)]);
|
||||
const out = await r.list("s3://bucket/raw/", { limit: 10 });
|
||||
expect([...out.uris].sort()).toEqual([
|
||||
"s3://bucket/raw/a.mov",
|
||||
"s3://bucket/raw/b.mov",
|
||||
]);
|
||||
expect(out.hasMore).toBe(false);
|
||||
});
|
||||
|
||||
it("throws LIST_UNSUPPORTED when plugin omits build", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const r = createStorageUriResolver([noBuildPlugin("memory", adapter)]);
|
||||
let threw: unknown;
|
||||
try {
|
||||
await r.list("memory://x/y/");
|
||||
} catch (err) {
|
||||
threw = err;
|
||||
}
|
||||
expect(threw).toBeInstanceOf(StorageUriError);
|
||||
expect((threw as StorageUriError).code).toBe(
|
||||
STORAGE_URI_CODES.LIST_UNSUPPORTED
|
||||
);
|
||||
});
|
||||
|
||||
it("round-trips continuation token opaquely", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const ad = new MemoryStorageLike();
|
||||
// Override list to inject a continuation token.
|
||||
ad.list = async () => ({
|
||||
keys: ["x/a", "x/b"],
|
||||
total: 2,
|
||||
hasMore: true,
|
||||
continuationToken: "def",
|
||||
});
|
||||
const r = createStorageUriResolver([withBuildPlugin("s3", ad)]);
|
||||
const out = await r.list("s3://bucket/x/", {
|
||||
continuationToken: "abc",
|
||||
});
|
||||
expect(out.continuationToken).toBe("def");
|
||||
expect(out.hasMore).toBe(true);
|
||||
});
|
||||
|
||||
it("forwards limit option through to adapter.list", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
let captured: unknown;
|
||||
const ad = new MemoryStorageLike();
|
||||
ad.list = async (opts) => {
|
||||
captured = opts;
|
||||
return { keys: [], total: 0, hasMore: false };
|
||||
};
|
||||
const r = createStorageUriResolver([withBuildPlugin("s3", ad)]);
|
||||
await r.list("s3://bucket/p/", { limit: 7 });
|
||||
expect(captured).toMatchObject({ prefix: "p/", limit: 7 });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,55 @@
|
||||
import {
|
||||
STORAGE_URI_CODES,
|
||||
StorageUriError,
|
||||
} from "../error-codes/error-codes.js";
|
||||
import type { UriPlugin } from "../types/plugin.types.js";
|
||||
import type { ListUriOptions, ListUriResult } from "../types/resolver.types.js";
|
||||
|
||||
/**
|
||||
* Implement `list(uri, options?)` semantics for a dispatched plugin.
|
||||
*
|
||||
* The input URI's key portion is treated as the listing prefix. The plugin
|
||||
* MUST expose a `build()` function so each returned adapter key can be
|
||||
* re-serialised back to a fully-qualified URI; plugins without `build` throw
|
||||
* `STORAGE_URI.LIST_UNSUPPORTED`.
|
||||
*
|
||||
* The adapter's `hasMore` and `continuationToken` fields are forwarded
|
||||
* verbatim — the resolver does not interpret tokens.
|
||||
*/
|
||||
export async function listViaPlugin(
|
||||
plugin: UriPlugin,
|
||||
uri: string,
|
||||
prefix: string,
|
||||
meta: Record<string, string> | undefined,
|
||||
options: ListUriOptions | undefined
|
||||
): Promise<ListUriResult> {
|
||||
if (typeof plugin.build !== "function") {
|
||||
throw new StorageUriError(
|
||||
STORAGE_URI_CODES.LIST_UNSUPPORTED,
|
||||
"plugin does not implement build(); list() requires it",
|
||||
{ uri, scheme: plugin.scheme }
|
||||
);
|
||||
}
|
||||
const build = plugin.build.bind(plugin);
|
||||
const listOpts: {
|
||||
prefix: string;
|
||||
limit?: number;
|
||||
continuationToken?: string;
|
||||
} = {
|
||||
prefix,
|
||||
};
|
||||
if (options?.limit !== undefined) listOpts.limit = options.limit;
|
||||
if (options?.continuationToken !== undefined) {
|
||||
listOpts.continuationToken = options.continuationToken;
|
||||
}
|
||||
const result = await plugin.adapter.list(listOpts);
|
||||
const uris = result.keys.map((k) => build(k, meta));
|
||||
const out: ListUriResult = {
|
||||
uris,
|
||||
hasMore: result.hasMore,
|
||||
...(result.continuationToken !== undefined
|
||||
? { continuationToken: result.continuationToken }
|
||||
: {}),
|
||||
};
|
||||
return out;
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
# resolver
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
The `StorageUriResolver` class is the entry point for URI-routed dispatch in
|
||||
`@suaveplan/storage-uri`. Constructed once at boot from an array of
|
||||
`UriPlugin`s, it exposes `open`/`read`/`write`/`stat`/`exists`/`remove`/
|
||||
`list` keyed by URI string and delegates each call to the plugin matching
|
||||
the URI's scheme. The resolver performs no I/O of its own — every byte goes
|
||||
through an existing `StorageLike` adapter — and is fully immutable after
|
||||
construction.
|
||||
|
||||
`createStorageUriResolver(plugins)` is the preferred factory. It throws
|
||||
`STORAGE_URI.SCHEME_CONFLICT` when two distinct plugin references claim the
|
||||
same scheme; registering the same reference twice is a silent no-op. The
|
||||
result is frozen via `Object.freeze`.
|
||||
|
||||
## Features
|
||||
|
||||
- O(1) scheme dispatch via `Map<scheme, UriPlugin>` (case-insensitive).
|
||||
- `AbortSignal` propagated to every adapter call and short-circuited
|
||||
pre-aborted with `STORAGE_URI.ABORTED`.
|
||||
- `SuaveplanError` subclasses thrown by an adapter (any package's namespaced
|
||||
error — `DriveUriError`, `StorageHttpError`, etc.) pass through the resolver
|
||||
unchanged so consumers can `switch (err.code)` on the original code. Only
|
||||
non-namespaced throwables (plain `Error`, foreign objects) are wrapped as
|
||||
`STORAGE_URI.ADAPTER_ERROR` with the original preserved as `cause`.
|
||||
- One span per public op (`storage.uri.{open,read,write,stat,exists,remove,list}`)
|
||||
on the `@suaveplan/storage-uri` tracer.
|
||||
- `op_duration_ms`, `reads_total`, `writes_total`, `deletes_total`,
|
||||
`errors_total` counters emitted automatically.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { createStorageUriResolver } from "@suaveplan/storage-uri";
|
||||
|
||||
const resolver = createStorageUriResolver([fileUriPlugin, httpsUriPlugin]);
|
||||
const bytes = await resolver.read("file:///cache/poster.jpg");
|
||||
await resolver.write("file:///cache/poster.jpg", newBytes);
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
const stream = await resolver.open("https://cdn.example.com/clip/master.m3u8");
|
||||
const reader = stream.getReader();
|
||||
try {
|
||||
while (true) {
|
||||
const { value, done } = await reader.read();
|
||||
if (done) break;
|
||||
sink.write(value);
|
||||
}
|
||||
} finally {
|
||||
reader.releaseLock();
|
||||
}
|
||||
|
||||
// Prefix list with continuation paging.
|
||||
let token: string | undefined;
|
||||
do {
|
||||
const page = await resolver.list("s3://bucket/raw/", { limit: 100, ...(token !== undefined ? { continuationToken: token } : {}) });
|
||||
for (const uri of page.uris) consume(uri);
|
||||
token = page.continuationToken;
|
||||
} while (token !== undefined);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `class StorageUriResolver`
|
||||
- `constructor(plugins: readonly UriPlugin[])`
|
||||
- `get schemeCount(): number`
|
||||
- `hasScheme(scheme: string): boolean`
|
||||
- `open(uri, signal?): Promise<ReadableStream<Uint8Array>>`
|
||||
- `read(uri, signal?): Promise<Uint8Array>`
|
||||
- `write(uri, body, signal?): Promise<void>`
|
||||
- `stat(uri, signal?): Promise<StatResult | undefined>`
|
||||
- `exists(uri, signal?): Promise<boolean>`
|
||||
- `remove(uri, signal?): Promise<boolean>`
|
||||
- `list(uri, options?, signal?): Promise<ListUriResult>`
|
||||
- `function createStorageUriResolver(plugins): StorageUriResolver`
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
Dispatch is split into `#dispatch(uri)` (parse + lookup + plugin-`parse`)
|
||||
and per-op handlers. Every public method funnels through the `#run`
|
||||
generic, which owns the pre-aborted check, span start/end, error wrap, and
|
||||
duration emission. Splitting `open`/`write`/`list` into sibling modules
|
||||
keeps each file under the 300-line code budget without forcing the call
|
||||
site to know which file owns which method. The class field is `#plugins`,
|
||||
not `_plugins`, to make accidental external access a hard syntax error.
|
||||
@@ -0,0 +1,115 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import type { StorageLike } from "@suaveplan/types";
|
||||
import { MemoryStorageLike } from "../../tests/setup/test-helpers.js";
|
||||
import { STORAGE_URI_CODES } from "../error-codes/error-codes.js";
|
||||
import type { UriPlugin } from "../types/plugin.types.js";
|
||||
import { createStorageUriResolver } from "./resolver.js";
|
||||
|
||||
describe("storage-uri spec conformance", () => {
|
||||
it("MemoryStorageLike satisfies the StorageLike contract structurally", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const adapter: StorageLike = new MemoryStorageLike();
|
||||
expect(typeof adapter.get).toBe("function");
|
||||
expect(typeof adapter.set).toBe("function");
|
||||
expect(typeof adapter.delete).toBe("function");
|
||||
expect(typeof adapter.exists).toBe("function");
|
||||
expect(typeof adapter.list).toBe("function");
|
||||
expect(typeof adapter.clear).toBe("function");
|
||||
});
|
||||
|
||||
it("the resolver class has the expected public surface", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const r = createStorageUriResolver([]);
|
||||
expect(typeof r.open).toBe("function");
|
||||
expect(typeof r.read).toBe("function");
|
||||
expect(typeof r.write).toBe("function");
|
||||
expect(typeof r.stat).toBe("function");
|
||||
expect(typeof r.exists).toBe("function");
|
||||
expect(typeof r.remove).toBe("function");
|
||||
expect(typeof r.list).toBe("function");
|
||||
expect(typeof r.hasScheme).toBe("function");
|
||||
expect(typeof r.schemeCount).toBe("number");
|
||||
});
|
||||
|
||||
it("every STORAGE_URI code is reachable from a real call site", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
// UNKNOWN_SCHEME
|
||||
const empty = createStorageUriResolver([]);
|
||||
await expect(empty.read("ftp://x/y")).rejects.toMatchObject({
|
||||
code: STORAGE_URI_CODES.UNKNOWN_SCHEME,
|
||||
});
|
||||
// INVALID_URI
|
||||
await expect(empty.read("bad")).rejects.toMatchObject({
|
||||
code: STORAGE_URI_CODES.INVALID_URI,
|
||||
});
|
||||
// SCHEME_CONFLICT
|
||||
const ad = new MemoryStorageLike();
|
||||
const p1: UriPlugin = {
|
||||
scheme: "x",
|
||||
adapter: ad,
|
||||
parse: () => ({ key: "" }),
|
||||
};
|
||||
const p2: UriPlugin = {
|
||||
scheme: "x",
|
||||
adapter: ad,
|
||||
parse: () => ({ key: "" }),
|
||||
};
|
||||
expect(() => createStorageUriResolver([p1, p2])).toThrow();
|
||||
// NOT_FOUND
|
||||
const r = createStorageUriResolver([
|
||||
{
|
||||
scheme: "m",
|
||||
adapter: ad,
|
||||
parse: () => ({ key: "missing" }),
|
||||
},
|
||||
]);
|
||||
await expect(r.read("m://x/missing")).rejects.toMatchObject({
|
||||
code: STORAGE_URI_CODES.NOT_FOUND,
|
||||
});
|
||||
// LIST_UNSUPPORTED
|
||||
await expect(r.list("m://x/p/")).rejects.toMatchObject({
|
||||
code: STORAGE_URI_CODES.LIST_UNSUPPORTED,
|
||||
});
|
||||
// WRITE_UNSUPPORTED
|
||||
const ro = new MemoryStorageLike();
|
||||
ro.set = async () => {
|
||||
throw new Error("read-only");
|
||||
};
|
||||
const wr = createStorageUriResolver([
|
||||
{
|
||||
scheme: "ro",
|
||||
adapter: ro,
|
||||
parse: () => ({ key: "a" }),
|
||||
},
|
||||
]);
|
||||
await expect(
|
||||
wr.write("ro://x/a", new Uint8Array([1]))
|
||||
).rejects.toMatchObject({
|
||||
code: STORAGE_URI_CODES.WRITE_UNSUPPORTED,
|
||||
});
|
||||
// ABORTED
|
||||
const ctrl = new AbortController();
|
||||
ctrl.abort();
|
||||
await expect(r.read("m://x/k", ctrl.signal)).rejects.toMatchObject({
|
||||
code: STORAGE_URI_CODES.ABORTED,
|
||||
});
|
||||
// ADAPTER_ERROR
|
||||
const broken = new MemoryStorageLike();
|
||||
broken.exists = async () => {
|
||||
throw new Error("boom");
|
||||
};
|
||||
const br = createStorageUriResolver([
|
||||
{
|
||||
scheme: "b",
|
||||
adapter: broken,
|
||||
parse: () => ({ key: "a" }),
|
||||
},
|
||||
]);
|
||||
await expect(br.exists("b://x/a")).rejects.toMatchObject({
|
||||
code: STORAGE_URI_CODES.ADAPTER_ERROR,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,61 @@
|
||||
# resolver.streams
|
||||
|
||||
## Purpose
|
||||
|
||||
Implements the `open(uri, signal?)` semantics for the resolver: forward a
|
||||
native `createReadStream` when the adapter exposes one, otherwise fall back
|
||||
to wrapping `adapter.get(key)` in a one-chunk `ReadableStream` and increment
|
||||
`storage_uri.stream_fallback_total{scheme}`. Splitting this branch into its
|
||||
own file keeps `resolver.ts` under the code-line budget while letting the
|
||||
streaming path evolve independently.
|
||||
|
||||
The function is exported so adapter authors and integration tests can call
|
||||
it directly without instantiating the full `StorageUriResolver` — useful for
|
||||
isolating the fallback behaviour in unit tests.
|
||||
|
||||
## Features
|
||||
|
||||
- Duck-typed `createReadStream` detection on the adapter.
|
||||
- One-chunk `ReadableStream` synthesis from a buffered `get` result.
|
||||
- `STORAGE_URI.NOT_FOUND` mapping when `get` returns `undefined`.
|
||||
- `storage_uri.stream_fallback_total` counter increment on the fallback
|
||||
path (not on the forwarding path).
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { openViaPlugin } from "@suaveplan/storage-uri/resolver";
|
||||
|
||||
const stream = await openViaPlugin(plugin, "memory://x/a", "a", undefined);
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
const ctrl = new AbortController();
|
||||
try {
|
||||
const stream = await openViaPlugin(plugin, uri, key, ctrl.signal);
|
||||
pipe(stream, sink, { signal: ctrl.signal });
|
||||
} finally {
|
||||
ctrl.abort();
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `function openViaPlugin(plugin, uri, key, signal): Promise<ReadableStream<Uint8Array>>`
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The function does NOT do the pre-aborted check; that lives in `#run` so
|
||||
every public method shares one short-circuit point. The check for
|
||||
`typeof adapter.createReadStream === "function"` is done on the casted
|
||||
`adapter` value because `StorageLike` does not declare the method — the
|
||||
duck-typing is intentional so any adapter exposing the right shape is
|
||||
forwarded automatically. The fallback path materialises the entire object
|
||||
in memory, which is correct for small payloads but a hazard for large ones
|
||||
behind a non-streaming adapter; operators should alert on the
|
||||
`storage_uri.stream_fallback_total` counter to detect mis-composed
|
||||
deployments before they OOM. The one-chunk `ReadableStream` close is
|
||||
deterministic — the queue is enqueued and the controller closed in the
|
||||
same microtask, so consumers see exactly one chunk before `done: true`.
|
||||
@@ -0,0 +1,169 @@
|
||||
import type { Counter, Meter, MeterProvider } from "@opentelemetry/api";
|
||||
import {
|
||||
afterEach,
|
||||
beforeEach,
|
||||
describe,
|
||||
expect,
|
||||
it,
|
||||
} from "@suaveplan/testing/runner";
|
||||
import { MemoryStorageLike } from "../../tests/setup/test-helpers.js";
|
||||
import {
|
||||
STORAGE_URI_CODES,
|
||||
StorageUriError,
|
||||
} from "../error-codes/error-codes.js";
|
||||
import { resetInstruments, setStorageUriMeterProvider } from "../otel/otel.js";
|
||||
import type { UriPlugin } from "../types/plugin.types.js";
|
||||
import { createStorageUriResolver } from "./resolver.js";
|
||||
|
||||
function basicPlugin(
|
||||
scheme: string,
|
||||
adapter: MemoryStorageLike,
|
||||
extras: Partial<{
|
||||
createReadStream(
|
||||
key: string,
|
||||
signal?: AbortSignal
|
||||
): ReadableStream<Uint8Array>;
|
||||
}> = {}
|
||||
): UriPlugin {
|
||||
const merged = Object.assign(adapter, extras);
|
||||
return {
|
||||
scheme,
|
||||
adapter: merged,
|
||||
parse(uri) {
|
||||
const after = uri.slice(uri.indexOf("://") + 3);
|
||||
const slash = after.indexOf("/");
|
||||
return { key: slash >= 0 ? after.slice(slash + 1) : "" };
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
interface Sample {
|
||||
value: number;
|
||||
attrs: Record<string, unknown> | undefined;
|
||||
}
|
||||
|
||||
class StubCounter implements Counter {
|
||||
public adds: Sample[] = [];
|
||||
public constructor(public readonly name: string) {}
|
||||
public add(v: number, attrs?: Record<string, unknown>): void {
|
||||
this.adds.push({ value: v, attrs });
|
||||
}
|
||||
}
|
||||
|
||||
class StubHistogram {
|
||||
public samples: Sample[] = [];
|
||||
public record(v: number, attrs?: Record<string, unknown>): void {
|
||||
this.samples.push({ value: v, attrs });
|
||||
}
|
||||
}
|
||||
|
||||
class StubMeter implements Meter {
|
||||
public counters: Map<string, StubCounter> = new Map();
|
||||
public histograms: Map<string, StubHistogram> = new Map();
|
||||
public createCounter(name: string): Counter {
|
||||
const c = new StubCounter(name);
|
||||
this.counters.set(name, c);
|
||||
return c;
|
||||
}
|
||||
public createHistogram(name: string): StubHistogram {
|
||||
const h = new StubHistogram();
|
||||
this.histograms.set(name, h);
|
||||
return h as unknown as StubHistogram;
|
||||
}
|
||||
public createUpDownCounter(): never {
|
||||
throw new Error("unused");
|
||||
}
|
||||
public createObservableCounter(): never {
|
||||
throw new Error("unused");
|
||||
}
|
||||
public createObservableUpDownCounter(): never {
|
||||
throw new Error("unused");
|
||||
}
|
||||
public createObservableGauge(): never {
|
||||
throw new Error("unused");
|
||||
}
|
||||
public addBatchObservableCallback(): void {}
|
||||
public removeBatchObservableCallback(): void {}
|
||||
public createGauge(): never {
|
||||
throw new Error("unused");
|
||||
}
|
||||
}
|
||||
|
||||
class StubMeterProvider implements MeterProvider {
|
||||
public meter = new StubMeter();
|
||||
public getMeter(): Meter {
|
||||
return this.meter;
|
||||
}
|
||||
}
|
||||
|
||||
describe("resolver.streams (open)", () => {
|
||||
let provider: StubMeterProvider;
|
||||
beforeEach(() => {
|
||||
provider = new StubMeterProvider();
|
||||
setStorageUriMeterProvider(provider);
|
||||
resetInstruments();
|
||||
});
|
||||
afterEach(() => {
|
||||
setStorageUriMeterProvider(undefined);
|
||||
resetInstruments();
|
||||
});
|
||||
|
||||
it("forwards adapter.createReadStream when available", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const ad = new MemoryStorageLike();
|
||||
const native = new ReadableStream<Uint8Array>({
|
||||
start(c) {
|
||||
c.enqueue(new Uint8Array([1]));
|
||||
c.close();
|
||||
},
|
||||
});
|
||||
const plugin = basicPlugin("mem", ad, {
|
||||
createReadStream: () => native,
|
||||
});
|
||||
const r = createStorageUriResolver([plugin]);
|
||||
const out = await r.open("mem://x/a");
|
||||
expect(out).toBe(native);
|
||||
expect(
|
||||
provider.meter.counters.get("storage_uri.stream_fallback_total")
|
||||
?.adds.length ?? 0
|
||||
).toBe(0);
|
||||
});
|
||||
|
||||
it("falls back to one-chunk stream and increments counter", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const ad = new MemoryStorageLike();
|
||||
await ad.set("a", new Uint8Array([1, 2, 3]));
|
||||
const r = createStorageUriResolver([basicPlugin("mem", ad)]);
|
||||
const stream = await r.open("mem://x/a");
|
||||
const reader = stream.getReader();
|
||||
const chunk = await reader.read();
|
||||
expect(chunk.done).toBe(false);
|
||||
expect(Array.from(chunk.value as Uint8Array)).toEqual([1, 2, 3]);
|
||||
const end = await reader.read();
|
||||
expect(end.done).toBe(true);
|
||||
const counter = provider.meter.counters.get(
|
||||
"storage_uri.stream_fallback_total"
|
||||
);
|
||||
expect(counter?.adds.length).toBe(1);
|
||||
expect(counter?.adds[0]?.attrs).toMatchObject({ scheme: "mem" });
|
||||
});
|
||||
|
||||
it("throws NOT_FOUND when adapter returns undefined", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const ad = new MemoryStorageLike();
|
||||
const r = createStorageUriResolver([basicPlugin("mem", ad)]);
|
||||
let threw: unknown;
|
||||
try {
|
||||
await r.open("mem://x/missing");
|
||||
} catch (err) {
|
||||
threw = err;
|
||||
}
|
||||
expect(threw).toBeInstanceOf(StorageUriError);
|
||||
expect((threw as StorageUriError).code).toBe(
|
||||
STORAGE_URI_CODES.NOT_FOUND
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,54 @@
|
||||
import {
|
||||
STORAGE_URI_CODES,
|
||||
StorageUriError,
|
||||
} from "../error-codes/error-codes.js";
|
||||
import { recordStreamFallback } from "../otel/otel.js";
|
||||
import type {
|
||||
StreamingStorageExtensions,
|
||||
UriPlugin,
|
||||
} from "../types/plugin.types.js";
|
||||
|
||||
/**
|
||||
* Implement `open(uri)` semantics for a dispatched plugin.
|
||||
*
|
||||
* When `plugin.adapter.createReadStream` exists, the resolver forwards the
|
||||
* call verbatim (no fallback counter increment). Otherwise it issues a
|
||||
* standard `get(key)`, throws `STORAGE_URI.NOT_FOUND` on `undefined`, wraps
|
||||
* the bytes in a one-chunk `ReadableStream`, and increments
|
||||
* `storage_uri.stream_fallback_total{scheme}`.
|
||||
*
|
||||
* The pre-aborted check and span/metrics bookkeeping is owned by the caller
|
||||
* (resolver `open` wrapper) so this function stays focused on the dispatch
|
||||
* branch.
|
||||
*/
|
||||
export async function openViaPlugin(
|
||||
plugin: UriPlugin,
|
||||
uri: string,
|
||||
key: string,
|
||||
signal: AbortSignal | undefined
|
||||
): Promise<ReadableStream<Uint8Array>> {
|
||||
const adapter = plugin.adapter as typeof plugin.adapter &
|
||||
StreamingStorageExtensions;
|
||||
if (typeof adapter.createReadStream === "function") {
|
||||
const stream = await adapter.createReadStream(key, signal);
|
||||
return stream;
|
||||
}
|
||||
const bytes = await adapter.get(key);
|
||||
if (bytes === undefined) {
|
||||
throw new StorageUriError(
|
||||
STORAGE_URI_CODES.NOT_FOUND,
|
||||
"resource not found",
|
||||
{
|
||||
uri,
|
||||
scheme: plugin.scheme,
|
||||
}
|
||||
);
|
||||
}
|
||||
recordStreamFallback(plugin.scheme);
|
||||
return new ReadableStream<Uint8Array>({
|
||||
start(ctrl) {
|
||||
ctrl.enqueue(bytes);
|
||||
ctrl.close();
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,317 @@
|
||||
import { SuaveplanError } from "@suaveplan/error";
|
||||
import {
|
||||
afterEach,
|
||||
beforeEach,
|
||||
describe,
|
||||
expect,
|
||||
it,
|
||||
} from "@suaveplan/testing/runner";
|
||||
import { MemoryStorageLike } from "../../tests/setup/test-helpers.js";
|
||||
import {
|
||||
STORAGE_URI_CODES,
|
||||
StorageUriError,
|
||||
} from "../error-codes/error-codes.js";
|
||||
import { resetInstruments } from "../otel/otel.js";
|
||||
import type { UriPlugin } from "../types/plugin.types.js";
|
||||
import {
|
||||
createStorageUriResolver,
|
||||
type StorageUriResolver,
|
||||
} from "./resolver.js";
|
||||
|
||||
function memoryPlugin(scheme: string, adapter: MemoryStorageLike): UriPlugin {
|
||||
return {
|
||||
scheme,
|
||||
adapter,
|
||||
parse(uri) {
|
||||
const idx = uri.indexOf("://");
|
||||
const after =
|
||||
idx >= 0 ? uri.slice(idx + 3) : uri.slice(uri.indexOf(":") + 1);
|
||||
const slash = after.indexOf("/");
|
||||
const authority = slash >= 0 ? after.slice(0, slash) : after;
|
||||
const key = slash >= 0 ? after.slice(slash + 1) : "";
|
||||
return { key, meta: { authority } };
|
||||
},
|
||||
build(key, meta) {
|
||||
const auth = meta?.["authority"] ?? "x";
|
||||
return `${scheme}://${auth}/${key}`;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("StorageUriResolver", () => {
|
||||
let adapter: MemoryStorageLike;
|
||||
let plugin: UriPlugin;
|
||||
let resolver: StorageUriResolver;
|
||||
|
||||
beforeEach(() => {
|
||||
resetInstruments();
|
||||
adapter = new MemoryStorageLike();
|
||||
plugin = memoryPlugin("s3", adapter);
|
||||
resolver = createStorageUriResolver([plugin]);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
resetInstruments();
|
||||
});
|
||||
|
||||
it("dispatches by scheme to the registered plugin", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
await adapter.set("k", new Uint8Array([1, 2, 3]));
|
||||
const out = await resolver.read("s3://bucket/k");
|
||||
expect(Array.from(out)).toEqual([1, 2, 3]);
|
||||
expect(adapter.getCalls).toBe(1);
|
||||
});
|
||||
|
||||
it("throws UNKNOWN_SCHEME for unregistered scheme", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
await expect(resolver.read("ftp://host/path")).rejects.toMatchObject({
|
||||
code: STORAGE_URI_CODES.UNKNOWN_SCHEME,
|
||||
scheme: "ftp",
|
||||
});
|
||||
});
|
||||
|
||||
it("matches scheme case-insensitively", { timeout: 5000 }, async () => {
|
||||
await adapter.set("k", new Uint8Array([9]));
|
||||
const out = await resolver.read("S3://bucket/k");
|
||||
expect(Array.from(out)).toEqual([9]);
|
||||
});
|
||||
|
||||
it("rejects conflicting scheme registrations", { timeout: 5000 }, () => {
|
||||
const a = memoryPlugin("s3", new MemoryStorageLike());
|
||||
const b = memoryPlugin("s3", new MemoryStorageLike());
|
||||
let threw: unknown;
|
||||
try {
|
||||
createStorageUriResolver([a, b]);
|
||||
} catch (err) {
|
||||
threw = err;
|
||||
}
|
||||
expect(threw).toBeInstanceOf(StorageUriError);
|
||||
expect((threw as StorageUriError).code).toBe(
|
||||
STORAGE_URI_CODES.SCHEME_CONFLICT
|
||||
);
|
||||
expect((threw as StorageUriError).scheme).toBe("s3");
|
||||
});
|
||||
|
||||
it("accepts idempotent reference re-registration", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const r = createStorageUriResolver([plugin, plugin]);
|
||||
expect(r.schemeCount).toBe(1);
|
||||
expect(r.hasScheme("s3")).toBe(true);
|
||||
await adapter.set("k", new Uint8Array([4]));
|
||||
const out = await r.read("s3://b/k");
|
||||
expect(out[0]).toBe(4);
|
||||
});
|
||||
|
||||
it("short-circuits on pre-aborted signal", { timeout: 5000 }, async () => {
|
||||
const ctrl = new AbortController();
|
||||
ctrl.abort();
|
||||
await expect(
|
||||
resolver.read("s3://bucket/k", ctrl.signal)
|
||||
).rejects.toMatchObject({ code: STORAGE_URI_CODES.ABORTED });
|
||||
expect(adapter.getCalls).toBe(0);
|
||||
});
|
||||
|
||||
it("forwards AbortSignal to adapter unchanged", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const ctrl = new AbortController();
|
||||
let captured: AbortSignal | undefined;
|
||||
const ad = new MemoryStorageLike();
|
||||
const sig = ctrl.signal;
|
||||
const p: UriPlugin = {
|
||||
scheme: "memory",
|
||||
adapter: Object.assign(ad, {
|
||||
async createReadStream(_k: string, s?: AbortSignal) {
|
||||
captured = s;
|
||||
return new ReadableStream<Uint8Array>({
|
||||
start(c) {
|
||||
c.enqueue(new Uint8Array([1]));
|
||||
c.close();
|
||||
},
|
||||
});
|
||||
},
|
||||
}),
|
||||
parse(uri) {
|
||||
return { key: uri.slice("memory://x/".length) };
|
||||
},
|
||||
};
|
||||
const r = createStorageUriResolver([p]);
|
||||
await r.open("memory://x/a", sig);
|
||||
expect(captured).toBe(sig);
|
||||
});
|
||||
|
||||
it("wraps non-SuaveplanError throwables as ADAPTER_ERROR", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const ad = new MemoryStorageLike();
|
||||
const original = new Error("disk fault");
|
||||
ad.get = async () => {
|
||||
throw original;
|
||||
};
|
||||
const r = createStorageUriResolver([memoryPlugin("s3", ad)]);
|
||||
let threw: unknown;
|
||||
try {
|
||||
await r.read("s3://bucket/k");
|
||||
} catch (err) {
|
||||
threw = err;
|
||||
}
|
||||
expect(threw).toBeInstanceOf(StorageUriError);
|
||||
const e = threw as StorageUriError;
|
||||
expect(e.code).toBe(STORAGE_URI_CODES.ADAPTER_ERROR);
|
||||
expect(e.cause).toBe(original);
|
||||
expect(e.uri).toBe("s3://bucket/k");
|
||||
expect(e.scheme).toBe("s3");
|
||||
});
|
||||
|
||||
it("passes through thrown SuaveplanError subclasses unchanged", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
// A SuaveplanError subclass from a foreign namespace (representing
|
||||
// any downstream package's namespaced error — DriveUriError,
|
||||
// StorageHttpError, etc.) must reach the caller untouched so
|
||||
// `err.code === "FOO.BAR"` discrimination works directly.
|
||||
class ForeignError extends SuaveplanError<"FOO.BAR"> {
|
||||
public constructor() {
|
||||
super("FOO.BAR", "downstream-defined failure");
|
||||
}
|
||||
}
|
||||
const original = new ForeignError();
|
||||
const ad = new MemoryStorageLike();
|
||||
ad.get = async () => {
|
||||
throw original;
|
||||
};
|
||||
const r = createStorageUriResolver([memoryPlugin("s3", ad)]);
|
||||
let threw: unknown;
|
||||
try {
|
||||
await r.read("s3://bucket/k");
|
||||
} catch (err) {
|
||||
threw = err;
|
||||
}
|
||||
expect(threw).toBe(original);
|
||||
expect((threw as ForeignError).code).toBe("FOO.BAR");
|
||||
expect(threw).not.toBeInstanceOf(StorageUriError);
|
||||
});
|
||||
|
||||
it("re-throws StorageUriError unchanged", { timeout: 5000 }, async () => {
|
||||
const inner = new StorageUriError(STORAGE_URI_CODES.NOT_FOUND, "nope", {
|
||||
uri: "s3://x/y",
|
||||
scheme: "s3",
|
||||
});
|
||||
const ad = new MemoryStorageLike();
|
||||
ad.get = async () => {
|
||||
throw inner;
|
||||
};
|
||||
const r = createStorageUriResolver([memoryPlugin("s3", ad)]);
|
||||
let threw: unknown;
|
||||
try {
|
||||
await r.read("s3://x/y");
|
||||
} catch (err) {
|
||||
threw = err;
|
||||
}
|
||||
expect(threw).toBe(inner);
|
||||
});
|
||||
|
||||
it("exists delegates to adapter.exists", { timeout: 5000 }, async () => {
|
||||
await adapter.set("k", new Uint8Array([1]));
|
||||
expect(await resolver.exists("s3://b/k")).toBe(true);
|
||||
expect(adapter.existsCalls).toBe(1);
|
||||
});
|
||||
|
||||
it("remove returns adapter.delete boolean", { timeout: 5000 }, async () => {
|
||||
expect(await resolver.remove("s3://b/missing")).toBe(false);
|
||||
await adapter.set("k", new Uint8Array([1]));
|
||||
expect(await resolver.remove("s3://b/k")).toBe(true);
|
||||
});
|
||||
|
||||
it("stat returns undefined for adapters without metadata path", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const out = await resolver.stat("s3://b/k");
|
||||
expect(out).toBeUndefined();
|
||||
});
|
||||
|
||||
it("stat returns adapter metadata when getMetadata is implemented", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const ad = new MemoryStorageLike();
|
||||
Object.assign(ad, {
|
||||
async getMetadata(_k: string) {
|
||||
return { size: 42, etag: "abc" };
|
||||
},
|
||||
});
|
||||
const r = createStorageUriResolver([memoryPlugin("s3", ad)]);
|
||||
const out = await r.stat("s3://b/k");
|
||||
expect(out?.size).toBe(42);
|
||||
expect(out?.etag).toBe("abc");
|
||||
});
|
||||
|
||||
it("throws INVALID_URI when parseUri rejects the input", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
await expect(resolver.read("bare-string")).rejects.toMatchObject({
|
||||
code: STORAGE_URI_CODES.INVALID_URI,
|
||||
});
|
||||
});
|
||||
|
||||
it("wraps non-Error throwables as ADAPTER_ERROR with no cause", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const ad = new MemoryStorageLike();
|
||||
ad.get = async () => {
|
||||
throw "string-thrown";
|
||||
};
|
||||
const r = createStorageUriResolver([memoryPlugin("s3", ad)]);
|
||||
let threw: unknown;
|
||||
try {
|
||||
await r.read("s3://b/k");
|
||||
} catch (err) {
|
||||
threw = err;
|
||||
}
|
||||
expect(threw).toBeInstanceOf(StorageUriError);
|
||||
expect((threw as StorageUriError).code).toBe(
|
||||
STORAGE_URI_CODES.ADAPTER_ERROR
|
||||
);
|
||||
expect((threw as StorageUriError).cause).toBeUndefined();
|
||||
});
|
||||
|
||||
it("parses authority terminated by end-of-string (no path)", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
// Plugin parse extracts authority only; resolver still dispatches.
|
||||
const ad = new MemoryStorageLike();
|
||||
const noPath: UriPlugin = {
|
||||
scheme: "x",
|
||||
adapter: ad,
|
||||
parse(_uri) {
|
||||
return { key: "" };
|
||||
},
|
||||
};
|
||||
const r = createStorageUriResolver([noPath]);
|
||||
// parseUri must accept "x://just-authority" — exercises findAuthorityEnd
|
||||
// fallthrough.
|
||||
await expect(r.exists("x://just-authority")).resolves.toBe(false);
|
||||
});
|
||||
|
||||
it("forwards createReadStream when present", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const ad = new MemoryStorageLike();
|
||||
const native = new ReadableStream<Uint8Array>({
|
||||
start(c) {
|
||||
c.enqueue(new Uint8Array([7]));
|
||||
c.close();
|
||||
},
|
||||
});
|
||||
Object.assign(ad, {
|
||||
async createReadStream() {
|
||||
return native;
|
||||
},
|
||||
});
|
||||
const r = createStorageUriResolver([memoryPlugin("mem", ad)]);
|
||||
const out = await r.open("mem://x/a");
|
||||
expect(out).toBe(native);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,251 @@
|
||||
import { SuaveplanError } from "@suaveplan/error";
|
||||
import {
|
||||
STORAGE_URI_CODES,
|
||||
StorageUriError,
|
||||
} from "../error-codes/error-codes.js";
|
||||
import { getInstruments, recordOp, redactUri } from "../otel/otel.js";
|
||||
import { parseUri } from "../parse-uri/parse-uri.js";
|
||||
import type {
|
||||
StatResult,
|
||||
StreamingStorageExtensions,
|
||||
UriPlugin,
|
||||
} from "../types/plugin.types.js";
|
||||
import type { ListUriOptions, ListUriResult } from "../types/resolver.types.js";
|
||||
import { listViaPlugin } from "./resolver.list.js";
|
||||
import { openViaPlugin } from "./resolver.streams.js";
|
||||
import { writeViaPlugin } from "./resolver.writes.js";
|
||||
|
||||
interface Dispatched {
|
||||
readonly plugin: UriPlugin;
|
||||
readonly key: string;
|
||||
readonly meta?: Record<string, string>;
|
||||
readonly scheme: string;
|
||||
}
|
||||
|
||||
type ResolverOp =
|
||||
| "open"
|
||||
| "read"
|
||||
| "write"
|
||||
| "stat"
|
||||
| "exists"
|
||||
| "remove"
|
||||
| "list";
|
||||
|
||||
/**
|
||||
* URI-routed dispatch over a frozen `scheme → UriPlugin` registry.
|
||||
*
|
||||
* The resolver performs no I/O of its own; every public method parses the
|
||||
* input URI, looks up a plugin by scheme (case-insensitive), and delegates
|
||||
* to the plugin's `StorageLike` adapter. Adapter throwables that are
|
||||
* `SuaveplanError` subclasses (any package's namespaced error — including
|
||||
* `StorageUriError`, `DriveUriError`, `StorageHttpError`, …) pass through
|
||||
* unchanged so consumers can route on the original `code`. Non-namespaced
|
||||
* throwables (plain `Error`, foreign objects) are wrapped as
|
||||
* `STORAGE_URI.ADAPTER_ERROR` with the original preserved as `cause`.
|
||||
*/
|
||||
export class StorageUriResolver {
|
||||
readonly #plugins: ReadonlyMap<string, UriPlugin>;
|
||||
|
||||
public constructor(plugins: readonly UriPlugin[]) {
|
||||
const map = new Map<string, UriPlugin>();
|
||||
for (const plugin of plugins) {
|
||||
const scheme = plugin.scheme.toLowerCase();
|
||||
const existing = map.get(scheme);
|
||||
if (existing !== undefined && existing !== plugin) {
|
||||
throw new StorageUriError(
|
||||
STORAGE_URI_CODES.SCHEME_CONFLICT,
|
||||
`two distinct plugins registered for scheme '${scheme}'`,
|
||||
{ scheme }
|
||||
);
|
||||
}
|
||||
map.set(scheme, plugin);
|
||||
}
|
||||
this.#plugins = map;
|
||||
Object.freeze(this);
|
||||
}
|
||||
|
||||
/** Number of distinct schemes registered. */
|
||||
public get schemeCount(): number {
|
||||
return this.#plugins.size;
|
||||
}
|
||||
|
||||
/** Test whether `scheme` is registered (case-insensitive). */
|
||||
public hasScheme(scheme: string): boolean {
|
||||
return this.#plugins.has(scheme.toLowerCase());
|
||||
}
|
||||
|
||||
#dispatch(uri: string): Dispatched {
|
||||
const parts = parseUri(uri);
|
||||
const plugin = this.#plugins.get(parts.scheme);
|
||||
if (plugin === undefined) {
|
||||
throw new StorageUriError(
|
||||
STORAGE_URI_CODES.UNKNOWN_SCHEME,
|
||||
`no plugin registered for scheme '${parts.scheme}'`,
|
||||
{ uri, scheme: parts.scheme }
|
||||
);
|
||||
}
|
||||
const parsed = plugin.parse(uri);
|
||||
const out: Dispatched = {
|
||||
plugin,
|
||||
scheme: parts.scheme,
|
||||
key: parsed.key,
|
||||
...(parsed.meta !== undefined ? { meta: parsed.meta } : {}),
|
||||
};
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Open a `ReadableStream<Uint8Array>` view of the resource at `uri`. */
|
||||
public async open(
|
||||
uri: string,
|
||||
signal?: AbortSignal
|
||||
): Promise<ReadableStream<Uint8Array>> {
|
||||
return this.#run("open", uri, signal, async (d) =>
|
||||
openViaPlugin(d.plugin, uri, d.key, signal)
|
||||
);
|
||||
}
|
||||
|
||||
/** Read the entire resource at `uri` into a `Uint8Array`. */
|
||||
public async read(uri: string, signal?: AbortSignal): Promise<Uint8Array> {
|
||||
return this.#run("read", uri, signal, async (d) => {
|
||||
const bytes = await d.plugin.adapter.get(d.key);
|
||||
if (bytes === undefined) {
|
||||
throw new StorageUriError(
|
||||
STORAGE_URI_CODES.NOT_FOUND,
|
||||
"resource not found",
|
||||
{ uri, scheme: d.scheme }
|
||||
);
|
||||
}
|
||||
return bytes;
|
||||
});
|
||||
}
|
||||
|
||||
/** Write `body` to the resource at `uri`. */
|
||||
public async write(
|
||||
uri: string,
|
||||
body: Uint8Array | ReadableStream<Uint8Array>,
|
||||
signal?: AbortSignal
|
||||
): Promise<void> {
|
||||
await this.#run("write", uri, signal, async (d) => {
|
||||
await writeViaPlugin(d.plugin, uri, d.key, body, signal);
|
||||
return undefined;
|
||||
});
|
||||
}
|
||||
|
||||
/** Fetch metadata or `undefined` if the adapter cannot answer cheaply. */
|
||||
public async stat(
|
||||
uri: string,
|
||||
signal?: AbortSignal
|
||||
): Promise<StatResult | undefined> {
|
||||
return this.#run("stat", uri, signal, async (d) => {
|
||||
const adapter = d.plugin.adapter as typeof d.plugin.adapter &
|
||||
StreamingStorageExtensions;
|
||||
if (typeof adapter.getMetadata !== "function") return undefined;
|
||||
return adapter.getMetadata(d.key, signal);
|
||||
});
|
||||
}
|
||||
|
||||
/** Test whether a resource exists at `uri`. */
|
||||
public async exists(uri: string, signal?: AbortSignal): Promise<boolean> {
|
||||
return this.#run("exists", uri, signal, async (d) =>
|
||||
d.plugin.adapter.exists(d.key)
|
||||
);
|
||||
}
|
||||
|
||||
/** Delete the resource at `uri`. Returns the adapter's boolean. */
|
||||
public async remove(uri: string, signal?: AbortSignal): Promise<boolean> {
|
||||
return this.#run("remove", uri, signal, async (d) =>
|
||||
d.plugin.adapter.delete(d.key)
|
||||
);
|
||||
}
|
||||
|
||||
/** Enumerate URIs sharing the input's key as a prefix. */
|
||||
public async list(
|
||||
uri: string,
|
||||
options?: ListUriOptions,
|
||||
signal?: AbortSignal
|
||||
): Promise<ListUriResult> {
|
||||
return this.#run("list", uri, signal, async (d) =>
|
||||
listViaPlugin(d.plugin, uri, d.key, d.meta, options)
|
||||
);
|
||||
}
|
||||
|
||||
async #run<T>(
|
||||
op: ResolverOp,
|
||||
uri: string,
|
||||
signal: AbortSignal | undefined,
|
||||
body: (d: Dispatched) => Promise<T>
|
||||
): Promise<T> {
|
||||
const start = Date.now();
|
||||
let scheme = "unknown";
|
||||
const tracer = getInstruments().tracer;
|
||||
const span = tracer.startSpan(`storage.uri.${op}`, {
|
||||
attributes: { "storage.uri": redactUri(uri) },
|
||||
});
|
||||
try {
|
||||
if (signal?.aborted === true) {
|
||||
throw new StorageUriError(
|
||||
STORAGE_URI_CODES.ABORTED,
|
||||
"signal aborted",
|
||||
{
|
||||
uri,
|
||||
}
|
||||
);
|
||||
}
|
||||
const dispatched = this.#dispatch(uri);
|
||||
scheme = dispatched.scheme;
|
||||
span.setAttribute("storage.uri.scheme", scheme);
|
||||
span.setAttribute("storage.uri.key", dispatched.key);
|
||||
const result = await body(dispatched);
|
||||
recordOp({
|
||||
op,
|
||||
scheme,
|
||||
outcome: "success",
|
||||
durationMs: Date.now() - start,
|
||||
});
|
||||
return result;
|
||||
} catch (err) {
|
||||
const mapped = this.#wrap(err, uri, scheme);
|
||||
const mappedScheme =
|
||||
mapped instanceof StorageUriError
|
||||
? (mapped.scheme ?? scheme)
|
||||
: scheme;
|
||||
const outcome: "not_found" | "aborted" | "error" =
|
||||
mapped.code === STORAGE_URI_CODES.NOT_FOUND
|
||||
? "not_found"
|
||||
: mapped.code === STORAGE_URI_CODES.ABORTED
|
||||
? "aborted"
|
||||
: "error";
|
||||
recordOp({
|
||||
op,
|
||||
scheme: mappedScheme,
|
||||
outcome,
|
||||
durationMs: Date.now() - start,
|
||||
code: mapped.code,
|
||||
});
|
||||
throw mapped;
|
||||
} finally {
|
||||
span.end();
|
||||
}
|
||||
}
|
||||
|
||||
#wrap(err: unknown, uri: string, scheme: string): SuaveplanError {
|
||||
if (err instanceof SuaveplanError) return err;
|
||||
const opts: { uri: string; scheme: string; cause?: unknown } = {
|
||||
uri,
|
||||
scheme,
|
||||
};
|
||||
if (err instanceof Error) opts.cause = err;
|
||||
return new StorageUriError(
|
||||
STORAGE_URI_CODES.ADAPTER_ERROR,
|
||||
"adapter threw a non-SuaveplanError",
|
||||
opts
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** Frozen-resolver factory; preferred over `new StorageUriResolver(...)`. */
|
||||
export function createStorageUriResolver(
|
||||
plugins: readonly UriPlugin[]
|
||||
): StorageUriResolver {
|
||||
return new StorageUriResolver(plugins);
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
# resolver.writes
|
||||
|
||||
## Purpose
|
||||
|
||||
Implements the `write(uri, body, signal?)` semantics. Accepts either
|
||||
`Uint8Array` or `ReadableStream<Uint8Array>` for the body, forwards a native
|
||||
`createWriteStream` when the adapter exposes one and the body is a stream,
|
||||
and otherwise drains the stream (via `new Response(stream).arrayBuffer()`)
|
||||
into a single `adapter.set` call. The drain path increments
|
||||
`storage_uri.buffered_write_total{scheme}` so operators can alert on
|
||||
mis-composed adapters that quietly buffer large payloads.
|
||||
|
||||
`SuaveplanError` subclasses thrown by `adapter.set` (any package's
|
||||
namespaced error — `DriveUriError`, `StorageHttpError`, etc.) pass through
|
||||
unchanged so consumers can route on the original `code`. Non-namespaced
|
||||
throwables (plain `Error`, foreign objects) are wrapped as
|
||||
`STORAGE_URI.WRITE_UNSUPPORTED` with the original preserved as `cause`,
|
||||
giving a normalised failure mode for read-only adapters that lack their
|
||||
own error class.
|
||||
|
||||
## Features
|
||||
|
||||
- Branch on `body` shape via `getReader` duck-test.
|
||||
- Forward `createWriteStream` only when present AND body is a stream.
|
||||
- Drain-and-set fallback for both buffered bodies and missing
|
||||
`createWriteStream`.
|
||||
- `SuaveplanError` pass-through on `adapter.set` throw; `WRITE_UNSUPPORTED`
|
||||
wrap with `cause` preserved for non-namespaced throwables only.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { writeViaPlugin } from "@suaveplan/storage-uri/resolver";
|
||||
|
||||
await writeViaPlugin(plugin, "file:///a.bin", "a.bin", new Uint8Array([1, 2, 3]), undefined);
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
async function writeStream(resolver: StorageUriResolver, uri: string, src: AsyncIterable<Uint8Array>): Promise<void> {
|
||||
const stream = new ReadableStream<Uint8Array>({
|
||||
async start(ctrl) {
|
||||
for await (const chunk of src) ctrl.enqueue(chunk);
|
||||
ctrl.close();
|
||||
},
|
||||
});
|
||||
await resolver.write(uri, stream);
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `function writeViaPlugin(plugin, uri, key, body, signal): Promise<void>`
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`new Response(stream).arrayBuffer()` is the cheapest cross-runtime way to
|
||||
drain a Web Stream into a contiguous buffer; it works in Node 18+, Bun, and
|
||||
every browser without any polyfill. The `WRITE_UNSUPPORTED` re-wrap is
|
||||
distinct from the `ADAPTER_ERROR` wrap in `resolver.ts` because callers
|
||||
typically branch on these two conditions differently — `WRITE_UNSUPPORTED`
|
||||
is a capability miss while `ADAPTER_ERROR` is a transient upstream fault.
|
||||
@@ -0,0 +1,247 @@
|
||||
import type { Counter, Meter, MeterProvider } from "@opentelemetry/api";
|
||||
import { SuaveplanError } from "@suaveplan/error";
|
||||
import {
|
||||
afterEach,
|
||||
beforeEach,
|
||||
describe,
|
||||
expect,
|
||||
it,
|
||||
} from "@suaveplan/testing/runner";
|
||||
import { MemoryStorageLike } from "../../tests/setup/test-helpers.js";
|
||||
import {
|
||||
STORAGE_URI_CODES,
|
||||
StorageUriError,
|
||||
} from "../error-codes/error-codes.js";
|
||||
import { resetInstruments, setStorageUriMeterProvider } from "../otel/otel.js";
|
||||
import type { UriPlugin } from "../types/plugin.types.js";
|
||||
import { createStorageUriResolver } from "./resolver.js";
|
||||
|
||||
function basicPlugin(scheme: string, adapter: MemoryStorageLike): UriPlugin {
|
||||
return {
|
||||
scheme,
|
||||
adapter,
|
||||
parse(uri) {
|
||||
const after = uri.slice(uri.indexOf("://") + 3);
|
||||
const slash = after.indexOf("/");
|
||||
return { key: slash >= 0 ? after.slice(slash + 1) : "" };
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
interface Sample {
|
||||
value: number;
|
||||
attrs: Record<string, unknown> | undefined;
|
||||
}
|
||||
|
||||
class StubCounter implements Counter {
|
||||
public adds: Sample[] = [];
|
||||
public add(v: number, attrs?: Record<string, unknown>): void {
|
||||
this.adds.push({ value: v, attrs });
|
||||
}
|
||||
}
|
||||
|
||||
class StubMeter implements Meter {
|
||||
public counters: Map<string, StubCounter> = new Map();
|
||||
public createCounter(name: string): Counter {
|
||||
const c = new StubCounter();
|
||||
this.counters.set(name, c);
|
||||
return c;
|
||||
}
|
||||
public createHistogram(): { record(): void } {
|
||||
return { record: () => {} };
|
||||
}
|
||||
public createUpDownCounter(): never {
|
||||
throw new Error("unused");
|
||||
}
|
||||
public createObservableCounter(): never {
|
||||
throw new Error("unused");
|
||||
}
|
||||
public createObservableUpDownCounter(): never {
|
||||
throw new Error("unused");
|
||||
}
|
||||
public createObservableGauge(): never {
|
||||
throw new Error("unused");
|
||||
}
|
||||
public addBatchObservableCallback(): void {}
|
||||
public removeBatchObservableCallback(): void {}
|
||||
public createGauge(): never {
|
||||
throw new Error("unused");
|
||||
}
|
||||
}
|
||||
|
||||
class StubMeterProvider implements MeterProvider {
|
||||
public meter = new StubMeter();
|
||||
public getMeter(): Meter {
|
||||
return this.meter;
|
||||
}
|
||||
}
|
||||
|
||||
describe("resolver.writes", () => {
|
||||
let provider: StubMeterProvider;
|
||||
beforeEach(() => {
|
||||
provider = new StubMeterProvider();
|
||||
setStorageUriMeterProvider(provider);
|
||||
resetInstruments();
|
||||
});
|
||||
afterEach(() => {
|
||||
setStorageUriMeterProvider(undefined);
|
||||
resetInstruments();
|
||||
});
|
||||
|
||||
it("forwards Uint8Array body directly to adapter.set", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const ad = new MemoryStorageLike();
|
||||
const r = createStorageUriResolver([basicPlugin("file", ad)]);
|
||||
const bytes = new Uint8Array([1, 2, 3]);
|
||||
await r.write("file://host/a.bin", bytes);
|
||||
expect(ad.setCalls).toBe(1);
|
||||
expect(ad.store.get("a.bin")).toEqual(bytes);
|
||||
const counter = provider.meter.counters.get(
|
||||
"storage_uri.buffered_write_total"
|
||||
);
|
||||
expect(counter?.adds.length ?? 0).toBe(0);
|
||||
});
|
||||
|
||||
it("drains ReadableStream into single set call", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const ad = new MemoryStorageLike();
|
||||
const r = createStorageUriResolver([basicPlugin("file", ad)]);
|
||||
const chunks = [
|
||||
new Uint8Array([1, 2]),
|
||||
new Uint8Array([3, 4]),
|
||||
new Uint8Array([5]),
|
||||
];
|
||||
const stream = new ReadableStream<Uint8Array>({
|
||||
start(c) {
|
||||
for (const ch of chunks) c.enqueue(ch);
|
||||
c.close();
|
||||
},
|
||||
});
|
||||
await r.write("file://host/a.bin", stream);
|
||||
expect(ad.setCalls).toBe(1);
|
||||
expect(Array.from(ad.store.get("a.bin") as Uint8Array)).toEqual([
|
||||
1, 2, 3, 4, 5,
|
||||
]);
|
||||
const counter = provider.meter.counters.get(
|
||||
"storage_uri.buffered_write_total"
|
||||
);
|
||||
expect(counter?.adds.length).toBe(1);
|
||||
expect(counter?.adds[0]?.attrs).toMatchObject({ scheme: "file" });
|
||||
});
|
||||
|
||||
it("wraps non-Suaveplan throw on set as WRITE_UNSUPPORTED", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const ad = new MemoryStorageLike();
|
||||
const original = new Error("read-only");
|
||||
ad.set = async () => {
|
||||
throw original;
|
||||
};
|
||||
const r = createStorageUriResolver([basicPlugin("https", ad)]);
|
||||
let threw: unknown;
|
||||
try {
|
||||
await r.write("https://cdn/x", new Uint8Array([1]));
|
||||
} catch (err) {
|
||||
threw = err;
|
||||
}
|
||||
expect(threw).toBeInstanceOf(StorageUriError);
|
||||
const e = threw as StorageUriError;
|
||||
expect(e.code).toBe(STORAGE_URI_CODES.WRITE_UNSUPPORTED);
|
||||
expect(e.cause).toBe(original);
|
||||
});
|
||||
|
||||
it("passes through thrown SuaveplanError subclasses on write", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
// A namespaced SuaveplanError thrown from `adapter.set` (e.g. a
|
||||
// `DriveUriError("WRITE_UNSUPPORTED")`) must reach the caller
|
||||
// with its original `code` — no `STORAGE_URI.WRITE_UNSUPPORTED`
|
||||
// envelope is added.
|
||||
class ForeignError extends SuaveplanError<"FOO.WRITE_DENIED"> {
|
||||
public constructor() {
|
||||
super("FOO.WRITE_DENIED", "downstream-defined write rejection");
|
||||
}
|
||||
}
|
||||
const original = new ForeignError();
|
||||
const ad = new MemoryStorageLike();
|
||||
ad.set = async () => {
|
||||
throw original;
|
||||
};
|
||||
const r = createStorageUriResolver([basicPlugin("drive", ad)]);
|
||||
let threw: unknown;
|
||||
try {
|
||||
await r.write("drive://primary/x", new Uint8Array([1]));
|
||||
} catch (err) {
|
||||
threw = err;
|
||||
}
|
||||
expect(threw).toBe(original);
|
||||
expect((threw as ForeignError).code).toBe("FOO.WRITE_DENIED");
|
||||
expect(threw).not.toBeInstanceOf(StorageUriError);
|
||||
});
|
||||
|
||||
it("forwards createWriteStream when adapter exposes it and body is a stream", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const ad = new MemoryStorageLike();
|
||||
let captured: ReadableStream<Uint8Array> | undefined;
|
||||
Object.assign(ad, {
|
||||
async createWriteStream(
|
||||
_k: string,
|
||||
body: ReadableStream<Uint8Array>
|
||||
) {
|
||||
captured = body;
|
||||
},
|
||||
});
|
||||
const r = createStorageUriResolver([basicPlugin("file", ad)]);
|
||||
const stream = new ReadableStream<Uint8Array>({
|
||||
start(c) {
|
||||
c.enqueue(new Uint8Array([1]));
|
||||
c.close();
|
||||
},
|
||||
});
|
||||
await r.write("file://h/a", stream);
|
||||
expect(captured).toBe(stream);
|
||||
expect(ad.setCalls).toBe(0);
|
||||
});
|
||||
|
||||
it("wraps non-Error set throwables as WRITE_UNSUPPORTED with no cause", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const ad = new MemoryStorageLike();
|
||||
ad.set = async () => {
|
||||
throw "string-thrown";
|
||||
};
|
||||
const r = createStorageUriResolver([basicPlugin("file", ad)]);
|
||||
let threw: unknown;
|
||||
try {
|
||||
await r.write("file://h/a", new Uint8Array([1]));
|
||||
} catch (err) {
|
||||
threw = err;
|
||||
}
|
||||
expect(threw).toBeInstanceOf(StorageUriError);
|
||||
expect((threw as StorageUriError).code).toBe(
|
||||
STORAGE_URI_CODES.WRITE_UNSUPPORTED
|
||||
);
|
||||
expect((threw as StorageUriError).cause).toBeUndefined();
|
||||
});
|
||||
|
||||
it("re-throws StorageUriError unchanged when adapter raises one on set", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const inner = new StorageUriError(STORAGE_URI_CODES.NOT_FOUND, "x");
|
||||
const ad = new MemoryStorageLike();
|
||||
ad.set = async () => {
|
||||
throw inner;
|
||||
};
|
||||
const r = createStorageUriResolver([basicPlugin("file", ad)]);
|
||||
let threw: unknown;
|
||||
try {
|
||||
await r.write("file://h/a", new Uint8Array([1]));
|
||||
} catch (err) {
|
||||
threw = err;
|
||||
}
|
||||
expect(threw).toBe(inner);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,75 @@
|
||||
import { SuaveplanError } from "@suaveplan/error";
|
||||
import {
|
||||
STORAGE_URI_CODES,
|
||||
StorageUriError,
|
||||
} from "../error-codes/error-codes.js";
|
||||
import { recordBufferedWrite } from "../otel/otel.js";
|
||||
import type {
|
||||
StreamingStorageExtensions,
|
||||
UriPlugin,
|
||||
} from "../types/plugin.types.js";
|
||||
|
||||
/**
|
||||
* Implement `write(uri, body)` semantics for a dispatched plugin.
|
||||
*
|
||||
* `body` may be `Uint8Array` or `ReadableStream<Uint8Array>`. When the
|
||||
* adapter exposes `createWriteStream` AND `body` is a stream, the call is
|
||||
* forwarded directly. Otherwise the body is drained into a single
|
||||
* `Uint8Array` (via `new Response(body).arrayBuffer()` for stream inputs)
|
||||
* and a single `adapter.set(key, bytes)` call is issued. The drain path
|
||||
* increments `storage_uri.buffered_write_total{scheme}`.
|
||||
*
|
||||
* Adapter throwables that are `SuaveplanError` subclasses (any package's
|
||||
* namespaced error — e.g. `DriveUriError`, `StorageHttpError`) pass through
|
||||
* unchanged so consumers can route on the original `code`. Non-namespaced
|
||||
* throwables (plain `Error`, foreign objects) are wrapped as
|
||||
* `STORAGE_URI.WRITE_UNSUPPORTED` with the original preserved as `cause`,
|
||||
* so a read-only adapter without its own error class still surfaces a
|
||||
* single normalised failure mode.
|
||||
*/
|
||||
export async function writeViaPlugin(
|
||||
plugin: UriPlugin,
|
||||
uri: string,
|
||||
key: string,
|
||||
body: Uint8Array | ReadableStream<Uint8Array>,
|
||||
signal: AbortSignal | undefined
|
||||
): Promise<void> {
|
||||
const adapter = plugin.adapter as typeof plugin.adapter &
|
||||
StreamingStorageExtensions;
|
||||
const isStream =
|
||||
typeof (body as ReadableStream<Uint8Array>).getReader === "function";
|
||||
try {
|
||||
if (isStream && typeof adapter.createWriteStream === "function") {
|
||||
await adapter.createWriteStream(
|
||||
key,
|
||||
body as ReadableStream<Uint8Array>,
|
||||
signal
|
||||
);
|
||||
return;
|
||||
}
|
||||
let bytes: Uint8Array;
|
||||
if (isStream) {
|
||||
const buf = await new Response(
|
||||
body as ReadableStream<Uint8Array>
|
||||
).arrayBuffer();
|
||||
bytes = new Uint8Array(buf);
|
||||
recordBufferedWrite(plugin.scheme);
|
||||
} else {
|
||||
bytes = body as Uint8Array;
|
||||
}
|
||||
await adapter.set(key, bytes);
|
||||
} catch (err) {
|
||||
if (err instanceof SuaveplanError) throw err;
|
||||
const opts: {
|
||||
uri: string;
|
||||
scheme: string;
|
||||
cause?: unknown;
|
||||
} = { uri, scheme: plugin.scheme };
|
||||
if (err instanceof Error) opts.cause = err;
|
||||
throw new StorageUriError(
|
||||
STORAGE_URI_CODES.WRITE_UNSUPPORTED,
|
||||
"adapter rejected write",
|
||||
opts
|
||||
);
|
||||
}
|
||||
}
|
||||
10
packages/persistence/storage/storage-uri/src/types/index.ts
Normal file
10
packages/persistence/storage/storage-uri/src/types/index.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
export type {
|
||||
ParsedUri,
|
||||
StatResult,
|
||||
StreamingStorageExtensions,
|
||||
UriPlugin,
|
||||
} from "./plugin.types.js";
|
||||
export type {
|
||||
ListUriOptions,
|
||||
ListUriResult,
|
||||
} from "./resolver.types.js";
|
||||
@@ -0,0 +1,72 @@
|
||||
# plugin.types
|
||||
|
||||
## Purpose
|
||||
|
||||
Declares the public type surface every adapter package targets when shipping
|
||||
its own `uriPlugin`. The resolver knows nothing about S3 buckets, CDN zones,
|
||||
or filesystem roots; instead it accepts any object conforming to
|
||||
`UriPlugin`, which is the structural contract between this package and every
|
||||
sibling `storage-*` adapter.
|
||||
|
||||
The same module declares the optional `StreamingStorageExtensions` interface
|
||||
the resolver duck-types for `createReadStream`, `createWriteStream`, and
|
||||
`getMetadata`, plus the `StatResult` shape returned by
|
||||
`StorageUriResolver.stat`. None of these types carry runtime code.
|
||||
|
||||
## Features
|
||||
|
||||
- `UriPlugin` — `{ scheme, adapter, parse, build? }` carrying the entire
|
||||
scheme-handler contract.
|
||||
- `ParsedUri` — return type of `plugin.parse(uri)`; `{ key, meta? }`.
|
||||
- `StreamingStorageExtensions` — duck-typed optional surface.
|
||||
- `StatResult` — `{ size?, etag?, lastModified?, contentType? }`.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import type { UriPlugin } from "@suaveplan/storage-uri/types";
|
||||
|
||||
const plugin: UriPlugin = {
|
||||
scheme: "memory",
|
||||
adapter,
|
||||
parse(uri) { return { key: uri.slice("memory://".length) }; },
|
||||
};
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import type { UriPlugin, StreamingStorageExtensions } from "@suaveplan/storage-uri/types";
|
||||
|
||||
function makePlugin(adapter: StorageLike & StreamingStorageExtensions): UriPlugin {
|
||||
return {
|
||||
scheme: "rackspace",
|
||||
adapter,
|
||||
parse(uri) {
|
||||
const url = new URL(uri);
|
||||
return { key: url.pathname.slice(1), meta: { container: url.host } };
|
||||
},
|
||||
build(key, meta) {
|
||||
const container = meta?.container ?? "";
|
||||
return `rackspace://${container}/${key}`;
|
||||
},
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `interface ParsedUri { key: string; meta?: Record<string,string> }`
|
||||
- `interface UriPlugin { scheme; adapter; parse; build? }`
|
||||
- `interface StreamingStorageExtensions { createReadStream?; createWriteStream?; getMetadata? }`
|
||||
- `interface StatResult { size?; etag?; lastModified?; contentType? }`
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`adapter` is typed as `StorageLike` (from `@suaveplan/types`), NOT
|
||||
`AbstractStorage` from `@suaveplan/storage`. That lets in-memory
|
||||
test stubs satisfy the contract without
|
||||
pulling in the storage runtime. `meta` is a `Record<string, string>` rather
|
||||
than a generic shape so all adapter-specific addressing (bucket, zone,
|
||||
container, query) is stringly-typed at the plugin boundary — each adapter is
|
||||
free to expose typed helpers on top.
|
||||
@@ -0,0 +1,67 @@
|
||||
import type { StorageLike } from "@suaveplan/types";
|
||||
|
||||
/**
|
||||
* Result of parsing a scheme-specific URI into its addressable storage key
|
||||
* plus optional auxiliary addressing dimensions (CDN zone, S3 bucket, container).
|
||||
*/
|
||||
export interface ParsedUri {
|
||||
/** The storage key passed to the underlying `StorageLike` adapter. */
|
||||
readonly key: string;
|
||||
/**
|
||||
* Adapter-specific extras (`bucket`, `container`, `zone`, `query`, ...).
|
||||
* The resolver never inspects this — plugins are responsible for forwarding
|
||||
* the values to their bound adapter as needed.
|
||||
*/
|
||||
readonly meta?: Record<string, string>;
|
||||
}
|
||||
|
||||
/**
|
||||
* A scheme handler registered with a `StorageUriResolver`.
|
||||
*
|
||||
* Each plugin owns ONE scheme (e.g. `"s3"`, `"file"`, `"https"`) and exposes:
|
||||
*
|
||||
* - `adapter` — a `StorageLike` against which `parse(uri).key` is dispatched.
|
||||
* - `parse(uri)` — splits the URI into a storage key plus optional meta.
|
||||
* - `build(key, meta?)` — OPTIONAL inverse used by `list()` re-serialisation
|
||||
* and by the conformance harness's round-trip assertions. Plugins without
|
||||
* `build` cause `list()` to throw `STORAGE_URI.LIST_UNSUPPORTED`.
|
||||
*/
|
||||
export interface UriPlugin {
|
||||
readonly scheme: string;
|
||||
readonly adapter: StorageLike;
|
||||
parse(uri: string): ParsedUri;
|
||||
build?(key: string, meta?: Record<string, string>): string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Optional duck-typed extensions an adapter MAY implement on top of
|
||||
* `StorageLike`. Used by `open()` and `write()` to forward native streaming
|
||||
* surfaces when present and fall back to the buffered path when not.
|
||||
*/
|
||||
export interface StreamingStorageExtensions {
|
||||
createReadStream?(
|
||||
key: string,
|
||||
signal?: AbortSignal
|
||||
): ReadableStream<Uint8Array> | Promise<ReadableStream<Uint8Array>>;
|
||||
createWriteStream?(
|
||||
key: string,
|
||||
body: ReadableStream<Uint8Array>,
|
||||
signal?: AbortSignal
|
||||
): Promise<void>;
|
||||
getMetadata?(
|
||||
key: string,
|
||||
signal?: AbortSignal
|
||||
): Promise<StatResult | undefined>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Metadata returned by `StorageUriResolver.stat(uri)`. Adapters that cannot
|
||||
* answer cheaply return `undefined`; callers MUST treat that as "unknown",
|
||||
* not "absent" (use `exists()` for absence).
|
||||
*/
|
||||
export interface StatResult {
|
||||
readonly size?: number;
|
||||
readonly etag?: string;
|
||||
readonly lastModified?: Date;
|
||||
readonly contentType?: string;
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
# resolver.types
|
||||
|
||||
## Purpose
|
||||
|
||||
Declares the option and result types carried by `StorageUriResolver.list`.
|
||||
These live in their own module so consumers can import the shapes without
|
||||
pulling in the resolver class itself — handy when type-checking plugin
|
||||
authoring code that never instantiates a resolver but does need to type its
|
||||
own list results.
|
||||
|
||||
## Features
|
||||
|
||||
- `ListUriOptions` — `{ limit?, continuationToken? }`. The `prefix` is
|
||||
derived from the input URI by the resolver; consumers never set it
|
||||
directly.
|
||||
- `ListUriResult` — `{ uris, hasMore, continuationToken? }` returned by
|
||||
`StorageUriResolver.list`.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import type { ListUriOptions, ListUriResult } from "@suaveplan/storage-uri/types";
|
||||
|
||||
const opts: ListUriOptions = { limit: 50 };
|
||||
const out: ListUriResult = await resolver.list("s3://bucket/raw/", opts);
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import type { ListUriOptions, ListUriResult } from "@suaveplan/storage-uri/types";
|
||||
|
||||
async function* drain(
|
||||
resolver: StorageUriResolver,
|
||||
base: string,
|
||||
): AsyncIterable<string> {
|
||||
let token: string | undefined;
|
||||
do {
|
||||
const opts: ListUriOptions = token === undefined ? {} : { continuationToken: token };
|
||||
const page: ListUriResult = await resolver.list(base, opts);
|
||||
yield* page.uris;
|
||||
token = page.continuationToken;
|
||||
} while (token !== undefined);
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `interface ListUriOptions { limit?: number; continuationToken?: string }`
|
||||
- `interface ListUriResult { uris: readonly string[]; hasMore: boolean; continuationToken?: string }`
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`uris` is a `readonly string[]` rather than a mutable array so consumers
|
||||
cannot accidentally mutate the resolver's return value. `continuationToken`
|
||||
is intentionally opaque — the resolver forwards whatever the adapter
|
||||
returned, and consumers MUST pass it back unchanged. Adapters that cannot
|
||||
paginate return `hasMore: false` and never populate `continuationToken`;
|
||||
consumers SHOULD treat `hasMore === false` as the loop's termination
|
||||
condition rather than `continuationToken === undefined`. The split between
|
||||
`ListUriOptions` and `ListUriResult` mirrors `StorageLike.list`'s own
|
||||
input/output shapes one-for-one, so adapter authors who already implement
|
||||
`StorageLike.list` need no extra adaptation layer to satisfy
|
||||
`StorageUriResolver.list` — only `build()` for URI re-serialisation. Both
|
||||
shapes are deeply readonly so consumers cannot mutate them after receipt.
|
||||
@@ -0,0 +1,21 @@
|
||||
/**
|
||||
* Options accepted by `StorageUriResolver.list(uri, options?, signal?)`.
|
||||
*
|
||||
* `prefix` is implicit — derived from the input URI's key portion — so this
|
||||
* type carries only the pagination knobs forwarded to `StorageLike.list`.
|
||||
*/
|
||||
export interface ListUriOptions {
|
||||
readonly limit?: number;
|
||||
readonly continuationToken?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Result returned by `StorageUriResolver.list`. Keys are re-serialised back to
|
||||
* fully-qualified URIs via the plugin's `build()` function; `hasMore` and
|
||||
* `continuationToken` are forwarded verbatim from the underlying adapter.
|
||||
*/
|
||||
export interface ListUriResult {
|
||||
readonly uris: readonly string[];
|
||||
readonly hasMore: boolean;
|
||||
readonly continuationToken?: string;
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
import { mkdtemp, rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { createNodeFsStorage } from "@suaveplan/storage-file";
|
||||
import { createHttpStorage } from "@suaveplan/storage-http";
|
||||
import {
|
||||
afterEach,
|
||||
beforeEach,
|
||||
describe,
|
||||
expect,
|
||||
it,
|
||||
} from "@suaveplan/testing/runner";
|
||||
import type { StorageLike } from "@suaveplan/types";
|
||||
import { createStorageUriResolver } from "../../src/index.js";
|
||||
import type { UriPlugin } from "../../src/types/plugin.types.js";
|
||||
|
||||
function fileUriPlugin(adapter: StorageLike, rootDir: string): UriPlugin {
|
||||
return {
|
||||
scheme: "file",
|
||||
adapter,
|
||||
parse(uri) {
|
||||
// file:///abs/path → key relative to rootDir.
|
||||
const prefix = "file://";
|
||||
const rest = uri.startsWith(prefix)
|
||||
? uri.slice(prefix.length)
|
||||
: uri;
|
||||
const abs = rest.startsWith("/") ? rest : `/${rest}`;
|
||||
const root = rootDir.endsWith("/") ? rootDir : `${rootDir}/`;
|
||||
const key = abs.startsWith(root)
|
||||
? abs.slice(root.length)
|
||||
: abs.replace(/^\//, "");
|
||||
return { key };
|
||||
},
|
||||
build(key) {
|
||||
const root = rootDir.endsWith("/") ? rootDir : `${rootDir}/`;
|
||||
return `file://${root}${key}`;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function httpsUriPlugin(adapter: StorageLike): UriPlugin {
|
||||
return {
|
||||
scheme: "https",
|
||||
adapter,
|
||||
parse(uri) {
|
||||
const url = new URL(uri);
|
||||
return {
|
||||
key: url.pathname.replace(/^\//, ""),
|
||||
meta: { host: url.host },
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("cross-scheme integration", () => {
|
||||
let root: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
root = await mkdtemp(join(tmpdir(), "storage-uri-int-"));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(root, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("composes a resolver with real storage-file and storage-http adapters", {
|
||||
timeout: 15000,
|
||||
}, async () => {
|
||||
const fileAdapter = createNodeFsStorage({ rootDir: root });
|
||||
|
||||
// In-process fetch stub serving a single payload at a known URL.
|
||||
const payload = new TextEncoder().encode("from-http");
|
||||
const httpAdapter = createHttpStorage({
|
||||
baseUrl: "https://origin.test/",
|
||||
fetch: async (input: unknown) => {
|
||||
const url =
|
||||
typeof input === "string"
|
||||
? input
|
||||
: input instanceof URL
|
||||
? input.toString()
|
||||
: input instanceof Request
|
||||
? input.url
|
||||
: String((input as { url?: string }).url ?? input);
|
||||
if (url.endsWith("/poster.bin")) {
|
||||
return new Response(payload, { status: 200 });
|
||||
}
|
||||
return new Response("not found", { status: 404 });
|
||||
},
|
||||
});
|
||||
|
||||
const resolver = createStorageUriResolver([
|
||||
fileUriPlugin(fileAdapter, root),
|
||||
httpsUriPlugin(httpAdapter),
|
||||
]);
|
||||
|
||||
// HTTP read.
|
||||
const httpBytes = await resolver.read("https://origin.test/poster.bin");
|
||||
expect(new TextDecoder().decode(httpBytes)).toBe("from-http");
|
||||
|
||||
// File round-trip via resolver.
|
||||
const fileUri = `file://${root}/cache/copy.bin`;
|
||||
await resolver.write(fileUri, httpBytes);
|
||||
const back = await resolver.read(fileUri);
|
||||
expect(new TextDecoder().decode(back)).toBe("from-http");
|
||||
|
||||
expect(await resolver.exists(fileUri)).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,5 @@
|
||||
// Polyfills for test environment — none required; Node 18+ provides
|
||||
// `URL`, `AbortController`, `ReadableStream`, and `Response` natively.
|
||||
// File exists so vitest has a stable setup entry and to mirror sibling
|
||||
// storage packages.
|
||||
export {};
|
||||
@@ -0,0 +1,54 @@
|
||||
// Test-only helpers used by the resolver test suite. Coverage excluded via
|
||||
// vitest config's `*.test.ts` filter — this file is `.ts` rather than
|
||||
// `.test.ts` so it can be imported from multiple test files.
|
||||
|
||||
import type { StorageLike } from "@suaveplan/types";
|
||||
|
||||
/**
|
||||
* Minimal in-memory `StorageLike` for resolver dispatch tests. NOT a mock —
|
||||
* it's a real implementation of the structural contract, written by hand
|
||||
* because rule 2 forbids `vi.mock`. Test files attach this to a
|
||||
* `UriPlugin` to exercise scheme dispatch.
|
||||
*/
|
||||
export class MemoryStorageLike implements StorageLike {
|
||||
public readonly store = new Map<string, Uint8Array>();
|
||||
public lastSignal: AbortSignal | undefined;
|
||||
public getCalls = 0;
|
||||
public setCalls = 0;
|
||||
public deleteCalls = 0;
|
||||
public existsCalls = 0;
|
||||
public listCalls = 0;
|
||||
|
||||
public async get(key: string): Promise<Uint8Array | undefined> {
|
||||
this.getCalls += 1;
|
||||
return this.store.get(key);
|
||||
}
|
||||
public async set(key: string, value: Uint8Array): Promise<void> {
|
||||
this.setCalls += 1;
|
||||
this.store.set(key, value);
|
||||
}
|
||||
public async delete(key: string): Promise<boolean> {
|
||||
this.deleteCalls += 1;
|
||||
return this.store.delete(key);
|
||||
}
|
||||
public async exists(key: string): Promise<boolean> {
|
||||
this.existsCalls += 1;
|
||||
return this.store.has(key);
|
||||
}
|
||||
public async list(options?: { prefix?: string; limit?: number }): Promise<{
|
||||
keys: string[];
|
||||
total: number;
|
||||
hasMore: boolean;
|
||||
continuationToken?: string | undefined;
|
||||
}> {
|
||||
this.listCalls += 1;
|
||||
const prefix = options?.prefix ?? "";
|
||||
const all = [...this.store.keys()].filter((k) => k.startsWith(prefix));
|
||||
const limit = options?.limit ?? all.length;
|
||||
const keys = all.slice(0, limit);
|
||||
return { keys, total: all.length, hasMore: keys.length < all.length };
|
||||
}
|
||||
public async clear(): Promise<void> {
|
||||
this.store.clear();
|
||||
}
|
||||
}
|
||||
12
packages/persistence/storage/storage-uri/tsconfig.json
Normal file
12
packages/persistence/storage/storage-uri/tsconfig.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/tsconfig",
|
||||
"extends": "../../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": ".",
|
||||
"outDir": "./dist",
|
||||
"lib": ["ES2022"],
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["src/**/*.ts", "tests/**/*.ts"],
|
||||
"exclude": ["dist", "node_modules"]
|
||||
}
|
||||
12
packages/persistence/storage/storage-uri/tsconfig.lib.json
Normal file
12
packages/persistence/storage/storage-uri/tsconfig.lib.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/tsconfig",
|
||||
"extends": "../../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "./src",
|
||||
"outDir": "./dist",
|
||||
"lib": ["ES2022"],
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"exclude": ["src/**/*.test.ts", "src/**/*.bench.ts", "dist", "node_modules"]
|
||||
}
|
||||
35
packages/persistence/storage/storage-uri/vite.config.ts
Normal file
35
packages/persistence/storage/storage-uri/vite.config.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { defineConfig } from "vite";
|
||||
import dts from "vite-plugin-dts";
|
||||
|
||||
export default defineConfig({
|
||||
root: __dirname,
|
||||
cacheDir: "../../../node_modules/.vite/packages/storage-uri",
|
||||
plugins: [
|
||||
dts({
|
||||
entryRoot: "src",
|
||||
tsconfigPath: "./tsconfig.lib.json",
|
||||
insertTypesEntry: true,
|
||||
}),
|
||||
],
|
||||
build: {
|
||||
outDir: "./dist",
|
||||
emptyOutDir: true,
|
||||
reportCompressedSize: true,
|
||||
sourcemap: true,
|
||||
lib: { entry: "src/index.ts", formats: ["es"] },
|
||||
rollupOptions: {
|
||||
treeshake: true,
|
||||
output: {
|
||||
preserveModules: true,
|
||||
preserveModulesRoot: "src",
|
||||
entryFileNames: "[name].js",
|
||||
},
|
||||
external: [
|
||||
"@opentelemetry/api",
|
||||
"@suaveplan/error",
|
||||
"@suaveplan/types",
|
||||
"tslib",
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
28
packages/persistence/storage/storage-uri/vitest.config.ts
Normal file
28
packages/persistence/storage/storage-uri/vitest.config.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
globals: false,
|
||||
environment: "node",
|
||||
pool: "forks",
|
||||
setupFiles: ["./tests/setup/polyfills.ts"],
|
||||
include: ["src/**/*.test.ts", "tests/**/*.test.ts"],
|
||||
exclude: ["dist/**", "node_modules/**"],
|
||||
coverage: {
|
||||
provider: "istanbul",
|
||||
reporter: ["text", "html", "lcov"],
|
||||
include: ["src/**/*.ts"],
|
||||
exclude: [
|
||||
"src/**/*.test.ts",
|
||||
"src/**/*.spec-conformance.test.ts",
|
||||
"src/**/*.types.ts",
|
||||
"src/**/index.ts",
|
||||
"src/**/*.bench.ts",
|
||||
"src/__benchmarks__/**",
|
||||
"tests/**",
|
||||
],
|
||||
thresholds: { statements: 100, branches: 100, functions: 100, lines: 100 },
|
||||
},
|
||||
testTimeout: 30000,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user