@objectstack/service-datasource 7.6.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 -0
- package/CHANGELOG.md +94 -0
- package/LICENSE +202 -0
- package/LICENSE.apache +202 -0
- package/README.md +50 -0
- package/dist/contracts/index.cjs +1 -0
- package/dist/contracts/index.cjs.map +1 -0
- package/dist/contracts/index.d.cts +178 -0
- package/dist/contracts/index.d.ts +178 -0
- package/dist/contracts/index.js +1 -0
- package/dist/contracts/index.js.map +1 -0
- package/dist/index.cjs +995 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +414 -0
- package/dist/index.d.ts +414 -0
- package/dist/index.js +995 -0
- package/dist/index.js.map +1 -0
- package/package.json +61 -0
- package/src/__tests__/admin-routes.test.ts +106 -0
- package/src/__tests__/datasource-admin-plugin.test.ts +231 -0
- package/src/__tests__/datasource-admin-service.test.ts +288 -0
- package/src/__tests__/datasource-secret-binder.test.ts +101 -0
- package/src/__tests__/external-datasource-service.test.ts +360 -0
- package/src/admin-routes.ts +117 -0
- package/src/contracts/datasource-admin-service.ts +119 -0
- package/src/contracts/datasource-driver-factory.ts +77 -0
- package/src/contracts/index.ts +18 -0
- package/src/datasource-admin-plugin.ts +362 -0
- package/src/datasource-admin-service.ts +297 -0
- package/src/datasource-secret-binder.ts +144 -0
- package/src/default-datasource-driver-factory.ts +185 -0
- package/src/external-datasource-service.ts +456 -0
- package/src/index.ts +73 -0
- package/src/logger.ts +11 -0
- package/src/plugin.ts +119 -0
- package/tsconfig.json +17 -0
- package/tsup.config.ts +19 -0
|
@@ -0,0 +1,360 @@
|
|
|
1
|
+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
|
|
2
|
+
|
|
3
|
+
import { describe, it, expect } from 'vitest';
|
|
4
|
+
import type { IntrospectedSchema } from '@objectstack/spec/contracts';
|
|
5
|
+
import {
|
|
6
|
+
ExternalDatasourceService,
|
|
7
|
+
type DatasourceLike,
|
|
8
|
+
type ObjectLike,
|
|
9
|
+
} from '../external-datasource-service.js';
|
|
10
|
+
|
|
11
|
+
/** Build a fake introspected schema for the `warehouse` datasource. */
|
|
12
|
+
function warehouseSchema(): IntrospectedSchema {
|
|
13
|
+
return {
|
|
14
|
+
dialect: 'postgres',
|
|
15
|
+
introspectedAt: '2026-05-30T00:00:00.000Z',
|
|
16
|
+
tables: {
|
|
17
|
+
'mart.fact_orders': {
|
|
18
|
+
name: 'mart.fact_orders',
|
|
19
|
+
indexes: [],
|
|
20
|
+
columns: [
|
|
21
|
+
{ name: 'order_id', type: 'text', nullable: false, primaryKey: true },
|
|
22
|
+
{ name: 'customer_id', type: 'text', nullable: false, primaryKey: false },
|
|
23
|
+
{ name: 'amount', type: 'numeric(10,2)', nullable: true, primaryKey: false },
|
|
24
|
+
{ name: 'ordered_at', type: 'timestamptz', nullable: true, primaryKey: false },
|
|
25
|
+
{ name: 'metadata', type: 'jsonb', nullable: true, primaryKey: false },
|
|
26
|
+
{ name: 'geo', type: 'geography', nullable: true, primaryKey: false },
|
|
27
|
+
],
|
|
28
|
+
},
|
|
29
|
+
'public.dim_customer': {
|
|
30
|
+
name: 'public.dim_customer',
|
|
31
|
+
indexes: [],
|
|
32
|
+
columns: [
|
|
33
|
+
{ name: 'id', type: 'text', nullable: false, primaryKey: true },
|
|
34
|
+
{ name: 'name', type: 'varchar(255)', nullable: true, primaryKey: false },
|
|
35
|
+
],
|
|
36
|
+
},
|
|
37
|
+
},
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function makeService(overrides?: {
|
|
42
|
+
datasource?: DatasourceLike;
|
|
43
|
+
objects?: ObjectLike[];
|
|
44
|
+
}) {
|
|
45
|
+
const ds: DatasourceLike = overrides?.datasource ?? {
|
|
46
|
+
name: 'warehouse',
|
|
47
|
+
schemaMode: 'external',
|
|
48
|
+
external: { allowedSchemas: ['mart', 'public'] },
|
|
49
|
+
};
|
|
50
|
+
const objects = overrides?.objects ?? [];
|
|
51
|
+
return new ExternalDatasourceService({
|
|
52
|
+
introspect: async () => warehouseSchema(),
|
|
53
|
+
getDatasource: async (n) => (n === ds.name ? ds : undefined),
|
|
54
|
+
getObject: async (n) => objects.find((o) => o.name === n),
|
|
55
|
+
listObjects: async () => objects,
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
describe('listRemoteTables', () => {
|
|
60
|
+
it('lists tables with parsed schema + column counts', async () => {
|
|
61
|
+
const svc = makeService();
|
|
62
|
+
const tables = await svc.listRemoteTables('warehouse');
|
|
63
|
+
expect(tables).toHaveLength(2);
|
|
64
|
+
const orders = tables.find((t) => t.name === 'fact_orders')!;
|
|
65
|
+
expect(orders.schema).toBe('mart');
|
|
66
|
+
expect(orders.columnCount).toBe(6);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it('filters by requested schema', async () => {
|
|
70
|
+
const svc = makeService();
|
|
71
|
+
const tables = await svc.listRemoteTables('warehouse', { schema: 'public' });
|
|
72
|
+
expect(tables.map((t) => t.name)).toEqual(['dim_customer']);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('respects allowedSchemas', async () => {
|
|
76
|
+
const svc = makeService({
|
|
77
|
+
datasource: { name: 'warehouse', schemaMode: 'external', external: { allowedSchemas: ['mart'] } },
|
|
78
|
+
});
|
|
79
|
+
const tables = await svc.listRemoteTables('warehouse');
|
|
80
|
+
expect(tables.map((t) => t.name)).toEqual(['fact_orders']);
|
|
81
|
+
});
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
describe('generateObjectDraft', () => {
|
|
85
|
+
it('maps columns to field types and flags lossy/unknown for review', async () => {
|
|
86
|
+
const svc = makeService();
|
|
87
|
+
const draft = await svc.generateObjectDraft('warehouse', 'fact_orders');
|
|
88
|
+
|
|
89
|
+
expect(draft.name).toBe('fact_orders');
|
|
90
|
+
expect(draft.datasource).toBe('warehouse');
|
|
91
|
+
const fields = draft.definition.fields as Record<string, { type: string; primaryKey?: boolean }>;
|
|
92
|
+
expect(fields.order_id).toEqual({ type: 'text', primaryKey: true });
|
|
93
|
+
expect(fields.amount.type).toBe('number');
|
|
94
|
+
expect(fields.ordered_at.type).toBe('datetime');
|
|
95
|
+
expect(fields.metadata.type).toBe('json');
|
|
96
|
+
// geography is unknown → defaulted to text + review note.
|
|
97
|
+
expect(fields.geo.type).toBe('text');
|
|
98
|
+
expect(draft.review.some((r) => r.column === 'geo')).toBe(true);
|
|
99
|
+
|
|
100
|
+
// Source carries the external binding + a REVIEW marker.
|
|
101
|
+
expect(draft.source).toContain("remoteName: 'fact_orders'");
|
|
102
|
+
expect(draft.source).toContain("remoteSchema: 'mart'");
|
|
103
|
+
expect(draft.source).toContain('REVIEW:');
|
|
104
|
+
expect(draft.source).toContain("order_id: { type: 'text', primaryKey: true }");
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it('honours include/exclude/rename/primaryKey options', async () => {
|
|
108
|
+
const svc = makeService();
|
|
109
|
+
const draft = await svc.generateObjectDraft('warehouse', 'fact_orders', {
|
|
110
|
+
includeColumns: ['order_id', 'amount'],
|
|
111
|
+
rename: { amount: 'total' },
|
|
112
|
+
primaryKey: ['order_id'],
|
|
113
|
+
});
|
|
114
|
+
const fields = draft.definition.fields as Record<string, unknown>;
|
|
115
|
+
expect(Object.keys(fields)).toEqual(['order_id', 'total']);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it('throws when the remote table is missing', async () => {
|
|
119
|
+
const svc = makeService();
|
|
120
|
+
await expect(svc.generateObjectDraft('warehouse', 'nope')).rejects.toThrow(/not found/);
|
|
121
|
+
});
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
describe('importObject', () => {
|
|
125
|
+
/** Build a service with a recording persistObject (runtime metadata store). */
|
|
126
|
+
function makeImporter(persistObject?: (name: string, def: Record<string, unknown>) => Promise<void>) {
|
|
127
|
+
const persisted: Array<{ name: string; def: Record<string, unknown> }> = [];
|
|
128
|
+
const svc = new ExternalDatasourceService({
|
|
129
|
+
introspect: async () => warehouseSchema(),
|
|
130
|
+
getDatasource: async () => ({ name: 'warehouse', schemaMode: 'external' }),
|
|
131
|
+
getObject: async () => undefined,
|
|
132
|
+
listObjects: async () => [],
|
|
133
|
+
persistObject:
|
|
134
|
+
persistObject ?? (async (name, def) => { persisted.push({ name, def }); }),
|
|
135
|
+
});
|
|
136
|
+
return { svc, persisted };
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
it('persists a runtime federated object and returns name/definition/review', async () => {
|
|
140
|
+
const { svc, persisted } = makeImporter();
|
|
141
|
+
const result = await svc.importObject('warehouse', 'fact_orders');
|
|
142
|
+
|
|
143
|
+
expect(result.name).toBe('fact_orders');
|
|
144
|
+
expect(persisted).toHaveLength(1);
|
|
145
|
+
expect(persisted[0].name).toBe('fact_orders');
|
|
146
|
+
const def = persisted[0].def as { datasource: string; external: { remoteName: string; writable?: boolean } };
|
|
147
|
+
expect(def.datasource).toBe('warehouse');
|
|
148
|
+
expect(def.external.remoteName).toBe('fact_orders');
|
|
149
|
+
// Read-only by default — no writable flag leaks in.
|
|
150
|
+
expect(def.external.writable).toBeUndefined();
|
|
151
|
+
// The geography column surfaced a review note (carried over from the draft).
|
|
152
|
+
expect(result.review.some((r) => r.column === 'geo')).toBe(true);
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it('applies the name override and writable opt-in', async () => {
|
|
156
|
+
const { svc, persisted } = makeImporter();
|
|
157
|
+
const result = await svc.importObject('warehouse', 'fact_orders', {
|
|
158
|
+
name: 'wh_orders',
|
|
159
|
+
writable: true,
|
|
160
|
+
});
|
|
161
|
+
expect(result.name).toBe('wh_orders');
|
|
162
|
+
const def = persisted[0].def as { name: string; label: string; external: { writable?: boolean } };
|
|
163
|
+
expect(def.name).toBe('wh_orders');
|
|
164
|
+
expect(def.label).toBe('Wh Orders');
|
|
165
|
+
expect(def.external.writable).toBe(true);
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
it('forwards draft options (include/rename) through to the persisted fields', async () => {
|
|
169
|
+
const { svc, persisted } = makeImporter();
|
|
170
|
+
await svc.importObject('warehouse', 'fact_orders', {
|
|
171
|
+
includeColumns: ['order_id', 'amount'],
|
|
172
|
+
rename: { amount: 'total' },
|
|
173
|
+
});
|
|
174
|
+
const def = persisted[0].def as { fields: Record<string, unknown> };
|
|
175
|
+
expect(Object.keys(def.fields)).toEqual(['order_id', 'total']);
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
it('throws when no writable metadata store is wired', async () => {
|
|
179
|
+
const svc = new ExternalDatasourceService({
|
|
180
|
+
introspect: async () => warehouseSchema(),
|
|
181
|
+
getDatasource: async () => ({ name: 'warehouse', schemaMode: 'external' }),
|
|
182
|
+
getObject: async () => undefined,
|
|
183
|
+
listObjects: async () => [],
|
|
184
|
+
// no persistObject
|
|
185
|
+
});
|
|
186
|
+
await expect(svc.importObject('warehouse', 'fact_orders')).rejects.toThrow(
|
|
187
|
+
/writable metadata store/,
|
|
188
|
+
);
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
it('throws when the remote table is missing (no persistence)', async () => {
|
|
192
|
+
const { svc, persisted } = makeImporter();
|
|
193
|
+
await expect(svc.importObject('warehouse', 'ghost')).rejects.toThrow(/not found/);
|
|
194
|
+
expect(persisted).toHaveLength(0);
|
|
195
|
+
});
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
describe('validateObject', () => {
|
|
199
|
+
const baseObject: ObjectLike = {
|
|
200
|
+
name: 'wh_order',
|
|
201
|
+
datasource: 'warehouse',
|
|
202
|
+
external: { remoteName: 'fact_orders' },
|
|
203
|
+
fields: {
|
|
204
|
+
order_id: { type: 'text' },
|
|
205
|
+
customer_id: { type: 'text' },
|
|
206
|
+
amount: { type: 'number' },
|
|
207
|
+
ordered_at: { type: 'datetime' },
|
|
208
|
+
},
|
|
209
|
+
};
|
|
210
|
+
|
|
211
|
+
it('returns ok for a matching federated object', async () => {
|
|
212
|
+
const svc = makeService({ objects: [baseObject] });
|
|
213
|
+
const result = await svc.validateObject('wh_order');
|
|
214
|
+
expect(result.ok).toBe(true);
|
|
215
|
+
expect(result.diffs).toHaveLength(0);
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
it('reports a type_mismatch error for an incompatible field', async () => {
|
|
219
|
+
const svc = makeService({
|
|
220
|
+
objects: [{ ...baseObject, fields: { ...baseObject.fields, amount: { type: 'datetime' } } }],
|
|
221
|
+
});
|
|
222
|
+
const result = await svc.validateObject('wh_order');
|
|
223
|
+
expect(result.ok).toBe(false);
|
|
224
|
+
expect(result.diffs).toContainEqual(
|
|
225
|
+
expect.objectContaining({ kind: 'type_mismatch', column: 'amount', severity: 'error' }),
|
|
226
|
+
);
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
it('reports a missing_column error', async () => {
|
|
230
|
+
const svc = makeService({
|
|
231
|
+
objects: [{ ...baseObject, fields: { ...baseObject.fields, nonexistent: { type: 'text' } } }],
|
|
232
|
+
});
|
|
233
|
+
const result = await svc.validateObject('wh_order');
|
|
234
|
+
expect(result.ok).toBe(false);
|
|
235
|
+
expect(result.diffs).toContainEqual(
|
|
236
|
+
expect.objectContaining({ kind: 'missing_column', column: 'nonexistent' }),
|
|
237
|
+
);
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
it('reports missing_table when the remote table is absent', async () => {
|
|
241
|
+
const svc = makeService({
|
|
242
|
+
objects: [{ ...baseObject, external: { remoteName: 'ghost' } }],
|
|
243
|
+
});
|
|
244
|
+
const result = await svc.validateObject('wh_order');
|
|
245
|
+
expect(result.ok).toBe(false);
|
|
246
|
+
expect(result.diffs[0].kind).toBe('missing_table');
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
it('treats a managed datasource object as nothing-to-validate', async () => {
|
|
250
|
+
const svc = makeService({
|
|
251
|
+
datasource: { name: 'warehouse', schemaMode: 'managed' },
|
|
252
|
+
objects: [baseObject],
|
|
253
|
+
});
|
|
254
|
+
const result = await svc.validateObject('wh_order');
|
|
255
|
+
expect(result.ok).toBe(true);
|
|
256
|
+
expect(result.diffs).toHaveLength(0);
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
it('honours columnMap and ignoreColumns', async () => {
|
|
260
|
+
const svc = makeService({
|
|
261
|
+
objects: [
|
|
262
|
+
{
|
|
263
|
+
name: 'wh_order',
|
|
264
|
+
datasource: 'warehouse',
|
|
265
|
+
external: {
|
|
266
|
+
remoteName: 'fact_orders',
|
|
267
|
+
columnMap: { customer_id: 'cust' },
|
|
268
|
+
ignoreColumns: ['metadata'],
|
|
269
|
+
},
|
|
270
|
+
fields: { order_id: { type: 'text' }, cust: { type: 'text' } },
|
|
271
|
+
},
|
|
272
|
+
],
|
|
273
|
+
});
|
|
274
|
+
const result = await svc.validateObject('wh_order');
|
|
275
|
+
expect(result.ok).toBe(true);
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
it('flags a lossy mapping as a warning without failing', async () => {
|
|
279
|
+
const svc = makeService({
|
|
280
|
+
objects: [
|
|
281
|
+
{
|
|
282
|
+
name: 'wh_order',
|
|
283
|
+
datasource: 'warehouse',
|
|
284
|
+
external: { remoteName: 'fact_orders' },
|
|
285
|
+
fields: { order_id: { type: 'text' }, metadata: { type: 'text' } },
|
|
286
|
+
},
|
|
287
|
+
],
|
|
288
|
+
});
|
|
289
|
+
const result = await svc.validateObject('wh_order');
|
|
290
|
+
expect(result.ok).toBe(true);
|
|
291
|
+
expect(result.diffs).toContainEqual(
|
|
292
|
+
expect.objectContaining({ kind: 'type_mismatch', column: 'metadata', severity: 'warning' }),
|
|
293
|
+
);
|
|
294
|
+
});
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
describe('validateAll', () => {
|
|
298
|
+
it('aggregates results across federated objects only', async () => {
|
|
299
|
+
const svc = makeService({
|
|
300
|
+
objects: [
|
|
301
|
+
{ name: 'local_thing', datasource: 'default', fields: { id: { type: 'text' } } },
|
|
302
|
+
{
|
|
303
|
+
name: 'wh_order',
|
|
304
|
+
datasource: 'warehouse',
|
|
305
|
+
external: { remoteName: 'fact_orders' },
|
|
306
|
+
fields: { order_id: { type: 'text' }, amount: { type: 'number' } },
|
|
307
|
+
},
|
|
308
|
+
],
|
|
309
|
+
});
|
|
310
|
+
const report = await svc.validateAll();
|
|
311
|
+
expect(report.ok).toBe(true);
|
|
312
|
+
expect(report.results.map((r) => r.object)).toEqual(['wh_order']);
|
|
313
|
+
});
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
describe('refreshCatalog', () => {
|
|
317
|
+
it('produces a snapshot with suggested field types', async () => {
|
|
318
|
+
const svc = makeService();
|
|
319
|
+
const catalog = await svc.refreshCatalog('warehouse');
|
|
320
|
+
expect(catalog.datasource).toBe('warehouse');
|
|
321
|
+
expect(catalog.name).toBe('warehouse_catalog');
|
|
322
|
+
const orders = catalog.tables.find((t) => t.remoteName === 'fact_orders')!;
|
|
323
|
+
expect(orders.columns.find((c) => c.name === 'amount')?.suggestedFieldType).toBe('number');
|
|
324
|
+
// Canonicalised through the Zod schema: primaryKey default applied.
|
|
325
|
+
expect(orders.columns.find((c) => c.name === 'order_id')?.primaryKey).toBe(true);
|
|
326
|
+
expect(orders.columns.find((c) => c.name === 'amount')?.primaryKey).toBe(false);
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
it('persists the snapshot as an external_catalog record when a store is wired', async () => {
|
|
330
|
+
const persisted: unknown[] = [];
|
|
331
|
+
const svc = new ExternalDatasourceService({
|
|
332
|
+
introspect: async () => warehouseSchema(),
|
|
333
|
+
getDatasource: async () => ({ name: 'warehouse', schemaMode: 'external' }),
|
|
334
|
+
getObject: async () => undefined,
|
|
335
|
+
listObjects: async () => [],
|
|
336
|
+
persistCatalog: async (c) => {
|
|
337
|
+
persisted.push(c);
|
|
338
|
+
},
|
|
339
|
+
});
|
|
340
|
+
const catalog = await svc.refreshCatalog('warehouse');
|
|
341
|
+
expect(persisted).toHaveLength(1);
|
|
342
|
+
expect(persisted[0]).toBe(catalog);
|
|
343
|
+
expect((persisted[0] as { name: string }).name).toBe('warehouse_catalog');
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
it('still returns the snapshot when persistence throws (best-effort cache)', async () => {
|
|
347
|
+
const svc = new ExternalDatasourceService({
|
|
348
|
+
introspect: async () => warehouseSchema(),
|
|
349
|
+
getDatasource: async () => ({ name: 'warehouse', schemaMode: 'external' }),
|
|
350
|
+
getObject: async () => undefined,
|
|
351
|
+
listObjects: async () => [],
|
|
352
|
+
persistCatalog: async () => {
|
|
353
|
+
throw new Error('metadata store is read-only');
|
|
354
|
+
},
|
|
355
|
+
logger: { warn: () => {} },
|
|
356
|
+
});
|
|
357
|
+
const catalog = await svc.refreshCatalog('warehouse');
|
|
358
|
+
expect(catalog.name).toBe('warehouse_catalog');
|
|
359
|
+
});
|
|
360
|
+
});
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
|
|
2
|
+
|
|
3
|
+
import type { PluginContext } from '@objectstack/core';
|
|
4
|
+
import type { IHttpServer } from '@objectstack/spec/contracts';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Datasource lifecycle REST routes (ADR-0015 Addendum §3.5).
|
|
8
|
+
*
|
|
9
|
+
* Mounted under `/api/v1/datasources` and served by the `datasource-admin`
|
|
10
|
+
* service. Every route degrades gracefully
|
|
11
|
+
* (`503 datasource_admin_unavailable`) when the service is not wired in, and
|
|
12
|
+
* lifecycle/validation failures surface as `400` with the service's message.
|
|
13
|
+
*
|
|
14
|
+
* GET /datasources → listDatasources (provenance + health)
|
|
15
|
+
* POST /datasources/test → testConnection (no persistence)
|
|
16
|
+
* POST /datasources → createDatasource (origin: 'runtime')
|
|
17
|
+
* PATCH /datasources/:name → updateDatasource (runtime only)
|
|
18
|
+
* DELETE /datasources/:name → removeDatasource (runtime only)
|
|
19
|
+
*
|
|
20
|
+
* Request bodies carry the connection draft inline with an optional cleartext
|
|
21
|
+
* `secret` field; the route splits `secret` out so it never reaches the draft
|
|
22
|
+
* the service persists.
|
|
23
|
+
*/
|
|
24
|
+
export function registerDatasourceAdminRoutes(
|
|
25
|
+
server: IHttpServer,
|
|
26
|
+
ctx: PluginContext,
|
|
27
|
+
basePath = '/api/v1',
|
|
28
|
+
): void {
|
|
29
|
+
const root = `${basePath}/datasources`;
|
|
30
|
+
|
|
31
|
+
const adminService = (): any => {
|
|
32
|
+
try {
|
|
33
|
+
return ctx.getService<any>('datasource-admin');
|
|
34
|
+
} catch {
|
|
35
|
+
return undefined;
|
|
36
|
+
}
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
const unavailable = (res: any) =>
|
|
40
|
+
res.status(503).json({ error: 'datasource_admin_unavailable' });
|
|
41
|
+
|
|
42
|
+
const badRequest = (res: any, err: unknown) =>
|
|
43
|
+
res.status(400).json({ error: 'datasource_admin_error', message: err instanceof Error ? err.message : String(err) });
|
|
44
|
+
|
|
45
|
+
/** Split an inline `{ secret, ...draft }` body into (draft, secret). */
|
|
46
|
+
const splitSecret = (body: any): { draft: any; secret: any } => {
|
|
47
|
+
const { secret, ...draft } = (body as Record<string, unknown>) ?? {};
|
|
48
|
+
// Accept either a bare string or a `{ value, namespace?, key? }` object.
|
|
49
|
+
const normalised =
|
|
50
|
+
secret == null
|
|
51
|
+
? undefined
|
|
52
|
+
: typeof secret === 'string'
|
|
53
|
+
? { value: secret }
|
|
54
|
+
: secret;
|
|
55
|
+
return { draft, secret: normalised };
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
// List all datasources with provenance + health.
|
|
59
|
+
server.get(root, async (_req: any, res: any) => {
|
|
60
|
+
const svc = adminService();
|
|
61
|
+
if (!svc?.listDatasources) return unavailable(res);
|
|
62
|
+
const datasources = await svc.listDatasources();
|
|
63
|
+
res.json({ datasources });
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
// Probe a connection without persisting anything. Registered before the
|
|
67
|
+
// `:name` routes so the literal `test` segment is never captured as a name.
|
|
68
|
+
server.post(`${root}/test`, async (req: any, res: any) => {
|
|
69
|
+
const svc = adminService();
|
|
70
|
+
if (!svc?.testConnection) return unavailable(res);
|
|
71
|
+
const { draft, secret } = splitSecret(req.body);
|
|
72
|
+
try {
|
|
73
|
+
const result = await svc.testConnection(draft, secret);
|
|
74
|
+
res.json({ result });
|
|
75
|
+
} catch (err) {
|
|
76
|
+
badRequest(res, err);
|
|
77
|
+
}
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
// Create a runtime datasource.
|
|
81
|
+
server.post(root, async (req: any, res: any) => {
|
|
82
|
+
const svc = adminService();
|
|
83
|
+
if (!svc?.createDatasource) return unavailable(res);
|
|
84
|
+
const { draft, secret } = splitSecret(req.body);
|
|
85
|
+
try {
|
|
86
|
+
const datasource = await svc.createDatasource(draft, secret);
|
|
87
|
+
res.status(201).json({ datasource });
|
|
88
|
+
} catch (err) {
|
|
89
|
+
badRequest(res, err);
|
|
90
|
+
}
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
// Patch a runtime datasource.
|
|
94
|
+
server.patch(`${root}/:name`, async (req: any, res: any) => {
|
|
95
|
+
const svc = adminService();
|
|
96
|
+
if (!svc?.updateDatasource) return unavailable(res);
|
|
97
|
+
const { draft, secret } = splitSecret(req.body);
|
|
98
|
+
try {
|
|
99
|
+
const datasource = await svc.updateDatasource(req.params.name, draft, secret);
|
|
100
|
+
res.json({ datasource });
|
|
101
|
+
} catch (err) {
|
|
102
|
+
badRequest(res, err);
|
|
103
|
+
}
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
// Remove a runtime datasource.
|
|
107
|
+
server.delete(`${root}/:name`, async (req: any, res: any) => {
|
|
108
|
+
const svc = adminService();
|
|
109
|
+
if (!svc?.removeDatasource) return unavailable(res);
|
|
110
|
+
try {
|
|
111
|
+
await svc.removeDatasource(req.params.name);
|
|
112
|
+
res.status(204).end();
|
|
113
|
+
} catch (err) {
|
|
114
|
+
badRequest(res, err);
|
|
115
|
+
}
|
|
116
|
+
});
|
|
117
|
+
}
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* IDatasourceAdminService — runtime datasource lifecycle contract
|
|
5
|
+
* (ADR-0015 Addendum: Runtime UI-Created Datasources).
|
|
6
|
+
*
|
|
7
|
+
* Where {@link IExternalDatasourceService} covers *federation* (introspection,
|
|
8
|
+
* object drafting, schema validation) of datasources that already exist, this
|
|
9
|
+
* service covers their *lifecycle*: testing a connection before saving,
|
|
10
|
+
* creating / updating / removing a **runtime** datasource (`origin: 'runtime'`),
|
|
11
|
+
* and listing all datasources with their provenance + health.
|
|
12
|
+
*
|
|
13
|
+
* Code-defined datasources (`origin: 'code'`, authored as `*.datasource.ts`)
|
|
14
|
+
* are read-only here: `updateDatasource` / `removeDatasource` reject them, and
|
|
15
|
+
* a runtime datasource never shadows a code one of the same name (code wins).
|
|
16
|
+
*
|
|
17
|
+
* Credentials are never persisted in cleartext: callers pass a {@link SecretInput}
|
|
18
|
+
* separately from the connection `config`; the implementation encrypts it into
|
|
19
|
+
* the secret store (`sys_secret`) and persists only an opaque `credentialsRef`.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
/** Provenance of a datasource definition. */
|
|
23
|
+
export type DatasourceOrigin = 'code' | 'runtime';
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* A cleartext secret (password or full connection string) supplied for a
|
|
27
|
+
* create/update/test call. Never persisted as-is — encrypted into the secret
|
|
28
|
+
* store, with only the returned handle (`credentialsRef`) kept on the record.
|
|
29
|
+
*/
|
|
30
|
+
export interface SecretInput {
|
|
31
|
+
/** The cleartext value to encrypt (e.g. password or connection string). */
|
|
32
|
+
value: string;
|
|
33
|
+
/** Optional secret-store namespace (defaults to `'datasource'`). */
|
|
34
|
+
namespace?: string;
|
|
35
|
+
/** Optional secret-store key (defaults to the datasource name). */
|
|
36
|
+
key?: string;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* The connection definition a caller supplies to test/create/update. A subset
|
|
41
|
+
* of `Datasource` — server-managed fields (`origin`) are never accepted from
|
|
42
|
+
* the client.
|
|
43
|
+
*/
|
|
44
|
+
export interface DatasourceDraft {
|
|
45
|
+
name: string;
|
|
46
|
+
label?: string;
|
|
47
|
+
driver: string;
|
|
48
|
+
schemaMode?: 'managed' | 'external' | 'validate-only';
|
|
49
|
+
/** Driver-specific connection config (host, port, database, …). No secrets. */
|
|
50
|
+
config?: Record<string, unknown>;
|
|
51
|
+
/** External federation settings (required when schemaMode != 'managed'). */
|
|
52
|
+
external?: Record<string, unknown>;
|
|
53
|
+
pool?: Record<string, unknown>;
|
|
54
|
+
active?: boolean;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Result of probing a connection (live driver connect + cheap round-trip). */
|
|
58
|
+
export interface TestConnectionResult {
|
|
59
|
+
ok: boolean;
|
|
60
|
+
/** Round-trip latency of the probe, when the connection succeeded. */
|
|
61
|
+
latencyMs?: number;
|
|
62
|
+
/** Driver-reported server version, when available. */
|
|
63
|
+
serverVersion?: string;
|
|
64
|
+
/** Human-readable failure reason, when `ok === false`. */
|
|
65
|
+
error?: string;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** A datasource with its provenance and current health (no secrets). */
|
|
69
|
+
export interface DatasourceSummary {
|
|
70
|
+
name: string;
|
|
71
|
+
label?: string;
|
|
72
|
+
driver: string;
|
|
73
|
+
schemaMode: 'managed' | 'external' | 'validate-only';
|
|
74
|
+
origin: DatasourceOrigin;
|
|
75
|
+
active: boolean;
|
|
76
|
+
/** Validation health: `unvalidated` until the first validate/test runs. */
|
|
77
|
+
status: 'ok' | 'error' | 'unvalidated';
|
|
78
|
+
/** Package id that defines a code-origin datasource (omitted for runtime). */
|
|
79
|
+
definedIn?: string;
|
|
80
|
+
/** True when a runtime row is shadowed by a code definition of the same name. */
|
|
81
|
+
conflictsWithCode?: boolean;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Runtime datasource lifecycle service. Registered into the kernel as the
|
|
86
|
+
* `'datasource-admin'` service; consumed by the REST layer and Studio wizard.
|
|
87
|
+
*/
|
|
88
|
+
export interface IDatasourceAdminService {
|
|
89
|
+
/** List every datasource (code + runtime) with provenance and health. */
|
|
90
|
+
listDatasources(): Promise<DatasourceSummary[]>;
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Probe a connection without persisting anything. Accepts an unsaved draft
|
|
94
|
+
* so the wizard can validate credentials before "Save".
|
|
95
|
+
*/
|
|
96
|
+
testConnection(input: DatasourceDraft, secret?: SecretInput): Promise<TestConnectionResult>;
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Persist a new runtime datasource (`origin: 'runtime'`, environment-scoped).
|
|
100
|
+
* Rejects when a code-defined datasource of the same name exists.
|
|
101
|
+
*/
|
|
102
|
+
createDatasource(input: DatasourceDraft, secret?: SecretInput): Promise<DatasourceSummary>;
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Patch an existing runtime datasource. Rejects for code-defined datasources.
|
|
106
|
+
* Passing `secret` re-wraps the stored credential.
|
|
107
|
+
*/
|
|
108
|
+
updateDatasource(
|
|
109
|
+
name: string,
|
|
110
|
+
patch: Partial<DatasourceDraft>,
|
|
111
|
+
secret?: SecretInput,
|
|
112
|
+
): Promise<DatasourceSummary>;
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Remove a runtime datasource. Rejects for code-defined ones and while
|
|
116
|
+
* objects are still bound to it.
|
|
117
|
+
*/
|
|
118
|
+
removeDatasource(name: string): Promise<void>;
|
|
119
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* IDatasourceDriverFactory — host-provided capability that builds a live driver
|
|
5
|
+
* from a connection spec (ADR-0015 Addendum §3.5).
|
|
6
|
+
*
|
|
7
|
+
* The framework deliberately ships no universal "driver-by-id" registry —
|
|
8
|
+
* concrete drivers (`SqlDriver`, `MongoDBDriver`, `TursoDriver`, …) are
|
|
9
|
+
* constructed by the host stack and registered as live connections. The
|
|
10
|
+
* runtime-datasource lifecycle (`IDatasourceAdminService`) needs to build a
|
|
11
|
+
* driver from an *unsaved* draft — to probe a connection before "Save", and to
|
|
12
|
+
* hot-register a pool after create/update — so the host exposes this factory
|
|
13
|
+
* as the `'datasource-driver-factory'` service.
|
|
14
|
+
*
|
|
15
|
+
* When no factory is registered, or none `supports()` a given driver id, the
|
|
16
|
+
* admin service degrades gracefully: `testConnection` returns
|
|
17
|
+
* `{ ok: false, error }` and create/update skip hot pool registration (the
|
|
18
|
+
* driver is picked up on the next boot instead).
|
|
19
|
+
*
|
|
20
|
+
* Security: the cleartext `secret` on {@link DatasourceConnectionSpec} is used
|
|
21
|
+
* only to open the live connection. Factories MUST NOT persist or log it.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
/** Everything needed to construct one live driver connection. */
|
|
25
|
+
export interface DatasourceConnectionSpec {
|
|
26
|
+
/** Datasource name, when building for an existing/named datasource. */
|
|
27
|
+
name?: string;
|
|
28
|
+
/** Driver id (e.g. `'postgres'`, `'sqlite'`, `'mongodb'`). */
|
|
29
|
+
driver: string;
|
|
30
|
+
/** Driver-specific connection config (host, port, database, …). No secrets. */
|
|
31
|
+
config: Record<string, unknown>;
|
|
32
|
+
/** Cleartext secret (password / DSN) injected for this connection only. */
|
|
33
|
+
secret?: string;
|
|
34
|
+
/** External federation settings (timeouts, allowed schemas, …). */
|
|
35
|
+
external?: Record<string, unknown>;
|
|
36
|
+
/** Connection pool settings. */
|
|
37
|
+
pool?: Record<string, unknown>;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* A live (or lazily-connecting) driver handle. Intentionally structural and
|
|
42
|
+
* fully optional so any concrete driver satisfies it — the admin service uses
|
|
43
|
+
* whatever capabilities are present and skips the rest.
|
|
44
|
+
*/
|
|
45
|
+
export interface DatasourceDriverHandle {
|
|
46
|
+
/** Open the connection / pool. */
|
|
47
|
+
connect?(): Promise<void>;
|
|
48
|
+
/** Close the connection / pool. */
|
|
49
|
+
disconnect?(): Promise<void>;
|
|
50
|
+
/** Cheap liveness round-trip (preferred for probes). */
|
|
51
|
+
ping?(): Promise<unknown>;
|
|
52
|
+
/** Introspect the live schema (fallback probe when `ping` is absent). */
|
|
53
|
+
introspectSchema?(): Promise<unknown>;
|
|
54
|
+
/** Liveness check on the underlying engine driver (probe fallback). */
|
|
55
|
+
checkHealth?(): Promise<boolean>;
|
|
56
|
+
/** Driver-reported server version, when available. */
|
|
57
|
+
serverVersion?(): Promise<string | undefined>;
|
|
58
|
+
/**
|
|
59
|
+
* Escape hatch: the concrete engine driver to hand to
|
|
60
|
+
* `IDataEngine.registerDriver()` when hot-registering a pool. When present
|
|
61
|
+
* the admin service registers *this* (whose `.name` must equal the
|
|
62
|
+
* datasource name for routing) instead of the handle itself; absent ⇒ the
|
|
63
|
+
* handle is assumed to be the driver. Never serialized.
|
|
64
|
+
*/
|
|
65
|
+
driver?: unknown;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Host-provided factory that builds drivers from connection specs. */
|
|
69
|
+
export interface IDatasourceDriverFactory {
|
|
70
|
+
/** True if this factory can build a driver for the given driver id. */
|
|
71
|
+
supports(driverId: string): boolean;
|
|
72
|
+
/**
|
|
73
|
+
* Build a driver instance for the spec. Implementations may return a
|
|
74
|
+
* not-yet-connected handle; the caller calls `connect()` when needed.
|
|
75
|
+
*/
|
|
76
|
+
create(spec: DatasourceConnectionSpec): Promise<DatasourceDriverHandle> | DatasourceDriverHandle;
|
|
77
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
|
|
2
|
+
|
|
3
|
+
// Datasource lifecycle + driver-factory contracts (ADR-0015 Addendum).
|
|
4
|
+
// Moved out of `@objectstack/spec` so the open framework no longer ships them.
|
|
5
|
+
export type {
|
|
6
|
+
DatasourceOrigin,
|
|
7
|
+
SecretInput,
|
|
8
|
+
DatasourceDraft,
|
|
9
|
+
TestConnectionResult,
|
|
10
|
+
DatasourceSummary,
|
|
11
|
+
IDatasourceAdminService,
|
|
12
|
+
} from './datasource-admin-service.js';
|
|
13
|
+
|
|
14
|
+
export type {
|
|
15
|
+
DatasourceConnectionSpec,
|
|
16
|
+
DatasourceDriverHandle,
|
|
17
|
+
IDatasourceDriverFactory,
|
|
18
|
+
} from './datasource-driver-factory.js';
|