From 480fe0deee0cc249a53db6d276444e833afe5959 Mon Sep 17 00:00:00 2001 From: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com> Date: Sun, 21 Jun 2026 11:03:06 +0800 Subject: [PATCH 1/2] feat(datasource): read-only introspection routes for Studio sync MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Expose the external-datasource introspection service over REST so Studio can list a datasource's remote tables and preview the object definition they would become: - GET /api/v1/datasources/:name/remote-tables → listRemoteTables() - POST /api/v1/datasources/:name/object-draft → generateObjectDraft(table) Both are read-only (introspect + type-map; no persistence) — the caller creates the object through the normal metadata channel. Degrade to 503 when the external-datasource service isn't wired; 400 when the table is missing. Verified live against a seeded SQLite datasource (customers/orders → remote-tables lists both; object-draft yields a typed object definition). Tests: remote-tables list + object-draft (200 + 400-without-table). Co-Authored-By: Claude Opus 4.8 --- .../src/__tests__/admin-routes.test.ts | 22 +++++++++++ .../service-datasource/src/admin-routes.ts | 37 +++++++++++++++++++ 2 files changed, 59 insertions(+) diff --git a/packages/services/service-datasource/src/__tests__/admin-routes.test.ts b/packages/services/service-datasource/src/__tests__/admin-routes.test.ts index bef2045582..f5e3f07f8b 100644 --- a/packages/services/service-datasource/src/__tests__/admin-routes.test.ts +++ b/packages/services/service-datasource/src/__tests__/admin-routes.test.ts @@ -54,6 +54,28 @@ describe('registerDatasourceAdminRoutes (real HonoHttpServer)', () => { expect(sqlite!.configSchema?.properties?.filename?.type).toBe('string'); }); + it('GET /api/v1/datasources/:name/remote-tables lists remote tables', async () => { + const listRemoteTables = vi.fn().mockResolvedValue([{ name: 'customers', columnCount: 4 }]); + const app = mount({ listRemoteTables }); + const res = await app.fetch(json('/api/v1/datasources/demo_ext/remote-tables')); + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ tables: [{ name: 'customers', columnCount: 4 }] }); + expect(listRemoteTables).toHaveBeenCalledWith('demo_ext'); + }); + + it('POST /api/v1/datasources/:name/object-draft generates a draft (400 without table)', async () => { + const generateObjectDraft = vi.fn().mockResolvedValue({ name: 'customers', definition: { fields: { id: {} } } }); + const app = mount({ generateObjectDraft }); + + const missing = await app.fetch(json('/api/v1/datasources/demo_ext/object-draft', { method: 'POST', body: JSON.stringify({}) })); + expect(missing.status).toBe(400); + + const ok = await app.fetch(json('/api/v1/datasources/demo_ext/object-draft', { method: 'POST', body: JSON.stringify({ table: 'customers' }) })); + expect(ok.status).toBe(200); + expect((await ok.json()).draft.name).toBe('customers'); + expect(generateObjectDraft).toHaveBeenCalledWith('demo_ext', 'customers', {}); + }); + it('POST /api/v1/datasources/test splits the inline secret out of the draft', async () => { const testConnection = vi.fn().mockResolvedValue({ ok: true }); const app = mount({ testConnection }); diff --git a/packages/services/service-datasource/src/admin-routes.ts b/packages/services/service-datasource/src/admin-routes.ts index f39225d75b..8f8f656604 100644 --- a/packages/services/service-datasource/src/admin-routes.ts +++ b/packages/services/service-datasource/src/admin-routes.ts @@ -37,6 +37,14 @@ export function registerDatasourceAdminRoutes( } }; + const externalService = (): any => { + try { + return ctx.getService('external-datasource'); + } catch { + return undefined; + } + }; + const unavailable = (res: any) => res.status(503).json({ error: 'datasource_admin_unavailable' }); @@ -71,6 +79,35 @@ export function registerDatasourceAdminRoutes( res.json({ drivers: DRIVER_CATALOG }); }); + // Read-only schema introspection for the Studio "sync objects" flow. + // `GET /datasources/:name/remote-tables` lists the datasource's remote tables; + // `POST /datasources/:name/object-draft` generates an ObjectStack object + // definition draft for one table (introspect + type-map, no persistence — + // the caller creates the object through the normal metadata channel). + server.get(`${root}/:name/remote-tables`, async (req: any, res: any) => { + const svc = externalService(); + if (!svc?.listRemoteTables) return unavailable(res); + try { + const tables = await svc.listRemoteTables(req.params.name); + res.json({ tables }); + } catch (err) { + badRequest(res, err); + } + }); + + server.post(`${root}/:name/object-draft`, async (req: any, res: any) => { + const svc = externalService(); + if (!svc?.generateObjectDraft) return unavailable(res); + const { table, ...opts } = (req.body as Record) ?? {}; + if (!table) return badRequest(res, new Error('Body field "table" is required.')); + try { + const draft = await svc.generateObjectDraft(req.params.name, String(table), opts); + res.json({ draft }); + } catch (err) { + badRequest(res, err); + } + }); + // Probe a connection without persisting anything. Registered before the // `:name` routes so the literal `test` segment is never captured as a name. server.post(`${root}/test`, async (req: any, res: any) => { From 4188f17c80767e4cf358f205e9fe5c8f4adb6b97 Mon Sep 17 00:00:00 2001 From: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com> Date: Sun, 21 Jun 2026 11:33:45 +0800 Subject: [PATCH 2/2] feat(datasource): add POST /datasources/:name/test for saved datasources MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The `datasource` `test_connection` action declares `POST /datasources/:id/test` (probe a saved datasource by name), but the admin routes only implemented `POST /datasources/test` (probe an unsaved inline draft). The action — and any UI that wires it (the Studio datasource manager) — hit a 404. Add a thin by-name route backed by a new `ExternalDatasourceService.testConnection(name)` that times a live introspect (driver connect + schema read) and returns `{ ok, latencyMs, tableCount }` or `{ ok:false, error }`. Reuses the same wired introspection pool as remote-tables/object-draft, so the secret is resolved through the existing path — the route never handles cleartext. Registered before the generic `:name` mutation routes. Co-Authored-By: Claude Opus 4.8 --- .../src/__tests__/admin-routes.test.ts | 9 +++++++ .../service-datasource/src/admin-routes.ts | 15 +++++++++++ .../src/external-datasource-service.ts | 25 +++++++++++++++++++ 3 files changed, 49 insertions(+) diff --git a/packages/services/service-datasource/src/__tests__/admin-routes.test.ts b/packages/services/service-datasource/src/__tests__/admin-routes.test.ts index f5e3f07f8b..1d2c4e3615 100644 --- a/packages/services/service-datasource/src/__tests__/admin-routes.test.ts +++ b/packages/services/service-datasource/src/__tests__/admin-routes.test.ts @@ -76,6 +76,15 @@ describe('registerDatasourceAdminRoutes (real HonoHttpServer)', () => { expect(generateObjectDraft).toHaveBeenCalledWith('demo_ext', 'customers', {}); }); + it('POST /api/v1/datasources/:name/test probes a saved datasource by name', async () => { + const testConnection = vi.fn().mockResolvedValue({ ok: true, latencyMs: 7, tableCount: 2 }); + const app = mount({ testConnection }); + const res = await app.fetch(json('/api/v1/datasources/demo_ext/test', { method: 'POST', body: '{}' })); + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ ok: true, latencyMs: 7, tableCount: 2 }); + expect(testConnection).toHaveBeenCalledWith('demo_ext'); + }); + it('POST /api/v1/datasources/test splits the inline secret out of the draft', async () => { const testConnection = vi.fn().mockResolvedValue({ ok: true }); const app = mount({ testConnection }); diff --git a/packages/services/service-datasource/src/admin-routes.ts b/packages/services/service-datasource/src/admin-routes.ts index 8f8f656604..105971e891 100644 --- a/packages/services/service-datasource/src/admin-routes.ts +++ b/packages/services/service-datasource/src/admin-routes.ts @@ -95,6 +95,21 @@ export function registerDatasourceAdminRoutes( } }); + // Test a *saved* datasource by name with a live round-trip (backs the + // `datasource` `test_connection` action). Distinct from `POST /datasources/test` + // which probes an unsaved draft carried inline. Registered before the generic + // `:name` mutation routes. + server.post(`${root}/:name/test`, async (req: any, res: any) => { + const svc = externalService(); + if (!svc?.testConnection) return unavailable(res); + try { + const result = await svc.testConnection(req.params.name); + res.json(result); + } catch (err) { + badRequest(res, err); + } + }); + server.post(`${root}/:name/object-draft`, async (req: any, res: any) => { const svc = externalService(); if (!svc?.generateObjectDraft) return unavailable(res); diff --git a/packages/services/service-datasource/src/external-datasource-service.ts b/packages/services/service-datasource/src/external-datasource-service.ts index 98a1bfa203..36260eb2bf 100644 --- a/packages/services/service-datasource/src/external-datasource-service.ts +++ b/packages/services/service-datasource/src/external-datasource-service.ts @@ -155,6 +155,31 @@ export class ExternalDatasourceService implements IExternalDatasourceService { return tables; } + /** + * Probe a *saved* datasource by name with a live round-trip. Reuses the + * introspection path (driver connect + schema read) as a cheap connectivity + * check, so the secret is resolved through the same wired pool as the rest of + * the introspection surface — the caller never handles cleartext. Returns a + * structured result rather than throwing so the route can render ok/error + * uniformly. This backs the `datasource` `test_connection` action + * (`POST /datasources/:name/test`). + */ + async testConnection( + datasource: string, + ): Promise<{ ok: boolean; latencyMs?: number; tableCount?: number; error?: string }> { + const started = Date.now(); + try { + const schema = await this.config.introspect(datasource); + return { + ok: true, + latencyMs: Date.now() - started, + tableCount: Object.keys(schema.tables).length, + }; + } catch (err) { + return { ok: false, error: err instanceof Error ? err.message : String(err) }; + } + } + async generateObjectDraft( datasource: string, remoteName: string,