@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.
@@ -0,0 +1,364 @@
1
+ // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2
+
3
+ /**
4
+ * DatasourceConnectionService — the single "definition → live driver" path
5
+ * (ADR-0062 D1).
6
+ *
7
+ * Given a datasource definition, it: consults the injectable connect policy
8
+ * (D5/epic seam), builds a driver via the host-provided driver factory,
9
+ * resolves any `external.credentialsRef` to a cleartext secret via the
10
+ * `SecretBinder` (D3, wired in Phase 2), opens the connection, and registers
11
+ * the live driver + the datasource *definition* into the ObjectQL engine under
12
+ * the datasource name (the engine routes by `driver.name === <datasource>`).
13
+ *
14
+ * Both origins converge here (D1):
15
+ * - **code-defined** datasources auto-connect at boot via
16
+ * {@link connectDeclared} (gated per D2 — see {@link isDatasourceAddressed}),
17
+ * called from `AppPlugin.start()`.
18
+ * - **runtime** (UI-created) datasources connect via {@link connect}, called
19
+ * from `DatasourceAdminServicePlugin`'s `registerPool` (create/update + boot
20
+ * rehydration).
21
+ *
22
+ * Idempotent: a datasource already registered as a live driver is skipped, so
23
+ * an app's legacy `onEnable` driver registration (the escape hatch, ADR-0062
24
+ * D8) and auto-connect never double-register.
25
+ */
26
+
27
+ import type {
28
+ IDatasourceDriverFactory,
29
+ DatasourceConnectionSpec,
30
+ } from './contracts/datasource-driver-factory.js';
31
+ import {
32
+ allowAllConnectPolicy,
33
+ type DatasourceConnectPolicy,
34
+ type DatasourceConnectContext,
35
+ } from './contracts/connect-policy.js';
36
+ import type { Logger } from './logger.js';
37
+
38
+ /** A datasource definition this service can connect (code- or runtime-origin). */
39
+ export interface ConnectableDatasource {
40
+ name: string;
41
+ label?: string;
42
+ driver: string;
43
+ schemaMode?: 'managed' | 'external' | 'validate-only';
44
+ config?: Record<string, unknown>;
45
+ external?: (Record<string, unknown> & {
46
+ credentialsRef?: string;
47
+ validation?: { onMismatch?: 'fail' | 'warn' | 'ignore' };
48
+ }) | undefined;
49
+ pool?: Record<string, unknown>;
50
+ active?: boolean;
51
+ origin?: 'code' | 'runtime';
52
+ /**
53
+ * ADR-0062 D2(c): explicit opt-in to auto-connect even for a managed,
54
+ * unrouted datasource. Defaults to false.
55
+ */
56
+ autoConnect?: boolean;
57
+ }
58
+
59
+ /** Minimal object shape used for the D2 routing gate + post-connect schema sync. */
60
+ export interface DatasourceBoundObject {
61
+ name?: string;
62
+ /** The object's explicit `datasource` binding (ADR-0015 federation). */
63
+ datasource?: string;
64
+ }
65
+
66
+ /** Engine surface this service drives (the ObjectQL `'data'` engine). */
67
+ export interface ConnectionEngineLike {
68
+ registerDriver?: (driver: unknown, isDefault?: boolean) => void;
69
+ registerDatasourceDef?: (def: {
70
+ name: string;
71
+ schemaMode?: string;
72
+ external?: { allowWrites?: boolean };
73
+ }) => void;
74
+ getDriverByName?: (name: string) => unknown;
75
+ /**
76
+ * Register read metadata (DDL-free) for a federated object so its physical
77
+ * remote table/columns resolve for queries. Idempotent; called per bound
78
+ * external object after the driver is registered, because boot schema-sync
79
+ * ran before this driver existed (ADR-0015 §18; matches what the legacy
80
+ * `onEnable` bridge does manually).
81
+ */
82
+ syncObjectSchema?: (objectName: string) => Promise<void>;
83
+ }
84
+
85
+ /** Secret dereference surface (the `SecretBinder.resolve`, Phase 2 / D3). */
86
+ export interface ConnectionSecretResolver {
87
+ resolve?: (credentialsRef: string) => Promise<string | undefined>;
88
+ }
89
+
90
+ export interface DatasourceConnectionServiceConfig {
91
+ /** Resolve the host driver factory (lazy — may be registered after init). */
92
+ factory: () => IDatasourceDriverFactory | undefined;
93
+ /** Resolve the ObjectQL engine (lazy). */
94
+ engine: () => ConnectionEngineLike | undefined;
95
+ /** Dereference `credentialsRef` → cleartext (Phase 2). Optional in Phase 1. */
96
+ secrets?: ConnectionSecretResolver;
97
+ /** Injectable connect policy. Defaults to {@link allowAllConnectPolicy}. */
98
+ policy?: DatasourceConnectPolicy;
99
+ logger?: Logger;
100
+ }
101
+
102
+ /** Outcome of a single {@link DatasourceConnectionService.connect} attempt. */
103
+ export type ConnectStatus =
104
+ | 'connected'
105
+ | 'already-registered'
106
+ | 'skipped-policy'
107
+ | 'skipped-no-infra'
108
+ | 'skipped-unsupported'
109
+ | 'failed-credentials'
110
+ | 'failed-degraded';
111
+
112
+ export interface ConnectResult {
113
+ name: string;
114
+ status: ConnectStatus;
115
+ reason?: string;
116
+ }
117
+
118
+ /**
119
+ * ADR-0062 D2 — is this declared datasource "meaningfully addressed", such that
120
+ * auto-connecting it is safe and intended?
121
+ *
122
+ * Returns true when:
123
+ * - (a) it is external (`schemaMode !== 'managed'`), OR
124
+ * - (b) some object **explicitly** binds to it (`object.datasource === name`), OR
125
+ * - (c) it sets `autoConnect: true`.
126
+ *
127
+ * Deliberately NOT triggered by a `datasourceMapping` rule alone. A managed
128
+ * datasource that is only *mapped* (namespace/package/default) but has no live
129
+ * driver historically falls through to the `default` driver at query time
130
+ * (`engine.getDriver` step 4) — e.g. `examples/app-crm`'s `crm_primary`
131
+ * (`:memory:`, mapped + default-fallback, no `onEnable`). Connecting it would
132
+ * divert those objects to a fresh, empty connection and silently change app
133
+ * behavior. So mapping-only routing to a *managed* datasource is treated as
134
+ * decorative, keeping existing apps byte-for-byte unchanged (D2's load-bearing
135
+ * backward-compat guarantee). External datasources and explicit
136
+ * `object.datasource` bindings never resolved to `default` (they throw when
137
+ * unregistered), so auto-connecting them is a strict improvement, not a change.
138
+ */
139
+ export function isDatasourceAddressed(
140
+ ds: Pick<ConnectableDatasource, 'name' | 'schemaMode' | 'autoConnect'>,
141
+ ctx: { objects?: readonly DatasourceBoundObject[] },
142
+ ): boolean {
143
+ if (ds.schemaMode && ds.schemaMode !== 'managed') return true; // (a)
144
+ if (ds.autoConnect === true) return true; // (c)
145
+ if (ctx.objects?.some((o) => o?.datasource === ds.name)) return true; // (b)
146
+ return false;
147
+ }
148
+
149
+ export class DatasourceConnectionService {
150
+ private readonly cfg: DatasourceConnectionServiceConfig;
151
+ private readonly policy: DatasourceConnectPolicy;
152
+ private readonly logger?: Logger;
153
+
154
+ constructor(cfg: DatasourceConnectionServiceConfig) {
155
+ this.cfg = cfg;
156
+ this.policy = cfg.policy ?? allowAllConnectPolicy;
157
+ this.logger = cfg.logger;
158
+ }
159
+
160
+ /**
161
+ * Auto-connect the declared (code-defined) datasources that pass the D2 gate.
162
+ * Called from `AppPlugin.start()` with the app bundle's datasources + objects.
163
+ * Each connected external datasource also has its bound objects' read metadata
164
+ * synced so they are immediately queryable with zero app code.
165
+ */
166
+ async connectDeclared(input: {
167
+ datasources: readonly ConnectableDatasource[];
168
+ objects?: readonly DatasourceBoundObject[];
169
+ }): Promise<ConnectResult[]> {
170
+ const objects = input.objects ?? [];
171
+ const results: ConnectResult[] = [];
172
+ for (const ds of input.datasources) {
173
+ if (!ds?.name) continue;
174
+ if (ds.active === false) continue;
175
+ if (!isDatasourceAddressed(ds, { objects })) continue; // D2 gate
176
+ const bound = objects
177
+ .filter((o) => o?.datasource === ds.name && typeof o?.name === 'string')
178
+ .map((o) => o.name as string);
179
+ results.push(
180
+ await this.connect(ds, { objects: bound, context: { origin: ds.origin ?? 'code', trigger: 'declared-auto' } }),
181
+ );
182
+ }
183
+ return results;
184
+ }
185
+
186
+ /**
187
+ * Build + connect + register a single datasource's live driver. The shared
188
+ * core used by both auto-connect and the runtime-admin pool registration.
189
+ *
190
+ * Failure policy (ADR-0062 D5): an `external` datasource with
191
+ * `validation.onMismatch: 'fail'` fails fast (re-throws, bricking boot as
192
+ * intended); everything else degrades with a warning so an optional replica's
193
+ * connectivity blip never bricks boot.
194
+ */
195
+ async connect(
196
+ record: ConnectableDatasource,
197
+ opts: { objects?: readonly string[]; context?: DatasourceConnectContext } = {},
198
+ ): Promise<ConnectResult> {
199
+ const name = record.name;
200
+ const engine = this.cfg.engine();
201
+ const factory = this.cfg.factory();
202
+
203
+ // Idempotent: never double-register (e.g. a legacy `onEnable` bridge already
204
+ // registered this driver — the D8 escape hatch).
205
+ if (engine?.getDriverByName?.(name)) {
206
+ return { name, status: 'already-registered' };
207
+ }
208
+
209
+ // Policy gate (fail-closed on throw).
210
+ let decision;
211
+ try {
212
+ decision = await this.policy.canConnect(
213
+ { name, driver: record.driver, schemaMode: record.schemaMode, external: record.external },
214
+ opts.context,
215
+ );
216
+ } catch (err) {
217
+ decision = { allow: false, reason: `connect policy threw: ${errMsg(err)}` };
218
+ }
219
+ if (!decision.allow) {
220
+ this.logger?.info?.(`datasource '${name}': connect denied by policy${decision.reason ? ` (${decision.reason})` : ''}`);
221
+ return { name, status: 'skipped-policy', reason: decision.reason };
222
+ }
223
+
224
+ if (!factory || !engine?.registerDriver) {
225
+ this.logger?.debug?.(`datasource '${name}': no driver factory / engine — left metadata-only`);
226
+ return { name, status: 'skipped-no-infra' };
227
+ }
228
+ if (!factory.supports(record.driver)) {
229
+ return this.handleFailure(
230
+ record,
231
+ 'skipped-unsupported',
232
+ `no driver factory supports driver '${record.driver}'`,
233
+ opts.context,
234
+ );
235
+ }
236
+
237
+ // Credential resolution (ADR-0062 D3) — FAIL-CLOSED, and done *before* the
238
+ // build try-block so a fail-fast verdict propagates (rather than being
239
+ // swallowed and re-classified by the catch below). A declared
240
+ // `external.credentialsRef` MUST resolve to a cleartext secret before we
241
+ // open a connection: building a driver without it would silently connect
242
+ // with no/wrong auth (or fail later with a confusing driver error). So an
243
+ // absent secret store, or an unresolvable/undecryptable ref, leaves the
244
+ // datasource unconnected with a clear message — never a silent skip.
245
+ let secret: string | undefined;
246
+ const credentialsRef = record.external?.credentialsRef;
247
+ if (credentialsRef) {
248
+ const resolver = this.cfg.secrets?.resolve;
249
+ if (!resolver) {
250
+ return this.handleFailure(
251
+ record,
252
+ 'failed-credentials',
253
+ `requires credential '${credentialsRef}' but no secret store (SecretBinder/ICryptoProvider) is configured`,
254
+ opts.context,
255
+ );
256
+ }
257
+ try {
258
+ secret = await resolver(credentialsRef);
259
+ } catch (err) {
260
+ return this.handleFailure(record, 'failed-credentials', `resolving credential '${credentialsRef}' threw: ${errMsg(err)}`, opts.context);
261
+ }
262
+ if (secret == null || secret === '') {
263
+ return this.handleFailure(
264
+ record,
265
+ 'failed-credentials',
266
+ `credential '${credentialsRef}' could not be resolved or decrypted (missing sys_secret row, or the encryption key changed)`,
267
+ opts.context,
268
+ );
269
+ }
270
+ }
271
+
272
+ try {
273
+ const handle = await factory.create({ ...toSpec(record), ...(secret ? { secret } : {}) });
274
+ if (typeof handle?.connect === 'function') await handle.connect();
275
+
276
+ // The engine routes a datasource to a driver by `driver.name === <datasource>`.
277
+ // Prefer the factory's underlying engine driver (the `driver` escape hatch);
278
+ // fall back to the handle. Stamp the name so routing resolves to this pool.
279
+ const engineDriver = (handle.driver ?? handle) as { name?: string };
280
+ try {
281
+ engineDriver.name = name;
282
+ } catch {
283
+ /* frozen driver — registration may still work if name already matches */
284
+ }
285
+ engine.registerDriver(engineDriver);
286
+ engine.registerDatasourceDef?.({
287
+ name,
288
+ schemaMode: record.schemaMode,
289
+ external: record.external as { allowWrites?: boolean } | undefined,
290
+ });
291
+
292
+ // Register read metadata for bound federated objects (DDL-free). Boot
293
+ // schema-sync ran before this driver existed, so do it on-demand now.
294
+ for (const objectName of opts.objects ?? []) {
295
+ try {
296
+ await engine.syncObjectSchema?.(objectName);
297
+ } catch (err) {
298
+ this.logger?.warn?.(`datasource '${name}': syncObjectSchema('${objectName}') failed: ${errMsg(err)}`);
299
+ }
300
+ }
301
+
302
+ this.logger?.info?.(`datasource '${name}': connected (driver=${record.driver}, schemaMode=${record.schemaMode ?? 'managed'})`);
303
+ return { name, status: 'connected' };
304
+ } catch (err) {
305
+ return this.handleFailure(record, 'failed-degraded', errMsg(err), opts.context);
306
+ }
307
+ }
308
+
309
+ /** Gracefully disconnect a previously-registered datasource pool. */
310
+ async disconnect(name: string): Promise<void> {
311
+ const driver = this.cfg.engine()?.getDriverByName?.(name) as { disconnect?: () => Promise<void> } | undefined;
312
+ if (typeof driver?.disconnect === 'function') {
313
+ try {
314
+ await driver.disconnect();
315
+ } catch (err) {
316
+ this.logger?.warn?.(`datasource '${name}': disconnect failed: ${errMsg(err)}`);
317
+ }
318
+ }
319
+ }
320
+
321
+ /**
322
+ * Apply the D5 connect-failure policy (also covers D3 credential failures). A
323
+ * code-defined `external` datasource with `onMismatch:'fail'` auto-connected at
324
+ * boot re-throws (fail-fast, bricking boot as intended). Runtime-admin
325
+ * create/update + boot rehydration always degrade-with-warning — a UI action
326
+ * or a replica blip must never brick the running server (preserves the
327
+ * pre-ADR-0062 admin behavior). Either way the datasource is left unconnected
328
+ * with a clear message — never a silent skip.
329
+ */
330
+ private handleFailure(
331
+ record: ConnectableDatasource,
332
+ status: ConnectStatus,
333
+ reason: string,
334
+ context?: DatasourceConnectContext,
335
+ ): ConnectResult {
336
+ const isExternal = record.schemaMode && record.schemaMode !== 'managed';
337
+ const failFast =
338
+ context?.trigger === 'declared-auto' &&
339
+ isExternal &&
340
+ record.external?.validation?.onMismatch === 'fail';
341
+ const msg = `datasource '${record.name}': connect failed — ${reason}`;
342
+ if (failFast) {
343
+ throw new Error(
344
+ `${msg}. (schemaMode=${record.schemaMode}, validation.onMismatch='fail' ⇒ fail-fast per ADR-0062 D5)`,
345
+ );
346
+ }
347
+ this.logger?.warn?.(`${msg} — degrading (datasource left unconnected)`);
348
+ return { name: record.name, status, reason };
349
+ }
350
+ }
351
+
352
+ function toSpec(record: ConnectableDatasource): DatasourceConnectionSpec {
353
+ return {
354
+ name: record.name,
355
+ driver: record.driver,
356
+ config: record.config ?? {},
357
+ external: record.external,
358
+ pool: record.pool,
359
+ };
360
+ }
361
+
362
+ function errMsg(err: unknown): string {
363
+ return err instanceof Error ? err.message : String(err);
364
+ }
@@ -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,
package/src/index.ts CHANGED
@@ -39,7 +39,25 @@ export type {
39
39
  DatasourceConnectionSpec,
40
40
  DatasourceDriverHandle,
41
41
  IDatasourceDriverFactory,
42
+ // Connect policy (ADR-0062 D5 / epic #2163 seam).
43
+ DatasourceConnectPolicy,
44
+ DatasourceConnectDecision,
45
+ DatasourceConnectContext,
46
+ DatasourceConnectSubject,
42
47
  } from './contracts/index.js';
48
+ export { allowAllConnectPolicy } from './contracts/index.js';
49
+
50
+ // Shared "definition → live driver" path (ADR-0062 D1).
51
+ export { DatasourceConnectionService, isDatasourceAddressed } from './datasource-connection-service.js';
52
+ export type {
53
+ DatasourceConnectionServiceConfig,
54
+ ConnectableDatasource,
55
+ DatasourceBoundObject,
56
+ ConnectionEngineLike,
57
+ ConnectionSecretResolver,
58
+ ConnectResult,
59
+ ConnectStatus,
60
+ } from './datasource-connection-service.js';
43
61
 
44
62
  // Decoupled lifecycle service + injected-config shape.
45
63
  export { DatasourceAdminService } from './datasource-admin-service.js';
package/src/logger.ts CHANGED
@@ -8,4 +8,6 @@
8
8
  export interface Logger {
9
9
  warn: (message: string, meta?: unknown) => void;
10
10
  info?: (message: string, meta?: unknown) => void;
11
+ debug?: (message: string, meta?: unknown) => void;
12
+ error?: (message: string, meta?: unknown) => void;
11
13
  }