fix(rest): a declared 5xx status survives mapDataError on the CRUD data routes - #7402
Conversation
…ta routes `mapDataError`'s explicit-status passthrough accepted 4xx only, while `resolveErrorResponse` — the door every metadata/UI/discovery/batch route reports through — accepts 400-599. One declared error therefore got two answers depending on which door caught it, and on the ~11 direct data-route call sites the declaration lost: the status was re-derived from the message TEXT, or fell out of `UNCLASSIFIED_FAULT` as `500 INTERNAL_ERROR`. Live since #5907: driver-sql / driver-turso throw `status: 501` / `code: NOT_IMPLEMENTED` for a spec-declared aggregate function the backend cannot compile, and the caller was told the server had fallen over instead. The gate is now 400-599 on both doors, with #5437's ruled disposition for a declared server fault — keep the status, keep the `code`, drop the prose. The `code` half reads `declaresServerFault` (`@objectstack/types`, PR #6122) so an empty or non-string code is not mistaken for an ADR-0112 declaration, and nothing is invented when the producer named no code. Fixes #5582 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0158ZQo7LiHSxGWpYKuPq1wu
…not 8) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0158ZQo7LiHSxGWpYKuPq1wu
|
The latest updates on your projects. Learn more about Vercel for GitHub. 1 Skipped Deployment
|
📓 Docs Drift CheckThis PR changes 1 package(s): 9 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:
⛔ 3 release-owned page(s) also reference the affected code. These are read-only:
|
CI's `check:type-check-debt` re-measures @objectstack/rest with the test exclusion lifted, which the package's own `pnpm typecheck` cannot see: 155 recorded, 157 measured (+2), both from the new file — a missing `.js` import extension under nodenext resolution (TS2835) and an implicitly-any spy callback (TS7006). Fixed rather than ratcheted; the layer measures 155 again. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0158ZQo7LiHSxGWpYKuPq1wu
Fixes #5582
rest-server.tshad two error doors giving opposite answers to one question — "the producer declared astatus":resolveErrorResponse(sendError/handleRouteError)mapDataErrorSo on the data routes a declared 5xx lost its declaration twice over: the passthrough skipped it, and the status was then re-derived from the message text by the sanitizing heuristics — or, matching none of them, fell out of
UNCLASSIFIED_FAULTas500 INTERNAL_ERROR.What changed
mapDataError's gate is now 400-599, the same doorresolveErrorResponseopens, with the disposition #5437 / PR #5464 already ruled for a declared server fault: keep the status, keep the machine-readablecode, drop the prose. The issue's own words for the fix, verbatim:The 4xx arm is untouched — wording truncated rather than erased (#5423),
objectretained.The live producer (#5907)
driver-sql/driver-tursothrowstatus: 501+code: NOT_IMPLEMENTEDfor an aggregate function@objectstack/specdeclares and the backend cannot compile (count_distinct/array_agg/string_agg). Those names clearmetadata-protocol's shape gate, so the throw reaches a CRUD data route. Before:500 INTERNAL_ERROR— the ADR-0112 code overwritten, not just the status, so the caller read "the server fell over" instead of "this backend does not implement that declared capability". After:501 NOT_IMPLEMENTED.Criterion spelling
The
codehalf readsdeclaresServerFaultfrom@objectstack/types(PR #6122, pinned byerror-leak.test.ts, already read by the analytics route here and byruntime's dispatcher) rather than a fourth open-coded truthiness check. Inside astatus >= 500branch what it contributes is its second half — a non-empty stringcode— which is what keeps an empty string or a numeric drivererrnooff the wire dressed as an ADR-0112 code.Deviation worth a reviewer's eye. The dispatch asked for
declaresServerFaultas the 5xx passthrough criterion. Gating the status on it would have left the defect alive for a producer that declares a status and no code:declaresServerFaultis false there, so such an error would still have its declared 502 re-derived from message text — the exact shape #5437 ruled against one door over — and the two doors would still disagree, just more narrowly. So the predicate gates thecodeand the band gates the status. A no-code 5xx now passes its status through carrying no code at all, which is the answerresolveErrorResponsealready gives that shape and justifies as "Nocodewas declared, so none is invented here — ADR-0112 says the PRODUCER names the condition" (rest-5xx-message-sanitization.test.ts). Full parity, and nothing invented.Tests
packages/rest/src/rest-5xx-status-passthrough.test.ts— 28 cases: the drivers(sql,turso):Unsupported aggregate function两面都是裸Error(code/status 皆 undefined),且三个 spec 已声明的聚合函数无任何 SQL 后端编译 #5907 501 acceptance case, the issue body's 502 with host:port, a 503, the whole band 500-599, the half-declaration controls (no code / empty code / non-string code), 9 sanitization cases, non-regression on the 4xx half and on the branches above the passthrough, and three walks through a realGET /api/v1/data/:objectin process.rest.test.ts"does NOT pass through an explicit 5xx status" — split, because both halves of its title were separately ruled on: the status half was the defect, the message half was never in question and is asserted unchanged.rest-4xx-message-truncation.test.ts"5xx never enters this branch at all" — the file's subject survives (truncation is a 4xx disposition only), so it now pins that a 5xx is dropped whole: no prefix, no ellipsis. Both files keep a no-code case.logUnexpectedRouteErrorcallslogWithheldServerFault(sendError 的显式状态直通覆盖 400–599,5xx 的原始驱动报错绕过全部泄漏启发式直达客户端(metadata-protocol 有活体产出方) #5437) on exactly this path, asserted in the route walk.Local:
@objectstack/rest80 files / 1308 tests pass;typecheckclean; eslint clean on the changed files;check:nul-bytes,check:error-code-casing,check:route-envelope,check:empty-changesetgreen.CI caught one thing the local run structurally could not, which is worth recording:
check:type-check-debtre-measures this package with the test exclusion lifted, and the package's ownpnpm typecheckreads a tsconfig that excludes*.test.ts— so the ledger went 155 to 157 (+2) while local typecheck was clean. Both were in the new file (a missing.jsimport extension under nodenext, TS2835; an implicitly-any spy callback, TS7006), fixed rather than ratcheted; the layer measures 155 again and both gate jobs are green.Reverse verification — predicted before running, one prediction wrong
Variant: restore the 4xx-only gate, keep the new tests.
#5907's 501 NOT_IMPLEMENTED survives instead of degrading to 500 INTERNAL_ERRORfails by nameThe §3 prediction was wrong and is recorded as measured rather than rewritten to fit. It was right about what those cases defend and wrong about which assertion carries it: the containment half (
not.toContain(message)) is genuinely direction-insensitive and stayed green in all 9 — the pre-fix terminal branch sanitised too (#5489) — while the envelope half moves, because the old heuristics answered some of those inputs with a different sanitised envelope (raw SQL and driver dumps becameDATA_STORE_FAULT'sDATABASE_ERROR; a unique-constraint payload became409 UNIQUE_VIOLATION).That is worth knowing rather than papering over: for a producer that declared a 5xx, this branch now takes precedence over those text classifiers. Intended — a declaration outranks a keyword guess about the same error, and
resolveErrorResponse's door is likewise the first one — and no live producer declares a 5xx with unique-violation or missing-relation text. Undeclared errors are untouched, which the revert made worth pinning explicitly: a new case walks all three dialects' unique violations (SQLite / Postgres / MySQL, driver code and nostatus) and asserts they still answer409 UNIQUE_VIOLATION(#6250).The fix was taken out with
git checkout origin/main -- packages/rest/src/rest-server.tsand restored from a saved patch; the restored file is byte-identical (md5sum -cOK).Follow-up, deliberately not in this PR
packages/drivers/driver-sql/src/sql-driver.ts'suncompilableAggregateFunctionErrordocblock records the measured consequence this PR removes — "on the/dataroutesmapDataError's generic status passthrough is 4xx-ONLY, so this declared 501 does not survive to the wire" — and points at #5582. It is now stale in the reader's favour only if they follow the pointer. Left untouched because the dispatch scoped drivers out; a comment-only correction is a three-line follow-up.Generated by Claude Code