@objectstack/service-datasource 7.6.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.
Files changed (37) hide show
  1. package/.turbo/turbo-build.log +28 -0
  2. package/CHANGELOG.md +94 -0
  3. package/LICENSE +202 -0
  4. package/LICENSE.apache +202 -0
  5. package/README.md +50 -0
  6. package/dist/contracts/index.cjs +1 -0
  7. package/dist/contracts/index.cjs.map +1 -0
  8. package/dist/contracts/index.d.cts +178 -0
  9. package/dist/contracts/index.d.ts +178 -0
  10. package/dist/contracts/index.js +1 -0
  11. package/dist/contracts/index.js.map +1 -0
  12. package/dist/index.cjs +995 -0
  13. package/dist/index.cjs.map +1 -0
  14. package/dist/index.d.cts +414 -0
  15. package/dist/index.d.ts +414 -0
  16. package/dist/index.js +995 -0
  17. package/dist/index.js.map +1 -0
  18. package/package.json +61 -0
  19. package/src/__tests__/admin-routes.test.ts +106 -0
  20. package/src/__tests__/datasource-admin-plugin.test.ts +231 -0
  21. package/src/__tests__/datasource-admin-service.test.ts +288 -0
  22. package/src/__tests__/datasource-secret-binder.test.ts +101 -0
  23. package/src/__tests__/external-datasource-service.test.ts +360 -0
  24. package/src/admin-routes.ts +117 -0
  25. package/src/contracts/datasource-admin-service.ts +119 -0
  26. package/src/contracts/datasource-driver-factory.ts +77 -0
  27. package/src/contracts/index.ts +18 -0
  28. package/src/datasource-admin-plugin.ts +362 -0
  29. package/src/datasource-admin-service.ts +297 -0
  30. package/src/datasource-secret-binder.ts +144 -0
  31. package/src/default-datasource-driver-factory.ts +185 -0
  32. package/src/external-datasource-service.ts +456 -0
  33. package/src/index.ts +73 -0
  34. package/src/logger.ts +11 -0
  35. package/src/plugin.ts +119 -0
  36. package/tsconfig.json +17 -0
  37. package/tsup.config.ts +19 -0
@@ -0,0 +1,362 @@
1
+ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2
+
3
+ import type { Plugin, PluginContext } from '@objectstack/core';
4
+ import { registerMetadataTypeActions } from '@objectstack/spec/kernel';
5
+ import type {
6
+ IDatasourceDriverFactory,
7
+ DatasourceConnectionSpec,
8
+ TestConnectionResult,
9
+ } from './contracts/index.js';
10
+ import {
11
+ DatasourceAdminService,
12
+ type DatasourceAdminServiceConfig,
13
+ type StoredDatasource,
14
+ type ProbeInput,
15
+ } from './datasource-admin-service.js';
16
+ import type { Logger } from './logger.js';
17
+
18
+ /**
19
+ * Minimal metadata-service surface used for datasource persistence + the
20
+ * bound-object count. Kept structural so the plugin doesn't hard-depend on the
21
+ * concrete `MetadataManager`.
22
+ */
23
+ interface MetadataServiceLike {
24
+ get: (type: string, name: string) => Promise<unknown>;
25
+ list: (type: string) => Promise<unknown[]>;
26
+ register: (type: string, name: string, data: unknown) => Promise<void>;
27
+ unregister: (type: string, name: string) => Promise<void>;
28
+ listObjects?: () => Promise<unknown[]>;
29
+ }
30
+
31
+ /** Engine surface used for hot pool (de)registration. */
32
+ interface DataEngineLike {
33
+ registerDriver?: (driver: unknown, isDefault?: boolean) => void;
34
+ registerDatasourceDef?: (def: { name: string; schemaMode?: string; external?: { allowWrites?: boolean } }) => void;
35
+ getDriverByName?: (name: string) => unknown;
36
+ }
37
+
38
+ /**
39
+ * Host-provided secret binding. Encrypts a cleartext credential into the secret
40
+ * store and returns an opaque `credentialsRef`; `unbind` deletes it. Wired by
41
+ * the stack that owns the `ICryptoProvider` + `sys_secret` store. When absent,
42
+ * the plugin fails *closed*: creating/updating a datasource *with* a secret
43
+ * throws rather than risk persisting cleartext.
44
+ */
45
+ export interface SecretBinder {
46
+ bind: (input: { value: string; namespace?: string; key?: string }, hint: { name: string }) => Promise<string>;
47
+ unbind?: (credentialsRef: string) => Promise<void>;
48
+ /**
49
+ * Dereference a `credentialsRef` back to cleartext for opening a live
50
+ * connection (boot rehydration + hot pool registration). Optional: when
51
+ * absent, pools for secret-bearing datasources are built without the
52
+ * credential (fine for credential-less drivers like sqlite/memory).
53
+ */
54
+ resolve?: (credentialsRef: string) => Promise<string | undefined>;
55
+ }
56
+
57
+ export interface DatasourceAdminServicePluginOptions {
58
+ /** Secret binding backed by the host's crypto provider + `sys_secret`. */
59
+ secrets?: SecretBinder;
60
+ /** Override the driver factory (defaults to the `'datasource-driver-factory'` service). */
61
+ driverFactory?: IDatasourceDriverFactory;
62
+ logger?: Logger;
63
+ }
64
+
65
+ /**
66
+ * DatasourceAdminServicePlugin — registers `IDatasourceAdminService` into the
67
+ * kernel as the `'datasource-admin'` service (ADR-0015 Addendum).
68
+ *
69
+ * Bridges the decoupled {@link DatasourceAdminService} to live infrastructure:
70
+ * - persistence + bound-object count via the `'metadata'` service
71
+ * (`register`/`unregister` write through to the runtime DB loader),
72
+ * - connection probe + hot pool (de)registration via the
73
+ * `'datasource-driver-factory'` capability and the `'data'` engine,
74
+ * - secret encryption via a host-provided {@link SecretBinder} (fail-closed).
75
+ *
76
+ * Every dependency degrades gracefully: a missing driver factory turns
77
+ * `testConnection` into a clear `{ ok: false }` and skips hot pool registration
78
+ * (the driver is picked up at next boot); a missing secret binder makes
79
+ * secret-bearing create/update fail loudly instead of leaking cleartext.
80
+ */
81
+ export class DatasourceAdminServicePlugin implements Plugin {
82
+ name = 'com.objectstack.service-datasource-admin';
83
+ version = '1.0.0';
84
+ type = 'standard' as const;
85
+ dependencies: string[] = [];
86
+
87
+ private service?: DatasourceAdminService;
88
+ private config?: DatasourceAdminServiceConfig;
89
+ private readonly options: DatasourceAdminServicePluginOptions;
90
+
91
+ constructor(options: DatasourceAdminServicePluginOptions = {}) {
92
+ this.options = options;
93
+ }
94
+
95
+ async init(ctx: PluginContext): Promise<void> {
96
+ const logger = this.options.logger;
97
+
98
+ // Contribute the metadata-admin "Test connection" type-level action,
99
+ // co-located with the route handler that serves it
100
+ // (`POST /api/v1/datasources/:name/test`, see admin-routes.ts). The
101
+ // open-source framework deliberately ships no declarative datasource
102
+ // action, so the button is emitted by `/api/v1/meta` only when this
103
+ // backend plugin is installed — never advertising a route the host
104
+ // can't serve. `${ctx.recordId}` resolves to the datasource's name.
105
+ registerMetadataTypeActions('datasource', [
106
+ {
107
+ name: 'test_connection',
108
+ label: 'Test connection',
109
+ icon: 'plug-zap',
110
+ type: 'api',
111
+ target: '/api/v1/datasources/${ctx.recordId}/test',
112
+ method: 'POST',
113
+ variant: 'secondary',
114
+ refreshAfter: false,
115
+ locations: ['record_header', 'list_item'],
116
+ },
117
+ ] as any);
118
+
119
+ // Resolve infra services lazily, per call — `init()` may run before the
120
+ // `data` / `metadata` plugins have registered their services (plugin start
121
+ // order is dependency- not registration-driven), and admin requests only
122
+ // arrive long after the full boot completes.
123
+ const metadataOf = (): MetadataServiceLike | undefined =>
124
+ safeGetService<MetadataServiceLike>(ctx, 'metadata');
125
+ const engineOf = (): DataEngineLike | undefined =>
126
+ safeGetService<DataEngineLike>(ctx, 'data');
127
+
128
+ const factory = (): IDatasourceDriverFactory | undefined =>
129
+ this.options.driverFactory ?? safeGetService<IDatasourceDriverFactory>(ctx, 'datasource-driver-factory');
130
+
131
+ const config: DatasourceAdminServiceConfig = {
132
+ probe: (input) => this.probe(factory(), input),
133
+
134
+ listDatasourceRecords: async () => {
135
+ const rows = ((await metadataOf()?.list('datasource')) ?? []) as StoredDatasource[];
136
+ // Artefact-loaded rows may omit `origin`; treat them as code-defined.
137
+ return rows.map((r) => ({ ...r, origin: r.origin ?? 'code' }));
138
+ },
139
+
140
+ getDatasourceRecord: async (name) => {
141
+ const row = (await metadataOf()?.get('datasource', name)) as StoredDatasource | undefined;
142
+ return row ? { ...row, origin: row.origin ?? 'code' } : undefined;
143
+ },
144
+
145
+ putDatasourceRecord: async (record) => {
146
+ const metadata = metadataOf();
147
+ if (!metadata?.register) {
148
+ throw new Error('Metadata service is unavailable; cannot persist datasource.');
149
+ }
150
+ await metadata.register('datasource', record.name, record);
151
+ },
152
+
153
+ deleteDatasourceRecord: async (name) => {
154
+ const metadata = metadataOf();
155
+ if (!metadata?.unregister) {
156
+ throw new Error('Metadata service is unavailable; cannot remove datasource.');
157
+ }
158
+ await metadata.unregister('datasource', name);
159
+ },
160
+
161
+ writeSecret: async (input, hint) => {
162
+ const binder = this.options.secrets;
163
+ if (!binder?.bind) {
164
+ throw new Error(
165
+ 'No secret store configured: refusing to persist a datasource credential in cleartext. ' +
166
+ 'Wire a SecretBinder (CryptoProvider + sys_secret) into DatasourceAdminServicePlugin.',
167
+ );
168
+ }
169
+ return binder.bind(input, hint);
170
+ },
171
+
172
+ removeSecret: async (ref) => {
173
+ await this.options.secrets?.unbind?.(ref);
174
+ },
175
+
176
+ countBoundObjects: async (datasource) => {
177
+ const metadata = metadataOf();
178
+ const objects = ((await metadata?.listObjects?.()) ??
179
+ (await metadata?.list('object')) ??
180
+ []) as Array<{ datasource?: string }>;
181
+ return objects.filter((o) => o?.datasource === datasource).length;
182
+ },
183
+
184
+ registerPool: async (record) => {
185
+ const f = factory();
186
+ const engine = engineOf();
187
+ if (!f || !engine?.registerDriver || !f.supports(record.driver)) return;
188
+ // Recover the cleartext credential from `sys_secret` so the pool opens
189
+ // with the real password. The cleartext is never persisted on the
190
+ // record (only `credentialsRef`), so it must be dereferenced here —
191
+ // both on create/update and on boot rehydration. Credential-less
192
+ // drivers (sqlite/memory) simply have no ref and skip this.
193
+ const credentialsRef = record.external?.credentialsRef;
194
+ const secret = credentialsRef ? await this.options.secrets?.resolve?.(credentialsRef) : undefined;
195
+ const handle = await f.create({ ...this.toSpec(record), ...(secret ? { secret } : {}) });
196
+ if (typeof handle?.connect === 'function') await handle.connect();
197
+ // The engine routes a datasource to a driver by `driver.name === <datasource name>`
198
+ // (see ObjectQL engine.getDriver). Prefer the factory's underlying engine
199
+ // driver (the `driver` escape hatch); fall back to the handle itself. Stamp
200
+ // the name so routing resolves to this pool.
201
+ const engineDriver = (handle.driver ?? handle) as { name?: string };
202
+ try {
203
+ engineDriver.name = record.name;
204
+ } catch {
205
+ /* frozen driver — registration may still work if name already matches */
206
+ }
207
+ engine.registerDriver(engineDriver);
208
+ engine.registerDatasourceDef?.({
209
+ name: record.name,
210
+ schemaMode: record.schemaMode,
211
+ external: record.external as { allowWrites?: boolean } | undefined,
212
+ });
213
+ },
214
+
215
+ unregisterPool: async (name) => {
216
+ const driver = engineOf()?.getDriverByName?.(name) as { disconnect?: () => Promise<void> } | undefined;
217
+ if (typeof driver?.disconnect === 'function') await driver.disconnect();
218
+ },
219
+
220
+ logger,
221
+ };
222
+
223
+ this.config = config;
224
+ this.service = new DatasourceAdminService(config);
225
+ ctx.registerService('datasource-admin', this.service);
226
+ }
227
+
228
+ 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.
232
+ await this.rehydratePools();
233
+ if (this.service) await ctx.trigger('datasource-admin:ready', this.service);
234
+ }
235
+
236
+ /**
237
+ * Boot-time rehydration: list persisted runtime datasources and re-register
238
+ * each one's connection pool (driver build → connect → registerDriver),
239
+ * decrypting its `sys_secret` credential on the way via the configured
240
+ * `registerPool` (which resolves `credentialsRef`). Code-defined datasources
241
+ * are owned by the host stack's own boot path and skipped here. Entirely
242
+ * best-effort: a missing factory/engine, an unpersisted dev store (nothing
243
+ * to rehydrate), or a single failing pool never blocks boot.
244
+ */
245
+ private async rehydratePools(): Promise<void> {
246
+ const cfg = this.config;
247
+ if (!cfg?.registerPool || !cfg.listDatasourceRecords) return;
248
+
249
+ let records: StoredDatasource[];
250
+ try {
251
+ records = await cfg.listDatasourceRecords();
252
+ } catch (err) {
253
+ this.options.logger?.warn?.('datasource rehydrate: listing records failed', err);
254
+ return;
255
+ }
256
+
257
+ const runtime = records.filter((r) => r.origin === 'runtime' && (r.active ?? true));
258
+ if (runtime.length === 0) return;
259
+
260
+ let registered = 0;
261
+ for (const record of runtime) {
262
+ try {
263
+ await cfg.registerPool(record);
264
+ registered++;
265
+ } catch (err) {
266
+ this.options.logger?.warn?.(`datasource rehydrate: pool '${record.name}' failed`, err);
267
+ }
268
+ }
269
+ this.options.logger?.info?.(
270
+ `Rehydrated ${registered}/${runtime.length} runtime datasource pool(s) on boot`,
271
+ );
272
+ }
273
+
274
+ async destroy(): Promise<void> {
275
+ this.service = undefined;
276
+ }
277
+
278
+ // --- internals -----------------------------------------------------------
279
+
280
+ private toSpec(record: StoredDatasource): DatasourceConnectionSpec {
281
+ return {
282
+ name: record.name,
283
+ driver: record.driver,
284
+ config: record.config ?? {},
285
+ external: record.external,
286
+ pool: record.pool,
287
+ };
288
+ }
289
+
290
+ /** Probe a connection via the driver factory: build → connect → ping → close. */
291
+ private async probe(
292
+ factory: IDatasourceDriverFactory | undefined,
293
+ input: ProbeInput,
294
+ ): Promise<TestConnectionResult> {
295
+ if (!factory) {
296
+ return { ok: false, error: 'No driver factory is registered to test connections.' };
297
+ }
298
+ if (!factory.supports(input.driver)) {
299
+ return { ok: false, error: `No driver factory supports driver '${input.driver}'.` };
300
+ }
301
+
302
+ let driver: any;
303
+ try {
304
+ driver = await factory.create({
305
+ driver: input.driver,
306
+ config: input.config,
307
+ secret: input.secret,
308
+ external: input.external,
309
+ });
310
+ } catch (err) {
311
+ return { ok: false, error: `Failed to build driver: ${errMsg(err)}` };
312
+ }
313
+
314
+ const startedAt = monotonicNow();
315
+ try {
316
+ if (typeof driver?.connect === 'function') await driver.connect();
317
+ // Prefer a cheap ping; fall back to the engine driver's health check, then
318
+ // a schema introspection round-trip — whichever the handle exposes.
319
+ if (typeof driver?.ping === 'function') await driver.ping();
320
+ else if (typeof driver?.checkHealth === 'function') await driver.checkHealth();
321
+ else if (typeof driver?.introspectSchema === 'function') await driver.introspectSchema();
322
+ const latencyMs = elapsedSince(startedAt);
323
+ let serverVersion: string | undefined;
324
+ try {
325
+ serverVersion = typeof driver?.serverVersion === 'function' ? await driver.serverVersion() : undefined;
326
+ } catch {
327
+ /* version is best-effort */
328
+ }
329
+ return { ok: true, latencyMs, ...(serverVersion ? { serverVersion } : {}) };
330
+ } catch (err) {
331
+ return { ok: false, error: errMsg(err) };
332
+ } finally {
333
+ try {
334
+ if (typeof driver?.disconnect === 'function') await driver.disconnect();
335
+ } catch {
336
+ /* best-effort teardown */
337
+ }
338
+ }
339
+ }
340
+ }
341
+
342
+ function safeGetService<T>(ctx: PluginContext, name: string): T | undefined {
343
+ try {
344
+ return ctx.getService<T>(name);
345
+ } catch {
346
+ return undefined;
347
+ }
348
+ }
349
+
350
+ function errMsg(err: unknown): string {
351
+ return err instanceof Error ? err.message : String(err);
352
+ }
353
+
354
+ /** Monotonic clock when available (avoids wall-clock skew); falls back to 0. */
355
+ function monotonicNow(): number {
356
+ const perf = (globalThis as { performance?: { now?: () => number } }).performance;
357
+ return typeof perf?.now === 'function' ? perf.now() : 0;
358
+ }
359
+
360
+ function elapsedSince(startedAt: number): number {
361
+ return Math.max(0, Math.round(monotonicNow() - startedAt));
362
+ }
@@ -0,0 +1,297 @@
1
+ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2
+
3
+ /**
4
+ * DatasourceAdminService — implements {@link IDatasourceAdminService}
5
+ * (ADR-0015 Addendum) on top of injected persistence + secret + driver probe
6
+ * callbacks.
7
+ *
8
+ * Like its federation sibling `ExternalDatasourceService`, this service is
9
+ * intentionally decoupled from the kernel: every side effect (connection probe,
10
+ * metadata read/write, secret write, bound-object count, hot pool (de)register)
11
+ * is injected via {@link DatasourceAdminServiceConfig}, so the lifecycle rules
12
+ * (origin gating, secret indirection, removal safety) are pure and unit-testable.
13
+ *
14
+ * Invariants enforced here, independent of the wiring:
15
+ * - Code-defined datasources (`origin: 'code'`) are read-only — update/remove
16
+ * reject them, and create refuses a name a code datasource already owns.
17
+ * - A runtime datasource never shadows a code one (code wins on collision).
18
+ * - Credentials never persist in cleartext: the cleartext {@link SecretInput}
19
+ * transits create/update/test only; create/update write it to the secret
20
+ * store and persist only the returned `credentialsRef`.
21
+ * - Removal is refused while objects are still bound to the datasource.
22
+ */
23
+
24
+ import type {
25
+ IDatasourceAdminService,
26
+ DatasourceDraft,
27
+ SecretInput,
28
+ TestConnectionResult,
29
+ DatasourceSummary,
30
+ } from './contracts/index.js';
31
+ import type { Logger } from './logger.js';
32
+
33
+ /** Datasource name rule (mirrors `DatasourceSchema.name`). */
34
+ const NAME_RE = /^[a-z_][a-z0-9_]*$/;
35
+
36
+ /**
37
+ * A persisted datasource record (subset of `Datasource`). `origin` distinguishes
38
+ * code-defined from runtime; `external.credentialsRef` is the opaque secret
39
+ * handle — never a cleartext credential.
40
+ */
41
+ export interface StoredDatasource {
42
+ name: string;
43
+ label?: string;
44
+ driver: string;
45
+ schemaMode?: 'managed' | 'external' | 'validate-only';
46
+ config?: Record<string, unknown>;
47
+ external?: (Record<string, unknown> & { credentialsRef?: string }) | undefined;
48
+ pool?: Record<string, unknown>;
49
+ active?: boolean;
50
+ origin?: 'code' | 'runtime';
51
+ /** Package that defines a code-origin datasource, when known. */
52
+ definedIn?: string;
53
+ }
54
+
55
+ /** What a connection probe needs (cleartext secret is transient, never stored). */
56
+ export interface ProbeInput {
57
+ driver: string;
58
+ config: Record<string, unknown>;
59
+ /** Cleartext secret used for this probe only (e.g. password / DSN). */
60
+ secret?: string;
61
+ external?: Record<string, unknown>;
62
+ timeoutMs?: number;
63
+ }
64
+
65
+ /**
66
+ * Injected dependencies. The plugin supplies real implementations backed by the
67
+ * driver registry, `IMetadataService` (runtime store), and the secret store;
68
+ * tests supply fakes.
69
+ */
70
+ export interface DatasourceAdminServiceConfig {
71
+ /** Probe a connection live (driver connect + cheap round-trip). */
72
+ probe: (input: ProbeInput) => Promise<TestConnectionResult>;
73
+ /** Read every datasource record (code + runtime). */
74
+ listDatasourceRecords: () => Promise<StoredDatasource[]>;
75
+ /** Read one datasource record by name. */
76
+ getDatasourceRecord: (name: string) => Promise<StoredDatasource | undefined>;
77
+ /** Persist a runtime datasource record into the runtime metadata store. */
78
+ putDatasourceRecord: (record: StoredDatasource) => Promise<void>;
79
+ /** Remove a runtime datasource record from the runtime metadata store. */
80
+ deleteDatasourceRecord: (name: string) => Promise<void>;
81
+ /** Encrypt + store a secret, returning an opaque `credentialsRef`. */
82
+ writeSecret: (input: SecretInput, hint: { name: string }) => Promise<string>;
83
+ /** Best-effort delete of a stored secret by ref (cleanup on remove/rewrap). */
84
+ removeSecret?: (credentialsRef: string) => Promise<void>;
85
+ /** Count objects bound to a datasource (removal blocked while > 0). */
86
+ countBoundObjects: (datasource: string) => Promise<number>;
87
+ /** Hot-(re)register a runtime datasource's connection pool after write. */
88
+ registerPool?: (record: StoredDatasource) => Promise<void> | void;
89
+ /** Tear down a runtime datasource's pool on remove. */
90
+ unregisterPool?: (name: string) => Promise<void> | void;
91
+ logger?: Logger;
92
+ }
93
+
94
+ export class DatasourceAdminService implements IDatasourceAdminService {
95
+ constructor(private readonly config: DatasourceAdminServiceConfig) {}
96
+
97
+ private get logger(): Logger | undefined {
98
+ return this.config.logger;
99
+ }
100
+
101
+ async listDatasources(): Promise<DatasourceSummary[]> {
102
+ const records = await this.config.listDatasourceRecords();
103
+
104
+ // Group by name; code wins on collision, and a shadowed runtime row marks
105
+ // the effective (code) entry as conflicting.
106
+ const byName = new Map<string, { code?: StoredDatasource; runtime?: StoredDatasource }>();
107
+ for (const rec of records) {
108
+ const slot = byName.get(rec.name) ?? {};
109
+ if (rec.origin === 'runtime') slot.runtime = rec;
110
+ else slot.code = rec;
111
+ byName.set(rec.name, slot);
112
+ }
113
+
114
+ const summaries: DatasourceSummary[] = [];
115
+ for (const [name, slot] of byName) {
116
+ const effective = slot.code ?? slot.runtime;
117
+ if (!effective) continue;
118
+ summaries.push({
119
+ name,
120
+ label: effective.label,
121
+ driver: effective.driver,
122
+ schemaMode: effective.schemaMode ?? 'managed',
123
+ origin: slot.code ? 'code' : 'runtime',
124
+ active: effective.active ?? true,
125
+ status: 'unvalidated',
126
+ ...(slot.code?.definedIn ? { definedIn: slot.code.definedIn } : {}),
127
+ ...(slot.code && slot.runtime ? { conflictsWithCode: true } : {}),
128
+ });
129
+ }
130
+ return summaries;
131
+ }
132
+
133
+ async testConnection(input: DatasourceDraft, secret?: SecretInput): Promise<TestConnectionResult> {
134
+ if (!input?.driver) {
135
+ return { ok: false, error: 'A driver is required to test a connection.' };
136
+ }
137
+ const queryTimeoutMs = (input.external as { queryTimeoutMs?: number } | undefined)?.queryTimeoutMs;
138
+ try {
139
+ return await this.config.probe({
140
+ driver: input.driver,
141
+ config: input.config ?? {},
142
+ secret: secret?.value,
143
+ external: input.external,
144
+ ...(typeof queryTimeoutMs === 'number' ? { timeoutMs: queryTimeoutMs } : {}),
145
+ });
146
+ } catch (err) {
147
+ return { ok: false, error: err instanceof Error ? err.message : String(err) };
148
+ }
149
+ }
150
+
151
+ async createDatasource(input: DatasourceDraft, secret?: SecretInput): Promise<DatasourceSummary> {
152
+ this.assertValidName(input?.name);
153
+ if (!input.driver) throw new Error('A driver is required to create a datasource.');
154
+
155
+ const existing = await this.config.getDatasourceRecord(input.name);
156
+ if (existing) {
157
+ if (existing.origin === 'code' || existing.origin === undefined) {
158
+ throw new Error(
159
+ `Cannot create datasource '${input.name}': a code-defined datasource owns this name (read-only).`,
160
+ );
161
+ }
162
+ throw new Error(`Datasource '${input.name}' already exists.`);
163
+ }
164
+
165
+ const record: StoredDatasource = {
166
+ ...this.toRecord(input),
167
+ origin: 'runtime',
168
+ };
169
+
170
+ if (secret) {
171
+ const credentialsRef = await this.config.writeSecret(secret, { name: input.name });
172
+ record.external = { ...(record.external ?? {}), credentialsRef };
173
+ }
174
+
175
+ await this.config.putDatasourceRecord(record);
176
+ await this.tryRegisterPool(record);
177
+ return this.toSummary(record);
178
+ }
179
+
180
+ async updateDatasource(
181
+ name: string,
182
+ patch: Partial<DatasourceDraft>,
183
+ secret?: SecretInput,
184
+ ): Promise<DatasourceSummary> {
185
+ const existing = await this.config.getDatasourceRecord(name);
186
+ if (!existing) throw new Error(`Datasource '${name}' not found.`);
187
+ if (existing.origin !== 'runtime') {
188
+ throw new Error(`Datasource '${name}' is code-defined and cannot be edited at runtime.`);
189
+ }
190
+
191
+ // Merge patch over the existing record; `name`/`origin` are never patched.
192
+ const merged: StoredDatasource = {
193
+ ...existing,
194
+ ...(patch.label !== undefined ? { label: patch.label } : {}),
195
+ ...(patch.driver !== undefined ? { driver: patch.driver } : {}),
196
+ ...(patch.schemaMode !== undefined ? { schemaMode: patch.schemaMode } : {}),
197
+ ...(patch.config !== undefined ? { config: patch.config } : {}),
198
+ ...(patch.pool !== undefined ? { pool: patch.pool } : {}),
199
+ ...(patch.active !== undefined ? { active: patch.active } : {}),
200
+ name: existing.name,
201
+ origin: 'runtime',
202
+ };
203
+ if (patch.external !== undefined) {
204
+ // Preserve the existing credentialsRef unless a new secret rewraps it.
205
+ merged.external = { ...patch.external, credentialsRef: existing.external?.credentialsRef };
206
+ }
207
+
208
+ if (secret) {
209
+ const prevRef = existing.external?.credentialsRef;
210
+ const credentialsRef = await this.config.writeSecret(secret, { name });
211
+ merged.external = { ...(merged.external ?? {}), credentialsRef };
212
+ if (prevRef && prevRef !== credentialsRef) await this.tryRemoveSecret(prevRef);
213
+ }
214
+
215
+ await this.config.putDatasourceRecord(merged);
216
+ await this.tryRegisterPool(merged);
217
+ return this.toSummary(merged);
218
+ }
219
+
220
+ async removeDatasource(name: string): Promise<void> {
221
+ const existing = await this.config.getDatasourceRecord(name);
222
+ if (!existing) throw new Error(`Datasource '${name}' not found.`);
223
+ if (existing.origin !== 'runtime') {
224
+ throw new Error(`Datasource '${name}' is code-defined and cannot be removed at runtime.`);
225
+ }
226
+
227
+ const bound = await this.config.countBoundObjects(name);
228
+ if (bound > 0) {
229
+ throw new Error(
230
+ `Cannot remove datasource '${name}': ${bound} object(s) are still bound to it.`,
231
+ );
232
+ }
233
+
234
+ await this.config.deleteDatasourceRecord(name);
235
+ if (existing.external?.credentialsRef) await this.tryRemoveSecret(existing.external.credentialsRef);
236
+ await this.tryUnregisterPool(name);
237
+ }
238
+
239
+ // --- internals -----------------------------------------------------------
240
+
241
+ private assertValidName(name: string | undefined): void {
242
+ if (!name || !NAME_RE.test(name)) {
243
+ throw new Error(
244
+ `Invalid datasource name '${name ?? ''}': must match /^[a-z_][a-z0-9_]*$/.`,
245
+ );
246
+ }
247
+ }
248
+
249
+ private toRecord(input: DatasourceDraft): StoredDatasource {
250
+ return {
251
+ name: input.name,
252
+ ...(input.label !== undefined ? { label: input.label } : {}),
253
+ driver: input.driver,
254
+ ...(input.schemaMode !== undefined ? { schemaMode: input.schemaMode } : {}),
255
+ ...(input.config !== undefined ? { config: input.config } : {}),
256
+ ...(input.external !== undefined ? { external: input.external } : {}),
257
+ ...(input.pool !== undefined ? { pool: input.pool } : {}),
258
+ ...(input.active !== undefined ? { active: input.active } : {}),
259
+ };
260
+ }
261
+
262
+ private toSummary(record: StoredDatasource): DatasourceSummary {
263
+ return {
264
+ name: record.name,
265
+ label: record.label,
266
+ driver: record.driver,
267
+ schemaMode: record.schemaMode ?? 'managed',
268
+ origin: record.origin ?? 'runtime',
269
+ active: record.active ?? true,
270
+ status: 'unvalidated',
271
+ };
272
+ }
273
+
274
+ private async tryRegisterPool(record: StoredDatasource): Promise<void> {
275
+ try {
276
+ await this.config.registerPool?.(record);
277
+ } catch (err) {
278
+ this.logger?.warn(`registerPool('${record.name}') failed`, err);
279
+ }
280
+ }
281
+
282
+ private async tryUnregisterPool(name: string): Promise<void> {
283
+ try {
284
+ await this.config.unregisterPool?.(name);
285
+ } catch (err) {
286
+ this.logger?.warn(`unregisterPool('${name}') failed`, err);
287
+ }
288
+ }
289
+
290
+ private async tryRemoveSecret(credentialsRef: string): Promise<void> {
291
+ try {
292
+ await this.config.removeSecret?.(credentialsRef);
293
+ } catch (err) {
294
+ this.logger?.warn(`removeSecret('${credentialsRef}') failed`, err);
295
+ }
296
+ }
297
+ }