Skip to content

Commit d5749d7

Browse files
os-zhuangclaude
andauthored
refactor(types,rest,services,plugin-sharing): one shared writer for the response envelope, and error.code is enforced at compile time (#3973) (#4229)
`BaseResponseSchema` declares one envelope for every REST body the platform emits. It declared it once; the code that *wrote* it was copied per route module. After #3843 and #3983 converted the last drifting one, seven modules each carried their own two-line `sendOk` / `sendError` pair — the envelope's shape in fourteen places rather than one. `check:route-envelope` proved those copies agreed, which is why this is a cleanup rather than a bug fix. But a guard proves agreement; it does not create it. An eighth module starts by copying the pair again — not hypothetically: `share-link-routes.ts` was found already drifting by the repo-wide scan, and its drift had broken `client.shareLinks.create()` / `.list()` (#3983). Placement was #3973's open question, not design. `packages/spec` is schemas-only (Prime Directive #2), and the callers span `rest`, four `services/*` and one `plugins/*`. `@objectstack/types` depends on nothing but `@objectstack/spec`, so every caller can reach it, and it is already where the repo puts a helper the HTTP boundaries share — `looksLikeInternalErrorLeak` (#3867) sits one file over and made the same argument first. The builders take a structural `{ status(n), json(body) }`, so the package imports no HTTP contract at all. `error.code` is now checked by the compiler. All seven copies typed it `string`, so an invented code was caught only at runtime, by a conformance suite parsing a driven body — i.e. only on routes some test happened to drive. The shared `sendError` types it as ADR-0112's closed `ErrorCode` union, so an unregistered code fails to compile at every call site at once. This cost no call-site churn: every code the seven modules emit was already registered. Nothing changes on the wire. The seven pairs were identical modulo the optional `status` and `extra` parameters this one unions, and each module's driven conformance suite still parses its real bodies against the real spec schemas. One internal call site was rewritten: `package-routes` passed `details` positionally and now passes `{ details }`, producing the same `error.details`. The guard got stronger. A module routing everything through the shared pair builds none itself, so the seven now declare `0 / 0 / 0` where they declared `2 / 1 / 1`, and the pair is pinned separately at `2 / 1 / 1` so the invariant is total for the surface rather than per-module. What the count asserts is no longer "your two builders are the enveloped ones" but "you have no builders". Two out-of-scope findings filed rather than folded in (Prime Directive #10): #4224 (settings passes four keys `ApiErrorSchema` does not declare — the reason `extra` stays `Record<string, unknown>`) and #4225 (three admin routes answer 503 naming the wrong service — carried out of #3973 so closing it does not bury the note). Co-authored-by: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com> Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
1 parent cc2de0e commit d5749d7

16 files changed

Lines changed: 611 additions & 334 deletions

File tree

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
---
2+
"@objectstack/types": minor
3+
"@objectstack/rest": patch
4+
"@objectstack/service-storage": patch
5+
"@objectstack/service-settings": patch
6+
"@objectstack/service-datasource": patch
7+
"@objectstack/service-i18n": patch
8+
"@objectstack/plugin-sharing": patch
9+
---
10+
11+
refactor(types,rest,services,plugin-sharing): one shared writer for the response envelope, and `error.code` is enforced at compile time (#3973)
12+
13+
`BaseResponseSchema` declares one envelope for every REST body the platform
14+
emits. It declared it once; the code that *wrote* it was copied per route
15+
module. After #3843 and #3983 converted the last drifting one, seven modules
16+
each carried their own two-line `sendOk` / `sendError` pair — so the envelope's
17+
shape lived in fourteen places rather than one.
18+
19+
`pnpm check:route-envelope` proved those seven copies agreed, which is why this
20+
is a cleanup rather than a bug fix. But a guard proves agreement; it does not
21+
create it. An eighth module starts by copying the pair again — not
22+
hypothetically: `share-link-routes.ts` was found already drifting by the
23+
repo-wide scan, and its drift had broken `client.shareLinks.create()` and
24+
`.list()` through `unwrapResponse` (#3983).
25+
26+
## What moved
27+
28+
`sendOk` / `sendError` now live once, in `@objectstack/types`
29+
(`response-envelope.ts`), and all seven modules import them:
30+
31+
| Module |
32+
|---|
33+
| `service-storage/storage-routes.ts` |
34+
| `service-settings/settings-routes.ts` |
35+
| `service-datasource/admin-routes.ts` |
36+
| `rest/external-datasource-routes.ts` |
37+
| `rest/package-routes.ts` |
38+
| `service-i18n/i18n-service-plugin.ts` |
39+
| `plugin-sharing/share-link-routes.ts` |
40+
41+
Placement was the open question in #3973, not design. `packages/spec` is
42+
schemas-only (Prime Directive #2), and the callers span `rest`, four
43+
`services/*` and one `plugins/*`, which rules out anything depending on them.
44+
`@objectstack/types` depends on nothing but `@objectstack/spec`, so every caller
45+
can reach it, and it is already where the repo puts a helper the HTTP boundaries
46+
share — `looksLikeInternalErrorLeak` (#3867) sits one file over and made the
47+
same argument first.
48+
49+
The builders take a structural `{ status(n), json(body) }`, so the package
50+
imports no HTTP contract at all: `IHttpResponse` satisfies it, and so does the
51+
`any`-typed `res` the older modules carry.
52+
53+
## `error.code` is now checked by the compiler
54+
55+
All seven copies typed the parameter `code: string`. ADR-0112 (#3841) closed the
56+
vocabulary — `ErrorCode` is `StandardErrorCode ∪ ERROR_CODE_LEDGER` — but an
57+
invented code was still caught only at runtime, by a conformance suite parsing a
58+
driven body, i.e. only on routes some test happened to drive.
59+
60+
The shared `sendError` types `code` as `ErrorCode`, so an unregistered code now
61+
fails to compile, at every call site at once:
62+
63+
```ts
64+
sendError(res, 400, 'NOT_A_REGISTERED_CODE', 'invented');
65+
// Argument of type '"NOT_A_REGISTERED_CODE"' is not assignable to parameter of type 'ErrorCode'.
66+
```
67+
68+
This cost no call-site churn: every code the seven modules emit was already
69+
registered.
70+
71+
## `extra` is closed at the same place
72+
73+
`sendError`'s last parameter is `Pick<ApiError, 'category' | 'httpStatus' |
74+
'details' | 'requestId'>` — exactly what `ApiErrorSchema` declares beside `code`
75+
and `message`.
76+
77+
It was `Record<string, unknown>` while `settings-routes` still hung `namespace` /
78+
`key` / `reason` / `fields` beside `code`. Those bodies passed every gate anyway:
79+
`ApiErrorSchema` is a plain `z.object`, so unknown keys were STRIPPED rather than
80+
rejected, and `envelopeViolations` inspects only the body's top level —
81+
conformant *by stripping* rather than by declaration. #4224 moved that module
82+
onto `details`, which is what lets the parameter close here. Closing it at the
83+
shared builder is the part that lasts: an undeclared sibling is now a compile
84+
error in every module at once, rather than a key that quietly evaporates in
85+
whichever module reintroduces it.
86+
87+
## Nothing changes on the wire
88+
89+
The seven pairs were identical modulo the optional `status` and `extra`
90+
parameters this one unions, and each module's driven conformance suite still
91+
parses its real bodies against the real spec schemas. One internal call site was
92+
rewritten: `package-routes` passed `details` positionally and now passes
93+
`{ details }`, producing the same `error.details` it always did.
94+
95+
## The guard got stronger
96+
97+
`scripts/check-route-envelope.mjs` counts response write sites per module. A
98+
module that routes everything through the shared pair builds **none** itself, so
99+
the seven now declare `0 / 0 / 0` where they used to declare `2 / 1 / 1`, and the
100+
shared pair is pinned separately at `2 / 1 / 1` so the invariant stays total for
101+
the surface rather than per-module. What the count asserts is no longer "your two
102+
builders are the enveloped ones" but "you have no builders" — and a new route
103+
that hand-rolls a body still moves it off zero and fails.

packages/plugins/plugin-sharing/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,8 @@
2121
"@objectstack/formula": "workspace:*",
2222
"@objectstack/objectql": "workspace:*",
2323
"@objectstack/platform-objects": "workspace:*",
24-
"@objectstack/spec": "workspace:*"
24+
"@objectstack/spec": "workspace:*",
25+
"@objectstack/types": "workspace:*"
2526
},
2627
"devDependencies": {
2728
"@types/node": "^26.1.1",

packages/plugins/plugin-sharing/src/share-link-routes.ts

Lines changed: 22 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -11,10 +11,11 @@
1111
* GET /api/v1/share-links/:token/messages → conversation rows → data: ai_messages[]
1212
*
1313
* Every body is the declared `{ success: true, data }` / `{ success: false,
14-
* error: { code, message } }` envelope, built in exactly two places — {@link sendOk}
15-
* and {@link sendError}. Read `sendOk`'s note first: the shapes above are the
16-
* dispatcher twin's, which this module converged onto in #3983, and the `success`
17-
* flag they used to omit is why two `client.shareLinks.*` methods were broken here.
14+
* error: { code, message } }` envelope, written by the shared `sendOk` /
15+
* `sendError` (`@objectstack/types`). Read the note below on why `data` carries
16+
* the payload bare: the shapes above are the dispatcher twin's, which this module
17+
* converged onto in #3983, and the `success` flag they used to omit is why two
18+
* `client.shareLinks.*` methods were broken here.
1819
*
1920
* The resolve route is intentionally public — it's the only endpoint
2021
* holders of a token need. It does:
@@ -29,7 +30,9 @@
2930
* and renders the response read-only.
3031
*/
3132

32-
import type { IHttpServer, IHttpRequest, IHttpResponse, RouteHandler } from '@objectstack/spec/contracts';
33+
import type { IHttpServer, IHttpRequest, RouteHandler } from '@objectstack/spec/contracts';
34+
// The declared envelope is written in ONE place for the whole platform (#3973).
35+
import { sendOk, sendError } from '@objectstack/types';
3336
import type { ShareLinkExecutionContext } from '@objectstack/spec/contracts';
3437
import type { ShareLinkService } from './share-link-service.js';
3538
import type { SharingEngine } from './sharing-service.js';
@@ -57,24 +60,9 @@ export interface ShareLinkRoutesOptions {
5760
const defaultContext = (_req: IHttpRequest): ShareLinkExecutionContext => ({});
5861

5962
/**
60-
* Emit an error in the DECLARED envelope — `BaseResponseSchema` +
61-
* `ApiErrorSchema` (`packages/spec/src/api/contract.zod.ts`), i.e.
62-
* `{ success: false, error: { code, message } }`.
63+
* ## Why `data` carries the payload bare on this module's five routes
6364
*
64-
* The nested `{ code, message }` was already right (#3675's changeset cited this
65-
* module as the good example of it); the `success` flag is what was missing.
66-
* See {@link sendOk} for why that flag is load-bearing rather than decorative.
67-
*/
68-
function sendError(res: IHttpResponse, status: number, code: string, message: string) {
69-
res.status(status).json({ success: false, error: { code, message } });
70-
}
71-
72-
/**
73-
* Emit a success body in the DECLARED envelope — `BaseResponseSchema`
74-
* (`packages/spec/src/api/contract.zod.ts`), i.e. `{ success: true, data }`,
75-
* with `data` carrying each route's payload DIRECTLY.
76-
*
77-
* ## This is a convergence, not a new dialect
65+
* `sendOk(res, links)`, not `sendOk(res, { links })`.
7866
*
7967
* These five routes have a twin: `runtime/src/domains/share-links.ts` serves
8068
* the same paths off the dispatcher, and for cloud's per-environment kernels
@@ -86,34 +74,22 @@ function sendError(res: IHttpResponse, status: number, code: string, message: st
8674
* routes, two shapes, decided by which surface happened to mount them — the
8775
* asymmetry #3636 fixed for `/i18n`, one domain over.
8876
*
89-
* ## What that asymmetry actually broke
90-
*
91-
* Three of these routes are `disposition: 'sdk'` in `runtime/src/route-ledger.ts`
92-
* (`shareLinks.create` / `.list` / `.revoke`), and `ObjectStackClient.unwrapResponse`
93-
* keys on a boolean `success`. With no flag it returns the body verbatim, so
94-
* against THIS surface:
95-
*
96-
* • `shareLinks.create()` — documented "Returns the link row (incl. `token`)"
97-
* — handed back `{ link: … }`, making `.token` `undefined`.
98-
* • `shareLinks.list()` — typed `Promise<any[]>` — handed back `{ links: [] }`,
99-
* so any `.map()` on it threw.
100-
*
77+
* That was not cosmetics. Three of these routes are `disposition: 'sdk'` in
78+
* `runtime/src/route-ledger.ts` (`shareLinks.create` / `.list` / `.revoke`), and
79+
* `ObjectStackClient.unwrapResponse` keys on a boolean `success`. With no flag
80+
* it returns the body verbatim, so against THIS surface `shareLinks.create()`
81+
* handed back `{ link: … }` (making the documented `.token` `undefined`) and
82+
* `shareLinks.list()` handed back `{ links: [] }`, so any `.map()` on it threw.
10183
* `packages/client/src/admin-surfaces.test.ts` mocks all three as
10284
* `{ success: true, data: <payload> }`: the SDK was written and tested against
103-
* the dispatcher's shape. So this is not envelope cosmetics — it is why two SDK
104-
* methods did not work on the plugin surface at all.
85+
* the dispatcher's shape (#3983).
10586
*
106-
* ## Why `data` carries the payload bare
107-
*
108-
* `sendOk(res, links)`, not `sendOk(res, { links })`. That is what makes
109-
* `unwrapResponse` return the same value on both surfaces, and it is what the
110-
* consumers already read (`body.links ?? body.data`, `created.link ?? created.data`,
111-
* `body?.data ?? []`) — they carry that tolerance precisely BECAUSE both shapes
112-
* exist in the fleet. Prime Directive #12: the shim goes once the producer agrees.
87+
* So passing the payload bare is what makes `unwrapResponse` return the same
88+
* value on both surfaces, and it is what the consumers already read
89+
* (`body.links ?? body.data`, `created.link ?? created.data`, `body?.data ?? []`)
90+
* — they carry that tolerance precisely BECAUSE both shapes existed in the
91+
* fleet. Prime Directive #12: the shim goes once the producer agrees.
11392
*/
114-
function sendOk(res: IHttpResponse, data: unknown, status = 200): void {
115-
res.status(status).json({ success: true, data });
116-
}
11793

11894
/** Strip `redactFields` from a record (also removes from nested arrays of objects). */
11995
function applyRedaction(record: any, redactFields: string[]): any {

packages/rest/src/external-datasource-routes.ts

Lines changed: 21 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22

33
import type { PluginContext } from '@objectstack/core';
44
import type { IHttpServer } from '@objectstack/spec/contracts';
5+
// The declared envelope is written in ONE place for the whole platform (#3973).
6+
import { sendOk, sendError } from '@objectstack/types';
57

68
/**
79
* External Datasource Federation REST routes (ADR-0015 §6.2).
@@ -22,8 +24,25 @@ import type { IHttpServer } from '@objectstack/spec/contracts';
2224
* into the private `@objectstack/datasource-admin` package, which registers
2325
* them via its own `registerDatasourceAdminRoutes`.
2426
*
25-
* Every body is built by `sendOk` / `sendError` below, in the envelope
26-
* `BaseResponseSchema` declares (#3843).
27+
* Every body is built by the shared `sendOk` / `sendError`, in the envelope
28+
* `BaseResponseSchema` declares (#3843, consolidated in #3973). Before #3843
29+
* this module emitted the pre-#3675 `{ error: '<string>' }`, with the message a
30+
* SIBLING of `error` on the import path — so a caller reading
31+
* `body.error.message` got `undefined`.
32+
*
33+
* Codes follow ADR-0112 (#3841, settled while #3843 was in review):
34+
* `external_service_unavailable` → the standard `SERVICE_UNAVAILABLE`, since the
35+
* ledger asks generic conditions to reuse the catalog rather than register a
36+
* per-service synonym; `external_import_error` → `EXTERNAL_IMPORT_ERROR`,
37+
* registered under this package because a refused federated import is specific
38+
* to it.
39+
*
40+
* Note `POST /validate` keeps its `ok` — unlike the `{ ok: true, key }` #3689
41+
* retired from storage, that one was a private second word for `success`, while
42+
* this `ok` is a COMPUTED verdict over the federated objects
43+
* (`results.every(r => r.ok)`). A domain verdict that happens to share the name
44+
* is not a second envelope flag, so it belongs inside `data` rather than being
45+
* dropped.
2746
*/
2847
export function registerExternalDatasourceRoutes(
2948
server: IHttpServer,
@@ -40,38 +59,6 @@ export function registerExternalDatasourceRoutes(
4059
}
4160
};
4261

43-
/**
44-
* Emit an error in the DECLARED envelope — `BaseResponseSchema` +
45-
* `ApiErrorSchema` (`packages/spec/src/api/contract.zod.ts`), i.e.
46-
* `{ success: false, error: { code, message } }`.
47-
*
48-
* Before #3843 this module emitted the pre-#3675 `{ error: '<string>' }`,
49-
* with the message a SIBLING of `error` on the import path — so a caller
50-
* reading `body.error.message` got `undefined`.
51-
*
52-
* Codes follow ADR-0112 (#3841, settled while this was in review):
53-
* `external_service_unavailable` → the standard `SERVICE_UNAVAILABLE`, since the
54-
* ledger asks generic conditions to reuse the catalog rather than register a
55-
* per-service synonym; `external_import_error` → `EXTERNAL_IMPORT_ERROR`,
56-
* registered under this package because a refused federated import is specific
57-
* to it.
58-
*/
59-
const sendError = (res: any, status: number, code: string, message: string) =>
60-
res.status(status).json({ success: false, error: { code, message } });
61-
62-
/**
63-
* Emit a success body in the DECLARED envelope — `{ success: true, data }`.
64-
*
65-
* Payload keys are unchanged, one level deeper. Note `POST /validate` keeps
66-
* its `ok` — unlike the `{ ok: true, key }` #3689 retired from storage, that
67-
* one was a private second word for `success`, while this `ok` is a COMPUTED
68-
* verdict over the federated objects (`results.every(r => r.ok)`). A domain
69-
* verdict that happens to share the name is not a second envelope flag, so it
70-
* belongs inside `data` rather than being dropped.
71-
*/
72-
const sendOk = (res: any, data: unknown, status = 200) =>
73-
res.status(status).json({ success: true, data });
74-
7562
const unavailable = (res: any) =>
7663
sendError(res, 503, 'SERVICE_UNAVAILABLE', 'The external-datasource service is not available.');
7764

packages/rest/src/package-routes.ts

Lines changed: 10 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22

33
import { IHttpServer } from '@objectstack/core';
44
import type { PackageService } from '@objectstack/service-package';
5+
// The declared envelope is written in ONE place for the whole platform (#3973).
6+
import { sendOk, sendError } from '@objectstack/types';
57

68
/**
79
* Options for package route registration.
@@ -44,13 +46,10 @@ export interface PackageRoutesOptions {
4446
* swallowed every `client.packages.install` call with a 400. The
4547
* dispatcher's own `POST /packages/:id/publish` (ADR-0033 draft publish)
4648
* is two segments — different shape, no clash.
47-
*/
48-
/**
49-
* Emit an error in the DECLARED envelope — `BaseResponseSchema` +
50-
* `ApiErrorSchema` (`packages/spec/src/api/contract.zod.ts`), i.e.
51-
* `{ success: false, error: { code, message } }`.
5249
*
53-
* This module was the *partially* converted one when #3843 was filed, which is
50+
* ## Where this module's error codes came from
51+
*
52+
* This was the *partially* converted module when #3843 was filed, which is
5453
* arguably worse than untouched: 3 of its 16 bodies carried `success: true`, so
5554
* the same registrar answered two shapes depending on which route you hit. Its
5655
* error bodies were the pre-#3675 `{ error: '<string>' }` throughout — and the
@@ -63,36 +62,17 @@ export interface PackageRoutesOptions {
6362
*
6463
* Because there were no codes here to preserve, these had to be MINTED. They
6564
* follow ADR-0112 (#3841, settled while this was in review): SCREAMING_SNAKE, and
66-
* registered in `ERROR_CODE_LEDGER` under `@objectstack/rest` — an unregistered
67-
* code fails `ApiErrorSchema` parse, which fails the conformance suite.
65+
* registered in `ERROR_CODE_LEDGER` under `@objectstack/rest`. That union is now
66+
* the `code` parameter's TYPE — `sendError` takes `ErrorCode`, not `string`
67+
* (#3973) — so an unregistered code fails to compile rather than waiting for a
68+
* conformance suite to parse a driven body.
6869
*
6970
* Generic conditions reuse the STANDARD catalog rather than becoming registered
7071
* synonyms of it: a missing request field is `MISSING_REQUIRED_FIELD`, an absent
7172
* package is `RESOURCE_NOT_FOUND`, an unexpected throw is `INTERNAL_ERROR`. Only
7273
* the package-specific outcomes are registered — `PACKAGE_MANIFEST_INVALID`,
7374
* `PACKAGE_PUBLISH_FAILED`, `PACKAGE_DELETE_PARTIAL`, `PACKAGE_DELETE_FAILED`.
7475
*/
75-
function sendError(res: any, status: number, code: string, message: string, details?: unknown) {
76-
res.status(status).json({
77-
success: false,
78-
error: { code, message, ...(details === undefined ? {} : { details }) },
79-
});
80-
}
81-
82-
/**
83-
* Emit a success body in the DECLARED envelope — `{ success: true, data }`.
84-
*
85-
* The three bodies that already carried `success: true` kept their payload as
86-
* SIBLINGS of the flag (`{ success: true, message, package }`); those move under
87-
* `data` so the envelope has one payload slot rather than a spread. `packages`
88-
* SDK methods read these through `unwrapResponse`, which returns `body.data`
89-
* when the flag is present, so `packages.list()` still resolves to
90-
* `{ packages, total }`.
91-
*/
92-
function sendOk(res: any, data: unknown, status = 200) {
93-
res.status(status).json({ success: true, data });
94-
}
95-
9676
export function registerPackageRoutes(
9777
server: IHttpServer,
9878
packageService: PackageService,
@@ -257,7 +237,7 @@ export function registerPackageRoutes(
257237
400,
258238
'PACKAGE_DELETE_PARTIAL',
259239
`Deleting ${packageId} left ${result.failedCount} item(s) behind.`,
260-
{ failed: result.failed, cleanups: result.cleanups },
240+
{ details: { failed: result.failed, cleanups: result.cleanups } },
261241
);
262242
return;
263243
}

0 commit comments

Comments
 (0)