Skip to content

Commit 6b9e6df

Browse files
fix(service-datasource): Setup → Datasources: Test answers truthfully for the primary datasource and for drivers that cannot introspect; Sync on the primary datasource refuses with the true reason (#24852)
Fixes #24772 Clause-②: yes (widening) ## What was wrong Setup → Datasources listed the primary datasource as `default · sqlite · connected`, and that is true. `DefaultDatasourcePlugin` connects it through `DatasourceConnectionService` with `asDefault`, and ADR-0062 registers its driver under the driver's own name (`com.objectstack.driver.sql`), not under `default`. Test and Sync then introspected by the name `default`. The engine's registry has no entry by that name, so both answered `[ObjectQL] Datasource 'default' has no registered driver to introspect.`: Test as `200 { ok: false }`, Sync as `400 EXTERNAL_DATASOURCE_ERROR`. A connected `memory` datasource got the same `ok: false` from Test, because Test was an introspection round trip and that driver has no `introspectSchema`. Measured before the change, on `origin/main` `c3a8e07b6`, through the real admin routes over a real `ObjectQL` engine, the real connection service and the real `ExternalDatasourceServicePlugin`: ```text POST /api/v1/datasources/default/test -> 200 {"ok":false,"error":"[ObjectQL] Datasource 'default' has no registered driver to introspect."} GET /api/v1/datasources/default/remote-tables -> 400 EXTERNAL_DATASOURCE_ERROR, same message POST /api/v1/datasources/qa_ram/test (memory) -> 200 {"ok":false,"error":"[ObjectQL] Driver for datasource 'qa_ram' does not support introspectSchema()."} admin list -> default status "ok", qa_ram status "ok" ``` ## What changed (route D, as the seat ruled, comment 6287770114) All of it is in `packages/services/service-datasource/src`. Nothing in `packages/objectql`. - **One resolver, reused.** `registeredDriverName()` in `datasource-connection-service.ts` is the default-driver resolver `DatasourceConnectionService.disconnect()` applied inline. It is extracted, and `disconnect()` calls it with its own `asDefault` flag, so its behaviour is unchanged. The Test path calls the same function. `disconnectAll()` names the reserved datasource through the same constant. - **Test, `POST /api/v1/datasources/:name/test`.** The plugin wires a new optional `liveDriver` seam: the engine's registry entry under the name `registeredDriverName()` resolves. `testConnection()` then: - for the primary datasource, answers the driver's `checkHealth()`, giving `{ ok: true, latencyMs }`, and does not introspect the platform database; - for a driver with no `introspectSchema` (`memory`, `mongodb`), answers `checkHealth()` with the answer's existing keys, `{ ok: true, latencyMs }` and no `tableCount`, instead of `ok: false`; - for a registered driver whose health check fails, answers `ok: false` with a reason; - for every other datasource, and for one with no live driver, runs the same introspection round trip as before, with the same `tableCount` and the same engine message. - **Sync on the primary datasource.** `listRemoteTables`, `generateObjectDraft`, `importObject` and `refreshCatalog` refuse `default` before any introspection. The status and code are the ones each door served before: `400 EXTERNAL_DATASOURCE_ERROR`, or `EXTERNAL_IMPORT_ERROR` on the import. The message is now the true reason: `Datasource 'default' is the platform's managed primary datasource, not a federation source: its tables are the platform's own objects, so there are no remote tables to list, draft or import from it. Sync objects from an external datasource instead.` ## The measured choice for Test on `default`: `checkHealth()`, not introspection by the resolved name Both reach the live driver. `checkHealth()` was chosen for three reasons: - Introspection costs a round trip per table of the platform database. Measured with the real `SqlDriver` on SQLite in memory: `introspectSchema()` took 14.0 ms at 10 tables and 132.0 ms at 120 tables. `checkHealth()` took 0.79 ms and 0.28 ms. On a networked Postgres, each of those per-table round trips also pays the network latency. - The connection question is what Test asks, and `checkHealth()` is that question (`SELECT 1` on SQL, `ping` on MongoDB). - The primary datasource is not a federation source, so a `tableCount` of the platform's tables answers nothing the page uses. No consumer in either repository reads `tableCount`. Every other datasource keeps the introspection probe and its `tableCount`, so nothing moves for a federation source. ## The seat's premises, re-checked before editing - **P1, holds.** The resolver `disconnect()` applied (`opts.asDefault ? engine.getDefaultDriverName() : name`) needs only `getDefaultDriverName` and `getDriverByName`. Both are on the `'data'` engine's published contract (`IDataEngine`, `packages/spec/src/contracts/data-engine.ts`), which the plugin already reads. No objectql change is needed. - **P2, holds.** The Setup driver catalog (`driver-catalog.ts`) lists `memory`, `sqlite`, `postgres`, `mysql` and `mongodb`. The two without `introspectSchema` have `checkHealth()`: `MemoryDriver.checkHealth()` returns `true`, and `MongoDBDriver.checkHealth()` runs `db.command({ ping: 1 })`. `IDataDriver` declares `checkHealth()` as required. ## Evidence after the change `packages/services/service-datasource/src/__tests__/primary-datasource-test-and-sync.test.ts` has 10 cases. They run through the real composition: a real `ObjectQL` engine (its source, through this package's vitest alias), the connection service connecting `default` with `asDefault` exactly as `DefaultDatasourcePlugin.init()` does, the plugin's real wiring, and the admin routes over Hono. - On `default`, the list reads `ok`, and Test answers `200 { ok: true, latencyMs }` with no `error` and no `tableCount`. The driver's `introspectSchema` is never called. - On `default`, `remote-tables` and `object-draft` answer `400`, with `error.code` `EXTERNAL_DATASOURCE_ERROR` and the true message. The import refuses with `{ code: 'EXTERNAL_IMPORT_ERROR', status: 400 }`, and the list and catalog refresh with `{ code: 'EXTERNAL_DATASOURCE_ERROR', status: 400 }`. The platform database is never introspected. - A `memory` datasource tests `{ ok: true, latencyMs }`, exactly those two keys. A registered driver whose health check answers `false` or throws gives `ok: false`. - Preservation: a managed runtime SQLite datasource still tests `{ ok: true, tableCount: 1 }` and still lists its tables with `200`. That is the seat's ban on changing Sync for other managed datasources. Sync on `memory` still refuses with the engine's reason. A datasource with no live driver still answers `ok: false`. Ablations, one behaviour each. Each was run through `scripts/ablation-replace.mjs`: the anchor hit once, the on-disk blob changed, and the restore was proven blob == HEAD with an empty `git diff HEAD`. The subject is this package's own source, imported relatively, so no build sits between the mutation and the reading. | leg | mutation | reading | |:---|:---|:---| | A1 | the Test path looks the driver up by the datasource name, not through `registeredDriverName` | red, 1 failed: `expected { ok: false, …(1) } to match object { ok: true }` | | A2 | route A put back: no primary guard on `listRemoteTables`, and introspection resolves the default | red, 2 failed: `expected 200 to be 400`, and `listRemoteTables('default')` resolved a value | | A3 | a driver without `introspectSchema` goes back to the introspection round trip | red, 2 failed: `expected { ok: false, …(1) } to match object { ok: true }` (re-run at `3226d4019`) | | A4 | Test on `default` introspects by the resolved name | red, 1 failed: `expected [ 'latencyMs', 'ok', 'tableCount' ] to deeply equal [ 'latencyMs', 'ok' ]` (re-run at `3226d4019`) | | A5 | a failed health check is ignored | red, 1 failed: `expected true to be false` | | A6 | the dropped `introspection` key put back on a driver that cannot introspect | red, 1 failed: `expected [ 'introspection', 'latencyMs', 'ok' ] to deeply equal [ 'latencyMs', 'ok' ]` (at `3226d4019`) | ## The published-surface addition: Clause-② yes (widening) `ExternalDatasourceServiceConfig` is exported from the package's `.` entry, so its new optional member `liveDriver` widens the published surface. The changeset is a `minor` bump that declares `Clause-②: yes (widening)` and names that member. No status code, error code or accepted value moves on any route. Test's answer keeps its existing keys (`ok`, `latencyMs`, `tableCount`, `error`): the `introspection` key from the first head was dropped, because nothing reads it. - `ExternalDatasourceServiceConfig.liveDriver?`: a host that builds the service without it keeps the old probe. `registeredDriverName` and `DEFAULT_DATASOURCE_NAME` are module exports only, not exported from the package entry. ## Acceptance notes - **`refreshCatalog` is included under the bounded in-place exemption.** It is the same defect class (the same false "no registered driver" answer for `default`), in the same file, through the same refusal helper. Its door (`POST …/external/refresh-catalog`) keeps `400 EXTERNAL_DATASOURCE_ERROR`. - **A boundary, checked by reading the code.** The refusal is keyed on the host-reserved name `default` (ADR-0062). The only way to register a driver literally named `default` is `ObjectQL.create({ datasources: { default: driverWithNoName } })`. A repository grep finds no production caller of `ObjectQL.create`, and every in-tree driver carries its own name. So no in-tree composition answered `200` on Sync for `default` before this change. - **The card's "no edit or delete" is by design, not a defect.** The tester's own `38-integrations-datasources-created.png` shows Edit and Delete on the runtime `qa_mem` row. Only the code-origin `default` row lacks them: code-defined datasources are read-only (`datasource-admin-service.ts` header), and objectui hides both on `origin: 'code'`. - **Not in this PR, per the ruling.** `ObjectQL.introspectDatasource` still does not apply ADR-0062 (no objectql edit). Sync on other managed runtime datasources is unchanged (route B, filed separately by the seat). The two objectui renderer findings are filed by the seat. - **Noted, not filed: the draft probe discards the answer of `checkHealth()`.** `DatasourceAdminServicePlugin.probe`, behind `POST /api/v1/datasources/test`, awaits `driver.checkHealth()` and ignores the boolean it returns, so a draft whose driver's health check answers `false` would report `ok: true`. This comes from reading the code only: no public-door reading was taken. ## Verification At HEAD `3226d4019` (no further merge of `origin/main`: no conflict and no main-caused red, AGENTS.md §10): - `pnpm --filter '@objectstack/service-datasource^...' build`: exit 0. - `pnpm --filter @objectstack/service-datasource build`: exit 0, and `check-dts-emitted` reports 4/4. - `pnpm --filter @objectstack/service-datasource typecheck`: exit 0. `--listFiles` includes the new test file. - `pnpm --filter @objectstack/service-datasource test`: 48 files, 862 tests passed, 47.6 s. - Gate families from `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` (69 commands): 68 exited 0 and 1 was not measured. `pnpm check:dual-build-cjs-loads` exited 3 (PREREQUISITE NOT MET: it reads every package's built output, and this worktree builds only this package's closure), so CI's `Lint & Repo Gates` is its reading. `--ran` reconciliation: `69 derived, 68 run, 1 NOT-MEASURED, 0 UNRUN`, with an exit code on every line. `node scripts/check-platform-checklist.mjs` is not owed: no exported symbol is renamed or deleted. `test:repo` was not run locally. CI owns it, and this diff touches no test of that project. --- _Generated by [Claude Code](https://claude.ai/code/session_01UmqzD97e14iws6hmVhgZ5y)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 81120ca commit 6b9e6df

5 files changed

Lines changed: 451 additions & 3 deletions

File tree

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
---
2+
'@objectstack/service-datasource': minor
3+
---
4+
5+
fix(service-datasource): Setup → Datasources: Test answers truthfully for the platform's primary datasource and for drivers that cannot introspect, and Sync on the primary datasource refuses with the true reason
6+
7+
Clause-②: yes (widening: the published `ExternalDatasourceServiceConfig`, exported from the package entry, gains one optional member, the `liveDriver` callback; no status code, error code or accepted value moves on any route, and Test's answer keeps its existing keys)
8+
9+
- **What was wrong.** The Datasources page listed the primary datasource (`default`) as connected, which is true. Its Test then answered `200 { ok: false }` and Sync answered `400`, both saying the datasource "has no registered driver to introspect". The primary datasource's driver is registered under the driver's own name, not under `default`, and both doors looked it up by `default`. A connected `memory` datasource also got `ok: false` from Test, because Test was an introspection round trip and that driver cannot introspect.
10+
- **Test, `POST /api/v1/datasources/:name/test`.** For the primary datasource, Test now finds the live driver the way the connection service's own teardown does and answers its health check: `{ ok: true, latencyMs }`. It does not list the platform database's tables. For a driver with no schema introspection (`memory`, `mongodb`), Test answers the health check too, `{ ok: true, latencyMs }` with no `tableCount`, instead of `ok: false`. A driver that fails its health check answers `ok: false`. Every other datasource is tested exactly as before (an introspection round trip with `tableCount`), and so is a datasource with no live driver.
11+
- **Sync on the primary datasource.** `GET …/remote-tables`, `POST …/object-draft`, the import (`POST …/external/tables/:remote/import`) and the catalog refresh still refuse `default`, with the same status and code as before (`400 EXTERNAL_DATASOURCE_ERROR`, or `EXTERNAL_IMPORT_ERROR` on the import). The message now gives the true reason: the primary datasource is the platform's managed storage, not a federation source, so there are no remote tables to list, draft or import from it. Its tables are never introspected.
12+
- **Unchanged.** Sync on every other datasource, including a managed runtime one, answers exactly as before.
13+
- **New, optional: `ExternalDatasourceServiceConfig.liveDriver`.** `(datasource) => driver | undefined` returns the live driver registered for a datasource, resolved by the primary datasource's naming rule. `ExternalDatasourceServicePlugin` wires it. Test reads it to reach the primary datasource and drivers without schema introspection.
14+
15+
What to do: nothing, if the plugin builds the service. If you build `ExternalDatasourceService` yourself, pass `liveDriver` to get the new Test answers; without it, Test stays the introspection round trip, as before.
Lines changed: 289 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,289 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#24772] Setup → Datasources: what Test and Sync answer for the platform's
5+
* PRIMARY datasource (`default`) and for a driver that cannot introspect.
6+
*
7+
* The page lists `default` as connected. That is true: `DefaultDatasourcePlugin`
8+
* connects it through `DatasourceConnectionService` with `asDefault`, and
9+
* ADR-0062 keeps its driver under the driver's NATURAL name, so the engine's
10+
* registry has no entry called `default`. Test and Sync both introspected by
11+
* the datasource name and answered "has no registered driver to introspect":
12+
* Test `200 { ok: false }`, Sync `400`. A connected `memory` datasource got
13+
* the same `ok: false` from Test, because its driver has no `introspectSchema`.
14+
*
15+
* The seat's ruling (route D) is what these pins hold:
16+
*
17+
* - Test answers truthfully for both. The primary datasource's driver is
18+
* found by the resolver `DatasourceConnectionService.disconnect` already
19+
* applies (`registeredDriverName`), and a driver that cannot introspect
20+
* answers its health probe instead of failing. Both answer the existing
21+
* `{ ok, latencyMs }` keys and nothing more (the seat's REWORK record:
22+
* no answer key without a consumer).
23+
* - Sync on `default` keeps refusing with the SAME status and code, and now
24+
* gives the true reason: the primary datasource is the platform's managed
25+
* storage, not a federation source (ADR-0015 §1). It never introspects the
26+
* platform database.
27+
* - Nothing else moves: a managed runtime datasource still tests and syncs
28+
* as before, and a datasource with no live driver still answers `ok: false`.
29+
*
30+
* The composition is the real one, end to end: a real `ObjectQL` engine (its
31+
* source, through this package's vitest alias), the real connection service
32+
* and driver factory, the real `ExternalDatasourceServicePlugin` wiring, and
33+
* the real admin routes over a Hono server. The SQL and memory drivers are
34+
* imported by the factory as packages, so they are their built `dist/`.
35+
*/
36+
37+
import { afterEach, describe, expect, it, vi } from 'vitest';
38+
import { ObjectQL } from '@objectstack/objectql';
39+
import { HonoHttpServer } from '@objectstack/plugin-hono-server';
40+
import type { IDataDriver } from '@objectstack/spec/contracts';
41+
import {
42+
DatasourceConnectionService,
43+
DEFAULT_DATASOURCE_NAME,
44+
registeredDriverName,
45+
} from '../datasource-connection-service.js';
46+
import { createDefaultDatasourceDriverFactory } from '../default-datasource-driver-factory.js';
47+
import { ExternalDatasourceServicePlugin } from '../plugin.js';
48+
import { ExternalDatasourceService } from '../external-datasource-service.js';
49+
import { DatasourceAdminService } from '../datasource-admin-service.js';
50+
import { registerDatasourceAdminRoutes } from '../admin-routes.js';
51+
import { ENTITLED_CREDENTIAL, createSessionAuthService, createGrantsEngine } from './entitled-caller.fixture.js';
52+
53+
const teardowns: Array<() => Promise<void>> = [];
54+
afterEach(async () => {
55+
for (const t of teardowns.splice(0)) await t();
56+
});
57+
58+
type Knex = { schema: { createTable(n: string, cb: (t: never) => void): Promise<void> } };
59+
type Table = { string(n: string): { primary(): void } };
60+
61+
/**
62+
* The page's composition: the primary datasource connected `asDefault` (the
63+
* `DefaultDatasourcePlugin.init()` call, verbatim), a managed runtime SQLite
64+
* datasource like the one the tester created, and a runtime `memory` one.
65+
*/
66+
async function boot() {
67+
const ql = new ObjectQL();
68+
const connection = new DatasourceConnectionService({
69+
factory: () => createDefaultDatasourceDriverFactory({}),
70+
engine: () => ql as never,
71+
});
72+
teardowns.push(() => connection.disconnectAll());
73+
74+
await connection.connect(
75+
{ name: 'default', label: 'Default', driver: 'sqlite', config: { filename: ':memory:' }, origin: 'code', bootCritical: true },
76+
{ asDefault: true, context: { origin: 'code', trigger: 'declared-auto' } },
77+
);
78+
await connection.connect(
79+
{ name: 'warehouse', driver: 'sqlite', config: { filename: ':memory:' }, origin: 'runtime' },
80+
{ context: { origin: 'runtime', trigger: 'runtime-admin' } },
81+
);
82+
await connection.connect(
83+
{ name: 'scratch', driver: 'memory', config: {}, origin: 'runtime' },
84+
{ context: { origin: 'runtime', trigger: 'runtime-admin' } },
85+
);
86+
87+
const primary = ql.getDriverByName(ql.getDefaultDriverName()!) as IDataDriver & { getKnex(): Knex };
88+
await primary.getKnex().schema.createTable('acct', (t) => (t as unknown as Table).string('id').primary());
89+
const warehouse = ql.getDriverByName('warehouse') as IDataDriver & { getKnex(): Knex };
90+
await warehouse.getKnex().schema.createTable('orders', (t) => (t as unknown as Table).string('id').primary());
91+
92+
const services = new Map<string, unknown>([['data', ql]]);
93+
await new ExternalDatasourceServicePlugin().init({
94+
getService: (n: string) => {
95+
if (!services.has(n)) throw new Error(`no service: ${n}`);
96+
return services.get(n);
97+
},
98+
registerService: (n: string, s: unknown) => void services.set(n, s),
99+
} as never);
100+
const external = services.get('external-datasource') as ExternalDatasourceService;
101+
102+
const admin = new DatasourceAdminService({
103+
listDatasourceRecords: async () => [
104+
{ name: 'default', driver: 'sqlite', origin: 'code', config: {} },
105+
{ name: 'warehouse', driver: 'sqlite', origin: 'runtime', config: {} },
106+
{ name: 'scratch', driver: 'memory', origin: 'runtime', config: {} },
107+
],
108+
connectionStates: () => connection.listConnectionStates(),
109+
} as never);
110+
111+
const server = new HonoHttpServer(0);
112+
const authService = createSessionAuthService();
113+
const grantsEngine = createGrantsEngine();
114+
registerDatasourceAdminRoutes(
115+
server,
116+
{
117+
getService: (n: string) => {
118+
if (n === 'auth') return authService;
119+
if (n === 'objectql' || n === 'data') return grantsEngine;
120+
if (n === 'external-datasource') return external;
121+
if (n === 'datasource-admin') return admin;
122+
throw new Error(`no service: ${n}`);
123+
},
124+
} as never,
125+
'/api/v1',
126+
);
127+
const app = server.getRawApp();
128+
const call = async (method: 'GET' | 'POST', path: string, body?: unknown) => {
129+
const res = await app.fetch(
130+
new Request(`http://local/api/v1/datasources${path}`, {
131+
method,
132+
headers: { authorization: ENTITLED_CREDENTIAL, 'content-type': 'application/json' },
133+
...(method === 'POST' ? { body: JSON.stringify(body ?? {}) } : {}),
134+
}),
135+
);
136+
return { status: res.status, body: (await res.json()) as any };
137+
};
138+
139+
return { ql, connection, external, primary, call };
140+
}
141+
142+
/** The refusal a service method threw: its envelope fields (ADR-0112). */
143+
async function thrown(answer: Promise<unknown>): Promise<Error & { code?: string; status?: number }> {
144+
const outcome = await answer.then(
145+
(value) => ({ value }),
146+
(error: unknown) => ({ error }),
147+
);
148+
expect(outcome).not.toHaveProperty('value');
149+
return (outcome as { error: Error & { code?: string; status?: number } }).error;
150+
}
151+
152+
const NOT_A_FEDERATION_SOURCE = "Datasource 'default' is the platform's managed primary datasource, not a federation source";
153+
154+
describe('the primary datasource: Test agrees with the "connected" the page lists', () => {
155+
it('Test answers ok through the live driver, and does not introspect the platform database', async () => {
156+
const { call, primary } = await boot();
157+
const introspect = vi.spyOn(primary, 'introspectSchema' as never);
158+
159+
const list = await call('GET', '');
160+
expect(list.status).toBe(200);
161+
expect(list.body.data.datasources.find((d: { name: string }) => d.name === 'default').status).toBe('ok');
162+
163+
const test = await call('POST', '/default/test');
164+
expect(test.status).toBe(200);
165+
expect(test.body.data).toMatchObject({ ok: true });
166+
expect(typeof test.body.data.latencyMs).toBe('number');
167+
// The primary datasource is not a federation source: Test asks the driver
168+
// whether it is connected, never lists the platform's tables to find out.
169+
// So the answer is the existing keys `ok` and `latencyMs`, with no
170+
// `tableCount` and no `error`.
171+
expect(Object.keys(test.body.data).sort()).toEqual(['latencyMs', 'ok']);
172+
expect(introspect).not.toHaveBeenCalled();
173+
});
174+
});
175+
176+
describe('the primary datasource: Sync refuses as before, with the true reason', () => {
177+
it('remote-tables and object-draft keep 400 EXTERNAL_DATASOURCE_ERROR, and never introspect it', async () => {
178+
const { call, primary } = await boot();
179+
const introspect = vi.spyOn(primary, 'introspectSchema' as never);
180+
181+
const tables = await call('GET', '/default/remote-tables');
182+
expect(tables.status).toBe(400);
183+
expect(tables.body.error.code).toBe('EXTERNAL_DATASOURCE_ERROR');
184+
expect(tables.body.error.message).toContain(NOT_A_FEDERATION_SOURCE);
185+
expect(tables.body.error.message).not.toContain('no registered driver');
186+
187+
const draft = await call('POST', '/default/object-draft', { table: 'acct' });
188+
expect(draft.status).toBe(400);
189+
expect(draft.body.error.code).toBe('EXTERNAL_DATASOURCE_ERROR');
190+
expect(draft.body.error.message).toContain(NOT_A_FEDERATION_SOURCE);
191+
192+
expect(introspect).not.toHaveBeenCalled();
193+
});
194+
195+
it('the import and the catalog refresh refuse it with the code their doors serve', async () => {
196+
const { external } = await boot();
197+
198+
expect(await thrown(external.listRemoteTables('default'))).toMatchObject({ code: 'EXTERNAL_DATASOURCE_ERROR', status: 400 });
199+
expect(await thrown(external.generateObjectDraft('default', 'acct'))).toMatchObject({ code: 'EXTERNAL_DATASOURCE_ERROR', status: 400 });
200+
expect(await thrown(external.refreshCatalog('default'))).toMatchObject({ code: 'EXTERNAL_DATASOURCE_ERROR', status: 400 });
201+
const imported = await thrown(external.importObject('default', 'acct'));
202+
expect(imported).toMatchObject({ code: 'EXTERNAL_IMPORT_ERROR', status: 400 });
203+
expect(imported.message).toContain(NOT_A_FEDERATION_SOURCE);
204+
});
205+
});
206+
207+
describe('a driver with no introspectSchema', () => {
208+
it('Test answers its health probe, connected, instead of ok: false', async () => {
209+
const { call } = await boot();
210+
211+
const test = await call('POST', '/scratch/test');
212+
expect(test.status).toBe(200);
213+
expect(test.body.data).toMatchObject({ ok: true });
214+
expect(typeof test.body.data.latencyMs).toBe('number');
215+
// The answer's existing keys, and no other: no `tableCount` (it cannot list
216+
// tables), no `error`, and no key invented to say why.
217+
expect(Object.keys(test.body.data).sort()).toEqual(['latencyMs', 'ok']);
218+
});
219+
220+
it('a registered driver that fails its health check is not reported connected', async () => {
221+
const service = (checkHealth: () => Promise<boolean>) =>
222+
new ExternalDatasourceService({
223+
introspect: async () => {
224+
throw new Error('introspect must not be reached');
225+
},
226+
liveDriver: () => ({ checkHealth }),
227+
getDatasource: async () => undefined,
228+
getObject: async () => undefined,
229+
listObjects: async () => [],
230+
});
231+
232+
const unhealthy = await service(async () => false).testConnection('scratch');
233+
expect(unhealthy.ok).toBe(false);
234+
expect(unhealthy.error).toContain('health check');
235+
236+
const throwing = await service(async () => {
237+
throw new Error('ECONNREFUSED');
238+
}).testConnection('scratch');
239+
expect(throwing).toEqual({ ok: false, error: 'ECONNREFUSED' });
240+
});
241+
});
242+
243+
describe('nothing else moves', () => {
244+
it('a managed runtime datasource tests and syncs exactly as before', async () => {
245+
const { call } = await boot();
246+
247+
const test = await call('POST', '/warehouse/test');
248+
expect(test.status).toBe(200);
249+
expect(test.body.data).toMatchObject({ ok: true, tableCount: 1 });
250+
251+
const tables = await call('GET', '/warehouse/remote-tables');
252+
expect(tables.status).toBe(200);
253+
expect(tables.body.data.tables.map((t: { name: string }) => t.name)).toEqual(['orders']);
254+
});
255+
256+
it('Sync on a driver that cannot introspect still refuses with the engine reason', async () => {
257+
const { call } = await boot();
258+
259+
const tables = await call('GET', '/scratch/remote-tables');
260+
expect(tables.status).toBe(400);
261+
expect(tables.body.error.code).toBe('EXTERNAL_DATASOURCE_ERROR');
262+
expect(tables.body.error.message).toContain('does not support introspectSchema()');
263+
});
264+
265+
it('a datasource with no live driver still answers ok: false', async () => {
266+
const { call } = await boot();
267+
268+
const test = await call('POST', '/ghost/test');
269+
expect(test.status).toBe(200);
270+
expect(test.body.data.ok).toBe(false);
271+
expect(test.body.data.error).toContain("Datasource 'ghost' has no registered driver");
272+
});
273+
});
274+
275+
describe('registeredDriverName — the one spelling of the ADR-0062 default-driver rule', () => {
276+
const engine = { getDefaultDriverName: () => 'com.objectstack.driver.sql' };
277+
278+
it('resolves the reserved name to the default driver, and every other name to itself', () => {
279+
expect(DEFAULT_DATASOURCE_NAME).toBe('default');
280+
expect(registeredDriverName(engine, 'default')).toBe('com.objectstack.driver.sql');
281+
expect(registeredDriverName(engine, 'warehouse')).toBe('warehouse');
282+
});
283+
284+
it('honours an explicit flag either way, as disconnect() passes its own', () => {
285+
expect(registeredDriverName(engine, 'default', false)).toBe('default');
286+
expect(registeredDriverName(engine, 'analytics', true)).toBe('com.objectstack.driver.sql');
287+
expect(registeredDriverName(undefined, 'default')).toBeUndefined();
288+
});
289+
});

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

Lines changed: 34 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -219,6 +219,38 @@ export function availabilityOf(status: ConnectStatus): DatasourceAvailability {
219219
}
220220
}
221221

222+
/**
223+
* The host-reserved name of the platform's primary datasource (ADR-0062,
224+
* #3826): the one {@link DatasourceConnectionService.connect} connects
225+
* `asDefault`, and the one runtime-admin create and app bundles may not use.
226+
*/
227+
export const DEFAULT_DATASOURCE_NAME = 'default';
228+
229+
/**
230+
* The name the engine's driver registry holds `datasource`'s driver under —
231+
* this package's ONE spelling of ADR-0062's default-driver rule.
232+
*
233+
* Every datasource but the default is registered under its own name
234+
* ({@link DatasourceConnectionService.connect} stamps it). The default keeps
235+
* its driver's NATURAL name and registers with `isDefault: true`, so
236+
* `getDriverByName('default')` can never find it: it is reached through the
237+
* engine's default-driver name instead. `undefined` when the default is asked
238+
* for and the engine has no default driver (or cannot say).
239+
*
240+
* `asDefault` defaults to "is this the reserved name". `disconnect` passes its
241+
* own flag through unchanged; the saved-datasource Test probe
242+
* (`ExternalDatasourceServicePlugin`, #24772) asks by name. ⛔ Never a second
243+
* copy of this rule at a call site: a copy is how the Test probe came to answer
244+
* "no registered driver" for a default that was live.
245+
*/
246+
export function registeredDriverName(
247+
engine: Pick<ConnectionEngineLike, 'getDefaultDriverName'> | undefined,
248+
datasource: string,
249+
asDefault: boolean = datasource === DEFAULT_DATASOURCE_NAME,
250+
): string | undefined {
251+
return asDefault ? engine?.getDefaultDriverName?.() : datasource;
252+
}
253+
222254
/**
223255
* ADR-0062 D2 — is this declared datasource "meaningfully addressed", such that
224256
* auto-connecting it is safe and intended?
@@ -714,7 +746,7 @@ export class DatasourceConnectionService {
714746
*/
715747
async disconnect(name: string, opts: { asDefault?: boolean } = {}): Promise<void> {
716748
const engine = this.cfg.engine();
717-
const driverName = opts.asDefault ? engine?.getDefaultDriverName?.() : name;
749+
const driverName = registeredDriverName(engine, name, opts.asDefault === true);
718750
// [commit 77b91bdb4] Cast-free: `getDriverByName` now answers the contract's
719751
// `IDataDriver | undefined` instead of a locally re-declared `unknown`.
720752
// The `typeof … === 'function'` guard below stays — a host-built driver in
@@ -883,7 +915,7 @@ export class DatasourceConnectionService {
883915
.filter(([, st]) => st.status === 'connected')
884916
.map(([n]) => n);
885917
for (const n of names) {
886-
await this.disconnect(n, { asDefault: n === 'default' });
918+
await this.disconnect(n, { asDefault: n === DEFAULT_DATASOURCE_NAME });
887919
}
888920
}
889921

0 commit comments

Comments
 (0)