@objectstack/service-datasource 10.0.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@objectstack/service-datasource",
3
- "version": "10.0.0",
3
+ "version": "10.2.0",
4
4
  "license": "Apache-2.0",
5
5
  "description": "The datasource service (ADR-0015): external-table federation (introspect/draft/import/validate) + runtime UI datasource lifecycle (list/test/create/update/remove + REST routes). Open-source mechanism; the tier line falls on which ICryptoProvider / driver factory a host injects.",
6
6
  "type": "module",
@@ -19,18 +19,18 @@
19
19
  }
20
20
  },
21
21
  "dependencies": {
22
- "@objectstack/core": "10.0.0",
23
- "@objectstack/spec": "10.0.0"
22
+ "@objectstack/core": "10.2.0",
23
+ "@objectstack/spec": "10.2.0"
24
24
  },
25
25
  "devDependencies": {
26
26
  "@types/node": "^26.0.0",
27
27
  "tsup": "^8.5.1",
28
28
  "typescript": "^6.0.3",
29
29
  "vitest": "^4.1.9",
30
- "@objectstack/driver-memory": "10.0.0",
31
- "@objectstack/driver-sql": "10.0.0",
32
- "@objectstack/plugin-hono-server": "10.0.0",
33
- "@objectstack/driver-mongodb": "10.0.0"
30
+ "@objectstack/driver-memory": "10.2.0",
31
+ "@objectstack/driver-sql": "10.2.0",
32
+ "@objectstack/plugin-hono-server": "10.2.0",
33
+ "@objectstack/driver-mongodb": "10.2.0"
34
34
  },
35
35
  "keywords": [
36
36
  "objectstack",
@@ -0,0 +1,294 @@
1
+ // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2
+
3
+ import { describe, it, expect, vi } from 'vitest';
4
+ import {
5
+ DatasourceConnectionService,
6
+ isDatasourceAddressed,
7
+ type ConnectableDatasource,
8
+ type ConnectionEngineLike,
9
+ } from '../datasource-connection-service.js';
10
+ import type { IDatasourceDriverFactory } from '../contracts/datasource-driver-factory.js';
11
+ import type { DatasourceConnectPolicy } from '../contracts/connect-policy.js';
12
+
13
+ /** A fake engine recording driver registration + schema syncs. */
14
+ function fakeEngine() {
15
+ const drivers = new Map<string, { name?: string }>();
16
+ const defs: Array<{ name: string; schemaMode?: string }> = [];
17
+ const synced: string[] = [];
18
+ const engine: ConnectionEngineLike & { drivers: typeof drivers; defs: typeof defs; synced: string[] } = {
19
+ drivers,
20
+ defs,
21
+ synced,
22
+ registerDriver: (driver: any) => {
23
+ if (drivers.has(driver.name)) return; // mirror engine's skip-if-present
24
+ drivers.set(driver.name, driver);
25
+ },
26
+ registerDatasourceDef: (def) => {
27
+ defs.push(def);
28
+ },
29
+ getDriverByName: (name) => drivers.get(name),
30
+ syncObjectSchema: async (name) => {
31
+ synced.push(name);
32
+ },
33
+ };
34
+ return engine;
35
+ }
36
+
37
+ /** A fake factory that builds a trivial connectable handle. */
38
+ function fakeFactory(opts: { supports?: (id: string) => boolean; connectThrows?: boolean } = {}): IDatasourceDriverFactory {
39
+ return {
40
+ supports: opts.supports ?? (() => true),
41
+ create: vi.fn(async () => {
42
+ const driver: any = { name: 'com.fake.driver' };
43
+ return {
44
+ driver,
45
+ connect: opts.connectThrows
46
+ ? async () => {
47
+ throw new Error('connection refused');
48
+ }
49
+ : async () => {
50
+ driver.connected = true;
51
+ },
52
+ };
53
+ }),
54
+ };
55
+ }
56
+
57
+ function svc(over: {
58
+ factory?: IDatasourceDriverFactory | undefined;
59
+ engine?: ConnectionEngineLike | undefined;
60
+ policy?: DatasourceConnectPolicy;
61
+ secrets?: { resolve?: (ref: string) => Promise<string | undefined> };
62
+ } = {}) {
63
+ const engine = over.engine === undefined ? fakeEngine() : over.engine;
64
+ const factory = over.factory === undefined ? fakeFactory() : over.factory;
65
+ const service = new DatasourceConnectionService({
66
+ factory: () => factory ?? undefined,
67
+ engine: () => engine ?? undefined,
68
+ policy: over.policy,
69
+ secrets: over.secrets,
70
+ });
71
+ return { service, engine: engine as ReturnType<typeof fakeEngine> | undefined, factory };
72
+ }
73
+
74
+ const externalDs: ConnectableDatasource = {
75
+ name: 'warehouse',
76
+ driver: 'sqlite',
77
+ schemaMode: 'external',
78
+ config: { filename: '/tmp/w.db' },
79
+ external: { allowWrites: false, validation: { onMismatch: 'warn' } },
80
+ };
81
+
82
+ describe('isDatasourceAddressed (ADR-0062 D2 gate)', () => {
83
+ it('connects external datasources (a)', () => {
84
+ expect(isDatasourceAddressed({ name: 'x', schemaMode: 'external' }, { objects: [] })).toBe(true);
85
+ expect(isDatasourceAddressed({ name: 'x', schemaMode: 'validate-only' }, { objects: [] })).toBe(true);
86
+ });
87
+
88
+ it('connects when an object explicitly binds via object.datasource (b)', () => {
89
+ expect(
90
+ isDatasourceAddressed({ name: 'reporting', schemaMode: 'managed' }, { objects: [{ name: 'o', datasource: 'reporting' }] }),
91
+ ).toBe(true);
92
+ });
93
+
94
+ it('connects when autoConnect:true (c)', () => {
95
+ expect(isDatasourceAddressed({ name: 'x', schemaMode: 'managed', autoConnect: true }, { objects: [] })).toBe(true);
96
+ });
97
+
98
+ it('does NOT connect a managed datasource that is only mapped / unrouted (app-crm byte-for-byte unchanged)', () => {
99
+ // app-crm: crm_primary is managed + referenced by datasourceMapping only,
100
+ // crm_analytics is managed + unrouted. Neither has an object binding.
101
+ expect(isDatasourceAddressed({ name: 'crm_primary', schemaMode: 'managed' }, { objects: [] })).toBe(false);
102
+ expect(isDatasourceAddressed({ name: 'crm_analytics', schemaMode: 'managed' }, { objects: [] })).toBe(false);
103
+ // An object bound to a DIFFERENT datasource must not flip the gate.
104
+ expect(
105
+ isDatasourceAddressed({ name: 'crm_primary', schemaMode: 'managed' }, { objects: [{ name: 'acct', datasource: 'default' }] }),
106
+ ).toBe(false);
107
+ });
108
+ });
109
+
110
+ describe('DatasourceConnectionService.connect', () => {
111
+ it('builds, connects, stamps the datasource name, and registers the driver + def', async () => {
112
+ const { service, engine, factory } = svc();
113
+ const result = await service.connect(externalDs, { objects: ['ext_customer'] });
114
+ expect(result.status).toBe('connected');
115
+ expect(factory.create).toHaveBeenCalledOnce();
116
+ // Driver registered under the DATASOURCE name (engine routes by driver.name).
117
+ expect(engine!.drivers.has('warehouse')).toBe(true);
118
+ expect(engine!.drivers.get('warehouse')!.name).toBe('warehouse');
119
+ // Datasource definition recorded for the write gate.
120
+ expect(engine!.defs).toEqual([{ name: 'warehouse', schemaMode: 'external', external: externalDs.external }]);
121
+ // Bound external objects got read metadata synced (DDL-free).
122
+ expect(engine!.synced).toEqual(['ext_customer']);
123
+ });
124
+
125
+ it('is idempotent — an already-registered driver is skipped (onEnable escape hatch)', async () => {
126
+ const { service, engine, factory } = svc();
127
+ engine!.drivers.set('warehouse', { name: 'warehouse' }); // pretend onEnable registered it
128
+ const result = await service.connect(externalDs, { objects: ['ext_customer'] });
129
+ expect(result.status).toBe('already-registered');
130
+ expect(factory.create).not.toHaveBeenCalled();
131
+ expect(engine!.synced).toEqual([]); // no double sync
132
+ });
133
+
134
+ it('resolves external.credentialsRef via the secret resolver before building', async () => {
135
+ const resolve = vi.fn(async () => 's3cr3t');
136
+ const create = vi.fn(async () => ({ driver: { name: 'd' }, connect: async () => {} }));
137
+ const factory: IDatasourceDriverFactory = { supports: () => true, create };
138
+ const { service } = svc({ factory, secrets: { resolve } });
139
+ await service.connect({ ...externalDs, external: { ...externalDs.external, credentialsRef: 'secret:wh/pw' } });
140
+ expect(resolve).toHaveBeenCalledWith('secret:wh/pw');
141
+ expect(create).toHaveBeenCalledWith(expect.objectContaining({ secret: 's3cr3t' }));
142
+ });
143
+
144
+ it('respects a deny policy — left unconnected, metadata-only', async () => {
145
+ const policy: DatasourceConnectPolicy = { canConnect: () => ({ allow: false, reason: 'egress blocked' }) };
146
+ const { service, engine, factory } = svc({ policy });
147
+ const result = await service.connect(externalDs);
148
+ expect(result.status).toBe('skipped-policy');
149
+ expect(result.reason).toBe('egress blocked');
150
+ expect(factory.create).not.toHaveBeenCalled();
151
+ expect(engine!.drivers.size).toBe(0);
152
+ });
153
+
154
+ it('treats a throwing policy as a denial (fail-closed)', async () => {
155
+ const policy: DatasourceConnectPolicy = {
156
+ canConnect: () => {
157
+ throw new Error('policy backend down');
158
+ },
159
+ };
160
+ const { service, engine } = svc({ policy });
161
+ const result = await service.connect(externalDs);
162
+ expect(result.status).toBe('skipped-policy');
163
+ expect(engine!.drivers.size).toBe(0);
164
+ });
165
+
166
+ it('degrades (no throw) when there is no factory / engine', async () => {
167
+ const noFactory = new DatasourceConnectionService({ factory: () => undefined, engine: () => fakeEngine() });
168
+ expect((await noFactory.connect(externalDs)).status).toBe('skipped-no-infra');
169
+ const noEngine = new DatasourceConnectionService({ factory: () => fakeFactory(), engine: () => undefined });
170
+ expect((await noEngine.connect(externalDs)).status).toBe('skipped-no-infra');
171
+ });
172
+
173
+ describe('D5 connect-failure policy', () => {
174
+ const failExternal: ConnectableDatasource = {
175
+ ...externalDs,
176
+ external: { allowWrites: false, validation: { onMismatch: 'fail' } },
177
+ };
178
+
179
+ it('fail-fast: a declared-auto external + onMismatch:fail re-throws (bricks boot)', async () => {
180
+ const { service } = svc({ factory: fakeFactory({ connectThrows: true }) });
181
+ await expect(
182
+ service.connect(failExternal, { context: { trigger: 'declared-auto' } }),
183
+ ).rejects.toThrow(/fail-fast/);
184
+ });
185
+
186
+ it('degrade: the SAME datasource connected via runtime-admin never bricks the server', async () => {
187
+ const { service } = svc({ factory: fakeFactory({ connectThrows: true }) });
188
+ const result = await service.connect(failExternal, { context: { trigger: 'runtime-admin' } });
189
+ expect(result.status).toBe('failed-degraded');
190
+ });
191
+
192
+ it('degrade: external + onMismatch:warn degrades even at boot', async () => {
193
+ const { service } = svc({ factory: fakeFactory({ connectThrows: true }) });
194
+ const result = await service.connect(externalDs, { context: { trigger: 'declared-auto' } });
195
+ expect(result.status).toBe('failed-degraded');
196
+ });
197
+ });
198
+ });
199
+
200
+ describe('D3 credential resolution — fail-closed', () => {
201
+ const credExternal: ConnectableDatasource = {
202
+ name: 'warehouse',
203
+ driver: 'sqlite',
204
+ schemaMode: 'external',
205
+ config: {},
206
+ external: { allowWrites: false, credentialsRef: 'sys_secret:abc', validation: { onMismatch: 'warn' } },
207
+ };
208
+
209
+ it('fails closed when a credentialsRef is declared but NO secret store is configured', async () => {
210
+ const factory = fakeFactory();
211
+ const { service, engine } = svc({ factory }); // no `secrets`
212
+ const result = await service.connect(credExternal, { context: { trigger: 'runtime-admin' } });
213
+ expect(result.status).toBe('failed-credentials');
214
+ expect(result.reason).toMatch(/no secret store/);
215
+ expect(factory.create).not.toHaveBeenCalled(); // never built without the secret
216
+ expect(engine!.drivers.size).toBe(0);
217
+ });
218
+
219
+ it('fails closed when the credentialsRef cannot be resolved/decrypted (undefined)', async () => {
220
+ const factory = fakeFactory();
221
+ const { service } = svc({ factory, secrets: { resolve: async () => undefined } });
222
+ const result = await service.connect(credExternal, { context: { trigger: 'runtime-admin' } });
223
+ expect(result.status).toBe('failed-credentials');
224
+ expect(result.reason).toMatch(/could not be resolved or decrypted/);
225
+ expect(factory.create).not.toHaveBeenCalled();
226
+ });
227
+
228
+ it('fails closed when the resolver throws', async () => {
229
+ const factory = fakeFactory();
230
+ const { service } = svc({
231
+ factory,
232
+ secrets: {
233
+ resolve: async () => {
234
+ throw new Error('kms unreachable');
235
+ },
236
+ },
237
+ });
238
+ const result = await service.connect(credExternal, { context: { trigger: 'runtime-admin' } });
239
+ expect(result.status).toBe('failed-credentials');
240
+ expect(factory.create).not.toHaveBeenCalled();
241
+ });
242
+
243
+ it('fail-fast: a declared-auto external + onMismatch:fail with an unresolvable credential re-throws', async () => {
244
+ const failCred: ConnectableDatasource = {
245
+ ...credExternal,
246
+ external: { allowWrites: false, credentialsRef: 'sys_secret:abc', validation: { onMismatch: 'fail' } },
247
+ };
248
+ const { service } = svc({ secrets: { resolve: async () => undefined } });
249
+ await expect(service.connect(failCred, { context: { trigger: 'declared-auto' } })).rejects.toThrow(/fail-fast/);
250
+ });
251
+
252
+ it('connects when the credential resolves to a secret', async () => {
253
+ const create = vi.fn(async () => ({ driver: { name: 'd' }, connect: async () => {} }));
254
+ const factory: IDatasourceDriverFactory = { supports: () => true, create };
255
+ const { service } = svc({ factory, secrets: { resolve: async () => 's3cr3t' } });
256
+ const result = await service.connect(credExternal);
257
+ expect(result.status).toBe('connected');
258
+ expect(create).toHaveBeenCalledWith(expect.objectContaining({ secret: 's3cr3t' }));
259
+ });
260
+ });
261
+
262
+ describe('DatasourceConnectionService.connectDeclared', () => {
263
+ it('connects only the gated datasources and syncs each one’s bound objects', async () => {
264
+ const { service, engine, factory } = svc();
265
+ const datasources: ConnectableDatasource[] = [
266
+ externalDs, // external → connect
267
+ { name: 'crm_primary', driver: 'sqlite', schemaMode: 'managed', config: { filename: ':memory:' } }, // managed+unrouted → skip
268
+ { name: 'reporting', driver: 'sqlite', schemaMode: 'managed', config: {} }, // managed but object-bound → connect
269
+ ];
270
+ const objects = [
271
+ { name: 'ext_customer', datasource: 'warehouse' },
272
+ { name: 'report_row', datasource: 'reporting' },
273
+ { name: 'account', datasource: 'default' }, // routes to default → no effect
274
+ ];
275
+ const results = await service.connectDeclared({ datasources, objects });
276
+ const byName = Object.fromEntries(results.map((r) => [r.name, r.status]));
277
+ expect(byName).toEqual({ warehouse: 'connected', reporting: 'connected' });
278
+ expect(engine!.drivers.has('crm_primary')).toBe(false); // unchanged
279
+ expect(engine!.drivers.has('warehouse')).toBe(true);
280
+ expect(engine!.drivers.has('reporting')).toBe(true);
281
+ expect(engine!.synced.sort()).toEqual(['ext_customer', 'report_row']);
282
+ expect(factory.create).toHaveBeenCalledTimes(2);
283
+ });
284
+
285
+ it('skips inactive datasources', async () => {
286
+ const { service, engine } = svc();
287
+ const results = await service.connectDeclared({
288
+ datasources: [{ ...externalDs, active: false }],
289
+ objects: [],
290
+ });
291
+ expect(results).toEqual([]);
292
+ expect(engine!.drivers.size).toBe(0);
293
+ });
294
+ });
@@ -0,0 +1,69 @@
1
+ // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2
+
3
+ /**
4
+ * DatasourceConnectPolicy — host-injectable gate consulted *before*
5
+ * {@link DatasourceConnectionService} builds and registers a live driver
6
+ * (ADR-0062 D1/D5, and the epic #2163 "connect-policy seam" note).
7
+ *
8
+ * The framework ships a permissive default ({@link allowAllConnectPolicy}) so a
9
+ * self-hosted single-environment runtime connects external datasources out of
10
+ * the box (subject to the D2 auto-connect gate, which is applied separately by
11
+ * {@link DatasourceConnectionService.connectDeclared}). A multi-tenant host
12
+ * (shared-container cloud) binds a stricter policy that can *fail-close* on the
13
+ * shared runtime — e.g. checking `sys_environment.plan`, an egress allow-list,
14
+ * and per-tenant quota — to enforce SSRF / egress isolation.
15
+ *
16
+ * This keeps a single connect path for code- and runtime-origin datasources
17
+ * (D1): the host injects a policy rather than forking a second connect path.
18
+ * No plan coupling lives in the open framework.
19
+ */
20
+
21
+ /** Why a connect is being attempted — lets a policy treat origins differently. */
22
+ export interface DatasourceConnectContext {
23
+ /** Provenance of the datasource being connected. */
24
+ origin?: 'code' | 'runtime';
25
+ /**
26
+ * What triggered this connect:
27
+ * - `declared-auto` — code-defined datasource auto-connected at boot (D2 gate passed).
28
+ * - `runtime-admin` — UI "Add/Update Datasource" hot pool registration.
29
+ * - `rehydrate` — boot rehydration of a persisted runtime datasource.
30
+ */
31
+ trigger?: 'declared-auto' | 'runtime-admin' | 'rehydrate';
32
+ }
33
+
34
+ /** A policy verdict. `allow:false` leaves the datasource unconnected (metadata-only). */
35
+ export interface DatasourceConnectDecision {
36
+ allow: boolean;
37
+ /** Human-readable reason, surfaced in logs when a connect is denied. */
38
+ reason?: string;
39
+ }
40
+
41
+ /** The minimal datasource shape a policy inspects (never a secret). */
42
+ export interface DatasourceConnectSubject {
43
+ name: string;
44
+ driver: string;
45
+ schemaMode?: 'managed' | 'external' | 'validate-only';
46
+ external?: Record<string, unknown>;
47
+ }
48
+
49
+ /** Host-provided policy gate consulted before opening a connection. */
50
+ export interface DatasourceConnectPolicy {
51
+ /**
52
+ * Decide whether `ds` may be connected. Sync or async. Throwing is treated
53
+ * as a denial (fail-closed) by {@link DatasourceConnectionService}.
54
+ */
55
+ canConnect(
56
+ ds: DatasourceConnectSubject,
57
+ ctx?: DatasourceConnectContext,
58
+ ): DatasourceConnectDecision | Promise<DatasourceConnectDecision>;
59
+ }
60
+
61
+ /**
62
+ * Open-core default: allow every connect. The D2 auto-connect gate (external /
63
+ * explicitly-routed / `autoConnect:true`) still applies on top of this for
64
+ * code-defined datasources, so a managed, unrouted datasource is never
65
+ * connected even under the permissive policy.
66
+ */
67
+ export const allowAllConnectPolicy: DatasourceConnectPolicy = {
68
+ canConnect: () => ({ allow: true }),
69
+ };
@@ -16,3 +16,14 @@ export type {
16
16
  DatasourceDriverHandle,
17
17
  IDatasourceDriverFactory,
18
18
  } from './datasource-driver-factory.js';
19
+
20
+ // Host-injectable connect policy (ADR-0062 D5 / epic #2163 seam).
21
+ export {
22
+ allowAllConnectPolicy,
23
+ } from './connect-policy.js';
24
+ export type {
25
+ DatasourceConnectPolicy,
26
+ DatasourceConnectDecision,
27
+ DatasourceConnectContext,
28
+ DatasourceConnectSubject,
29
+ } from './connect-policy.js';
@@ -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
  /**
@@ -135,6 +139,13 @@ export interface DatasourceAdminServicePluginOptions {
135
139
  secrets?: SecretBinder;
136
140
  /** Override the driver factory (defaults to the `'datasource-driver-factory'` service). */
137
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;
138
149
  logger?: Logger;
139
150
  }
140
151
 
@@ -162,6 +173,8 @@ export class DatasourceAdminServicePlugin implements Plugin {
162
173
 
163
174
  private service?: DatasourceAdminService;
164
175
  private config?: DatasourceAdminServiceConfig;
176
+ /** Shared "definition → live driver" path (ADR-0062 D1); also exposed as the `'datasource-connection'` service. */
177
+ private connection?: DatasourceConnectionService;
165
178
  private readonly options: DatasourceAdminServicePluginOptions;
166
179
 
167
180
  constructor(options: DatasourceAdminServicePluginOptions = {}) {
@@ -204,6 +217,19 @@ export class DatasourceAdminServicePlugin implements Plugin {
204
217
  const factory = (): IDatasourceDriverFactory | undefined =>
205
218
  this.options.driverFactory ?? safeGetService<IDatasourceDriverFactory>(ctx, 'datasource-driver-factory');
206
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
+
207
233
  const config: DatasourceAdminServiceConfig = {
208
234
  probe: (input) => this.probe(factory(), input),
209
235
 
@@ -261,40 +287,21 @@ export class DatasourceAdminServicePlugin implements Plugin {
261
287
  return objects.filter((o) => o?.datasource === datasource).length;
262
288
  },
263
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.
264
297
  registerPool: async (record) => {
265
- const f = factory();
266
- const engine = engineOf();
267
- if (!f || !engine?.registerDriver || !f.supports(record.driver)) return;
268
- // Recover the cleartext credential from `sys_secret` so the pool opens
269
- // with the real password. The cleartext is never persisted on the
270
- // record (only `credentialsRef`), so it must be dereferenced here —
271
- // both on create/update and on boot rehydration. Credential-less
272
- // drivers (sqlite/memory) simply have no ref and skip this.
273
- const credentialsRef = record.external?.credentialsRef;
274
- const secret = credentialsRef ? await this.options.secrets?.resolve?.(credentialsRef) : undefined;
275
- const handle = await f.create({ ...this.toSpec(record), ...(secret ? { secret } : {}) });
276
- if (typeof handle?.connect === 'function') await handle.connect();
277
- // The engine routes a datasource to a driver by `driver.name === <datasource name>`
278
- // (see ObjectQL engine.getDriver). Prefer the factory's underlying engine
279
- // driver (the `driver` escape hatch); fall back to the handle itself. Stamp
280
- // the name so routing resolves to this pool.
281
- const engineDriver = (handle.driver ?? handle) as { name?: string };
282
- try {
283
- engineDriver.name = record.name;
284
- } catch {
285
- /* frozen driver — registration may still work if name already matches */
286
- }
287
- engine.registerDriver(engineDriver);
288
- engine.registerDatasourceDef?.({
289
- name: record.name,
290
- schemaMode: record.schemaMode,
291
- external: record.external as { allowWrites?: boolean } | undefined,
298
+ await this.connection?.connect(record, {
299
+ context: { origin: record.origin ?? 'runtime', trigger: 'runtime-admin' },
292
300
  });
293
301
  },
294
302
 
295
303
  unregisterPool: async (name) => {
296
- const driver = engineOf()?.getDriverByName?.(name) as { disconnect?: () => Promise<void> } | undefined;
297
- if (typeof driver?.disconnect === 'function') await driver.disconnect();
304
+ await this.connection?.disconnect(name);
298
305
  },
299
306
 
300
307
  logger,
@@ -426,16 +433,6 @@ export class DatasourceAdminServicePlugin implements Plugin {
426
433
 
427
434
  // --- internals -----------------------------------------------------------
428
435
 
429
- private toSpec(record: StoredDatasource): DatasourceConnectionSpec {
430
- return {
431
- name: record.name,
432
- driver: record.driver,
433
- config: record.config ?? {},
434
- external: record.external,
435
- pool: record.pool,
436
- };
437
- }
438
-
439
436
  /** Probe a connection via the driver factory: build → connect → ping → close. */
440
437
  private async probe(
441
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;