|
| 1 | +--- |
| 2 | +"@objectstack/spec": minor |
| 3 | +"@objectstack/runtime": minor |
| 4 | +"@objectstack/client": patch |
| 5 | +--- |
| 6 | + |
| 7 | +fix(runtime,spec)!: the dispatcher's `error.code` is the semantic string it always declared; the HTTP status moves to `httpStatus` (#3842) |
| 8 | + |
| 9 | +`HttpDispatcher.error()` took the HTTP status as its `code` argument and wrote it |
| 10 | +straight into the field `ApiErrorSchema` reserves for a semantic string, so |
| 11 | +`error.code` came back as `400`/`403`/`503` — a number, duplicating the response |
| 12 | +status and occupying the one slot a caller is meant to branch on. The real code |
| 13 | +then had to go somewhere else, and did, three somewhere-elses: `details.code` |
| 14 | +(auth gate, permission denial, anonymous deny), `details.type` |
| 15 | +(project-membership gate), and `error.type` (`routeNotFound`). Four sites, three |
| 16 | +parking spots, because the declared one was full. |
| 17 | + |
| 18 | +**FROM → TO on the wire.** A dispatcher error body |
| 19 | + |
| 20 | +```json |
| 21 | +{ "success": false, |
| 22 | + "error": { "message": "…", "code": 403, "details": { "code": "PERMISSION_DENIED" } } } |
| 23 | +``` |
| 24 | + |
| 25 | +is now |
| 26 | + |
| 27 | +```json |
| 28 | +{ "success": false, |
| 29 | + "error": { "code": "PERMISSION_DENIED", "message": "…", "httpStatus": 403 } } |
| 30 | +``` |
| 31 | + |
| 32 | +| Reading | Was | Now | |
| 33 | +|---|---|---| |
| 34 | +| semantic code | `error.details.code` / `error.details.type` / `error.type` | `error.code` | |
| 35 | +| HTTP status | `error.code` | `error.httpStatus` (or the response status) | |
| 36 | +| context | `error.details` (with the code mixed in) | `error.details` (context only, absent when empty) | |
| 37 | + |
| 38 | +**One-line fix for a direct reader:** replace `body.error.details?.code ?? |
| 39 | +body.error.type` with `body.error.code`, and `body.error.code` with |
| 40 | +`body.error.httpStatus`. **SDK callers need no change** — `ObjectStackClient` |
| 41 | +already normalised this (`err.code` semantic, `err.httpStatus` numeric) and still |
| 42 | +reads the old shape, so a client newer than its server is unaffected. |
| 43 | + |
| 44 | +Every code already on the wire moves **verbatim** — `PERMISSION_DENIED`, |
| 45 | +`ROUTE_NOT_FOUND`, `PASSWORD_EXPIRED`, `PROJECT_MEMBERSHIP_REQUIRED`, |
| 46 | +`VALIDATION_FAILED`, `unauthenticated`. This change moves a field; it does not |
| 47 | +rename anything. Reconciling the repo's two code vocabularies is #3841, and this |
| 48 | +leaves it exactly one map and one enum to sweep instead of four parking spots. |
| 49 | + |
| 50 | +A branch with no code of its own is served one derived from the status, via the |
| 51 | +single declared map `HttpStatusErrorCodeMap` / `standardErrorCodeForHttpStatus` |
| 52 | +in `@objectstack/spec/api` (`403` → `permission_denied`, `503` → |
| 53 | +`service_unavailable`, …). Derivation is necessary because `ApiErrorSchema.code` |
| 54 | +is required; drawing it from `StandardErrorCode` keeps a derived code a |
| 55 | +catalogued one rather than an invented string. |
| 56 | + |
| 57 | +**Spec changes:** |
| 58 | + |
| 59 | +- `ApiErrorSchema` gains optional `httpStatus: number` — the precedent is |
| 60 | + `EnhancedApiErrorSchema.httpStatus`. Additive. |
| 61 | +- `StandardErrorCode` gains `method_not_allowed` and `precondition_required`, |
| 62 | + the two statuses the runtime returns that the enum could not name. Additive. |
| 63 | +- **Breaking — `DispatcherErrorCode`** was `'404' | '405' | '501' | '503'` (string |
| 64 | + spellings of HTTP statuses, for matching against the numeric `error.code`). It |
| 65 | + is now `'ROUTE_NOT_FOUND' | 'METHOD_NOT_ALLOWED' | 'NOT_IMPLEMENTED' | |
| 66 | + 'SERVICE_UNAVAILABLE'` — the same four members the removed `error.type` enum |
| 67 | + declared, moved verbatim. FROM `DispatcherErrorCode.parse('404')` TO |
| 68 | + `DispatcherErrorCode.parse('ROUTE_NOT_FOUND')`; to match a status, read |
| 69 | + `error.httpStatus`. TypeScript flags every call site. |
| 70 | +- **Breaking — `DispatcherErrorResponseSchema`**: `error.code` is `z.string()` |
| 71 | + (was `z.number().int()`), `error.type` is **removed** (folded into `code`), and |
| 72 | + `error.httpStatus` / `error.details` are declared. This schema is what |
| 73 | + legitimised the deviation — it declared the opposite of `ApiErrorSchema` for |
| 74 | + the same field. FROM `{ code: 404, type: 'ROUTE_NOT_FOUND' }` TO |
| 75 | + `{ code: 'ROUTE_NOT_FOUND', httpStatus: 404 }`. |
| 76 | + |
| 77 | +**Also aligned, because they are the same wire surface:** `dispatcher-plugin`'s |
| 78 | +`errorResponseBase` (the THROWN-error exit) and its inline 404, and the MCP 405. |
| 79 | +`errorResponseBase` previously discarded a thrown error's `.code` outright — it |
| 80 | +had nowhere to put it — so the two exits of one surface disagreed about what a |
| 81 | +caller would see; they now agree. Every body on this surface is built by one |
| 82 | +helper (`packages/runtime/src/error-envelope.ts`), guarded in both directions by |
| 83 | +`error-envelope.conformance.test.ts`: each branch driven and parsed against the |
| 84 | +schema imported from `packages/spec`, plus a source scan so a new branch cannot |
| 85 | +quietly reintroduce a numeric `code` or a `type`-as-code sibling. |
| 86 | + |
| 87 | +This deletes the #3687 pin in `http-dispatcher.test.ts`, which asked to be |
| 88 | +deleted rather than updated once the dispatcher was fixed. |
0 commit comments