@objectstack/service-datasource 10.0.0 → 10.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.turbo/turbo-build.log +28 -16
- package/CHANGELOG.md +100 -0
- package/dist/chunk-76HQ74MX.cjs +82 -0
- package/dist/chunk-76HQ74MX.cjs.map +1 -0
- package/dist/chunk-BI2SYWLC.cjs +9 -0
- package/dist/chunk-BI2SYWLC.cjs.map +1 -0
- package/dist/chunk-JRBGOCRJ.js +82 -0
- package/dist/chunk-JRBGOCRJ.js.map +1 -0
- package/dist/chunk-XLS4RP7B.js +9 -0
- package/dist/chunk-XLS4RP7B.js.map +1 -0
- package/dist/contracts/index.cjs +7 -1
- package/dist/contracts/index.cjs.map +1 -1
- package/dist/contracts/index.d.cts +59 -1
- package/dist/contracts/index.d.ts +59 -1
- package/dist/contracts/index.js +6 -0
- package/dist/index.cjs +284 -106
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +270 -5
- package/dist/index.d.ts +270 -5
- package/dist/index.js +216 -38
- package/dist/index.js.map +1 -1
- package/dist/sqlite-driver-fallback-BPFQYLX7.js +11 -0
- package/dist/sqlite-driver-fallback-BPFQYLX7.js.map +1 -0
- package/dist/sqlite-driver-fallback-JX4XOICD.cjs +11 -0
- package/dist/sqlite-driver-fallback-JX4XOICD.cjs.map +1 -0
- package/package.json +8 -7
- package/src/__tests__/datasource-connection-service.test.ts +294 -0
- package/src/contracts/connect-policy.ts +69 -0
- package/src/contracts/index.ts +11 -0
- package/src/datasource-admin-plugin.ts +37 -40
- package/src/datasource-admin-service.ts +2 -0
- package/src/datasource-connection-service.ts +364 -0
- package/src/default-datasource-driver-factory.ts +26 -9
- package/src/index.ts +29 -0
- package/src/logger.ts +2 -0
- package/src/sqlite-driver-fallback.test.ts +184 -0
- package/src/sqlite-driver-fallback.ts +195 -0
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import {
|
|
2
|
+
NATIVE_SQLITE_MEMORY_FALLBACK_WARNING,
|
|
3
|
+
NATIVE_SQLITE_WASM_FALLBACK_WARNING,
|
|
4
|
+
resolveSqliteDriver
|
|
5
|
+
} from "./chunk-JRBGOCRJ.js";
|
|
6
|
+
export {
|
|
7
|
+
NATIVE_SQLITE_MEMORY_FALLBACK_WARNING,
|
|
8
|
+
NATIVE_SQLITE_WASM_FALLBACK_WARNING,
|
|
9
|
+
resolveSqliteDriver
|
|
10
|
+
};
|
|
11
|
+
//# sourceMappingURL=sqlite-driver-fallback-BPFQYLX7.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":[],"sourcesContent":[],"mappings":"","names":[]}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
"use strict";Object.defineProperty(exports, "__esModule", {value: true});
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
var _chunk76HQ74MXcjs = require('./chunk-76HQ74MX.cjs');
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
exports.NATIVE_SQLITE_MEMORY_FALLBACK_WARNING = _chunk76HQ74MXcjs.NATIVE_SQLITE_MEMORY_FALLBACK_WARNING; exports.NATIVE_SQLITE_WASM_FALLBACK_WARNING = _chunk76HQ74MXcjs.NATIVE_SQLITE_WASM_FALLBACK_WARNING; exports.resolveSqliteDriver = _chunk76HQ74MXcjs.resolveSqliteDriver;
|
|
11
|
+
//# sourceMappingURL=sqlite-driver-fallback-JX4XOICD.cjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["/home/runner/work/framework/framework/packages/services/service-datasource/dist/sqlite-driver-fallback-JX4XOICD.cjs"],"names":[],"mappings":"AAAA;AACE;AACA;AACA;AACF,wDAA6B;AAC7B;AACE;AACA;AACA;AACF,kRAAC","file":"/home/runner/work/framework/framework/packages/services/service-datasource/dist/sqlite-driver-fallback-JX4XOICD.cjs"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@objectstack/service-datasource",
|
|
3
|
-
"version": "10.
|
|
3
|
+
"version": "10.3.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,19 @@
|
|
|
19
19
|
}
|
|
20
20
|
},
|
|
21
21
|
"dependencies": {
|
|
22
|
-
"@objectstack/core": "10.
|
|
23
|
-
"@objectstack/spec": "10.
|
|
22
|
+
"@objectstack/core": "10.3.0",
|
|
23
|
+
"@objectstack/spec": "10.3.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.
|
|
31
|
-
"@objectstack/driver-sql": "10.
|
|
32
|
-
"@objectstack/
|
|
33
|
-
"@objectstack/
|
|
30
|
+
"@objectstack/driver-memory": "10.3.0",
|
|
31
|
+
"@objectstack/driver-sql": "10.3.0",
|
|
32
|
+
"@objectstack/driver-sqlite-wasm": "10.3.0",
|
|
33
|
+
"@objectstack/plugin-hono-server": "10.3.0",
|
|
34
|
+
"@objectstack/driver-mongodb": "10.3.0"
|
|
34
35
|
},
|
|
35
36
|
"keywords": [
|
|
36
37
|
"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
|
+
};
|
package/src/contracts/index.ts
CHANGED
|
@@ -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
|
-
|
|
266
|
-
|
|
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
|
-
|
|
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;
|