262 lines
13 KiB
Markdown
262 lines
13 KiB
Markdown
# Design — @suaveplan/drive-uri
|
|
|
|
## Tier and dependency posture
|
|
|
|
Universal tier. Zero `node:` / `bun:` imports in `src/`. Runtime deps:
|
|
|
|
- `@suaveplan/error` — `defineCodes`, `SuaveplanError`
|
|
- `@suaveplan/storage-uri` — `UriPlugin`, `ParsedUri`, `parseUri`, `runUriPluginConformance`, `StorageUriError` (for `cause` chain typing)
|
|
- `@suaveplan/drive` — `Drive` (consumed type), `ReadOptions`, `WriteOptions`
|
|
- `@suaveplan/drive-template` — `DriveTemplate` (type only — schema is never decoded inside `drive-uri`)
|
|
- `@suaveplan/types` — `StorageLike` (the contract the adapter shim satisfies)
|
|
- `@opentelemetry/api` — meter + tracer
|
|
- `tslib`
|
|
|
|
`@suaveplan/drive-mesh`, `drive-plugin-chunks`, `drive-transport-https`, `drive-webrtc` are **NOT** runtime deps. The consumer wires those into the `Drive` instance they pass in; `drive-uri` only sees `Drive`. Same posture as `storage-uri` and the storage providers.
|
|
|
|
DevDeps: `@suaveplan/testing`, `@suaveplan/storage`, `@suaveplan/storage-file`, `@suaveplan/drive-plugin-chunks`, `@suaveplan/drive-mesh`, `@vitest/coverage-istanbul`, `fast-check`, `typescript`, `vite`, `vite-plugin-dts`, `vitest`. The integration tests require real `Drive` + `chunksPlugin` + storage-file pairs (no mocks per CLAUDE.md rule 2).
|
|
|
|
## File layout
|
|
|
|
```text
|
|
packages/drive/drive-uri/
|
|
package.json
|
|
README.md
|
|
src/
|
|
index.ts # explicit named re-exports (rule 10)
|
|
error-codes/
|
|
error-codes.ts # defineCodes("DRIVE_URI", [...]), DriveUriError
|
|
error-codes.test.ts
|
|
error-codes.md
|
|
index.ts
|
|
adapter/
|
|
drive-storage-like.ts # Drive → StorageLike shim
|
|
drive-storage-like.test.ts
|
|
drive-storage-like.md
|
|
index.ts
|
|
plugins/
|
|
drive/
|
|
drive.ts # driveUriPlugin(options): UriPlugin
|
|
drive.streams.ts # createReadStream/createWriteStream variants
|
|
drive.test.ts
|
|
drive.streams.test.ts
|
|
drive.md
|
|
drive.streams.md
|
|
index.ts
|
|
drive-mesh/
|
|
drive-mesh.ts # driveMeshUriPlugin(options): UriPlugin
|
|
drive-mesh.test.ts
|
|
drive-mesh.md
|
|
index.ts
|
|
drive-template/
|
|
drive-template.ts # driveTemplateUriPlugin(options): UriPlugin
|
|
drive-template.test.ts
|
|
drive-template.md
|
|
index.ts
|
|
types/
|
|
plugin-options.types.ts # DriveUriPluginOptions, DriveMeshPluginOptions, DriveTemplatePluginOptions, TemplateProvider
|
|
plugin-options.types.md
|
|
index.ts
|
|
otel/
|
|
otel.ts # meter + tracer wiring, recordOp, recordTemplateHydration
|
|
otel.test.ts
|
|
otel.md
|
|
index.ts
|
|
conformance/
|
|
conformance.ts # re-export of runUriPluginConformance from storage-uri
|
|
conformance.md # how to author the fixture set for a drive-uri plugin
|
|
index.ts
|
|
tests/
|
|
setup/polyfills.ts
|
|
integration/
|
|
drive-roundtrip.test.ts # real Drive + storage-file
|
|
template-roundtrip.test.ts # real Drive + chunksPlugin + storage-file + in-process templateProvider
|
|
compose-with-storage-uri.test.ts # one resolver, file:// + drive:// in the same dispatch
|
|
tsconfig.json
|
|
tsconfig.lib.json
|
|
vite.config.ts
|
|
vitest.config.ts
|
|
```
|
|
|
|
Every implementation `.ts` outside the excluded set (`*.test.ts`, `*.types.ts` inside a feature dir, `index.ts`) has a sibling `.md` of ≥200 words with the 6 required sections.
|
|
|
|
Per AGENTS §10 (no redundant naming): the `plugins/` parent does NOT cause its children to be named `*-plugin.ts` — children are `drive/drive.ts`, `drive-mesh/drive-mesh.ts`, `drive-template/drive-template.ts`. Each exports its `*UriPlugin` factory and the plugin-specific types.
|
|
|
|
## Adapter shim — `Drive → StorageLike`
|
|
|
|
```ts
|
|
export class DriveStorageLikeAdapter implements StorageLike {
|
|
constructor(private readonly drive: Drive) {}
|
|
|
|
async get(key: string): Promise<Uint8Array | undefined> {
|
|
try {
|
|
return await this.drive.read(key);
|
|
} catch (err) {
|
|
if (isDriveNotFound(err)) return undefined;
|
|
throw err;
|
|
}
|
|
}
|
|
|
|
async set(key: string, value: Uint8Array): Promise<void> {
|
|
await this.drive.write(key, value);
|
|
}
|
|
|
|
async delete(key: string): Promise<boolean> {
|
|
return this.drive.delete(key);
|
|
}
|
|
|
|
async exists(key: string): Promise<boolean> {
|
|
return (await this.drive.stat(key)) !== null;
|
|
}
|
|
|
|
async list(options?: { prefix?: string; limit?: number }): Promise<ListResult> {
|
|
return this.drive.list(options ?? {});
|
|
}
|
|
|
|
async clear(): Promise<void> {
|
|
throw new Error("clear() is not supported on Drive — use targeted delete instead");
|
|
}
|
|
|
|
// Optional: bridge createReadStream onto the resolver's open() fast path.
|
|
createReadStream(key: string, signal?: AbortSignal): ReadableStream<Uint8Array> {
|
|
return this.drive.createReadStream(key, signal);
|
|
}
|
|
}
|
|
```
|
|
|
|
`isDriveNotFound(err)` is the only place that has to know about `DRIVE.IO_FAILED`'s NOT_FOUND variant. The shim does not own any chunk codec, manifest fetching, peer discovery, or template validation — that all happens inside `Drive` via its installed middleware.
|
|
|
|
## Three plugins — common shape
|
|
|
|
```ts
|
|
export function driveUriPlugin(options: DriveUriPluginOptions): UriPlugin {
|
|
validateOptions(options); // throws DRIVE_URI.INVALID_URI / DRIVE_ID_MISMATCH eagerly
|
|
const adapter = new DriveStorageLikeAdapter(options.drive);
|
|
return {
|
|
scheme: "drive",
|
|
adapter,
|
|
parse(uri) {
|
|
const parsed = parseUri(uri);
|
|
if (parsed.scheme !== "drive") throw new DriveUriError("INVALID_URI", { uri });
|
|
const driveId = parsed.authority ?? "";
|
|
if (driveId !== options.driveId) {
|
|
throw new DriveUriError("DRIVE_ID_MISMATCH", { uri, expected: options.driveId, actual: driveId });
|
|
}
|
|
return { key: parsed.path.replace(/^\//, ""), meta: { driveId } };
|
|
},
|
|
build(key, meta) {
|
|
const id = meta?.driveId ?? options.driveId;
|
|
return `drive://${id}/${key.replace(/^\//, "")}`;
|
|
},
|
|
};
|
|
}
|
|
```
|
|
|
|
`driveMeshUriPlugin` is identical in shape, swapping `scheme: "drive+mesh"` and capturing `swarmId` instead of validating `driveId`. The mesh plugin does **not** enforce a swarmId match — it captures the swarmId for instrumentation only because the `Drive` instance is the consumer's choice and they may use the same `Drive` to read from multiple swarms (the mesh transport handles peer routing).
|
|
|
|
`driveTemplateUriPlugin` adds the hydration step in `parse` (or, more accurately, in `get` — the parse happens synchronously, the hydration is async):
|
|
|
|
```ts
|
|
export function driveTemplateUriPlugin(options: DriveTemplatePluginOptions): UriPlugin {
|
|
if (!options.templateProvider) {
|
|
throw new DriveUriError("CAPABILITY_MISSING", { reason: "templateProvider is required" });
|
|
}
|
|
const hydrated = new Map<string, Promise<void>>(); // templateId → in-flight hydration
|
|
const adapter = wrapDriveWithHydration(options.drive, options.templateProvider, hydrated);
|
|
return {
|
|
scheme: "drive+template",
|
|
adapter,
|
|
parse(uri) { /* extract templateId + path */ },
|
|
build(key, meta) { /* re-serialise */ },
|
|
};
|
|
}
|
|
```
|
|
|
|
The hydration cache is a per-plugin `Map<string, Promise<void>>` keyed by `templateId`. First `get(key)` for a given template-id awaits `templateProvider.fetchTemplate(templateId) → drive.hydrateFrom(template)`; concurrent reads await the same in-flight promise; subsequent reads see a resolved promise and short-circuit. The cache is intentionally **not** LRU — the only state it holds is "we've already hydrated this template into the Drive", and re-hydrating is idempotent at the `Drive` layer via `TemplateNonceLru`.
|
|
|
|
## Error mapping
|
|
|
|
| Origin (in `cause`) | Re-wrap | Why |
|
|
|---|---|---|
|
|
| `DRIVE.IO_FAILED` with NOT_FOUND | `undefined` from `adapter.get`, then `STORAGE_URI.NOT_FOUND` from resolver | Matches `StorageLike` contract |
|
|
| `DRIVE.IO_FAILED` other | `STORAGE_URI.ADAPTER_ERROR` (via resolver) | Generic adapter failure |
|
|
| `DRIVE_MESH.ALL_PEERS_FAILED` / `CHUNK_UNAVAILABLE` / `SIG_VERIFY_FAILED` | `STORAGE_URI.ADAPTER_ERROR` with original code on `cause` | Mesh-specific failures preserved for diagnosis |
|
|
| `CHUNKS.AEAD_TAG_INVALID` / `MANIFEST_NOT_FOUND` | `STORAGE_URI.ADAPTER_ERROR` with original on `cause` | Chunks plugin failures |
|
|
| `DTP_*` from `hydrateFrom` | `DRIVE_URI.TEMPLATE_INVALID` with original on `cause` | Template validation is a drive-uri-tier semantic concern |
|
|
| `templateProvider.fetchTemplate` rejects | `DRIVE_URI.TEMPLATE_NOT_AVAILABLE` with original on `cause` | Distinct from validation failure |
|
|
| AbortSignal aborted before adapter call | `DRIVE_URI.ABORTED` | Pre-flight short-circuit |
|
|
| URI's driveId ≠ plugin's configured driveId | `DRIVE_URI.DRIVE_ID_MISMATCH` | Detected in `parse`, before any `Drive` call |
|
|
| Malformed URI | `DRIVE_URI.INVALID_URI` (or `STORAGE_URI.INVALID_URI` from `parseUri`) | Either is acceptable — the resolver surfaces whichever is thrown |
|
|
|
|
## OTel surface
|
|
|
|
Tracer + meter: `@suaveplan/drive-uri`.
|
|
|
|
Histograms:
|
|
|
|
- `drive_uri.op_duration_ms{op, scheme, outcome}` — `outcome ∈ success | not_found | error | aborted`
|
|
|
|
Counters:
|
|
|
|
- `drive_uri.reads_total{scheme, outcome}`
|
|
- `drive_uri.writes_total{scheme, outcome}` — increments even though writes throw (so operator can see misuse rate)
|
|
- `drive_uri.deletes_total{scheme, outcome}`
|
|
- `drive_uri.errors_total{scheme, code}` — `code` is the `DRIVE_URI.*` enum or `ADAPTER_ERROR` for downstream
|
|
- `drive_uri.template_hydrations_total{outcome}` — increments once per first-use template; cache-hit reads do not increment
|
|
|
|
Spans: a single span per public op (`drive.uri.read`, `drive.uri.write`, `drive.uri.stat`, …). Attributes:
|
|
|
|
- `drive.uri` — full URI minus query string
|
|
- `drive.uri.scheme`
|
|
- `drive.uri.drive_id?` / `drive.uri.swarm_id?` / `drive.uri.template_id?` — depending on scheme
|
|
- `drive.uri.path` — post-parse path
|
|
|
|
Query strings are dropped from `drive.uri` regardless of scheme. This is a contract, not an opt-in.
|
|
|
|
## Conformance harness
|
|
|
|
`drive-uri` does not author its own conformance harness. It re-exports `runUriPluginConformance` from `@suaveplan/storage-uri` (the canonical home) and adds three planned fixture sets (one per scheme) to its own test suite. Each fixture set covers:
|
|
|
|
1. **Valid URIs** — three representative shapes per scheme (root path, nested path, path with allowed special chars).
|
|
2. **Invalid URIs** — wrong scheme prefix, missing authority, embedded null, oversize (> 4096 chars).
|
|
3. **Round-trip** — `build(parse(uri).key, parse(uri).meta) === uri` for each valid fixture.
|
|
|
|
This keeps the conformance contract in one place (`storage-uri`) and avoids drift between the two URI-plugin packages.
|
|
|
|
## Composition recipe (in README)
|
|
|
|
```ts
|
|
import { createStorageUriResolver } from "@suaveplan/storage-uri";
|
|
import { fileUriPlugin } from "@suaveplan/storage-file"; // future
|
|
import { httpsUriPlugin } from "@suaveplan/storage-http"; // future
|
|
import {
|
|
driveUriPlugin,
|
|
driveMeshUriPlugin,
|
|
driveTemplateUriPlugin,
|
|
} from "@suaveplan/drive-uri";
|
|
|
|
const localDrive = new Drive({ /* local-only config */ });
|
|
const meshDrive = new Drive({ /* mesh + chunks + transports */ });
|
|
|
|
const resolver = createStorageUriResolver([
|
|
fileUriPlugin({ rootDir: "/var/media" }),
|
|
httpsUriPlugin(httpStorage),
|
|
driveUriPlugin({ drive: localDrive, driveId: "local-cache-01" }),
|
|
driveMeshUriPlugin({ drive: meshDrive, swarmId: "*", instrumentation: { includeSwarmIdInSpans: true } }),
|
|
driveTemplateUriPlugin({ drive: meshDrive, templateProvider }),
|
|
]);
|
|
|
|
const stream = await resolver.open("drive+mesh://swarmA/raw/clip.mov");
|
|
const buf = await resolver.read("drive+template://templA/manifest/clip.jpg");
|
|
```
|
|
|
|
One resolver, mixed schemes, same dispatch path. No second resolver class, no fall-through helper, no umbrella registry.
|
|
|
|
## Trade-offs accepted
|
|
|
|
- **No DriveUriResolver class.** All routing through `StorageUriResolver`. Loses one layer of indirection; gains: no duplicated dispatch code, no conflict-detection seam between two resolvers, simpler composition.
|
|
- **Drive instance is the consumer's responsibility.** `drive-uri` does NOT new up a Drive. Cost: the consumer wires `chunksPlugin` + transports + templates themselves. Benefit: drive-uri stays decoupled and testable; the same Drive can serve multiple URI schemes (e.g. one mesh-configured Drive backs both `drive+mesh://` and `drive+template://`).
|
|
- **Template hydration cache is per-plugin-instance, not global.** Two different `driveTemplateUriPlugin` instances do not share the cache. Cost: marginal — consumers rarely instantiate two; if they do, `Drive.hydrateFrom`'s nonce LRU still makes re-hydration idempotent at the drive tier.
|
|
- **`clear()` throws.** Wiping a Drive via the URI surface is a footgun; the explicit error code points the consumer at `Drive.delete(path)` instead.
|
|
- **Writes are deferred.** See proposal §"Out of scope". Reading is the dominant URI use case (media pipelines, hydration sources, sharing links); writes are a separate design problem with non-trivial fan-out semantics.
|