@@ -23,8 +23,10 @@ import type {
2323 IntrospectedSchema ,
2424 IntrospectedTable ,
2525 IntrospectedColumn ,
26+ IDataDriver ,
2627} from '@objectstack/spec/contracts' ;
2728import type { SchemaDiffEntry } from '@objectstack/spec/shared' ;
29+ import { DEFAULT_DATASOURCE_NAME } from './datasource-connection-service.js' ;
2830import {
2931 suggestFieldTypeForSqlType ,
3032 isCompatible ,
@@ -77,6 +79,18 @@ export interface Logger {
7779export interface ExternalDatasourceServiceConfig {
7880 /** Introspect a datasource's live schema via its driver. */
7981 introspect : ( datasource : string ) => Promise < IntrospectedSchema > ;
82+ /**
83+ * [#24772] The live driver registered for `datasource`, resolved by
84+ * ADR-0062's default-driver rule (`registeredDriverName` in
85+ * `datasource-connection-service.ts`), or `undefined` when none is
86+ * registered. {@link ExternalDatasourceService.testConnection} reads it to
87+ * probe a datasource that `introspect` cannot answer for: the platform's
88+ * primary datasource, and a driver with no `introspectSchema`.
89+ *
90+ * Optional. A host that wires only `introspect` keeps Test as the
91+ * introspection round trip it always was.
92+ */
93+ liveDriver ?: ( datasource : string ) => Pick < IDataDriver , 'checkHealth' | 'introspectSchema' > | undefined ;
8094 /** Resolve a datasource definition by name. */
8195 getDatasource : ( name : string ) => Promise < DatasourceLike | undefined > ;
8296 /** Resolve one object definition by name. */
@@ -421,6 +435,43 @@ function importNameRefusedError(message: string): Error {
421435 return err ;
422436}
423437
438+ /**
439+ * [#24772] The refusal every federation door answers for the platform's
440+ * primary datasource: listing its remote tables, drafting or importing one,
441+ * refreshing its catalog.
442+ *
443+ * ADR-0015 §1 puts federation on datasources whose `schemaMode` is not
444+ * `managed`. The primary datasource is the platform's own managed storage:
445+ * every table in it was created from an object the platform already serves.
446+ * These doors used to answer the engine's "has no registered driver to
447+ * introspect" for it, which is false (the driver is live; ADR-0062 registers
448+ * it under its natural name), while the Datasources page reads it as
449+ * connected. The answer is still a refusal, with the same status and code
450+ * these doors served before; only the reason is now the true one.
451+ *
452+ * ⛔ Not "make introspection reach the default". Listing and importing the
453+ * primary database's tables would let an import federate a platform table, and
454+ * the engine's write gate exempts the default datasource, so nothing would
455+ * stand behind it.
456+ *
457+ * `code` is the one the calling door serves (`EXTERNAL_IMPORT_ERROR` for the
458+ * import, `EXTERNAL_DATASOURCE_ERROR` for the rest), so the declaration and
459+ * the served envelope agree, as for `importNameRefusedError` above.
460+ */
461+ function primaryDatasourceNotFederatedError (
462+ datasource : string ,
463+ code : 'EXTERNAL_DATASOURCE_ERROR' | 'EXTERNAL_IMPORT_ERROR' ,
464+ ) : Error {
465+ const err = new Error (
466+ `Datasource '${ datasource } ' is the platform's managed primary datasource, not a federation source: ` +
467+ `its tables are the platform's own objects, so there are no remote tables to list, draft or import ` +
468+ `from it. Sync objects from an external datasource instead.` ,
469+ ) as Error & { code ?: string ; status ?: number } ;
470+ err . code = code ;
471+ err . status = 400 ;
472+ return err ;
473+ }
474+
424475/** snake_case → Title Case label. */
425476function toLabel ( name : string ) : string {
426477 return name
@@ -454,12 +505,16 @@ export class ExternalDatasourceService implements IExternalDatasourceService {
454505 * whose schema the driver did not name is never let through a filter it
455506 * cannot be judged by. With no filter, every introspected table is listed.
456507 * See {@link unattributedSchemaFilterError} for why the in-tree SQL driver
457- * lands on the refusal.
508+ * lands on the refusal. The platform's primary datasource is refused
509+ * outright: see {@link primaryDatasourceNotFederatedError}.
458510 */
459511 async listRemoteTables (
460512 datasource : string ,
461513 opts ?: { schema ?: string } ,
462514 ) : Promise < RemoteTable [ ] > {
515+ if ( datasource === DEFAULT_DATASOURCE_NAME ) {
516+ throw primaryDatasourceNotFederatedError ( datasource , 'EXTERNAL_DATASOURCE_ERROR' ) ;
517+ }
463518 const [ schema , ds ] = await Promise . all ( [
464519 this . config . introspect ( datasource ) ,
465520 this . config . getDatasource ( datasource ) ,
@@ -504,12 +559,46 @@ export class ExternalDatasourceService implements IExternalDatasourceService {
504559 * structured result rather than throwing so the route can render ok/error
505560 * uniformly. This backs the `datasource` `test_connection` action
506561 * (`POST /datasources/:name/test`).
562+ *
563+ * [#24772] Two datasources the introspection round trip cannot test, which
564+ * answered `ok: false` while the Datasources page read them as connected.
565+ * With a `liveDriver` wired, both take the driver's own connectivity probe,
566+ * `checkHealth()`, instead:
567+ *
568+ * - the platform's PRIMARY datasource. Its driver is live under its natural
569+ * name (ADR-0062), and it is not a federation source, so its tables are not
570+ * introspected: that costs a round trip per table of the platform database
571+ * and answers nothing the connection question needs. No `tableCount`.
572+ * - a driver with no `introspectSchema` (memory, mongodb). The answer says
573+ * so in `introspection: 'unsupported'`, rather than calling a connected
574+ * datasource failed.
575+ *
576+ * Every other datasource is tested exactly as before. So is one with no live
577+ * driver: `introspect` names the absence itself.
507578 */
508579 async testConnection (
509580 datasource : string ,
510- ) : Promise < { ok : boolean ; latencyMs ?: number ; tableCount ?: number ; error ?: string } > {
581+ ) : Promise < {
582+ ok : boolean ;
583+ latencyMs ?: number ;
584+ tableCount ?: number ;
585+ introspection ?: 'unsupported' ;
586+ error ?: string ;
587+ } > {
511588 const started = Date . now ( ) ;
512589 try {
590+ const driver = this . config . liveDriver ?.( datasource ) ;
591+ const canIntrospect = typeof driver ?. introspectSchema === 'function' ;
592+ if ( driver && ( datasource === DEFAULT_DATASOURCE_NAME || ! canIntrospect ) ) {
593+ if ( ! ( await driver . checkHealth ( ) ) ) {
594+ return { ok : false , error : `Datasource '${ datasource } ' is registered, but its driver failed its health check.` } ;
595+ }
596+ return {
597+ ok : true ,
598+ latencyMs : Date . now ( ) - started ,
599+ ...( canIntrospect ? { } : { introspection : 'unsupported' as const } ) ,
600+ } ;
601+ }
513602 const schema = await this . config . introspect ( datasource ) ;
514603 return {
515604 ok : true ,
@@ -526,6 +615,9 @@ export class ExternalDatasourceService implements IExternalDatasourceService {
526615 remoteName : string ,
527616 opts : GenerateDraftOpts = { } ,
528617 ) : Promise < ObjectDraft > {
618+ if ( datasource === DEFAULT_DATASOURCE_NAME ) {
619+ throw primaryDatasourceNotFederatedError ( datasource , 'EXTERNAL_DATASOURCE_ERROR' ) ;
620+ }
529621 const schema = await this . config . introspect ( datasource ) ;
530622 const table = this . findTable ( schema , remoteName ) ;
531623 if ( ! table ) {
@@ -615,6 +707,11 @@ export class ExternalDatasourceService implements IExternalDatasourceService {
615707 remoteName : string ,
616708 opts : ImportObjectOpts = { } ,
617709 ) : Promise < ImportObjectResult > {
710+ // [#24772] First: no store configuration makes the primary datasource a
711+ // federation source, so its refusal is the true answer whatever else holds.
712+ if ( datasource === DEFAULT_DATASOURCE_NAME ) {
713+ throw primaryDatasourceNotFederatedError ( datasource , 'EXTERNAL_IMPORT_ERROR' ) ;
714+ }
618715 if ( ! this . config . persistObject ) {
619716 throw new Error (
620717 `importObject requires a writable metadata store, but none is wired ` +
@@ -667,6 +764,9 @@ export class ExternalDatasourceService implements IExternalDatasourceService {
667764 }
668765
669766 async refreshCatalog ( datasource : string ) : Promise < ExternalCatalog > {
767+ if ( datasource === DEFAULT_DATASOURCE_NAME ) {
768+ throw primaryDatasourceNotFederatedError ( datasource , 'EXTERNAL_DATASOURCE_ERROR' ) ;
769+ }
670770 const schema = await this . config . introspect ( datasource ) ;
671771 // Parse through the Zod schema so the persisted record is canonical
672772 // (defaults applied, shape validated) and matches the `external_catalog`
0 commit comments