Skip to content

Commit e270dab

Browse files
fix(service-datasource)!: POST /datasources/:name/test answers an unknown name 404 RESOURCE_NOT_FOUND (#27574)
domain:server#1 · os-sales · session_01L6bhgXcN9HaZYSEUdJWEjw <!-- claude-projects-attribution: {"github_login":"os-sales"} --> _Requested by **Sales** · [project thread](https://claude.ai/code/project/chan_01D5bWFY3rFzgJcytD5gD4Uu?thread=cmsg_01D5bWFY3rFzgJcytD5gD4UuVpEmthvkRbuHGjC6oSQZvd)_ Fixes #27510 Clause-②: no (narrowing) — `POST /api/v1/datasources/:name/test` and the in-process `ExternalDatasourceService.testConnection(name)` refuse a name that resolves no saved datasource with `404 RESOURCE_NOT_FOUND`, which they answered `200 { ok: false }` before. Nothing is widened. Metadata-protocol: none Skills-docs: none — no content/docs page or skills/** file states what `POST /api/v1/datasources/:name/test` answers (grep for the route, `test_connection` and `testConnection` over content/docs and skills/ found none; data-modeling/external-datasources.mdx names only `GET /api/v1/datasources`); the `RESOURCE_NOT_FOUND` example in protocol/kernel/error-handling.mdx is `GET /api/v1/datasources/:name`, whose answer this door now matches. ## 维护者速读 - **改了什么**:`POST /api/v1/datasources/NAME/test` 遇到不存在的数据源名,现在返回 `404 RESOURCE_NOT_FOUND`(与 `GET /datasources/NAME` 同码同文案),不再返回 `200 {ok:false}`。 - **为什么改**:原来"数据源不存在"和"数据源连不上"返回一模一样,管理工具和脚本无法区分;其它按名字访问的接口早已统一为 404。 - **风险与回滚**:风险低,只影响查无此名的请求;已存在但连不上的数据源仍是 `200 {ok:false}`,主数据源 `default` 即使没有存储记录也照常测试。回滚即 revert 本 PR。 - **先看**:① `external-datasource-service.ts` 的 `testConnection` 开头新增的存在性检查;② 新测试 `saved-datasource-test-unknown-name.test.ts`;③ 两个旧测试里补上的数据源记录。 - **两分钟验证**:`pnpm --filter @objectstack/service-datasource exec vitest run src/__tests__/saved-datasource-test-unknown-name.test.ts`,预期 5 个用例全绿。 - **需要你做的**:无,席位落地。 ## What changes - `ExternalDatasourceService.testConnection(name)` first resolves the saved record through `getDatasource`. This is the same stored-record read that the sibling by-name doors make: both plugins wire it to `metadata.get('datasource', name)`. When nothing is stored under that name, it throws a `404` / `RESOURCE_NOT_FOUND` error in the words `GET /datasources/NAME` uses. The admin route's existing relay in `badRequest` answers that error as `404`, so the route handler is unchanged. - The primary datasource (`default`) is not looked up. It may have no stored record, and its live driver answers for it, as before. - Every other outcome is unchanged. A saved datasource that fails its probe still gets `200 { ok: false, error }`, and one that is up still gets `200 { ok: true, … }`. - Two docblocks in `admin-routes.ts` now list the Test door among the by-name doors that answer `404`. - Changeset: `@objectstack/service-datasource` patch. ## Tests - **New: `src/__tests__/saved-datasource-test-unknown-name.test.ts`.** It runs through the real HTTP door over the real composition: `ObjectQL`, the connection service and driver factory, the admin service, the `ExternalDatasourceServicePlugin` wiring, and the admin routes on Hono. It has 5 cases: - An unknown name answers `404 RESOURCE_NOT_FOUND`, names the datasource and has no `data`. - That answer has the same status, code and message as `GET /datasources/NAME` for the same name. - Control: a saved datasource with no live driver keeps `200 { ok: false, error }`. - Control: a saved datasource that is up keeps `200 { ok: true }`. - Control: the primary datasource with **no stored record** is still tested through its live driver and answers `200 { ok: true, latencyMs }`. - **Red first.** The new file ran at base `d4ff1a025f` before the fix and gave `Tests 2 failed | 3 passed (5)`. The two failures were the unknown-name cases, which received `status: 200, success: true`. - **Fixture triage (two existing files).** Five existing pins tested "a saved datasource with no live driver" under a name that had no stored record. Two of them did this over HTTP (`/ghost/test`), so they pinned this defect directly. Their assertions are unchanged. Each fixture now stores that datasource's record: - `primary-datasource-test-and-sync.test.ts`: `ghost` was added to `RECORDS`, and the health-check unit's `getDatasource` returns a record. - `saved-datasource-test-after-failed-connect.test.ts`: `ghost` is seeded into the records map, and the verdict units' `getDatasource` returns a record. - **Package suite on the final head `1ae90cf405`.** `pnpm --filter @objectstack/service-datasource test` gave `Test Files 59 passed (59)` and `Tests 989 passed (989)`. `typecheck` (`tsc --noEmit`, which covers the three test files: `--listFiles` counts 3/3) exited 0. `eslint --no-inline-config packages/services/service-datasource` exited 0. <details><summary>Ablation (scripts/ablation-replace.mjs, wrap mode)</summary> The subject is imported from `src` by a relative import, so no `dist` is involved. - A1 replaced the existence guard with `if (false ...)`. The new file went red with `2 failed | 3 passed (5)`: both unknown-name cases failed. The file was restored with blob `4750f4f6e7fb` equal to HEAD, and `git diff HEAD` was empty. - A2 dropped the primary-datasource exemption. The new file went red with `1 failed | 4 passed (5)`: "the primary datasource with no stored record is still tested through its live driver" failed. The file was restored with blob equal to HEAD and an empty `git diff HEAD`. </details> ## Acceptance notes - **Out of scope, unchanged.** `GET /datasources/NAME/remote-tables` and `POST /datasources/NAME/object-draft` answer an unknown name `400 EXTERNAL_DATASOURCE_ERROR`. The code path is `assertFederationSource`: `getDatasource` returns `undefined`, `isFederationSource(undefined)` is false, and the door throws `notAFederationSourceError`. This is a separate discrepancy, named in the claim. This PR did not measure it over HTTP. - A host that wires no `metadata` service now answers every non-primary name `404` at Test. `GET /datasources/NAME` already does the same in that host, because both read the same store. - `datasource-route-ledger.ts` is unchanged. Its `POST /:name/test` note does not state what the route answers. ## Verification The derived gate list comes from `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` at `1ae90cf405`. Each command ran with its exit code captured before any pipe, and the record was reconciled with `--ran`: **62 derived, 61 run green (exit 0), 1 NOT MEASURED, 0 UNRUN**. - NOT MEASURED: `pnpm check:dual-build-cjs-loads` exited 3 with `PREREQUISITE NOT MET — this gate reads built output, and some package has no dist/`. It needs a full-repo build, so the reading is left to CI. - Dogfood battery (owed by the `(narrowing)` arm, claim amendment 6498154139), on `b09ea62c76`: `pnpm --filter '@objectstack/dogfood^...' build && pnpm --filter @objectstack/dogfood test` under the os-verify-lock, VERDICT command-exit 0, Test Files 350 passed | 3 skipped (353), Tests 2817 passed | 20 skipped (2837). - Overlap: none of the 30 open PRs touches any file in this diff, so no `merge-tree` was needed. <details><summary>The 61 green families (exit 0)</summary> - `node scripts/check-adr-0087-registration.mjs --base origin/main` - `node scripts/check-adr-0087-registration.mjs --self-test` - `node scripts/check-changeset-no-major.mjs --base origin/main` - `node scripts/check-closing-keyword-parity.mjs` - `node scripts/check-closing-keyword-parity.mjs --self-test` - `node scripts/check-comment-mask-adoption.mjs` - `node scripts/check-comment-mask-adoption.mjs --self-test` - `node scripts/check-comment-mask-corpus.mjs` - `node scripts/check-dts-emitted.mjs --self-test` - `node scripts/check-empty-changeset.mjs --base origin/main` - `node scripts/check-empty-changeset.mjs --self-test` - `node scripts/check-issue-citations.mjs` - `node scripts/check-plugin-teardown-shape.mjs` - `node scripts/check-plugin-teardown-shape.mjs --self-test` - `node scripts/check-tenant-audit-census.mjs` - `node scripts/check-tenant-audit-census.mjs --self-test` - `node scripts/ci/assert-turbo-remote-cache.mjs --self-test` - `node scripts/ci/plan-test-core-shards.mjs --self-test` - `node scripts/ci/prune-turbo-cache.mjs --self-test` - `node scripts/docs-audit/check-affected-docs.mjs` - `node scripts/docs-audit/check-drift-comment.mjs` - `node scripts/pm/release-rehearsal-clone.mjs --self-test` - `node scripts/release-pending-publish.mjs --self-test` - `pnpm --filter @objectstack/spec run check:duration-unit-keys` - `pnpm check:changeset-gate-self-tests` - `pnpm check:ci-filter-parity` - `pnpm check:closed-ceremony-text` - `pnpm check:cross-package-test-inputs` - `pnpm check:dispatcher-error-vocabulary` - `pnpm check:doc-authoring` - `pnpm check:driver-memory-census` - `pnpm check:dts-closure` - `pnpm check:engine-double-contract` - `pnpm check:error-status-conformance` - `pnpm check:gitlink-declared` - `pnpm check:issue-citations` - `pnpm check:keyed-text-bounds` - `pnpm check:lean-entry-closure` - `pnpm check:logger-receiver-detach` - `pnpm check:nul-bytes` - `pnpm check:objectql-double-limit` - `pnpm check:org-identifier` - `pnpm check:page-declaration-shape` - `pnpm check:platform-object-tenancy-census` - `pnpm check:pm-changeset-deadline-census` - `pnpm check:published-files` - `pnpm check:query-options-erasure` - `pnpm check:refd-timer-probe` - `pnpm check:registry-log-declared` - `pnpm check:rest-log-spy-declared` - `pnpm check:route-envelope` - `pnpm check:slot-lookup` - `pnpm check:sourcemap-no-sources-content` - `pnpm check:system-context-census` - `pnpm check:test-source-alias` - `pnpm check:tier-file-adoption` - `pnpm check:type-check-coverage` - `pnpm check:type-check-debt` - `pnpm check:undeclared-dep-imports` - `pnpm check:watch-hint-literal` - `pnpm check:where-matcher` </details> --- _Generated by [Claude Code](https://claude.ai/code/session_01L6bhgXcN9HaZYSEUdJWEjw)_ 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01L6bhgXcN9HaZYSEUdJWEjw --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 882d4e4 commit e270dab

6 files changed

Lines changed: 247 additions & 7 deletions
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
---
2+
'@objectstack/service-datasource': patch
3+
---
4+
5+
fix(service-datasource)!: `POST /api/v1/datasources/:name/test` refuses a name that no saved datasource has with `404 RESOURCE_NOT_FOUND`, as the other by-name doors do, instead of answering `200 { ok: false }`
6+
7+
Clause-②: no (narrowing) — `POST /api/v1/datasources/:name/test` and the in-process `ExternalDatasourceService.testConnection(name)` refuse a name that resolves no saved datasource with `404 RESOURCE_NOT_FOUND`, which they answered `200 { ok: false }` before. Nothing is widened.
8+
Metadata-protocol: none
9+
10+
<!-- adr-0087: not-required (no-migration-prescription) No key of any schema is added, removed, renamed or re-shaped, and no export or type changes: the Test door's request and its { ok, latencyMs, tableCount, error } answer keep their shape, and a name that resolves no saved datasource is now refused instead of answered. A failed probe of a datasource that does not exist had no meaning to keep, so there is nothing for a migration to rewrite. -->
11+
12+
**BREAKING** accept-set narrowing at the saved-datasource Test door (`@objectstack/service-datasource`), served at `POST /api/v1/datasources/:name/test`.
13+
14+
- **Before.** Testing a saved datasource by a name that no saved datasource has (a typo, a removed datasource, a name from another deployment) was answered `200` with `{ ok: false, error: "[ObjectQL] Datasource '…' has no registered driver to introspect." }`. That is the answer a saved datasource that is down gets, so an admin tool or script could not tell "this datasource is down" from "this datasource does not exist". `GET`, `PATCH` and `DELETE /api/v1/datasources/:name` already answered the same name `404`.
15+
- **Now.** That request is refused `404` with `error.code` `RESOURCE_NOT_FOUND` and the message `GET /api/v1/datasources/:name` gives for the same name. Nothing is probed. The name is resolved through the same stored-record read the sibling by-name doors make. In process, `ExternalDatasourceService.testConnection(name)` throws that `404` error instead of returning `{ ok: false }`.
16+
- **Unchanged.** A saved datasource that fails its probe still answers `200 { ok: false, error }`, and one that is up still answers `200 { ok: true, … }`. The platform's primary datasource (`default`) is still tested through its live driver, whether or not a datasource record is stored for it. Every other outcome of `testConnection` is still returned, never thrown.
17+
- **Who sees it.** A caller holding the platform-settings capability who tests a name that no saved datasource has.
18+
19+
**The remedy.** None needed: pick the datasource from `GET /api/v1/datasources`, or check the name. A script that read `ok: false` as "does not exist" now reads `404`.

‎packages/services/service-datasource/src/__tests__/primary-datasource-test-and-sync.test.ts‎

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -67,13 +67,17 @@ type Table = { string(n: string): { primary(): void } };
6767
* the same records (`metadata.get('datasource', name)`, as `plugin.ts` wires
6868
* it), so a datasource's Sync answer and its listed `schemaMode` are one fact.
6969
* `stream` is a FEDERATED datasource on a driver that cannot introspect, the
70-
* subject of the engine-reason pin below.
70+
* subject of the engine-reason pin below. `ghost` is saved but never
71+
* connected, the no-live-driver subject of the Test pin below: [#27510] it is
72+
* a stored record because Test answers a name with no record `404`
73+
* (`saved-datasource-test-unknown-name.test.ts`).
7174
*/
7275
const RECORDS = [
7376
{ name: 'default', driver: 'sqlite', origin: 'code', config: {} },
7477
{ name: 'warehouse', driver: 'sqlite', origin: 'runtime', config: {} },
7578
{ name: 'scratch', driver: 'memory', origin: 'runtime', config: {} },
7679
{ name: 'stream', driver: 'memory', origin: 'runtime', schemaMode: 'external', external: {}, config: {} },
80+
{ name: 'ghost', driver: 'sqlite', origin: 'runtime', config: {} },
7781
];
7882

7983
/**
@@ -248,7 +252,8 @@ describe('a driver with no introspectSchema', () => {
248252
throw new Error('introspect must not be reached');
249253
},
250254
liveDriver: () => ({ checkHealth }),
251-
getDatasource: async () => undefined,
255+
// [#27510] A saved datasource: Test answers a name with no record 404.
256+
getDatasource: async (name) => ({ name }),
252257
getObject: async () => undefined,
253258
listObjects: async () => [],
254259
});

‎packages/services/service-datasource/src/__tests__/saved-datasource-test-after-failed-connect.test.ts‎

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -126,7 +126,7 @@ async function boot() {
126126
return { status: res.status, body: (await res.json()) as any };
127127
};
128128

129-
return { call, connection, build };
129+
return { call, connection, build, records };
130130
}
131131

132132
const listed = async (call: Awaited<ReturnType<typeof boot>>['call'], name: string) =>
@@ -192,7 +192,10 @@ describe('Test on a datasource whose connect failed', () => {
192192

193193
describe('what does not move', () => {
194194
it('a datasource with no verdict and no driver keeps the driver-missing sentence', async () => {
195-
const { call } = await boot();
195+
const { call, records } = await boot();
196+
// Saved, never connected. [#27510] It is a stored record because Test
197+
// answers a name with no record 404 (`saved-datasource-test-unknown-name.test.ts`).
198+
records.set('ghost', { name: 'ghost', driver: 'postgres', origin: 'runtime', config: UNREACHABLE });
196199

197200
const test = await call('POST', '/ghost/test');
198201
expect(test.status).toBe(200);
@@ -234,7 +237,8 @@ describe('only a failed verdict is answered', () => {
234237
},
235238
liveDriver: () => undefined,
236239
connectionState: () => ({ availability, ...(reason ? { reason } : {}) }),
237-
getDatasource: async () => undefined,
240+
// [#27510] A saved datasource: Test answers a name with no record 404.
241+
getDatasource: async (name) => ({ name }),
238242
getObject: async () => undefined,
239243
listObjects: async () => [],
240244
});
Lines changed: 182 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,182 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* `POST /datasources/:name/test` on a name that resolves no saved datasource.
5+
*
6+
* Every other by-name door answers an unknown name `404 RESOURCE_NOT_FOUND`
7+
* (`datasource-unknown-name.test.ts` holds the four `datasource-admin` ones).
8+
* Test answered it `200 { ok: false }` with the engine's "has no registered
9+
* driver to introspect" sentence, which is the answer it gives a saved
10+
* datasource that is down. An admin tool or script testing a datasource by
11+
* name could not tell "this datasource is down" from "this datasource does not
12+
* exist".
13+
*
14+
* The triage ruling these pins hold: resolve the saved name with the lookup
15+
* the sibling routes use and answer `RESOURCE_NOT_FOUND`; keep `{ ok: false }`
16+
* for a datasource that exists and fails its probe. Two controls ride along:
17+
* a saved datasource that is up still answers `ok: true`, and the platform's
18+
* primary datasource, which may have no stored record, is still tested through
19+
* its live driver.
20+
*
21+
* The composition is the real one, end to end: a real `ObjectQL` engine, the
22+
* real connection service and driver factory, the real admin service, the real
23+
* `ExternalDatasourceServicePlugin` wiring, and the real admin routes over a
24+
* Hono server. Both services read the same stored records, as the two plugins
25+
* do (`metadata.get('datasource', name)`).
26+
*/
27+
28+
import { afterEach, describe, expect, it } from 'vitest';
29+
import { ObjectQL } from '@objectstack/objectql';
30+
import { HonoHttpServer } from '@objectstack/plugin-hono-server';
31+
import { DatasourceConnectionService, DEFAULT_DATASOURCE_NAME } from '../datasource-connection-service.js';
32+
import { createDefaultDatasourceDriverFactory } from '../default-datasource-driver-factory.js';
33+
import { ExternalDatasourceServicePlugin } from '../plugin.js';
34+
import { ExternalDatasourceService } from '../external-datasource-service.js';
35+
import { DatasourceAdminService, type StoredDatasource } from '../datasource-admin-service.js';
36+
import { registerDatasourceAdminRoutes } from '../admin-routes.js';
37+
import { ENTITLED_CREDENTIAL, createSessionAuthService, createGrantsEngine } from './entitled-caller.fixture.js';
38+
39+
const teardowns: Array<() => Promise<void>> = [];
40+
afterEach(async () => {
41+
for (const t of teardowns.splice(0)) await t();
42+
});
43+
44+
/**
45+
* The saved datasources. `warehouse` is connected; `offline` is saved but has
46+
* no live driver (nothing connected it), so its probe fails. The primary
47+
* datasource has NO record here: it is connected `asDefault`, as
48+
* `DefaultDatasourcePlugin.init()` does, and nothing stores it.
49+
*/
50+
const RECORDS: StoredDatasource[] = [
51+
{ name: 'warehouse', driver: 'memory', origin: 'runtime', config: {} },
52+
{ name: 'offline', driver: 'memory', origin: 'runtime', config: {} },
53+
];
54+
55+
async function boot() {
56+
const ql = new ObjectQL();
57+
const connection = new DatasourceConnectionService({
58+
factory: () => createDefaultDatasourceDriverFactory({}),
59+
engine: () => ql as never,
60+
});
61+
teardowns.push(() => connection.disconnectAll());
62+
63+
await connection.connect(
64+
{ name: DEFAULT_DATASOURCE_NAME, driver: 'memory', config: {}, origin: 'code', bootCritical: true },
65+
{ asDefault: true, context: { origin: 'code', trigger: 'declared-auto' } },
66+
);
67+
await connection.connect(RECORDS[0], { context: { origin: 'runtime', trigger: 'runtime-admin' } });
68+
69+
const records = new Map(RECORDS.map((r) => [r.name, r]));
70+
const admin = new DatasourceAdminService({
71+
listDatasourceRecords: async () => [...records.values()],
72+
getDatasourceRecord: async (n: string) => records.get(n),
73+
connectionStates: () => connection.listConnectionStates(),
74+
} as never);
75+
76+
const services = new Map<string, unknown>([
77+
['data', ql],
78+
['metadata', { get: async (type: string, n: string) => (type === 'datasource' ? records.get(n) : undefined) }],
79+
['datasource-connection', connection],
80+
]);
81+
await new ExternalDatasourceServicePlugin().init({
82+
getService: (n: string) => {
83+
if (!services.has(n)) throw new Error(`no service: ${n}`);
84+
return services.get(n);
85+
},
86+
registerService: (n: string, s: unknown) => void services.set(n, s),
87+
} as never);
88+
const external = services.get('external-datasource') as ExternalDatasourceService;
89+
90+
const server = new HonoHttpServer(0);
91+
const authService = createSessionAuthService();
92+
const grantsEngine = createGrantsEngine();
93+
registerDatasourceAdminRoutes(
94+
server,
95+
{
96+
getService: (n: string) => {
97+
if (n === 'auth') return authService;
98+
if (n === 'objectql' || n === 'data') return grantsEngine;
99+
if (n === 'external-datasource') return external;
100+
if (n === 'datasource-admin') return admin;
101+
throw new Error(`no service: ${n}`);
102+
},
103+
} as never,
104+
'/api/v1',
105+
);
106+
const app = server.getRawApp();
107+
const call = async (method: 'GET' | 'POST', path: string) => {
108+
const res = await app.fetch(
109+
new Request(`http://local/api/v1/datasources${path}`, {
110+
method,
111+
headers: { authorization: ENTITLED_CREDENTIAL, 'content-type': 'application/json' },
112+
...(method === 'POST' ? { body: '{}' } : {}),
113+
}),
114+
);
115+
return { status: res.status, body: (await res.json()) as any };
116+
};
117+
118+
return { call, records };
119+
}
120+
121+
describe('POST /datasources/:name/test on a name that resolves no saved datasource', () => {
122+
it('answers 404 RESOURCE_NOT_FOUND naming the datasource, not 200 { ok: false }', async () => {
123+
const { call } = await boot();
124+
125+
const test = await call('POST', '/no_such_ds/test');
126+
127+
expect({ status: test.status, success: test.body.success, code: test.body.error?.code }).toEqual({
128+
status: 404,
129+
success: false,
130+
code: 'RESOURCE_NOT_FOUND',
131+
});
132+
expect(test.body.error.message).toContain('no_such_ds');
133+
expect(test.body).not.toHaveProperty('data');
134+
});
135+
136+
it('gives the answer GET /datasources/:name gives the same name: status, code and message', async () => {
137+
const { call } = await boot();
138+
139+
const read = await call('GET', '/no_such_ds');
140+
const test = await call('POST', '/no_such_ds/test');
141+
142+
expect(read.status).toBe(404);
143+
expect({ status: test.status, code: test.body.error?.code, message: test.body.error?.message }).toEqual({
144+
status: read.status,
145+
code: read.body.error.code,
146+
message: read.body.error.message,
147+
});
148+
});
149+
});
150+
151+
describe('POST /datasources/:name/test on a datasource that exists', () => {
152+
it('a saved datasource that fails its probe still answers 200 { ok: false, error }', async () => {
153+
const { call } = await boot();
154+
155+
const test = await call('POST', '/offline/test');
156+
157+
expect(test.status).toBe(200);
158+
expect(test.body.data.ok).toBe(false);
159+
expect(typeof test.body.data.error).toBe('string');
160+
expect(test.body.data.error).toContain('offline');
161+
});
162+
163+
it('a saved datasource that is up still answers 200 { ok: true }', async () => {
164+
const { call } = await boot();
165+
166+
const test = await call('POST', '/warehouse/test');
167+
168+
expect(test.status).toBe(200);
169+
expect(test.body.data).toMatchObject({ ok: true });
170+
});
171+
172+
it('the primary datasource with no stored record is still tested through its live driver', async () => {
173+
const { call, records } = await boot();
174+
expect(records.has(DEFAULT_DATASOURCE_NAME)).toBe(false);
175+
176+
const test = await call('POST', `/${DEFAULT_DATASOURCE_NAME}/test`);
177+
178+
expect(test.status).toBe(200);
179+
expect(test.body.data).toMatchObject({ ok: true });
180+
expect(typeof test.body.data.latencyMs).toBe('number');
181+
});
182+
});

‎packages/services/service-datasource/src/admin-routes.ts‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -86,7 +86,7 @@ const SERVICE_ERROR_CODE: Record<ServiceName, ErrorCode> = {
8686
* `DATASOURCE_ADMIN_ERROR` or `EXTERNAL_DATASOURCE_ERROR`. A name that resolves
8787
* no stored datasource is not a refusal: every `datasource-admin` door that
8888
* takes `:name` answers it `404` `RESOURCE_NOT_FOUND`, the read and the writes
89-
* alike (see `badRequest`).
89+
* alike, and so does `POST /datasources/:name/test` (see `badRequest`).
9090
*
9191
* Served by `datasource-admin`:
9292
*
@@ -641,7 +641,8 @@ export function registerDatasourceAdminRoutes(
641641
* unknown datasource reads the same at every by-name door, and a caller can
642642
* tell "no such datasource" from "your request was refused". The throwers
643643
* today are the admin service's `updateDatasource`, `removeDatasource` and
644-
* `migrateCredential` (`datasourceNotFoundError`).
644+
* `migrateCredential`, and the external service's `testConnection` (each
645+
* through its `datasourceNotFoundError`).
645646
*/
646647
const badRequest = (res: any, service: ServiceName, err: unknown) => {
647648
const envelope = err as { code?: unknown; status?: unknown } | null | undefined;

‎packages/services/service-datasource/src/external-datasource-service.ts‎

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -447,6 +447,21 @@ function importNameRefusedError(message: string): Error {
447447
return err;
448448
}
449449

450+
/**
451+
* [#27510] What the saved-datasource Test answers for a name that resolves no
452+
* stored datasource: the `404` / `RESOURCE_NOT_FOUND` envelope (ADR-0112), in
453+
* the words `GET /datasources/:name` uses, so the admin route relays it as the
454+
* read sibling answers the same name. It mirrors the admin service's helper of
455+
* the same name; `saved-datasource-test-unknown-name.test.ts` holds the two
456+
* doors' answers equal.
457+
*/
458+
function datasourceNotFoundError(name: string): Error {
459+
const err = new Error(`Datasource "${name}" does not exist.`) as Error & { code?: string; status?: number };
460+
err.code = 'RESOURCE_NOT_FOUND';
461+
err.status = 404;
462+
return err;
463+
}
464+
450465
/**
451466
* [#24772] The refusal every federation door answers for the platform's
452467
* primary datasource: listing its remote tables, drafting or importing one,
@@ -696,10 +711,24 @@ export class ExternalDatasourceService implements IExternalDatasourceService {
696711
* rebuild does, and replaces the verdict either way. A datasource whose
697712
* failed rebuild kept its old pool serving has a live driver, so it is
698713
* probed as before.
714+
*
715+
* [#27510] A name that resolves no saved datasource is not a datasource that
716+
* failed its probe, so it is the one outcome that THROWS: the 404
717+
* {@link datasourceNotFoundError}, which the route answers exactly as
718+
* `GET /datasources/:name` answers the same name. It answered `ok: false`
719+
* with the engine's "has no registered driver" sentence, the answer a saved
720+
* datasource that is down gets, so a caller could not tell the two apart.
721+
* The name is resolved through `getDatasource`, the stored-record read the
722+
* sibling `:name` doors make (both plugins wire it to
723+
* `metadata.get('datasource', name)`). The primary datasource is not looked
724+
* up: it may have no stored record, and its live driver answers for it.
699725
*/
700726
async testConnection(
701727
datasource: string,
702728
): Promise<{ ok: boolean; latencyMs?: number; tableCount?: number; error?: string }> {
729+
if (datasource !== DEFAULT_DATASOURCE_NAME && !(await this.config.getDatasource(datasource))) {
730+
throw datasourceNotFoundError(datasource);
731+
}
703732
const started = Date.now();
704733
try {
705734
const driver = this.config.liveDriver?.(datasource);

0 commit comments

Comments
 (0)