Files
suaveplan/openspec/archive/2026-05-16-fix-storage-uri-error-passthrough/proposal.md
2026-05-27 23:29:53 +01:00

9.9 KiB

Pass SuaveplanError subclasses through StorageUriResolver unchanged

Package: @suaveplan/storage-uri (behavioural change), @suaveplan/drive-uri (spec tightening only — no code change) Status: PROPOSED Type: behaviour fix Depends on: add-storage-uri (in openspec/changes/), add-drive-uri (in openspec/changes/) — neither archived yet, so both deltas update in-place.

  • Spec delta (storage-uri): ./specs/storage-uri/spec.md
  • Spec delta (drive-uri): ./specs/drive-uri/spec.md

Why

StorageUriResolver today re-wraps every non-StorageUriError rejection from a plugin's adapter as STORAGE_URI.ADAPTER_ERROR (or STORAGE_URI.WRITE_UNSUPPORTED for write throws). That was a reasonable default at the time of add-storage-uri — the resolver had no knowledge of any other error class — but it breaks the error-code discriminator system for every downstream plugin family that emits SuaveplanError subclasses.

Concretely: a drive+mesh:// URI failing chunk-signature verification today reaches the consumer as STORAGE_URI.ADAPTER_ERROR with cause.code === "DRIVE_URI.MESH_VERIFY_FAILED" (or, transitively, cause.cause.code === "DRIVE_MESH.SIG_VERIFY_FAILED"). Consumers cannot write switch (err.code) against the meaningful code without inspecting .cause chains — defeating the whole point of having a per-package code registry. The same problem applies to every DriveUriError, every StorageHttpError, every StorageS3Error, and any future URI-plugin family.

The fix is local: teach the resolver to treat any SuaveplanError subclass as already-namespaced and pass it through untouched. Wrap only non-Suaveplan throwables (Error, plain objects, strings, etc.) — those still need the STORAGE_URI.ADAPTER_ERROR envelope for stable diagnostics. StorageUriError itself remains passed through (no double-wrap), unchanged from today.

This change also retires the spec deviations that add-drive-uri carried at the top of its delta ("the resolver wraps DriveUriError as STORAGE_URI.ADAPTER_ERROR, preserving the original on cause"). With the pass-through in place, the drive-uri scenarios can assert directly on err.code === "DRIVE_URI.*" without .cause indirection.

What changes

  • MODIFIED: StorageUriResolver's error-wrap branch — change the existing instanceof StorageUriError ? rethrow : wrap discriminator to instanceof SuaveplanError ? rethrow : wrap. StorageUriError extends SuaveplanError so the existing StorageUriError pass-through is preserved as a special case of the new rule.
  • MODIFIED: The WRITE_UNSUPPORTED re-wrap branch (for write() failures from the adapter) — same discriminator change. SuaveplanError subclasses thrown from adapter.set pass through with their original code; plain Error throws are still wrapped as STORAGE_URI.WRITE_UNSUPPORTED.
  • MODIFIED: storage-uri spec delta — two requirements updated:
    • "Adapter error re-wrapping": broaden the pass-through rule from StorageUriError to SuaveplanError; add a scenario asserting that a thrown DriveUriError reaches the caller with its original code.
    • "Streaming writes with buffered fallback": broaden the same rule on the write path; the "read-only adapter surfaces WRITE_UNSUPPORTED" scenario now applies only to non-Suaveplan throwables. A new scenario covers the pass-through case.
  • MODIFIED: drive-uri spec delta — five scenarios tightened to drop the .cause indirection. The two **Note —** revisited in fix-storage-uri-error-passthrough... annotations at the top of the drive-uri delta are removed (the first one); the second note (about signal threading) stays.
  • ADDED: A new storage-uri test src/resolver/resolver.passthrough.test.ts (or extension of resolver.test.ts) covering:
    • A thrown SuaveplanError("FOO.BAR", ...) passes through unchanged.
    • A thrown plain Error("disk fault") is still wrapped as STORAGE_URI.ADAPTER_ERROR with cause.message === "disk fault".
    • A thrown SuaveplanError from adapter.set passes through (no WRITE_UNSUPPORTED wrap).
  • MODIFIED: Existing drive-uri tests that assert on err.cause.code — switched to assert on err.code directly. The drive-uri test suite shrinks slightly in line count (no more .cause ladders) and stays at 100/100/100/100 coverage.

Out of scope

  • Signal threading through adapter.get. Separate concern, tracked under a future add-signal-to-storage-like change. Touches every StorageLike adapter; defer until a real consumer requires cancellable buffered reads. The signal-threading note in add-drive-uri/specs/drive-uri/spec.md stays.
  • Resolver-side translation of well-known downstream codes. Tempting to map DRIVE_MESH.SIG_VERIFY_FAILED → some STORAGE_URI.INTEGRITY_FAILED umbrella code; rejected because it would force the resolver to know every downstream registry. The pass-through gives consumers the right code without the resolver having to translate.
  • UriResolverLike in @suaveplan/types. No concrete second consumer; deferred until one appears.
  • Touching any other URI-plugin package. This is a one-file behaviour change inside storage-uri plus spec tightening in storage-uri and drive-uri. Other plugin packages (storage-http, storage-cdn-*, etc.) inherit the new behaviour for free without code change.

Source mapping

This is a follow-up to genesis-original changes; no upstream suaveplan/ reference.

Requirements with no source line

Requirement Justification
Pass-through trigger is instanceof SuaveplanError, not a list of known classes The defineCodes discipline is the contract for "this is a structured, namespaced error"; SuaveplanError is its runtime carrier. Any current or future package that uses defineCodes automatically participates. Adding a class allowlist would force the resolver to grow whenever a new URI-plugin family lands.
Plain Error / non-SuaveplanError throws still wrap Unstructured throws have no namespaced code; without the wrap, consumers cannot route diagnostics by code at all. The wrap remains the right default for foreign throwables.
Spec tightening lives in this change, not as a retroactive edit to add-drive-uri Both add-storage-uri and add-drive-uri are still in openspec/changes/ (not archived). The cleanest history is: each change captures the contract as authored; this change layers a MODIFIED delta on top. Once all three archive, the canonical specs reflect the final behaviour.

Verification approach

Every requirement carries a **Verified by:** line in the respective spec delta. The two new / tightened test files:

  • src/resolver/resolver.test.ts — extended with two new it(...) blocks ("passes through thrown SuaveplanError subclasses unchanged", "still wraps non-SuaveplanError throwables as ADAPTER_ERROR").
  • src/resolver/resolver.writes.test.ts — extended with one new it(...) block ("passes through thrown SuaveplanError subclasses on write").
  • packages/drive/drive-uri/src/plugins/**/*.test.ts — .cause-based assertions rewritten to assert on err.code directly. No new tests added; same scenarios, simpler assertions.

Impact

Affected dependents

Run bunx turbo ls --filter=...@suaveplan/storage-uri to enumerate.

  • @suaveplan/storage-uri — [x] tests run / [x] docs reviewed / [ ] no action needed (this is the package being modified)
  • @suaveplan/drive-uri — [x] tests run (assertions tightened) / [x] docs reviewed (spec deviations retired) / [ ] no action needed
  • @suaveplan/storage-file — [ ] tests run / [ ] docs reviewed / [x] no action needed (does not throw SuaveplanError subclasses from StorageLike methods today; new pass-through path is a no-op for it)
  • @suaveplan/storage-http — [ ] tests run / [ ] docs reviewed / [x] no action needed (throws StorageHttpError extends SuaveplanError — automatically benefits from pass-through but no breaking change since old behaviour was ADAPTER_ERROR{cause:StorageHttpError} which consumers were inspecting via .cause already; spec scenario in storage-uri's WRITE_UNSUPPORTED requirement is the only place this surfaced)
  • @suaveplan/storage-cdn, storage-cdn-rackspace, storage-cdn-bunnycdn — [ ] tests run / [ ] docs reviewed / [x] no action needed (same as storage-http — SuaveplanError subclasses pass through; downstream tests will be more direct but currently pass via .cause inspection, which still works)

The [ ] tests run boxes for the four storage adapter packages are unchecked because the pass-through is purely permissive — consumers who were inspecting .cause see the same cause populated (the wrap still happens for plain Error, and SuaveplanError subclasses now reach the caller directly which is strictly more information). Run bunx turbo test --filter=...@suaveplan/storage-uri before merging to confirm no consumer relied on the wrap envelope itself (err.code === "STORAGE_URI.ADAPTER_ERROR").

Affected upstreams

  • @suaveplan/error — None. SuaveplanError is consumed as-is via instanceof.
  • @suaveplan/types — None.

Behaviour change propagation (§13a)

Old behaviour: resolver.read(uri) rejects with StorageUriError{code: "STORAGE_URI.ADAPTER_ERROR", cause: downstreamErr} when the plugin's adapter throws.

New behaviour: resolver.read(uri) rejects with downstreamErr directly (same instance) when downstreamErr instanceof SuaveplanError; otherwise still rejects with StorageUriError{code: "STORAGE_URI.ADAPTER_ERROR", cause: downstreamErr}.

Consumer search command:

grep -rln 'STORAGE_URI\.ADAPTER_ERROR\|"STORAGE_URI.ADAPTER_ERROR"' packages/ \
  | grep -v "/dist/\|/coverage/\|/storage-uri/"

Recommended replacement: consumers that currently switch (err.cause?.code) to route on the downstream code can now switch (err.code) directly. No migration is forced — the old .cause inspection still works (the wrap still happens for non-SuaveplanError).