@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.
- package/.turbo/turbo-build.log +19 -15
- package/CHANGELOG.md +88 -0
- package/README.md +34 -0
- package/dist/chunk-BI2SYWLC.cjs +9 -0
- package/dist/chunk-BI2SYWLC.cjs.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 +555 -89
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +204 -4
- package/dist/index.d.ts +204 -4
- package/dist/index.js +497 -31
- package/dist/index.js.map +1 -1
- package/package.json +8 -8
- package/src/__tests__/admin-routes.test.ts +58 -0
- package/src/__tests__/datasource-admin-plugin.test.ts +59 -0
- package/src/__tests__/datasource-admin-service.test.ts +30 -0
- package/src/__tests__/datasource-connection-service.test.ts +294 -0
- package/src/admin-routes.ts +75 -0
- package/src/contracts/connect-policy.ts +69 -0
- package/src/contracts/index.ts +11 -0
- package/src/datasource-admin-plugin.ts +189 -43
- package/src/datasource-admin-service.ts +32 -0
- package/src/datasource-connection-service.ts +364 -0
- package/src/driver-catalog.ts +113 -0
- package/src/external-datasource-service.ts +25 -0
- package/src/index.ts +18 -0
- package/src/logger.ts +2 -0
|
@@ -42,6 +42,64 @@ describe('registerDatasourceAdminRoutes (real HonoHttpServer)', () => {
|
|
|
42
42
|
expect(listDatasources).toHaveBeenCalledOnce();
|
|
43
43
|
});
|
|
44
44
|
|
|
45
|
+
it('GET /api/v1/datasources/drivers returns the driver catalog with configSchema', async () => {
|
|
46
|
+
const app = mount({}); // no service dependency — static catalog
|
|
47
|
+
const res = await app.fetch(json('/api/v1/datasources/drivers'));
|
|
48
|
+
expect(res.status).toBe(200);
|
|
49
|
+
const body = (await res.json()) as { drivers: Array<{ id: string; label: string; configSchema: any }> };
|
|
50
|
+
expect(Array.isArray(body.drivers)).toBe(true);
|
|
51
|
+
const sqlite = body.drivers.find((d) => d.id === 'sqlite');
|
|
52
|
+
expect(sqlite).toBeTruthy();
|
|
53
|
+
expect(sqlite!.label).toBe('SQLite');
|
|
54
|
+
expect(sqlite!.configSchema?.properties?.filename?.type).toBe('string');
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('GET /api/v1/datasources/:name/remote-tables lists remote tables', async () => {
|
|
58
|
+
const listRemoteTables = vi.fn().mockResolvedValue([{ name: 'customers', columnCount: 4 }]);
|
|
59
|
+
const app = mount({ listRemoteTables });
|
|
60
|
+
const res = await app.fetch(json('/api/v1/datasources/demo_ext/remote-tables'));
|
|
61
|
+
expect(res.status).toBe(200);
|
|
62
|
+
expect(await res.json()).toEqual({ tables: [{ name: 'customers', columnCount: 4 }] });
|
|
63
|
+
expect(listRemoteTables).toHaveBeenCalledWith('demo_ext');
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it('POST /api/v1/datasources/:name/object-draft generates a draft (400 without table)', async () => {
|
|
67
|
+
const generateObjectDraft = vi.fn().mockResolvedValue({ name: 'customers', definition: { fields: { id: {} } } });
|
|
68
|
+
const app = mount({ generateObjectDraft });
|
|
69
|
+
|
|
70
|
+
const missing = await app.fetch(json('/api/v1/datasources/demo_ext/object-draft', { method: 'POST', body: JSON.stringify({}) }));
|
|
71
|
+
expect(missing.status).toBe(400);
|
|
72
|
+
|
|
73
|
+
const ok = await app.fetch(json('/api/v1/datasources/demo_ext/object-draft', { method: 'POST', body: JSON.stringify({ table: 'customers' }) }));
|
|
74
|
+
expect(ok.status).toBe(200);
|
|
75
|
+
expect((await ok.json()).draft.name).toBe('customers');
|
|
76
|
+
expect(generateObjectDraft).toHaveBeenCalledWith('demo_ext', 'customers', {});
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it('GET /api/v1/datasources/:name returns the credential-stripped detail (404 when unknown)', async () => {
|
|
80
|
+
const getDatasource = vi.fn(async (name: string) =>
|
|
81
|
+
name === 'demo_ext'
|
|
82
|
+
? { name: 'demo_ext', driver: 'sqlite', schemaMode: 'managed', config: { filename: '/tmp/x.db' }, active: true, origin: 'runtime', hasSecret: false }
|
|
83
|
+
: undefined,
|
|
84
|
+
);
|
|
85
|
+
const app = mount({ getDatasource });
|
|
86
|
+
const ok = await app.fetch(json('/api/v1/datasources/demo_ext'));
|
|
87
|
+
expect(ok.status).toBe(200);
|
|
88
|
+
expect((await ok.json()).datasource.config.filename).toBe('/tmp/x.db');
|
|
89
|
+
const missing = await app.fetch(json('/api/v1/datasources/nope'));
|
|
90
|
+
expect(missing.status).toBe(404);
|
|
91
|
+
expect(getDatasource).toHaveBeenCalledWith('demo_ext');
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it('POST /api/v1/datasources/:name/test probes a saved datasource by name', async () => {
|
|
95
|
+
const testConnection = vi.fn().mockResolvedValue({ ok: true, latencyMs: 7, tableCount: 2 });
|
|
96
|
+
const app = mount({ testConnection });
|
|
97
|
+
const res = await app.fetch(json('/api/v1/datasources/demo_ext/test', { method: 'POST', body: '{}' }));
|
|
98
|
+
expect(res.status).toBe(200);
|
|
99
|
+
expect(await res.json()).toEqual({ ok: true, latencyMs: 7, tableCount: 2 });
|
|
100
|
+
expect(testConnection).toHaveBeenCalledWith('demo_ext');
|
|
101
|
+
});
|
|
102
|
+
|
|
45
103
|
it('POST /api/v1/datasources/test splits the inline secret out of the draft', async () => {
|
|
46
104
|
const testConnection = vi.fn().mockResolvedValue({ ok: true });
|
|
47
105
|
const app = mount({ testConnection });
|
|
@@ -229,3 +229,62 @@ describe('DatasourceAdminServicePlugin: persistence + bound count', () => {
|
|
|
229
229
|
await expect(service.removeDatasource('reporting')).rejects.toThrow(/1 object\(s\)/);
|
|
230
230
|
});
|
|
231
231
|
});
|
|
232
|
+
|
|
233
|
+
describe('DatasourceAdminServicePlugin: runtime datasource durability', () => {
|
|
234
|
+
/** In-memory `sys_metadata` engine shared across two boots (a "restart"). */
|
|
235
|
+
function fakeSysMetadataEngine() {
|
|
236
|
+
const rows: Array<Record<string, unknown>> = [];
|
|
237
|
+
return {
|
|
238
|
+
rows,
|
|
239
|
+
registerDriver() {},
|
|
240
|
+
registerDatasourceDef() {},
|
|
241
|
+
getDriverByName() { return undefined; },
|
|
242
|
+
findOne: async (_o: string, q: { where?: Record<string, unknown> }) => {
|
|
243
|
+
const w = q.where ?? {};
|
|
244
|
+
return rows.find((r) => Object.entries(w).every(([k, v]) => r[k] === v));
|
|
245
|
+
},
|
|
246
|
+
find: async (_o: string, q: { where?: Record<string, unknown> }) => {
|
|
247
|
+
const w = q.where ?? {};
|
|
248
|
+
return rows.filter((r) => Object.entries(w).every(([k, v]) => r[k] === v));
|
|
249
|
+
},
|
|
250
|
+
insert: async (_o: string, row: Record<string, unknown>) => { rows.push({ ...row }); },
|
|
251
|
+
update: async (_o: string, row: Record<string, unknown>, opts: { where: Record<string, unknown> }) => {
|
|
252
|
+
const i = rows.findIndex((r) => r.id === opts.where.id);
|
|
253
|
+
if (i >= 0) rows[i] = { ...rows[i], ...row };
|
|
254
|
+
},
|
|
255
|
+
delete: async (_o: string, opts: { where: Record<string, unknown> }) => {
|
|
256
|
+
const i = rows.findIndex((r) => r.id === opts.where.id);
|
|
257
|
+
if (i >= 0) rows.splice(i, 1);
|
|
258
|
+
},
|
|
259
|
+
};
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
it('persists a UI-created datasource to sys_metadata and restores it after a restart', async () => {
|
|
263
|
+
const data = fakeSysMetadataEngine();
|
|
264
|
+
|
|
265
|
+
// Boot #1: create a runtime sqlite datasource (no secret needed).
|
|
266
|
+
const b1 = await boot({ services: { data } });
|
|
267
|
+
await b1.service.createDatasource({ name: 'demo_ext', driver: 'sqlite', config: { filename: '/tmp/x.db' } });
|
|
268
|
+
// It is durably written to sys_metadata (not just the in-memory registry).
|
|
269
|
+
expect(data.rows.filter((r) => r.type === 'datasource' && r.name === 'demo_ext')).toHaveLength(1);
|
|
270
|
+
|
|
271
|
+
// Boot #2 = "restart": fresh in-memory registry, SAME sys_metadata engine.
|
|
272
|
+
const b2 = await boot({ services: { data } });
|
|
273
|
+
// Before restore, the fresh registry is empty.
|
|
274
|
+
expect(await b2.service.listDatasources()).toHaveLength(0);
|
|
275
|
+
// start() restores runtime rows from sys_metadata into the registry.
|
|
276
|
+
await b2.plugin.start(b2.ctx);
|
|
277
|
+
const after = await b2.service.listDatasources();
|
|
278
|
+
expect(after.map((d) => d.name)).toContain('demo_ext');
|
|
279
|
+
expect(after.find((d) => d.name === 'demo_ext')?.origin).toBe('runtime');
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
it('removes the durable sys_metadata row when a datasource is deleted', async () => {
|
|
283
|
+
const data = fakeSysMetadataEngine();
|
|
284
|
+
const b = await boot({ services: { data } });
|
|
285
|
+
await b.service.createDatasource({ name: 'gone', driver: 'sqlite', config: { filename: '/tmp/y.db' } });
|
|
286
|
+
expect(data.rows.some((r) => r.name === 'gone')).toBe(true);
|
|
287
|
+
await b.service.removeDatasource('gone');
|
|
288
|
+
expect(data.rows.some((r) => r.name === 'gone')).toBe(false);
|
|
289
|
+
});
|
|
290
|
+
});
|
|
@@ -286,3 +286,33 @@ describe('removeDatasource', () => {
|
|
|
286
286
|
await expect(service.removeDatasource('nope')).rejects.toThrow(/not found/i);
|
|
287
287
|
});
|
|
288
288
|
});
|
|
289
|
+
|
|
290
|
+
describe('getDatasource', () => {
|
|
291
|
+
it('returns config + hasSecret with the credentialsRef stripped', async () => {
|
|
292
|
+
const { service } = makeHarness({
|
|
293
|
+
seed: [
|
|
294
|
+
{
|
|
295
|
+
name: 'pg',
|
|
296
|
+
driver: 'postgres',
|
|
297
|
+
origin: 'runtime',
|
|
298
|
+
config: { host: 'db', port: 5432, database: 'app' },
|
|
299
|
+
external: { credentialsRef: 'sys_secret://datasource/pg#1' },
|
|
300
|
+
},
|
|
301
|
+
],
|
|
302
|
+
});
|
|
303
|
+
const ds = await service.getDatasource('pg');
|
|
304
|
+
expect(ds).toMatchObject({ name: 'pg', driver: 'postgres', origin: 'runtime', hasSecret: true });
|
|
305
|
+
expect(ds!.config).toEqual({ host: 'db', port: 5432, database: 'app' });
|
|
306
|
+
// The opaque credential handle must never be returned.
|
|
307
|
+
expect(JSON.stringify(ds)).not.toContain('sys_secret');
|
|
308
|
+
expect(JSON.stringify(ds)).not.toContain('credentialsRef');
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
it('reports hasSecret:false and returns undefined for unknown names', async () => {
|
|
312
|
+
const { service } = makeHarness({
|
|
313
|
+
seed: [{ name: 'lite', driver: 'sqlite', origin: 'runtime', config: { filename: '/tmp/a.db' } }],
|
|
314
|
+
});
|
|
315
|
+
expect((await service.getDatasource('lite'))!.hasSecret).toBe(false);
|
|
316
|
+
expect(await service.getDatasource('missing')).toBeUndefined();
|
|
317
|
+
});
|
|
318
|
+
});
|
|
@@ -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
|
+
});
|
package/src/admin-routes.ts
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
import type { PluginContext } from '@objectstack/core';
|
|
4
4
|
import type { IHttpServer } from '@objectstack/spec/contracts';
|
|
5
|
+
import { DRIVER_CATALOG } from './driver-catalog.js';
|
|
5
6
|
|
|
6
7
|
/**
|
|
7
8
|
* Datasource lifecycle REST routes (ADR-0015 Addendum §3.5).
|
|
@@ -36,6 +37,14 @@ export function registerDatasourceAdminRoutes(
|
|
|
36
37
|
}
|
|
37
38
|
};
|
|
38
39
|
|
|
40
|
+
const externalService = (): any => {
|
|
41
|
+
try {
|
|
42
|
+
return ctx.getService<any>('external-datasource');
|
|
43
|
+
} catch {
|
|
44
|
+
return undefined;
|
|
45
|
+
}
|
|
46
|
+
};
|
|
47
|
+
|
|
39
48
|
const unavailable = (res: any) =>
|
|
40
49
|
res.status(503).json({ error: 'datasource_admin_unavailable' });
|
|
41
50
|
|
|
@@ -63,6 +72,72 @@ export function registerDatasourceAdminRoutes(
|
|
|
63
72
|
res.json({ datasources });
|
|
64
73
|
});
|
|
65
74
|
|
|
75
|
+
// Catalog of connection drivers + their JSON-Schema config (drives the
|
|
76
|
+
// Studio connection form). Static metadata — no service dependency, so it
|
|
77
|
+
// is always available even before any datasource-admin service is wired.
|
|
78
|
+
server.get(`${root}/drivers`, async (_req: any, res: any) => {
|
|
79
|
+
res.json({ drivers: DRIVER_CATALOG });
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
// Read-only schema introspection for the Studio "sync objects" flow.
|
|
83
|
+
// `GET /datasources/:name/remote-tables` lists the datasource's remote tables;
|
|
84
|
+
// `POST /datasources/:name/object-draft` generates an ObjectStack object
|
|
85
|
+
// definition draft for one table (introspect + type-map, no persistence —
|
|
86
|
+
// the caller creates the object through the normal metadata channel).
|
|
87
|
+
server.get(`${root}/:name/remote-tables`, async (req: any, res: any) => {
|
|
88
|
+
const svc = externalService();
|
|
89
|
+
if (!svc?.listRemoteTables) return unavailable(res);
|
|
90
|
+
try {
|
|
91
|
+
const tables = await svc.listRemoteTables(req.params.name);
|
|
92
|
+
res.json({ tables });
|
|
93
|
+
} catch (err) {
|
|
94
|
+
badRequest(res, err);
|
|
95
|
+
}
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
// Test a *saved* datasource by name with a live round-trip (backs the
|
|
99
|
+
// `datasource` `test_connection` action). Distinct from `POST /datasources/test`
|
|
100
|
+
// which probes an unsaved draft carried inline. Registered before the generic
|
|
101
|
+
// `:name` mutation routes.
|
|
102
|
+
// Read one datasource's full detail for the edit form (credential stripped;
|
|
103
|
+
// `config` is non-sensitive, plus a `hasSecret` flag). Registered after the
|
|
104
|
+
// static `/drivers` route so that literal segment is never captured as a name.
|
|
105
|
+
server.get(`${root}/:name`, async (req: any, res: any) => {
|
|
106
|
+
const svc = adminService();
|
|
107
|
+
if (!svc?.getDatasource) return unavailable(res);
|
|
108
|
+
try {
|
|
109
|
+
const datasource = await svc.getDatasource(req.params.name);
|
|
110
|
+
if (!datasource) return res.status(404).json({ error: 'not_found' });
|
|
111
|
+
res.json({ datasource });
|
|
112
|
+
} catch (err) {
|
|
113
|
+
badRequest(res, err);
|
|
114
|
+
}
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
server.post(`${root}/:name/test`, async (req: any, res: any) => {
|
|
118
|
+
const svc = externalService();
|
|
119
|
+
if (!svc?.testConnection) return unavailable(res);
|
|
120
|
+
try {
|
|
121
|
+
const result = await svc.testConnection(req.params.name);
|
|
122
|
+
res.json(result);
|
|
123
|
+
} catch (err) {
|
|
124
|
+
badRequest(res, err);
|
|
125
|
+
}
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
server.post(`${root}/:name/object-draft`, async (req: any, res: any) => {
|
|
129
|
+
const svc = externalService();
|
|
130
|
+
if (!svc?.generateObjectDraft) return unavailable(res);
|
|
131
|
+
const { table, ...opts } = (req.body as Record<string, unknown>) ?? {};
|
|
132
|
+
if (!table) return badRequest(res, new Error('Body field "table" is required.'));
|
|
133
|
+
try {
|
|
134
|
+
const draft = await svc.generateObjectDraft(req.params.name, String(table), opts);
|
|
135
|
+
res.json({ draft });
|
|
136
|
+
} catch (err) {
|
|
137
|
+
badRequest(res, err);
|
|
138
|
+
}
|
|
139
|
+
});
|
|
140
|
+
|
|
66
141
|
// Probe a connection without persisting anything. Registered before the
|
|
67
142
|
// `:name` routes so the literal `test` segment is never captured as a name.
|
|
68
143
|
server.post(`${root}/test`, async (req: any, res: any) => {
|
|
@@ -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';
|