259 lines
19 KiB
Markdown
259 lines
19 KiB
Markdown
<!-- markdownlint-disable MD024 -->
|
|
|
|
# Capability Spec: drive-uri
|
|
|
|
**Package**: `@suaveplan/drive-uri`
|
|
|
|
> Every requirement carries a `**Verified by:**` line.
|
|
>
|
|
> **Note — buffered-read signal threading is revisited in `add-signal-to-storage-like`.** `StorageUriResolver.read()` does not currently thread `AbortSignal` through `adapter.get()`, only through `adapter.createReadStream()` via `open()`. The signal-related requirement below therefore verifies signal propagation through the streaming path. A future cross-cutting change against `@suaveplan/types`' `StorageLike` interface will extend `get/set/exists/...` to accept an optional `AbortSignal`; once it lands, the requirement can be extended to cover the buffered path.
|
|
|
|
## Requirements
|
|
|
|
### Requirement: `drive://` plugin parses authority as driveId and delegates to `Drive.read`
|
|
|
|
The `driveUriPlugin(options)` factory SHALL return a `UriPlugin` with `scheme === "drive"`. Its `parse` SHALL extract the URI authority as `driveId` and the remainder as a slash-prefix-trimmed `key`. The returned plugin SHALL forward `key` to the consumer-supplied `Drive` via `Drive.read(key, { signal })` (or `Drive.createReadStream` on `open()`). The plugin SHALL throw `DRIVE_URI.DRIVE_ID_MISMATCH` when the URI's driveId does not equal the `driveId` declared in `options`.
|
|
|
|
The `StorageUriResolver` SHALL surface this throw directly per the pass-through rule in `storage-uri`'s "Adapter error re-wrapping" requirement — `err.code === "DRIVE_URI.DRIVE_ID_MISMATCH"` reaches the caller without a `STORAGE_URI.*` envelope.
|
|
|
|
#### Scenario: URI dispatches to Drive.read
|
|
|
|
- **WHEN** `resolver.read("drive://primary/folder/file.bin")` is called and the resolver holds `driveUriPlugin({ drive, driveId: "primary" })`
|
|
- **THEN** `drive.read("folder/file.bin", { signal })` is invoked exactly once and its return value is the resolver's result
|
|
|
|
#### Scenario: driveId mismatch surfaces DRIVE_URI.DRIVE_ID_MISMATCH directly
|
|
|
|
- **WHEN** `resolver.read("drive://other/x")` is called and the plugin is configured with `driveId: "primary"`
|
|
- **THEN** the call rejects with `DriveUriError` having `code === "DRIVE_URI.DRIVE_ID_MISMATCH"`, `uri === "drive://other/x"`, `expected === "primary"`, `actual === "other"` (the resolver passes the `DriveUriError` through unchanged; no `STORAGE_URI.*` envelope is added)
|
|
|
|
#### Scenario: build round-trips driveId and key
|
|
|
|
- **WHEN** `plugin.build("folder/file.bin", { driveId: "primary" })` is called
|
|
- **THEN** the returned string equals `"drive://primary/folder/file.bin"`
|
|
|
|
**Verified by:** `src/plugins/drive/drive.test.ts::"dispatches a drive:// URI to Drive.read"` AND `src/plugins/drive/drive.test.ts::"throws DRIVE_ID_MISMATCH when URI driveId differs from configured driveId"` AND `src/plugins/drive/drive.test.ts::"build round-trips driveId and key into a drive:// URI"`
|
|
|
|
---
|
|
|
|
### Requirement: `drive+mesh://` plugin captures swarmId and delegates to Drive without dispatch-on-swarm
|
|
|
|
The `driveMeshUriPlugin(options)` factory SHALL return a `UriPlugin` with `scheme === "drive+mesh"`. Its `parse` SHALL extract the URI authority as `meta.swarmId` and the remainder as `key`. The plugin SHALL forward `key` to the consumer-supplied `Drive` exactly like the `drive` plugin; it SHALL NOT use `swarmId` to choose a `Drive` or transport — peer routing is the `Drive`'s and its transports' responsibility. The swarmId SHALL be surfaced as the `drive.uri.swarm_id` span attribute on every emitted span.
|
|
|
|
#### Scenario: URI dispatches to the configured Drive regardless of swarmId
|
|
|
|
- **WHEN** `resolver.read("drive+mesh://swarmA/raw/clip.mov")` is called and the resolver holds `driveMeshUriPlugin({ drive: meshDrive })`
|
|
- **THEN** `meshDrive.read("raw/clip.mov", { signal })` is invoked exactly once
|
|
|
|
#### Scenario: swarmId appears on the emitted span
|
|
|
|
- **WHEN** `resolver.read("drive+mesh://swarmA/raw/clip.mov")` is called
|
|
- **THEN** the emitted span has attribute `drive.uri.swarm_id === "swarmA"`
|
|
|
|
#### Scenario: build round-trips swarmId and key
|
|
|
|
- **WHEN** `plugin.build("raw/clip.mov", { swarmId: "swarmA" })` is called
|
|
- **THEN** the returned string equals `"drive+mesh://swarmA/raw/clip.mov"`
|
|
|
|
**Verified by:** `src/plugins/drive-mesh/drive-mesh.test.ts::"dispatches a drive+mesh:// URI to the configured Drive"` AND `src/plugins/drive-mesh/drive-mesh.test.ts::"surfaces swarmId on the emitted span"` AND `src/plugins/drive-mesh/drive-mesh.test.ts::"build round-trips swarmId and key into a drive+mesh:// URI"`
|
|
|
|
---
|
|
|
|
### Requirement: `drive+template://` plugin fetches the template, hydrates the Drive, then reads
|
|
|
|
The `driveTemplateUriPlugin(options)` factory SHALL return a `UriPlugin` with `scheme === "drive+template"`. On the first `get` for a given `templateId`, the plugin SHALL invoke `options.templateProvider.fetchTemplate(templateId, signal)`, then `options.drive.hydrateFrom(template, { signal })`, before delegating to `options.drive.read(key, { signal })`. Concurrent reads against the same `templateId` SHALL await a single in-flight hydration. Subsequent reads after a successful hydration SHALL skip both calls.
|
|
|
|
#### Scenario: first read hydrates exactly once
|
|
|
|
- **WHEN** `resolver.read("drive+template://templA/folder/file.bin")` is called for the first time
|
|
- **THEN** `templateProvider.fetchTemplate("templA", signal)` is invoked exactly once AND `drive.hydrateFrom(template, { signal })` is invoked exactly once AND `drive.read("folder/file.bin", { signal })` is invoked exactly once
|
|
|
|
#### Scenario: concurrent reads against the same templateId share one hydration
|
|
|
|
- **WHEN** two `resolver.read("drive+template://templA/x")` calls are issued before the first hydration resolves
|
|
- **THEN** `templateProvider.fetchTemplate` is invoked exactly once AND `drive.hydrateFrom` is invoked exactly once AND both `drive.read` calls run after hydration resolves
|
|
|
|
#### Scenario: second read after successful hydration skips fetch and hydrate
|
|
|
|
- **WHEN** a second `resolver.read("drive+template://templA/y")` is issued after the first read returned successfully
|
|
- **THEN** `templateProvider.fetchTemplate` is NOT invoked again AND `drive.hydrateFrom` is NOT invoked again AND `drive.read("y", { signal })` is invoked
|
|
|
|
**Verified by:** `src/plugins/drive-template/drive-template.test.ts::"hydrates exactly once on the first read"` AND `src/plugins/drive-template/drive-template.test.ts::"shares a single hydration across concurrent reads against the same templateId"` AND `src/plugins/drive-template/drive-template.test.ts::"skips fetch and hydrate on subsequent reads against the same templateId"`
|
|
|
|
---
|
|
|
|
### Requirement: `templateProvider` is required for `drive+template://`
|
|
|
|
The `driveTemplateUriPlugin(options)` factory SHALL throw `DRIVE_URI.CAPABILITY_MISSING` synchronously at construction time when `options.templateProvider` is missing or is not a function-typed `fetchTemplate` carrier. The factory MUST NOT defer this check to first use — the plugin would otherwise silently accept registration and fail at the first `read`.
|
|
|
|
#### Scenario: missing templateProvider throws at construction
|
|
|
|
- **WHEN** `driveTemplateUriPlugin({ drive })` is called without a `templateProvider`
|
|
- **THEN** the call throws `DriveUriError` with `code === "DRIVE_URI.CAPABILITY_MISSING"` and a `reason` field naming `templateProvider`
|
|
|
|
#### Scenario: non-function templateProvider.fetchTemplate is rejected
|
|
|
|
- **WHEN** `driveTemplateUriPlugin({ drive, templateProvider: { fetchTemplate: "not a function" as unknown as TemplateProvider["fetchTemplate"] } })` is called
|
|
- **THEN** the call throws `DriveUriError` with `code === "DRIVE_URI.CAPABILITY_MISSING"`
|
|
|
|
**Verified by:** `src/plugins/drive-template/drive-template.test.ts::"throws CAPABILITY_MISSING when templateProvider is absent"` AND `src/plugins/drive-template/drive-template.test.ts::"throws CAPABILITY_MISSING when templateProvider.fetchTemplate is not a function"`
|
|
|
|
---
|
|
|
|
### Requirement: Template fetch and hydration failures map to distinct DRIVE_URI codes
|
|
|
|
When `options.templateProvider.fetchTemplate` rejects, the plugin SHALL re-wrap the rejection as `DRIVE_URI.TEMPLATE_NOT_AVAILABLE` with the original error preserved on `cause`. When `Drive.hydrateFrom` rejects (any `DTP_*` code), the plugin SHALL re-wrap the rejection as `DRIVE_URI.TEMPLATE_INVALID` with the original on `cause`. The two failures MUST NOT be conflated — operator response differs (network/availability vs. integrity).
|
|
|
|
The `StorageUriResolver` SHALL surface both `DriveUriError`s directly per the pass-through rule.
|
|
|
|
#### Scenario: fetchTemplate rejection becomes TEMPLATE_NOT_AVAILABLE
|
|
|
|
- **WHEN** `resolver.read("drive+template://templA/x")` is called and `templateProvider.fetchTemplate` rejects with `new Error("404")`
|
|
- **THEN** the call rejects with `DriveUriError` having `code === "DRIVE_URI.TEMPLATE_NOT_AVAILABLE"` AND `cause.message === "404"`
|
|
|
|
#### Scenario: hydrateFrom rejection becomes TEMPLATE_INVALID
|
|
|
|
- **WHEN** `resolver.read("drive+template://templA/x")` is called and `drive.hydrateFrom` rejects with a `SuaveplanError` carrying `code === "DTP.TEMPLATE_EXPIRED"`
|
|
- **THEN** the call rejects with `DriveUriError` having `code === "DRIVE_URI.TEMPLATE_INVALID"` AND `cause.code === "DTP.TEMPLATE_EXPIRED"`
|
|
|
|
**Verified by:** `src/plugins/drive-template/drive-template.test.ts::"wraps fetchTemplate rejection as TEMPLATE_NOT_AVAILABLE"` AND `src/plugins/drive-template/drive-template.test.ts::"wraps hydrateFrom rejection as TEMPLATE_INVALID"`
|
|
|
|
---
|
|
|
|
### Requirement: `DriveStorageLikeAdapter` translates Drive missing-file errors into `undefined`
|
|
|
|
The internal `DriveStorageLikeAdapter` class SHALL implement `StorageLike` and SHALL translate `Drive.read` rejections whose `cause` chain indicates a not-found condition into `undefined` (matching the `StorageLike.get` contract). Every other failure SHALL be re-thrown unchanged so the upstream `StorageUriResolver` can wrap it as `STORAGE_URI.ADAPTER_ERROR` (or pass it through if the failure is itself a `SuaveplanError` subclass).
|
|
|
|
#### Scenario: not-found Drive error returns undefined
|
|
|
|
- **WHEN** `adapter.get("missing.bin")` is called and `Drive.read` rejects with a `DRIVE.IO_FAILED` whose cause is a `NOT_FOUND`-flavoured marker
|
|
- **THEN** the call resolves to `undefined`
|
|
|
|
#### Scenario: arbitrary Drive failure is re-thrown
|
|
|
|
- **WHEN** `adapter.get("x")` is called and `Drive.read` rejects with `new SuaveplanError("DRIVE.PLUGIN_FAILED", { message: "plugin crashed" })`
|
|
- **THEN** the call rejects with the same error reference; the adapter does NOT translate it to `undefined`
|
|
|
|
#### Scenario: clear() is unsupported
|
|
|
|
- **WHEN** `adapter.clear()` is called
|
|
- **THEN** the call rejects with a `SuaveplanError` whose message names `Drive.delete(path)` as the supported alternative
|
|
|
|
**Verified by:** `src/adapter/drive-storage-like.test.ts::"returns undefined for Drive not-found errors"` AND `src/adapter/drive-storage-like.test.ts::"re-throws non-not-found Drive errors unchanged"` AND `src/adapter/drive-storage-like.test.ts::"clear is unsupported and names the alternative"`
|
|
|
|
---
|
|
|
|
### Requirement: AbortSignal propagation across every plugin op
|
|
|
|
The adapter shim `createReadStream(key, signal)` SHALL forward the `AbortSignal` argument unchanged to `Drive.createReadStream(key, signal)` so that `StorageUriResolver.open()` threads the caller's signal into the drive's streaming path. A signal that is already aborted at call time SHALL cause the adapter shim to reject with `DRIVE_URI.ABORTED` without invoking the `Drive`. The `drive+template` plugin SHALL forward the signal to `templateProvider.fetchTemplate` and `Drive.hydrateFrom` on the first hydration. `StorageUriResolver`'s own pre-flight abort check (which raises `STORAGE_URI.ABORTED` before any plugin is consulted) is preserved as-is and is not modified by drive-uri.
|
|
|
|
#### Scenario: pre-aborted signal short-circuits the adapter stream
|
|
|
|
- **WHEN** `adapter.createReadStream("x", AbortSignal.abort())` is called on the adapter returned by `driveUriPlugin`
|
|
- **THEN** the call throws `DriveUriError` with `code === "DRIVE_URI.ABORTED"` AND `Drive.createReadStream` is NOT invoked
|
|
|
|
#### Scenario: live signal reaches Drive.createReadStream unchanged
|
|
|
|
- **WHEN** `adapter.createReadStream("x", liveSignal)` is called
|
|
- **THEN** the `Drive.createReadStream` invocation receives the same `liveSignal` reference
|
|
|
|
**Verified by:** `src/plugins/drive/drive.test.ts::"short-circuits with DRIVE_URI.ABORTED on a pre-aborted signal"` AND `src/plugins/drive/drive.test.ts::"forwards a live AbortSignal unchanged to Drive.createReadStream"`
|
|
|
|
---
|
|
|
|
### Requirement: Streaming reads forward Drive.createReadStream when present
|
|
|
|
The `DriveStorageLikeAdapter` SHALL expose a `createReadStream(key, signal)` method that forwards directly to `Drive.createReadStream(key, signal)`. When `resolver.open("drive://…")` is invoked, the resolver SHALL use this method (no buffered fallback) and the `storage_uri.stream_fallback_total` counter SHALL NOT be incremented.
|
|
|
|
#### Scenario: open returns Drive.createReadStream output
|
|
|
|
- **WHEN** `resolver.open("drive://primary/clip.mov")` is called
|
|
- **THEN** the returned `ReadableStream` is the same reference returned by `Drive.createReadStream("clip.mov", signal)` AND `storage_uri.stream_fallback_total` does NOT increment
|
|
|
|
#### Scenario: mid-stream abort cancels the Drive stream
|
|
|
|
- **WHEN** `resolver.open("drive://primary/clip.mov", controller.signal)` is called and `controller.abort()` is invoked after the first chunk
|
|
- **THEN** the underlying `Drive.createReadStream`'s signal observes the abort
|
|
|
|
**Verified by:** `src/plugins/drive/drive.streams.test.ts::"open forwards Drive.createReadStream without buffered fallback"` AND `src/plugins/drive/drive.streams.test.ts::"mid-stream abort propagates to Drive.createReadStream"`
|
|
|
|
---
|
|
|
|
### Requirement: Writes via drive:// schemes are rejected
|
|
|
|
The three plugins SHALL reject `set` (write) calls with `DRIVE_URI.WRITE_UNSUPPORTED`. The rejection SHALL carry the original URI and scheme on the error so operators can see misuse rate via `drive_uri.writes_total{scheme, outcome: "error"}`.
|
|
|
|
The `StorageUriResolver` SHALL surface this throw directly per the pass-through rule — `err.code === "DRIVE_URI.WRITE_UNSUPPORTED"` reaches the caller without a `STORAGE_URI.WRITE_UNSUPPORTED` envelope.
|
|
|
|
#### Scenario: write against drive:// rejects with DRIVE_URI.WRITE_UNSUPPORTED
|
|
|
|
- **WHEN** `resolver.write("drive://primary/x", new Uint8Array([1, 2, 3]))` is called
|
|
- **THEN** the call rejects with `DriveUriError` having `code === "DRIVE_URI.WRITE_UNSUPPORTED"`, `uri === "drive://primary/x"`, `scheme === "drive"`
|
|
|
|
#### Scenario: writes_total counter increments on the rejection
|
|
|
|
- **WHEN** `resolver.write("drive+mesh://swarmA/x", bytes)` is called
|
|
- **THEN** `drive_uri.writes_total{scheme: "drive+mesh", outcome: "error"}` is incremented by 1
|
|
|
|
**Verified by:** `src/plugins/drive/drive.test.ts::"rejects writes with DRIVE_URI.WRITE_UNSUPPORTED"` AND `src/otel/otel.test.ts::"increments writes_total on rejected writes"`
|
|
|
|
---
|
|
|
|
### Requirement: OpenTelemetry instrumentation
|
|
|
|
Every plugin operation SHALL emit a span on the `@suaveplan/drive-uri` tracer and SHALL record `drive_uri.op_duration_ms{op, scheme, outcome}` on its meter. Errors SHALL increment `drive_uri.errors_total{scheme, code}`. The `drive.uri` span attribute SHALL exclude any URI query string. The `drive+template` plugin SHALL increment `drive_uri.template_hydrations_total{outcome}` exactly once per first-use template; cache-hit reads SHALL NOT increment it.
|
|
|
|
#### Scenario: successful read records duration and reads counter
|
|
|
|
- **WHEN** `resolver.read("drive://primary/x")` succeeds
|
|
- **THEN** `drive_uri.op_duration_ms{op:"read", scheme:"drive", outcome:"success"}` records one observation AND `drive_uri.reads_total{scheme:"drive", outcome:"success"}` increments by 1
|
|
|
|
#### Scenario: error increments errors_total with the resolved code
|
|
|
|
- **WHEN** `resolver.read("drive+template://templA/x")` rejects with `TEMPLATE_NOT_AVAILABLE`
|
|
- **THEN** `drive_uri.errors_total{scheme:"drive+template", code:"DRIVE_URI.TEMPLATE_NOT_AVAILABLE"}` increments by 1
|
|
|
|
#### Scenario: query strings are stripped from span attributes
|
|
|
|
- **WHEN** `resolver.read("drive+mesh://swarmA/path?peers=abcdef")` is called
|
|
- **THEN** the emitted span's `drive.uri` attribute equals `"drive+mesh://swarmA/path"` (no `?peers=…`)
|
|
|
|
#### Scenario: template_hydrations_total increments once per first-use template
|
|
|
|
- **WHEN** `resolver.read("drive+template://templA/x")` is called then `resolver.read("drive+template://templA/y")` is called
|
|
- **THEN** `drive_uri.template_hydrations_total{outcome:"success"}` is exactly 1
|
|
|
|
**Verified by:** `src/otel/otel.test.ts::"records duration histogram and reads counter on success"` AND `src/otel/otel.test.ts::"increments errors_total with the resolved DRIVE_URI code"` AND `src/otel/otel.test.ts::"strips query strings from the drive.uri span attribute"` AND `src/otel/otel.test.ts::"increments template_hydrations_total exactly once per first-use template"`
|
|
|
|
---
|
|
|
|
### Requirement: Conformance harness reuse via `runUriPluginConformance`
|
|
|
|
The package SHALL re-export `runUriPluginConformance` from `@suaveplan/storage-uri` and SHALL include fixture-driven conformance tests for each of the three plugins. The fixtures SHALL cover three valid URI shapes, four invalid URI shapes, and a `build` round-trip for each valid fixture. The package SHALL NOT author its own parallel harness.
|
|
|
|
#### Scenario: drive plugin passes the conformance harness
|
|
|
|
- **WHEN** `runUriPluginConformance(driveUriPlugin({ drive, driveId: "primary" }), driveFixtures)` is invoked
|
|
- **THEN** every assertion passes without throwing
|
|
|
|
#### Scenario: drive+template plugin passes the conformance harness
|
|
|
|
- **WHEN** `runUriPluginConformance(driveTemplateUriPlugin({ drive, templateProvider }), templateFixtures)` is invoked
|
|
- **THEN** every assertion passes without throwing
|
|
|
|
**Verified by:** `src/conformance/conformance.test.ts::"drive plugin satisfies runUriPluginConformance"` AND `src/conformance/conformance.test.ts::"drive+template plugin satisfies runUriPluginConformance"`
|
|
|
|
---
|
|
|
|
### Requirement: Cross-scheme composition with `storage-uri`
|
|
|
|
A single `StorageUriResolver` MUST accept a mix of `storage-uri`'s plugins and `drive-uri`'s plugins and dispatch each URI to the correct plugin without code-path divergence. Registration of one drive plugin SHALL NOT prevent registration of any number of `storage-uri` plugins, and vice versa.
|
|
|
|
#### Scenario: file:// and drive:// resolve through the same resolver
|
|
|
|
- **WHEN** `createStorageUriResolver([fileUriPlugin({ rootDir }), driveUriPlugin({ drive, driveId: "primary" })])` is constructed and both `resolver.read("file:///tmp/a")` and `resolver.read("drive://primary/b")` are invoked
|
|
- **THEN** the file:// call routes to the file plugin's adapter AND the drive:// call routes to the drive plugin's adapter; neither call observes the other's state
|
|
|
|
**Verified by:** `tests/integration/compose-with-storage-uri.test.ts::"file:// and drive:// resolve through one StorageUriResolver"`
|
|
|
|
<!-- markdownlint-enable MD024 -->
|