@objectstack/service-datasource 9.10.0 → 10.0.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 +10 -10
- package/CHANGELOG.md +34 -0
- package/README.md +34 -0
- package/dist/index.cjs +359 -63
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +28 -0
- package/dist/index.d.ts +28 -0
- package/dist/index.js +296 -0
- package/dist/index.js.map +1 -1
- package/package.json +8 -8
- package/src/__tests__/admin-routes.test.ts +58 -0
- package/src/__tests__/datasource-admin-plugin.test.ts +59 -0
- package/src/__tests__/datasource-admin-service.test.ts +30 -0
- package/src/admin-routes.ts +75 -0
- package/src/datasource-admin-plugin.ts +152 -3
- package/src/datasource-admin-service.ts +30 -0
- package/src/driver-catalog.ts +113 -0
- package/src/external-datasource-service.ts +25 -0
package/dist/index.js
CHANGED
|
@@ -47,6 +47,28 @@ var ExternalDatasourceService = class {
|
|
|
47
47
|
}
|
|
48
48
|
return tables;
|
|
49
49
|
}
|
|
50
|
+
/**
|
|
51
|
+
* Probe a *saved* datasource by name with a live round-trip. Reuses the
|
|
52
|
+
* introspection path (driver connect + schema read) as a cheap connectivity
|
|
53
|
+
* check, so the secret is resolved through the same wired pool as the rest of
|
|
54
|
+
* the introspection surface — the caller never handles cleartext. Returns a
|
|
55
|
+
* structured result rather than throwing so the route can render ok/error
|
|
56
|
+
* uniformly. This backs the `datasource` `test_connection` action
|
|
57
|
+
* (`POST /datasources/:name/test`).
|
|
58
|
+
*/
|
|
59
|
+
async testConnection(datasource) {
|
|
60
|
+
const started = Date.now();
|
|
61
|
+
try {
|
|
62
|
+
const schema = await this.config.introspect(datasource);
|
|
63
|
+
return {
|
|
64
|
+
ok: true,
|
|
65
|
+
latencyMs: Date.now() - started,
|
|
66
|
+
tableCount: Object.keys(schema.tables).length
|
|
67
|
+
};
|
|
68
|
+
} catch (err) {
|
|
69
|
+
return { ok: false, error: err instanceof Error ? err.message : String(err) };
|
|
70
|
+
}
|
|
71
|
+
}
|
|
50
72
|
async generateObjectDraft(datasource, remoteName, opts = {}) {
|
|
51
73
|
const schema = await this.config.introspect(datasource);
|
|
52
74
|
const table = this.findTable(schema, remoteName);
|
|
@@ -382,6 +404,29 @@ var DatasourceAdminService = class {
|
|
|
382
404
|
}
|
|
383
405
|
return summaries;
|
|
384
406
|
}
|
|
407
|
+
/**
|
|
408
|
+
* Read one datasource's full detail for editing, with the credential stripped.
|
|
409
|
+
* Returns `config` (non-sensitive — credentials live in `sys_secret`, never in
|
|
410
|
+
* config), `origin`, and a `hasSecret` flag so the UI can show "leave blank to
|
|
411
|
+
* keep" without ever receiving the `credentialsRef` or any cleartext. Returns
|
|
412
|
+
* `undefined` when the name is unknown.
|
|
413
|
+
*/
|
|
414
|
+
async getDatasource(name) {
|
|
415
|
+
const rec = await this.config.getDatasourceRecord(name);
|
|
416
|
+
if (!rec) return void 0;
|
|
417
|
+
const hasSecret = Boolean(rec.external?.credentialsRef);
|
|
418
|
+
return {
|
|
419
|
+
name: rec.name,
|
|
420
|
+
label: rec.label,
|
|
421
|
+
driver: rec.driver,
|
|
422
|
+
schemaMode: rec.schemaMode ?? "managed",
|
|
423
|
+
config: rec.config ?? {},
|
|
424
|
+
active: rec.active ?? true,
|
|
425
|
+
origin: rec.origin === "runtime" ? "runtime" : "code",
|
|
426
|
+
hasSecret,
|
|
427
|
+
...rec.definedIn ? { definedIn: rec.definedIn } : {}
|
|
428
|
+
};
|
|
429
|
+
}
|
|
385
430
|
async testConnection(input, secret) {
|
|
386
431
|
if (!input?.driver) {
|
|
387
432
|
return { ok: false, error: "A driver is required to test a connection." };
|
|
@@ -525,6 +570,57 @@ var DatasourceAdminService = class {
|
|
|
525
570
|
|
|
526
571
|
// src/datasource-admin-plugin.ts
|
|
527
572
|
import { registerMetadataTypeActions } from "@objectstack/spec/kernel";
|
|
573
|
+
var DS_META_TYPE = "datasource";
|
|
574
|
+
var SYS_METADATA = "sys_metadata";
|
|
575
|
+
function newMetaId() {
|
|
576
|
+
return typeof crypto !== "undefined" && typeof crypto.randomUUID === "function" ? crypto.randomUUID() : `meta_${Date.now()}_${Math.random().toString(36).slice(2)}`;
|
|
577
|
+
}
|
|
578
|
+
async function persistDatasourceRow(engine, record) {
|
|
579
|
+
if (!engine?.insert || !engine.findOne) return;
|
|
580
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
581
|
+
const existing = await engine.findOne(SYS_METADATA, {
|
|
582
|
+
where: { type: DS_META_TYPE, name: record.name, state: "active" }
|
|
583
|
+
});
|
|
584
|
+
if (existing) {
|
|
585
|
+
await engine.update?.(
|
|
586
|
+
SYS_METADATA,
|
|
587
|
+
{ metadata: JSON.stringify(record), updated_at: now, version: (existing.version || 0) + 1, state: "active" },
|
|
588
|
+
{ where: { id: existing.id } }
|
|
589
|
+
);
|
|
590
|
+
} else {
|
|
591
|
+
await engine.insert(SYS_METADATA, {
|
|
592
|
+
id: newMetaId(),
|
|
593
|
+
name: record.name,
|
|
594
|
+
type: DS_META_TYPE,
|
|
595
|
+
scope: "platform",
|
|
596
|
+
metadata: JSON.stringify(record),
|
|
597
|
+
state: "active",
|
|
598
|
+
version: 1,
|
|
599
|
+
created_at: now,
|
|
600
|
+
updated_at: now
|
|
601
|
+
});
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
async function deleteDatasourceRow(engine, name) {
|
|
605
|
+
if (!engine?.findOne) return;
|
|
606
|
+
const existing = await engine.findOne(SYS_METADATA, { where: { type: DS_META_TYPE, name, state: "active" } });
|
|
607
|
+
if (!existing) return;
|
|
608
|
+
if (engine.delete) await engine.delete(SYS_METADATA, { where: { id: existing.id } });
|
|
609
|
+
else await engine.update?.(SYS_METADATA, { state: "inactive" }, { where: { id: existing.id } });
|
|
610
|
+
}
|
|
611
|
+
async function loadDatasourceRows(engine) {
|
|
612
|
+
if (!engine?.find) return [];
|
|
613
|
+
const rows = await engine.find(SYS_METADATA, { where: { type: DS_META_TYPE, state: "active" } });
|
|
614
|
+
const out = [];
|
|
615
|
+
for (const r of rows ?? []) {
|
|
616
|
+
const raw = r.metadata;
|
|
617
|
+
try {
|
|
618
|
+
out.push(typeof raw === "string" ? JSON.parse(raw) : raw);
|
|
619
|
+
} catch {
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
return out;
|
|
623
|
+
}
|
|
528
624
|
var DatasourceAdminServicePlugin = class {
|
|
529
625
|
constructor(options = {}) {
|
|
530
626
|
this.name = "com.objectstack.service-datasource-admin";
|
|
@@ -567,6 +663,7 @@ var DatasourceAdminServicePlugin = class {
|
|
|
567
663
|
throw new Error("Metadata service is unavailable; cannot persist datasource.");
|
|
568
664
|
}
|
|
569
665
|
await metadata.register("datasource", record.name, record);
|
|
666
|
+
await persistDatasourceRow(engineOf(), record);
|
|
570
667
|
},
|
|
571
668
|
deleteDatasourceRecord: async (name) => {
|
|
572
669
|
const metadata = metadataOf();
|
|
@@ -574,6 +671,7 @@ var DatasourceAdminServicePlugin = class {
|
|
|
574
671
|
throw new Error("Metadata service is unavailable; cannot remove datasource.");
|
|
575
672
|
}
|
|
576
673
|
await metadata.unregister("datasource", name);
|
|
674
|
+
await deleteDatasourceRow(engineOf(), name);
|
|
577
675
|
},
|
|
578
676
|
writeSecret: async (input, hint) => {
|
|
579
677
|
const binder = this.options.secrets;
|
|
@@ -621,11 +719,70 @@ var DatasourceAdminServicePlugin = class {
|
|
|
621
719
|
this.config = config;
|
|
622
720
|
this.service = new DatasourceAdminService(config);
|
|
623
721
|
ctx.registerService("datasource-admin", this.service);
|
|
722
|
+
try {
|
|
723
|
+
const manifest = ctx.getService("manifest");
|
|
724
|
+
if (manifest && typeof manifest.register === "function") {
|
|
725
|
+
manifest.register({
|
|
726
|
+
id: "com.objectstack.service-datasource.nav",
|
|
727
|
+
namespace: "sys",
|
|
728
|
+
version: this.version,
|
|
729
|
+
type: "plugin",
|
|
730
|
+
scope: "system",
|
|
731
|
+
name: "Datasource Navigation",
|
|
732
|
+
description: "Contributes the Datasources entry to the Setup app Integrations group.",
|
|
733
|
+
navigationContributions: [
|
|
734
|
+
{
|
|
735
|
+
app: "setup",
|
|
736
|
+
group: "group_integrations",
|
|
737
|
+
priority: 100,
|
|
738
|
+
items: [
|
|
739
|
+
{
|
|
740
|
+
id: "nav_datasources",
|
|
741
|
+
type: "url",
|
|
742
|
+
label: "Datasources",
|
|
743
|
+
url: "/apps/setup/component/metadata/resource?type=datasource",
|
|
744
|
+
icon: "database",
|
|
745
|
+
requiredPermissions: ["manage_platform_settings"]
|
|
746
|
+
}
|
|
747
|
+
]
|
|
748
|
+
}
|
|
749
|
+
]
|
|
750
|
+
});
|
|
751
|
+
}
|
|
752
|
+
} catch (err) {
|
|
753
|
+
this.options.logger?.warn?.("datasource nav contribution skipped", err);
|
|
754
|
+
}
|
|
624
755
|
}
|
|
625
756
|
async start(ctx) {
|
|
757
|
+
await this.restoreRuntimeDatasources(ctx);
|
|
626
758
|
await this.rehydratePools();
|
|
627
759
|
if (this.service) await ctx.trigger("datasource-admin:ready", this.service);
|
|
628
760
|
}
|
|
761
|
+
/** Reload persisted runtime datasource rows (sys_metadata) into the registry. */
|
|
762
|
+
async restoreRuntimeDatasources(ctx) {
|
|
763
|
+
const engine = safeGetService2(ctx, "data");
|
|
764
|
+
const metadata = safeGetService2(ctx, "metadata");
|
|
765
|
+
if (!engine?.find || !metadata?.register) return;
|
|
766
|
+
let rows;
|
|
767
|
+
try {
|
|
768
|
+
rows = await loadDatasourceRows(engine);
|
|
769
|
+
} catch (err) {
|
|
770
|
+
this.options.logger?.warn?.("datasource restore: reading sys_metadata failed", err);
|
|
771
|
+
return;
|
|
772
|
+
}
|
|
773
|
+
let restored = 0;
|
|
774
|
+
for (const rec of rows) {
|
|
775
|
+
const name = rec.name;
|
|
776
|
+
if (!name) continue;
|
|
777
|
+
try {
|
|
778
|
+
await metadata.register("datasource", name, rec);
|
|
779
|
+
restored += 1;
|
|
780
|
+
} catch (err) {
|
|
781
|
+
this.options.logger?.warn?.(`datasource restore: register '${name}' failed`, err);
|
|
782
|
+
}
|
|
783
|
+
}
|
|
784
|
+
if (restored > 0) this.options.logger?.info?.(`datasource: restored ${restored} runtime record(s) from sys_metadata`);
|
|
785
|
+
}
|
|
629
786
|
/**
|
|
630
787
|
* Boot-time rehydration: list persisted runtime datasources and re-register
|
|
631
788
|
* each one's connection pool (driver build → connect → registerDriver),
|
|
@@ -914,6 +1071,92 @@ function createDatasourceSecretBinder(deps) {
|
|
|
914
1071
|
};
|
|
915
1072
|
}
|
|
916
1073
|
|
|
1074
|
+
// src/driver-catalog.ts
|
|
1075
|
+
var SSL_PROP = {
|
|
1076
|
+
ssl: { type: "boolean", title: "Use SSL/TLS", default: false }
|
|
1077
|
+
};
|
|
1078
|
+
var DRIVER_CATALOG = [
|
|
1079
|
+
{
|
|
1080
|
+
id: "memory",
|
|
1081
|
+
label: "In-Memory",
|
|
1082
|
+
description: "Ephemeral in-memory driver for dev, tests, and prototyping. No connection settings.",
|
|
1083
|
+
icon: "memory-stick",
|
|
1084
|
+
configSchema: { type: "object", properties: {}, additionalProperties: false }
|
|
1085
|
+
},
|
|
1086
|
+
{
|
|
1087
|
+
id: "sqlite",
|
|
1088
|
+
label: "SQLite",
|
|
1089
|
+
description: "File-backed (or in-memory) SQL database. Great for local dev and small deployments.",
|
|
1090
|
+
icon: "database",
|
|
1091
|
+
configSchema: {
|
|
1092
|
+
type: "object",
|
|
1093
|
+
properties: {
|
|
1094
|
+
filename: {
|
|
1095
|
+
type: "string",
|
|
1096
|
+
title: "Filename",
|
|
1097
|
+
description: 'Database file path, or ":memory:" for an ephemeral in-memory database.',
|
|
1098
|
+
default: ":memory:"
|
|
1099
|
+
}
|
|
1100
|
+
},
|
|
1101
|
+
required: ["filename"],
|
|
1102
|
+
additionalProperties: false
|
|
1103
|
+
}
|
|
1104
|
+
},
|
|
1105
|
+
{
|
|
1106
|
+
id: "postgres",
|
|
1107
|
+
label: "PostgreSQL",
|
|
1108
|
+
description: "PostgreSQL connection. Supply host/port/database or a connection URL.",
|
|
1109
|
+
icon: "database",
|
|
1110
|
+
configSchema: {
|
|
1111
|
+
type: "object",
|
|
1112
|
+
properties: {
|
|
1113
|
+
url: { type: "string", title: "Connection URL", description: "postgres://user:pass@host:5432/db (overrides the fields below when set)." },
|
|
1114
|
+
host: { type: "string", title: "Host", default: "localhost" },
|
|
1115
|
+
port: { type: "number", title: "Port", default: 5432 },
|
|
1116
|
+
database: { type: "string", title: "Database" },
|
|
1117
|
+
username: { type: "string", title: "User" },
|
|
1118
|
+
password: { type: "string", title: "Password", format: "password" },
|
|
1119
|
+
schema: { type: "string", title: "Schema", default: "public" },
|
|
1120
|
+
...SSL_PROP
|
|
1121
|
+
},
|
|
1122
|
+
additionalProperties: true
|
|
1123
|
+
}
|
|
1124
|
+
},
|
|
1125
|
+
{
|
|
1126
|
+
id: "mysql",
|
|
1127
|
+
label: "MySQL / MariaDB",
|
|
1128
|
+
description: "MySQL or MariaDB connection.",
|
|
1129
|
+
icon: "database",
|
|
1130
|
+
configSchema: {
|
|
1131
|
+
type: "object",
|
|
1132
|
+
properties: {
|
|
1133
|
+
host: { type: "string", title: "Host", default: "localhost" },
|
|
1134
|
+
port: { type: "number", title: "Port", default: 3306 },
|
|
1135
|
+
database: { type: "string", title: "Database" },
|
|
1136
|
+
username: { type: "string", title: "User" },
|
|
1137
|
+
password: { type: "string", title: "Password", format: "password" },
|
|
1138
|
+
...SSL_PROP
|
|
1139
|
+
},
|
|
1140
|
+
additionalProperties: true
|
|
1141
|
+
}
|
|
1142
|
+
},
|
|
1143
|
+
{
|
|
1144
|
+
id: "mongo",
|
|
1145
|
+
label: "MongoDB",
|
|
1146
|
+
description: "MongoDB connection via a connection URI.",
|
|
1147
|
+
icon: "database",
|
|
1148
|
+
configSchema: {
|
|
1149
|
+
type: "object",
|
|
1150
|
+
properties: {
|
|
1151
|
+
url: { type: "string", title: "Connection URI", description: "mongodb://host:27017" },
|
|
1152
|
+
database: { type: "string", title: "Database" }
|
|
1153
|
+
},
|
|
1154
|
+
required: ["url"],
|
|
1155
|
+
additionalProperties: true
|
|
1156
|
+
}
|
|
1157
|
+
}
|
|
1158
|
+
];
|
|
1159
|
+
|
|
917
1160
|
// src/admin-routes.ts
|
|
918
1161
|
function registerDatasourceAdminRoutes(server, ctx, basePath = "/api/v1") {
|
|
919
1162
|
const root = `${basePath}/datasources`;
|
|
@@ -924,6 +1167,13 @@ function registerDatasourceAdminRoutes(server, ctx, basePath = "/api/v1") {
|
|
|
924
1167
|
return void 0;
|
|
925
1168
|
}
|
|
926
1169
|
};
|
|
1170
|
+
const externalService = () => {
|
|
1171
|
+
try {
|
|
1172
|
+
return ctx.getService("external-datasource");
|
|
1173
|
+
} catch {
|
|
1174
|
+
return void 0;
|
|
1175
|
+
}
|
|
1176
|
+
};
|
|
927
1177
|
const unavailable = (res) => res.status(503).json({ error: "datasource_admin_unavailable" });
|
|
928
1178
|
const badRequest = (res, err) => res.status(400).json({ error: "datasource_admin_error", message: err instanceof Error ? err.message : String(err) });
|
|
929
1179
|
const splitSecret = (body) => {
|
|
@@ -937,6 +1187,52 @@ function registerDatasourceAdminRoutes(server, ctx, basePath = "/api/v1") {
|
|
|
937
1187
|
const datasources = await svc.listDatasources();
|
|
938
1188
|
res.json({ datasources });
|
|
939
1189
|
});
|
|
1190
|
+
server.get(`${root}/drivers`, async (_req, res) => {
|
|
1191
|
+
res.json({ drivers: DRIVER_CATALOG });
|
|
1192
|
+
});
|
|
1193
|
+
server.get(`${root}/:name/remote-tables`, async (req, res) => {
|
|
1194
|
+
const svc = externalService();
|
|
1195
|
+
if (!svc?.listRemoteTables) return unavailable(res);
|
|
1196
|
+
try {
|
|
1197
|
+
const tables = await svc.listRemoteTables(req.params.name);
|
|
1198
|
+
res.json({ tables });
|
|
1199
|
+
} catch (err) {
|
|
1200
|
+
badRequest(res, err);
|
|
1201
|
+
}
|
|
1202
|
+
});
|
|
1203
|
+
server.get(`${root}/:name`, async (req, res) => {
|
|
1204
|
+
const svc = adminService();
|
|
1205
|
+
if (!svc?.getDatasource) return unavailable(res);
|
|
1206
|
+
try {
|
|
1207
|
+
const datasource = await svc.getDatasource(req.params.name);
|
|
1208
|
+
if (!datasource) return res.status(404).json({ error: "not_found" });
|
|
1209
|
+
res.json({ datasource });
|
|
1210
|
+
} catch (err) {
|
|
1211
|
+
badRequest(res, err);
|
|
1212
|
+
}
|
|
1213
|
+
});
|
|
1214
|
+
server.post(`${root}/:name/test`, async (req, res) => {
|
|
1215
|
+
const svc = externalService();
|
|
1216
|
+
if (!svc?.testConnection) return unavailable(res);
|
|
1217
|
+
try {
|
|
1218
|
+
const result = await svc.testConnection(req.params.name);
|
|
1219
|
+
res.json(result);
|
|
1220
|
+
} catch (err) {
|
|
1221
|
+
badRequest(res, err);
|
|
1222
|
+
}
|
|
1223
|
+
});
|
|
1224
|
+
server.post(`${root}/:name/object-draft`, async (req, res) => {
|
|
1225
|
+
const svc = externalService();
|
|
1226
|
+
if (!svc?.generateObjectDraft) return unavailable(res);
|
|
1227
|
+
const { table, ...opts } = req.body ?? {};
|
|
1228
|
+
if (!table) return badRequest(res, new Error('Body field "table" is required.'));
|
|
1229
|
+
try {
|
|
1230
|
+
const draft = await svc.generateObjectDraft(req.params.name, String(table), opts);
|
|
1231
|
+
res.json({ draft });
|
|
1232
|
+
} catch (err) {
|
|
1233
|
+
badRequest(res, err);
|
|
1234
|
+
}
|
|
1235
|
+
});
|
|
940
1236
|
server.post(`${root}/test`, async (req, res) => {
|
|
941
1237
|
const svc = adminService();
|
|
942
1238
|
if (!svc?.testConnection) return unavailable(res);
|