Files
suaveplan/openspec/archive/2026-05-16-add-drive-uri/design.md
2026-05-27 23:29:53 +01:00

13 KiB

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

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

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

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):

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)

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.