@objectstack/service-datasource 9.11.0 → 10.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -1,3 +1,7 @@
1
+ import {
2
+ allowAllConnectPolicy
3
+ } from "./chunk-XLS4RP7B.js";
4
+
1
5
  // src/external-datasource-service.ts
2
6
  import {
3
7
  suggestFieldType,
@@ -47,6 +51,28 @@ var ExternalDatasourceService = class {
47
51
  }
48
52
  return tables;
49
53
  }
54
+ /**
55
+ * Probe a *saved* datasource by name with a live round-trip. Reuses the
56
+ * introspection path (driver connect + schema read) as a cheap connectivity
57
+ * check, so the secret is resolved through the same wired pool as the rest of
58
+ * the introspection surface — the caller never handles cleartext. Returns a
59
+ * structured result rather than throwing so the route can render ok/error
60
+ * uniformly. This backs the `datasource` `test_connection` action
61
+ * (`POST /datasources/:name/test`).
62
+ */
63
+ async testConnection(datasource) {
64
+ const started = Date.now();
65
+ try {
66
+ const schema = await this.config.introspect(datasource);
67
+ return {
68
+ ok: true,
69
+ latencyMs: Date.now() - started,
70
+ tableCount: Object.keys(schema.tables).length
71
+ };
72
+ } catch (err) {
73
+ return { ok: false, error: err instanceof Error ? err.message : String(err) };
74
+ }
75
+ }
50
76
  async generateObjectDraft(datasource, remoteName, opts = {}) {
51
77
  const schema = await this.config.introspect(datasource);
52
78
  const table = this.findTable(schema, remoteName);
@@ -346,6 +372,179 @@ function safeGetService(ctx, name) {
346
372
  }
347
373
  }
348
374
 
375
+ // src/datasource-connection-service.ts
376
+ function isDatasourceAddressed(ds, ctx) {
377
+ if (ds.schemaMode && ds.schemaMode !== "managed") return true;
378
+ if (ds.autoConnect === true) return true;
379
+ if (ctx.objects?.some((o) => o?.datasource === ds.name)) return true;
380
+ return false;
381
+ }
382
+ var DatasourceConnectionService = class {
383
+ constructor(cfg) {
384
+ this.cfg = cfg;
385
+ this.policy = cfg.policy ?? allowAllConnectPolicy;
386
+ this.logger = cfg.logger;
387
+ }
388
+ /**
389
+ * Auto-connect the declared (code-defined) datasources that pass the D2 gate.
390
+ * Called from `AppPlugin.start()` with the app bundle's datasources + objects.
391
+ * Each connected external datasource also has its bound objects' read metadata
392
+ * synced so they are immediately queryable with zero app code.
393
+ */
394
+ async connectDeclared(input) {
395
+ const objects = input.objects ?? [];
396
+ const results = [];
397
+ for (const ds of input.datasources) {
398
+ if (!ds?.name) continue;
399
+ if (ds.active === false) continue;
400
+ if (!isDatasourceAddressed(ds, { objects })) continue;
401
+ const bound = objects.filter((o) => o?.datasource === ds.name && typeof o?.name === "string").map((o) => o.name);
402
+ results.push(
403
+ await this.connect(ds, { objects: bound, context: { origin: ds.origin ?? "code", trigger: "declared-auto" } })
404
+ );
405
+ }
406
+ return results;
407
+ }
408
+ /**
409
+ * Build + connect + register a single datasource's live driver. The shared
410
+ * core used by both auto-connect and the runtime-admin pool registration.
411
+ *
412
+ * Failure policy (ADR-0062 D5): an `external` datasource with
413
+ * `validation.onMismatch: 'fail'` fails fast (re-throws, bricking boot as
414
+ * intended); everything else degrades with a warning so an optional replica's
415
+ * connectivity blip never bricks boot.
416
+ */
417
+ async connect(record, opts = {}) {
418
+ const name = record.name;
419
+ const engine = this.cfg.engine();
420
+ const factory = this.cfg.factory();
421
+ if (engine?.getDriverByName?.(name)) {
422
+ return { name, status: "already-registered" };
423
+ }
424
+ let decision;
425
+ try {
426
+ decision = await this.policy.canConnect(
427
+ { name, driver: record.driver, schemaMode: record.schemaMode, external: record.external },
428
+ opts.context
429
+ );
430
+ } catch (err) {
431
+ decision = { allow: false, reason: `connect policy threw: ${errMsg(err)}` };
432
+ }
433
+ if (!decision.allow) {
434
+ this.logger?.info?.(`datasource '${name}': connect denied by policy${decision.reason ? ` (${decision.reason})` : ""}`);
435
+ return { name, status: "skipped-policy", reason: decision.reason };
436
+ }
437
+ if (!factory || !engine?.registerDriver) {
438
+ this.logger?.debug?.(`datasource '${name}': no driver factory / engine \u2014 left metadata-only`);
439
+ return { name, status: "skipped-no-infra" };
440
+ }
441
+ if (!factory.supports(record.driver)) {
442
+ return this.handleFailure(
443
+ record,
444
+ "skipped-unsupported",
445
+ `no driver factory supports driver '${record.driver}'`,
446
+ opts.context
447
+ );
448
+ }
449
+ let secret;
450
+ const credentialsRef = record.external?.credentialsRef;
451
+ if (credentialsRef) {
452
+ const resolver = this.cfg.secrets?.resolve;
453
+ if (!resolver) {
454
+ return this.handleFailure(
455
+ record,
456
+ "failed-credentials",
457
+ `requires credential '${credentialsRef}' but no secret store (SecretBinder/ICryptoProvider) is configured`,
458
+ opts.context
459
+ );
460
+ }
461
+ try {
462
+ secret = await resolver(credentialsRef);
463
+ } catch (err) {
464
+ return this.handleFailure(record, "failed-credentials", `resolving credential '${credentialsRef}' threw: ${errMsg(err)}`, opts.context);
465
+ }
466
+ if (secret == null || secret === "") {
467
+ return this.handleFailure(
468
+ record,
469
+ "failed-credentials",
470
+ `credential '${credentialsRef}' could not be resolved or decrypted (missing sys_secret row, or the encryption key changed)`,
471
+ opts.context
472
+ );
473
+ }
474
+ }
475
+ try {
476
+ const handle = await factory.create({ ...toSpec(record), ...secret ? { secret } : {} });
477
+ if (typeof handle?.connect === "function") await handle.connect();
478
+ const engineDriver = handle.driver ?? handle;
479
+ try {
480
+ engineDriver.name = name;
481
+ } catch {
482
+ }
483
+ engine.registerDriver(engineDriver);
484
+ engine.registerDatasourceDef?.({
485
+ name,
486
+ schemaMode: record.schemaMode,
487
+ external: record.external
488
+ });
489
+ for (const objectName of opts.objects ?? []) {
490
+ try {
491
+ await engine.syncObjectSchema?.(objectName);
492
+ } catch (err) {
493
+ this.logger?.warn?.(`datasource '${name}': syncObjectSchema('${objectName}') failed: ${errMsg(err)}`);
494
+ }
495
+ }
496
+ this.logger?.info?.(`datasource '${name}': connected (driver=${record.driver}, schemaMode=${record.schemaMode ?? "managed"})`);
497
+ return { name, status: "connected" };
498
+ } catch (err) {
499
+ return this.handleFailure(record, "failed-degraded", errMsg(err), opts.context);
500
+ }
501
+ }
502
+ /** Gracefully disconnect a previously-registered datasource pool. */
503
+ async disconnect(name) {
504
+ const driver = this.cfg.engine()?.getDriverByName?.(name);
505
+ if (typeof driver?.disconnect === "function") {
506
+ try {
507
+ await driver.disconnect();
508
+ } catch (err) {
509
+ this.logger?.warn?.(`datasource '${name}': disconnect failed: ${errMsg(err)}`);
510
+ }
511
+ }
512
+ }
513
+ /**
514
+ * Apply the D5 connect-failure policy (also covers D3 credential failures). A
515
+ * code-defined `external` datasource with `onMismatch:'fail'` auto-connected at
516
+ * boot re-throws (fail-fast, bricking boot as intended). Runtime-admin
517
+ * create/update + boot rehydration always degrade-with-warning — a UI action
518
+ * or a replica blip must never brick the running server (preserves the
519
+ * pre-ADR-0062 admin behavior). Either way the datasource is left unconnected
520
+ * with a clear message — never a silent skip.
521
+ */
522
+ handleFailure(record, status, reason, context) {
523
+ const isExternal = record.schemaMode && record.schemaMode !== "managed";
524
+ const failFast = context?.trigger === "declared-auto" && isExternal && record.external?.validation?.onMismatch === "fail";
525
+ const msg = `datasource '${record.name}': connect failed \u2014 ${reason}`;
526
+ if (failFast) {
527
+ throw new Error(
528
+ `${msg}. (schemaMode=${record.schemaMode}, validation.onMismatch='fail' \u21D2 fail-fast per ADR-0062 D5)`
529
+ );
530
+ }
531
+ this.logger?.warn?.(`${msg} \u2014 degrading (datasource left unconnected)`);
532
+ return { name: record.name, status, reason };
533
+ }
534
+ };
535
+ function toSpec(record) {
536
+ return {
537
+ name: record.name,
538
+ driver: record.driver,
539
+ config: record.config ?? {},
540
+ external: record.external,
541
+ pool: record.pool
542
+ };
543
+ }
544
+ function errMsg(err) {
545
+ return err instanceof Error ? err.message : String(err);
546
+ }
547
+
349
548
  // src/datasource-admin-service.ts
350
549
  var NAME_RE = /^[a-z_][a-z0-9_]*$/;
351
550
  var DatasourceAdminService = class {
@@ -382,6 +581,29 @@ var DatasourceAdminService = class {
382
581
  }
383
582
  return summaries;
384
583
  }
584
+ /**
585
+ * Read one datasource's full detail for editing, with the credential stripped.
586
+ * Returns `config` (non-sensitive — credentials live in `sys_secret`, never in
587
+ * config), `origin`, and a `hasSecret` flag so the UI can show "leave blank to
588
+ * keep" without ever receiving the `credentialsRef` or any cleartext. Returns
589
+ * `undefined` when the name is unknown.
590
+ */
591
+ async getDatasource(name) {
592
+ const rec = await this.config.getDatasourceRecord(name);
593
+ if (!rec) return void 0;
594
+ const hasSecret = Boolean(rec.external?.credentialsRef);
595
+ return {
596
+ name: rec.name,
597
+ label: rec.label,
598
+ driver: rec.driver,
599
+ schemaMode: rec.schemaMode ?? "managed",
600
+ config: rec.config ?? {},
601
+ active: rec.active ?? true,
602
+ origin: rec.origin === "runtime" ? "runtime" : "code",
603
+ hasSecret,
604
+ ...rec.definedIn ? { definedIn: rec.definedIn } : {}
605
+ };
606
+ }
385
607
  async testConnection(input, secret) {
386
608
  if (!input?.driver) {
387
609
  return { ok: false, error: "A driver is required to test a connection." };
@@ -525,6 +747,57 @@ var DatasourceAdminService = class {
525
747
 
526
748
  // src/datasource-admin-plugin.ts
527
749
  import { registerMetadataTypeActions } from "@objectstack/spec/kernel";
750
+ var DS_META_TYPE = "datasource";
751
+ var SYS_METADATA = "sys_metadata";
752
+ function newMetaId() {
753
+ return typeof crypto !== "undefined" && typeof crypto.randomUUID === "function" ? crypto.randomUUID() : `meta_${Date.now()}_${Math.random().toString(36).slice(2)}`;
754
+ }
755
+ async function persistDatasourceRow(engine, record) {
756
+ if (!engine?.insert || !engine.findOne) return;
757
+ const now = (/* @__PURE__ */ new Date()).toISOString();
758
+ const existing = await engine.findOne(SYS_METADATA, {
759
+ where: { type: DS_META_TYPE, name: record.name, state: "active" }
760
+ });
761
+ if (existing) {
762
+ await engine.update?.(
763
+ SYS_METADATA,
764
+ { metadata: JSON.stringify(record), updated_at: now, version: (existing.version || 0) + 1, state: "active" },
765
+ { where: { id: existing.id } }
766
+ );
767
+ } else {
768
+ await engine.insert(SYS_METADATA, {
769
+ id: newMetaId(),
770
+ name: record.name,
771
+ type: DS_META_TYPE,
772
+ scope: "platform",
773
+ metadata: JSON.stringify(record),
774
+ state: "active",
775
+ version: 1,
776
+ created_at: now,
777
+ updated_at: now
778
+ });
779
+ }
780
+ }
781
+ async function deleteDatasourceRow(engine, name) {
782
+ if (!engine?.findOne) return;
783
+ const existing = await engine.findOne(SYS_METADATA, { where: { type: DS_META_TYPE, name, state: "active" } });
784
+ if (!existing) return;
785
+ if (engine.delete) await engine.delete(SYS_METADATA, { where: { id: existing.id } });
786
+ else await engine.update?.(SYS_METADATA, { state: "inactive" }, { where: { id: existing.id } });
787
+ }
788
+ async function loadDatasourceRows(engine) {
789
+ if (!engine?.find) return [];
790
+ const rows = await engine.find(SYS_METADATA, { where: { type: DS_META_TYPE, state: "active" } });
791
+ const out = [];
792
+ for (const r of rows ?? []) {
793
+ const raw = r.metadata;
794
+ try {
795
+ out.push(typeof raw === "string" ? JSON.parse(raw) : raw);
796
+ } catch {
797
+ }
798
+ }
799
+ return out;
800
+ }
528
801
  var DatasourceAdminServicePlugin = class {
529
802
  constructor(options = {}) {
530
803
  this.name = "com.objectstack.service-datasource-admin";
@@ -551,6 +824,14 @@ var DatasourceAdminServicePlugin = class {
551
824
  const metadataOf = () => safeGetService2(ctx, "metadata");
552
825
  const engineOf = () => safeGetService2(ctx, "data");
553
826
  const factory = () => this.options.driverFactory ?? safeGetService2(ctx, "datasource-driver-factory");
827
+ this.connection = new DatasourceConnectionService({
828
+ factory,
829
+ engine: () => engineOf(),
830
+ secrets: { resolve: (ref) => this.options.secrets?.resolve?.(ref) ?? Promise.resolve(void 0) },
831
+ policy: this.options.connectPolicy,
832
+ logger: this.options.logger
833
+ });
834
+ ctx.registerService("datasource-connection", this.connection);
554
835
  const config = {
555
836
  probe: (input) => this.probe(factory(), input),
556
837
  listDatasourceRecords: async () => {
@@ -567,6 +848,7 @@ var DatasourceAdminServicePlugin = class {
567
848
  throw new Error("Metadata service is unavailable; cannot persist datasource.");
568
849
  }
569
850
  await metadata.register("datasource", record.name, record);
851
+ await persistDatasourceRow(engineOf(), record);
570
852
  },
571
853
  deleteDatasourceRecord: async (name) => {
572
854
  const metadata = metadataOf();
@@ -574,6 +856,7 @@ var DatasourceAdminServicePlugin = class {
574
856
  throw new Error("Metadata service is unavailable; cannot remove datasource.");
575
857
  }
576
858
  await metadata.unregister("datasource", name);
859
+ await deleteDatasourceRow(engineOf(), name);
577
860
  },
578
861
  writeSecret: async (input, hint) => {
579
862
  const binder = this.options.secrets;
@@ -592,40 +875,90 @@ var DatasourceAdminServicePlugin = class {
592
875
  const objects = await metadata?.listObjects?.() ?? await metadata?.list("object") ?? [];
593
876
  return objects.filter((o) => o?.datasource === datasource).length;
594
877
  },
878
+ // Hot pool (de)registration converges on the shared
879
+ // DatasourceConnectionService (ADR-0062 D1) — one connect path for code-
880
+ // and runtime-origin datasources. `connect()` builds the driver via the
881
+ // factory, dereferences `external.credentialsRef` through the SecretBinder,
882
+ // opens the connection, and registers the live driver + datasource def.
883
+ // Runtime-admin connects always degrade-with-warning on failure (never
884
+ // fail-fast), preserving the pre-ADR-0062 admin behavior.
595
885
  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
886
+ await this.connection?.connect(record, {
887
+ context: { origin: record.origin ?? "runtime", trigger: "runtime-admin" }
613
888
  });
614
889
  },
615
890
  unregisterPool: async (name) => {
616
- const driver = engineOf()?.getDriverByName?.(name);
617
- if (typeof driver?.disconnect === "function") await driver.disconnect();
891
+ await this.connection?.disconnect(name);
618
892
  },
619
893
  logger
620
894
  };
621
895
  this.config = config;
622
896
  this.service = new DatasourceAdminService(config);
623
897
  ctx.registerService("datasource-admin", this.service);
898
+ try {
899
+ const manifest = ctx.getService("manifest");
900
+ if (manifest && typeof manifest.register === "function") {
901
+ manifest.register({
902
+ id: "com.objectstack.service-datasource.nav",
903
+ namespace: "sys",
904
+ version: this.version,
905
+ type: "plugin",
906
+ scope: "system",
907
+ name: "Datasource Navigation",
908
+ description: "Contributes the Datasources entry to the Setup app Integrations group.",
909
+ navigationContributions: [
910
+ {
911
+ app: "setup",
912
+ group: "group_integrations",
913
+ priority: 100,
914
+ items: [
915
+ {
916
+ id: "nav_datasources",
917
+ type: "url",
918
+ label: "Datasources",
919
+ url: "/apps/setup/component/metadata/resource?type=datasource",
920
+ icon: "database",
921
+ requiredPermissions: ["manage_platform_settings"]
922
+ }
923
+ ]
924
+ }
925
+ ]
926
+ });
927
+ }
928
+ } catch (err) {
929
+ this.options.logger?.warn?.("datasource nav contribution skipped", err);
930
+ }
624
931
  }
625
932
  async start(ctx) {
933
+ await this.restoreRuntimeDatasources(ctx);
626
934
  await this.rehydratePools();
627
935
  if (this.service) await ctx.trigger("datasource-admin:ready", this.service);
628
936
  }
937
+ /** Reload persisted runtime datasource rows (sys_metadata) into the registry. */
938
+ async restoreRuntimeDatasources(ctx) {
939
+ const engine = safeGetService2(ctx, "data");
940
+ const metadata = safeGetService2(ctx, "metadata");
941
+ if (!engine?.find || !metadata?.register) return;
942
+ let rows;
943
+ try {
944
+ rows = await loadDatasourceRows(engine);
945
+ } catch (err) {
946
+ this.options.logger?.warn?.("datasource restore: reading sys_metadata failed", err);
947
+ return;
948
+ }
949
+ let restored = 0;
950
+ for (const rec of rows) {
951
+ const name = rec.name;
952
+ if (!name) continue;
953
+ try {
954
+ await metadata.register("datasource", name, rec);
955
+ restored += 1;
956
+ } catch (err) {
957
+ this.options.logger?.warn?.(`datasource restore: register '${name}' failed`, err);
958
+ }
959
+ }
960
+ if (restored > 0) this.options.logger?.info?.(`datasource: restored ${restored} runtime record(s) from sys_metadata`);
961
+ }
629
962
  /**
630
963
  * Boot-time rehydration: list persisted runtime datasources and re-register
631
964
  * each one's connection pool (driver build → connect → registerDriver),
@@ -664,15 +997,6 @@ var DatasourceAdminServicePlugin = class {
664
997
  this.service = void 0;
665
998
  }
666
999
  // --- 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
1000
  /** Probe a connection via the driver factory: build → connect → ping → close. */
677
1001
  async probe(factory, input) {
678
1002
  if (!factory) {
@@ -690,7 +1014,7 @@ var DatasourceAdminServicePlugin = class {
690
1014
  external: input.external
691
1015
  });
692
1016
  } catch (err) {
693
- return { ok: false, error: `Failed to build driver: ${errMsg(err)}` };
1017
+ return { ok: false, error: `Failed to build driver: ${errMsg2(err)}` };
694
1018
  }
695
1019
  const startedAt = monotonicNow();
696
1020
  try {
@@ -706,7 +1030,7 @@ var DatasourceAdminServicePlugin = class {
706
1030
  }
707
1031
  return { ok: true, latencyMs, ...serverVersion ? { serverVersion } : {} };
708
1032
  } catch (err) {
709
- return { ok: false, error: errMsg(err) };
1033
+ return { ok: false, error: errMsg2(err) };
710
1034
  } finally {
711
1035
  try {
712
1036
  if (typeof driver?.disconnect === "function") await driver.disconnect();
@@ -722,7 +1046,7 @@ function safeGetService2(ctx, name) {
722
1046
  return void 0;
723
1047
  }
724
1048
  }
725
- function errMsg(err) {
1049
+ function errMsg2(err) {
726
1050
  return err instanceof Error ? err.message : String(err);
727
1051
  }
728
1052
  function monotonicNow() {
@@ -914,6 +1238,92 @@ function createDatasourceSecretBinder(deps) {
914
1238
  };
915
1239
  }
916
1240
 
1241
+ // src/driver-catalog.ts
1242
+ var SSL_PROP = {
1243
+ ssl: { type: "boolean", title: "Use SSL/TLS", default: false }
1244
+ };
1245
+ var DRIVER_CATALOG = [
1246
+ {
1247
+ id: "memory",
1248
+ label: "In-Memory",
1249
+ description: "Ephemeral in-memory driver for dev, tests, and prototyping. No connection settings.",
1250
+ icon: "memory-stick",
1251
+ configSchema: { type: "object", properties: {}, additionalProperties: false }
1252
+ },
1253
+ {
1254
+ id: "sqlite",
1255
+ label: "SQLite",
1256
+ description: "File-backed (or in-memory) SQL database. Great for local dev and small deployments.",
1257
+ icon: "database",
1258
+ configSchema: {
1259
+ type: "object",
1260
+ properties: {
1261
+ filename: {
1262
+ type: "string",
1263
+ title: "Filename",
1264
+ description: 'Database file path, or ":memory:" for an ephemeral in-memory database.',
1265
+ default: ":memory:"
1266
+ }
1267
+ },
1268
+ required: ["filename"],
1269
+ additionalProperties: false
1270
+ }
1271
+ },
1272
+ {
1273
+ id: "postgres",
1274
+ label: "PostgreSQL",
1275
+ description: "PostgreSQL connection. Supply host/port/database or a connection URL.",
1276
+ icon: "database",
1277
+ configSchema: {
1278
+ type: "object",
1279
+ properties: {
1280
+ url: { type: "string", title: "Connection URL", description: "postgres://user:pass@host:5432/db (overrides the fields below when set)." },
1281
+ host: { type: "string", title: "Host", default: "localhost" },
1282
+ port: { type: "number", title: "Port", default: 5432 },
1283
+ database: { type: "string", title: "Database" },
1284
+ username: { type: "string", title: "User" },
1285
+ password: { type: "string", title: "Password", format: "password" },
1286
+ schema: { type: "string", title: "Schema", default: "public" },
1287
+ ...SSL_PROP
1288
+ },
1289
+ additionalProperties: true
1290
+ }
1291
+ },
1292
+ {
1293
+ id: "mysql",
1294
+ label: "MySQL / MariaDB",
1295
+ description: "MySQL or MariaDB connection.",
1296
+ icon: "database",
1297
+ configSchema: {
1298
+ type: "object",
1299
+ properties: {
1300
+ host: { type: "string", title: "Host", default: "localhost" },
1301
+ port: { type: "number", title: "Port", default: 3306 },
1302
+ database: { type: "string", title: "Database" },
1303
+ username: { type: "string", title: "User" },
1304
+ password: { type: "string", title: "Password", format: "password" },
1305
+ ...SSL_PROP
1306
+ },
1307
+ additionalProperties: true
1308
+ }
1309
+ },
1310
+ {
1311
+ id: "mongo",
1312
+ label: "MongoDB",
1313
+ description: "MongoDB connection via a connection URI.",
1314
+ icon: "database",
1315
+ configSchema: {
1316
+ type: "object",
1317
+ properties: {
1318
+ url: { type: "string", title: "Connection URI", description: "mongodb://host:27017" },
1319
+ database: { type: "string", title: "Database" }
1320
+ },
1321
+ required: ["url"],
1322
+ additionalProperties: true
1323
+ }
1324
+ }
1325
+ ];
1326
+
917
1327
  // src/admin-routes.ts
918
1328
  function registerDatasourceAdminRoutes(server, ctx, basePath = "/api/v1") {
919
1329
  const root = `${basePath}/datasources`;
@@ -924,6 +1334,13 @@ function registerDatasourceAdminRoutes(server, ctx, basePath = "/api/v1") {
924
1334
  return void 0;
925
1335
  }
926
1336
  };
1337
+ const externalService = () => {
1338
+ try {
1339
+ return ctx.getService("external-datasource");
1340
+ } catch {
1341
+ return void 0;
1342
+ }
1343
+ };
927
1344
  const unavailable = (res) => res.status(503).json({ error: "datasource_admin_unavailable" });
928
1345
  const badRequest = (res, err) => res.status(400).json({ error: "datasource_admin_error", message: err instanceof Error ? err.message : String(err) });
929
1346
  const splitSecret = (body) => {
@@ -937,6 +1354,52 @@ function registerDatasourceAdminRoutes(server, ctx, basePath = "/api/v1") {
937
1354
  const datasources = await svc.listDatasources();
938
1355
  res.json({ datasources });
939
1356
  });
1357
+ server.get(`${root}/drivers`, async (_req, res) => {
1358
+ res.json({ drivers: DRIVER_CATALOG });
1359
+ });
1360
+ server.get(`${root}/:name/remote-tables`, async (req, res) => {
1361
+ const svc = externalService();
1362
+ if (!svc?.listRemoteTables) return unavailable(res);
1363
+ try {
1364
+ const tables = await svc.listRemoteTables(req.params.name);
1365
+ res.json({ tables });
1366
+ } catch (err) {
1367
+ badRequest(res, err);
1368
+ }
1369
+ });
1370
+ server.get(`${root}/:name`, async (req, res) => {
1371
+ const svc = adminService();
1372
+ if (!svc?.getDatasource) return unavailable(res);
1373
+ try {
1374
+ const datasource = await svc.getDatasource(req.params.name);
1375
+ if (!datasource) return res.status(404).json({ error: "not_found" });
1376
+ res.json({ datasource });
1377
+ } catch (err) {
1378
+ badRequest(res, err);
1379
+ }
1380
+ });
1381
+ server.post(`${root}/:name/test`, async (req, res) => {
1382
+ const svc = externalService();
1383
+ if (!svc?.testConnection) return unavailable(res);
1384
+ try {
1385
+ const result = await svc.testConnection(req.params.name);
1386
+ res.json(result);
1387
+ } catch (err) {
1388
+ badRequest(res, err);
1389
+ }
1390
+ });
1391
+ server.post(`${root}/:name/object-draft`, async (req, res) => {
1392
+ const svc = externalService();
1393
+ if (!svc?.generateObjectDraft) return unavailable(res);
1394
+ const { table, ...opts } = req.body ?? {};
1395
+ if (!table) return badRequest(res, new Error('Body field "table" is required.'));
1396
+ try {
1397
+ const draft = await svc.generateObjectDraft(req.params.name, String(table), opts);
1398
+ res.json({ draft });
1399
+ } catch (err) {
1400
+ badRequest(res, err);
1401
+ }
1402
+ });
940
1403
  server.post(`${root}/test`, async (req, res) => {
941
1404
  const svc = adminService();
942
1405
  if (!svc?.testConnection) return unavailable(res);
@@ -984,10 +1447,13 @@ function registerDatasourceAdminRoutes(server, ctx, basePath = "/api/v1") {
984
1447
  export {
985
1448
  DatasourceAdminService,
986
1449
  DatasourceAdminServicePlugin,
1450
+ DatasourceConnectionService,
987
1451
  ExternalDatasourceService,
988
1452
  ExternalDatasourceServicePlugin,
1453
+ allowAllConnectPolicy,
989
1454
  createDatasourceSecretBinder,
990
1455
  createDefaultDatasourceDriverFactory,
1456
+ isDatasourceAddressed,
991
1457
  parseCredentialsRef,
992
1458
  registerDatasourceAdminRoutes,
993
1459
  toCredentialsRef