@objectstack/service-datasource 9.10.0 → 10.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -229,3 +229,62 @@ describe('DatasourceAdminServicePlugin: persistence + bound count', () => {
229
229
  await expect(service.removeDatasource('reporting')).rejects.toThrow(/1 object\(s\)/);
230
230
  });
231
231
  });
232
+
233
+ describe('DatasourceAdminServicePlugin: runtime datasource durability', () => {
234
+ /** In-memory `sys_metadata` engine shared across two boots (a "restart"). */
235
+ function fakeSysMetadataEngine() {
236
+ const rows: Array<Record<string, unknown>> = [];
237
+ return {
238
+ rows,
239
+ registerDriver() {},
240
+ registerDatasourceDef() {},
241
+ getDriverByName() { return undefined; },
242
+ findOne: async (_o: string, q: { where?: Record<string, unknown> }) => {
243
+ const w = q.where ?? {};
244
+ return rows.find((r) => Object.entries(w).every(([k, v]) => r[k] === v));
245
+ },
246
+ find: async (_o: string, q: { where?: Record<string, unknown> }) => {
247
+ const w = q.where ?? {};
248
+ return rows.filter((r) => Object.entries(w).every(([k, v]) => r[k] === v));
249
+ },
250
+ insert: async (_o: string, row: Record<string, unknown>) => { rows.push({ ...row }); },
251
+ update: async (_o: string, row: Record<string, unknown>, opts: { where: Record<string, unknown> }) => {
252
+ const i = rows.findIndex((r) => r.id === opts.where.id);
253
+ if (i >= 0) rows[i] = { ...rows[i], ...row };
254
+ },
255
+ delete: async (_o: string, opts: { where: Record<string, unknown> }) => {
256
+ const i = rows.findIndex((r) => r.id === opts.where.id);
257
+ if (i >= 0) rows.splice(i, 1);
258
+ },
259
+ };
260
+ }
261
+
262
+ it('persists a UI-created datasource to sys_metadata and restores it after a restart', async () => {
263
+ const data = fakeSysMetadataEngine();
264
+
265
+ // Boot #1: create a runtime sqlite datasource (no secret needed).
266
+ const b1 = await boot({ services: { data } });
267
+ await b1.service.createDatasource({ name: 'demo_ext', driver: 'sqlite', config: { filename: '/tmp/x.db' } });
268
+ // It is durably written to sys_metadata (not just the in-memory registry).
269
+ expect(data.rows.filter((r) => r.type === 'datasource' && r.name === 'demo_ext')).toHaveLength(1);
270
+
271
+ // Boot #2 = "restart": fresh in-memory registry, SAME sys_metadata engine.
272
+ const b2 = await boot({ services: { data } });
273
+ // Before restore, the fresh registry is empty.
274
+ expect(await b2.service.listDatasources()).toHaveLength(0);
275
+ // start() restores runtime rows from sys_metadata into the registry.
276
+ await b2.plugin.start(b2.ctx);
277
+ const after = await b2.service.listDatasources();
278
+ expect(after.map((d) => d.name)).toContain('demo_ext');
279
+ expect(after.find((d) => d.name === 'demo_ext')?.origin).toBe('runtime');
280
+ });
281
+
282
+ it('removes the durable sys_metadata row when a datasource is deleted', async () => {
283
+ const data = fakeSysMetadataEngine();
284
+ const b = await boot({ services: { data } });
285
+ await b.service.createDatasource({ name: 'gone', driver: 'sqlite', config: { filename: '/tmp/y.db' } });
286
+ expect(data.rows.some((r) => r.name === 'gone')).toBe(true);
287
+ await b.service.removeDatasource('gone');
288
+ expect(data.rows.some((r) => r.name === 'gone')).toBe(false);
289
+ });
290
+ });
@@ -286,3 +286,33 @@ describe('removeDatasource', () => {
286
286
  await expect(service.removeDatasource('nope')).rejects.toThrow(/not found/i);
287
287
  });
288
288
  });
289
+
290
+ describe('getDatasource', () => {
291
+ it('returns config + hasSecret with the credentialsRef stripped', async () => {
292
+ const { service } = makeHarness({
293
+ seed: [
294
+ {
295
+ name: 'pg',
296
+ driver: 'postgres',
297
+ origin: 'runtime',
298
+ config: { host: 'db', port: 5432, database: 'app' },
299
+ external: { credentialsRef: 'sys_secret://datasource/pg#1' },
300
+ },
301
+ ],
302
+ });
303
+ const ds = await service.getDatasource('pg');
304
+ expect(ds).toMatchObject({ name: 'pg', driver: 'postgres', origin: 'runtime', hasSecret: true });
305
+ expect(ds!.config).toEqual({ host: 'db', port: 5432, database: 'app' });
306
+ // The opaque credential handle must never be returned.
307
+ expect(JSON.stringify(ds)).not.toContain('sys_secret');
308
+ expect(JSON.stringify(ds)).not.toContain('credentialsRef');
309
+ });
310
+
311
+ it('reports hasSecret:false and returns undefined for unknown names', async () => {
312
+ const { service } = makeHarness({
313
+ seed: [{ name: 'lite', driver: 'sqlite', origin: 'runtime', config: { filename: '/tmp/a.db' } }],
314
+ });
315
+ expect((await service.getDatasource('lite'))!.hasSecret).toBe(false);
316
+ expect(await service.getDatasource('missing')).toBeUndefined();
317
+ });
318
+ });
@@ -2,6 +2,7 @@
2
2
 
3
3
  import type { PluginContext } from '@objectstack/core';
4
4
  import type { IHttpServer } from '@objectstack/spec/contracts';
5
+ import { DRIVER_CATALOG } from './driver-catalog.js';
5
6
 
6
7
  /**
7
8
  * Datasource lifecycle REST routes (ADR-0015 Addendum §3.5).
@@ -36,6 +37,14 @@ export function registerDatasourceAdminRoutes(
36
37
  }
37
38
  };
38
39
 
40
+ const externalService = (): any => {
41
+ try {
42
+ return ctx.getService<any>('external-datasource');
43
+ } catch {
44
+ return undefined;
45
+ }
46
+ };
47
+
39
48
  const unavailable = (res: any) =>
40
49
  res.status(503).json({ error: 'datasource_admin_unavailable' });
41
50
 
@@ -63,6 +72,72 @@ export function registerDatasourceAdminRoutes(
63
72
  res.json({ datasources });
64
73
  });
65
74
 
75
+ // Catalog of connection drivers + their JSON-Schema config (drives the
76
+ // Studio connection form). Static metadata — no service dependency, so it
77
+ // is always available even before any datasource-admin service is wired.
78
+ server.get(`${root}/drivers`, async (_req: any, res: any) => {
79
+ res.json({ drivers: DRIVER_CATALOG });
80
+ });
81
+
82
+ // Read-only schema introspection for the Studio "sync objects" flow.
83
+ // `GET /datasources/:name/remote-tables` lists the datasource's remote tables;
84
+ // `POST /datasources/:name/object-draft` generates an ObjectStack object
85
+ // definition draft for one table (introspect + type-map, no persistence —
86
+ // the caller creates the object through the normal metadata channel).
87
+ server.get(`${root}/:name/remote-tables`, async (req: any, res: any) => {
88
+ const svc = externalService();
89
+ if (!svc?.listRemoteTables) return unavailable(res);
90
+ try {
91
+ const tables = await svc.listRemoteTables(req.params.name);
92
+ res.json({ tables });
93
+ } catch (err) {
94
+ badRequest(res, err);
95
+ }
96
+ });
97
+
98
+ // Test a *saved* datasource by name with a live round-trip (backs the
99
+ // `datasource` `test_connection` action). Distinct from `POST /datasources/test`
100
+ // which probes an unsaved draft carried inline. Registered before the generic
101
+ // `:name` mutation routes.
102
+ // Read one datasource's full detail for the edit form (credential stripped;
103
+ // `config` is non-sensitive, plus a `hasSecret` flag). Registered after the
104
+ // static `/drivers` route so that literal segment is never captured as a name.
105
+ server.get(`${root}/:name`, async (req: any, res: any) => {
106
+ const svc = adminService();
107
+ if (!svc?.getDatasource) return unavailable(res);
108
+ try {
109
+ const datasource = await svc.getDatasource(req.params.name);
110
+ if (!datasource) return res.status(404).json({ error: 'not_found' });
111
+ res.json({ datasource });
112
+ } catch (err) {
113
+ badRequest(res, err);
114
+ }
115
+ });
116
+
117
+ server.post(`${root}/:name/test`, async (req: any, res: any) => {
118
+ const svc = externalService();
119
+ if (!svc?.testConnection) return unavailable(res);
120
+ try {
121
+ const result = await svc.testConnection(req.params.name);
122
+ res.json(result);
123
+ } catch (err) {
124
+ badRequest(res, err);
125
+ }
126
+ });
127
+
128
+ server.post(`${root}/:name/object-draft`, async (req: any, res: any) => {
129
+ const svc = externalService();
130
+ if (!svc?.generateObjectDraft) return unavailable(res);
131
+ const { table, ...opts } = (req.body as Record<string, unknown>) ?? {};
132
+ if (!table) return badRequest(res, new Error('Body field "table" is required.'));
133
+ try {
134
+ const draft = await svc.generateObjectDraft(req.params.name, String(table), opts);
135
+ res.json({ draft });
136
+ } catch (err) {
137
+ badRequest(res, err);
138
+ }
139
+ });
140
+
66
141
  // Probe a connection without persisting anything. Registered before the
67
142
  // `:name` routes so the literal `test` segment is never captured as a name.
68
143
  server.post(`${root}/test`, async (req: any, res: any) => {
@@ -33,6 +33,82 @@ interface DataEngineLike {
33
33
  registerDriver?: (driver: unknown, isDefault?: boolean) => void;
34
34
  registerDatasourceDef?: (def: { name: string; schemaMode?: string; external?: { allowWrites?: boolean } }) => void;
35
35
  getDriverByName?: (name: string) => unknown;
36
+ // sys_metadata CRUD used to persist runtime datasource records durably (same
37
+ // table runtime objects use). Optional — absent on lightweight kernels, in
38
+ // which case persistence degrades to in-memory (pre-existing behavior).
39
+ findOne?: (object: string, query: { where?: Record<string, unknown> }) => Promise<Record<string, unknown> | undefined | null>;
40
+ find?: (object: string, query: { where?: Record<string, unknown> }) => Promise<Record<string, unknown>[]>;
41
+ insert?: (object: string, row: Record<string, unknown>) => Promise<unknown>;
42
+ update?: (object: string, row: Record<string, unknown>, opts: { where: Record<string, unknown> }) => Promise<unknown>;
43
+ delete?: (object: string, opts: { where: Record<string, unknown> }) => Promise<unknown>;
44
+ }
45
+
46
+ /**
47
+ * Durable persistence for runtime datasource records via the `sys_metadata`
48
+ * table — the same store runtime objects use (the protocol writes objects there
49
+ * directly). `MetadataManager.register()` alone is in-memory unless a writable
50
+ * `datasource:` loader is wired, which standalone `serve` does not do; so a
51
+ * UI-created datasource vanished on restart. These helpers persist on write and
52
+ * the plugin restores them into the registry on boot before rehydrating pools.
53
+ * Credential cleartext is never stored — only the opaque `external.credentialsRef`.
54
+ */
55
+ const DS_META_TYPE = 'datasource';
56
+ const SYS_METADATA = 'sys_metadata';
57
+
58
+ function newMetaId(): string {
59
+ return typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function'
60
+ ? crypto.randomUUID()
61
+ : `meta_${Date.now()}_${Math.random().toString(36).slice(2)}`;
62
+ }
63
+
64
+ async function persistDatasourceRow(engine: DataEngineLike | undefined, record: { name: string }): Promise<void> {
65
+ if (!engine?.insert || !engine.findOne) return; // no durable store — in-memory only
66
+ const now = new Date().toISOString();
67
+ const existing = await engine.findOne(SYS_METADATA, {
68
+ where: { type: DS_META_TYPE, name: record.name, state: 'active' },
69
+ });
70
+ if (existing) {
71
+ await engine.update?.(
72
+ SYS_METADATA,
73
+ { metadata: JSON.stringify(record), updated_at: now, version: ((existing.version as number) || 0) + 1, state: 'active' },
74
+ { where: { id: existing.id } },
75
+ );
76
+ } else {
77
+ await engine.insert(SYS_METADATA, {
78
+ id: newMetaId(),
79
+ name: record.name,
80
+ type: DS_META_TYPE,
81
+ scope: 'platform',
82
+ metadata: JSON.stringify(record),
83
+ state: 'active',
84
+ version: 1,
85
+ created_at: now,
86
+ updated_at: now,
87
+ });
88
+ }
89
+ }
90
+
91
+ async function deleteDatasourceRow(engine: DataEngineLike | undefined, name: string): Promise<void> {
92
+ if (!engine?.findOne) return;
93
+ const existing = await engine.findOne(SYS_METADATA, { where: { type: DS_META_TYPE, name, state: 'active' } });
94
+ if (!existing) return;
95
+ if (engine.delete) await engine.delete(SYS_METADATA, { where: { id: existing.id } });
96
+ else await engine.update?.(SYS_METADATA, { state: 'inactive' }, { where: { id: existing.id } });
97
+ }
98
+
99
+ async function loadDatasourceRows(engine: DataEngineLike | undefined): Promise<Array<Record<string, unknown>>> {
100
+ if (!engine?.find) return [];
101
+ const rows = await engine.find(SYS_METADATA, { where: { type: DS_META_TYPE, state: 'active' } });
102
+ const out: Array<Record<string, unknown>> = [];
103
+ for (const r of rows ?? []) {
104
+ const raw = (r as { metadata?: unknown }).metadata;
105
+ try {
106
+ out.push(typeof raw === 'string' ? JSON.parse(raw) : (raw as Record<string, unknown>));
107
+ } catch {
108
+ /* skip corrupt row */
109
+ }
110
+ }
111
+ return out;
36
112
  }
37
113
 
38
114
  /**
@@ -147,7 +223,10 @@ export class DatasourceAdminServicePlugin implements Plugin {
147
223
  if (!metadata?.register) {
148
224
  throw new Error('Metadata service is unavailable; cannot persist datasource.');
149
225
  }
226
+ // In-memory registry (immediate visibility) + durable sys_metadata row
227
+ // (survives restart; restored on boot by restoreRuntimeDatasources).
150
228
  await metadata.register('datasource', record.name, record);
229
+ await persistDatasourceRow(engineOf(), record);
151
230
  },
152
231
 
153
232
  deleteDatasourceRecord: async (name) => {
@@ -156,6 +235,7 @@ export class DatasourceAdminServicePlugin implements Plugin {
156
235
  throw new Error('Metadata service is unavailable; cannot remove datasource.');
157
236
  }
158
237
  await metadata.unregister('datasource', name);
238
+ await deleteDatasourceRow(engineOf(), name);
159
239
  },
160
240
 
161
241
  writeSecret: async (input, hint) => {
@@ -223,16 +303,85 @@ export class DatasourceAdminServicePlugin implements Plugin {
223
303
  this.config = config;
224
304
  this.service = new DatasourceAdminService(config);
225
305
  ctx.registerService('datasource-admin', this.service);
306
+
307
+ // Setup-app nav (ADR-0029 D7): datasources are a *capability* this plugin
308
+ // owns, so it contributes its own entry into the `group_integrations` slot
309
+ // (core setup-nav must not fill capability-owned slots). datasource is a
310
+ // metadata type, so the entry opens the generic metadata-admin engine route
311
+ // rather than a bespoke page or an object view.
312
+ try {
313
+ const manifest = ctx.getService<{ register(m: any): void }>('manifest');
314
+ if (manifest && typeof manifest.register === 'function') {
315
+ manifest.register({
316
+ id: 'com.objectstack.service-datasource.nav',
317
+ namespace: 'sys',
318
+ version: this.version,
319
+ type: 'plugin',
320
+ scope: 'system',
321
+ name: 'Datasource Navigation',
322
+ description: 'Contributes the Datasources entry to the Setup app Integrations group.',
323
+ navigationContributions: [
324
+ {
325
+ app: 'setup',
326
+ group: 'group_integrations',
327
+ priority: 100,
328
+ items: [
329
+ {
330
+ id: 'nav_datasources',
331
+ type: 'url',
332
+ label: 'Datasources',
333
+ url: '/apps/setup/component/metadata/resource?type=datasource',
334
+ icon: 'database',
335
+ requiredPermissions: ['manage_platform_settings'],
336
+ },
337
+ ],
338
+ },
339
+ ],
340
+ });
341
+ }
342
+ } catch (err) {
343
+ this.options.logger?.warn?.('datasource nav contribution skipped', err);
344
+ }
226
345
  }
227
346
 
228
347
  async start(ctx: PluginContext): Promise<void> {
229
- // Rebuild live connection pools for persisted runtime datasources before
230
- // announcing readiness a node restart otherwise leaves UI-created
231
- // datasources with a record but no open pool until the next write.
348
+ // Restore UI-created (runtime) datasources from the durable sys_metadata
349
+ // store back into the in-memory registry, THEN rebuild their live pools.
350
+ // `register()` is in-memory only in standalone serve (no writable
351
+ // `datasource:` loader), so without this a node restart drops every
352
+ // UI-created datasource. Code-defined datasources come from the artifact and
353
+ // are unaffected.
354
+ await this.restoreRuntimeDatasources(ctx);
232
355
  await this.rehydratePools();
233
356
  if (this.service) await ctx.trigger('datasource-admin:ready', this.service);
234
357
  }
235
358
 
359
+ /** Reload persisted runtime datasource rows (sys_metadata) into the registry. */
360
+ private async restoreRuntimeDatasources(ctx: PluginContext): Promise<void> {
361
+ const engine = safeGetService<DataEngineLike>(ctx, 'data');
362
+ const metadata = safeGetService<MetadataServiceLike>(ctx, 'metadata');
363
+ if (!engine?.find || !metadata?.register) return;
364
+ let rows: Array<Record<string, unknown>>;
365
+ try {
366
+ rows = await loadDatasourceRows(engine);
367
+ } catch (err) {
368
+ this.options.logger?.warn?.('datasource restore: reading sys_metadata failed', err);
369
+ return;
370
+ }
371
+ let restored = 0;
372
+ for (const rec of rows) {
373
+ const name = (rec as { name?: string }).name;
374
+ if (!name) continue;
375
+ try {
376
+ await metadata.register('datasource', name, rec);
377
+ restored += 1;
378
+ } catch (err) {
379
+ this.options.logger?.warn?.(`datasource restore: register '${name}' failed`, err);
380
+ }
381
+ }
382
+ if (restored > 0) this.options.logger?.info?.(`datasource: restored ${restored} runtime record(s) from sys_metadata`);
383
+ }
384
+
236
385
  /**
237
386
  * Boot-time rehydration: list persisted runtime datasources and re-register
238
387
  * each one's connection pool (driver build → connect → registerDriver),
@@ -130,6 +130,36 @@ export class DatasourceAdminService implements IDatasourceAdminService {
130
130
  return summaries;
131
131
  }
132
132
 
133
+ /**
134
+ * Read one datasource's full detail for editing, with the credential stripped.
135
+ * Returns `config` (non-sensitive — credentials live in `sys_secret`, never in
136
+ * config), `origin`, and a `hasSecret` flag so the UI can show "leave blank to
137
+ * keep" without ever receiving the `credentialsRef` or any cleartext. Returns
138
+ * `undefined` when the name is unknown.
139
+ */
140
+ async getDatasource(name: string): Promise<
141
+ | (Pick<StoredDatasource, 'name' | 'label' | 'driver' | 'schemaMode' | 'config' | 'active' | 'definedIn'> & {
142
+ origin: 'code' | 'runtime';
143
+ hasSecret: boolean;
144
+ })
145
+ | undefined
146
+ > {
147
+ const rec = await this.config.getDatasourceRecord(name);
148
+ if (!rec) return undefined;
149
+ const hasSecret = Boolean(rec.external?.credentialsRef);
150
+ return {
151
+ name: rec.name,
152
+ label: rec.label,
153
+ driver: rec.driver,
154
+ schemaMode: rec.schemaMode ?? 'managed',
155
+ config: rec.config ?? {},
156
+ active: rec.active ?? true,
157
+ origin: rec.origin === 'runtime' ? 'runtime' : 'code',
158
+ hasSecret,
159
+ ...(rec.definedIn ? { definedIn: rec.definedIn } : {}),
160
+ };
161
+ }
162
+
133
163
  async testConnection(input: DatasourceDraft, secret?: SecretInput): Promise<TestConnectionResult> {
134
164
  if (!input?.driver) {
135
165
  return { ok: false, error: 'A driver is required to test a connection.' };
@@ -0,0 +1,113 @@
1
+ // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2
+
3
+ /**
4
+ * Built-in datasource driver catalog.
5
+ *
6
+ * Each entry carries a JSON-Schema `configSchema` describing the driver's
7
+ * connection options, so the Studio UI can render a typed connection form
8
+ * instead of a raw-JSON editor (the `DriverDefinitionSchema.configSchema`
9
+ * contract — "Used by the UI to generate the connection form").
10
+ *
11
+ * Served by `GET /api/v1/datasources/drivers`. This is the curated set of
12
+ * connection drivers the connection form offers; a future runtime driver
13
+ * registry can supersede this list without changing the route contract.
14
+ */
15
+
16
+ export interface DriverCatalogEntry {
17
+ /** Unique driver identifier used as `datasource.driver`. */
18
+ id: string;
19
+ /** Display label. */
20
+ label: string;
21
+ /** Optional one-line description. */
22
+ description?: string;
23
+ /** Optional Lucide icon name. */
24
+ icon?: string;
25
+ /** JSON Schema (draft-2020-12) for the driver's `config` object. */
26
+ configSchema: Record<string, unknown>;
27
+ }
28
+
29
+ const SSL_PROP = {
30
+ ssl: { type: 'boolean', title: 'Use SSL/TLS', default: false },
31
+ } as const;
32
+
33
+ export const DRIVER_CATALOG: DriverCatalogEntry[] = [
34
+ {
35
+ id: 'memory',
36
+ label: 'In-Memory',
37
+ description: 'Ephemeral in-memory driver for dev, tests, and prototyping. No connection settings.',
38
+ icon: 'memory-stick',
39
+ configSchema: { type: 'object', properties: {}, additionalProperties: false },
40
+ },
41
+ {
42
+ id: 'sqlite',
43
+ label: 'SQLite',
44
+ description: 'File-backed (or in-memory) SQL database. Great for local dev and small deployments.',
45
+ icon: 'database',
46
+ configSchema: {
47
+ type: 'object',
48
+ properties: {
49
+ filename: {
50
+ type: 'string',
51
+ title: 'Filename',
52
+ description: 'Database file path, or ":memory:" for an ephemeral in-memory database.',
53
+ default: ':memory:',
54
+ },
55
+ },
56
+ required: ['filename'],
57
+ additionalProperties: false,
58
+ },
59
+ },
60
+ {
61
+ id: 'postgres',
62
+ label: 'PostgreSQL',
63
+ description: 'PostgreSQL connection. Supply host/port/database or a connection URL.',
64
+ icon: 'database',
65
+ configSchema: {
66
+ type: 'object',
67
+ properties: {
68
+ url: { type: 'string', title: 'Connection URL', description: 'postgres://user:pass@host:5432/db (overrides the fields below when set).' },
69
+ host: { type: 'string', title: 'Host', default: 'localhost' },
70
+ port: { type: 'number', title: 'Port', default: 5432 },
71
+ database: { type: 'string', title: 'Database' },
72
+ username: { type: 'string', title: 'User' },
73
+ password: { type: 'string', title: 'Password', format: 'password' },
74
+ schema: { type: 'string', title: 'Schema', default: 'public' },
75
+ ...SSL_PROP,
76
+ },
77
+ additionalProperties: true,
78
+ },
79
+ },
80
+ {
81
+ id: 'mysql',
82
+ label: 'MySQL / MariaDB',
83
+ description: 'MySQL or MariaDB connection.',
84
+ icon: 'database',
85
+ configSchema: {
86
+ type: 'object',
87
+ properties: {
88
+ host: { type: 'string', title: 'Host', default: 'localhost' },
89
+ port: { type: 'number', title: 'Port', default: 3306 },
90
+ database: { type: 'string', title: 'Database' },
91
+ username: { type: 'string', title: 'User' },
92
+ password: { type: 'string', title: 'Password', format: 'password' },
93
+ ...SSL_PROP,
94
+ },
95
+ additionalProperties: true,
96
+ },
97
+ },
98
+ {
99
+ id: 'mongo',
100
+ label: 'MongoDB',
101
+ description: 'MongoDB connection via a connection URI.',
102
+ icon: 'database',
103
+ configSchema: {
104
+ type: 'object',
105
+ properties: {
106
+ url: { type: 'string', title: 'Connection URI', description: 'mongodb://host:27017' },
107
+ database: { type: 'string', title: 'Database' },
108
+ },
109
+ required: ['url'],
110
+ additionalProperties: true,
111
+ },
112
+ },
113
+ ];
@@ -155,6 +155,31 @@ export class ExternalDatasourceService implements IExternalDatasourceService {
155
155
  return tables;
156
156
  }
157
157
 
158
+ /**
159
+ * Probe a *saved* datasource by name with a live round-trip. Reuses the
160
+ * introspection path (driver connect + schema read) as a cheap connectivity
161
+ * check, so the secret is resolved through the same wired pool as the rest of
162
+ * the introspection surface — the caller never handles cleartext. Returns a
163
+ * structured result rather than throwing so the route can render ok/error
164
+ * uniformly. This backs the `datasource` `test_connection` action
165
+ * (`POST /datasources/:name/test`).
166
+ */
167
+ async testConnection(
168
+ datasource: string,
169
+ ): Promise<{ ok: boolean; latencyMs?: number; tableCount?: number; error?: string }> {
170
+ const started = Date.now();
171
+ try {
172
+ const schema = await this.config.introspect(datasource);
173
+ return {
174
+ ok: true,
175
+ latencyMs: Date.now() - started,
176
+ tableCount: Object.keys(schema.tables).length,
177
+ };
178
+ } catch (err) {
179
+ return { ok: false, error: err instanceof Error ? err.message : String(err) };
180
+ }
181
+ }
182
+
158
183
  async generateObjectDraft(
159
184
  datasource: string,
160
185
  remoteName: string,