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 existinginstanceof StorageUriError ? rethrow : wrapdiscriminator toinstanceof SuaveplanError ? rethrow : wrap.StorageUriError extends SuaveplanErrorso the existingStorageUriErrorpass-through is preserved as a special case of the new rule. - MODIFIED: The
WRITE_UNSUPPORTEDre-wrap branch (forwrite()failures from the adapter) — same discriminator change.SuaveplanErrorsubclasses thrown fromadapter.setpass through with their original code; plainErrorthrows are still wrapped asSTORAGE_URI.WRITE_UNSUPPORTED. - MODIFIED:
storage-urispec delta — two requirements updated:- "Adapter error re-wrapping": broaden the pass-through rule from
StorageUriErrortoSuaveplanError; add a scenario asserting that a thrownDriveUriErrorreaches the caller with its originalcode. - "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.
- "Adapter error re-wrapping": broaden the pass-through rule from
- MODIFIED:
drive-urispec delta — five scenarios tightened to drop the.causeindirection. The two**Note —** revisited infix-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-uritestsrc/resolver/resolver.passthrough.test.ts(or extension ofresolver.test.ts) covering:- A thrown
SuaveplanError("FOO.BAR", ...)passes through unchanged. - A thrown plain
Error("disk fault")is still wrapped asSTORAGE_URI.ADAPTER_ERRORwithcause.message === "disk fault". - A thrown
SuaveplanErrorfromadapter.setpasses through (noWRITE_UNSUPPORTEDwrap).
- A thrown
- MODIFIED: Existing
drive-uritests that assert onerr.cause.code— switched to assert onerr.codedirectly. The drive-uri test suite shrinks slightly in line count (no more.causeladders) and stays at 100/100/100/100 coverage.
Out of scope
- Signal threading through
adapter.get. Separate concern, tracked under a futureadd-signal-to-storage-likechange. Touches everyStorageLikeadapter; defer until a real consumer requires cancellable buffered reads. The signal-threading note inadd-drive-uri/specs/drive-uri/spec.mdstays. - Resolver-side translation of well-known downstream codes. Tempting to map
DRIVE_MESH.SIG_VERIFY_FAILED→ someSTORAGE_URI.INTEGRITY_FAILEDumbrella 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. UriResolverLikein@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-uriplus spec tightening instorage-urianddrive-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 newit(...)blocks ("passes through thrown SuaveplanError subclasses unchanged","still wraps non-SuaveplanError throwables as ADAPTER_ERROR").src/resolver/resolver.writes.test.ts— extended with one newit(...)block ("passes through thrown SuaveplanError subclasses on write").packages/drive/drive-uri/src/plugins/**/*.test.ts—.cause-based assertions rewritten to assert onerr.codedirectly. 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 throwSuaveplanErrorsubclasses fromStorageLikemethods today; new pass-through path is a no-op for it)@suaveplan/storage-http— [ ] tests run / [ ] docs reviewed / [x] no action needed (throwsStorageHttpError extends SuaveplanError— automatically benefits from pass-through but no breaking change since old behaviour wasADAPTER_ERROR{cause:StorageHttpError}which consumers were inspecting via.causealready; spec scenario in storage-uri'sWRITE_UNSUPPORTEDrequirement 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 —SuaveplanErrorsubclasses pass through; downstream tests will be more direct but currently pass via.causeinspection, 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.SuaveplanErroris consumed as-is viainstanceof.@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).