# 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 { try { return await this.drive.read(key); } catch (err) { if (isDriveNotFound(err)) return undefined; throw err; } } async set(key: string, value: Uint8Array): Promise { await this.drive.write(key, value); } async delete(key: string): Promise { return this.drive.delete(key); } async exists(key: string): Promise { return (await this.drive.stat(key)) !== null; } async list(options?: { prefix?: string; limit?: number }): Promise { return this.drive.list(options ?? {}); } async clear(): Promise { 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 { 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>(); // 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>` 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.