@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
package/dist/index.js
ADDED
|
@@ -0,0 +1,995 @@
|
|
|
1
|
+
// src/external-datasource-service.ts
|
|
2
|
+
import {
|
|
3
|
+
suggestFieldType,
|
|
4
|
+
isCompatible,
|
|
5
|
+
ExternalCatalogSchema
|
|
6
|
+
} from "@objectstack/spec/data";
|
|
7
|
+
var BUILTIN_COLUMNS = /* @__PURE__ */ new Set(["id", "created_at", "updated_at"]);
|
|
8
|
+
function parseQualified(raw) {
|
|
9
|
+
const idx = raw.indexOf(".");
|
|
10
|
+
if (idx === -1) return { name: raw };
|
|
11
|
+
return { schema: raw.slice(0, idx), name: raw.slice(idx + 1) };
|
|
12
|
+
}
|
|
13
|
+
function toObjectName(remoteName) {
|
|
14
|
+
const { name } = parseQualified(remoteName);
|
|
15
|
+
return name.replace(/[^a-zA-Z0-9_]/g, "_").replace(/^[^a-z_]/, (c) => `_${c.toLowerCase()}`).toLowerCase();
|
|
16
|
+
}
|
|
17
|
+
function toLabel(name) {
|
|
18
|
+
return name.split("_").filter(Boolean).map((w) => w.charAt(0).toUpperCase() + w.slice(1)).join(" ");
|
|
19
|
+
}
|
|
20
|
+
var ExternalDatasourceService = class {
|
|
21
|
+
constructor(config) {
|
|
22
|
+
this.config = config;
|
|
23
|
+
}
|
|
24
|
+
get logger() {
|
|
25
|
+
return this.config.logger;
|
|
26
|
+
}
|
|
27
|
+
findTable(schema, remoteName) {
|
|
28
|
+
const want = parseQualified(remoteName).name;
|
|
29
|
+
for (const table of Object.values(schema.tables)) {
|
|
30
|
+
if (table.name === remoteName) return table;
|
|
31
|
+
if (parseQualified(table.name).name === want) return table;
|
|
32
|
+
}
|
|
33
|
+
return void 0;
|
|
34
|
+
}
|
|
35
|
+
async listRemoteTables(datasource, opts) {
|
|
36
|
+
const [schema, ds] = await Promise.all([
|
|
37
|
+
this.config.introspect(datasource),
|
|
38
|
+
this.config.getDatasource(datasource)
|
|
39
|
+
]);
|
|
40
|
+
const allowed = ds?.external?.allowedSchemas;
|
|
41
|
+
const tables = [];
|
|
42
|
+
for (const table of Object.values(schema.tables)) {
|
|
43
|
+
const { schema: tableSchema, name } = parseQualified(table.name);
|
|
44
|
+
if (opts?.schema && tableSchema && tableSchema !== opts.schema) continue;
|
|
45
|
+
if (allowed && tableSchema && !allowed.includes(tableSchema)) continue;
|
|
46
|
+
tables.push({ schema: tableSchema, name, columnCount: table.columns.length });
|
|
47
|
+
}
|
|
48
|
+
return tables;
|
|
49
|
+
}
|
|
50
|
+
async generateObjectDraft(datasource, remoteName, opts = {}) {
|
|
51
|
+
const schema = await this.config.introspect(datasource);
|
|
52
|
+
const table = this.findTable(schema, remoteName);
|
|
53
|
+
if (!table) {
|
|
54
|
+
throw new Error(
|
|
55
|
+
`Remote table '${remoteName}' not found on datasource '${datasource}'.`
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
const dialect = schema.dialect;
|
|
59
|
+
const matched = parseQualified(table.name);
|
|
60
|
+
const remoteSchema = opts.remoteSchema ?? matched.schema;
|
|
61
|
+
const resolvedRemoteName = matched.name;
|
|
62
|
+
const include = opts.includeColumns ? new Set(opts.includeColumns) : void 0;
|
|
63
|
+
const exclude = opts.excludeColumns ? new Set(opts.excludeColumns) : /* @__PURE__ */ new Set();
|
|
64
|
+
const pkOverride = opts.primaryKey ? new Set(opts.primaryKey) : void 0;
|
|
65
|
+
const fields = {};
|
|
66
|
+
const review = [];
|
|
67
|
+
for (const col of table.columns) {
|
|
68
|
+
if (include && !include.has(col.name)) continue;
|
|
69
|
+
if (exclude.has(col.name)) continue;
|
|
70
|
+
const fieldName = opts.rename?.[col.name] ?? col.name;
|
|
71
|
+
const suggested = suggestFieldType(col.type, dialect);
|
|
72
|
+
const fieldType = suggested ?? "text";
|
|
73
|
+
if (!suggested) {
|
|
74
|
+
review.push({
|
|
75
|
+
column: col.name,
|
|
76
|
+
remoteType: col.type,
|
|
77
|
+
note: `unrecognised remote type \u2014 defaulted to 'text', verify`
|
|
78
|
+
});
|
|
79
|
+
} else if (isCompatible(col.type, fieldType, dialect) === "lossy") {
|
|
80
|
+
review.push({
|
|
81
|
+
column: col.name,
|
|
82
|
+
remoteType: col.type,
|
|
83
|
+
note: `mapped lossy to '${fieldType}'`
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
const isPk = pkOverride ? pkOverride.has(col.name) : col.primaryKey;
|
|
87
|
+
fields[fieldName] = isPk ? { type: fieldType, primaryKey: true } : { type: fieldType };
|
|
88
|
+
}
|
|
89
|
+
const name = toObjectName(resolvedRemoteName);
|
|
90
|
+
const definition = {
|
|
91
|
+
name,
|
|
92
|
+
label: toLabel(name),
|
|
93
|
+
datasource,
|
|
94
|
+
external: {
|
|
95
|
+
...remoteSchema ? { remoteSchema } : {},
|
|
96
|
+
remoteName: resolvedRemoteName
|
|
97
|
+
},
|
|
98
|
+
fields
|
|
99
|
+
};
|
|
100
|
+
return {
|
|
101
|
+
name,
|
|
102
|
+
datasource,
|
|
103
|
+
definition,
|
|
104
|
+
source: renderObjectSource(definition, fields, review),
|
|
105
|
+
review
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
async importObject(datasource, remoteName, opts = {}) {
|
|
109
|
+
if (!this.config.persistObject) {
|
|
110
|
+
throw new Error(
|
|
111
|
+
`importObject requires a writable metadata store, but none is wired (datasource '${datasource}'). This deployment may be GitOps-only \u2014 use 'os datasource introspect' and commit the generated *.object.ts instead.`
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
const draft = await this.generateObjectDraft(datasource, remoteName, opts);
|
|
115
|
+
const name = opts.name ?? draft.name;
|
|
116
|
+
const external = {
|
|
117
|
+
...draft.definition.external,
|
|
118
|
+
...opts.writable ? { writable: true } : {}
|
|
119
|
+
};
|
|
120
|
+
const definition = {
|
|
121
|
+
...draft.definition,
|
|
122
|
+
name,
|
|
123
|
+
label: toLabel(name),
|
|
124
|
+
external
|
|
125
|
+
};
|
|
126
|
+
await this.config.persistObject(name, definition);
|
|
127
|
+
this.logger?.info?.(`importObject: persisted '${name}' from ${datasource}.${remoteName}`, {
|
|
128
|
+
writable: opts.writable === true,
|
|
129
|
+
review: draft.review.length
|
|
130
|
+
});
|
|
131
|
+
return { name, definition, review: draft.review };
|
|
132
|
+
}
|
|
133
|
+
async refreshCatalog(datasource) {
|
|
134
|
+
const schema = await this.config.introspect(datasource);
|
|
135
|
+
const catalog = ExternalCatalogSchema.parse({
|
|
136
|
+
name: `${datasource}_catalog`,
|
|
137
|
+
datasource,
|
|
138
|
+
snapshotAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
139
|
+
dialect: schema.dialect,
|
|
140
|
+
tables: Object.values(schema.tables).map((t) => {
|
|
141
|
+
const { schema: s, name } = parseQualified(t.name);
|
|
142
|
+
return {
|
|
143
|
+
remoteSchema: s,
|
|
144
|
+
remoteName: name,
|
|
145
|
+
columns: t.columns.map((c) => ({
|
|
146
|
+
name: c.name,
|
|
147
|
+
sqlType: c.type,
|
|
148
|
+
nullable: c.nullable,
|
|
149
|
+
primaryKey: c.primaryKey,
|
|
150
|
+
suggestedFieldType: suggestFieldType(c.type, schema.dialect)
|
|
151
|
+
}))
|
|
152
|
+
};
|
|
153
|
+
})
|
|
154
|
+
});
|
|
155
|
+
if (this.config.persistCatalog) {
|
|
156
|
+
try {
|
|
157
|
+
await this.config.persistCatalog(catalog);
|
|
158
|
+
} catch (err) {
|
|
159
|
+
this.logger?.warn?.(`refreshCatalog: failed to persist '${catalog.name}'`, err);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
return catalog;
|
|
163
|
+
}
|
|
164
|
+
async validateObject(objectName) {
|
|
165
|
+
const obj = await this.config.getObject(objectName);
|
|
166
|
+
if (!obj) {
|
|
167
|
+
throw new Error(`Object '${objectName}' not found.`);
|
|
168
|
+
}
|
|
169
|
+
const datasource = obj.datasource ?? "default";
|
|
170
|
+
const ds = await this.config.getDatasource(datasource);
|
|
171
|
+
if (!ds || !ds.schemaMode || ds.schemaMode === "managed") {
|
|
172
|
+
return { ok: true, datasource, object: objectName, diffs: [] };
|
|
173
|
+
}
|
|
174
|
+
const schema = await this.config.introspect(datasource);
|
|
175
|
+
const dialect = schema.dialect;
|
|
176
|
+
const remoteName = obj.external?.remoteName ?? obj.name;
|
|
177
|
+
const table = this.findTable(schema, remoteName);
|
|
178
|
+
const diffs = [];
|
|
179
|
+
if (!table) {
|
|
180
|
+
diffs.push({
|
|
181
|
+
kind: "missing_table",
|
|
182
|
+
remoteSchema: obj.external?.remoteSchema,
|
|
183
|
+
remoteName,
|
|
184
|
+
severity: "error"
|
|
185
|
+
});
|
|
186
|
+
return { ok: false, datasource, object: objectName, diffs };
|
|
187
|
+
}
|
|
188
|
+
const columnsByName = new Map(table.columns.map((c) => [c.name, c]));
|
|
189
|
+
const ignore = new Set(obj.external?.ignoreColumns ?? []);
|
|
190
|
+
const fieldToRemote = /* @__PURE__ */ new Map();
|
|
191
|
+
for (const [remoteCol, fieldName] of Object.entries(obj.external?.columnMap ?? {})) {
|
|
192
|
+
fieldToRemote.set(fieldName, remoteCol);
|
|
193
|
+
}
|
|
194
|
+
for (const [fieldName, field] of Object.entries(obj.fields ?? {})) {
|
|
195
|
+
if (BUILTIN_COLUMNS.has(fieldName)) continue;
|
|
196
|
+
const remoteCol = fieldToRemote.get(fieldName) ?? fieldName;
|
|
197
|
+
if (ignore.has(remoteCol)) continue;
|
|
198
|
+
const col = columnsByName.get(remoteCol);
|
|
199
|
+
if (!col) {
|
|
200
|
+
diffs.push({
|
|
201
|
+
kind: "missing_column",
|
|
202
|
+
remoteName,
|
|
203
|
+
column: remoteCol,
|
|
204
|
+
severity: "error"
|
|
205
|
+
});
|
|
206
|
+
continue;
|
|
207
|
+
}
|
|
208
|
+
const fieldType = field.type ?? "text";
|
|
209
|
+
const compat = isCompatible(col.type, fieldType, dialect);
|
|
210
|
+
if (compat === false) {
|
|
211
|
+
diffs.push({
|
|
212
|
+
kind: "type_mismatch",
|
|
213
|
+
remoteName,
|
|
214
|
+
column: remoteCol,
|
|
215
|
+
expected: fieldType,
|
|
216
|
+
actual: col.type,
|
|
217
|
+
severity: "error"
|
|
218
|
+
});
|
|
219
|
+
} else if (compat === "lossy") {
|
|
220
|
+
diffs.push({
|
|
221
|
+
kind: "type_mismatch",
|
|
222
|
+
remoteName,
|
|
223
|
+
column: remoteCol,
|
|
224
|
+
expected: fieldType,
|
|
225
|
+
actual: col.type,
|
|
226
|
+
severity: "warning"
|
|
227
|
+
});
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
const ok = !diffs.some((d) => d.severity === "error");
|
|
231
|
+
return { ok, datasource, object: objectName, diffs };
|
|
232
|
+
}
|
|
233
|
+
async validateAll() {
|
|
234
|
+
const objects = await this.config.listObjects();
|
|
235
|
+
const federated = objects.filter(
|
|
236
|
+
(o) => o.external !== void 0 || o.datasource && o.datasource !== "default"
|
|
237
|
+
);
|
|
238
|
+
const results = await Promise.all(
|
|
239
|
+
federated.map(
|
|
240
|
+
(o) => this.validateObject(o.name).catch((err) => {
|
|
241
|
+
this.logger?.warn(`validateObject('${o.name}') failed`, err);
|
|
242
|
+
return {
|
|
243
|
+
ok: false,
|
|
244
|
+
datasource: o.datasource ?? "default",
|
|
245
|
+
object: o.name,
|
|
246
|
+
diffs: [
|
|
247
|
+
{
|
|
248
|
+
kind: "missing_table",
|
|
249
|
+
remoteName: o.external?.remoteName ?? o.name,
|
|
250
|
+
actual: err instanceof Error ? err.message : String(err),
|
|
251
|
+
severity: "error"
|
|
252
|
+
}
|
|
253
|
+
]
|
|
254
|
+
};
|
|
255
|
+
})
|
|
256
|
+
)
|
|
257
|
+
);
|
|
258
|
+
const ok = results.every((r) => r.ok);
|
|
259
|
+
return { ok, results };
|
|
260
|
+
}
|
|
261
|
+
};
|
|
262
|
+
function renderObjectSource(definition, fields, review) {
|
|
263
|
+
const reviewByColumn = new Map(review.map((r) => [r.column, r.note]));
|
|
264
|
+
const external = definition.external;
|
|
265
|
+
const fieldLines = Object.entries(fields).map(([fieldName, f]) => {
|
|
266
|
+
const note = reviewByColumn.get(fieldName);
|
|
267
|
+
const pk = f.primaryKey ? ", primaryKey: true" : "";
|
|
268
|
+
const comment = note ? ` // REVIEW: ${note}` : "";
|
|
269
|
+
return ` ${fieldName}: { type: '${f.type}'${pk} },${comment}`;
|
|
270
|
+
});
|
|
271
|
+
const externalLine = external.remoteSchema ? ` external: { remoteSchema: '${external.remoteSchema}', remoteName: '${external.remoteName}' },` : ` external: { remoteName: '${external.remoteName}' },`;
|
|
272
|
+
return [
|
|
273
|
+
`// Generated by \`os datasource introspect\` (ADR-0015). Review before committing.`,
|
|
274
|
+
`import type { ServiceObjectInput } from '@objectstack/spec/data';`,
|
|
275
|
+
``,
|
|
276
|
+
`const ${definition.name}: ServiceObjectInput = {`,
|
|
277
|
+
` name: '${definition.name}',`,
|
|
278
|
+
` label: '${definition.label}',`,
|
|
279
|
+
` datasource: '${definition.datasource}',`,
|
|
280
|
+
externalLine,
|
|
281
|
+
` fields: {`,
|
|
282
|
+
...fieldLines,
|
|
283
|
+
` },`,
|
|
284
|
+
`};`,
|
|
285
|
+
``,
|
|
286
|
+
`export default ${definition.name};`,
|
|
287
|
+
``
|
|
288
|
+
].join("\n");
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
// src/plugin.ts
|
|
292
|
+
var ExternalDatasourceServicePlugin = class {
|
|
293
|
+
constructor(options = {}) {
|
|
294
|
+
this.name = "com.objectstack.service-external-datasource";
|
|
295
|
+
this.version = "1.0.0";
|
|
296
|
+
this.type = "standard";
|
|
297
|
+
this.dependencies = [];
|
|
298
|
+
this.options = options;
|
|
299
|
+
}
|
|
300
|
+
async init(ctx) {
|
|
301
|
+
const engine = safeGetService(ctx, "data");
|
|
302
|
+
const metadata = safeGetService(ctx, "metadata");
|
|
303
|
+
const introspect = this.options.introspect ?? (async (datasource) => {
|
|
304
|
+
if (engine?.introspectDatasource) return engine.introspectDatasource(datasource);
|
|
305
|
+
const driver = engine?.getDatasourceDriver?.(datasource);
|
|
306
|
+
if (driver?.introspectSchema) return driver.introspectSchema();
|
|
307
|
+
throw new Error(
|
|
308
|
+
`Cannot introspect datasource '${datasource}': no driver introspection available.`
|
|
309
|
+
);
|
|
310
|
+
});
|
|
311
|
+
const config = {
|
|
312
|
+
introspect,
|
|
313
|
+
getDatasource: async (n) => await metadata?.get("datasource", n),
|
|
314
|
+
getObject: async (n) => metadata?.getObject ? await metadata.getObject(n) : await metadata?.get("object", n),
|
|
315
|
+
listObjects: async () => (metadata?.listObjects ? await metadata.listObjects() : await metadata?.list?.("object")) ?? [],
|
|
316
|
+
// Persist the refreshed snapshot as an `external_catalog` metadata record
|
|
317
|
+
// so the boot gate + Studio's schema browser can read it without
|
|
318
|
+
// re-introspecting. No-op when the metadata service can't write.
|
|
319
|
+
...metadata?.register ? {
|
|
320
|
+
persistCatalog: async (catalog) => {
|
|
321
|
+
await metadata.register("external_catalog", catalog.name, catalog);
|
|
322
|
+
},
|
|
323
|
+
// Runtime "Import as Object": persist a federated object so it's
|
|
324
|
+
// immediately queryable, no git commit required (ADR-0015 Addendum).
|
|
325
|
+
persistObject: async (name, definition) => {
|
|
326
|
+
await metadata.register("object", name, definition);
|
|
327
|
+
}
|
|
328
|
+
} : {},
|
|
329
|
+
logger: this.options.logger
|
|
330
|
+
};
|
|
331
|
+
this.service = new ExternalDatasourceService(config);
|
|
332
|
+
ctx.registerService("external-datasource", this.service);
|
|
333
|
+
}
|
|
334
|
+
async start(ctx) {
|
|
335
|
+
if (this.service) await ctx.trigger("external-datasource:ready", this.service);
|
|
336
|
+
}
|
|
337
|
+
async destroy() {
|
|
338
|
+
this.service = void 0;
|
|
339
|
+
}
|
|
340
|
+
};
|
|
341
|
+
function safeGetService(ctx, name) {
|
|
342
|
+
try {
|
|
343
|
+
return ctx.getService(name);
|
|
344
|
+
} catch {
|
|
345
|
+
return void 0;
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
// src/datasource-admin-service.ts
|
|
350
|
+
var NAME_RE = /^[a-z_][a-z0-9_]*$/;
|
|
351
|
+
var DatasourceAdminService = class {
|
|
352
|
+
constructor(config) {
|
|
353
|
+
this.config = config;
|
|
354
|
+
}
|
|
355
|
+
get logger() {
|
|
356
|
+
return this.config.logger;
|
|
357
|
+
}
|
|
358
|
+
async listDatasources() {
|
|
359
|
+
const records = await this.config.listDatasourceRecords();
|
|
360
|
+
const byName = /* @__PURE__ */ new Map();
|
|
361
|
+
for (const rec of records) {
|
|
362
|
+
const slot = byName.get(rec.name) ?? {};
|
|
363
|
+
if (rec.origin === "runtime") slot.runtime = rec;
|
|
364
|
+
else slot.code = rec;
|
|
365
|
+
byName.set(rec.name, slot);
|
|
366
|
+
}
|
|
367
|
+
const summaries = [];
|
|
368
|
+
for (const [name, slot] of byName) {
|
|
369
|
+
const effective = slot.code ?? slot.runtime;
|
|
370
|
+
if (!effective) continue;
|
|
371
|
+
summaries.push({
|
|
372
|
+
name,
|
|
373
|
+
label: effective.label,
|
|
374
|
+
driver: effective.driver,
|
|
375
|
+
schemaMode: effective.schemaMode ?? "managed",
|
|
376
|
+
origin: slot.code ? "code" : "runtime",
|
|
377
|
+
active: effective.active ?? true,
|
|
378
|
+
status: "unvalidated",
|
|
379
|
+
...slot.code?.definedIn ? { definedIn: slot.code.definedIn } : {},
|
|
380
|
+
...slot.code && slot.runtime ? { conflictsWithCode: true } : {}
|
|
381
|
+
});
|
|
382
|
+
}
|
|
383
|
+
return summaries;
|
|
384
|
+
}
|
|
385
|
+
async testConnection(input, secret) {
|
|
386
|
+
if (!input?.driver) {
|
|
387
|
+
return { ok: false, error: "A driver is required to test a connection." };
|
|
388
|
+
}
|
|
389
|
+
const queryTimeoutMs = input.external?.queryTimeoutMs;
|
|
390
|
+
try {
|
|
391
|
+
return await this.config.probe({
|
|
392
|
+
driver: input.driver,
|
|
393
|
+
config: input.config ?? {},
|
|
394
|
+
secret: secret?.value,
|
|
395
|
+
external: input.external,
|
|
396
|
+
...typeof queryTimeoutMs === "number" ? { timeoutMs: queryTimeoutMs } : {}
|
|
397
|
+
});
|
|
398
|
+
} catch (err) {
|
|
399
|
+
return { ok: false, error: err instanceof Error ? err.message : String(err) };
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
async createDatasource(input, secret) {
|
|
403
|
+
this.assertValidName(input?.name);
|
|
404
|
+
if (!input.driver) throw new Error("A driver is required to create a datasource.");
|
|
405
|
+
const existing = await this.config.getDatasourceRecord(input.name);
|
|
406
|
+
if (existing) {
|
|
407
|
+
if (existing.origin === "code" || existing.origin === void 0) {
|
|
408
|
+
throw new Error(
|
|
409
|
+
`Cannot create datasource '${input.name}': a code-defined datasource owns this name (read-only).`
|
|
410
|
+
);
|
|
411
|
+
}
|
|
412
|
+
throw new Error(`Datasource '${input.name}' already exists.`);
|
|
413
|
+
}
|
|
414
|
+
const record = {
|
|
415
|
+
...this.toRecord(input),
|
|
416
|
+
origin: "runtime"
|
|
417
|
+
};
|
|
418
|
+
if (secret) {
|
|
419
|
+
const credentialsRef = await this.config.writeSecret(secret, { name: input.name });
|
|
420
|
+
record.external = { ...record.external ?? {}, credentialsRef };
|
|
421
|
+
}
|
|
422
|
+
await this.config.putDatasourceRecord(record);
|
|
423
|
+
await this.tryRegisterPool(record);
|
|
424
|
+
return this.toSummary(record);
|
|
425
|
+
}
|
|
426
|
+
async updateDatasource(name, patch, secret) {
|
|
427
|
+
const existing = await this.config.getDatasourceRecord(name);
|
|
428
|
+
if (!existing) throw new Error(`Datasource '${name}' not found.`);
|
|
429
|
+
if (existing.origin !== "runtime") {
|
|
430
|
+
throw new Error(`Datasource '${name}' is code-defined and cannot be edited at runtime.`);
|
|
431
|
+
}
|
|
432
|
+
const merged = {
|
|
433
|
+
...existing,
|
|
434
|
+
...patch.label !== void 0 ? { label: patch.label } : {},
|
|
435
|
+
...patch.driver !== void 0 ? { driver: patch.driver } : {},
|
|
436
|
+
...patch.schemaMode !== void 0 ? { schemaMode: patch.schemaMode } : {},
|
|
437
|
+
...patch.config !== void 0 ? { config: patch.config } : {},
|
|
438
|
+
...patch.pool !== void 0 ? { pool: patch.pool } : {},
|
|
439
|
+
...patch.active !== void 0 ? { active: patch.active } : {},
|
|
440
|
+
name: existing.name,
|
|
441
|
+
origin: "runtime"
|
|
442
|
+
};
|
|
443
|
+
if (patch.external !== void 0) {
|
|
444
|
+
merged.external = { ...patch.external, credentialsRef: existing.external?.credentialsRef };
|
|
445
|
+
}
|
|
446
|
+
if (secret) {
|
|
447
|
+
const prevRef = existing.external?.credentialsRef;
|
|
448
|
+
const credentialsRef = await this.config.writeSecret(secret, { name });
|
|
449
|
+
merged.external = { ...merged.external ?? {}, credentialsRef };
|
|
450
|
+
if (prevRef && prevRef !== credentialsRef) await this.tryRemoveSecret(prevRef);
|
|
451
|
+
}
|
|
452
|
+
await this.config.putDatasourceRecord(merged);
|
|
453
|
+
await this.tryRegisterPool(merged);
|
|
454
|
+
return this.toSummary(merged);
|
|
455
|
+
}
|
|
456
|
+
async removeDatasource(name) {
|
|
457
|
+
const existing = await this.config.getDatasourceRecord(name);
|
|
458
|
+
if (!existing) throw new Error(`Datasource '${name}' not found.`);
|
|
459
|
+
if (existing.origin !== "runtime") {
|
|
460
|
+
throw new Error(`Datasource '${name}' is code-defined and cannot be removed at runtime.`);
|
|
461
|
+
}
|
|
462
|
+
const bound = await this.config.countBoundObjects(name);
|
|
463
|
+
if (bound > 0) {
|
|
464
|
+
throw new Error(
|
|
465
|
+
`Cannot remove datasource '${name}': ${bound} object(s) are still bound to it.`
|
|
466
|
+
);
|
|
467
|
+
}
|
|
468
|
+
await this.config.deleteDatasourceRecord(name);
|
|
469
|
+
if (existing.external?.credentialsRef) await this.tryRemoveSecret(existing.external.credentialsRef);
|
|
470
|
+
await this.tryUnregisterPool(name);
|
|
471
|
+
}
|
|
472
|
+
// --- internals -----------------------------------------------------------
|
|
473
|
+
assertValidName(name) {
|
|
474
|
+
if (!name || !NAME_RE.test(name)) {
|
|
475
|
+
throw new Error(
|
|
476
|
+
`Invalid datasource name '${name ?? ""}': must match /^[a-z_][a-z0-9_]*$/.`
|
|
477
|
+
);
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
toRecord(input) {
|
|
481
|
+
return {
|
|
482
|
+
name: input.name,
|
|
483
|
+
...input.label !== void 0 ? { label: input.label } : {},
|
|
484
|
+
driver: input.driver,
|
|
485
|
+
...input.schemaMode !== void 0 ? { schemaMode: input.schemaMode } : {},
|
|
486
|
+
...input.config !== void 0 ? { config: input.config } : {},
|
|
487
|
+
...input.external !== void 0 ? { external: input.external } : {},
|
|
488
|
+
...input.pool !== void 0 ? { pool: input.pool } : {},
|
|
489
|
+
...input.active !== void 0 ? { active: input.active } : {}
|
|
490
|
+
};
|
|
491
|
+
}
|
|
492
|
+
toSummary(record) {
|
|
493
|
+
return {
|
|
494
|
+
name: record.name,
|
|
495
|
+
label: record.label,
|
|
496
|
+
driver: record.driver,
|
|
497
|
+
schemaMode: record.schemaMode ?? "managed",
|
|
498
|
+
origin: record.origin ?? "runtime",
|
|
499
|
+
active: record.active ?? true,
|
|
500
|
+
status: "unvalidated"
|
|
501
|
+
};
|
|
502
|
+
}
|
|
503
|
+
async tryRegisterPool(record) {
|
|
504
|
+
try {
|
|
505
|
+
await this.config.registerPool?.(record);
|
|
506
|
+
} catch (err) {
|
|
507
|
+
this.logger?.warn(`registerPool('${record.name}') failed`, err);
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
async tryUnregisterPool(name) {
|
|
511
|
+
try {
|
|
512
|
+
await this.config.unregisterPool?.(name);
|
|
513
|
+
} catch (err) {
|
|
514
|
+
this.logger?.warn(`unregisterPool('${name}') failed`, err);
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
async tryRemoveSecret(credentialsRef) {
|
|
518
|
+
try {
|
|
519
|
+
await this.config.removeSecret?.(credentialsRef);
|
|
520
|
+
} catch (err) {
|
|
521
|
+
this.logger?.warn(`removeSecret('${credentialsRef}') failed`, err);
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
};
|
|
525
|
+
|
|
526
|
+
// src/datasource-admin-plugin.ts
|
|
527
|
+
import { registerMetadataTypeActions } from "@objectstack/spec/kernel";
|
|
528
|
+
var DatasourceAdminServicePlugin = class {
|
|
529
|
+
constructor(options = {}) {
|
|
530
|
+
this.name = "com.objectstack.service-datasource-admin";
|
|
531
|
+
this.version = "1.0.0";
|
|
532
|
+
this.type = "standard";
|
|
533
|
+
this.dependencies = [];
|
|
534
|
+
this.options = options;
|
|
535
|
+
}
|
|
536
|
+
async init(ctx) {
|
|
537
|
+
const logger = this.options.logger;
|
|
538
|
+
registerMetadataTypeActions("datasource", [
|
|
539
|
+
{
|
|
540
|
+
name: "test_connection",
|
|
541
|
+
label: "Test connection",
|
|
542
|
+
icon: "plug-zap",
|
|
543
|
+
type: "api",
|
|
544
|
+
target: "/api/v1/datasources/${ctx.recordId}/test",
|
|
545
|
+
method: "POST",
|
|
546
|
+
variant: "secondary",
|
|
547
|
+
refreshAfter: false,
|
|
548
|
+
locations: ["record_header", "list_item"]
|
|
549
|
+
}
|
|
550
|
+
]);
|
|
551
|
+
const metadataOf = () => safeGetService2(ctx, "metadata");
|
|
552
|
+
const engineOf = () => safeGetService2(ctx, "data");
|
|
553
|
+
const factory = () => this.options.driverFactory ?? safeGetService2(ctx, "datasource-driver-factory");
|
|
554
|
+
const config = {
|
|
555
|
+
probe: (input) => this.probe(factory(), input),
|
|
556
|
+
listDatasourceRecords: async () => {
|
|
557
|
+
const rows = await metadataOf()?.list("datasource") ?? [];
|
|
558
|
+
return rows.map((r) => ({ ...r, origin: r.origin ?? "code" }));
|
|
559
|
+
},
|
|
560
|
+
getDatasourceRecord: async (name) => {
|
|
561
|
+
const row = await metadataOf()?.get("datasource", name);
|
|
562
|
+
return row ? { ...row, origin: row.origin ?? "code" } : void 0;
|
|
563
|
+
},
|
|
564
|
+
putDatasourceRecord: async (record) => {
|
|
565
|
+
const metadata = metadataOf();
|
|
566
|
+
if (!metadata?.register) {
|
|
567
|
+
throw new Error("Metadata service is unavailable; cannot persist datasource.");
|
|
568
|
+
}
|
|
569
|
+
await metadata.register("datasource", record.name, record);
|
|
570
|
+
},
|
|
571
|
+
deleteDatasourceRecord: async (name) => {
|
|
572
|
+
const metadata = metadataOf();
|
|
573
|
+
if (!metadata?.unregister) {
|
|
574
|
+
throw new Error("Metadata service is unavailable; cannot remove datasource.");
|
|
575
|
+
}
|
|
576
|
+
await metadata.unregister("datasource", name);
|
|
577
|
+
},
|
|
578
|
+
writeSecret: async (input, hint) => {
|
|
579
|
+
const binder = this.options.secrets;
|
|
580
|
+
if (!binder?.bind) {
|
|
581
|
+
throw new Error(
|
|
582
|
+
"No secret store configured: refusing to persist a datasource credential in cleartext. Wire a SecretBinder (CryptoProvider + sys_secret) into DatasourceAdminServicePlugin."
|
|
583
|
+
);
|
|
584
|
+
}
|
|
585
|
+
return binder.bind(input, hint);
|
|
586
|
+
},
|
|
587
|
+
removeSecret: async (ref) => {
|
|
588
|
+
await this.options.secrets?.unbind?.(ref);
|
|
589
|
+
},
|
|
590
|
+
countBoundObjects: async (datasource) => {
|
|
591
|
+
const metadata = metadataOf();
|
|
592
|
+
const objects = await metadata?.listObjects?.() ?? await metadata?.list("object") ?? [];
|
|
593
|
+
return objects.filter((o) => o?.datasource === datasource).length;
|
|
594
|
+
},
|
|
595
|
+
registerPool: async (record) => {
|
|
596
|
+
const f = factory();
|
|
597
|
+
const engine = engineOf();
|
|
598
|
+
if (!f || !engine?.registerDriver || !f.supports(record.driver)) return;
|
|
599
|
+
const credentialsRef = record.external?.credentialsRef;
|
|
600
|
+
const secret = credentialsRef ? await this.options.secrets?.resolve?.(credentialsRef) : void 0;
|
|
601
|
+
const handle = await f.create({ ...this.toSpec(record), ...secret ? { secret } : {} });
|
|
602
|
+
if (typeof handle?.connect === "function") await handle.connect();
|
|
603
|
+
const engineDriver = handle.driver ?? handle;
|
|
604
|
+
try {
|
|
605
|
+
engineDriver.name = record.name;
|
|
606
|
+
} catch {
|
|
607
|
+
}
|
|
608
|
+
engine.registerDriver(engineDriver);
|
|
609
|
+
engine.registerDatasourceDef?.({
|
|
610
|
+
name: record.name,
|
|
611
|
+
schemaMode: record.schemaMode,
|
|
612
|
+
external: record.external
|
|
613
|
+
});
|
|
614
|
+
},
|
|
615
|
+
unregisterPool: async (name) => {
|
|
616
|
+
const driver = engineOf()?.getDriverByName?.(name);
|
|
617
|
+
if (typeof driver?.disconnect === "function") await driver.disconnect();
|
|
618
|
+
},
|
|
619
|
+
logger
|
|
620
|
+
};
|
|
621
|
+
this.config = config;
|
|
622
|
+
this.service = new DatasourceAdminService(config);
|
|
623
|
+
ctx.registerService("datasource-admin", this.service);
|
|
624
|
+
}
|
|
625
|
+
async start(ctx) {
|
|
626
|
+
await this.rehydratePools();
|
|
627
|
+
if (this.service) await ctx.trigger("datasource-admin:ready", this.service);
|
|
628
|
+
}
|
|
629
|
+
/**
|
|
630
|
+
* Boot-time rehydration: list persisted runtime datasources and re-register
|
|
631
|
+
* each one's connection pool (driver build → connect → registerDriver),
|
|
632
|
+
* decrypting its `sys_secret` credential on the way via the configured
|
|
633
|
+
* `registerPool` (which resolves `credentialsRef`). Code-defined datasources
|
|
634
|
+
* are owned by the host stack's own boot path and skipped here. Entirely
|
|
635
|
+
* best-effort: a missing factory/engine, an unpersisted dev store (nothing
|
|
636
|
+
* to rehydrate), or a single failing pool never blocks boot.
|
|
637
|
+
*/
|
|
638
|
+
async rehydratePools() {
|
|
639
|
+
const cfg = this.config;
|
|
640
|
+
if (!cfg?.registerPool || !cfg.listDatasourceRecords) return;
|
|
641
|
+
let records;
|
|
642
|
+
try {
|
|
643
|
+
records = await cfg.listDatasourceRecords();
|
|
644
|
+
} catch (err) {
|
|
645
|
+
this.options.logger?.warn?.("datasource rehydrate: listing records failed", err);
|
|
646
|
+
return;
|
|
647
|
+
}
|
|
648
|
+
const runtime = records.filter((r) => r.origin === "runtime" && (r.active ?? true));
|
|
649
|
+
if (runtime.length === 0) return;
|
|
650
|
+
let registered = 0;
|
|
651
|
+
for (const record of runtime) {
|
|
652
|
+
try {
|
|
653
|
+
await cfg.registerPool(record);
|
|
654
|
+
registered++;
|
|
655
|
+
} catch (err) {
|
|
656
|
+
this.options.logger?.warn?.(`datasource rehydrate: pool '${record.name}' failed`, err);
|
|
657
|
+
}
|
|
658
|
+
}
|
|
659
|
+
this.options.logger?.info?.(
|
|
660
|
+
`Rehydrated ${registered}/${runtime.length} runtime datasource pool(s) on boot`
|
|
661
|
+
);
|
|
662
|
+
}
|
|
663
|
+
async destroy() {
|
|
664
|
+
this.service = void 0;
|
|
665
|
+
}
|
|
666
|
+
// --- internals -----------------------------------------------------------
|
|
667
|
+
toSpec(record) {
|
|
668
|
+
return {
|
|
669
|
+
name: record.name,
|
|
670
|
+
driver: record.driver,
|
|
671
|
+
config: record.config ?? {},
|
|
672
|
+
external: record.external,
|
|
673
|
+
pool: record.pool
|
|
674
|
+
};
|
|
675
|
+
}
|
|
676
|
+
/** Probe a connection via the driver factory: build → connect → ping → close. */
|
|
677
|
+
async probe(factory, input) {
|
|
678
|
+
if (!factory) {
|
|
679
|
+
return { ok: false, error: "No driver factory is registered to test connections." };
|
|
680
|
+
}
|
|
681
|
+
if (!factory.supports(input.driver)) {
|
|
682
|
+
return { ok: false, error: `No driver factory supports driver '${input.driver}'.` };
|
|
683
|
+
}
|
|
684
|
+
let driver;
|
|
685
|
+
try {
|
|
686
|
+
driver = await factory.create({
|
|
687
|
+
driver: input.driver,
|
|
688
|
+
config: input.config,
|
|
689
|
+
secret: input.secret,
|
|
690
|
+
external: input.external
|
|
691
|
+
});
|
|
692
|
+
} catch (err) {
|
|
693
|
+
return { ok: false, error: `Failed to build driver: ${errMsg(err)}` };
|
|
694
|
+
}
|
|
695
|
+
const startedAt = monotonicNow();
|
|
696
|
+
try {
|
|
697
|
+
if (typeof driver?.connect === "function") await driver.connect();
|
|
698
|
+
if (typeof driver?.ping === "function") await driver.ping();
|
|
699
|
+
else if (typeof driver?.checkHealth === "function") await driver.checkHealth();
|
|
700
|
+
else if (typeof driver?.introspectSchema === "function") await driver.introspectSchema();
|
|
701
|
+
const latencyMs = elapsedSince(startedAt);
|
|
702
|
+
let serverVersion;
|
|
703
|
+
try {
|
|
704
|
+
serverVersion = typeof driver?.serverVersion === "function" ? await driver.serverVersion() : void 0;
|
|
705
|
+
} catch {
|
|
706
|
+
}
|
|
707
|
+
return { ok: true, latencyMs, ...serverVersion ? { serverVersion } : {} };
|
|
708
|
+
} catch (err) {
|
|
709
|
+
return { ok: false, error: errMsg(err) };
|
|
710
|
+
} finally {
|
|
711
|
+
try {
|
|
712
|
+
if (typeof driver?.disconnect === "function") await driver.disconnect();
|
|
713
|
+
} catch {
|
|
714
|
+
}
|
|
715
|
+
}
|
|
716
|
+
}
|
|
717
|
+
};
|
|
718
|
+
function safeGetService2(ctx, name) {
|
|
719
|
+
try {
|
|
720
|
+
return ctx.getService(name);
|
|
721
|
+
} catch {
|
|
722
|
+
return void 0;
|
|
723
|
+
}
|
|
724
|
+
}
|
|
725
|
+
function errMsg(err) {
|
|
726
|
+
return err instanceof Error ? err.message : String(err);
|
|
727
|
+
}
|
|
728
|
+
function monotonicNow() {
|
|
729
|
+
const perf = globalThis.performance;
|
|
730
|
+
return typeof perf?.now === "function" ? perf.now() : 0;
|
|
731
|
+
}
|
|
732
|
+
function elapsedSince(startedAt) {
|
|
733
|
+
return Math.max(0, Math.round(monotonicNow() - startedAt));
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
// src/default-datasource-driver-factory.ts
|
|
737
|
+
var DRIVER_ID_ALIASES = {
|
|
738
|
+
postgres: "postgres",
|
|
739
|
+
postgresql: "postgres",
|
|
740
|
+
pg: "postgres",
|
|
741
|
+
sqlite: "sqlite",
|
|
742
|
+
sqlite3: "sqlite",
|
|
743
|
+
"better-sqlite3": "sqlite",
|
|
744
|
+
mongodb: "mongodb",
|
|
745
|
+
mongo: "mongodb",
|
|
746
|
+
memory: "memory",
|
|
747
|
+
inmemory: "memory",
|
|
748
|
+
"in-memory": "memory"
|
|
749
|
+
};
|
|
750
|
+
function resolveKind(driverId) {
|
|
751
|
+
return DRIVER_ID_ALIASES[String(driverId ?? "").toLowerCase()];
|
|
752
|
+
}
|
|
753
|
+
function toHandle(driver, serverVersion) {
|
|
754
|
+
return {
|
|
755
|
+
connect: typeof driver?.connect === "function" ? () => driver.connect() : void 0,
|
|
756
|
+
disconnect: typeof driver?.disconnect === "function" ? () => driver.disconnect() : void 0,
|
|
757
|
+
checkHealth: typeof driver?.checkHealth === "function" ? () => driver.checkHealth() : void 0,
|
|
758
|
+
ping: typeof driver?.checkHealth === "function" ? () => driver.checkHealth() : void 0,
|
|
759
|
+
...serverVersion ? { serverVersion } : {},
|
|
760
|
+
driver
|
|
761
|
+
};
|
|
762
|
+
}
|
|
763
|
+
function buildSqlConnection(spec, client) {
|
|
764
|
+
const cfg = spec.config ?? {};
|
|
765
|
+
if (client === "better-sqlite3") {
|
|
766
|
+
const filename = cfg.filename ?? cfg.file ?? cfg.database ?? ":memory:";
|
|
767
|
+
return { filename };
|
|
768
|
+
}
|
|
769
|
+
const url = cfg.url ?? cfg.connectionString;
|
|
770
|
+
if (url) {
|
|
771
|
+
return spec.secret ? { connectionString: url, password: spec.secret } : { connectionString: url };
|
|
772
|
+
}
|
|
773
|
+
return {
|
|
774
|
+
host: cfg.host,
|
|
775
|
+
port: cfg.port,
|
|
776
|
+
database: cfg.database,
|
|
777
|
+
user: cfg.user ?? cfg.username,
|
|
778
|
+
...spec.secret ? { password: spec.secret } : cfg.password ? { password: cfg.password } : {},
|
|
779
|
+
...cfg.ssl != null ? { ssl: cfg.ssl } : {}
|
|
780
|
+
};
|
|
781
|
+
}
|
|
782
|
+
function buildMongoUrl(spec) {
|
|
783
|
+
const cfg = spec.config ?? {};
|
|
784
|
+
const explicit = cfg.url ?? cfg.uri;
|
|
785
|
+
if (explicit) return explicit;
|
|
786
|
+
const host = cfg.host ?? "localhost";
|
|
787
|
+
const port = cfg.port ?? 27017;
|
|
788
|
+
const db = cfg.database ?? "";
|
|
789
|
+
const user = cfg.user ?? cfg.username;
|
|
790
|
+
const auth = user ? `${encodeURIComponent(user)}:${encodeURIComponent(spec.secret ?? "")}@` : "";
|
|
791
|
+
return `mongodb://${auth}${host}:${port}/${db}`;
|
|
792
|
+
}
|
|
793
|
+
function createDefaultDatasourceDriverFactory() {
|
|
794
|
+
return {
|
|
795
|
+
supports(driverId) {
|
|
796
|
+
return resolveKind(driverId) !== void 0;
|
|
797
|
+
},
|
|
798
|
+
async create(spec) {
|
|
799
|
+
const kind = resolveKind(spec.driver);
|
|
800
|
+
if (!kind) {
|
|
801
|
+
throw new Error(`Unsupported driver id '${spec.driver}'.`);
|
|
802
|
+
}
|
|
803
|
+
const schemaMode = spec.external?.schemaMode ?? spec.config?.schemaMode;
|
|
804
|
+
if (kind === "postgres") {
|
|
805
|
+
const { SqlDriver } = await import("@objectstack/driver-sql");
|
|
806
|
+
const driver = new SqlDriver({
|
|
807
|
+
client: "pg",
|
|
808
|
+
connection: buildSqlConnection(spec, "pg"),
|
|
809
|
+
pool: { min: 0, max: 5 },
|
|
810
|
+
...schemaMode ? { schemaMode } : {}
|
|
811
|
+
});
|
|
812
|
+
return toHandle(driver, () => sqlServerVersion(driver, "pg"));
|
|
813
|
+
}
|
|
814
|
+
if (kind === "sqlite") {
|
|
815
|
+
const { SqlDriver } = await import("@objectstack/driver-sql");
|
|
816
|
+
const driver = new SqlDriver({
|
|
817
|
+
client: "better-sqlite3",
|
|
818
|
+
connection: buildSqlConnection(spec, "better-sqlite3"),
|
|
819
|
+
useNullAsDefault: true,
|
|
820
|
+
...schemaMode ? { schemaMode } : {}
|
|
821
|
+
});
|
|
822
|
+
return toHandle(driver, () => sqlServerVersion(driver, "sqlite"));
|
|
823
|
+
}
|
|
824
|
+
if (kind === "mongodb") {
|
|
825
|
+
let MongoDBDriver;
|
|
826
|
+
try {
|
|
827
|
+
({ MongoDBDriver } = await import("@objectstack/driver-mongodb"));
|
|
828
|
+
} catch (err) {
|
|
829
|
+
throw new Error(
|
|
830
|
+
`mongodb driver requested but @objectstack/driver-mongodb is not installed (${err?.message ?? err}).`
|
|
831
|
+
);
|
|
832
|
+
}
|
|
833
|
+
const driver = new MongoDBDriver({ url: buildMongoUrl(spec) });
|
|
834
|
+
return toHandle(driver);
|
|
835
|
+
}
|
|
836
|
+
const { InMemoryDriver } = await import("@objectstack/driver-memory");
|
|
837
|
+
return toHandle(new InMemoryDriver());
|
|
838
|
+
}
|
|
839
|
+
};
|
|
840
|
+
}
|
|
841
|
+
async function sqlServerVersion(driver, client) {
|
|
842
|
+
if (typeof driver?.execute !== "function") return void 0;
|
|
843
|
+
try {
|
|
844
|
+
const sql = client === "pg" ? "SELECT version() AS v" : "SELECT sqlite_version() AS v";
|
|
845
|
+
const rows = await driver.execute(sql);
|
|
846
|
+
const first = Array.isArray(rows) ? rows[0] : Array.isArray(rows?.rows) ? rows.rows[0] : rows;
|
|
847
|
+
const v = first?.v ?? first?.version ?? first?.["sqlite_version()"];
|
|
848
|
+
return typeof v === "string" ? v : void 0;
|
|
849
|
+
} catch {
|
|
850
|
+
return void 0;
|
|
851
|
+
}
|
|
852
|
+
}
|
|
853
|
+
|
|
854
|
+
// src/datasource-secret-binder.ts
|
|
855
|
+
var REF_PREFIX = "sys_secret:";
|
|
856
|
+
function toCredentialsRef(handleId) {
|
|
857
|
+
return `${REF_PREFIX}${handleId}`;
|
|
858
|
+
}
|
|
859
|
+
function parseCredentialsRef(ref) {
|
|
860
|
+
return ref?.startsWith(REF_PREFIX) ? ref.slice(REF_PREFIX.length) : void 0;
|
|
861
|
+
}
|
|
862
|
+
function createDatasourceSecretBinder(deps) {
|
|
863
|
+
const { engine, cryptoProvider } = deps;
|
|
864
|
+
const defaultNamespace = deps.namespace ?? "datasource";
|
|
865
|
+
return {
|
|
866
|
+
async bind(input, hint) {
|
|
867
|
+
const namespace = input.namespace ?? defaultNamespace;
|
|
868
|
+
const key = input.key ?? hint.name;
|
|
869
|
+
const handle = await cryptoProvider.encrypt(input.value, { namespace, key });
|
|
870
|
+
await engine.insert("sys_secret", {
|
|
871
|
+
id: handle.id,
|
|
872
|
+
namespace,
|
|
873
|
+
key,
|
|
874
|
+
kms_key_id: handle.kmsKeyId,
|
|
875
|
+
alg: handle.alg,
|
|
876
|
+
version: handle.version,
|
|
877
|
+
ciphertext: handle.ciphertext
|
|
878
|
+
});
|
|
879
|
+
return toCredentialsRef(handle.id);
|
|
880
|
+
},
|
|
881
|
+
async unbind(credentialsRef) {
|
|
882
|
+
const id = parseCredentialsRef(credentialsRef);
|
|
883
|
+
if (!id) return;
|
|
884
|
+
await engine.delete("sys_secret", { where: { id } });
|
|
885
|
+
},
|
|
886
|
+
async resolve(credentialsRef) {
|
|
887
|
+
const id = parseCredentialsRef(credentialsRef);
|
|
888
|
+
if (!id || typeof engine.find !== "function") return void 0;
|
|
889
|
+
try {
|
|
890
|
+
const result = await engine.find("sys_secret", {
|
|
891
|
+
where: { id },
|
|
892
|
+
limit: 1,
|
|
893
|
+
// Secrets are scoped through their owning datasource artefact, so
|
|
894
|
+
// skip the tenant-audit warning (mirrors SettingsService's store).
|
|
895
|
+
bypassTenantAudit: true
|
|
896
|
+
});
|
|
897
|
+
const rows = (Array.isArray(result) ? result : result?.data) ?? [];
|
|
898
|
+
const row = rows[0];
|
|
899
|
+
if (!row?.ciphertext) return void 0;
|
|
900
|
+
return await cryptoProvider.decrypt(
|
|
901
|
+
{
|
|
902
|
+
id: row.id,
|
|
903
|
+
kmsKeyId: row.kms_key_id,
|
|
904
|
+
alg: row.alg,
|
|
905
|
+
version: row.version,
|
|
906
|
+
ciphertext: row.ciphertext
|
|
907
|
+
},
|
|
908
|
+
{ namespace: row.namespace, key: row.key }
|
|
909
|
+
);
|
|
910
|
+
} catch {
|
|
911
|
+
return void 0;
|
|
912
|
+
}
|
|
913
|
+
}
|
|
914
|
+
};
|
|
915
|
+
}
|
|
916
|
+
|
|
917
|
+
// src/admin-routes.ts
|
|
918
|
+
function registerDatasourceAdminRoutes(server, ctx, basePath = "/api/v1") {
|
|
919
|
+
const root = `${basePath}/datasources`;
|
|
920
|
+
const adminService = () => {
|
|
921
|
+
try {
|
|
922
|
+
return ctx.getService("datasource-admin");
|
|
923
|
+
} catch {
|
|
924
|
+
return void 0;
|
|
925
|
+
}
|
|
926
|
+
};
|
|
927
|
+
const unavailable = (res) => res.status(503).json({ error: "datasource_admin_unavailable" });
|
|
928
|
+
const badRequest = (res, err) => res.status(400).json({ error: "datasource_admin_error", message: err instanceof Error ? err.message : String(err) });
|
|
929
|
+
const splitSecret = (body) => {
|
|
930
|
+
const { secret, ...draft } = body ?? {};
|
|
931
|
+
const normalised = secret == null ? void 0 : typeof secret === "string" ? { value: secret } : secret;
|
|
932
|
+
return { draft, secret: normalised };
|
|
933
|
+
};
|
|
934
|
+
server.get(root, async (_req, res) => {
|
|
935
|
+
const svc = adminService();
|
|
936
|
+
if (!svc?.listDatasources) return unavailable(res);
|
|
937
|
+
const datasources = await svc.listDatasources();
|
|
938
|
+
res.json({ datasources });
|
|
939
|
+
});
|
|
940
|
+
server.post(`${root}/test`, async (req, res) => {
|
|
941
|
+
const svc = adminService();
|
|
942
|
+
if (!svc?.testConnection) return unavailable(res);
|
|
943
|
+
const { draft, secret } = splitSecret(req.body);
|
|
944
|
+
try {
|
|
945
|
+
const result = await svc.testConnection(draft, secret);
|
|
946
|
+
res.json({ result });
|
|
947
|
+
} catch (err) {
|
|
948
|
+
badRequest(res, err);
|
|
949
|
+
}
|
|
950
|
+
});
|
|
951
|
+
server.post(root, async (req, res) => {
|
|
952
|
+
const svc = adminService();
|
|
953
|
+
if (!svc?.createDatasource) return unavailable(res);
|
|
954
|
+
const { draft, secret } = splitSecret(req.body);
|
|
955
|
+
try {
|
|
956
|
+
const datasource = await svc.createDatasource(draft, secret);
|
|
957
|
+
res.status(201).json({ datasource });
|
|
958
|
+
} catch (err) {
|
|
959
|
+
badRequest(res, err);
|
|
960
|
+
}
|
|
961
|
+
});
|
|
962
|
+
server.patch(`${root}/:name`, async (req, res) => {
|
|
963
|
+
const svc = adminService();
|
|
964
|
+
if (!svc?.updateDatasource) return unavailable(res);
|
|
965
|
+
const { draft, secret } = splitSecret(req.body);
|
|
966
|
+
try {
|
|
967
|
+
const datasource = await svc.updateDatasource(req.params.name, draft, secret);
|
|
968
|
+
res.json({ datasource });
|
|
969
|
+
} catch (err) {
|
|
970
|
+
badRequest(res, err);
|
|
971
|
+
}
|
|
972
|
+
});
|
|
973
|
+
server.delete(`${root}/:name`, async (req, res) => {
|
|
974
|
+
const svc = adminService();
|
|
975
|
+
if (!svc?.removeDatasource) return unavailable(res);
|
|
976
|
+
try {
|
|
977
|
+
await svc.removeDatasource(req.params.name);
|
|
978
|
+
res.status(204).end();
|
|
979
|
+
} catch (err) {
|
|
980
|
+
badRequest(res, err);
|
|
981
|
+
}
|
|
982
|
+
});
|
|
983
|
+
}
|
|
984
|
+
export {
|
|
985
|
+
DatasourceAdminService,
|
|
986
|
+
DatasourceAdminServicePlugin,
|
|
987
|
+
ExternalDatasourceService,
|
|
988
|
+
ExternalDatasourceServicePlugin,
|
|
989
|
+
createDatasourceSecretBinder,
|
|
990
|
+
createDefaultDatasourceDriverFactory,
|
|
991
|
+
parseCredentialsRef,
|
|
992
|
+
registerDatasourceAdminRoutes,
|
|
993
|
+
toCredentialsRef
|
|
994
|
+
};
|
|
995
|
+
//# sourceMappingURL=index.js.map
|