Skip to content

Commit 7bf5349

Browse files
authored
fix(service-datasource): the datasource-admin 503 names the service the route actually needs (#4225) (#4234)
Six of the nine service-backed routes in admin-routes.ts resolve `datasource-admin`; three resolve `external-datasource` and still answered "The datasource-admin service is not available." An operator whose federation service was unwired was sent to a service that was running fine. The lookup and the message now come from one argument: the two resolvers collapse into `resolve(res, service, method)`, which answers the 503 itself naming whatever service it failed to resolve. The per-route capability check is preserved. Wire-visible on those three routes only — `error.message` now matches the string packages/rest/src/external-datasource-routes.ts already emits. Status and `error.code` unchanged on all nine. All nine 503s are pinned to the service they name, driven against a context that resolves services per name (the old mock answered the same object for every lookup, which is why nothing could see this). Closes #4225
1 parent 469b3d4 commit 7bf5349

5 files changed

Lines changed: 240 additions & 47 deletions

File tree

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
---
2+
"@objectstack/service-datasource": patch
3+
---
4+
5+
fix(service-datasource): the datasource-admin 503 names the service the route actually needs (#4225)
6+
7+
`admin-routes.ts` registered nine service-backed routes behind one hard-coded 503:
8+
9+
```ts
10+
const unavailable = (res) =>
11+
sendError(res, 503, 'SERVICE_UNAVAILABLE', 'The datasource-admin service is not available.');
12+
```
13+
14+
Six of those routes resolve `datasource-admin`, so the message was right. Three
15+
resolve `external-datasource``GET /:name/remote-tables`, `POST /:name/test`,
16+
`POST /:name/object-draft` — and answered with the same sentence. An operator
17+
whose federation service was unwired was told to go look at `datasource-admin`,
18+
which was running fine.
19+
20+
The code was never the bug. `SERVICE_UNAVAILABLE` is correct for all nine:
21+
ADR-0112's ledger asks generic conditions to reuse the standard catalog rather
22+
than register a per-service 503 synonym, and this module documents that decision
23+
inline. Which service is down is carried by `message`, exactly as intended — the
24+
`message` was simply wrong on three routes.
25+
26+
Rather than parameterise the 503 helper and leave the name typed out a second
27+
time at each call site, the lookup and the message now come from one argument.
28+
The two `adminService()` / `externalService()` resolvers collapse into a single
29+
`resolve(res, service, method)` that answers the 503 itself, naming whatever
30+
service it just failed to resolve:
31+
32+
```ts
33+
const svc = resolve(res, 'external-datasource', 'listRemoteTables');
34+
if (!svc) return;
35+
```
36+
37+
Fixing the three messages needed only the parameter; taking the name from the
38+
lookup is what stops a tenth route reintroducing the mismatch. The per-route
39+
capability check is preserved — a host may wire a partial implementation, so
40+
"the service is registered" and "this route can use it" stay separate facts.
41+
42+
Wire-visible change, on those three routes only: the 503 body's `error.message`
43+
now reads `The external-datasource service is not available.` — the same string
44+
`packages/rest/src/external-datasource-routes.ts` already emits for its own
45+
surface. Status and `error.code` are unchanged on all nine.
46+
47+
Each of the nine 503s is now pinned to the service it names, driven through the
48+
real `HonoHttpServer` against a context that resolves services **per name**. The
49+
mock every existing test used answers the same object for every lookup, which is
50+
why nothing could see this: it cannot tell the two services apart. One case
51+
covers the operator's actual situation — `datasource-admin` wired and answering
52+
200s, `external-datasource` absent — including `POST /:name/test`, where the
53+
wired admin service has a `testConnection` of its own and must not answer for the
54+
external route.
55+
56+
Pre-existing: #3843 carried every code string over verbatim and #3973 changed no
57+
bytes on the wire.

packages/services/service-datasource/README.md

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,10 @@ The runtime admin owns only the `origin: 'runtime'` lifecycle.
3737

3838
Mounted under `/api/v1/datasources` by `registerDatasourceAdminRoutes` (lifecycle
3939
+ introspection) and the federation routes by the external service. Every route
40-
degrades gracefully (`503` / "unavailable") when its service isn't wired.
40+
degrades gracefully (`503` / "unavailable") when its service isn't wired, and the
41+
message names **that** service — one registrar, but two services behind it: the
42+
three routes marked below dispatch to `external-datasource`, the rest to
43+
`datasource-admin` (#4225).
4144

4245
**Lifecycle & connection**
4346
- `GET /datasources` — list (code + runtime, with provenance/health)
@@ -47,9 +50,9 @@ degrades gracefully (`503` / "unavailable") when its service isn't wired.
4750
- `PATCH /datasources/:name` — update a runtime datasource
4851
- `DELETE /datasources/:name` — remove a runtime datasource (blocked while objects are bound)
4952
- `POST /datasources/test` — probe an unsaved draft (inline body)
50-
- `POST /datasources/:name/test` — probe a **saved** datasource by name (backs the `test_connection` action)
53+
- `POST /datasources/:name/test` — probe a **saved** datasource by name (backs the `test_connection` action)*`external-datasource`*
5154

52-
**Introspection / sync (read-only)**
55+
**Introspection / sync (read-only)** — all on `external-datasource`
5356
- `GET /datasources/:name/remote-tables` — list remote tables
5457
- `POST /datasources/:name/object-draft` — generate an object definition draft for one table (no persistence)
5558
- federation import/validate/refresh routes under `/datasources/:name/external/*` (ADR-0015)

packages/services/service-datasource/src/__tests__/admin-routes.test.ts

Lines changed: 91 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,22 @@ function mount(svc: unknown) {
2626
return server.getRawApp();
2727
}
2828

29+
/**
30+
* Mount with a service PER NAME, unlike `mount` above, which answers the same
31+
* object for every lookup.
32+
*
33+
* That difference is the point: this module dispatches to two services, and a
34+
* context that cannot tell them apart cannot show which one a route resolved.
35+
* It is what let #4225 sit here — the 503 named `datasource-admin` on all nine
36+
* routes, three of which resolve `external-datasource`, and no test could see it.
37+
*/
38+
function mountServices(services: Record<string, unknown>) {
39+
const server = new HonoHttpServer(0);
40+
const ctx = { getService: vi.fn((name: string) => services[name]) } as any;
41+
registerDatasourceAdminRoutes(server, ctx, '/api/v1');
42+
return server.getRawApp();
43+
}
44+
2945
describe('registerDatasourceAdminRoutes (real HonoHttpServer)', () => {
3046
it('GET /api/v1/datasources returns the service listing', async () => {
3147
const listDatasources = vi.fn().mockResolvedValue([
@@ -136,10 +152,19 @@ describe('registerDatasourceAdminRoutes (real HonoHttpServer)', () => {
136152
expect(await res.json()).toEqual({ success: true, data: { datasource: { name: 'pg', origin: 'runtime' } } });
137153
});
138154

139-
it('degrades to 503 when the datasource-admin service is not wired', async () => {
140-
const app = mount(undefined);
155+
it('degrades to 503 when the service registry THROWS, not just when it answers undefined', async () => {
156+
// The other arm of the resolver's try/catch. `getService` throwing on an
157+
// unregistered name is the shape a real `PluginContext` has; every mock in
158+
// this file returns `undefined` instead, so nothing else drives this branch.
159+
const server = new HonoHttpServer(0);
160+
const ctx = {
161+
getService: vi.fn(() => {
162+
throw new Error('service "datasource-admin" is not registered');
163+
}),
164+
} as any;
165+
registerDatasourceAdminRoutes(server, ctx, '/api/v1');
141166

142-
const res = await app.fetch(json('/api/v1/datasources'));
167+
const res = await server.getRawApp().fetch(json('/api/v1/datasources'));
143168

144169
expect(res.status).toBe(503);
145170
expect(await res.json()).toEqual({
@@ -151,6 +176,69 @@ describe('registerDatasourceAdminRoutes (real HonoHttpServer)', () => {
151176
});
152177
});
153178

179+
/**
180+
* #4225 — the 503 names the service the route ACTUALLY resolves.
181+
*
182+
* Every route below is listed with the service it dispatches to, so the table
183+
* is the module's service map as well as its test: a new route that resolves
184+
* one service and reports the other has to disagree with a row here.
185+
*/
186+
const UNAVAILABLE: Array<{ route: string; service: string; run: (app: any) => Promise<Response> }> = [
187+
{ route: 'GET /datasources', service: 'datasource-admin', run: (a) => a.fetch(json('/api/v1/datasources')) },
188+
{ route: 'GET /datasources/:name', service: 'datasource-admin', run: (a) => a.fetch(json('/api/v1/datasources/pg')) },
189+
{ route: 'POST /datasources/test', service: 'datasource-admin', run: (a) => a.fetch(json('/api/v1/datasources/test', { method: 'POST', body: '{}' })) },
190+
{ route: 'POST /datasources', service: 'datasource-admin', run: (a) => a.fetch(json('/api/v1/datasources', { method: 'POST', body: '{}' })) },
191+
{ route: 'PATCH /datasources/:name', service: 'datasource-admin', run: (a) => a.fetch(json('/api/v1/datasources/pg', { method: 'PATCH', body: '{}' })) },
192+
{ route: 'DELETE /datasources/:name', service: 'datasource-admin', run: (a) => a.fetch(json('/api/v1/datasources/pg', { method: 'DELETE' })) },
193+
{ route: 'GET /datasources/:name/remote-tables', service: 'external-datasource', run: (a) => a.fetch(json('/api/v1/datasources/ext/remote-tables')) },
194+
{ route: 'POST /datasources/:name/test', service: 'external-datasource', run: (a) => a.fetch(json('/api/v1/datasources/ext/test', { method: 'POST', body: '{}' })) },
195+
{ route: 'POST /datasources/:name/object-draft', service: 'external-datasource', run: (a) => a.fetch(json('/api/v1/datasources/ext/object-draft', { method: 'POST', body: JSON.stringify({ table: 'customers' }) })) },
196+
];
197+
198+
for (const c of UNAVAILABLE) {
199+
it(`${c.route} degrades to 503 naming ${c.service} (#4225)`, async () => {
200+
const res = await c.run(mountServices({}));
201+
expect(res.status).toBe(503);
202+
expect(await res.json()).toEqual({
203+
success: false,
204+
error: {
205+
code: 'SERVICE_UNAVAILABLE',
206+
message: `The ${c.service} service is not available.`,
207+
},
208+
});
209+
});
210+
}
211+
212+
it('a wired datasource-admin does not answer for an unwired external-datasource (#4225)', async () => {
213+
// The operator's actual situation: lifecycle works, federation does not. The
214+
// old message sent them to read the logs of the service that was running.
215+
const app = mountServices({
216+
'datasource-admin': {
217+
listDatasources: async () => [{ name: 'ext', origin: 'runtime' }],
218+
getDatasource: async () => ({ name: 'ext', driver: 'sqlite' }),
219+
testConnection: async () => ({ ok: true }),
220+
},
221+
// 'external-datasource' deliberately absent.
222+
});
223+
224+
expect((await app.fetch(json('/api/v1/datasources'))).status).toBe(200);
225+
226+
const remote = await app.fetch(json('/api/v1/datasources/ext/remote-tables'));
227+
expect(remote.status).toBe(503);
228+
expect(((await remote.json()) as any).error.message).toBe(
229+
'The external-datasource service is not available.',
230+
);
231+
232+
// `POST /:name/test` resolves `external-datasource` even though its unsaved-draft
233+
// sibling `POST /test` resolves `datasource-admin` — the wired admin service
234+
// above has a `testConnection`, and it must not answer for this route.
235+
const saved = await app.fetch(json('/api/v1/datasources/ext/test', { method: 'POST', body: '{}' }));
236+
expect(saved.status).toBe(503);
237+
expect(((await saved.json()) as any).error.message).toBe(
238+
'The external-datasource service is not available.',
239+
);
240+
});
241+
154242
it('surfaces lifecycle errors as 400 with the service message', async () => {
155243
const createDatasource = vi.fn().mockRejectedValue(new Error('duplicate name'));
156244
const app = mount({ createDatasource });

packages/services/service-datasource/src/__tests__/envelope.conformance.test.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -172,6 +172,15 @@ describe('datasource-admin envelope (#3843) — error bodies', () => {
172172
code: 'SERVICE_UNAVAILABLE',
173173
run: () => drive(mount(undefined), '/api/v1/datasources'),
174174
},
175+
{
176+
// The same 503, from the three routes served by the OTHER service (#4225).
177+
// Which service is named is asserted in `admin-routes.test.ts`; what this
178+
// row adds is that the branch emits the declared envelope, like its twin.
179+
name: 'the external-datasource service is not wired',
180+
status: 503,
181+
code: 'SERVICE_UNAVAILABLE',
182+
run: () => drive(mount(undefined), '/api/v1/datasources/ext/remote-tables'),
183+
},
175184
{
176185
name: 'a lifecycle failure carries the service message',
177186
status: 400,

0 commit comments

Comments
 (0)