Skip to content

Commit 9e06f3d

Browse files
committed
test(service-datasource): pin Test and Sync on the primary datasource and on a driver that cannot introspect
Through the real composition: a real ObjectQL engine, the connection service connecting the primary datasource asDefault, the federation plugin's wiring and the admin routes over Hono. Plus the patch changeset. Claude-Session: https://claude.ai/code/session_01UmqzD97e14iws6hmVhgZ5y Co-authored-by: Claude <noreply@anthropic.com>
1 parent 64eb6ff commit 9e06f3d

2 files changed

Lines changed: 298 additions & 0 deletions

File tree

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

0 commit comments

Comments
 (0)