@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.
Files changed (37) hide show
  1. package/.turbo/turbo-build.log +28 -0
  2. package/CHANGELOG.md +94 -0
  3. package/LICENSE +202 -0
  4. package/LICENSE.apache +202 -0
  5. package/README.md +50 -0
  6. package/dist/contracts/index.cjs +1 -0
  7. package/dist/contracts/index.cjs.map +1 -0
  8. package/dist/contracts/index.d.cts +178 -0
  9. package/dist/contracts/index.d.ts +178 -0
  10. package/dist/contracts/index.js +1 -0
  11. package/dist/contracts/index.js.map +1 -0
  12. package/dist/index.cjs +995 -0
  13. package/dist/index.cjs.map +1 -0
  14. package/dist/index.d.cts +414 -0
  15. package/dist/index.d.ts +414 -0
  16. package/dist/index.js +995 -0
  17. package/dist/index.js.map +1 -0
  18. package/package.json +61 -0
  19. package/src/__tests__/admin-routes.test.ts +106 -0
  20. package/src/__tests__/datasource-admin-plugin.test.ts +231 -0
  21. package/src/__tests__/datasource-admin-service.test.ts +288 -0
  22. package/src/__tests__/datasource-secret-binder.test.ts +101 -0
  23. package/src/__tests__/external-datasource-service.test.ts +360 -0
  24. package/src/admin-routes.ts +117 -0
  25. package/src/contracts/datasource-admin-service.ts +119 -0
  26. package/src/contracts/datasource-driver-factory.ts +77 -0
  27. package/src/contracts/index.ts +18 -0
  28. package/src/datasource-admin-plugin.ts +362 -0
  29. package/src/datasource-admin-service.ts +297 -0
  30. package/src/datasource-secret-binder.ts +144 -0
  31. package/src/default-datasource-driver-factory.ts +185 -0
  32. package/src/external-datasource-service.ts +456 -0
  33. package/src/index.ts +73 -0
  34. package/src/logger.ts +11 -0
  35. package/src/plugin.ts +119 -0
  36. package/tsconfig.json +17 -0
  37. 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';