@objectstack/service-datasource 9.11.0 → 10.2.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.
@@ -4,7 +4,6 @@ import type { Plugin, PluginContext } from '@objectstack/core';
4
4
  import { registerMetadataTypeActions } from '@objectstack/spec/kernel';
5
5
  import type {
6
6
  IDatasourceDriverFactory,
7
- DatasourceConnectionSpec,
8
7
  TestConnectionResult,
9
8
  } from './contracts/index.js';
10
9
  import {
@@ -13,6 +12,11 @@ import {
13
12
  type StoredDatasource,
14
13
  type ProbeInput,
15
14
  } from './datasource-admin-service.js';
15
+ import {
16
+ DatasourceConnectionService,
17
+ type ConnectionEngineLike,
18
+ } from './datasource-connection-service.js';
19
+ import type { DatasourceConnectPolicy } from './contracts/connect-policy.js';
16
20
  import type { Logger } from './logger.js';
17
21
 
18
22
  /**
@@ -33,6 +37,82 @@ interface DataEngineLike {
33
37
  registerDriver?: (driver: unknown, isDefault?: boolean) => void;
34
38
  registerDatasourceDef?: (def: { name: string; schemaMode?: string; external?: { allowWrites?: boolean } }) => void;
35
39
  getDriverByName?: (name: string) => unknown;
40
+ // sys_metadata CRUD used to persist runtime datasource records durably (same
41
+ // table runtime objects use). Optional — absent on lightweight kernels, in
42
+ // which case persistence degrades to in-memory (pre-existing behavior).
43
+ findOne?: (object: string, query: { where?: Record<string, unknown> }) => Promise<Record<string, unknown> | undefined | null>;
44
+ find?: (object: string, query: { where?: Record<string, unknown> }) => Promise<Record<string, unknown>[]>;
45
+ insert?: (object: string, row: Record<string, unknown>) => Promise<unknown>;
46
+ update?: (object: string, row: Record<string, unknown>, opts: { where: Record<string, unknown> }) => Promise<unknown>;
47
+ delete?: (object: string, opts: { where: Record<string, unknown> }) => Promise<unknown>;
48
+ }
49
+
50
+ /**
51
+ * Durable persistence for runtime datasource records via the `sys_metadata`
52
+ * table — the same store runtime objects use (the protocol writes objects there
53
+ * directly). `MetadataManager.register()` alone is in-memory unless a writable
54
+ * `datasource:` loader is wired, which standalone `serve` does not do; so a
55
+ * UI-created datasource vanished on restart. These helpers persist on write and
56
+ * the plugin restores them into the registry on boot before rehydrating pools.
57
+ * Credential cleartext is never stored — only the opaque `external.credentialsRef`.
58
+ */
59
+ const DS_META_TYPE = 'datasource';
60
+ const SYS_METADATA = 'sys_metadata';
61
+
62
+ function newMetaId(): string {
63
+ return typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function'
64
+ ? crypto.randomUUID()
65
+ : `meta_${Date.now()}_${Math.random().toString(36).slice(2)}`;
66
+ }
67
+
68
+ async function persistDatasourceRow(engine: DataEngineLike | undefined, record: { name: string }): Promise<void> {
69
+ if (!engine?.insert || !engine.findOne) return; // no durable store — in-memory only
70
+ const now = new Date().toISOString();
71
+ const existing = await engine.findOne(SYS_METADATA, {
72
+ where: { type: DS_META_TYPE, name: record.name, state: 'active' },
73
+ });
74
+ if (existing) {
75
+ await engine.update?.(
76
+ SYS_METADATA,
77
+ { metadata: JSON.stringify(record), updated_at: now, version: ((existing.version as number) || 0) + 1, state: 'active' },
78
+ { where: { id: existing.id } },
79
+ );
80
+ } else {
81
+ await engine.insert(SYS_METADATA, {
82
+ id: newMetaId(),
83
+ name: record.name,
84
+ type: DS_META_TYPE,
85
+ scope: 'platform',
86
+ metadata: JSON.stringify(record),
87
+ state: 'active',
88
+ version: 1,
89
+ created_at: now,
90
+ updated_at: now,
91
+ });
92
+ }
93
+ }
94
+
95
+ async function deleteDatasourceRow(engine: DataEngineLike | undefined, name: string): Promise<void> {
96
+ if (!engine?.findOne) return;
97
+ const existing = await engine.findOne(SYS_METADATA, { where: { type: DS_META_TYPE, name, state: 'active' } });
98
+ if (!existing) return;
99
+ if (engine.delete) await engine.delete(SYS_METADATA, { where: { id: existing.id } });
100
+ else await engine.update?.(SYS_METADATA, { state: 'inactive' }, { where: { id: existing.id } });
101
+ }
102
+
103
+ async function loadDatasourceRows(engine: DataEngineLike | undefined): Promise<Array<Record<string, unknown>>> {
104
+ if (!engine?.find) return [];
105
+ const rows = await engine.find(SYS_METADATA, { where: { type: DS_META_TYPE, state: 'active' } });
106
+ const out: Array<Record<string, unknown>> = [];
107
+ for (const r of rows ?? []) {
108
+ const raw = (r as { metadata?: unknown }).metadata;
109
+ try {
110
+ out.push(typeof raw === 'string' ? JSON.parse(raw) : (raw as Record<string, unknown>));
111
+ } catch {
112
+ /* skip corrupt row */
113
+ }
114
+ }
115
+ return out;
36
116
  }
37
117
 
38
118
  /**
@@ -59,6 +139,13 @@ export interface DatasourceAdminServicePluginOptions {
59
139
  secrets?: SecretBinder;
60
140
  /** Override the driver factory (defaults to the `'datasource-driver-factory'` service). */
61
141
  driverFactory?: IDatasourceDriverFactory;
142
+ /**
143
+ * Host-injectable connect policy consulted before opening any datasource
144
+ * connection (ADR-0062 D5 / epic #2163 seam). Open-core default is permissive
145
+ * (allow); a multi-tenant host binds a stricter, fail-closed policy. Shared by
146
+ * both code-defined auto-connect and runtime-admin pool registration.
147
+ */
148
+ connectPolicy?: DatasourceConnectPolicy;
62
149
  logger?: Logger;
63
150
  }
64
151
 
@@ -86,6 +173,8 @@ export class DatasourceAdminServicePlugin implements Plugin {
86
173
 
87
174
  private service?: DatasourceAdminService;
88
175
  private config?: DatasourceAdminServiceConfig;
176
+ /** Shared "definition → live driver" path (ADR-0062 D1); also exposed as the `'datasource-connection'` service. */
177
+ private connection?: DatasourceConnectionService;
89
178
  private readonly options: DatasourceAdminServicePluginOptions;
90
179
 
91
180
  constructor(options: DatasourceAdminServicePluginOptions = {}) {
@@ -128,6 +217,19 @@ export class DatasourceAdminServicePlugin implements Plugin {
128
217
  const factory = (): IDatasourceDriverFactory | undefined =>
129
218
  this.options.driverFactory ?? safeGetService<IDatasourceDriverFactory>(ctx, 'datasource-driver-factory');
130
219
 
220
+ // The single "definition → live driver" path (ADR-0062 D1). Built here so
221
+ // the admin pool registration (runtime origin) and the app-plugin
222
+ // auto-connect (code origin) share one connect + lifecycle + policy path.
223
+ // Registered as a kernel service so `AppPlugin.start()` can resolve it.
224
+ this.connection = new DatasourceConnectionService({
225
+ factory,
226
+ engine: () => engineOf() as ConnectionEngineLike | undefined,
227
+ secrets: { resolve: (ref) => this.options.secrets?.resolve?.(ref) ?? Promise.resolve(undefined) },
228
+ policy: this.options.connectPolicy,
229
+ logger: this.options.logger,
230
+ });
231
+ ctx.registerService('datasource-connection', this.connection);
232
+
131
233
  const config: DatasourceAdminServiceConfig = {
132
234
  probe: (input) => this.probe(factory(), input),
133
235
 
@@ -147,7 +249,10 @@ export class DatasourceAdminServicePlugin implements Plugin {
147
249
  if (!metadata?.register) {
148
250
  throw new Error('Metadata service is unavailable; cannot persist datasource.');
149
251
  }
252
+ // In-memory registry (immediate visibility) + durable sys_metadata row
253
+ // (survives restart; restored on boot by restoreRuntimeDatasources).
150
254
  await metadata.register('datasource', record.name, record);
255
+ await persistDatasourceRow(engineOf(), record);
151
256
  },
152
257
 
153
258
  deleteDatasourceRecord: async (name) => {
@@ -156,6 +261,7 @@ export class DatasourceAdminServicePlugin implements Plugin {
156
261
  throw new Error('Metadata service is unavailable; cannot remove datasource.');
157
262
  }
158
263
  await metadata.unregister('datasource', name);
264
+ await deleteDatasourceRow(engineOf(), name);
159
265
  },
160
266
 
161
267
  writeSecret: async (input, hint) => {
@@ -181,40 +287,21 @@ export class DatasourceAdminServicePlugin implements Plugin {
181
287
  return objects.filter((o) => o?.datasource === datasource).length;
182
288
  },
183
289
 
290
+ // Hot pool (de)registration converges on the shared
291
+ // DatasourceConnectionService (ADR-0062 D1) — one connect path for code-
292
+ // and runtime-origin datasources. `connect()` builds the driver via the
293
+ // factory, dereferences `external.credentialsRef` through the SecretBinder,
294
+ // opens the connection, and registers the live driver + datasource def.
295
+ // Runtime-admin connects always degrade-with-warning on failure (never
296
+ // fail-fast), preserving the pre-ADR-0062 admin behavior.
184
297
  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,
298
+ await this.connection?.connect(record, {
299
+ context: { origin: record.origin ?? 'runtime', trigger: 'runtime-admin' },
212
300
  });
213
301
  },
214
302
 
215
303
  unregisterPool: async (name) => {
216
- const driver = engineOf()?.getDriverByName?.(name) as { disconnect?: () => Promise<void> } | undefined;
217
- if (typeof driver?.disconnect === 'function') await driver.disconnect();
304
+ await this.connection?.disconnect(name);
218
305
  },
219
306
 
220
307
  logger,
@@ -223,16 +310,85 @@ export class DatasourceAdminServicePlugin implements Plugin {
223
310
  this.config = config;
224
311
  this.service = new DatasourceAdminService(config);
225
312
  ctx.registerService('datasource-admin', this.service);
313
+
314
+ // Setup-app nav (ADR-0029 D7): datasources are a *capability* this plugin
315
+ // owns, so it contributes its own entry into the `group_integrations` slot
316
+ // (core setup-nav must not fill capability-owned slots). datasource is a
317
+ // metadata type, so the entry opens the generic metadata-admin engine route
318
+ // rather than a bespoke page or an object view.
319
+ try {
320
+ const manifest = ctx.getService<{ register(m: any): void }>('manifest');
321
+ if (manifest && typeof manifest.register === 'function') {
322
+ manifest.register({
323
+ id: 'com.objectstack.service-datasource.nav',
324
+ namespace: 'sys',
325
+ version: this.version,
326
+ type: 'plugin',
327
+ scope: 'system',
328
+ name: 'Datasource Navigation',
329
+ description: 'Contributes the Datasources entry to the Setup app Integrations group.',
330
+ navigationContributions: [
331
+ {
332
+ app: 'setup',
333
+ group: 'group_integrations',
334
+ priority: 100,
335
+ items: [
336
+ {
337
+ id: 'nav_datasources',
338
+ type: 'url',
339
+ label: 'Datasources',
340
+ url: '/apps/setup/component/metadata/resource?type=datasource',
341
+ icon: 'database',
342
+ requiredPermissions: ['manage_platform_settings'],
343
+ },
344
+ ],
345
+ },
346
+ ],
347
+ });
348
+ }
349
+ } catch (err) {
350
+ this.options.logger?.warn?.('datasource nav contribution skipped', err);
351
+ }
226
352
  }
227
353
 
228
354
  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.
355
+ // Restore UI-created (runtime) datasources from the durable sys_metadata
356
+ // store back into the in-memory registry, THEN rebuild their live pools.
357
+ // `register()` is in-memory only in standalone serve (no writable
358
+ // `datasource:` loader), so without this a node restart drops every
359
+ // UI-created datasource. Code-defined datasources come from the artifact and
360
+ // are unaffected.
361
+ await this.restoreRuntimeDatasources(ctx);
232
362
  await this.rehydratePools();
233
363
  if (this.service) await ctx.trigger('datasource-admin:ready', this.service);
234
364
  }
235
365
 
366
+ /** Reload persisted runtime datasource rows (sys_metadata) into the registry. */
367
+ private async restoreRuntimeDatasources(ctx: PluginContext): Promise<void> {
368
+ const engine = safeGetService<DataEngineLike>(ctx, 'data');
369
+ const metadata = safeGetService<MetadataServiceLike>(ctx, 'metadata');
370
+ if (!engine?.find || !metadata?.register) return;
371
+ let rows: Array<Record<string, unknown>>;
372
+ try {
373
+ rows = await loadDatasourceRows(engine);
374
+ } catch (err) {
375
+ this.options.logger?.warn?.('datasource restore: reading sys_metadata failed', err);
376
+ return;
377
+ }
378
+ let restored = 0;
379
+ for (const rec of rows) {
380
+ const name = (rec as { name?: string }).name;
381
+ if (!name) continue;
382
+ try {
383
+ await metadata.register('datasource', name, rec);
384
+ restored += 1;
385
+ } catch (err) {
386
+ this.options.logger?.warn?.(`datasource restore: register '${name}' failed`, err);
387
+ }
388
+ }
389
+ if (restored > 0) this.options.logger?.info?.(`datasource: restored ${restored} runtime record(s) from sys_metadata`);
390
+ }
391
+
236
392
  /**
237
393
  * Boot-time rehydration: list persisted runtime datasources and re-register
238
394
  * each one's connection pool (driver build → connect → registerDriver),
@@ -277,16 +433,6 @@ export class DatasourceAdminServicePlugin implements Plugin {
277
433
 
278
434
  // --- internals -----------------------------------------------------------
279
435
 
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
436
  /** Probe a connection via the driver factory: build → connect → ping → close. */
291
437
  private async probe(
292
438
  factory: IDatasourceDriverFactory | undefined,
@@ -47,6 +47,8 @@ export interface StoredDatasource {
47
47
  external?: (Record<string, unknown> & { credentialsRef?: string }) | undefined;
48
48
  pool?: Record<string, unknown>;
49
49
  active?: boolean;
50
+ /** Force a live connection at boot even when managed + unrouted (ADR-0062 D2(c)). */
51
+ autoConnect?: boolean;
50
52
  origin?: 'code' | 'runtime';
51
53
  /** Package that defines a code-origin datasource, when known. */
52
54
  definedIn?: string;
@@ -130,6 +132,36 @@ export class DatasourceAdminService implements IDatasourceAdminService {
130
132
  return summaries;
131
133
  }
132
134
 
135
+ /**
136
+ * Read one datasource's full detail for editing, with the credential stripped.
137
+ * Returns `config` (non-sensitive — credentials live in `sys_secret`, never in
138
+ * config), `origin`, and a `hasSecret` flag so the UI can show "leave blank to
139
+ * keep" without ever receiving the `credentialsRef` or any cleartext. Returns
140
+ * `undefined` when the name is unknown.
141
+ */
142
+ async getDatasource(name: string): Promise<
143
+ | (Pick<StoredDatasource, 'name' | 'label' | 'driver' | 'schemaMode' | 'config' | 'active' | 'definedIn'> & {
144
+ origin: 'code' | 'runtime';
145
+ hasSecret: boolean;
146
+ })
147
+ | undefined
148
+ > {
149
+ const rec = await this.config.getDatasourceRecord(name);
150
+ if (!rec) return undefined;
151
+ const hasSecret = Boolean(rec.external?.credentialsRef);
152
+ return {
153
+ name: rec.name,
154
+ label: rec.label,
155
+ driver: rec.driver,
156
+ schemaMode: rec.schemaMode ?? 'managed',
157
+ config: rec.config ?? {},
158
+ active: rec.active ?? true,
159
+ origin: rec.origin === 'runtime' ? 'runtime' : 'code',
160
+ hasSecret,
161
+ ...(rec.definedIn ? { definedIn: rec.definedIn } : {}),
162
+ };
163
+ }
164
+
133
165
  async testConnection(input: DatasourceDraft, secret?: SecretInput): Promise<TestConnectionResult> {
134
166
  if (!input?.driver) {
135
167
  return { ok: false, error: 'A driver is required to test a connection.' };