@objectstack/service-datasource 9.11.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.
- package/.turbo/turbo-build.log +10 -10
- package/CHANGELOG.md +19 -0
- package/README.md +34 -0
- package/dist/index.cjs +359 -63
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +28 -0
- package/dist/index.d.ts +28 -0
- package/dist/index.js +296 -0
- package/dist/index.js.map +1 -1
- package/package.json +8 -8
- package/src/__tests__/admin-routes.test.ts +58 -0
- package/src/__tests__/datasource-admin-plugin.test.ts +59 -0
- package/src/__tests__/datasource-admin-service.test.ts +30 -0
- package/src/admin-routes.ts +75 -0
- package/src/datasource-admin-plugin.ts +152 -3
- package/src/datasource-admin-service.ts +30 -0
- package/src/driver-catalog.ts +113 -0
- package/src/external-datasource-service.ts +25 -0
|
@@ -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
|
+
});
|
package/src/admin-routes.ts
CHANGED
|
@@ -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
|
-
//
|
|
230
|
-
//
|
|
231
|
-
//
|
|
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,
|